Skip to content

Commit dd64764

Browse files
committed
chore: move api related files to models/api
1 parent fe64679 commit dd64764

8 files changed

Lines changed: 175 additions & 50 deletions

File tree

bot_sdk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
CommandSpec,
99
InvalidArgumentsError,
1010
UnknownCommandError,
11+
OptionValidator,
12+
Validator,
1113
)
1214
from .runner import BotRunner
1315
from .logging import setup_logging
@@ -57,4 +59,6 @@
5759
"User",
5860
"GetUserGroupsRequest",
5961
"GetUserGroupsResponse",
62+
"Validator",
63+
"OptionValidator",
6064
]

bot_sdk/commands.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,74 @@ async def send_reply(self, message: Any, content: str) -> Any:
3131
...
3232

3333

34+
class Validator(Protocol):
35+
"""Callable validator that may coerce or reject a value."""
36+
37+
def __call__(self, value: Any) -> Any:
38+
...
39+
40+
def help_hint(self) -> Optional[str]: # optional, but recommended
41+
...
42+
43+
44+
class OptionValidator:
45+
"""Ensure a value is one of the allowed options (useful for enums).
46+
47+
By default the match is case-sensitive. Set ``case_insensitive`` to allow
48+
case-insensitive string comparison while still returning the original
49+
value.
50+
"""
51+
52+
def __init__(self, options: Iterable[Any], *, case_insensitive: bool = False) -> None:
53+
opts = list(options)
54+
if not opts:
55+
raise ValueError("options must not be empty")
56+
self.options = opts
57+
self.case_insensitive = case_insensitive
58+
if case_insensitive:
59+
self._normalized_map = {self._normalize(opt): opt for opt in opts}
60+
else:
61+
self._allowed = set(opts)
62+
63+
def __call__(self, value: Any) -> Any:
64+
if self.case_insensitive and isinstance(value, str):
65+
normalized = self._normalize(value)
66+
if normalized in self._normalized_map:
67+
return self._normalized_map[normalized]
68+
self._raise_error(value)
69+
70+
if not self.case_insensitive:
71+
if value in self._allowed:
72+
return value
73+
self._raise_error(value)
74+
75+
# If case-insensitive but the value is not a string, fall back to direct membership.
76+
if value in self.options:
77+
return value
78+
self._raise_error(value)
79+
80+
def _raise_error(self, value: Any) -> None:
81+
choices = ", ".join(str(opt) for opt in self.options)
82+
raise ValueError(f"Value must be one of: {choices} (got {value})")
83+
84+
@staticmethod
85+
def _normalize(value: Any) -> str:
86+
return str(value).lower()
87+
88+
def help_hint(self) -> str:
89+
choices = ", ".join(str(opt) for opt in self.options)
90+
suffix = " (case-insensitive)" if self.case_insensitive else ""
91+
return f"options: {choices}{suffix}"
92+
93+
3494
@dataclass
3595
class CommandArgument:
3696
name: str
3797
type: type = str
3898
required: bool = True
3999
description: str = ""
40100
multiple: bool = False # capture the remainder of args
101+
validator: Optional[Validator] = None
41102

42103

43104
@dataclass
@@ -190,9 +251,17 @@ def _parse_args(self, args_tokens: List[str], spec: CommandSpec) -> Dict[str, An
190251
def _convert_value(self, value: str, arg_spec: CommandArgument) -> ArgType:
191252
target = arg_spec.type
192253
try:
254+
converted: ArgType
193255
if target is bool:
194-
return self._to_bool(value)
195-
return target(value)
256+
converted = self._to_bool(value)
257+
else:
258+
converted = target(value)
259+
260+
if arg_spec.validator is not None:
261+
return arg_spec.validator(converted)
262+
return converted
263+
except InvalidArgumentsError:
264+
raise
196265
except Exception as exc: # pragma: no cover - simple conversion guard
197266
raise InvalidArgumentsError(arg_spec.name, f"Invalid value for {arg_spec.name}: {value}") from exc
198267

@@ -263,11 +332,27 @@ def _format_spec_detail(self, spec: CommandSpec) -> str:
263332
requirement = "required" if arg.required else "optional"
264333
multi = " (multiple)" if arg.multiple else ""
265334
desc = f" - {arg.name}: {requirement}{multi}"
335+
validator_hint = self._format_validator_hint(arg.validator)
266336
if arg.description:
267337
desc += f" — {arg.description}"
338+
if validator_hint:
339+
desc += f" [{validator_hint}]"
268340
lines.append(desc)
269341
return "\n".join(lines)
270342

343+
@staticmethod
344+
def _format_validator_hint(validator: Optional[Validator]) -> str:
345+
if validator is None:
346+
return ""
347+
hint_fn = getattr(validator, "help_hint", None)
348+
if callable(hint_fn):
349+
try:
350+
hint = hint_fn()
351+
return str(hint) if hint else ""
352+
except Exception: # pragma: no cover - help rendering should not break help
353+
return ""
354+
return f"validated by {validator.__class__.__name__}"
355+
271356

272357
__all__ = [
273358
"CommandParser",
@@ -277,4 +362,6 @@ def _format_spec_detail(self, spec: CommandSpec) -> str:
277362
"CommandError",
278363
"UnknownCommandError",
279364
"InvalidArgumentsError",
365+
"Validator",
366+
"OptionValidator",
280367
]

bot_sdk/models/__init__.py

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,12 @@
1-
from .request import (
2-
StreamMessageRequest,
3-
PrivateMessageRequest,
4-
UpdatePresenceRequest,
5-
GetUserGroupsRequest,
6-
)
7-
from .response import (
8-
RegisterResponse,
9-
EventsResponse,
10-
SendMessageResponse,
11-
UserProfileResponse,
12-
SubscriptionsResponse,
13-
ChannelResponse,
14-
GetUserGroupsResponse,
15-
)
16-
from .types import (
17-
Message,
18-
Event,
19-
PrivateRecipient,
20-
ProfileFieldValue,
21-
User,
22-
Channel,
23-
UserGroup,
24-
GroupSettingValue,
25-
)
1+
"""Model package.
262
27-
__all__ = [
28-
"StreamMessageRequest",
29-
"PrivateMessageRequest",
30-
"RegisterResponse",
31-
"EventsResponse",
32-
"SendMessageResponse",
33-
"UserProfileResponse",
34-
"ProfileFieldValue",
35-
"User",
36-
"SubscriptionsResponse",
37-
"ChannelResponse",
38-
"Channel",
39-
"Message",
40-
"Event",
41-
"PrivateRecipient",
42-
"UpdatePresenceRequest",
43-
"GetUserGroupsRequest",
44-
"GetUserGroupsResponse",
45-
"UserGroup",
46-
"GroupSettingValue",
47-
]
3+
api/ contains Zulip API-facing models.
4+
data/ holds SDK-side generic data models; bot-specific models live in bot dirs.
5+
"""
6+
7+
from .api import * # noqa: F401,F403
8+
from .data import * # noqa: F401,F403
9+
from .api import __all__ as _api_all
10+
from .data import __all__ as _data_all
11+
12+
__all__ = list(_api_all) + list(_data_all)

bot_sdk/models/api/__init__.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from .request import (
2+
StreamMessageRequest,
3+
PrivateMessageRequest,
4+
UpdatePresenceRequest,
5+
GetUserGroupsRequest,
6+
)
7+
from .response import (
8+
RegisterResponse,
9+
EventsResponse,
10+
SendMessageResponse,
11+
UserProfileResponse,
12+
SubscriptionsResponse,
13+
ChannelResponse,
14+
GetUserGroupsResponse,
15+
)
16+
from .types import (
17+
Message,
18+
Event,
19+
PrivateRecipient,
20+
ProfileFieldValue,
21+
User,
22+
Channel,
23+
UserGroup,
24+
GroupSettingValue,
25+
)
26+
27+
__all__ = [
28+
"StreamMessageRequest",
29+
"PrivateMessageRequest",
30+
"RegisterResponse",
31+
"EventsResponse",
32+
"SendMessageResponse",
33+
"UserProfileResponse",
34+
"ProfileFieldValue",
35+
"User",
36+
"SubscriptionsResponse",
37+
"ChannelResponse",
38+
"Channel",
39+
"Message",
40+
"Event",
41+
"PrivateRecipient",
42+
"UpdatePresenceRequest",
43+
"GetUserGroupsRequest",
44+
"GetUserGroupsResponse",
45+
"UserGroup",
46+
"GroupSettingValue",
47+
]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ class GetUserGroupsRequest(BaseModel):
3939
model_config = ConfigDict(extra="allow")
4040

4141

42-
__all__ = ["StreamMessageRequest", "PrivateMessageRequest", "UpdatePresenceRequest", "GetUserGroupsRequest"]
42+
__all__ = ["StreamMessageRequest", "PrivateMessageRequest", "UpdatePresenceRequest", "GetUserGroupsRequest"]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,4 @@ class UserGroup(BaseModel):
122122
model_config = ConfigDict(extra="allow")
123123

124124

125-
__all__ = ["Message", "Event", "PrivateRecipient", "ProfileFieldValue", "User", "Channel", "GroupSettingValue", "UserGroup"]
125+
__all__ = ["Message", "Event", "PrivateRecipient", "ProfileFieldValue", "User", "Channel", "GroupSettingValue", "UserGroup"]

bot_sdk/models/data/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
from pydantic import BaseModel, ConfigDict
5+
6+
7+
class DataModel(BaseModel):
8+
"""Base Pydantic model for SDK-side data structures.
9+
10+
- Disallows unknown fields by default
11+
- Ready to be extended by bot developers for their own data schemas
12+
"""
13+
14+
model_config = ConfigDict(extra="forbid")
15+
16+
17+
class Timestamped(DataModel):
18+
created_at: datetime | None = None
19+
updated_at: datetime | None = None
20+
21+
22+
__all__ = ["DataModel", "Timestamped"]

0 commit comments

Comments
 (0)