Added pydocstyle (D) rules to Ruff and fixed all violations.
CI / Formatting (push) Successful in 12s
CI / Linting (push) Successful in 13s
CI / Tests (Python 3.12) (push) Successful in 26s
CI / Tests (Python 3.13) (push) Successful in 25s
CI / Tests (Python 3.14) (push) Successful in 25s
CI / Type Checking (push) Successful in 26s

This commit is contained in:
2026-02-19 11:47:47 -05:00
parent 33dd49e20a
commit ca4adbcebf
30 changed files with 309 additions and 591 deletions
+1 -2
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Owlbot - A modular chat bot for Owncast.
Owlbot - A modular chat bot for Owncast.
The public API for building modules is in the `owlbot.api` subpackage: The public API for building modules is in the `owlbot.api` subpackage:
+1 -2
View File
@@ -83,8 +83,7 @@ def on_command(
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int | float = 0,
) -> Callable[[CommandHandler], CommandHandler]: ) -> Callable[[CommandHandler], CommandHandler]:
""" """Register a command handler.
Decorator to register a command handler.
:param name: Primary command name (case-insensitive). :param name: Primary command name (case-insensitive).
:param aliases: Optional list of alternative names. :param aliases: Optional list of alternative names.
+19 -35
View File
@@ -36,8 +36,7 @@ class Config:
config_path: str | Path = "config.yaml", config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None, overrides: dict[str, Any] | None = None,
): ):
""" """Initialize the configuration manager.
Initialize the configuration manager.
:param config_path: Path to the YAML config file. :param config_path: Path to the YAML config file.
:param overrides: CLI overrides (keys match property names). :param overrides: CLI overrides (keys match property names).
@@ -91,9 +90,9 @@ class Config:
default: Any = _UNSET, default: Any = _UNSET,
type_fn: type = str, type_fn: type = str,
) -> Any: ) -> Any:
""" """Resolve a setting through the priority chain.
Resolve a setting through the priority chain:
CLI arg > env var > config file > default. Priority order: CLI arg > env var > config file > default.
When *default* is a ``str``, the resolved value is coerced to ``str`` When *default* is a ``str``, the resolved value is coerced to ``str``
(the implicit *type_fn*). For non-string types, pass both *default* (the implicit *type_fn*). For non-string types, pass both *default*
@@ -123,8 +122,7 @@ class Config:
@property @property
def webhook_secret(self) -> str: def webhook_secret(self) -> str:
""" """Secret string for the webhook URL path.
Secret string for the webhook URL path.
The webhook endpoint is always /webhook/<secret>. A cryptographically The webhook endpoint is always /webhook/<secret>. A cryptographically
secure value is generated on first run if not explicitly configured. secure value is generated on first run if not explicitly configured.
@@ -139,8 +137,7 @@ class Config:
@property @property
def webhook_path(self) -> str: def webhook_path(self) -> str:
""" """URL path where Owncast sends webhooks.
URL path where Owncast sends webhooks.
Always returns /webhook/<secret>. The secret is auto-generated Always returns /webhook/<secret>. The secret is auto-generated
on first run if not configured. on first run if not configured.
@@ -207,8 +204,7 @@ class Config:
@property @property
def public_base_url(self) -> str: def public_base_url(self) -> str:
""" """Public base URL for Owlbot's web server.
Public base URL for Owlbot's web server.
Used to construct URLs for module routes and the webhook endpoint. Used to construct URLs for module routes and the webhook endpoint.
Falls back to ``owncast.url`` if not explicitly set. Falls back to ``owncast.url`` if not explicitly set.
@@ -224,8 +220,7 @@ class Config:
@property @property
def storage_dir(self) -> Path: def storage_dir(self) -> Path:
""" """Directory for module database files.
Directory for module database files.
Each module gets its own database file named '<module_name>.db'. Each module gets its own database file named '<module_name>.db'.
Defaults to 'data/' in the working directory. Defaults to 'data/' in the working directory.
@@ -242,8 +237,7 @@ class Config:
@property @property
def modules_dir(self) -> Path: def modules_dir(self) -> Path:
""" """Directory containing user modules.
Directory containing user modules.
Built-in modules are loaded from the package regardless of this setting. Built-in modules are loaded from the package regardless of this setting.
Defaults to 'modules/' in the working directory. Defaults to 'modules/' in the working directory.
@@ -260,8 +254,7 @@ class Config:
@property @property
def log_dir(self) -> Path | None: def log_dir(self) -> Path | None:
""" """Directory for the log file.
Directory for the log file.
When set, an ``owlbot.log`` file is written to this directory in When set, an ``owlbot.log`` file is written to this directory in
addition to stdout. Returns ``None`` when unset (stdout only). addition to stdout. Returns ``None`` when unset (stdout only).
@@ -405,8 +398,7 @@ class Config:
logger.info(f"Configuration saved to: {self.config_path.absolute()}") logger.info(f"Configuration saved to: {self.config_path.absolute()}")
def is_module_enabled(self, module_name: str) -> bool: def is_module_enabled(self, module_name: str) -> bool:
""" """Check if a module is enabled.
Check if a module is enabled.
Modules are enabled by default unless explicitly disabled with Modules are enabled by default unless explicitly disabled with
``modules.<name>.enabled: false`` in the config file. ``modules.<name>.enabled: false`` in the config file.
@@ -424,8 +416,7 @@ class Config:
return True return True
def get_module_config(self, module_name: str) -> dict[str, Any]: def get_module_config(self, module_name: str) -> dict[str, Any]:
""" """Get the configuration dict for a module.
Get the configuration dict for a module.
Returns merged defaults and config file values, with config file Returns merged defaults and config file values, with config file
values taking precedence. values taking precedence.
@@ -443,8 +434,7 @@ class Config:
return {**defaults, **config} return {**defaults, **config}
def set_module_config(self, module_name: str, config: dict[str, Any]) -> None: def set_module_config(self, module_name: str, config: dict[str, Any]) -> None:
""" """Update the configuration for a module at runtime and persist to disk.
Update the configuration for a module at runtime and persist to disk.
:param module_name: The module name. :param module_name: The module name.
:param config: Dict of configuration values to set. :param config: Dict of configuration values to set.
@@ -459,8 +449,7 @@ class Config:
def register_module_defaults( def register_module_defaults(
self, module_name: str, defaults: dict[str, Any] self, module_name: str, defaults: dict[str, Any]
) -> None: ) -> None:
""" """Register default configuration values for a module.
Register default configuration values for a module.
Called by modules during setup to declare their expected config keys Called by modules during setup to declare their expected config keys
and default values. Missing keys are backfilled into the config file and default values. Missing keys are backfilled into the config file
@@ -516,8 +505,7 @@ class ModuleConfig:
"""Pre-scoped configuration for a specific module.""" """Pre-scoped configuration for a specific module."""
def __init__(self, config: Config, module_name: str): def __init__(self, config: Config, module_name: str):
""" """Initialize a module-scoped configuration.
Initialize a module-scoped configuration.
:param config: The parent Config object. :param config: The parent Config object.
:param module_name: The name of the module this config :param module_name: The name of the module this config
@@ -537,8 +525,7 @@ class ModuleConfig:
return self._config.public_base_url return self._config.public_base_url
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
""" """Get a config value by key.
Get a config value by key.
:param key: The configuration key. :param key: The configuration key.
:param default: Value to return if key is not found. :param default: Value to return if key is not found.
@@ -547,16 +534,14 @@ class ModuleConfig:
return self.as_dict().get(key, default) return self.as_dict().get(key, default)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
""" """Get the full config dict for this module.
Get the full config dict for this module.
:return: Dict of all configuration values. :return: Dict of all configuration values.
""" """
return self._config.get_module_config(self._module_name) return self._config.get_module_config(self._module_name)
def set(self, key: str, value: Any) -> None: def set(self, key: str, value: Any) -> None:
""" """Set a config value at runtime and persist to disk.
Set a config value at runtime and persist to disk.
:param key: The configuration key. :param key: The configuration key.
:param value: The value to set. :param value: The value to set.
@@ -566,8 +551,7 @@ class ModuleConfig:
self._config.set_module_config(self._module_name, current) self._config.set_module_config(self._module_name, current)
def register_defaults(self, defaults: dict[str, Any]) -> None: def register_defaults(self, defaults: dict[str, Any]) -> None:
""" """Register default values for this module's config.
Register default values for this module's config.
Called during setup() to declare expected config keys and their Called during setup() to declare expected config keys and their
default values. default values.
+7 -12
View File
@@ -44,8 +44,7 @@ if TYPE_CHECKING:
@dataclass @dataclass
class ModuleContext: class ModuleContext:
""" """Shared services available to all module handlers.
Shared services available to all module handlers.
Created once per module during loading and reused for all handler Created once per module during loading and reused for all handler
invocations. This bundles the common dependencies that every handler needs. invocations. This bundles the common dependencies that every handler needs.
@@ -83,6 +82,7 @@ class ModuleContext:
logger: logging.Logger = field(init=False) logger: logging.Logger = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Derive the module-scoped logger from the module name."""
self.logger = logging.getLogger(f"owlbot.modules.{self.module_name}") self.logger = logging.getLogger(f"owlbot.modules.{self.module_name}")
@@ -96,8 +96,7 @@ class PropagationState:
@dataclass @dataclass
class EventContext[E]: class EventContext[E]:
""" """Context passed to event handlers.
Context passed to event handlers.
Each handler invocation receives its own EventContext instance with the event Each handler invocation receives its own EventContext instance with the event
data and access to shared services via the module context. data and access to shared services via the module context.
@@ -165,16 +164,14 @@ class EventContext[E]:
@property @property
def propagation_stopped(self) -> bool: def propagation_stopped(self) -> bool:
""" """Check if event propagation has been stopped by a handler.
Check if event propagation has been stopped by a handler.
:return: True if stop_propagation() was called by any handler. :return: True if stop_propagation() was called by any handler.
""" """
return self._propagation.stopped return self._propagation.stopped
def stop_propagation(self, reason: str | None = None) -> None: def stop_propagation(self, reason: str | None = None) -> None:
""" """Stop event from being dispatched to remaining handlers and commands.
Stop event from being dispatched to remaining handlers and commands.
Once called, no further handlers will be invoked for this event, and Once called, no further handlers will be invoked for this event, and
command dispatch (for CHAT events) will be skipped. command dispatch (for CHAT events) will be skipped.
@@ -189,8 +186,7 @@ class EventContext[E]:
@dataclass @dataclass
class CommandContext: class CommandContext:
""" """Context passed to command handlers.
Context passed to command handlers.
Provides access to the parsed command data, original chat event context, Provides access to the parsed command data, original chat event context,
and shared services. Like EventContext and RouteContext, all ModuleContext and shared services. Like EventContext and RouteContext, all ModuleContext
@@ -289,8 +285,7 @@ class CommandContext:
@dataclass @dataclass
class RouteContext: class RouteContext:
""" """Context passed to HTTP route handlers.
Context passed to HTTP route handlers.
Similar to EventContext but includes the aiohttp request object Similar to EventContext but includes the aiohttp request object
for accessing HTTP-specific data (body, headers, query params). for accessing HTTP-specific data (body, headers, query params).
+14 -28
View File
@@ -43,8 +43,7 @@ class EventType(StrEnum):
def _parse_timestamp(ts: str | None) -> datetime | None: def _parse_timestamp(ts: str | None) -> datetime | None:
""" """Parse an ISO 8601 timestamp string into a datetime.
Parse an ISO 8601 timestamp string into a datetime.
Handles timezone suffixes and the Go zero-value timestamp that Owncast Handles timezone suffixes and the Go zero-value timestamp that Owncast
sends for missing/unset timestamps. sends for missing/unset timestamps.
@@ -91,8 +90,7 @@ class User:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> User: def from_dict(cls, data: dict[str, Any]) -> User:
""" """Create a User from webhook JSON data.
Create a User from webhook JSON data.
:param data: The user data from the webhook payload. :param data: The user data from the webhook payload.
:return: A populated User instance. :return: A populated User instance.
@@ -111,8 +109,7 @@ class User:
@property @property
def is_moderator(self) -> bool: def is_moderator(self) -> bool:
""" """Check if the user has moderator privileges.
Check if the user has moderator privileges.
:return: True if the user has the MODERATOR scope. :return: True if the user has the MODERATOR scope.
""" """
@@ -133,8 +130,7 @@ class ChatEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> ChatEvent: def from_dict(cls, data: dict[str, Any]) -> ChatEvent:
""" """Create a ChatEvent from webhook JSON data.
Create a ChatEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated ChatEvent instance. :return: A populated ChatEvent instance.
@@ -168,8 +164,7 @@ class UserJoinedEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> UserJoinedEvent: def from_dict(cls, data: dict[str, Any]) -> UserJoinedEvent:
""" """Create a UserJoinedEvent from webhook JSON data.
Create a UserJoinedEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated UserJoinedEvent instance. :return: A populated UserJoinedEvent instance.
@@ -193,8 +188,7 @@ class UserPartedEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> UserPartedEvent: def from_dict(cls, data: dict[str, Any]) -> UserPartedEvent:
""" """Create a UserPartedEvent from webhook JSON data.
Create a UserPartedEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated UserPartedEvent instance. :return: A populated UserPartedEvent instance.
@@ -219,8 +213,7 @@ class NameChangedEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> NameChangedEvent: def from_dict(cls, data: dict[str, Any]) -> NameChangedEvent:
""" """Create a NameChangedEvent from webhook JSON data.
Create a NameChangedEvent from webhook JSON data.
Note: The user object contains the OLD display name in user.display_name. Note: The user object contains the OLD display name in user.display_name.
@@ -248,8 +241,7 @@ class StreamStartedEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStartedEvent: def from_dict(cls, data: dict[str, Any]) -> StreamStartedEvent:
""" """Create a StreamStartedEvent from webhook JSON data.
Create a StreamStartedEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated StreamStartedEvent instance. :return: A populated StreamStartedEvent instance.
@@ -275,8 +267,7 @@ class StreamStoppedEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStoppedEvent: def from_dict(cls, data: dict[str, Any]) -> StreamStoppedEvent:
""" """Create a StreamStoppedEvent from webhook JSON data.
Create a StreamStoppedEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated StreamStoppedEvent instance. :return: A populated StreamStoppedEvent instance.
@@ -305,8 +296,7 @@ class StreamStatus:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStatus: def from_dict(cls, data: dict[str, Any]) -> StreamStatus:
""" """Create a StreamStatus from webhook JSON data.
Create a StreamStatus from webhook JSON data.
:param data: The status data from the webhook payload. :param data: The status data from the webhook payload.
:return: A populated StreamStatus instance. :return: A populated StreamStatus instance.
@@ -336,8 +326,7 @@ class StreamTitleUpdatedEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamTitleUpdatedEvent: def from_dict(cls, data: dict[str, Any]) -> StreamTitleUpdatedEvent:
""" """Create a StreamTitleUpdatedEvent from webhook JSON data.
Create a StreamTitleUpdatedEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated StreamTitleUpdatedEvent instance. :return: A populated StreamTitleUpdatedEvent instance.
@@ -364,8 +353,7 @@ class VisibilityUpdateEvent:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> VisibilityUpdateEvent: def from_dict(cls, data: dict[str, Any]) -> VisibilityUpdateEvent:
""" """Create a VisibilityUpdateEvent from webhook JSON data.
Create a VisibilityUpdateEvent from webhook JSON data.
:param data: The event data from the webhook payload. :param data: The event data from the webhook payload.
:return: A populated VisibilityUpdateEvent instance. :return: A populated VisibilityUpdateEvent instance.
@@ -407,8 +395,7 @@ _EVENT_TYPE_MAP: dict[EventType, type[Event]] = {
def parse_event(data: dict[str, Any]) -> _ParsedEvent | None: def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
""" """Parse a raw webhook payload into a typed event.
Parse a raw webhook payload into a typed event.
:param data: The JSON payload from Owncast's webhook. :param data: The JSON payload from Owncast's webhook.
:return: Tuple of (event_type, event_instance), or None if the event type :return: Tuple of (event_type, event_instance), or None if the event type
@@ -431,8 +418,7 @@ def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
def log_event(event_type: EventType, event: Event) -> None: def log_event(event_type: EventType, event: Event) -> None:
""" """Log event details for debugging and monitoring.
Log event details for debugging and monitoring.
:param event_type: The type of event being logged. :param event_type: The type of event being logged.
:param event: The parsed event instance. :param event: The parsed event instance.
+2 -4
View File
@@ -43,8 +43,7 @@ type EventHandler = "Callable[[EventContext[Any]], Awaitable[None]]"
class Priority(IntEnum): class Priority(IntEnum):
""" """Standard priority levels for event handlers.
Standard priority levels for event handlers.
Higher values run first. Handlers at the same priority level run in Higher values run first. Handlers at the same priority level run in
registration order. Custom numeric values can be used for fine-grained control. registration order. Custom numeric values can be used for fine-grained control.
@@ -61,8 +60,7 @@ def on_event(
*event_types: EventType, *event_types: EventType,
priority: int = Priority.NORMAL, priority: int = Priority.NORMAL,
) -> Callable[[EventHandler], EventHandler]: ) -> Callable[[EventHandler], EventHandler]:
""" """Register a function as a handler for one or more event types.
Decorator to register a function as a handler for one or more event types.
The decorated function will be called whenever an event of the specified The decorated function will be called whenever an event of the specified
type(s) is received. Handlers are executed sequentially in priority order type(s) is received. Handlers are executed sequentially in priority order
+2 -4
View File
@@ -31,8 +31,7 @@ type LifecycleHandler = "Callable[[ModuleContext], Awaitable[None]]"
def on_setup(func: LifecycleHandler) -> LifecycleHandler: def on_setup(func: LifecycleHandler) -> LifecycleHandler:
""" """Mark a function as a module setup hook.
Decorator to mark a function as a module setup hook.
The decorated function will be called during module loading with a The decorated function will be called during module loading with a
``ModuleContext``. Setup hooks run inside a storage transaction that ``ModuleContext``. Setup hooks run inside a storage transaction that
@@ -52,8 +51,7 @@ def on_setup(func: LifecycleHandler) -> LifecycleHandler:
def on_teardown(func: LifecycleHandler) -> LifecycleHandler: def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
""" """Mark a function as a module teardown hook.
Decorator to mark a function as a module teardown hook.
The decorated function will be called during module unload or bot The decorated function will be called during module unload or bot
shutdown with the same ``ModuleContext`` from setup. shutdown with the same ``ModuleContext`` from setup.
+77 -154
View File
@@ -186,8 +186,7 @@ class OwncastAdminClient(OwncastClient):
def __init__( def __init__(
self, base_url: str, username: str, password: str, http_client: HttpClient self, base_url: str, username: str, password: str, http_client: HttpClient
): ):
""" """Initialize the admin client.
Initialize the admin client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev"). :param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param username: Admin username. :param username: Admin username.
@@ -203,24 +202,21 @@ class OwncastAdminClient(OwncastClient):
) )
async def get_status(self) -> dict[str, Any]: async def get_status(self) -> dict[str, Any]:
""" """Get the current server status including stream info and viewer count.
Get the current server status including stream info and viewer count.
:return: Server status dict. :return: Server status dict.
""" """
return dict(await self._get("/api/admin/status")) return dict(await self._get("/api/admin/status"))
async def get_active_viewers(self) -> list[dict[str, Any]]: async def get_active_viewers(self) -> list[dict[str, Any]]:
""" """Get a list of currently active viewers.
Get a list of currently active viewers.
:return: Viewer list as returned by the Owncast API. :return: Viewer list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/viewers")) return list(await self._get("/api/admin/viewers"))
async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]: async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]:
""" """Get viewer count data over time for charting.
Get viewer count data over time for charting.
:param window_start: Unix timestamp (seconds since epoch) :param window_start: Unix timestamp (seconds since epoch)
for the start of the window. for the start of the window.
@@ -233,24 +229,21 @@ class OwncastAdminClient(OwncastClient):
) )
async def get_hardware_stats(self) -> dict[str, Any]: async def get_hardware_stats(self) -> dict[str, Any]:
""" """Get server hardware statistics (CPU, memory, disk).
Get server hardware statistics (CPU, memory, disk).
:return: Hardware stats dict. :return: Hardware stats dict.
""" """
return dict(await self._get("/api/admin/hardwarestats")) return dict(await self._get("/api/admin/hardwarestats"))
async def get_server_config(self) -> dict[str, Any]: async def get_server_config(self) -> dict[str, Any]:
""" """Get the full server configuration.
Get the full server configuration.
:return: Server configuration dict. :return: Server configuration dict.
""" """
return dict(await self._get("/api/admin/serverconfig")) return dict(await self._get("/api/admin/serverconfig"))
async def disconnect_stream(self) -> dict[str, Any]: async def disconnect_stream(self) -> dict[str, Any]:
""" """Disconnect the current inbound stream.
Disconnect the current inbound stream.
:return: API response confirming the disconnect. :return: API response confirming the disconnect.
""" """
@@ -258,16 +251,14 @@ class OwncastAdminClient(OwncastClient):
return dict(await self._get("/api/admin/disconnect")) return dict(await self._get("/api/admin/disconnect"))
async def get_chat_messages(self) -> list[dict[str, Any]]: async def get_chat_messages(self) -> list[dict[str, Any]]:
""" """Get chat messages from the admin perspective.
Get chat messages from the admin perspective.
:return: Chat message list as returned by the Owncast API. :return: Chat message list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/chat/messages")) return list(await self._get("/api/admin/chat/messages"))
async def get_connected_chat_clients(self) -> list[dict[str, Any]]: async def get_connected_chat_clients(self) -> list[dict[str, Any]]:
""" """Get currently connected chat clients.
Get currently connected chat clients.
:return: Client list as returned by the Owncast API. :return: Client list as returned by the Owncast API.
""" """
@@ -276,8 +267,7 @@ class OwncastAdminClient(OwncastClient):
async def set_message_visibility( async def set_message_visibility(
self, message_ids: list[str], visible: bool self, message_ids: list[str], visible: bool
) -> str: ) -> str:
""" """Hide or show chat messages.
Hide or show chat messages.
:param message_ids: List of message IDs to modify. :param message_ids: List of message IDs to modify.
:param visible: True to show messages, False to hide them. :param visible: True to show messages, False to hide them.
@@ -292,8 +282,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_user_enabled(self, user_id: str, enabled: bool) -> str: async def set_user_enabled(self, user_id: str, enabled: bool) -> str:
""" """Enable or disable a chat user.
Enable or disable a chat user.
:param user_id: The user ID to modify. :param user_id: The user ID to modify.
:param enabled: True to enable, False to disable the user. :param enabled: True to enable, False to disable the user.
@@ -307,16 +296,14 @@ class OwncastAdminClient(OwncastClient):
) )
async def get_disabled_users(self) -> list[dict[str, Any]]: async def get_disabled_users(self) -> list[dict[str, Any]]:
""" """Get a list of disabled chat users.
Get a list of disabled chat users.
:return: User list as returned by the Owncast API. :return: User list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/chat/users/disabled")) return list(await self._get("/api/admin/chat/users/disabled"))
async def set_user_moderator(self, user_id: str, is_mod: bool) -> str: async def set_user_moderator(self, user_id: str, is_mod: bool) -> str:
""" """Grant or revoke moderator status for a user.
Grant or revoke moderator status for a user.
:param user_id: The user ID to modify. :param user_id: The user ID to modify.
:param is_mod: True to grant moderator, False to revoke. :param is_mod: True to grant moderator, False to revoke.
@@ -330,16 +317,14 @@ class OwncastAdminClient(OwncastClient):
) )
async def get_moderators(self) -> list[dict[str, Any]]: async def get_moderators(self) -> list[dict[str, Any]]:
""" """Get a list of moderator users.
Get a list of moderator users.
:return: User list as returned by the Owncast API. :return: User list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/chat/users/moderators")) return list(await self._get("/api/admin/chat/users/moderators"))
async def ban_ip_address(self, ip: str) -> str: async def ban_ip_address(self, ip: str) -> str:
""" """Ban an IP address from chat.
Ban an IP address from chat.
:param ip: The IP address to ban. :param ip: The IP address to ban.
:return: Success message from the server. :return: Success message from the server.
@@ -348,8 +333,7 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/chat/users/ipbans/create", {"value": ip}) return await self._post("/api/admin/chat/users/ipbans/create", {"value": ip})
async def unban_ip_address(self, ip: str) -> str: async def unban_ip_address(self, ip: str) -> str:
""" """Remove an IP address ban.
Remove an IP address ban.
:param ip: The IP address to unban. :param ip: The IP address to unban.
:return: Success message from the server. :return: Success message from the server.
@@ -358,16 +342,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/chat/users/ipbans/remove", {"value": ip}) return await self._post("/api/admin/chat/users/ipbans/remove", {"value": ip})
async def get_ip_address_bans(self) -> list[dict[str, Any]]: async def get_ip_address_bans(self) -> list[dict[str, Any]]:
""" """Get a list of banned IP addresses.
Get a list of banned IP addresses.
:return: IP ban list as returned by the Owncast API. :return: IP ban list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/chat/users/ipbans")) return list(await self._get("/api/admin/chat/users/ipbans"))
async def set_stream_title(self, title: str) -> str: async def set_stream_title(self, title: str) -> str:
""" """Set the stream title.
Set the stream title.
:param title: The new stream title. :param title: The new stream title.
:return: Success message from the server. :return: Success message from the server.
@@ -377,8 +359,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_server_name(self, name: str) -> str: async def set_server_name(self, name: str) -> str:
""" """Set the server name.
Set the server name.
:param name: The new server name. :param name: The new server name.
:return: Success message from the server. :return: Success message from the server.
@@ -388,8 +369,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_server_summary(self, summary: str) -> str: async def set_server_summary(self, summary: str) -> str:
""" """Set the server summary.
Set the server summary.
:param summary: The new server summary. :param summary: The new server summary.
:return: Success message from the server. :return: Success message from the server.
@@ -399,8 +379,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_welcome_message(self, message: str) -> str: async def set_welcome_message(self, message: str) -> str:
""" """Set the welcome message shown to new viewers.
Set the welcome message shown to new viewers.
:param message: The new welcome message. :param message: The new welcome message.
:return: Success message from the server. :return: Success message from the server.
@@ -410,8 +389,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_offline_message(self, message: str) -> str: async def set_offline_message(self, message: str) -> str:
""" """Set the message shown when the stream is offline.
Set the message shown when the stream is offline.
:param message: The new offline message. :param message: The new offline message.
:return: Success message from the server. :return: Success message from the server.
@@ -421,8 +399,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_page_content(self, content: str) -> str: async def set_page_content(self, content: str) -> str:
""" """Set the custom page content (HTML/markdown below the player).
Set the custom page content (HTML/markdown below the player).
:param content: The page content. :param content: The page content.
:return: Success message from the server. :return: Success message from the server.
@@ -432,8 +409,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_admin_password(self, password: str) -> str: async def set_admin_password(self, password: str) -> str:
""" """Change the admin password.
Change the admin password.
:param password: The new admin password. :param password: The new admin password.
:return: Success message from the server. :return: Success message from the server.
@@ -443,8 +419,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_server_url(self, url: str) -> str: async def set_server_url(self, url: str) -> str:
""" """Set the public server URL.
Set the public server URL.
:param url: The new server URL. :param url: The new server URL.
:return: Success message from the server. :return: Success message from the server.
@@ -454,8 +429,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_custom_styles(self, css: str) -> str: async def set_custom_styles(self, css: str) -> str:
""" """Set custom CSS styles for the web interface.
Set custom CSS styles for the web interface.
:param css: The CSS string. :param css: The CSS string.
:return: Success message from the server. :return: Success message from the server.
@@ -465,8 +439,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_custom_javascript(self, js: str) -> str: async def set_custom_javascript(self, js: str) -> str:
""" """Set custom JavaScript for the web interface.
Set custom JavaScript for the web interface.
:param js: The JavaScript string. :param js: The JavaScript string.
:return: Success message from the server. :return: Success message from the server.
@@ -476,8 +449,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_socket_host_override(self, host: str) -> str: async def set_socket_host_override(self, host: str) -> str:
""" """Set the WebSocket host override.
Set the WebSocket host override.
:param host: The WebSocket host override value. :param host: The WebSocket host override value.
:return: Success message from the server. :return: Success message from the server.
@@ -487,8 +459,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_ffmpeg_path(self, path: str) -> str: async def set_ffmpeg_path(self, path: str) -> str:
""" """Set the path to the ffmpeg binary.
Set the path to the ffmpeg binary.
:param path: The ffmpeg binary path. :param path: The ffmpeg binary path.
:return: Success message from the server. :return: Success message from the server.
@@ -498,8 +469,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_video_serving_endpoint(self, endpoint: str) -> str: async def set_video_serving_endpoint(self, endpoint: str) -> str:
""" """Set the video serving endpoint (e.g., for CDN).
Set the video serving endpoint (e.g., for CDN).
:param endpoint: The video serving endpoint URL. :param endpoint: The video serving endpoint URL.
:return: Success message from the server. :return: Success message from the server.
@@ -509,8 +479,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_video_codec(self, codec: str) -> str: async def set_video_codec(self, codec: str) -> str:
""" """Set the video codec.
Set the video codec.
:param codec: The video codec name. :param codec: The video codec name.
:return: Success message from the server. :return: Success message from the server.
@@ -520,8 +489,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_chat_disabled(self, disabled: bool) -> str: async def set_chat_disabled(self, disabled: bool) -> str:
""" """Enable or disable the chat.
Enable or disable the chat.
:param disabled: True to disable chat, False to enable it. :param disabled: True to disable chat, False to enable it.
:return: Success message from the server. :return: Success message from the server.
@@ -531,8 +499,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_chat_join_messages_enabled(self, enabled: bool) -> str: async def set_chat_join_messages_enabled(self, enabled: bool) -> str:
""" """Enable or disable chat join messages.
Enable or disable chat join messages.
:param enabled: True to show join messages, False to hide them. :param enabled: True to show join messages, False to hide them.
:return: Success message from the server. :return: Success message from the server.
@@ -542,8 +509,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_chat_established_mode(self, enabled: bool) -> str: async def set_chat_established_mode(self, enabled: bool) -> str:
""" """Enable or disable established user mode for chat.
Enable or disable established user mode for chat.
:param enabled: True to enable established mode. :param enabled: True to enable established mode.
:return: Success message from the server. :return: Success message from the server.
@@ -555,8 +521,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_chat_spam_protection(self, enabled: bool) -> str: async def set_chat_spam_protection(self, enabled: bool) -> str:
""" """Enable or disable chat spam protection.
Enable or disable chat spam protection.
:param enabled: True to enable spam protection. :param enabled: True to enable spam protection.
:return: Success message from the server. :return: Success message from the server.
@@ -568,8 +533,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_chat_slur_filter(self, enabled: bool) -> str: async def set_chat_slur_filter(self, enabled: bool) -> str:
""" """Enable or disable the chat slur filter.
Enable or disable the chat slur filter.
:param enabled: True to enable the slur filter. :param enabled: True to enable the slur filter.
:return: Success message from the server. :return: Success message from the server.
@@ -579,8 +543,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_nsfw(self, nsfw: bool) -> str: async def set_nsfw(self, nsfw: bool) -> str:
""" """Set the NSFW flag for the server.
Set the NSFW flag for the server.
:param nsfw: True to mark the server as NSFW. :param nsfw: True to mark the server as NSFW.
:return: Success message from the server. :return: Success message from the server.
@@ -588,8 +551,7 @@ class OwncastAdminClient(OwncastClient):
return await self._set_config_value("/api/admin/config/nsfw", nsfw, "NSFW flag") return await self._set_config_value("/api/admin/config/nsfw", nsfw, "NSFW flag")
async def set_directory_enabled(self, enabled: bool) -> str: async def set_directory_enabled(self, enabled: bool) -> str:
""" """Enable or disable listing in the Owncast directory.
Enable or disable listing in the Owncast directory.
:param enabled: True to enable directory listing. :param enabled: True to enable directory listing.
:return: Success message from the server. :return: Success message from the server.
@@ -599,8 +561,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_hide_viewer_count(self, hide: bool) -> str: async def set_hide_viewer_count(self, hide: bool) -> str:
""" """Show or hide the viewer count.
Show or hide the viewer count.
:param hide: True to hide the viewer count. :param hide: True to hide the viewer count.
:return: Success message from the server. :return: Success message from the server.
@@ -610,8 +571,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_disable_search_indexing(self, disabled: bool) -> str: async def set_disable_search_indexing(self, disabled: bool) -> str:
""" """Enable or disable search engine indexing.
Enable or disable search engine indexing.
:param disabled: True to disable search indexing. :param disabled: True to disable search indexing.
:return: Success message from the server. :return: Success message from the server.
@@ -623,8 +583,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_forbidden_usernames(self, names: list[str]) -> str: async def set_forbidden_usernames(self, names: list[str]) -> str:
""" """Set the list of forbidden usernames.
Set the list of forbidden usernames.
:param names: List of forbidden username strings. :param names: List of forbidden username strings.
:return: Success message from the server. :return: Success message from the server.
@@ -634,8 +593,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_suggested_usernames(self, names: list[str]) -> str: async def set_suggested_usernames(self, names: list[str]) -> str:
""" """Set the list of suggested usernames for new viewers.
Set the list of suggested usernames for new viewers.
:param names: List of suggested username strings. :param names: List of suggested username strings.
:return: Success message from the server. :return: Success message from the server.
@@ -645,8 +603,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_tags(self, tags: list[str]) -> str: async def set_tags(self, tags: list[str]) -> str:
""" """Set the server tags.
Set the server tags.
:param tags: List of tag strings. :param tags: List of tag strings.
:return: Success message from the server. :return: Success message from the server.
@@ -654,8 +611,7 @@ class OwncastAdminClient(OwncastClient):
return await self._set_config_value("/api/admin/config/tags", tags, "tags") return await self._set_config_value("/api/admin/config/tags", tags, "tags")
async def set_federation_blocked_domains(self, domains: list[str]) -> str: async def set_federation_blocked_domains(self, domains: list[str]) -> str:
""" """Set the list of blocked federation domains.
Set the list of blocked federation domains.
:param domains: List of domain strings to block. :param domains: List of domain strings to block.
:return: Success message from the server. :return: Success message from the server.
@@ -667,8 +623,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_stream_keys(self, keys: list[StreamKey]) -> str: async def set_stream_keys(self, keys: list[StreamKey]) -> str:
""" """Set the stream keys.
Set the stream keys.
:param keys: List of :class:`StreamKey` objects. :param keys: List of :class:`StreamKey` objects.
:return: Success message from the server. :return: Success message from the server.
@@ -680,8 +635,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_video_variants(self, variants: list[VideoVariant]) -> str: async def set_video_variants(self, variants: list[VideoVariant]) -> str:
""" """Set the video output variants (quality levels).
Set the video output variants (quality levels).
:param variants: List of :class:`VideoVariant` objects. :param variants: List of :class:`VideoVariant` objects.
:return: Success message from the server. :return: Success message from the server.
@@ -693,8 +647,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_social_handles(self, handles: list[SocialHandle]) -> str: async def set_social_handles(self, handles: list[SocialHandle]) -> str:
""" """Set the social media handles displayed on the page.
Set the social media handles displayed on the page.
:param handles: List of :class:`SocialHandle` objects. :param handles: List of :class:`SocialHandle` objects.
:return: Success message from the server. :return: Success message from the server.
@@ -706,8 +659,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_external_actions(self, actions: list[ExternalAction]) -> str: async def set_external_actions(self, actions: list[ExternalAction]) -> str:
""" """Set the external actions (buttons/links in the player).
Set the external actions (buttons/links in the player).
:param actions: List of :class:`ExternalAction` objects. :param actions: List of :class:`ExternalAction` objects.
:return: Success message from the server. :return: Success message from the server.
@@ -727,8 +679,7 @@ class OwncastAdminClient(OwncastClient):
bucket: str, bucket: str,
region: str, region: str,
) -> str: ) -> str:
""" """Set the S3 storage configuration.
Set the S3 storage configuration.
:param enabled: Whether S3 storage is enabled. :param enabled: Whether S3 storage is enabled.
:param endpoint: The S3 endpoint URL. :param endpoint: The S3 endpoint URL.
@@ -757,8 +708,7 @@ class OwncastAdminClient(OwncastClient):
webhook: str, webhook: str,
go_live_message: str, go_live_message: str,
) -> str: ) -> str:
""" """Set the Discord notification configuration.
Set the Discord notification configuration.
:param enabled: Whether Discord notifications are enabled. :param enabled: Whether Discord notifications are enabled.
:param webhook: The Discord webhook URL. :param webhook: The Discord webhook URL.
@@ -780,8 +730,7 @@ class OwncastAdminClient(OwncastClient):
enabled: bool, enabled: bool,
go_live_message: str, go_live_message: str,
) -> str: ) -> str:
""" """Set the browser notification configuration.
Set the browser notification configuration.
:param enabled: Whether browser notifications are enabled. :param enabled: Whether browser notifications are enabled.
:param go_live_message: The message shown when going live. :param go_live_message: The message shown when going live.
@@ -797,8 +746,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_color_variables(self, variables: dict[str, Any]) -> str: async def set_color_variables(self, variables: dict[str, Any]) -> str:
""" """Set the custom color variables for the web interface.
Set the custom color variables for the web interface.
:param variables: Color variables dict. :param variables: Color variables dict.
:return: Success message from the server. :return: Success message from the server.
@@ -808,8 +756,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_stream_latency(self, level: int) -> str: async def set_stream_latency(self, level: int) -> str:
""" """Set the stream latency level.
Set the stream latency level.
:param level: Latency level value. :param level: Latency level value.
:return: Success message from the server. :return: Success message from the server.
@@ -819,8 +766,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_rtmp_port(self, port: int) -> str: async def set_rtmp_port(self, port: int) -> str:
""" """Set the RTMP server port.
Set the RTMP server port.
:param port: The RTMP port number. :param port: The RTMP port number.
:return: Success message from the server. :return: Success message from the server.
@@ -830,8 +776,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_web_server_port(self, port: int) -> str: async def set_web_server_port(self, port: int) -> str:
""" """Set the web server port.
Set the web server port.
:param port: The web server port number. :param port: The web server port number.
:return: Success message from the server. :return: Success message from the server.
@@ -841,8 +786,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_web_server_ip(self, ip: str) -> str: async def set_web_server_ip(self, ip: str) -> str:
""" """Set the web server bind IP address.
Set the web server bind IP address.
:param ip: The IP address to bind to. :param ip: The IP address to bind to.
:return: Success message from the server. :return: Success message from the server.
@@ -852,8 +796,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_federation_enabled(self, enabled: bool) -> str: async def set_federation_enabled(self, enabled: bool) -> str:
""" """Enable or disable federation (ActivityPub).
Enable or disable federation (ActivityPub).
:param enabled: True to enable federation. :param enabled: True to enable federation.
:return: Success message from the server. :return: Success message from the server.
@@ -863,8 +806,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_federation_username(self, name: str) -> str: async def set_federation_username(self, name: str) -> str:
""" """Set the federation (ActivityPub) username.
Set the federation (ActivityPub) username.
:param name: The federation username. :param name: The federation username.
:return: Success message from the server. :return: Success message from the server.
@@ -874,8 +816,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_federation_go_live_message(self, message: str) -> str: async def set_federation_go_live_message(self, message: str) -> str:
""" """Set the message sent to followers when going live.
Set the message sent to followers when going live.
:param message: The go-live notification message. :param message: The go-live notification message.
:return: Success message from the server. :return: Success message from the server.
@@ -887,8 +828,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def set_logo(self, base64_data_url: str) -> str: async def set_logo(self, base64_data_url: str) -> str:
""" """Set the server logo from a base64 data URL.
Set the server logo from a base64 data URL.
:param base64_data_url: Data URL string :param base64_data_url: Data URL string
(e.g., ``data:image/png;base64,iVBOR...``). (e.g., ``data:image/png;base64,iVBOR...``).
@@ -899,8 +839,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def upload_emoji(self, name: str, data_base64: str) -> str: async def upload_emoji(self, name: str, data_base64: str) -> str:
""" """Upload a custom emoji.
Upload a custom emoji.
:param name: The emoji name. :param name: The emoji name.
:param data_base64: Base64-encoded image data for the emoji. :param data_base64: Base64-encoded image data for the emoji.
@@ -912,8 +851,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def delete_emoji(self, name: str) -> str: async def delete_emoji(self, name: str) -> str:
""" """Delete a custom emoji.
Delete a custom emoji.
:param name: The emoji name to delete. :param name: The emoji name to delete.
:return: Success message from the server. :return: Success message from the server.
@@ -922,16 +860,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/emoji/delete", {"name": name}) return await self._post("/api/admin/emoji/delete", {"name": name})
async def get_webhooks(self) -> list[dict[str, Any]]: async def get_webhooks(self) -> list[dict[str, Any]]:
""" """Get all registered webhooks.
Get all registered webhooks.
:return: Webhook list as returned by the Owncast API. :return: Webhook list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/webhooks")) return list(await self._get("/api/admin/webhooks"))
async def create_webhook(self, url: str, events: list[str]) -> str: async def create_webhook(self, url: str, events: list[str]) -> str:
""" """Create a new webhook.
Create a new webhook.
:param url: The URL to send webhook events to. :param url: The URL to send webhook events to.
:param events: List of event type strings to subscribe to. :param events: List of event type strings to subscribe to.
@@ -943,8 +879,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def delete_webhook(self, webhook_id: int) -> str: async def delete_webhook(self, webhook_id: int) -> str:
""" """Delete a webhook.
Delete a webhook.
:param webhook_id: The ID of the webhook to delete. :param webhook_id: The ID of the webhook to delete.
:return: Success message from the server. :return: Success message from the server.
@@ -953,16 +888,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/webhooks/delete", {"id": webhook_id}) return await self._post("/api/admin/webhooks/delete", {"id": webhook_id})
async def get_access_tokens(self) -> list[dict[str, Any]]: async def get_access_tokens(self) -> list[dict[str, Any]]:
""" """Get all access tokens.
Get all access tokens.
:return: Token list as returned by the Owncast API. :return: Token list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/accesstokens")) return list(await self._get("/api/admin/accesstokens"))
async def create_access_token(self, name: str, scopes: list[str]) -> str: async def create_access_token(self, name: str, scopes: list[str]) -> str:
""" """Create a new access token.
Create a new access token.
:param name: Display name for the token. :param name: Display name for the token.
:param scopes: List of permission scope strings. :param scopes: List of permission scope strings.
@@ -974,8 +907,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def delete_access_token(self, token: str) -> str: async def delete_access_token(self, token: str) -> str:
""" """Delete an access token.
Delete an access token.
:param token: The token string to delete. :param token: The token string to delete.
:return: Success message from the server. :return: Success message from the server.
@@ -985,8 +917,7 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/accesstokens/delete", {"token": token}) return await self._post("/api/admin/accesstokens/delete", {"token": token})
async def get_followers(self, offset: int = 0, limit: int = 25) -> dict[str, Any]: async def get_followers(self, offset: int = 0, limit: int = 25) -> dict[str, Any]:
""" """Get a paginated list of followers.
Get a paginated list of followers.
:param offset: Number of followers to skip. :param offset: Number of followers to skip.
:param limit: Maximum number of followers to return. :param limit: Maximum number of followers to return.
@@ -999,24 +930,21 @@ class OwncastAdminClient(OwncastClient):
) )
async def get_pending_follow_requests(self) -> list[dict[str, Any]]: async def get_pending_follow_requests(self) -> list[dict[str, Any]]:
""" """Get pending follow requests.
Get pending follow requests.
:return: Follower request list as returned by the Owncast API. :return: Follower request list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/followers/pending")) return list(await self._get("/api/admin/followers/pending"))
async def get_blocked_followers(self) -> list[dict[str, Any]]: async def get_blocked_followers(self) -> list[dict[str, Any]]:
""" """Get blocked and rejected followers.
Get blocked and rejected followers.
:return: Follower list as returned by the Owncast API. :return: Follower list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/followers/blocked")) return list(await self._get("/api/admin/followers/blocked"))
async def approve_follower(self, actor_iri: str, approved: bool) -> str: async def approve_follower(self, actor_iri: str, approved: bool) -> str:
""" """Approve or reject a follow request.
Approve or reject a follow request.
:param actor_iri: The ActivityPub actor IRI of the follower. :param actor_iri: The ActivityPub actor IRI of the follower.
:param approved: True to approve, False to reject. :param approved: True to approve, False to reject.
@@ -1030,8 +958,7 @@ class OwncastAdminClient(OwncastClient):
) )
async def send_federated_message(self, message: str) -> str: async def send_federated_message(self, message: str) -> str:
""" """Send a message to all followers via federation.
Send a message to all followers via federation.
:param message: The message text to send. :param message: The message text to send.
:return: Success message from the server. :return: Success message from the server.
@@ -1040,24 +967,21 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/federation/send", {"value": message}) return await self._post("/api/admin/federation/send", {"value": message})
async def get_logs(self) -> list[dict[str, Any]]: async def get_logs(self) -> list[dict[str, Any]]:
""" """Get server logs.
Get server logs.
:return: Log entry list as returned by the Owncast API. :return: Log entry list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/logs")) return list(await self._get("/api/admin/logs"))
async def get_warnings(self) -> list[dict[str, Any]]: async def get_warnings(self) -> list[dict[str, Any]]:
""" """Get server warning and error logs.
Get server warning and error logs.
:return: Log entry list as returned by the Owncast API. :return: Log entry list as returned by the Owncast API.
""" """
return list(await self._get("/api/admin/logs/warnings")) return list(await self._get("/api/admin/logs/warnings"))
async def get_playback_metrics(self) -> dict[str, Any]: async def get_playback_metrics(self) -> dict[str, Any]:
""" """Get playback quality metrics.
Get playback quality metrics.
:return: Playback metrics as returned by the Owncast API. :return: Playback metrics as returned by the Owncast API.
""" """
@@ -1066,8 +990,7 @@ class OwncastAdminClient(OwncastClient):
async def _set_config_value( async def _set_config_value(
self, endpoint: str, value: Any, description: str self, endpoint: str, value: Any, description: str
) -> str: ) -> str:
""" """Set a configuration value via POST with a ``{"value": ...}`` body.
Set a configuration value via POST with a ``{"value": ...}`` body.
:param endpoint: The config API endpoint path. :param endpoint: The config API endpoint path.
:param value: The value to set. :param value: The value to set.
+13 -26
View File
@@ -52,8 +52,7 @@ class OwncastError(Exception):
"""Raised when an Owncast API request fails.""" """Raised when an Owncast API request fails."""
def __init__(self, status: int, message: str): def __init__(self, status: int, message: str):
""" """Initialize the error.
Initialize the error.
:param status: HTTP status code from the failed request, or 0 if the :param status: HTTP status code from the failed request, or 0 if the
request failed due to a connection error before receiving a response. request failed due to a connection error before receiving a response.
@@ -72,8 +71,7 @@ class OwncastClient:
""" """
def __init__(self, base_url: str, access_token: str, http_client: HttpClient): def __init__(self, base_url: str, access_token: str, http_client: HttpClient):
""" """Initialize the Owncast client.
Initialize the Owncast client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev"). :param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param access_token: API access token from Owncast admin settings. :param access_token: API access token from Owncast admin settings.
@@ -94,8 +92,7 @@ class OwncastClient:
return self._base_url return self._base_url
async def get_status(self) -> dict[str, Any]: async def get_status(self) -> dict[str, Any]:
""" """Get the public server status.
Get the public server status.
This is a public endpoint that does not require authentication. This is a public endpoint that does not require authentication.
Returns server info including version, online status, and viewer count. Returns server info including version, online status, and viewer count.
@@ -106,8 +103,7 @@ class OwncastClient:
return dict(await self._get("/api/status")) return dict(await self._get("/api/status"))
async def send_message(self, body: str) -> str: async def send_message(self, body: str) -> str:
""" """Send a chat message visible to all viewers.
Send a chat message visible to all viewers.
:param body: The message text (supports markdown). :param body: The message text (supports markdown).
:return: Success message from the server. :return: Success message from the server.
@@ -116,8 +112,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/send", {"body": body}) return await self._post("/api/integrations/chat/send", {"body": body})
async def send_system_message(self, body: str) -> str: async def send_system_message(self, body: str) -> str:
""" """Send a system message visible to all viewers.
Send a system message visible to all viewers.
System messages are styled differently from regular chat (typically System messages are styled differently from regular chat (typically
italicized or dimmed) and are used for announcements or notifications. italicized or dimmed) and are used for announcements or notifications.
@@ -129,8 +124,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/system", {"body": body}) return await self._post("/api/integrations/chat/system", {"body": body})
async def send_action(self, body: str) -> str: async def send_action(self, body: str) -> str:
""" """Send an action message (like IRC /me).
Send an action message (like IRC /me).
Action messages display as "*BotName does something*" and are used Action messages display as "*BotName does something*" and are used
for describing actions rather than speech. for describing actions rather than speech.
@@ -142,8 +136,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/action", {"body": body}) return await self._post("/api/integrations/chat/action", {"body": body})
async def send_system_message_to_client(self, client_id: int, body: str) -> str: async def send_system_message_to_client(self, client_id: int, body: str) -> str:
""" """Send a private system message to a specific viewer.
Send a private system message to a specific viewer.
The message is only visible to the targeted client, useful for The message is only visible to the targeted client, useful for
welcome messages or private notifications. welcome messages or private notifications.
@@ -160,8 +153,7 @@ class OwncastClient:
async def set_message_visibility( async def set_message_visibility(
self, message_ids: list[str], visible: bool self, message_ids: list[str], visible: bool
) -> str: ) -> str:
""" """Hide or show chat messages (moderation).
Hide or show chat messages (moderation).
Hidden messages are removed from the chat display for all viewers. Hidden messages are removed from the chat display for all viewers.
This is typically used for moderation purposes. This is typically used for moderation purposes.
@@ -179,8 +171,7 @@ class OwncastClient:
) )
async def get_chat_history(self) -> list[dict[str, Any]]: async def get_chat_history(self) -> list[dict[str, Any]]:
""" """Fetch recent chat messages.
Fetch recent chat messages.
:return: List of recent chat message objects with user info and content. :return: List of recent chat message objects with user info and content.
""" """
@@ -188,8 +179,7 @@ class OwncastClient:
return list(await self._get("/api/integrations/chat")) return list(await self._get("/api/integrations/chat"))
async def get_connected_clients(self) -> list[dict[str, Any]]: async def get_connected_clients(self) -> list[dict[str, Any]]:
""" """Get list of currently connected viewers.
Get list of currently connected viewers.
:return: List of connected client objects with user info and connection details. :return: List of connected client objects with user info and connection details.
""" """
@@ -197,8 +187,7 @@ class OwncastClient:
return list(await self._get("/api/integrations/clients")) return list(await self._get("/api/integrations/clients"))
async def set_stream_title(self, title: str) -> str: async def set_stream_title(self, title: str) -> str:
""" """Update the stream title.
Update the stream title.
:param title: The new stream title. :param title: The new stream title.
:return: Success message from the server. :return: Success message from the server.
@@ -207,8 +196,7 @@ class OwncastClient:
return await self._post("/api/integrations/streamtitle", {"value": title}) return await self._post("/api/integrations/streamtitle", {"value": title})
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str: async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
""" """Send a POST request to the Owncast API.
Send a POST request to the Owncast API.
Owncast POST endpoints return ``{"success": true, "message": "..."}``. Owncast POST endpoints return ``{"success": true, "message": "..."}``.
This method validates the response and returns just the message string. This method validates the response and returns just the message string.
@@ -270,8 +258,7 @@ class OwncastClient:
raise OwncastError(0, str(e)) from e raise OwncastError(0, str(e)) from e
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any: async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
""" """Send a GET request to the Owncast API.
Send a GET request to the Owncast API.
:param endpoint: The API endpoint path. :param endpoint: The API endpoint path.
:param params: Optional query parameters. :param params: Optional query parameters.
+1 -2
View File
@@ -62,8 +62,7 @@ def on_route(
*, *,
methods: list[str] | None = None, methods: list[str] | None = None,
) -> Callable[[RouteHandler], RouteHandler]: ) -> Callable[[RouteHandler], RouteHandler]:
""" """Register an HTTP route handler.
Decorator to register an HTTP route handler.
Routes are namespaced under /owlbot/<module_name>/<path>. Routes are namespaced under /owlbot/<module_name>/<path>.
+13 -24
View File
@@ -34,8 +34,7 @@ class StorageError(Exception):
class ModuleStorage: class ModuleStorage:
""" """Module-scoped async SQLite storage backed by a lazy connection pool.
Module-scoped async SQLite storage backed by a lazy connection pool.
Each module gets its own isolated database file. Connections are created Each module gets its own isolated database file. Connections are created
lazily and pooled up to ``pool_size``. WAL mode is enabled so that lazily and pooled up to ``pool_size``. WAL mode is enabled so that
@@ -54,8 +53,7 @@ class ModuleStorage:
""" """
def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4): def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4):
""" """Initialize the storage API.
Initialize the storage API.
:param storage_dir: Directory where module databases are stored. :param storage_dir: Directory where module databases are stored.
:param module_name: Name of the module this storage belongs to. :param module_name: Name of the module this storage belongs to.
@@ -92,8 +90,7 @@ class ModuleStorage:
@asynccontextmanager @asynccontextmanager
async def transaction(self) -> AsyncIterator[ModuleStorage]: async def transaction(self) -> AsyncIterator[ModuleStorage]:
""" """Context manager for explicit transaction control within a handler.
Context manager for explicit transaction control within a handler.
Use this when you need multiple operations to succeed or fail together Use this when you need multiple operations to succeed or fail together
within a single handler. Commits on success, rolls back on exception. within a single handler. Commits on success, rolls back on exception.
@@ -105,6 +102,7 @@ class ModuleStorage:
# Both committed together, or both rolled back on error # Both committed together, or both rolled back on error
:return: This ModuleStorage instance. :return: This ModuleStorage instance.
""" """
self._logger.debug("Explicit transaction started.") self._logger.debug("Explicit transaction started.")
try: try:
@@ -119,8 +117,7 @@ class ModuleStorage:
sql: str, sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (), parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Cursor: ) -> aiosqlite.Cursor:
""" """Execute a SQL statement.
Execute a SQL statement.
:param sql: SQL statement (use ? or :name for parameters). :param sql: SQL statement (use ? or :name for parameters).
:param parameters: Query parameters (tuple for ?, dict for :name). :param parameters: Query parameters (tuple for ?, dict for :name).
@@ -140,8 +137,7 @@ class ModuleStorage:
sql: str, sql: str,
parameters: list[tuple[Any, ...]] | list[dict[str, Any]], parameters: list[tuple[Any, ...]] | list[dict[str, Any]],
) -> aiosqlite.Cursor: ) -> aiosqlite.Cursor:
""" """Execute a SQL statement with multiple parameter sets.
Execute a SQL statement with multiple parameter sets.
Useful for batch inserts/updates. Useful for batch inserts/updates.
@@ -166,8 +162,7 @@ class ModuleStorage:
sql: str, sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (), parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Row | None: ) -> aiosqlite.Row | None:
""" """Execute a query and fetch one row.
Execute a query and fetch one row.
:param sql: SELECT statement. :param sql: SELECT statement.
:param parameters: Query parameters. :param parameters: Query parameters.
@@ -189,8 +184,7 @@ class ModuleStorage:
sql: str, sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (), parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> list[aiosqlite.Row]: ) -> list[aiosqlite.Row]:
""" """Execute a query and fetch all rows.
Execute a query and fetch all rows.
:param sql: SELECT statement. :param sql: SELECT statement.
:param parameters: Query parameters. :param parameters: Query parameters.
@@ -212,8 +206,7 @@ class ModuleStorage:
sql: str, sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (), parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> Any | None: ) -> Any | None:
""" """Execute a query and fetch a single value.
Execute a query and fetch a single value.
:param sql: SELECT statement returning one column. :param sql: SELECT statement returning one column.
:param parameters: Query parameters. :param parameters: Query parameters.
@@ -260,8 +253,7 @@ class ModuleStorage:
@asynccontextmanager @asynccontextmanager
async def _connection(self) -> AsyncIterator[aiosqlite.Connection]: async def _connection(self) -> AsyncIterator[aiosqlite.Connection]:
""" """Async context manager that provides a connection.
Async context manager that provides a connection.
If already inside a ``_checkout``, yields the checked-out connection If already inside a ``_checkout``, yields the checked-out connection
without releasing it. Otherwise acquires a standalone connection from without releasing it. Otherwise acquires a standalone connection from
@@ -285,8 +277,7 @@ class ModuleStorage:
@asynccontextmanager @asynccontextmanager
async def _checkout(self) -> AsyncIterator[None]: async def _checkout(self) -> AsyncIterator[None]:
""" """Check out a connection from the pool for the duration of a handler.
Check out a connection from the pool for the duration of a handler.
Sets a ContextVar so that all storage operations within the handler Sets a ContextVar so that all storage operations within the handler
reuse the same connection. reuse the same connection.
@@ -300,8 +291,7 @@ class ModuleStorage:
self._release(conn) self._release(conn)
async def _commit(self) -> None: async def _commit(self) -> None:
""" """Commit the current transaction (internal use by bot).
Commit the current transaction (internal use by bot).
Called automatically after each handler completes successfully. Called automatically after each handler completes successfully.
""" """
@@ -311,8 +301,7 @@ class ModuleStorage:
self._logger.debug("Transaction committed.") self._logger.debug("Transaction committed.")
async def _rollback(self) -> None: async def _rollback(self) -> None:
""" """Rollback the current transaction (internal use by bot).
Rollback the current transaction (internal use by bot).
Called automatically if a handler throws an exception. Called automatically if a handler throws an exception.
""" """
+2 -4
View File
@@ -47,8 +47,7 @@ class Owlbot:
overrides: dict[str, Any] | None = None, overrides: dict[str, Any] | None = None,
skip_api_check: bool = False, skip_api_check: bool = False,
): ):
""" """Initialize Owlbot.
Initialize Owlbot.
:param config_path: Path to the YAML config file. :param config_path: Path to the YAML config file.
:param overrides: CLI overrides passed to the config manager. :param overrides: CLI overrides passed to the config manager.
@@ -161,8 +160,7 @@ class Owlbot:
logger.info("Owlbot shutdown complete.") logger.info("Owlbot shutdown complete.")
async def _check_api_accessibility(self) -> None: async def _check_api_accessibility(self) -> None:
""" """Verify that the Owncast APIs are reachable before proceeding with startup.
Verify that the Owncast APIs are reachable before proceeding with startup.
Calls a non-transformative endpoint on each configured client to confirm Calls a non-transformative endpoint on each configured client to confirm
the server is accessible and credentials are valid. Raises the server is accessible and credentials are valid. Raises
@@ -56,8 +56,7 @@ __all__ = [
@on_setup @on_setup
async def setup(ctx: ModuleContext) -> None: async def setup(ctx: ModuleContext) -> None:
""" """Initialize the custom_commands module.
Initialize the custom_commands module.
Creates the database schema and loads existing commands from the database. Creates the database schema and loads existing commands from the database.
@@ -30,8 +30,7 @@ from .placeholders import DEFAULT_MAX_DEPTH, process_placeholders
async def get_aliases_for_command( async def get_aliases_for_command(
storage: ModuleStorage, command_name: str storage: ModuleStorage, command_name: str
) -> list[str]: ) -> list[str]:
""" """Fetch all aliases for a command from the database.
Fetch all aliases for a command from the database.
:param storage: The module storage instance. :param storage: The module storage instance.
:param command_name: The canonical command name. :param command_name: The canonical command name.
@@ -47,8 +46,7 @@ async def get_aliases_for_command(
async def resolve_command_name( async def resolve_command_name(
storage: ModuleStorage, name: str storage: ModuleStorage, name: str
) -> aiosqlite.Row | None: ) -> aiosqlite.Row | None:
""" """Resolve a command name or alias to the full command row.
Resolve a command name or alias to the full command row.
Checks the commands table first, then falls back to the aliases table. Checks the commands table first, then falls back to the aliases table.
Returns the command's name, requires_moderator, and cooldown columns. Returns the command's name, requires_moderator, and cooldown columns.
@@ -70,8 +68,7 @@ async def resolve_command_name(
async def custom_command_handler(ctx: CommandContext) -> None: async def custom_command_handler(ctx: CommandContext) -> None:
""" """Shared handler for all custom commands.
Shared handler for all custom commands.
Looks up the command in the database, increments use count, Looks up the command in the database, increments use count,
processes placeholders, and sends the response. processes placeholders, and sends the response.
@@ -112,8 +109,7 @@ def reregister_command(
requires_moderator: bool, requires_moderator: bool,
cooldown: int, cooldown: int,
) -> None: ) -> None:
""" """Unregister and re-register a custom command with updated settings.
Unregister and re-register a custom command with updated settings.
Both registry operations are synchronous, so no other coroutine can observe Both registry operations are synchronous, so no other coroutine can observe
the intermediate unregistered state. the intermediate unregistered state.
@@ -67,8 +67,7 @@ async def _resolve_or_error(ctx: CommandContext, raw_name: str) -> aiosqlite.Row
@on_command("addcommand", aliases=["addcmd"], requires_moderator=True) @on_command("addcommand", aliases=["addcmd"], requires_moderator=True)
async def addcommand(ctx: CommandContext) -> None: async def addcommand(ctx: CommandContext) -> None:
""" """Create a new custom command.
Create a new custom command.
Usage: !addcommand !name response text Usage: !addcommand !name response text
@@ -134,8 +133,7 @@ async def addcommand(ctx: CommandContext) -> None:
@on_command("editcommand", aliases=["editcmd"], requires_moderator=True) @on_command("editcommand", aliases=["editcmd"], requires_moderator=True)
async def editcommand(ctx: CommandContext) -> None: async def editcommand(ctx: CommandContext) -> None:
""" """Edit an existing custom command's response.
Edit an existing custom command's response.
Usage: !editcommand !name new response Usage: !editcommand !name new response
@@ -175,8 +173,7 @@ async def editcommand(ctx: CommandContext) -> None:
@on_command("deletecommand", aliases=["delcmd"], requires_moderator=True) @on_command("deletecommand", aliases=["delcmd"], requires_moderator=True)
async def deletecommand(ctx: CommandContext) -> None: async def deletecommand(ctx: CommandContext) -> None:
""" """Delete a custom command.
Delete a custom command.
Usage: !deletecommand !name Usage: !deletecommand !name
@@ -215,8 +212,7 @@ async def deletecommand(ctx: CommandContext) -> None:
@on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True) @on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True)
async def commandmodonly(ctx: CommandContext) -> None: async def commandmodonly(ctx: CommandContext) -> None:
""" """Toggle moderator-only access for a custom command.
Toggle moderator-only access for a custom command.
Usage: !commandmodonly !name <on|off> Usage: !commandmodonly !name <on|off>
@@ -255,8 +251,7 @@ async def commandmodonly(ctx: CommandContext) -> None:
@on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True) @on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True)
async def resetcommand(ctx: CommandContext) -> None: async def resetcommand(ctx: CommandContext) -> None:
""" """Reset a custom command's use counter to 0.
Reset a custom command's use counter to 0.
Usage: !resetcommand !name Usage: !resetcommand !name
@@ -288,8 +283,7 @@ async def resetcommand(ctx: CommandContext) -> None:
@on_command("editcounter", aliases=["editcount"], requires_moderator=True) @on_command("editcounter", aliases=["editcount"], requires_moderator=True)
async def editcounter(ctx: CommandContext) -> None: async def editcounter(ctx: CommandContext) -> None:
""" """Set, increment, or decrement a named counter.
Set, increment, or decrement a named counter.
Usage: !editcounter <name> <value> Usage: !editcounter <name> <value>
@@ -351,8 +345,7 @@ async def editcounter(ctx: CommandContext) -> None:
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True) @on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
async def commandcooldown(ctx: CommandContext) -> None: async def commandcooldown(ctx: CommandContext) -> None:
""" """Set or disable a custom command's cooldown.
Set or disable a custom command's cooldown.
Usage: !commandcooldown !name <seconds> Usage: !commandcooldown !name <seconds>
@@ -404,8 +397,7 @@ async def commandcooldown(ctx: CommandContext) -> None:
@on_command("addalias", requires_moderator=True) @on_command("addalias", requires_moderator=True)
async def addalias(ctx: CommandContext) -> None: async def addalias(ctx: CommandContext) -> None:
""" """Add an alias to an existing custom command.
Add an alias to an existing custom command.
Usage: !addalias !command !alias Usage: !addalias !command !alias
@@ -485,8 +477,7 @@ async def addalias(ctx: CommandContext) -> None:
@on_command("removealias", requires_moderator=True) @on_command("removealias", requires_moderator=True)
async def removealias(ctx: CommandContext) -> None: async def removealias(ctx: CommandContext) -> None:
""" """Remove an alias from a custom command.
Remove an alias from a custom command.
Usage: !removealias !alias Usage: !removealias !alias
@@ -535,8 +526,7 @@ async def removealias(ctx: CommandContext) -> None:
@on_command("listcommands", aliases=["listcmds"], cooldown=15) @on_command("listcommands", aliases=["listcmds"], cooldown=15)
async def listcommands(ctx: CommandContext) -> None: async def listcommands(ctx: CommandContext) -> None:
""" """List all custom commands.
List all custom commands.
Sends a URL to the command list web page. Sends a URL to the command list web page.
Has a 15-second cooldown to prevent spam. Has a 15-second cooldown to prevent spam.
@@ -105,10 +105,10 @@ class PlaceholderContext:
def _find_matching_close(template: str, pos: int) -> int: def _find_matching_close(template: str, pos: int) -> int:
"""Find the position of the ``)`` that closes an escaped ``\\$(...)`` group. r"""Find the position of the ``)`` that closes an escaped ``\$(...)`` group.
Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner
placeholders (e.g. ``\\$(rand $(1) $(2))``) are consumed in their entirety. placeholders (e.g. ``\$(rand $(1) $(2))``) are consumed in their entirety.
:param template: The full template string. :param template: The full template string.
:param pos: The position immediately after the opening ``$(`` of the :param pos: The position immediately after the opening ``$(`` of the
@@ -30,8 +30,7 @@ _jinja_env = jinja2.Environment(
@on_route("/list", methods=["GET"]) @on_route("/list", methods=["GET"])
async def command_list_page(ctx: RouteContext) -> web.Response: async def command_list_page(ctx: RouteContext) -> web.Response:
""" """Serve an HTML page listing all custom commands in a table.
Serve an HTML page listing all custom commands in a table.
Columns: Command, Aliases, Response, Cooldown, Permissions. Columns: Command, Aliases, Response, Cooldown, Permissions.
Accessible at /owlbot/custom_commands/list. Accessible at /owlbot/custom_commands/list.
+6 -12
View File
@@ -41,8 +41,7 @@ _jinja_env = jinja2.Environment(
@on_setup @on_setup
async def setup(ctx: ModuleContext) -> None: async def setup(ctx: ModuleContext) -> None:
""" """Initialize the quotes module.
Initialize the quotes module.
Creates the database schema. Creates the database schema.
@@ -63,8 +62,7 @@ async def setup(ctx: ModuleContext) -> None:
@on_route("/list", methods=["GET"]) @on_route("/list", methods=["GET"])
async def quotes_list_page(ctx: RouteContext) -> web.Response: async def quotes_list_page(ctx: RouteContext) -> web.Response:
""" """Serve an HTML page listing all quotes in a table.
Serve an HTML page listing all quotes in a table.
Columns: #, Quote, Added By, Date Added. Columns: #, Quote, Added By, Date Added.
Accessible at /owlbot/quotes/list. Accessible at /owlbot/quotes/list.
@@ -94,8 +92,7 @@ async def quotes_list_page(ctx: RouteContext) -> web.Response:
@on_command("quote", aliases=["q"]) @on_command("quote", aliases=["q"])
async def quote_command(ctx: CommandContext) -> None: async def quote_command(ctx: CommandContext) -> None:
""" """Display a quote. Random if no argument, specific if an ID is given.
Display a quote. Random if no argument, specific if an ID is given.
:param ctx: The command context. :param ctx: The command context.
""" """
@@ -137,8 +134,7 @@ async def quote_command(ctx: CommandContext) -> None:
@on_command("addquote", requires_moderator=True) @on_command("addquote", requires_moderator=True)
async def addquote_command(ctx: CommandContext) -> None: async def addquote_command(ctx: CommandContext) -> None:
""" """Add a new quote to the database. Moderator only.
Add a new quote to the database. Moderator only.
:param ctx: The command context. :param ctx: The command context.
""" """
@@ -159,8 +155,7 @@ async def addquote_command(ctx: CommandContext) -> None:
@on_command("deletequote", aliases=["delquote"], requires_moderator=True) @on_command("deletequote", aliases=["delquote"], requires_moderator=True)
async def deletequote_command(ctx: CommandContext) -> None: async def deletequote_command(ctx: CommandContext) -> None:
""" """Delete a quote by ID. Moderator only.
Delete a quote by ID. Moderator only.
:param ctx: The command context. :param ctx: The command context.
""" """
@@ -190,8 +185,7 @@ async def deletequote_command(ctx: CommandContext) -> None:
@on_command("listquotes", cooldown=15) @on_command("listquotes", cooldown=15)
async def listquotes_command(ctx: CommandContext) -> None: async def listquotes_command(ctx: CommandContext) -> None:
""" """Send the URL to the quotes list web page.
Send the URL to the quotes list web page.
:param ctx: The command context. :param ctx: The command context.
""" """
+2 -4
View File
@@ -56,8 +56,7 @@ __all__ = [
@on_setup @on_setup
async def setup(ctx: ModuleContext) -> None: async def setup(ctx: ModuleContext) -> None:
""" """Initialize the timers module.
Initialize the timers module.
Creates the database schema, initializes chat counters for enabled timers, Creates the database schema, initializes chat counters for enabled timers,
and starts the background scheduler. and starts the background scheduler.
@@ -97,8 +96,7 @@ async def setup(ctx: ModuleContext) -> None:
@on_teardown @on_teardown
async def teardown(ctx: ModuleContext) -> None: async def teardown(ctx: ModuleContext) -> None:
""" """Clean up the timers module.
Clean up the timers module.
Stops the background scheduler task. Stops the background scheduler task.
@@ -32,8 +32,7 @@ from .scheduler import get_scheduler
@on_event(EventType.CHAT, priority=Priority.LOWEST) @on_event(EventType.CHAT, priority=Priority.LOWEST)
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None: async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
""" """Count a chat message for all tracked timers.
Count a chat message for all tracked timers.
Runs at lowest priority so all other CHAT handlers (moderation, etc.) Runs at lowest priority so all other CHAT handlers (moderation, etc.)
execute first. Skips bot messages and hidden messages. execute first. Skips bot messages and hidden messages.
@@ -60,8 +59,7 @@ async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST) @on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None: async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None:
""" """Remove hidden messages from chat counts.
Remove hidden messages from chat counts.
Only handles the hide case. Un-hiding does not re-add messages because Only handles the hide case. Un-hiding does not re-add messages because
we cannot distinguish previously counted user messages from bot messages we cannot distinguish previously counted user messages from bot messages
+10 -20
View File
@@ -33,8 +33,7 @@ _NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$")
async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None: async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None:
""" """Resolve a timer by numeric ID or name.
Resolve a timer by numeric ID or name.
Tries parsing as an integer first, then falls back to a name lookup. Tries parsing as an integer first, then falls back to a name lookup.
@@ -66,8 +65,7 @@ async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row
def _timer_display(row: aiosqlite.Row) -> str: def _timer_display(row: aiosqlite.Row) -> str:
""" """Format a timer's display identifier.
Format a timer's display identifier.
:param row: Timer database row. :param row: Timer database row.
:return: Display string like "timer_name (#3)" or "timer #3". :return: Display string like "timer_name (#3)" or "timer #3".
@@ -79,8 +77,7 @@ def _timer_display(row: aiosqlite.Row) -> str:
@on_command("addtimer", requires_moderator=True) @on_command("addtimer", requires_moderator=True)
async def addtimer(ctx: CommandContext) -> None: async def addtimer(ctx: CommandContext) -> None:
""" """Create a new empty timer with an optional name.
Create a new empty timer with an optional name.
Usage: !addtimer [name] Usage: !addtimer [name]
@@ -123,8 +120,7 @@ async def addtimer(ctx: CommandContext) -> None:
@on_command("settimermessage", requires_moderator=True) @on_command("settimermessage", requires_moderator=True)
async def settimermessage(ctx: CommandContext) -> None: async def settimermessage(ctx: CommandContext) -> None:
""" """Set the message text for a timer.
Set the message text for a timer.
Usage: !settimermessage <id|name> <message> Usage: !settimermessage <id|name> <message>
@@ -164,8 +160,7 @@ async def settimermessage(ctx: CommandContext) -> None:
@on_command("settimerinterval", requires_moderator=True) @on_command("settimerinterval", requires_moderator=True)
async def settimerinterval(ctx: CommandContext) -> None: async def settimerinterval(ctx: CommandContext) -> None:
""" """Set the interval for a timer.
Set the interval for a timer.
Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *). Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *).
@@ -212,8 +207,7 @@ async def settimerinterval(ctx: CommandContext) -> None:
@on_command("settimerlines", requires_moderator=True) @on_command("settimerlines", requires_moderator=True)
async def settimerlines(ctx: CommandContext) -> None: async def settimerlines(ctx: CommandContext) -> None:
""" """Set the minimum chat lines between timer firings.
Set the minimum chat lines between timer firings.
Usage: !settimerlines <id|name> <count> Usage: !settimerlines <id|name> <count>
@@ -260,8 +254,7 @@ async def settimerlines(ctx: CommandContext) -> None:
@on_command("enabletimer", requires_moderator=True) @on_command("enabletimer", requires_moderator=True)
async def enabletimer(ctx: CommandContext) -> None: async def enabletimer(ctx: CommandContext) -> None:
""" """Enable a timer.
Enable a timer.
Won't enable a timer that has no message set. Won't enable a timer that has no message set.
@@ -310,8 +303,7 @@ async def enabletimer(ctx: CommandContext) -> None:
@on_command("disabletimer", requires_moderator=True) @on_command("disabletimer", requires_moderator=True)
async def disabletimer(ctx: CommandContext) -> None: async def disabletimer(ctx: CommandContext) -> None:
""" """Disable a timer.
Disable a timer.
Usage: !disabletimer <id|name> Usage: !disabletimer <id|name>
@@ -351,8 +343,7 @@ async def disabletimer(ctx: CommandContext) -> None:
@on_command("deletetimer", requires_moderator=True) @on_command("deletetimer", requires_moderator=True)
async def deletetimer(ctx: CommandContext) -> None: async def deletetimer(ctx: CommandContext) -> None:
""" """Permanently delete a timer.
Permanently delete a timer.
Usage: !deletetimer <id|name> Usage: !deletetimer <id|name>
@@ -382,8 +373,7 @@ async def deletetimer(ctx: CommandContext) -> None:
@on_command("listtimers", requires_moderator=True, cooldown=15) @on_command("listtimers", requires_moderator=True, cooldown=15)
async def listtimers(ctx: CommandContext) -> None: async def listtimers(ctx: CommandContext) -> None:
""" """Send the URL to the timer list web page.
Send the URL to the timer list web page.
Usage: !listtimers Usage: !listtimers
+1 -2
View File
@@ -31,8 +31,7 @@ _jinja_env = jinja2.Environment(
@on_route("/list", methods=["GET"]) @on_route("/list", methods=["GET"])
async def timer_list_page(ctx: RouteContext) -> web.Response: async def timer_list_page(ctx: RouteContext) -> web.Response:
""" """Serve an HTML page listing all timers in a table.
Serve an HTML page listing all timers in a table.
Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired. Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired.
Accessible at /owlbot/timers/list. Accessible at /owlbot/timers/list.
+16 -30
View File
@@ -51,8 +51,7 @@ _MIN_CRON_MINUTES = 1
def parse_interval(value: str) -> tuple[IntervalType, str]: def parse_interval(value: str) -> tuple[IntervalType, str]:
""" """Parse and validate an interval string.
Parse and validate an interval string.
Simple durations (no spaces) are parsed with a regex. Cron expressions Simple durations (no spaces) are parsed with a regex. Cron expressions
(contain spaces) are validated with cronsim. Returns the detected type (contain spaces) are validated with cronsim. Returns the detected type
@@ -72,8 +71,7 @@ def parse_interval(value: str) -> tuple[IntervalType, str]:
def _parse_simple(value: str) -> tuple[IntervalType, str]: def _parse_simple(value: str) -> tuple[IntervalType, str]:
""" """Parse and validate a simple duration string.
Parse and validate a simple duration string.
:param value: Duration string like "30s", "5m", "1h30m". :param value: Duration string like "30s", "5m", "1h30m".
:return: Tuple of (IntervalType.SIMPLE, value). :return: Tuple of (IntervalType.SIMPLE, value).
@@ -99,8 +97,7 @@ def _parse_simple(value: str) -> tuple[IntervalType, str]:
def _parse_cron(value: str) -> tuple[IntervalType, str]: def _parse_cron(value: str) -> tuple[IntervalType, str]:
""" """Validate a cron expression.
Validate a cron expression.
:param value: Cron expression string (5 fields). :param value: Cron expression string (5 fields).
:return: Tuple of (IntervalType.CRON, value). :return: Tuple of (IntervalType.CRON, value).
@@ -125,8 +122,7 @@ def _parse_cron(value: str) -> tuple[IntervalType, str]:
def _duration_to_seconds(value: str) -> int: def _duration_to_seconds(value: str) -> int:
""" """Convert a validated simple duration string to total seconds.
Convert a validated simple duration string to total seconds.
:param value: A previously validated duration string. :param value: A previously validated duration string.
:return: Total seconds. :return: Total seconds.
@@ -141,8 +137,7 @@ def _duration_to_seconds(value: str) -> int:
def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None: def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
""" """Compute when a timer will next be time-due.
Compute when a timer will next be time-due.
:param row: Database row with interval_type, interval_value, last_fired_at. :param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time. :param now: Current UTC time.
@@ -167,8 +162,7 @@ def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool: def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool:
""" """Check whether a timer should fire based on its interval and last fire time.
Check whether a timer should fire based on its interval and last fire time.
:param row: Database row with interval_type, interval_value, last_fired_at. :param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time. :param now: Current UTC time.
@@ -187,6 +181,7 @@ class TimerScheduler:
""" """
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize an empty scheduler with no running task."""
self._task: asyncio.Task[None] | None = None self._task: asyncio.Task[None] | None = None
self._counted_ids: dict[int, set[str]] = {} self._counted_ids: dict[int, set[str]] = {}
self._wake_event: asyncio.Event | None = None self._wake_event: asyncio.Event | None = None
@@ -197,8 +192,7 @@ class TimerScheduler:
return self._counted_ids return self._counted_ids
def init_counted_ids(self, timer_ids: list[int]) -> None: def init_counted_ids(self, timer_ids: list[int]) -> None:
""" """Initialize empty counter sets for the given timer IDs.
Initialize empty counter sets for the given timer IDs.
Called during module setup to prepare tracking for enabled timers. Called during module setup to prepare tracking for enabled timers.
@@ -207,8 +201,7 @@ class TimerScheduler:
self._counted_ids = {tid: set() for tid in timer_ids} self._counted_ids = {tid: set() for tid in timer_ids}
def start(self, ctx: ModuleContext) -> None: def start(self, ctx: ModuleContext) -> None:
""" """Start the background scheduler task.
Start the background scheduler task.
:param ctx: The module context. :param ctx: The module context.
""" """
@@ -219,8 +212,7 @@ class TimerScheduler:
ctx.logger.debug("Timer scheduler started.") ctx.logger.debug("Timer scheduler started.")
async def stop(self, ctx: ModuleContext) -> None: async def stop(self, ctx: ModuleContext) -> None:
""" """Cancel the background scheduler task and wait for it to exit.
Cancel the background scheduler task and wait for it to exit.
:param ctx: The module context. :param ctx: The module context.
""" """
@@ -233,8 +225,7 @@ class TimerScheduler:
ctx.logger.info("Timer scheduler stopped.") ctx.logger.info("Timer scheduler stopped.")
def reschedule(self) -> None: def reschedule(self) -> None:
""" """Wake the scheduler so it recalculates the next fire time.
Wake the scheduler so it recalculates the next fire time.
Called by timer management commands when timer state changes. Called by timer management commands when timer state changes.
""" """
@@ -242,8 +233,7 @@ class TimerScheduler:
self._wake_event.set() self._wake_event.set()
async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None: async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None:
""" """Background loop that sleeps until the next timer is due and fires it.
Background loop that sleeps until the next timer is due and fires it.
Uses an asyncio.Event to allow early wake-ups when timer state changes. Uses an asyncio.Event to allow early wake-ups when timer state changes.
@@ -283,8 +273,7 @@ class TimerScheduler:
ctx.logger.exception("Scheduler tick failed.") ctx.logger.exception("Scheduler tick failed.")
async def _tick(self, ctx: ModuleContext) -> None: async def _tick(self, ctx: ModuleContext) -> None:
""" """Single scheduler tick: query enabled timers and fire any that are due.
Single scheduler tick: query enabled timers and fire any that are due.
:param ctx: The module context. :param ctx: The module context.
""" """
@@ -342,8 +331,7 @@ class TimerScheduler:
async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]: async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]:
""" """Query enabled timers and return seconds until the soonest one is time-due.
Query enabled timers and return seconds until the soonest one is time-due.
:param ctx: The module context. :param ctx: The module context.
:return: Tuple of (seconds until next timer, display name of that timer), :return: Tuple of (seconds until next timer, display name of that timer),
@@ -394,8 +382,7 @@ _scheduler: TimerScheduler | None = None
def get_scheduler() -> TimerScheduler: def get_scheduler() -> TimerScheduler:
""" """Return the active scheduler instance.
Return the active scheduler instance.
:return: The active TimerScheduler. :return: The active TimerScheduler.
:raises RuntimeError: If the scheduler has not been initialized. :raises RuntimeError: If the scheduler has not been initialized.
@@ -406,8 +393,7 @@ def get_scheduler() -> TimerScheduler:
def set_scheduler(scheduler: TimerScheduler) -> None: def set_scheduler(scheduler: TimerScheduler) -> None:
""" """Set the active scheduler instance.
Set the active scheduler instance.
:param scheduler: The TimerScheduler to install. :param scheduler: The TimerScheduler to install.
""" """
+3 -6
View File
@@ -48,8 +48,7 @@ type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
class HttpServer: class HttpServer:
""" """HTTP server for Owlbot.
HTTP server for Owlbot.
Manages the aiohttp Application, built-in routes (webhook), Manages the aiohttp Application, built-in routes (webhook),
and module-registered routes via the RouteDispatcher. and module-registered routes via the RouteDispatcher.
@@ -61,8 +60,7 @@ class HttpServer:
event_dispatch: WebhookCallback, event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher, route_dispatcher: RouteDispatcher,
): ):
""" """Initialize the web server.
Initialize the web server.
:param config: Bot configuration. :param config: Bot configuration.
:param event_dispatch: Async callback to dispatch webhook events. :param event_dispatch: Async callback to dispatch webhook events.
@@ -94,8 +92,7 @@ class HttpServer:
logger.debug("HttpServer initialized.") logger.debug("HttpServer initialized.")
async def start(self, host: str, port: int) -> None: async def start(self, host: str, port: int) -> None:
""" """Start the HTTP server.
Start the HTTP server.
:param host: Address to bind to. :param host: Address to bind to.
:param port: Port to bind to. :param port: Port to bind to.
+14 -28
View File
@@ -54,8 +54,7 @@ class ModuleLoader:
config: Config, config: Config,
http_client: HttpClient, http_client: HttpClient,
): ):
""" """Initialize the module loader.
Initialize the module loader.
:param modules_dir: Path to the user modules directory. :param modules_dir: Path to the user modules directory.
:param config: Configuration object for checking module enable/disable state. :param config: Configuration object for checking module enable/disable state.
@@ -105,8 +104,7 @@ class ModuleLoader:
) )
def get_module_context(self, module_name: str) -> ModuleContext | None: def get_module_context(self, module_name: str) -> ModuleContext | None:
""" """Look up a module's context by name.
Look up a module's context by name.
:param module_name: The module name. :param module_name: The module name.
:return: The ModuleContext if the module is loaded, None otherwise. :return: The ModuleContext if the module is loaded, None otherwise.
@@ -128,8 +126,7 @@ class ModuleLoader:
return ctx return ctx
def discover_module_names(self) -> list[str]: def discover_module_names(self) -> list[str]:
""" """Discover all loadable modules from both built-in and user directories.
Discover all loadable modules from both built-in and user directories.
Built-in modules ship with the package. User modules are discovered Built-in modules ship with the package. User modules are discovered
from the configured modules directory. If a user module has the same from the configured modules directory. If a user module has the same
@@ -160,8 +157,7 @@ class ModuleLoader:
return modules return modules
def _discover_user_module_names(self) -> set[str]: def _discover_user_module_names(self) -> set[str]:
""" """Scan the user modules directory for loadable modules.
Scan the user modules directory for loadable modules.
Supports both single-file modules (``name.py``) and package modules Supports both single-file modules (``name.py``) and package modules
(``name/__init__.py``). Files and directories starting with underscore (``name/__init__.py``). Files and directories starting with underscore
@@ -197,8 +193,7 @@ class ModuleLoader:
return found return found
async def load_all_modules(self) -> list[str]: async def load_all_modules(self) -> list[str]:
""" """Discover and load all enabled modules using two-phase loading.
Discover and load all enabled modules using two-phase loading.
**Phase 1:** Import every module, create contexts, and register all **Phase 1:** Import every module, create contexts, and register all
decorated handlers (``@on_event``, ``@on_command``, ``@on_route``). decorated handlers (``@on_event``, ``@on_command``, ``@on_route``).
@@ -255,8 +250,7 @@ class ModuleLoader:
return loaded return loaded
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None: async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
""" """Load a single module by name.
Load a single module by name.
If both a package and single-file form exist for the same name, If both a package and single-file form exist for the same name,
the package form is used. Checks if the module is enabled in the package form is used. Checks if the module is enabled in
@@ -354,8 +348,7 @@ class ModuleLoader:
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
async def unload_module(self, module_name: str) -> bool: async def unload_module(self, module_name: str) -> bool:
""" """Unload a module, calling its teardown and cleaning up all state.
Unload a module, calling its teardown and cleaning up all state.
:param module_name: The module to unload. :param module_name: The module to unload.
:return: True if module was unloaded, False if not found. :return: True if module was unloaded, False if not found.
@@ -397,8 +390,7 @@ class ModuleLoader:
return True return True
async def unload_all_modules(self) -> None: async def unload_all_modules(self) -> None:
""" """Unload all modules, calling teardown and cleaning up all state.
Unload all modules, calling teardown and cleaning up all state.
Called during bot shutdown to allow modules to clean up resources. Called during bot shutdown to allow modules to clean up resources.
""" """
@@ -411,8 +403,7 @@ class ModuleLoader:
logger.info("All module unload complete.") logger.info("All module unload complete.")
async def _run_module_setup(self, module_name: str) -> None: async def _run_module_setup(self, module_name: str) -> None:
""" """Run a module's ``@on_setup`` hooks if any are defined.
Run a module's ``@on_setup`` hooks if any are defined.
All setup handlers run inside a single storage transaction that is All setup handlers run inside a single storage transaction that is
committed on success. If any handler fails, the transaction is committed on success. If any handler fails, the transaction is
@@ -449,8 +440,7 @@ class ModuleLoader:
) from e ) from e
def _resolve_module_path(self, module_name: str) -> Path: def _resolve_module_path(self, module_name: str) -> Path:
""" """Resolve the filesystem path for a module by name.
Resolve the filesystem path for a module by name.
User modules are checked first (package form, then single-file). User modules are checked first (package form, then single-file).
If no user module is found and the name is a built-in, the built-in If no user module is found and the name is a built-in, the built-in
@@ -478,8 +468,7 @@ class ModuleLoader:
@staticmethod @staticmethod
def _resolve_builtin_module_path(module_name: str) -> Path: def _resolve_builtin_module_path(module_name: str) -> Path:
""" """Locate a built-in module's ``__init__.py`` inside the package.
Locate a built-in module's ``__init__.py`` inside the package.
:param module_name: Name of the built-in module. :param module_name: Name of the built-in module.
:return: Path to the module's ``__init__.py``. :return: Path to the module's ``__init__.py``.
@@ -493,8 +482,7 @@ class ModuleLoader:
) )
def _register_module_handlers(self, module: ModuleType, module_name: str) -> None: def _register_module_handlers(self, module: ModuleType, module_name: str) -> None:
""" """Scan a module for decorated handlers and register them.
Scan a module for decorated handlers and register them.
Delegates to each registry's ``register_from_module()`` method, Delegates to each registry's ``register_from_module()`` method,
which knows how to find its own decorator markers. which knows how to find its own decorator markers.
@@ -510,8 +498,7 @@ class ModuleLoader:
def _collect_lifecycle_handlers( def _collect_lifecycle_handlers(
module: ModuleType, marker: str module: ModuleType, marker: str
) -> list[LifecycleHandler]: ) -> list[LifecycleHandler]:
""" """Collect callables from a module that have a given marker attribute.
Collect callables from a module that have a given marker attribute.
Scans ``vars(module)`` for callable objects where ``getattr(obj, marker)`` Scans ``vars(module)`` for callable objects where ``getattr(obj, marker)``
is truthy. Used to find ``@on_setup`` (marker ``"_owlbot_setup"``) and is truthy. Used to find ``@on_setup`` (marker ``"_owlbot_setup"``) and
@@ -528,8 +515,7 @@ class ModuleLoader:
] ]
def _cleanup_module(self, module_name: str) -> None: def _cleanup_module(self, module_name: str) -> None:
""" """Remove all state associated with a module.
Remove all state associated with a module.
Removes the top-level module entry and any submodule entries Removes the top-level module entry and any submodule entries
(for package-style modules) from ``sys.modules``. (for package-style modules) from ``sys.modules``.
+27 -54
View File
@@ -41,16 +41,14 @@ logger = logging.getLogger("owlbot.commands")
class CommandRegistry: class CommandRegistry:
""" """Holds all registered commands for a bot instance.
Holds all registered commands for a bot instance.
Instance-scoped to enable proper dependency injection and allow multiple Instance-scoped to enable proper dependency injection and allow multiple
bot instances to coexist without sharing state. bot instances to coexist without sharing state.
""" """
def __init__(self, prefix: str = "!") -> None: def __init__(self, prefix: str = "!") -> None:
""" """Initialize the command registry.
Initialize the command registry.
:param prefix: Command prefix character (e.g., "!" for "!ping"). :param prefix: Command prefix character (e.g., "!" for "!ping").
""" """
@@ -76,8 +74,7 @@ class CommandRegistry:
cooldown: int | float = 0, cooldown: int | float = 0,
module_name: str, module_name: str,
) -> None: ) -> None:
""" """Register a command handler.
Register a command handler.
:param name: Primary command name (case-insensitive). :param name: Primary command name (case-insensitive).
:param handler: Async function to handle the command. :param handler: Async function to handle the command.
@@ -123,8 +120,7 @@ class CommandRegistry:
) )
def unregister(self, name: str) -> bool: def unregister(self, name: str) -> bool:
""" """Unregister a command and all its aliases.
Unregister a command and all its aliases.
:param name: The primary command name or any alias. :param name: The primary command name or any alias.
:return: True if command was found and removed, False otherwise. :return: True if command was found and removed, False otherwise.
@@ -149,8 +145,7 @@ class CommandRegistry:
return True return True
def get(self, trigger: str) -> CommandInfo | None: def get(self, trigger: str) -> CommandInfo | None:
""" """Look up a command by name or alias.
Look up a command by name or alias.
:param trigger: Command name or alias (case-insensitive). :param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found, None otherwise. :return: CommandInfo if found, None otherwise.
@@ -162,8 +157,7 @@ class CommandRegistry:
return self._commands.get(primary) return self._commands.get(primary)
def exists(self, trigger: str) -> bool: def exists(self, trigger: str) -> bool:
""" """Check if a command is registered.
Check if a command is registered.
:param trigger: Command name or alias (case-insensitive). :param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise. :return: True if the command exists, False otherwise.
@@ -171,16 +165,14 @@ class CommandRegistry:
return trigger.lower() in self._aliases return trigger.lower() in self._aliases
def get_all(self) -> dict[str, CommandInfo]: def get_all(self) -> dict[str, CommandInfo]:
""" """Get all registered commands.
Get all registered commands.
:return: Dict mapping primary command names to CommandInfo. :return: Dict mapping primary command names to CommandInfo.
""" """
return self._commands.copy() return self._commands.copy()
def unregister_by_module(self, module_name: str) -> int: def unregister_by_module(self, module_name: str) -> int:
""" """Remove all commands registered by a specific module.
Remove all commands registered by a specific module.
:param module_name: The module whose commands should be removed. :param module_name: The module whose commands should be removed.
:return: Number of commands removed. :return: Number of commands removed.
@@ -197,8 +189,7 @@ class CommandRegistry:
return len(to_remove) return len(to_remove)
def register_from_module(self, module: ModuleType, module_name: str) -> None: def register_from_module(self, module: ModuleType, module_name: str) -> None:
""" """Scan a Python module for @on_command-decorated functions and register them.
Scan a Python module for @on_command-decorated functions and register them.
Looks for functions with the ``_owlbot_command`` attribute set by Looks for functions with the ``_owlbot_command`` attribute set by
the ``@on_command`` decorator and registers each one. the ``@on_command`` decorator and registers each one.
@@ -222,8 +213,7 @@ class CommandRegistry:
) )
def parse(self, message: str) -> tuple[str, str] | None: def parse(self, message: str) -> tuple[str, str] | None:
""" """Parse a message to extract command and arguments.
Parse a message to extract command and arguments.
:param message: The chat message body. :param message: The chat message body.
:return: Tuple of (command_name, args_string), or None if not a command. :return: Tuple of (command_name, args_string), or None if not a command.
@@ -245,8 +235,7 @@ class CommandRegistry:
class CommandDispatcher: class CommandDispatcher:
""" """Dispatches chat events to registered command handlers.
Dispatches chat events to registered command handlers.
Parses messages, checks authentication/moderator requirements, Parses messages, checks authentication/moderator requirements,
and calls the appropriate command handler. and calls the appropriate command handler.
@@ -260,8 +249,7 @@ class CommandDispatcher:
loaded_modules: set[str], loaded_modules: set[str],
command_prefix: str = "!", command_prefix: str = "!",
) -> None: ) -> None:
""" """Initialize the command dispatcher.
Initialize the command dispatcher.
Creates and owns a :class:`CommandRegistry` internally. Creates and owns a :class:`CommandRegistry` internally.
@@ -296,8 +284,7 @@ class CommandDispatcher:
*, *,
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
) -> None: ) -> None:
""" """Register a built-in command handler.
Register a built-in command handler.
Built-in commands use a simpler handler signature (event, owncast_client) Built-in commands use a simpler handler signature (event, owncast_client)
and don't require module infrastructure. and don't require module infrastructure.
@@ -328,8 +315,7 @@ class CommandDispatcher:
cooldown: int | float = 0, cooldown: int | float = 0,
module_name: str, module_name: str,
) -> None: ) -> None:
""" """Register a command handler.
Register a command handler.
Delegates to the internal CommandRegistry. Delegates to the internal CommandRegistry.
@@ -355,8 +341,7 @@ class CommandDispatcher:
self._cooldown_tracker.pop(name.lower(), None) self._cooldown_tracker.pop(name.lower(), None)
def unregister(self, name: str) -> bool: def unregister(self, name: str) -> bool:
""" """Unregister a command and all its aliases.
Unregister a command and all its aliases.
Delegates to the internal CommandRegistry. Delegates to the internal CommandRegistry.
@@ -371,8 +356,7 @@ class CommandDispatcher:
return result return result
def get(self, trigger: str) -> CommandInfo | None: def get(self, trigger: str) -> CommandInfo | None:
""" """Look up a command by name or alias.
Look up a command by name or alias.
Delegates to the internal CommandRegistry. Delegates to the internal CommandRegistry.
@@ -382,8 +366,7 @@ class CommandDispatcher:
return self._command_registry.get(trigger) return self._command_registry.get(trigger)
def exists(self, trigger: str) -> bool: def exists(self, trigger: str) -> bool:
""" """Check if a command is registered.
Check if a command is registered.
Delegates to the internal CommandRegistry. Delegates to the internal CommandRegistry.
@@ -393,8 +376,7 @@ class CommandDispatcher:
return self._command_registry.exists(trigger) return self._command_registry.exists(trigger)
def get_by_module(self, module_name: str) -> dict[str, CommandInfo]: def get_by_module(self, module_name: str) -> dict[str, CommandInfo]:
""" """Get all commands registered by a specific module.
Get all commands registered by a specific module.
:param module_name: The module whose commands to return. :param module_name: The module whose commands to return.
:return: Dict mapping primary command names to CommandInfo for that module. :return: Dict mapping primary command names to CommandInfo for that module.
@@ -406,8 +388,7 @@ class CommandDispatcher:
} }
def register_from_module(self, module: ModuleType, module_name: str) -> None: def register_from_module(self, module: ModuleType, module_name: str) -> None:
""" """Scan a Python module for @on_command-decorated functions and register them.
Scan a Python module for @on_command-decorated functions and register them.
Delegates to the internal CommandRegistry. Delegates to the internal CommandRegistry.
@@ -417,8 +398,7 @@ class CommandDispatcher:
self._command_registry.register_from_module(module, module_name) self._command_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int: def unregister_by_module(self, module_name: str) -> int:
""" """Remove all commands registered by a specific module.
Remove all commands registered by a specific module.
Delegates to the internal CommandRegistry. Delegates to the internal CommandRegistry.
@@ -432,8 +412,7 @@ class CommandDispatcher:
return self._command_registry.unregister_by_module(module_name) return self._command_registry.unregister_by_module(module_name)
async def dispatch(self, event: ChatEvent) -> None: async def dispatch(self, event: ChatEvent) -> None:
""" """Dispatch a chat event to the appropriate command handler if it's a command.
Dispatch a chat event to the appropriate command handler if it's a command.
:param event: The chat event to check for commands. :param event: The chat event to check for commands.
""" """
@@ -605,8 +584,7 @@ class CommandDispatcher:
class ModuleCommands: class ModuleCommands:
""" """Module-scoped wrapper around CommandDispatcher.
Module-scoped wrapper around CommandDispatcher.
This wrapper auto-supplies the module name for registration operations, This wrapper auto-supplies the module name for registration operations,
so modules don't need to pass their own name back into the API. so modules don't need to pass their own name back into the API.
@@ -614,8 +592,7 @@ class ModuleCommands:
""" """
def __init__(self, dispatcher: CommandDispatcher, module_name: str) -> None: def __init__(self, dispatcher: CommandDispatcher, module_name: str) -> None:
""" """Initialize the module-scoped command wrapper.
Initialize the module-scoped command wrapper.
:param dispatcher: The CommandDispatcher that owns the command registry. :param dispatcher: The CommandDispatcher that owns the command registry.
:param module_name: The name of the module using this wrapper. :param module_name: The name of the module using this wrapper.
@@ -643,8 +620,7 @@ class ModuleCommands:
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int | float = 0,
) -> None: ) -> None:
""" """Register a command handler for this module.
Register a command handler for this module.
The module name is automatically supplied. The module name is automatically supplied.
@@ -667,8 +643,7 @@ class ModuleCommands:
) )
def unregister(self, name: str) -> bool: def unregister(self, name: str) -> bool:
""" """Unregister a command and all its aliases.
Unregister a command and all its aliases.
Only commands registered by this module can be unregistered. Only commands registered by this module can be unregistered.
@@ -682,8 +657,7 @@ class ModuleCommands:
return self._dispatcher.unregister(name) return self._dispatcher.unregister(name)
def get(self, trigger: str) -> CommandInfo | None: def get(self, trigger: str) -> CommandInfo | None:
""" """Look up a command by name or alias within this module's registrations.
Look up a command by name or alias within this module's registrations.
:param trigger: Command name or alias (case-insensitive). :param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found and owned by this module, None otherwise. :return: CommandInfo if found and owned by this module, None otherwise.
@@ -694,8 +668,7 @@ class ModuleCommands:
return info return info
def exists(self, trigger: str) -> bool: def exists(self, trigger: str) -> bool:
""" """Check if a command is registered across all modules.
Check if a command is registered across all modules.
:param trigger: Command name or alias (case-insensitive). :param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise. :return: True if the command exists, False otherwise.
+23 -46
View File
@@ -49,8 +49,7 @@ logger = logging.getLogger("owlbot.events")
class EventRegistry: class EventRegistry:
""" """Holds all registered event handlers for a bot instance.
Holds all registered event handlers for a bot instance.
Instance-scoped to enable proper dependency injection and allow multiple Instance-scoped to enable proper dependency injection and allow multiple
bot instances to coexist without sharing state. bot instances to coexist without sharing state.
@@ -69,8 +68,7 @@ class EventRegistry:
module_name: str, module_name: str,
priority: int = Priority.NORMAL, priority: int = Priority.NORMAL,
) -> None: ) -> None:
""" """Register a handler for the given event types.
Register a handler for the given event types.
Called by the module loader after scanning for decorated functions. Called by the module loader after scanning for decorated functions.
@@ -97,8 +95,7 @@ class EventRegistry:
) )
def unregister(self, handler: EventHandler) -> bool: def unregister(self, handler: EventHandler) -> bool:
""" """Unregister a handler from all event types it is registered for.
Unregister a handler from all event types it is registered for.
:param handler: The handler function to unregister. :param handler: The handler function to unregister.
:return: True if handler was found and removed, False otherwise. :return: True if handler was found and removed, False otherwise.
@@ -126,8 +123,7 @@ class EventRegistry:
return False return False
def get(self, event_type: EventType) -> list[HandlerEntry]: def get(self, event_type: EventType) -> list[HandlerEntry]:
""" """Get all handlers registered for a specific event type.
Get all handlers registered for a specific event type.
:param event_type: The event type to look up. :param event_type: The event type to look up.
:return: List of (handler, module_name, priority) tuples. :return: List of (handler, module_name, priority) tuples.
@@ -135,8 +131,7 @@ class EventRegistry:
return self._handlers.get(event_type.value, []) return self._handlers.get(event_type.value, [])
def get_all(self) -> EventHandlerMap: def get_all(self) -> EventHandlerMap:
""" """Get a copy of the entire handler registry.
Get a copy of the entire handler registry.
:return: Dict mapping event type values to handler lists. :return: Dict mapping event type values to handler lists.
""" """
@@ -145,8 +140,7 @@ class EventRegistry:
} }
def get_handler_module(self, handler: EventHandler) -> str | None: def get_handler_module(self, handler: EventHandler) -> str | None:
""" """Look up which module registered a given handler.
Look up which module registered a given handler.
:param handler: The handler function to look up. :param handler: The handler function to look up.
:return: The module name if found, None otherwise. :return: The module name if found, None otherwise.
@@ -158,8 +152,7 @@ class EventRegistry:
return None return None
def unregister_by_module(self, module_name: str) -> int: def unregister_by_module(self, module_name: str) -> int:
""" """Remove all handlers registered by a specific module.
Remove all handlers registered by a specific module.
:param module_name: The module whose handlers should be removed. :param module_name: The module whose handlers should be removed.
:return: Number of handlers removed. :return: Number of handlers removed.
@@ -181,8 +174,7 @@ class EventRegistry:
return len(seen) return len(seen)
def register_from_module(self, module: ModuleType, module_name: str) -> None: def register_from_module(self, module: ModuleType, module_name: str) -> None:
""" """Scan a Python module for @on_event-decorated functions and register them.
Scan a Python module for @on_event-decorated functions and register them.
Looks for functions with the ``_owlbot_event`` attribute set by Looks for functions with the ``_owlbot_event`` attribute set by
the ``@on_event`` decorator and registers each one. the ``@on_event`` decorator and registers each one.
@@ -201,8 +193,7 @@ class EventRegistry:
class EventDispatcher: class EventDispatcher:
""" """Dispatches events to registered handlers sequentially by priority.
Dispatches events to registered handlers sequentially by priority.
After all event handlers complete, command dispatch is triggered for After all event handlers complete, command dispatch is triggered for
CHAT events (via the injected command_dispatch callback). CHAT events (via the injected command_dispatch callback).
@@ -214,8 +205,7 @@ class EventDispatcher:
get_module_context: Callable[[str], ModuleContext], get_module_context: Callable[[str], ModuleContext],
handler_timeout: float, handler_timeout: float,
) -> None: ) -> None:
""" """Initialize the event dispatcher.
Initialize the event dispatcher.
Creates and owns a :class:`EventRegistry` internally. Creates and owns a :class:`EventRegistry` internally.
@@ -237,8 +227,7 @@ class EventDispatcher:
module_name: str, module_name: str,
priority: int = Priority.NORMAL, priority: int = Priority.NORMAL,
) -> None: ) -> None:
""" """Register a handler for the given event types.
Register a handler for the given event types.
A single handler can respond to multiple event types; the registry A single handler can respond to multiple event types; the registry
stores a separate entry per event type. This is the shared entry stores a separate entry per event type. This is the shared entry
@@ -255,8 +244,7 @@ class EventDispatcher:
self._handler_registry.register(handler, event_types, module_name, priority) self._handler_registry.register(handler, event_types, module_name, priority)
def unregister(self, handler: EventHandler) -> bool: def unregister(self, handler: EventHandler) -> bool:
""" """Unregister a handler from all event types it is registered for.
Unregister a handler from all event types it is registered for.
Unlike commands (looked up by name string), event handlers are Unlike commands (looked up by name string), event handlers are
identified by object identity. A handler registered for multiple identified by object identity. A handler registered for multiple
@@ -268,8 +256,7 @@ class EventDispatcher:
return self._handler_registry.unregister(handler) return self._handler_registry.unregister(handler)
def get_by_module(self, module_name: str) -> EventHandlerMap: def get_by_module(self, module_name: str) -> EventHandlerMap:
""" """Get all handlers registered by a specific module, grouped by event type.
Get all handlers registered by a specific module, grouped by event type.
The registry stores handlers grouped by event type, not by module, The registry stores handlers grouped by event type, not by module,
so this filters across all event types to collect a single module's so this filters across all event types to collect a single module's
@@ -288,8 +275,7 @@ class EventDispatcher:
return result return result
def get_handler_module(self, handler: EventHandler) -> str | None: def get_handler_module(self, handler: EventHandler) -> str | None:
""" """Reverse-lookup which module registered a given handler.
Reverse-lookup which module registered a given handler.
Scans all event types since handlers are stored by event type, Scans all event types since handlers are stored by event type,
not by module. not by module.
@@ -300,8 +286,7 @@ class EventDispatcher:
return self._handler_registry.get_handler_module(handler) return self._handler_registry.get_handler_module(handler)
def register_from_module(self, module: ModuleType, module_name: str) -> None: def register_from_module(self, module: ModuleType, module_name: str) -> None:
""" """Scan a Python module for ``@on_event``-decorated functions and register them.
Scan a Python module for ``@on_event``-decorated functions and register them.
This is the import-phase entry point: the module loader calls it once This is the import-phase entry point: the module loader calls it once
per module. Decorator attributes are read here and passed as explicit per module. Decorator attributes are read here and passed as explicit
@@ -314,8 +299,7 @@ class EventDispatcher:
self._handler_registry.register_from_module(module, module_name) self._handler_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int: def unregister_by_module(self, module_name: str) -> int:
""" """Remove all handlers registered by a specific module.
Remove all handlers registered by a specific module.
Used during module teardown to clean up all of a module's handlers Used during module teardown to clean up all of a module's handlers
in one call, regardless of which event types they were registered for. in one call, regardless of which event types they were registered for.
@@ -326,8 +310,7 @@ class EventDispatcher:
return self._handler_registry.unregister_by_module(module_name) return self._handler_registry.unregister_by_module(module_name)
async def dispatch(self, event_type: EventType, event: Event) -> None: async def dispatch(self, event_type: EventType, event: Event) -> None:
""" """Dispatch an event to handlers sequentially by priority, then to commands.
Dispatch an event to handlers sequentially by priority, then to commands.
:param event_type: The type of event to dispatch. :param event_type: The type of event to dispatch.
:param event: The parsed event instance. :param event: The parsed event instance.
@@ -391,8 +374,7 @@ class EventDispatcher:
module_name: str, module_name: str,
propagation: PropagationState, propagation: PropagationState,
) -> None: ) -> None:
""" """Call a single handler with timeout enforcement and transaction management.
Call a single handler with timeout enforcement and transaction management.
:param handler: The handler function to call. :param handler: The handler function to call.
:param event: The event to pass to the handler. :param event: The event to pass to the handler.
@@ -444,8 +426,7 @@ class EventDispatcher:
class ModuleEvents: class ModuleEvents:
""" """Module-scoped wrapper around EventDispatcher.
Module-scoped wrapper around EventDispatcher.
This wrapper auto-supplies the module name for registration operations, This wrapper auto-supplies the module name for registration operations,
so modules don't need to pass their own name back into the API. so modules don't need to pass their own name back into the API.
@@ -453,8 +434,7 @@ class ModuleEvents:
""" """
def __init__(self, dispatcher: EventDispatcher, module_name: str) -> None: def __init__(self, dispatcher: EventDispatcher, module_name: str) -> None:
""" """Initialize the module-scoped handler wrapper.
Initialize the module-scoped handler wrapper.
:param dispatcher: The EventDispatcher that owns the handler registry. :param dispatcher: The EventDispatcher that owns the handler registry.
:param module_name: The name of the module using this wrapper. :param module_name: The name of the module using this wrapper.
@@ -473,8 +453,7 @@ class ModuleEvents:
event_types: tuple[EventType, ...], event_types: tuple[EventType, ...],
priority: int = Priority.NORMAL, priority: int = Priority.NORMAL,
) -> None: ) -> None:
""" """Register an event handler for this module.
Register an event handler for this module.
The module name is automatically supplied. The module name is automatically supplied.
@@ -491,8 +470,7 @@ class ModuleEvents:
) )
def unregister(self, handler: EventHandler) -> bool: def unregister(self, handler: EventHandler) -> bool:
""" """Unregister a handler from all event types it is registered for.
Unregister a handler from all event types it is registered for.
Only handlers registered by this module can be unregistered. Only handlers registered by this module can be unregistered.
@@ -505,8 +483,7 @@ class ModuleEvents:
return self._dispatcher.unregister(handler) return self._dispatcher.unregister(handler)
def get(self, event_type: EventType) -> list[HandlerEntry]: def get(self, event_type: EventType) -> list[HandlerEntry]:
""" """Get handlers registered by this module for a specific event type.
Get handlers registered by this module for a specific event type.
:param event_type: The event type to look up. :param event_type: The event type to look up.
:return: List of (handler, module_name, priority) tuples for this module only. :return: List of (handler, module_name, priority) tuples for this module only.
+27 -54
View File
@@ -39,8 +39,7 @@ logger = logging.getLogger("owlbot.web")
class RouteRegistry: class RouteRegistry:
""" """Holds all registered routes for a bot instance.
Holds all registered routes for a bot instance.
Routes are namespaced by module to prevent conflicts. Supports path Routes are namespaced by module to prevent conflicts. Supports path
patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via
@@ -64,8 +63,7 @@ class RouteRegistry:
methods: list[str] | None = None, methods: list[str] | None = None,
module_name: str, module_name: str,
) -> RouteInfo: ) -> RouteInfo:
""" """Register a route handler.
Register a route handler.
:param path: URL path relative to module namespace. Supports :param path: URL path relative to module namespace. Supports
``{name}`` and ``{name:regex}`` patterns. ``{name}`` and ``{name:regex}`` patterns.
@@ -108,8 +106,7 @@ class RouteRegistry:
return info return info
def unregister(self, full_path: str) -> bool: def unregister(self, full_path: str) -> bool:
""" """Unregister a route by its full path.
Unregister a route by its full path.
:param full_path: The full route path including namespace. :param full_path: The full route path including namespace.
:return: True if route was found and removed, False otherwise. :return: True if route was found and removed, False otherwise.
@@ -134,8 +131,7 @@ class RouteRegistry:
return False return False
def get(self, full_path: str) -> RouteInfo | None: def get(self, full_path: str) -> RouteInfo | None:
""" """Look up a route by its full path (exact match on registered pattern).
Look up a route by its full path (exact match on registered pattern).
:param full_path: The full route path including namespace. :param full_path: The full route path including namespace.
:return: RouteInfo if found, None otherwise. :return: RouteInfo if found, None otherwise.
@@ -146,8 +142,7 @@ class RouteRegistry:
return None return None
def match(self, full_path: str) -> tuple[RouteInfo, dict[str, str]] | None: def match(self, full_path: str) -> tuple[RouteInfo, dict[str, str]] | None:
""" """Match a request path against registered routes.
Match a request path against registered routes.
Scans routes in registration order (first match wins). Uses Scans routes in registration order (first match wins). Uses
``DynamicResource._match()`` for both plain and parameterized paths. ``DynamicResource._match()`` for both plain and parameterized paths.
@@ -162,16 +157,14 @@ class RouteRegistry:
return None return None
def get_all(self) -> dict[str, RouteInfo]: def get_all(self) -> dict[str, RouteInfo]:
""" """Get all registered routes.
Get all registered routes.
:return: Dict mapping full paths to RouteInfo. :return: Dict mapping full paths to RouteInfo.
""" """
return {info.full_path: info for _, info in self._routes} return {info.full_path: info for _, info in self._routes}
def get_by_module(self, module_name: str) -> list[RouteInfo]: def get_by_module(self, module_name: str) -> list[RouteInfo]:
""" """Get all routes registered by a specific module.
Get all routes registered by a specific module.
:param module_name: The module name. :param module_name: The module name.
:return: List of RouteInfo for that module. :return: List of RouteInfo for that module.
@@ -180,8 +173,7 @@ class RouteRegistry:
return [info for _, info in self._routes if info.full_path in paths] return [info for _, info in self._routes if info.full_path in paths]
def unregister_by_module(self, module_name: str) -> int: def unregister_by_module(self, module_name: str) -> int:
""" """Remove all routes registered by a specific module.
Remove all routes registered by a specific module.
:param module_name: The module whose routes should be removed. :param module_name: The module whose routes should be removed.
:return: Number of routes removed. :return: Number of routes removed.
@@ -202,8 +194,7 @@ class RouteRegistry:
return count return count
def register_from_module(self, module: ModuleType, module_name: str) -> None: def register_from_module(self, module: ModuleType, module_name: str) -> None:
""" """Scan a Python module for @on_route-decorated functions and register them.
Scan a Python module for @on_route-decorated functions and register them.
Looks for functions with the ``_owlbot_route`` attribute set by Looks for functions with the ``_owlbot_route`` attribute set by
the ``@on_route`` decorator and registers each one. the ``@on_route`` decorator and registers each one.
@@ -225,8 +216,7 @@ class RouteRegistry:
class RouteDispatcher: class RouteDispatcher:
""" """Dispatches HTTP requests to registered module route handlers.
Dispatches HTTP requests to registered module route handlers.
Looks up routes in the RouteRegistry, validates methods, creates Looks up routes in the RouteRegistry, validates methods, creates
RouteContext, and calls the handler with timeout and transaction management. RouteContext, and calls the handler with timeout and transaction management.
@@ -237,8 +227,7 @@ class RouteDispatcher:
get_module_context: Callable[[str], ModuleContext], get_module_context: Callable[[str], ModuleContext],
handler_timeout: float, handler_timeout: float,
) -> None: ) -> None:
""" """Initialize the route dispatcher.
Initialize the route dispatcher.
Creates and owns a :class:`RouteRegistry` internally. Creates and owns a :class:`RouteRegistry` internally.
@@ -258,8 +247,7 @@ class RouteDispatcher:
methods: list[str] | None = None, methods: list[str] | None = None,
module_name: str, module_name: str,
) -> RouteInfo: ) -> RouteInfo:
""" """Register a route handler.
Register a route handler.
Delegates to the internal RouteRegistry. Delegates to the internal RouteRegistry.
@@ -278,8 +266,7 @@ class RouteDispatcher:
) )
def unregister(self, full_path: str) -> bool: def unregister(self, full_path: str) -> bool:
""" """Unregister a route by its full path.
Unregister a route by its full path.
Delegates to the internal RouteRegistry. Delegates to the internal RouteRegistry.
@@ -289,8 +276,7 @@ class RouteDispatcher:
return self._route_registry.unregister(full_path) return self._route_registry.unregister(full_path)
def get(self, full_path: str) -> RouteInfo | None: def get(self, full_path: str) -> RouteInfo | None:
""" """Look up a route by its full path.
Look up a route by its full path.
Delegates to the internal RouteRegistry. Delegates to the internal RouteRegistry.
@@ -300,8 +286,7 @@ class RouteDispatcher:
return self._route_registry.get(full_path) return self._route_registry.get(full_path)
def get_by_module(self, module_name: str) -> list[RouteInfo]: def get_by_module(self, module_name: str) -> list[RouteInfo]:
""" """Get all routes registered by a specific module.
Get all routes registered by a specific module.
Delegates to the internal RouteRegistry. Delegates to the internal RouteRegistry.
@@ -311,8 +296,7 @@ class RouteDispatcher:
return self._route_registry.get_by_module(module_name) return self._route_registry.get_by_module(module_name)
def register_from_module(self, module: ModuleType, module_name: str) -> None: def register_from_module(self, module: ModuleType, module_name: str) -> None:
""" """Scan a Python module for @on_route-decorated functions and register them.
Scan a Python module for @on_route-decorated functions and register them.
Delegates to the internal RouteRegistry. Delegates to the internal RouteRegistry.
@@ -322,8 +306,7 @@ class RouteDispatcher:
self._route_registry.register_from_module(module, module_name) self._route_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int: def unregister_by_module(self, module_name: str) -> int:
""" """Remove all routes registered by a specific module.
Remove all routes registered by a specific module.
Delegates to the internal RouteRegistry. Delegates to the internal RouteRegistry.
@@ -333,8 +316,7 @@ class RouteDispatcher:
return self._route_registry.unregister_by_module(module_name) return self._route_registry.unregister_by_module(module_name)
async def dispatch(self, request: web.Request) -> web.StreamResponse: async def dispatch(self, request: web.Request) -> web.StreamResponse:
""" """Dispatch an HTTP request to the appropriate module route handler.
Dispatch an HTTP request to the appropriate module route handler.
Extracts module_name and path from the URL, matches it against Extracts module_name and path from the URL, matches it against
registered routes (supporting path patterns), validates the HTTP registered routes (supporting path patterns), validates the HTTP
@@ -374,8 +356,7 @@ class RouteDispatcher:
route_info: RouteInfo, route_info: RouteInfo,
match_info: dict[str, str] | None = None, match_info: dict[str, str] | None = None,
) -> web.StreamResponse: ) -> web.StreamResponse:
""" """Handle an HTTP request to a module-registered route.
Handle an HTTP request to a module-registered route.
:param request: The aiohttp request object. :param request: The aiohttp request object.
:param route_info: Information about the registered route. :param route_info: Information about the registered route.
@@ -447,8 +428,7 @@ class RouteDispatcher:
class ModuleRoutes: class ModuleRoutes:
""" """Module-scoped wrapper around RouteDispatcher.
Module-scoped wrapper around RouteDispatcher.
This wrapper auto-supplies the module name for route operations, This wrapper auto-supplies the module name for route operations,
so modules don't need to know the internal routing namespace. so modules don't need to know the internal routing namespace.
@@ -458,8 +438,7 @@ class ModuleRoutes:
def __init__( def __init__(
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
) -> None: ) -> None:
""" """Initialize the module-scoped routes wrapper.
Initialize the module-scoped routes wrapper.
:param dispatcher: The RouteDispatcher that owns the route registry. :param dispatcher: The RouteDispatcher that owns the route registry.
:param module_name: The name of the module using this wrapper. :param module_name: The name of the module using this wrapper.
@@ -475,8 +454,7 @@ class ModuleRoutes:
return self._dispatcher.get_by_module(self._module_name) return self._dispatcher.get_by_module(self._module_name)
def url_for(self, path: str) -> str: def url_for(self, path: str) -> str:
""" """Build a public URL for a route registered by this module.
Build a public URL for a route registered by this module.
:param path: The route path (e.g., "/list"). :param path: The route path (e.g., "/list").
:return: Full public URL (e.g., "http://host/owlbot/quotes/list"). :return: Full public URL (e.g., "http://host/owlbot/quotes/list").
@@ -490,8 +468,7 @@ class ModuleRoutes:
*, *,
methods: list[str] | None = None, methods: list[str] | None = None,
) -> RouteInfo: ) -> RouteInfo:
""" """Register a route handler for this module.
Register a route handler for this module.
The module name is automatically supplied. The module name is automatically supplied.
@@ -509,8 +486,7 @@ class ModuleRoutes:
) )
def unregister(self, path: str) -> bool: def unregister(self, path: str) -> bool:
""" """Unregister a route by its relative path.
Unregister a route by its relative path.
:param path: Relative route path (e.g., "/stats"). :param path: Relative route path (e.g., "/stats").
:return: True if route was found and removed, False otherwise. :return: True if route was found and removed, False otherwise.
@@ -518,8 +494,7 @@ class ModuleRoutes:
return self._dispatcher.unregister(self._full_path(path)) return self._dispatcher.unregister(self._full_path(path))
def get(self, path: str) -> RouteInfo | None: def get(self, path: str) -> RouteInfo | None:
""" """Look up a route by its relative path.
Look up a route by its relative path.
:param path: Relative route path (e.g., "/stats"). :param path: Relative route path (e.g., "/stats").
:return: RouteInfo if found, None otherwise. :return: RouteInfo if found, None otherwise.
@@ -527,8 +502,7 @@ class ModuleRoutes:
return self._dispatcher.get(self._full_path(path)) return self._dispatcher.get(self._full_path(path))
def exists(self, path: str) -> bool: def exists(self, path: str) -> bool:
""" """Check if a route is registered at the given relative path.
Check if a route is registered at the given relative path.
:param path: Relative route path (e.g., "/stats"). :param path: Relative route path (e.g., "/stats").
:return: True if the route exists, False otherwise. :return: True if the route exists, False otherwise.
@@ -536,8 +510,7 @@ class ModuleRoutes:
return self.get(path) is not None return self.get(path) is not None
def _full_path(self, path: str) -> str: def _full_path(self, path: str) -> str:
""" """Normalize a relative path into the full namespaced path.
Normalize a relative path into the full namespaced path.
:param path: Relative route path (e.g., "/list" or "list"). :param path: Relative route path (e.g., "/list" or "list").
:return: Full path (e.g., "/owlbot/quotes/list"). :return: Full path (e.g., "/owlbot/quotes/list").
+5
View File
@@ -77,6 +77,11 @@ select = [
"SIM", # flake8-simplify "SIM", # flake8-simplify
"TCH", # flake8-type-checking "TCH", # flake8-type-checking
"RUF", # Ruff-specific rules "RUF", # Ruff-specific rules
"D", # pydocstyle
]
ignore = [
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
+3
View File
@@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Shared test fixtures for the Owlbot test suite."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -27,5 +29,6 @@ if TYPE_CHECKING:
@pytest.fixture @pytest.fixture
async def storage(tmp_path: Path) -> AsyncIterator[ModuleStorage]: async def storage(tmp_path: Path) -> AsyncIterator[ModuleStorage]:
"""Yield an open ModuleStorage backed by a temporary directory."""
async with ModuleStorage(tmp_path, "test_module") as s: async with ModuleStorage(tmp_path, "test_module") as s:
yield s yield s