Enabled all Ruff lint rules and resolved findings with justified inline suppressions.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-04-13 15:31:06 -04:00
parent b68c717845
commit 0ff3c7a6b4
44 changed files with 452 additions and 430 deletions
+23 -21
View File
@@ -177,25 +177,27 @@ Examples:
args = parser.parse_args() args = parser.parse_args()
overrides: dict[str, Any] = {} _cli_override_map = {
if args.host is not None: "host": "host",
overrides["host"] = args.host "port": "port",
if args.port is not None: "modules": "modules_dir",
overrides["port"] = args.port "storage_dir": "storage_dir",
if args.modules is not None: "log_dir": "log_dir",
overrides["modules_dir"] = args.modules }
if args.storage_dir is not None: overrides: dict[str, Any] = {
overrides["storage_dir"] = args.storage_dir v: getattr(args, k)
if args.log_dir is not None: for k, v in _cli_override_map.items()
overrides["log_dir"] = args.log_dir if getattr(args, k) is not None
}
if args.webhook_path: if args.webhook_path:
try: try:
config = Config(args.config, overrides=overrides) config = Config(args.config, overrides=overrides)
except Exception as e: except Exception as e: # noqa: BLE001 # CLI boundary; any config error should print and exit
print(f"Error loading config: {e}", file=sys.stderr) print(f"Error loading config: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
if config._data.get("owlbot", {}).get("public_base_url"): # Framework-internal raw config check; private to module authors.
if config._data.get("owlbot", {}).get("public_base_url"): # noqa: SLF001
base = config.public_base_url base = config.public_base_url
else: else:
base = f"http://{config.host}:{config.port}" base = f"http://{config.host}:{config.port}"
@@ -225,7 +227,7 @@ Examples:
logger = logging.getLogger("owlbot") logger = logging.getLogger("owlbot")
logger.info(f"Log level: {logging.getLevelName(log_level)}") logger.info("Log level: %s", logging.getLevelName(log_level))
try: try:
bot = Owlbot( bot = Owlbot(
@@ -233,8 +235,8 @@ Examples:
overrides=overrides, overrides=overrides,
skip_api_check=args.skip_api_check, skip_api_check=args.skip_api_check,
) )
except StartupError as e: except StartupError:
logger.error(str(e)) logger.exception("Bot initialization failed.")
sys.exit(1) sys.exit(1)
if bot.config.log_dir is not None: if bot.config.log_dir is not None:
@@ -249,9 +251,9 @@ Examples:
logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S"), logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S"),
) )
logging.getLogger().addHandler(file_handler) logging.getLogger().addHandler(file_handler)
logger.info(f"File logging enabled: {bot.config.log_dir / 'owlbot.log'}") logger.info("File logging enabled: %s", bot.config.log_dir / "owlbot.log")
except OSError as e: except OSError as e:
logger.warning(f"Could not set up file logging: {e}") logger.warning("Could not set up file logging: %s", e)
async def _run() -> None: async def _run() -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -267,7 +269,7 @@ Examples:
wd_task: asyncio.Task[None] | None = None wd_task: asyncio.Task[None] | None = None
if wd_interval is not None: if wd_interval is not None:
logger.info( logger.info(
f"systemd watchdog enabled (pinging every {wd_interval:.1f}s)." "systemd watchdog enabled (pinging every %.1fs).", wd_interval
) )
wd_task = asyncio.create_task( wd_task = asyncio.create_task(
_watchdog_loop(wd_interval, stop_event), _watchdog_loop(wd_interval, stop_event),
@@ -285,8 +287,8 @@ Examples:
try: try:
uvloop.run(_run()) uvloop.run(_run())
except StartupError as e: except StartupError:
logger.error(str(e)) logger.exception("Startup failed.")
sys.exit(1) sys.exit(1)
except Exception: except Exception:
logger.exception("Unexpected error during Owlbot execution.") logger.exception("Unexpected error during Owlbot execution.")
+3 -3
View File
@@ -33,9 +33,9 @@ This package provides all the APIs modules use to interact with Owlbot:
# Event types and dataclasses (pure data). # Event types and dataclasses (pure data).
# Module-scoped service wrappers (re-exported from registries). # Module-scoped service wrappers (re-exported from registries).
from ..registries.commands import ModuleCommands from owlbot.registries.commands import ModuleCommands
from ..registries.events import ModuleEvents from owlbot.registries.events import ModuleEvents
from ..registries.routes import ModuleRoutes from owlbot.registries.routes import ModuleRoutes
# Command system (module-facing). # Command system (module-facing).
from .commands import ( from .commands import (
+5 -4
View File
@@ -40,7 +40,7 @@ class CommandMark(TypedDict):
aliases: list[str] | tuple[str, ...] | None aliases: list[str] | tuple[str, ...] | None
requires_authenticated: bool requires_authenticated: bool
requires_moderator: bool requires_moderator: bool
cooldown: int | float cooldown: int
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -67,7 +67,7 @@ class CommandInfo:
aliases: frozenset[str] = field(default_factory=frozenset) aliases: frozenset[str] = field(default_factory=frozenset)
requires_authenticated: bool = False requires_authenticated: bool = False
requires_moderator: bool = False requires_moderator: bool = False
cooldown: int | float = 0 cooldown: int = 0
all_triggers: frozenset[str] = field(init=False, repr=False) all_triggers: frozenset[str] = field(init=False, repr=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -81,7 +81,7 @@ def on_command(
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False, requires_authenticated: bool = False,
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int = 0,
) -> Callable[[CommandHandler], CommandHandler]: ) -> Callable[[CommandHandler], CommandHandler]:
"""Register a command handler. """Register a command handler.
@@ -97,7 +97,8 @@ def on_command(
def decorator(func: CommandHandler) -> CommandHandler: def decorator(func: CommandHandler) -> CommandHandler:
# Mark the function with command info for deferred registration. # Mark the function with command info for deferred registration.
# The module loader will scan for this attribute and register commands. # The module loader will scan for this attribute and register commands.
func._owlbot_command = CommandMark( # type: ignore[attr-defined] # Framework decorator marker; private to module authors.
func._owlbot_command = CommandMark( # type: ignore[attr-defined] # noqa: SLF001
name=name, name=name,
aliases=aliases, aliases=aliases,
requires_authenticated=requires_authenticated, requires_authenticated=requires_authenticated,
+12 -12
View File
@@ -63,7 +63,7 @@ class Config:
self, self,
config_path: str | Path = "config.yaml", config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None, overrides: dict[str, Any] | None = 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.
@@ -340,13 +340,13 @@ class Config:
try: try:
with self.config_path.open() as f: with self.config_path.open() as f:
self._data = _yaml.load(f) or {} self._data = _yaml.load(f) or {}
except YAMLError as e: except YAMLError:
logger.error(f"Failed to parse config file: {e}") logger.exception("Failed to parse config file.")
raise raise
except OSError as e: except OSError:
logger.error(f"Failed to read config file: {e}") logger.exception("Failed to read config file.")
raise raise
logger.info(f"Configuration loaded from: {self.config_path.absolute()}") logger.info("Configuration loaded from: %s", self.config_path.absolute())
else: else:
raise FileNotFoundError(f"Config file not found: {self.config_path}") raise FileNotFoundError(f"Config file not found: {self.config_path}")
@@ -403,13 +403,13 @@ class Config:
try: try:
with self.config_path.open("w") as f: with self.config_path.open("w") as f:
_yaml.dump(self._data, f) _yaml.dump(self._data, f)
except YAMLError as e: except YAMLError:
logger.error(f"Failed to serialize config data: {e}") logger.exception("Failed to serialize config data.")
raise raise
except OSError as e: except OSError:
logger.error(f"Failed to write config file: {e}") logger.exception("Failed to write config file.")
raise raise
logger.info(f"Configuration saved to: {self.config_path.absolute()}") logger.info("Configuration saved to: %s", 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.
@@ -505,7 +505,7 @@ class Config:
class ModuleConfig: 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) -> None:
"""Initialize a module-scoped configuration. """Initialize a module-scoped configuration.
:param config: The parent Config object. :param config: The parent Config object.
+4 -3
View File
@@ -30,9 +30,10 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from aiohttp import web from aiohttp import web
from ..registries.commands import ModuleCommands from owlbot.registries.commands import ModuleCommands
from ..registries.events import ModuleEvents from owlbot.registries.events import ModuleEvents
from ..registries.routes import ModuleRoutes from owlbot.registries.routes import ModuleRoutes
from .commands import CommandEvent from .commands import CommandEvent
from .config import ModuleConfig from .config import ModuleConfig
from .event_types import ChatEvent, User from .event_types import ChatEvent, User
+11 -9
View File
@@ -72,7 +72,7 @@ def _parse_timestamp(ts: str | None) -> datetime | None:
return datetime.fromisoformat(ts) return datetime.fromisoformat(ts)
except ValueError: except ValueError:
# Malformed timestamp. Return None rather than crashing. # Malformed timestamp. Return None rather than crashing.
logger.warning(f"Owncast API returned timestamp in unexpected format: {ts!r}") logger.warning("Owncast API returned timestamp in unexpected format: %r", ts)
return None return None
@@ -438,22 +438,24 @@ def log_event(event_type: EventType, event: Event) -> None:
""" """
match event: match event:
case ChatEvent(user=user, message_id=mid, body=body): case ChatEvent(user=user, message_id=mid, body=body):
logger.info(f"[{event_type}] {user.display_name} ({mid}): {body}") logger.info("[%s] %s (%s): %s", event_type, user.display_name, mid, body)
case UserJoinedEvent(user=user): case UserJoinedEvent(user=user):
logger.info(f"[{event_type}] {user.display_name} joined.") logger.info("[%s] %s joined.", event_type, user.display_name)
case UserPartedEvent(user=user): case UserPartedEvent(user=user):
logger.info(f"[{event_type}] {user.display_name} parted.") logger.info("[%s] %s parted.", event_type, user.display_name)
case NameChangedEvent(user=user, new_name=new_name): case NameChangedEvent(user=user, new_name=new_name):
logger.info( logger.info(
f"[{event_type}] {user.display_name} changed name to {new_name}." "[%s] %s changed name to %s.", event_type, user.display_name, new_name
) )
case StreamStartedEvent(stream_title=title): case StreamStartedEvent(stream_title=title):
logger.info(f'[{event_type}] Stream started: "{title}"') logger.info('[%s] Stream started: "%s"', event_type, title)
case StreamStoppedEvent(): case StreamStoppedEvent():
logger.info(f"[{event_type}] Stream ended.") logger.info("[%s] Stream ended.", event_type)
case StreamTitleUpdatedEvent(stream_title=title): case StreamTitleUpdatedEvent(stream_title=title):
logger.info(f'[{event_type}] Title changed to "{title}"') logger.info('[%s] Title changed to "%s"', event_type, title)
case VisibilityUpdateEvent(is_visible=visible, message_ids=ids): case VisibilityUpdateEvent(is_visible=visible, message_ids=ids):
action = "shown" if visible else "hidden" action = "shown" if visible else "hidden"
id_list = ", ".join(ids) id_list = ", ".join(ids)
logger.info(f"[{event_type}] {len(ids)} message(s) {action}: {id_list}") logger.info(
"[%s] %d message(s) %s: %s", event_type, len(ids), action, id_list
)
+2 -1
View File
@@ -75,7 +75,8 @@ def on_event(
def decorator(func: EventHandler) -> EventHandler: def decorator(func: EventHandler) -> EventHandler:
# Mark the function with event info for deferred registration. # Mark the function with event info for deferred registration.
# The module loader will scan for this attribute and register handlers. # The module loader will scan for this attribute and register handlers.
func._owlbot_event = EventMark( # type: ignore[attr-defined] # Framework decorator marker; private to module authors.
func._owlbot_event = EventMark( # type: ignore[attr-defined] # noqa: SLF001
event_types=event_types, priority=priority event_types=event_types, priority=priority
) )
return func return func
+3 -8
View File
@@ -25,6 +25,8 @@ from typing import Any
import aiohttp import aiohttp
import orjson import orjson
from owlbot._version import __version__
logger = logging.getLogger("owlbot.http") logger = logging.getLogger("owlbot.http")
@@ -56,14 +58,7 @@ class HttpClient:
return self._session return self._session
async def _start(self) -> None: async def _start(self) -> None:
"""Create the underlying aiohttp session. """Create the underlying aiohttp session."""
The User-Agent header is derived from the package version at
call time so the import is deferred until the bot is actually
starting up.
"""
from .. import __version__
connector = aiohttp.TCPConnector(keepalive_timeout=120) connector = aiohttp.TCPConnector(keepalive_timeout=120)
timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10) timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10)
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
+4 -2
View File
@@ -46,7 +46,8 @@ def on_setup(func: LifecycleHandler) -> LifecycleHandler:
:param func: The setup function to mark. :param func: The setup function to mark.
:return: The same function, with ``_owlbot_setup`` attribute set. :return: The same function, with ``_owlbot_setup`` attribute set.
""" """
func._owlbot_setup = True # type: ignore[attr-defined] # Framework decorator marker; private to module authors.
func._owlbot_setup = True # type: ignore[attr-defined] # noqa: SLF001
return func return func
@@ -65,5 +66,6 @@ def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
:param func: The teardown function to mark. :param func: The teardown function to mark.
:return: The same function, with ``_owlbot_teardown`` attribute set. :return: The same function, with ``_owlbot_teardown`` attribute set.
""" """
func._owlbot_teardown = True # type: ignore[attr-defined] # Framework decorator marker; private to module authors.
func._owlbot_teardown = True # type: ignore[attr-defined] # noqa: SLF001
return func return func
+37 -34
View File
@@ -185,7 +185,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
): ) -> None:
"""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").
@@ -198,7 +198,7 @@ class OwncastAdminClient(OwncastClient):
self._headers = None self._headers = None
self._auth = aiohttp.BasicAuth(username, password) self._auth = aiohttp.BasicAuth(username, password)
self._logger.debug( self._logger.debug(
f"Owncast admin API client initialized for: {self._base_url}" "Owncast admin API client initialized for: %s", self._base_url
) )
async def get_status(self) -> dict[str, Any]: async def get_status(self) -> dict[str, Any]:
@@ -265,7 +265,7 @@ class OwncastAdminClient(OwncastClient):
return list(await self._get("/api/admin/chat/clients")) return list(await self._get("/api/admin/chat/clients"))
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.
@@ -275,13 +275,13 @@ class OwncastAdminClient(OwncastClient):
""" """
action = "Showing" if visible else "Hiding" action = "Showing" if visible else "Hiding"
ids = ", ".join(message_ids) ids = ", ".join(message_ids)
self._logger.info(f"{action} {len(message_ids)} message(s): {ids}") self._logger.info("%s %d message(s): %s", action, len(message_ids), ids)
return await self._post( return await self._post(
"/api/admin/chat/messagevisibility", "/api/admin/chat/messagevisibility",
{"idArray": message_ids, "visible": visible}, {"idArray": message_ids, "visible": visible},
) )
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.
@@ -289,7 +289,7 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server. :return: Success message from the server.
""" """
action = "Enabling" if enabled else "Disabling" action = "Enabling" if enabled else "Disabling"
self._logger.info(f"{action} user {user_id}.") self._logger.info("%s user %s.", action, user_id)
return await self._post( return await self._post(
"/api/admin/chat/users/setenabled", "/api/admin/chat/users/setenabled",
{"userId": user_id, "enabled": enabled}, {"userId": user_id, "enabled": enabled},
@@ -302,7 +302,7 @@ class OwncastAdminClient(OwncastClient):
""" """
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.
@@ -310,7 +310,7 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server. :return: Success message from the server.
""" """
action = "Granting" if is_mod else "Revoking" action = "Granting" if is_mod else "Revoking"
self._logger.info(f"{action} moderator status for user {user_id}.") self._logger.info("%s moderator status for user %s.", action, user_id)
return await self._post( return await self._post(
"/api/admin/chat/users/setmoderator", "/api/admin/chat/users/setmoderator",
{"userId": user_id, "isModerator": is_mod}, {"userId": user_id, "isModerator": is_mod},
@@ -329,7 +329,7 @@ class OwncastAdminClient(OwncastClient):
: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.
""" """
self._logger.info(f"Banning IP address: {ip}") self._logger.info("Banning IP address: %s", ip)
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:
@@ -338,7 +338,7 @@ class OwncastAdminClient(OwncastClient):
: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.
""" """
self._logger.info(f"Unbanning IP address: {ip}") self._logger.info("Unbanning IP address: %s", ip)
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]]:
@@ -488,7 +488,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/video/codec", codec, "video codec" "/api/admin/config/video/codec", codec, "video codec"
) )
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.
@@ -498,7 +498,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/chat/disable", disabled, "chat disabled" "/api/admin/config/chat/disable", disabled, "chat disabled"
) )
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.
@@ -508,7 +508,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/chat/joinmessagesenabled", enabled, "chat join messages" "/api/admin/config/chat/joinmessagesenabled", enabled, "chat join messages"
) )
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.
@@ -520,7 +520,7 @@ class OwncastAdminClient(OwncastClient):
"chat established mode", "chat established mode",
) )
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.
@@ -532,7 +532,7 @@ class OwncastAdminClient(OwncastClient):
"chat spam protection", "chat spam protection",
) )
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.
@@ -542,7 +542,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/chat/slurfilterenabled", enabled, "chat slur filter" "/api/admin/config/chat/slurfilterenabled", enabled, "chat slur filter"
) )
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.
@@ -550,7 +550,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.
@@ -560,7 +560,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/directoryenabled", enabled, "directory enabled" "/api/admin/config/directoryenabled", enabled, "directory enabled"
) )
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.
@@ -570,7 +570,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/hideviewercount", hide, "hide viewer count" "/api/admin/config/hideviewercount", hide, "hide viewer count"
) )
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.
@@ -630,7 +630,7 @@ class OwncastAdminClient(OwncastClient):
""" """
return await self._set_config_value( return await self._set_config_value(
"/api/admin/config/streamkeys", "/api/admin/config/streamkeys",
[k._to_dict() for k in keys], [k._to_dict() for k in keys], # noqa: SLF001 # framework serialization; private to module authors
"stream keys", "stream keys",
) )
@@ -642,7 +642,7 @@ class OwncastAdminClient(OwncastClient):
""" """
return await self._set_config_value( return await self._set_config_value(
"/api/admin/config/video/streamoutputvariants", "/api/admin/config/video/streamoutputvariants",
[v._to_dict() for v in variants], [v._to_dict() for v in variants], # noqa: SLF001 # framework serialization; private to module authors
"video variants", "video variants",
) )
@@ -654,7 +654,7 @@ class OwncastAdminClient(OwncastClient):
""" """
return await self._set_config_value( return await self._set_config_value(
"/api/admin/config/socialhandles", "/api/admin/config/socialhandles",
[h._to_dict() for h in handles], [h._to_dict() for h in handles], # noqa: SLF001 # framework serialization; private to module authors
"social handles", "social handles",
) )
@@ -666,12 +666,13 @@ class OwncastAdminClient(OwncastClient):
""" """
return await self._set_config_value( return await self._set_config_value(
"/api/admin/config/externalactions", "/api/admin/config/externalactions",
[a._to_dict() for a in actions], [a._to_dict() for a in actions], # noqa: SLF001 # framework serialization; private to module authors
"external actions", "external actions",
) )
async def set_s3_config( async def set_s3_config(
self, self,
*,
enabled: bool, enabled: bool,
endpoint: str, endpoint: str,
access_key: str, access_key: str,
@@ -704,6 +705,7 @@ class OwncastAdminClient(OwncastClient):
async def set_discord_notifications( async def set_discord_notifications(
self, self,
*,
enabled: bool, enabled: bool,
webhook: str, webhook: str,
go_live_message: str, go_live_message: str,
@@ -727,6 +729,7 @@ class OwncastAdminClient(OwncastClient):
async def set_browser_notifications( async def set_browser_notifications(
self, self,
*,
enabled: bool, enabled: bool,
go_live_message: str, go_live_message: str,
) -> str: ) -> str:
@@ -795,7 +798,7 @@ class OwncastAdminClient(OwncastClient):
"/api/admin/config/webserverip", ip, "web server IP" "/api/admin/config/webserverip", ip, "web server IP"
) )
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.
@@ -845,7 +848,7 @@ class OwncastAdminClient(OwncastClient):
:param data_base64: Base64-encoded image data for the emoji. :param data_base64: Base64-encoded image data for the emoji.
:return: Success message from the server. :return: Success message from the server.
""" """
self._logger.info(f"Uploading emoji: {name}") self._logger.info("Uploading emoji: %s", name)
return await self._post( return await self._post(
"/api/admin/emoji/upload", {"name": name, "data": data_base64} "/api/admin/emoji/upload", {"name": name, "data": data_base64}
) )
@@ -856,7 +859,7 @@ class OwncastAdminClient(OwncastClient):
: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.
""" """
self._logger.info(f"Deleting emoji: {name}") self._logger.info("Deleting emoji: %s", name)
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]]:
@@ -873,7 +876,7 @@ class OwncastAdminClient(OwncastClient):
:param events: List of event type strings to subscribe to. :param events: List of event type strings to subscribe to.
:return: Success message from the server. :return: Success message from the server.
""" """
self._logger.info(f"Creating webhook for {url} with events: {events}") self._logger.info("Creating webhook for %s with events: %s", url, events)
return await self._post( return await self._post(
"/api/admin/webhooks/create", {"url": url, "events": events} "/api/admin/webhooks/create", {"url": url, "events": events}
) )
@@ -884,7 +887,7 @@ class OwncastAdminClient(OwncastClient):
: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.
""" """
self._logger.info(f"Deleting webhook: {webhook_id}") self._logger.info("Deleting webhook: %d", webhook_id)
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]]:
@@ -901,7 +904,7 @@ class OwncastAdminClient(OwncastClient):
:param scopes: List of permission scope strings. :param scopes: List of permission scope strings.
:return: Success message from the server. :return: Success message from the server.
""" """
self._logger.info(f"Creating access token: {name}") self._logger.info("Creating access token: %s", name)
return await self._post( return await self._post(
"/api/admin/accesstokens/create", {"name": name, "scopes": scopes} "/api/admin/accesstokens/create", {"name": name, "scopes": scopes}
) )
@@ -912,7 +915,7 @@ class OwncastAdminClient(OwncastClient):
: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.
""" """
self._logger.info(f"Deleting access token ending in '...{token[-4:]}'.") self._logger.info("Deleting access token ending in '...%s'.", token[-4:])
return await self._post("/api/admin/accesstokens/delete", {"token": token}) return await self._post("/api/admin/accesstokens/delete", {"token": token})
@@ -943,7 +946,7 @@ class OwncastAdminClient(OwncastClient):
""" """
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.
@@ -951,7 +954,7 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server. :return: Success message from the server.
""" """
action = "Approving" if approved else "Rejecting" action = "Approving" if approved else "Rejecting"
self._logger.info(f"{action} follower {actor_iri}.") self._logger.info("%s follower %s.", action, actor_iri)
return await self._post( return await self._post(
"/api/admin/followers/approve", "/api/admin/followers/approve",
{"actorIRI": actor_iri, "approved": approved}, {"actorIRI": actor_iri, "approved": approved},
@@ -963,7 +966,7 @@ class OwncastAdminClient(OwncastClient):
: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.
""" """
self._logger.info(f"Sending federated message: {message}") self._logger.info("Sending federated message: %s", message)
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]]:
@@ -998,5 +1001,5 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server. :return: Success message from the server.
:raises OwncastError: If the request fails. :raises OwncastError: If the request fails.
""" """
self._logger.info(f"Setting config: {description}") self._logger.info("Setting config: %s", description)
return await self._post(endpoint, {"value": value}) return await self._post(endpoint, {"value": value})
+28 -21
View File
@@ -17,6 +17,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from http import HTTPStatus
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import aiohttp import aiohttp
@@ -52,7 +53,7 @@ def _extract_error(text: str) -> str:
class OwncastError(Exception): 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) -> None:
"""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
@@ -71,7 +72,9 @@ class OwncastClient:
transport, with per-request Bearer token authentication. transport, with per-request Bearer token authentication.
""" """
def __init__(self, base_url: str, access_token: str, http_client: HttpClient): def __init__(
self, base_url: str, access_token: str, http_client: HttpClient
) -> None:
"""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").
@@ -118,7 +121,7 @@ class OwncastClient:
""" """
if not unsanitized: if not unsanitized:
body = str(_escape_html(body)) body = str(_escape_html(body))
self._logger.info(f"Sending chat message: {body}") self._logger.info("Sending chat message: %s", body)
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, *, unsanitized: bool = False) -> str: async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
@@ -136,7 +139,7 @@ class OwncastClient:
""" """
if not unsanitized: if not unsanitized:
body = str(_escape_html(body)) body = str(_escape_html(body))
self._logger.info(f"Sending system message: {body}") self._logger.info("Sending system message: %s", body)
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, *, unsanitized: bool = False) -> str: async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
@@ -154,7 +157,7 @@ class OwncastClient:
""" """
if not unsanitized: if not unsanitized:
body = str(_escape_html(body)) body = str(_escape_html(body))
self._logger.info(f"Sending action: {body}") self._logger.info("Sending action: %s", body)
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( async def send_system_message_to_client(
@@ -175,13 +178,13 @@ class OwncastClient:
""" """
if not unsanitized: if not unsanitized:
body = str(_escape_html(body)) body = str(_escape_html(body))
self._logger.info(f"Sending system message to client {client_id}: {body}") self._logger.info("Sending system message to client %d: %s", client_id, body)
return await self._post( return await self._post(
f"/api/integrations/chat/system/client/{client_id}", {"body": body} f"/api/integrations/chat/system/client/{client_id}", {"body": body}
) )
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).
@@ -194,7 +197,7 @@ class OwncastClient:
""" """
action = "Showing" if visible else "Hiding" action = "Showing" if visible else "Hiding"
ids = ", ".join(message_ids) ids = ", ".join(message_ids)
self._logger.info(f"{action} {len(message_ids)} message(s): {ids}") self._logger.info("%s %d message(s): %s", action, len(message_ids), ids)
return await self._post( return await self._post(
"/api/integrations/chat/messagevisibility", "/api/integrations/chat/messagevisibility",
{"idArray": message_ids, "visible": visible}, {"idArray": message_ids, "visible": visible},
@@ -222,7 +225,7 @@ class OwncastClient:
:param title: The new stream title. :param title: The new stream title.
:return: Success message from the server. :return: Success message from the server.
""" """
self._logger.info(f"Setting stream title: {title}") self._logger.info("Setting stream title: %s", title)
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:
@@ -246,11 +249,11 @@ class OwncastClient:
auth=self._auth, auth=self._auth,
allow_redirects=False, allow_redirects=False,
) as response: ) as response:
if response.status >= 400: if response.status >= HTTPStatus.BAD_REQUEST:
text = await response.text() text = await response.text()
message = _extract_error(text) message = _extract_error(text)
self._logger.error( self._logger.error(
f"Error {response.status} on POST {endpoint}: {message}" "Error %d on POST %s: %s", response.status, endpoint, message
) )
raise OwncastError(response.status, message) raise OwncastError(response.status, message)
self._logger.debug("POST %s -> %d", endpoint, response.status) self._logger.debug("POST %s -> %d", endpoint, response.status)
@@ -259,32 +262,36 @@ class OwncastClient:
result = await response.json(loads=orjson.loads) result = await response.json(loads=orjson.loads)
except (ValueError, ContentTypeError): except (ValueError, ContentTypeError):
text = await response.text() text = await response.text()
self._logger.error(f"Invalid JSON on POST {endpoint}: {text}") self._logger.exception(
"Invalid JSON on POST %s: %s", endpoint, text
)
raise OwncastError(response.status, text) from None raise OwncastError(response.status, text) from None
if isinstance(result, dict): if isinstance(result, dict):
# Is there an error field? Owncast returns # Is there an error field? Owncast returns
# {"error": "..."} for internal errors. # {"error": "..."} for internal errors.
if "error" in result: if "error" in result:
self._logger.error( self._logger.error(
f"Error on POST {endpoint}: {result['error']}" "Error on POST %s: %s", endpoint, result["error"]
) )
raise OwncastError(response.status, result["error"]) raise OwncastError(response.status, result["error"])
# Does the success flag indicate failure? # Does the success flag indicate failure?
if "success" in result and not result["success"]: if "success" in result and not result["success"]:
message = result.get("message", "unknown error") message = result.get("message", "unknown error")
self._logger.error(f"Error on POST {endpoint}: {message}") self._logger.error(
"Error on POST %s: %s", endpoint, message
)
raise OwncastError(response.status, message) raise OwncastError(response.status, message)
# Is this a simple success response? Extract the message. # Is this a simple success response? Extract the message.
if "success" in result: if "success" in result:
return str(result.get("message", "")) return str(result.get("message", ""))
# Unknown response shape from Owncast. # Unknown response shape from Owncast.
self._logger.warning( self._logger.warning(
f"Unknown response on POST {endpoint}: {result}" "Unknown response on POST %s: %s", endpoint, result
) )
return "" return ""
return "" return ""
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
self._logger.error(f"Connection error on POST {endpoint}: {e}") self._logger.exception("Connection error on POST %s.", endpoint)
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:
@@ -305,11 +312,11 @@ class OwncastClient:
auth=self._auth, auth=self._auth,
allow_redirects=False, allow_redirects=False,
) as response: ) as response:
if response.status >= 400: if response.status >= HTTPStatus.BAD_REQUEST:
text = await response.text() text = await response.text()
message = _extract_error(text) message = _extract_error(text)
self._logger.error( self._logger.error(
f"Error {response.status} on GET {endpoint}: {message}" "Error %d on GET %s: %s", response.status, endpoint, message
) )
raise OwncastError(response.status, message) raise OwncastError(response.status, message)
self._logger.debug("GET %s -> %d", endpoint, response.status) self._logger.debug("GET %s -> %d", endpoint, response.status)
@@ -317,14 +324,14 @@ class OwncastClient:
result = await response.json(loads=orjson.loads) result = await response.json(loads=orjson.loads)
except (ValueError, ContentTypeError): except (ValueError, ContentTypeError):
text = await response.text() text = await response.text()
self._logger.error(f"Invalid JSON on GET {endpoint}: {text}") self._logger.exception("Invalid JSON on GET %s: %s", endpoint, text)
raise OwncastError(response.status, text) from None raise OwncastError(response.status, text) from None
# Is there an error field? Owncast returns # Is there an error field? Owncast returns
# {"error": "..."} for internal errors. # {"error": "..."} for internal errors.
if isinstance(result, dict) and "error" in result: if isinstance(result, dict) and "error" in result:
self._logger.error(f"Error on GET {endpoint}: {result['error']}") self._logger.error("Error on GET %s: %s", endpoint, result["error"])
raise OwncastError(response.status, result["error"]) raise OwncastError(response.status, result["error"])
return result return result
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
self._logger.error(f"Connection error on GET {endpoint}: {e}") self._logger.exception("Connection error on GET %s.", endpoint)
raise OwncastError(0, str(e)) from e raise OwncastError(0, str(e)) from e
+2 -1
View File
@@ -79,7 +79,8 @@ def on_route(
def decorator(func: RouteHandler) -> RouteHandler: def decorator(func: RouteHandler) -> RouteHandler:
# Mark the function with route info for deferred registration. # Mark the function with route info for deferred registration.
# methods=None is resolved to ["GET"] by RouteRegistry.register(). # methods=None is resolved to ["GET"] by RouteRegistry.register().
func._owlbot_route = RouteMark( # type: ignore[attr-defined] # Framework decorator marker; private to module authors.
func._owlbot_route = RouteMark( # type: ignore[attr-defined] # noqa: SLF001
path=path, methods=methods, streaming=streaming path=path, methods=methods, streaming=streaming
) )
return func return func
+13 -20
View File
@@ -22,7 +22,7 @@ import contextvars
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Self
import aiosqlite import aiosqlite
@@ -53,7 +53,7 @@ class ModuleStorage:
unit. unit.
""" """
def __init__(self, storage_dir: Path | None, module_name: str): def __init__(self, storage_dir: Path | None, module_name: str) -> None:
"""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.
@@ -71,7 +71,7 @@ class ModuleStorage:
) )
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage") self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
async def __aenter__(self) -> ModuleStorage: async def __aenter__(self) -> Self:
"""Enter an async context manager that closes the storage on exit. """Enter an async context manager that closes the storage on exit.
Enables ``async with ModuleStorage(...) as s:`` for scoped usage Enables ``async with ModuleStorage(...) as s:`` for scoped usage
@@ -141,13 +141,11 @@ class ModuleStorage:
:raises StorageError: If execution fails. :raises StorageError: If execution fails.
""" """
async with self._connection() as conn: async with self._connection() as conn:
self._logger.debug( self._logger.debug("Execute: %s", sql)
"Execute: %s%s", sql[:80], "..." if len(sql) > 80 else ""
)
try: try:
return await conn.execute(sql, parameters) return await conn.execute(sql, parameters)
except aiosqlite.Error as e: except aiosqlite.Error as e:
self._logger.error(f"SQL error: {e}") self._logger.exception("SQL error.")
raise StorageError(f"SQL execution failed: {e}") from e raise StorageError(f"SQL execution failed: {e}") from e
async def execute_many( async def execute_many(
@@ -166,15 +164,14 @@ class ModuleStorage:
""" """
async with self._connection() as conn: async with self._connection() as conn:
self._logger.debug( self._logger.debug(
"Execute many (%d rows): %s%s", "Execute many (%d rows): %s",
len(parameters), len(parameters),
sql[:80], sql,
"..." if len(sql) > 80 else "",
) )
try: try:
return await conn.executemany(sql, parameters) return await conn.executemany(sql, parameters)
except aiosqlite.Error as e: except aiosqlite.Error as e:
self._logger.error(f"SQL error in executemany: {e}") self._logger.exception("SQL error in executemany.")
raise StorageError(f"SQL execution failed: {e}") from e raise StorageError(f"SQL execution failed: {e}") from e
async def fetch_one( async def fetch_one(
@@ -191,14 +188,12 @@ class ModuleStorage:
:raises StorageError: If execution fails. :raises StorageError: If execution fails.
""" """
async with self._connection() as conn: async with self._connection() as conn:
self._logger.debug( self._logger.debug("Fetch one: %s", sql)
"Fetch one: %s%s", sql[:80], "..." if len(sql) > 80 else ""
)
try: try:
cursor = await conn.execute(sql, parameters) cursor = await conn.execute(sql, parameters)
return await cursor.fetchone() return await cursor.fetchone()
except aiosqlite.Error as e: except aiosqlite.Error as e:
self._logger.error(f"SQL error: {e}") self._logger.exception("SQL error.")
raise StorageError(f"SQL fetch failed: {e}") from e raise StorageError(f"SQL fetch failed: {e}") from e
async def fetch_all( async def fetch_all(
@@ -215,14 +210,12 @@ class ModuleStorage:
:raises StorageError: If execution fails. :raises StorageError: If execution fails.
""" """
async with self._connection() as conn: async with self._connection() as conn:
self._logger.debug( self._logger.debug("Fetch all: %s", sql)
"Fetch all: %s%s", sql[:80], "..." if len(sql) > 80 else ""
)
try: try:
cursor = await conn.execute(sql, parameters) cursor = await conn.execute(sql, parameters)
return list(await cursor.fetchall()) return list(await cursor.fetchall())
except aiosqlite.Error as e: except aiosqlite.Error as e:
self._logger.error(f"SQL error: {e}") self._logger.exception("SQL error.")
raise StorageError(f"SQL fetch failed: {e}") from e raise StorageError(f"SQL fetch failed: {e}") from e
async def fetch_value( async def fetch_value(
@@ -264,7 +257,7 @@ class ModuleStorage:
await asyncio.shield(conn.close()) await asyncio.shield(conn.close())
raise raise
self._conn = conn self._conn = conn
self._logger.info(f"Database opened at: {self._db_path}") self._logger.info("Database opened at: %s", self._db_path)
return conn return conn
@asynccontextmanager @asynccontextmanager
+46 -33
View File
@@ -17,7 +17,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Self
from ruamel.yaml.error import YAMLError from ruamel.yaml.error import YAMLError
@@ -45,15 +45,16 @@ class Owlbot:
self, self,
config_path: str | Path = "config.yaml", config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None, overrides: dict[str, Any] | None = None,
*,
skip_api_check: bool = False, skip_api_check: bool = False,
): ) -> None:
"""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.
:param skip_api_check: Skip API accessibility checks during startup. :param skip_api_check: Skip API accessibility checks during startup.
""" """
logger.info(f"Owlbot v{__version__} - A logal.dev project") logger.info("Owlbot v%s - A logal.dev project", __version__)
self.skip_api_check = skip_api_check self.skip_api_check = skip_api_check
@@ -66,8 +67,9 @@ class Owlbot:
self.modules_dir = self.config.modules_dir self.modules_dir = self.config.modules_dir
if not self.modules_dir.exists(): if not self.modules_dir.exists():
logger.info( logger.info(
f"User modules directory not found: {self.modules_dir} " "User modules directory not found: %s "
f"(only built-in modules will be loaded)" "(only built-in modules will be loaded)",
self.modules_dir,
) )
# Shared HTTP client (owns the aiohttp session lifecycle). # Shared HTTP client (owns the aiohttp session lifecycle).
@@ -88,12 +90,12 @@ class Owlbot:
logger.debug("Owlbot initialization complete.") logger.debug("Owlbot initialization complete.")
async def __aenter__(self) -> Owlbot: async def __aenter__(self) -> Self:
"""Start the bot as an async context manager.""" """Start the bot as an async context manager."""
await self.start() await self.start()
return self return self
async def __aexit__(self, *exc_info: Any) -> None: async def __aexit__(self, *exc_info: object) -> None:
"""Stop the bot when exiting the async context manager.""" """Stop the bot when exiting the async context manager."""
await self.stop() await self.stop()
@@ -114,7 +116,8 @@ class Owlbot:
try: try:
# Auth is handled per-request by each Owncast client. # Auth is handled per-request by each Owncast client.
await self.http_client._start() # Framework lifecycle; private to module authors.
await self.http_client._start() # noqa: SLF001
if self.skip_api_check: if self.skip_api_check:
logger.info("Skipping startup connection tests.") logger.info("Skipping startup connection tests.")
@@ -155,7 +158,8 @@ class Owlbot:
await self.module_loader.unload_all_modules() await self.module_loader.unload_all_modules()
# Close the shared HTTP client (safe to call if startup failed early). # Close the shared HTTP client (safe to call if startup failed early).
await self.http_client._close() # Framework lifecycle; private to module authors.
await self.http_client._close() # noqa: SLF001
logger.info("Owlbot shutdown complete.") logger.info("Owlbot shutdown complete.")
@@ -176,16 +180,19 @@ class Owlbot:
status = await owncast_client.get_status() status = await owncast_client.get_status()
except OwncastError as e: except OwncastError as e:
if e.status == 0: if e.status == 0:
logger.error( logger.exception(
"Startup connection test failed: " "Startup connection test failed: Could not connect to %s: %s",
f"Could not connect to {base_url}: {e.message}" base_url,
e.message,
) )
raise StartupError( raise StartupError(
f"Could not connect to {base_url}: {e.message}" f"Could not connect to {base_url}: {e.message}"
) from e ) from e
logger.error( logger.exception(
"Startup connection test failed: " "Startup connection test failed: %s returned HTTP %s: %s",
f"{base_url} returned HTTP {e.status}: {e.message}" base_url,
e.status,
e.message,
) )
raise StartupError( raise StartupError(
"Owncast version check failed: " "Owncast version check failed: "
@@ -195,9 +202,10 @@ class Owlbot:
version = status.get("versionNumber") if isinstance(status, dict) else None version = status.get("versionNumber") if isinstance(status, dict) else None
if not version: if not version:
logger.error( logger.error(
f"Startup connection test failed: {base_url} " "Startup connection test failed: %s "
"does not appear to be an Owncast instance " "does not appear to be an Owncast instance "
"(no versionNumber in response)" "(no versionNumber in response)",
base_url,
) )
raise StartupError( raise StartupError(
f"{base_url} does not appear to be an Owncast instance " f"{base_url} does not appear to be an Owncast instance "
@@ -207,12 +215,15 @@ class Owlbot:
# Warn on version mismatch but continue startup. # Warn on version mismatch but continue startup.
if version != OWNCAST_TARGET_VERSION: if version != OWNCAST_TARGET_VERSION:
logger.warning( logger.warning(
f"Owncast version {version} detected at {base_url}, " "Owncast version %s detected at %s, "
f"but this build of Owlbot is designed for {OWNCAST_TARGET_VERSION}. " "but this build of Owlbot is designed for %s. "
"Startup will continue, but some things may not behave as expected." "Startup will continue, but some things may not behave as expected.",
version,
base_url,
OWNCAST_TARGET_VERSION,
) )
else: else:
logger.info(f"Owncast version {version} detected at {base_url}.") logger.info("Owncast version %s detected at %s.", version, base_url)
logger.debug("Checking Owncast integrations API accessibility...") logger.debug("Checking Owncast integrations API accessibility...")
try: try:
@@ -220,17 +231,18 @@ class Owlbot:
logger.debug("Owncast integrations API is accessible.") logger.debug("Owncast integrations API is accessible.")
except OwncastError as e: except OwncastError as e:
if e.status == 0: if e.status == 0:
logger.error( logger.exception(
"Startup connection test failed: Could not " "Startup connection test failed: Could not "
f"connect to integrations API: {e.message}" "connect to integrations API: %s",
e.message,
) )
raise StartupError( raise StartupError(
f"Could not connect to integrations API: {e.message}" f"Could not connect to integrations API: {e.message}"
) from e ) from e
logger.error( logger.exception(
"Startup connection test failed: " "Startup connection test failed: Integrations API returned HTTP %s: %s",
"Integrations API returned " e.status,
f"HTTP {e.status}: {e.message}" e.message,
) )
raise StartupError( raise StartupError(
"Owncast integrations API check failed: " "Owncast integrations API check failed: "
@@ -244,17 +256,18 @@ class Owlbot:
logger.info("Owncast admin API is enabled.") logger.info("Owncast admin API is enabled.")
except OwncastError as e: except OwncastError as e:
if e.status == 0: if e.status == 0:
logger.error( logger.exception(
"Startup connection test failed: Could " "Startup connection test failed: Could "
f"not connect to admin API: {e.message}" "not connect to admin API: %s",
e.message,
) )
raise StartupError( raise StartupError(
f"Could not connect to admin API: {e.message}" f"Could not connect to admin API: {e.message}"
) from e ) from e
logger.error( logger.exception(
"Startup connection test failed: " "Startup connection test failed: Admin API returned HTTP %s: %s",
"Admin API returned " e.status,
f"HTTP {e.status}: {e.message}" e.message,
) )
raise StartupError( raise StartupError(
"Owncast admin API check failed: " "Owncast admin API check failed: "
+3 -2
View File
@@ -20,6 +20,7 @@ stream in real time and provides a browser-based clip editor.
from __future__ import annotations from __future__ import annotations
import asyncio
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -95,7 +96,7 @@ async def setup(ctx: ModuleContext) -> None:
processor = VideoProcessor(ctx.logger) processor = VideoProcessor(ctx.logger)
clips_dir = Path(str(ctx.config.get("clips_dir"))) clips_dir = Path(str(ctx.config.get("clips_dir")))
clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240 await asyncio.to_thread(clips_dir.mkdir, parents=True, exist_ok=True)
manager = ClipManager(ctx, repo, processor, clips_dir) manager = ClipManager(ctx, repo, processor, clips_dir)
ctx.state["manager"] = manager ctx.state["manager"] = manager
@@ -106,7 +107,7 @@ async def setup(ctx: ModuleContext) -> None:
if status.get("online", False): if status.get("online", False):
ctx.logger.info("Stream is already live. Starting HLS cache.") ctx.logger.info("Stream is already live. Starting HLS cache.")
await manager.start_caching() await manager.start_caching()
except Exception: except Exception: # noqa: BLE001 # best-effort; non-fatal if Owncast is unreachable during setup
ctx.logger.warning( ctx.logger.warning(
"Could not check Owncast status during setup.", exc_info=True "Could not check Owncast status during setup.", exc_info=True
) )
+10 -8
View File
@@ -27,6 +27,7 @@ import re
import shutil import shutil
import tempfile import tempfile
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urljoin from urllib.parse import urljoin
@@ -140,7 +141,7 @@ class ChunkCache:
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await self._task await self._task
self._task = None self._task = None
await asyncio.to_thread(shutil.rmtree, self._cache_dir, True) await asyncio.to_thread(shutil.rmtree, self._cache_dir, ignore_errors=True)
self._sequences.clear() self._sequences.clear()
async def _download_segment(self, segment_url: str, seq: int) -> bool: async def _download_segment(self, segment_url: str, seq: int) -> bool:
@@ -152,7 +153,7 @@ class ChunkCache:
""" """
try: try:
async with self._http.get(segment_url) as seg_resp: async with self._http.get(segment_url) as seg_resp:
if seg_resp.status != 200: if seg_resp.status != HTTPStatus.OK:
return False return False
data = await seg_resp.read() data = await seg_resp.read()
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -177,7 +178,7 @@ class ChunkCache:
while True: while True:
try: try:
async with self._http.get(self._variant_url) as resp: async with self._http.get(self._variant_url) as resp:
if resp.status != 200: if resp.status != HTTPStatus.OK:
self._logger.warning( self._logger.warning(
"Variant playlist returned HTTP %d.", resp.status "Variant playlist returned HTTP %d.", resp.status
) )
@@ -233,7 +234,7 @@ class ChunkCache:
oldest = min(self._sequences) oldest = min(self._sequences)
self._sequences.discard(oldest) self._sequences.discard(oldest)
path = self._segment_path(oldest) path = self._segment_path(oldest)
await asyncio.to_thread(path.unlink, True) await asyncio.to_thread(path.unlink, missing_ok=True)
if len(self._sequences) < pre_prune: if len(self._sequences) < pre_prune:
self._logger.debug( self._logger.debug(
"Pruned %d segment(s). Cache: %d chunks, %.1fs total.", "Pruned %d segment(s). Cache: %d chunks, %.1fs total.",
@@ -245,7 +246,7 @@ class ChunkCache:
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
self._logger.error("Error in HLS polling loop.", exc_info=True) self._logger.exception("Error in HLS polling loop.")
await asyncio.sleep(self._target_duration) await asyncio.sleep(self._target_duration)
@@ -338,11 +339,12 @@ async def start_caching(
cache.start() cache.start()
logger.info("HLS caching started. Cache dir: %s", cache_dir) logger.info("HLS caching started. Cache dir: %s", cache_dir)
return cache
except BaseException: except BaseException:
shutil.rmtree(cache_dir, ignore_errors=True) shutil.rmtree(cache_dir, ignore_errors=True)
raise raise
else:
return cache
async def _fetch_playlist( async def _fetch_playlist(
@@ -364,7 +366,7 @@ async def _fetch_playlist(
exc_info = False exc_info = False
try: try:
async with http.get(url) as resp: async with http.get(url) as resp:
if resp.status != 200: if resp.status != HTTPStatus.OK:
reason = f"HTTP {resp.status}" reason = f"HTTP {resp.status}"
else: else:
content = await resp.text() content = await resp.text()
@@ -409,7 +411,7 @@ async def _download_init_segment(
exc_info = False exc_info = False
try: try:
async with http.get(init_url) as resp: async with http.get(init_url) as resp:
if resp.status != 200: if resp.status != HTTPStatus.OK:
reason = f"HTTP {resp.status}" reason = f"HTTP {resp.status}"
else: else:
data = await resp.read() data = await resp.read()
+1 -1
View File
@@ -45,7 +45,7 @@ async def clip_command(ctx: CommandContext) -> None:
"Not enough stream data is available yet. Please try again later." "Not enough stream data is available yet. Please try again later."
) )
return return
except Exception: except Exception: # noqa: BLE001 # catch-all after specific exceptions; ffmpeg/IO failures are unpredictable
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Failed to create clip preview. Please try again." "Failed to create clip preview. Please try again."
) )
+10 -10
View File
@@ -192,8 +192,8 @@ class ClipManager:
preview_path, self._cache preview_path, self._cache
) )
except Exception: except Exception:
self._ctx.logger.error("Failed to generate clip preview.", exc_info=True) self._ctx.logger.exception("Failed to generate clip preview.")
await asyncio.to_thread(shutil.rmtree, work_dir, True) await asyncio.to_thread(shutil.rmtree, work_dir, ignore_errors=True)
raise raise
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@@ -303,7 +303,7 @@ class ClipManager:
thumbnail_path, thumbnail_path,
duration=actual_duration, duration=actual_duration,
) )
except Exception: except Exception: # noqa: BLE001 # best-effort; clip already saved, thumbnail is optional
self._ctx.logger.warning( self._ctx.logger.warning(
"Thumbnail generation failed for clip %d.", "Thumbnail generation failed for clip %d.",
clip.id, clip.id,
@@ -311,14 +311,12 @@ class ClipManager:
) )
thumbnail_path.unlink(missing_ok=True) thumbnail_path.unlink(missing_ok=True)
return clip
except Exception: except Exception:
# Clean up partial state to avoid orphaned DB rows or files. # Clean up partial state to avoid orphaned DB rows or files.
if clip_id is not None: if clip_id is not None:
try: try:
await self._repo.delete(clip_id) await self._repo.delete(clip_id)
except Exception: except Exception: # noqa: BLE001 # last-resort; cleanup itself failed, just log it
self._ctx.logger.warning( self._ctx.logger.warning(
"Failed to clean up DB row for clip %d.", "Failed to clean up DB row for clip %d.",
clip_id, clip_id,
@@ -327,8 +325,10 @@ class ClipManager:
if clip_path is not None: if clip_path is not None:
clip_path.unlink(missing_ok=True) clip_path.unlink(missing_ok=True)
raise raise
else:
return clip
finally: finally:
await asyncio.to_thread(shutil.rmtree, session.work_dir, True) await asyncio.to_thread(shutil.rmtree, session.work_dir, ignore_errors=True)
async def cleanup_session(self, token: str) -> None: async def cleanup_session(self, token: str) -> None:
"""Remove a session and clean up its working directory. """Remove a session and clean up its working directory.
@@ -339,7 +339,7 @@ class ClipManager:
if session is not None: if session is not None:
if session.expiry_task is not None: if session.expiry_task is not None:
session.expiry_task.cancel() session.expiry_task.cancel()
await asyncio.to_thread(shutil.rmtree, session.work_dir, True) await asyncio.to_thread(shutil.rmtree, session.work_dir, ignore_errors=True)
async def start_caching(self) -> None: async def start_caching(self) -> None:
"""Start HLS caching from the Owncast stream. """Start HLS caching from the Owncast stream.
@@ -357,7 +357,7 @@ class ClipManager:
logger=self._ctx.logger, logger=self._ctx.logger,
) )
except Exception: except Exception:
self._ctx.logger.error("Failed to start HLS caching.", exc_info=True) self._ctx.logger.exception("Failed to start HLS caching.")
async def stop_caching(self) -> None: async def stop_caching(self) -> None:
"""Stop HLS caching and clean up cached files.""" """Stop HLS caching and clean up cached files."""
@@ -434,5 +434,5 @@ def get_manager(ctx: ModuleContext) -> ClipManager:
manager = ctx.state.get("manager") manager = ctx.state.get("manager")
if not isinstance(manager, ClipManager): if not isinstance(manager, ClipManager):
msg = "ClipManager is not initialized." msg = "ClipManager is not initialized."
raise RuntimeError(msg) raise RuntimeError(msg) # noqa: TRY004 # state error, not a type error
return manager return manager
+2 -2
View File
@@ -113,7 +113,7 @@ async def editor_preview_video(ctx: RouteContext) -> web.StreamResponse:
@on_route("/static/editor.js", methods=["GET"]) @on_route("/static/editor.js", methods=["GET"])
async def editor_js(ctx: RouteContext) -> web.StreamResponse: async def editor_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the clip editor JavaScript. """Serve the clip editor JavaScript.
:param ctx: The route context. :param ctx: The route context.
@@ -155,7 +155,7 @@ async def editor_submit(ctx: RouteContext) -> web.Response:
except InvalidClipParamsError as e: except InvalidClipParamsError as e:
return _error_page(ctx, 400, e.reason) return _error_page(ctx, 400, e.reason)
except Exception: except Exception:
ctx.logger.error("Failed to create clip.", exc_info=True) ctx.logger.exception("Failed to create clip.")
return _error_page(ctx, 500, "Clip processing failed.") return _error_page(ctx, 500, "Clip processing failed.")
clip_url = ctx.routes.url_for(f"/view/{clip.id}") clip_url = ctx.routes.url_for(f"/view/{clip.id}")
@@ -45,7 +45,7 @@ async def addcommand(ctx: CommandContext) -> None:
""" """
prefix = ctx.commands.prefix prefix = ctx.commands.prefix
args = ctx.args_list args = ctx.args_list
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}addcommand {prefix}<name> <response text>" f"Usage: {prefix}addcommand {prefix}<name> <response text>"
) )
@@ -53,7 +53,7 @@ async def addcommand(ctx: CommandContext) -> None:
name = _clean_name(args[0], prefix) name = _clean_name(args[0], prefix)
parts = ctx.args.split(maxsplit=1) parts = ctx.args.split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}addcommand {prefix}<name> <response text>" f"Usage: {prefix}addcommand {prefix}<name> <response text>"
) )
@@ -87,7 +87,7 @@ async def editcommand(ctx: CommandContext) -> None:
""" """
prefix = ctx.commands.prefix prefix = ctx.commands.prefix
args = ctx.args_list args = ctx.args_list
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}editcommand {prefix}<name> <new response>" f"Usage: {prefix}editcommand {prefix}<name> <new response>"
) )
@@ -95,7 +95,7 @@ async def editcommand(ctx: CommandContext) -> None:
name = _clean_name(args[0], prefix) name = _clean_name(args[0], prefix)
parts = ctx.args.split(maxsplit=1) parts = ctx.args.split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}editcommand {prefix}<name> <new response>" f"Usage: {prefix}editcommand {prefix}<name> <new response>"
) )
@@ -166,7 +166,7 @@ async def commandmodonly(ctx: CommandContext) -> None:
""" """
prefix = ctx.commands.prefix prefix = ctx.commands.prefix
args = ctx.args_list args = ctx.args_list
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}commandmodonly {prefix}<name> <on|off>" f"Usage: {prefix}commandmodonly {prefix}<name> <on|off>"
) )
@@ -241,7 +241,7 @@ async def editcounter(ctx: CommandContext) -> None:
""" """
prefix = ctx.commands.prefix prefix = ctx.commands.prefix
args = ctx.args_list args = ctx.args_list
if len(args) != 2: if len(args) != 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}editcounter <name> <value>" f"Usage: {prefix}editcounter <name> <value>"
) )
@@ -291,7 +291,7 @@ async def commandcooldown(ctx: CommandContext) -> None:
""" """
prefix = ctx.commands.prefix prefix = ctx.commands.prefix
args = ctx.args_list args = ctx.args_list
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}commandcooldown {prefix}<name> <seconds>" f"Usage: {prefix}commandcooldown {prefix}<name> <seconds>"
) )
@@ -344,7 +344,7 @@ async def addalias(ctx: CommandContext) -> None:
""" """
prefix = ctx.commands.prefix prefix = ctx.commands.prefix
args = ctx.args_list args = ctx.args_list
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Usage: {prefix}addalias {prefix}<command> {prefix}<alias>" f"Usage: {prefix}addalias {prefix}<command> {prefix}<alias>"
) )
@@ -127,16 +127,12 @@ class CommandManager:
raise CommandAlreadyExistsError(name) raise CommandAlreadyExistsError(name)
default_cooldown: int = self._ctx.config.get("default_cooldown", 5) default_cooldown: int = self._ctx.config.get("default_cooldown", 5)
command = await self._repo.create(name, response, default_cooldown)
self._commands.register( self._commands.register(
name=name, name=name,
handler=custom_command_handler, handler=custom_command_handler,
cooldown=default_cooldown, cooldown=default_cooldown,
) )
try:
command = await self._repo.create(name, response, default_cooldown)
except Exception:
self._commands.unregister(name)
raise
self._ctx.logger.info("Custom command '%s' created.", name) self._ctx.logger.info("Custom command '%s' created.", name)
return command return command
@@ -336,7 +332,7 @@ def get_manager(ctx: ModuleContext) -> CommandManager:
""" """
manager = ctx.state.get("manager") manager = ctx.state.get("manager")
if not isinstance(manager, CommandManager): if not isinstance(manager, CommandManager):
raise RuntimeError("CommandManager is not initialized.") raise RuntimeError("CommandManager is not initialized.") # noqa: TRY004 # state error, not a type error
return manager return manager
@@ -262,7 +262,7 @@ def _parse_placeholder_date(date_str: str) -> datetime | None:
""" """
# Split off the timezone abbreviation (last token). # Split off the timezone abbreviation (last token).
parts = date_str.rsplit(maxsplit=1) parts = date_str.rsplit(maxsplit=1)
if len(parts) != 2: if len(parts) != 2: # noqa: PLR2004 # just checking argument count
return None return None
date_part, tz_abbrev = parts date_part, tz_abbrev = parts
@@ -321,7 +321,7 @@ async def _evaluate_arg(
async def _evaluate_user( async def _evaluate_user(
name: str, name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str], args: list[str],
ctx: PlaceholderContext, ctx: PlaceholderContext,
) -> str: ) -> str:
@@ -332,7 +332,7 @@ async def _evaluate_user(
async def _evaluate_count( async def _evaluate_count(
name: str, name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str], args: list[str],
ctx: PlaceholderContext, ctx: PlaceholderContext,
) -> str: ) -> str:
@@ -350,7 +350,7 @@ async def _evaluate_count(
) )
modifier_str = args[1] if len(args) > 1 else "+1" modifier_str = args[1] if len(args) > 1 else "+1"
if len(args) > 2: if len(args) > 2: # noqa: PLR2004 # just checking argument count
raise PlaceholderError( raise PlaceholderError(
"Invalid $(count): too many arguments, expected $(count name [modifier])" "Invalid $(count): too many arguments, expected $(count name [modifier])"
) )
@@ -369,7 +369,7 @@ async def _evaluate_count(
async def _evaluate_getcount( async def _evaluate_getcount(
name: str, name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str], args: list[str],
ctx: PlaceholderContext, ctx: PlaceholderContext,
) -> str: ) -> str:
@@ -391,16 +391,16 @@ async def _evaluate_getcount(
async def _evaluate_rand( async def _evaluate_rand(
name: str, name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str], args: list[str],
ctx: PlaceholderContext, ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
) -> str: ) -> str:
"""``$(rand start stop)`` -- random integer in range.""" """``$(rand start stop)`` -- random integer in range."""
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
raise PlaceholderError( raise PlaceholderError(
"Invalid $(rand): too few arguments, expected $(rand start stop)" "Invalid $(rand): too few arguments, expected $(rand start stop)"
) )
if len(args) > 2: if len(args) > 2: # noqa: PLR2004 # just checking argument count
raise PlaceholderError( raise PlaceholderError(
"Invalid $(rand): too many arguments, expected $(rand start stop)" "Invalid $(rand): too many arguments, expected $(rand start stop)"
) )
@@ -411,13 +411,13 @@ async def _evaluate_rand(
raise PlaceholderError( raise PlaceholderError(
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)" "Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
) from e ) from e
return str(random.randint(min(start, stop), max(start, stop))) # noqa: S311 return str(random.randint(min(start, stop), max(start, stop))) # noqa: S311 # not security-sensitive; chat command RNG
async def _evaluate_countdown( async def _evaluate_countdown(
name: str, name: str,
args: list[str], args: list[str],
ctx: PlaceholderContext, ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
) -> str: ) -> str:
"""``$(countdown date)`` / ``$(countup date)`` -- time delta.""" """``$(countdown date)`` / ``$(countup date)`` -- time delta."""
date_str = " ".join(args) date_str = " ".join(args)
@@ -204,6 +204,7 @@ def _parse_nodes(
template: str, template: str,
pos: int, pos: int,
depth: int, depth: int,
*,
inside_placeholder: bool, inside_placeholder: bool,
max_depth: int, max_depth: int,
) -> tuple[list[Node], int, bool]: ) -> tuple[list[Node], int, bool]:
@@ -372,7 +372,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
await asyncio.sleep(15) await asyncio.sleep(15)
try: try:
await response.write(b": keepalive\n\n") await response.write(b": keepalive\n\n")
except Exception: except Exception: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
ctx.logger.debug("SSE keepalive failed, disconnecting client.") ctx.logger.debug("SSE keepalive failed, disconnecting client.")
break break
finally: finally:
@@ -383,7 +383,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
@on_route("/static/wall.js", methods=["GET"]) @on_route("/static/wall.js", methods=["GET"])
async def static_wall_js(ctx: RouteContext) -> web.FileResponse: async def static_wall_js(ctx: RouteContext) -> web.FileResponse: # noqa: ARG001 # required by route handler signature
"""Serve the emoji wall JavaScript file. """Serve the emoji wall JavaScript file.
:param ctx: The route context. :param ctx: The route context.
+8 -7
View File
@@ -36,6 +36,7 @@ from .types import (
MAX_QUESTION_LENGTH, MAX_QUESTION_LENGTH,
MIN_DURATION, MIN_DURATION,
MIN_OPTIONS, MIN_OPTIONS,
REMINDER_THRESHOLD,
RESULT_EXPIRY, RESULT_EXPIRY,
STREAM_GRACE_PERIOD, STREAM_GRACE_PERIOD,
ActivePoll, ActivePoll,
@@ -256,10 +257,10 @@ class PollManager:
# so clearing the creation reference has no effect on it. # so clearing the creation reference has no effect on it.
self.cancel_creation_token() self.cancel_creation_token()
# Start auto-end timer task with 60-second reminder. # Start auto-end timer task with reminder before expiry.
async def _poll_timer() -> None: async def _poll_timer() -> None:
if duration > 60: if duration > REMINDER_THRESHOLD:
await asyncio.sleep(duration - 60) await asyncio.sleep(duration - REMINDER_THRESHOLD)
if poll.allow_multiple: if poll.allow_multiple:
instructions = "Use <strong>!vote</strong> to get a voting link." instructions = "Use <strong>!vote</strong> to get a voting link."
else: else:
@@ -269,13 +270,13 @@ class PollManager:
escaped_question = escape(poll.question) escaped_question = escape(poll.question)
reminder = ( reminder = (
f"<strong>60 seconds remaining:</strong>" f"<strong>{REMINDER_THRESHOLD} seconds remaining:</strong>"
f" {escaped_question}<br>{instructions}" f" {escaped_question}<br>{instructions}"
) )
await self._ctx.owncast_client.send_system_message( await self._ctx.owncast_client.send_system_message(
reminder, unsanitized=True reminder, unsanitized=True
) )
await asyncio.sleep(60) await asyncio.sleep(REMINDER_THRESHOLD)
else: else:
await asyncio.sleep(duration) await asyncio.sleep(duration)
await self.end() await self.end()
@@ -283,7 +284,7 @@ class PollManager:
poll.timer_task = asyncio.create_task(_poll_timer()) poll.timer_task = asyncio.create_task(_poll_timer())
# Announce in chat. # Announce in chat.
if duration >= 60: if duration >= REMINDER_THRESHOLD:
minutes = duration // 60 minutes = duration // 60
time_str = f"{minutes} minute{'s' if minutes != 1 else ''}" time_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
else: else:
@@ -692,5 +693,5 @@ def get_manager(ctx: ModuleContext) -> PollManager:
""" """
manager = ctx.state.get("manager") manager = ctx.state.get("manager")
if not isinstance(manager, PollManager): if not isinstance(manager, PollManager):
raise RuntimeError("PollManager is not initialized.") raise RuntimeError("PollManager is not initialized.") # noqa: TRY004 # state error, not a type error
return manager return manager
+4 -4
View File
@@ -339,7 +339,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
remaining = poll.time_remaining remaining = poll.time_remaining
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining}) payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
await response.write(payload) await response.write(payload)
except Exception: except Exception: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
ctx.logger.debug("SSE keepalive failed for %s...", token[:8]) ctx.logger.debug("SSE keepalive failed for %s...", token[:8])
break break
finally: finally:
@@ -439,7 +439,7 @@ async def results_page(ctx: RouteContext) -> web.Response:
@on_route("/static/polls.css", methods=["GET"]) @on_route("/static/polls.css", methods=["GET"])
async def static_polls_css(ctx: RouteContext) -> web.StreamResponse: async def static_polls_css(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the polls stylesheet. """Serve the polls stylesheet.
:param ctx: The route context. :param ctx: The route context.
@@ -452,7 +452,7 @@ async def static_polls_css(ctx: RouteContext) -> web.StreamResponse:
@on_route("/static/polls.js", methods=["GET"]) @on_route("/static/polls.js", methods=["GET"])
async def static_polls_js(ctx: RouteContext) -> web.StreamResponse: async def static_polls_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the polls JavaScript file. """Serve the polls JavaScript file.
:param ctx: The route context. :param ctx: The route context.
@@ -465,7 +465,7 @@ async def static_polls_js(ctx: RouteContext) -> web.StreamResponse:
@on_route("/static/create.js", methods=["GET"]) @on_route("/static/create.js", methods=["GET"])
async def static_create_js(ctx: RouteContext) -> web.StreamResponse: async def static_create_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the create form JavaScript file. """Serve the create form JavaScript file.
:param ctx: The route context. :param ctx: The route context.
+1
View File
@@ -38,6 +38,7 @@ MAX_OPTIONS = 10
DEFAULT_DURATION = 240 DEFAULT_DURATION = 240
MIN_DURATION = 30 MIN_DURATION = 30
MAX_DURATION = 600 MAX_DURATION = 600
REMINDER_THRESHOLD = 60
CREATION_TOKEN_TIMEOUT = 15 * 60 CREATION_TOKEN_TIMEOUT = 15 * 60
STREAM_GRACE_PERIOD = 5 * 60 STREAM_GRACE_PERIOD = 5 * 60
RESULT_EXPIRY = 3600 RESULT_EXPIRY = 3600
+1 -1
View File
@@ -92,5 +92,5 @@ def get_manager(ctx: ModuleContext) -> QuoteManager:
""" """
manager = ctx.state.get("manager") manager = ctx.state.get("manager")
if not isinstance(manager, QuoteManager): if not isinstance(manager, QuoteManager):
raise RuntimeError("QuoteManager is not initialized.") raise RuntimeError("QuoteManager is not initialized.") # noqa: TRY004 # state error, not a type error
return manager return manager
+1 -1
View File
@@ -82,7 +82,7 @@ async def setup(ctx: ModuleContext) -> None:
ctx.logger.info("Stream is live. Timers started.") ctx.logger.info("Stream is live. Timers started.")
else: else:
ctx.logger.info("Stream is offline. Timers will start on stream start.") ctx.logger.info("Stream is offline. Timers will start on stream start.")
except Exception: except Exception: # noqa: BLE001 # best-effort; non-fatal if Owncast is unreachable during setup
ctx.logger.warning( ctx.logger.warning(
"Could not check stream status. Timers will start on stream start.", "Could not check stream status. Timers will start on stream start.",
exc_info=True, exc_info=True,
+3 -3
View File
@@ -67,7 +67,7 @@ async def settimermessage(ctx: CommandContext) -> None:
:param ctx: The command context. :param ctx: The command context.
""" """
parts = ctx.args.split(maxsplit=1) parts = ctx.args.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip(): if len(parts) < 2 or not parts[1].strip(): # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Usage: !settimermessage <id|name> <message>" "Usage: !settimermessage <id|name> <message>"
) )
@@ -95,7 +95,7 @@ async def settimerinterval(ctx: CommandContext) -> None:
:param ctx: The command context. :param ctx: The command context.
""" """
parts = ctx.args.split(maxsplit=1) parts = ctx.args.split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Usage: !settimerinterval <id|name> <interval>" "Usage: !settimerinterval <id|name> <interval>"
) )
@@ -132,7 +132,7 @@ async def settimerlines(ctx: CommandContext) -> None:
:param ctx: The command context. :param ctx: The command context.
""" """
args = ctx.args_list args = ctx.args_list
if len(args) < 2: if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>") await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
return return
+1 -1
View File
@@ -455,5 +455,5 @@ def get_manager(ctx: ModuleContext) -> TimerManager:
""" """
manager = ctx.state.get("manager") manager = ctx.state.get("manager")
if not isinstance(manager, TimerManager): if not isinstance(manager, TimerManager):
raise RuntimeError("TimerManager is not initialized.") raise RuntimeError("TimerManager is not initialized.") # noqa: TRY004 # state error, not a type error
return manager return manager
+10 -8
View File
@@ -60,7 +60,7 @@ class HttpServer:
config: Config, config: Config,
event_dispatch: WebhookCallback, event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher, route_dispatcher: RouteDispatcher,
): ) -> None:
"""Initialize the web server. """Initialize the web server.
:param config: Bot configuration. :param config: Bot configuration.
@@ -112,11 +112,11 @@ class HttpServer:
logger.debug("Binding web server to %s:%s...", host, port) logger.debug("Binding web server to %s:%s...", host, port)
try: try:
await site.start() await site.start()
except OSError as e: except OSError:
logger.error(f"Failed to bind web server to {host}:{port}: {e}") logger.exception("Failed to bind web server to %s:%s.", host, port)
raise raise
logger.info(f"Web server started at: http://{host}:{port}") logger.info("Web server started at: http://%s:%s", host, port)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the HTTP server.""" """Stop the HTTP server."""
@@ -129,8 +129,8 @@ class HttpServer:
"""Wait for all pending webhook dispatch tasks to complete.""" """Wait for all pending webhook dispatch tasks to complete."""
if self._pending_tasks: if self._pending_tasks:
logger.info( logger.info(
f"Waiting for {len(self._pending_tasks)} " "Waiting for %d pending event(s) to complete...",
"pending event(s) to complete..." len(self._pending_tasks),
) )
await asyncio.gather(*self._pending_tasks) await asyncio.gather(*self._pending_tasks)
logger.debug("All pending events drained.") logger.debug("All pending events drained.")
@@ -146,7 +146,7 @@ class HttpServer:
except ValueError as e: except ValueError as e:
# Invalid JSON received. This shouldn't happen # Invalid JSON received. This shouldn't happen
# with legitimate Owncast webhooks. # with legitimate Owncast webhooks.
logger.warning(f"Failed to parse webhook JSON: {e}") logger.warning("Failed to parse webhook JSON: %s", e)
return web.Response(status=400) return web.Response(status=400)
event_type = data.get("type", "unknown") event_type = data.get("type", "unknown")
@@ -156,7 +156,9 @@ class HttpServer:
if result is None: if result is None:
# Owncast may have added a new webhook we don't handle yet. # Owncast may have added a new webhook we don't handle yet.
logger.warning( logger.warning(
f"Owncast sent unrecognized event type: {event_type!r}. Payload: {data}" "Owncast sent unrecognized event type: %r. Payload: %s",
event_type,
data,
) )
return web.Response(status=400) return web.Response(status=400)
+41 -34
View File
@@ -22,6 +22,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from . import builtin_modules
from .api.config import Config, ModuleConfig from .api.config import Config, ModuleConfig
from .api.context import ModuleContext from .api.context import ModuleContext
from .api.owncast_admin_client import OwncastAdminClient from .api.owncast_admin_client import OwncastAdminClient
@@ -56,7 +57,7 @@ class ModuleLoader:
modules_dir: str | Path, modules_dir: str | Path,
config: Config, config: Config,
http_client: HttpClient, http_client: HttpClient,
): ) -> None:
"""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.
@@ -105,7 +106,8 @@ class ModuleLoader:
self._core_template_dir = Path(__file__).resolve().parent / "templates" self._core_template_dir = Path(__file__).resolve().parent / "templates"
logger.debug( logger.debug(
f"ModuleLoader initialized (user modules directory: {self.modules_dir})" "ModuleLoader initialized (user modules directory: %s)",
self.modules_dir,
) )
def get_module_context(self, module_name: str) -> ModuleContext | None: def get_module_context(self, module_name: str) -> ModuleContext | None:
@@ -146,15 +148,16 @@ class ModuleLoader:
reserved = user_names & RESERVED_MODULE_NAMES reserved = user_names & RESERVED_MODULE_NAMES
for name in sorted(reserved): for name in sorted(reserved):
logger.warning( logger.warning(
f"User module '{name}' uses a reserved name and will be skipped." "User module '%s' uses a reserved name and will be skipped.", name
) )
user_names -= reserved user_names -= reserved
overrides = user_names & BUILTIN_MODULE_NAMES overrides = user_names & BUILTIN_MODULE_NAMES
for name in sorted(overrides): for name in sorted(overrides):
logger.info( logger.info(
f"User module '{name}' found; built-in module of " "User module '%s' found; built-in module of "
f"the same name will be skipped." "the same name will be skipped.",
name,
) )
merged = BUILTIN_MODULE_NAMES | user_names merged = BUILTIN_MODULE_NAMES | user_names
@@ -162,9 +165,11 @@ class ModuleLoader:
builtin_count = len(BUILTIN_MODULE_NAMES - user_names) builtin_count = len(BUILTIN_MODULE_NAMES - user_names)
user_count = len(user_names) user_count = len(user_names)
logger.info( logger.info(
f"Discovered {len(modules)} module(s) " "Discovered %d module(s) (%d built-in, %d user): %s",
f"({builtin_count} built-in, {user_count} user): " len(modules),
f"{', '.join(modules) if modules else 'none'}" builtin_count,
user_count,
", ".join(modules) if modules else "none",
) )
return modules return modules
@@ -200,7 +205,7 @@ class ModuleLoader:
if found: if found:
logger.debug( logger.debug(
f"Found {len(found)} user module(s): {', '.join(sorted(found))}" "Found %d user module(s): %s", len(found), ", ".join(sorted(found))
) )
return found return found
@@ -220,7 +225,7 @@ class ModuleLoader:
:return: Names of successfully loaded modules. :return: Names of successfully loaded modules.
""" """
discovered = self.discover_module_names() discovered = self.discover_module_names()
logger.info(f"Loading {len(discovered)} module(s)...") logger.info("Loading %d module(s)...", len(discovered))
imported: list[str] = [] imported: list[str] = []
loaded: list[str] = [] loaded: list[str] = []
@@ -236,21 +241,21 @@ class ModuleLoader:
if "disabled in config" in str(e): if "disabled in config" in str(e):
disabled_count += 1 disabled_count += 1
else: else:
logger.error(str(e)) logger.exception("Module import failed.")
failed_count += 1 failed_count += 1
# Phase 2: Run @on_setup hooks for each imported module. # Phase 2: Run @on_setup hooks for each imported module.
logger.debug( logger.debug(
f"Import phase complete ({len(imported)} imported). " "Import phase complete (%d imported). Running setup handlers...",
f"Running setup handlers..." len(imported),
) )
for module_name in imported: for module_name in imported:
try: try:
await self._run_module_setup(module_name) await self._run_module_setup(module_name)
loaded.append(module_name) loaded.append(module_name)
logger.info(f"Loaded module '{module_name}'.") logger.info("Loaded module '%s'.", module_name)
except ModuleLoadError as e: except ModuleLoadError:
logger.error(str(e)) logger.exception("Module setup failed.")
failed_count += 1 failed_count += 1
parts = [f"{len(loaded)} loaded"] parts = [f"{len(loaded)} loaded"]
@@ -258,7 +263,7 @@ class ModuleLoader:
parts.append(f"{disabled_count} disabled") parts.append(f"{disabled_count} disabled")
if failed_count: if failed_count:
parts.append(f"{failed_count} failed") parts.append(f"{failed_count} failed")
logger.info(f"Module loading complete: {', '.join(parts)}") logger.info("Module loading complete: %s", ", ".join(parts))
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:
@@ -346,7 +351,7 @@ class ModuleLoader:
if _run_setup: if _run_setup:
await self._run_module_setup(module_name) await self._run_module_setup(module_name)
logger.info(f"Loaded module '{module_name}'.") logger.info("Loaded module '%s'.", module_name)
else: else:
logger.debug("Imported module '%s' (setup deferred).", module_name) logger.debug("Imported module '%s' (setup deferred).", module_name)
@@ -357,11 +362,13 @@ class ModuleLoader:
cleanup_ctx = self._module_contexts.get(module_name) cleanup_ctx = self._module_contexts.get(module_name)
if cleanup_ctx: if cleanup_ctx:
try: try:
await cleanup_ctx.storage._close() # Framework lifecycle; private to module authors.
except Exception as close_err: await cleanup_ctx.storage._close() # noqa: SLF001
logger.error( except Exception:
f"Failed to close storage for module '{module_name}' " logger.exception(
f"during load error cleanup: {close_err}" "Failed to close storage for module '%s' "
"during load error cleanup.",
module_name,
) )
self._cleanup_module(module_name) self._cleanup_module(module_name)
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
@@ -374,7 +381,7 @@ class ModuleLoader:
""" """
logger.debug("Unloading module '%s'", module_name) logger.debug("Unloading module '%s'", module_name)
if module_name not in self.loaded_modules: if module_name not in self.loaded_modules:
logger.warning(f"Cannot unload '{module_name}': not loaded") logger.warning("Cannot unload '%s': not loaded", module_name)
return False return False
module_ctx = self._module_contexts.get(module_name) module_ctx = self._module_contexts.get(module_name)
@@ -391,21 +398,22 @@ class ModuleLoader:
logger.debug("Running @on_teardown for module: %s", module_name) logger.debug("Running @on_teardown for module: %s", module_name)
await teardown_func(module_ctx) await teardown_func(module_ctx)
logger.debug("Teardown completed for module: %s", module_name) logger.debug("Teardown completed for module: %s", module_name)
except Exception as e: except Exception:
logger.exception(f"Teardown failed for module '{module_name}': {e}") logger.exception("Teardown failed for module '%s'.", module_name)
else: else:
logger.debug("Module '%s' has no @on_teardown handlers.", module_name) logger.debug("Module '%s' has no @on_teardown handlers.", module_name)
if module_ctx: if module_ctx:
try: try:
await module_ctx.storage._close() # Framework lifecycle; private to module authors.
except Exception as e: await module_ctx.storage._close() # noqa: SLF001
except Exception:
logger.exception( logger.exception(
f"Failed to close storage for module '{module_name}': {e}" "Failed to close storage for module '%s'.", module_name
) )
self._cleanup_module(module_name) self._cleanup_module(module_name)
logger.info(f"Unloaded module '{module_name}'.") logger.info("Unloaded module '%s'.", module_name)
return True return True
async def unload_all_modules(self) -> None: async def unload_all_modules(self) -> None:
@@ -416,7 +424,7 @@ class ModuleLoader:
if not self.loaded_modules: if not self.loaded_modules:
return return
logger.info(f"Unloading {len(self.loaded_modules)} module(s)...") logger.info("Unloading %d module(s)...", len(self.loaded_modules))
for module_name in list(self.loaded_modules): for module_name in list(self.loaded_modules):
await self.unload_module(module_name) await self.unload_module(module_name)
logger.info("All module unload complete.") logger.info("All module unload complete.")
@@ -446,7 +454,8 @@ class ModuleLoader:
await setup_func(module_ctx) await setup_func(module_ctx)
logger.debug("Setup completed for module: %s", module_name) logger.debug("Setup completed for module: %s", module_name)
except Exception as e: except Exception as e:
await module_ctx.storage._close() # Framework lifecycle; private to module authors.
await module_ctx.storage._close() # noqa: SLF001
self._cleanup_module(module_name) self._cleanup_module(module_name)
raise ModuleLoadError( raise ModuleLoadError(
f"Setup failed for module '{module_name}': {e}" f"Setup failed for module '{module_name}': {e}"
@@ -486,8 +495,6 @@ class ModuleLoader:
: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``.
""" """
from . import builtin_modules
return ( return (
Path(builtin_modules.__file__).resolve().parent Path(builtin_modules.__file__).resolve().parent
/ module_name / module_name
+39 -31
View File
@@ -26,15 +26,16 @@ import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark from owlbot._version import __version__
from ..api.context import CommandContext, EventContext from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
from ..api.event_types import ChatEvent from owlbot.api.context import CommandContext, EventContext
from owlbot.api.event_types import ChatEvent
if TYPE_CHECKING: if TYPE_CHECKING:
from types import ModuleType from types import ModuleType
from ..api.context import ModuleContext from owlbot.api.context import ModuleContext
from ..api.owncast_client import OwncastClient from owlbot.api.owncast_client import OwncastClient
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]] type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
@@ -72,7 +73,7 @@ class CommandRegistry:
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False, requires_authenticated: bool = False,
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int = 0,
module_name: str, module_name: str,
) -> None: ) -> None:
"""Register a command handler. """Register a command handler.
@@ -317,7 +318,7 @@ class CommandDispatcher:
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False, requires_authenticated: bool = False,
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int = 0,
module_name: str, module_name: str,
) -> None: ) -> None:
"""Register a command handler. """Register a command handler.
@@ -438,14 +439,17 @@ class CommandDispatcher:
user = event.user user = event.user
logger.info( logger.info(
f"Command '{command_info.name}' invoked by {user.display_name}" "Command '%s' invoked by %s with args: %r",
f" with args: {args!r}" command_info.name,
user.display_name,
args,
) )
if command_info.requires_authenticated and not user.is_authenticated: if command_info.requires_authenticated and not user.is_authenticated:
logger.info( logger.info(
f"Command '{command_name}' denied for {user.display_name}: " "Command '%s' denied for %s: authentication required",
"authentication required" command_name,
user.display_name,
) )
await self._owncast_client.send_system_message_to_client( await self._owncast_client.send_system_message_to_client(
event.client_id, event.client_id,
@@ -455,8 +459,9 @@ class CommandDispatcher:
if command_info.requires_moderator and not user.is_moderator: if command_info.requires_moderator and not user.is_moderator:
logger.info( logger.info(
f"Command '{command_name}' denied for {user.display_name}: " "Command '%s' denied for %s: moderator required",
"moderator required" command_name,
user.display_name,
) )
await self._owncast_client.send_system_message_to_client( await self._owncast_client.send_system_message_to_client(
event.client_id, event.client_id,
@@ -470,8 +475,10 @@ class CommandDispatcher:
if last is not None and now - last < command_info.cooldown: if last is not None and now - last < command_info.cooldown:
remaining = math.ceil(command_info.cooldown - (now - last)) remaining = math.ceil(command_info.cooldown - (now - last))
logger.info( logger.info(
f"Command '{command_name}' denied for {user.display_name}: " "Command '%s' denied for %s: on cooldown (%ds remaining)",
f"on cooldown ({remaining}s remaining)" command_name,
user.display_name,
remaining,
) )
await self._owncast_client.send_system_message_to_client( await self._owncast_client.send_system_message_to_client(
event.client_id, event.client_id,
@@ -501,12 +508,14 @@ class CommandDispatcher:
) )
except TimeoutError: except TimeoutError:
logger.warning( logger.warning(
f"Built-in command '{command_info.name}' " "Built-in command '%s' cancelled after %ss timeout.",
f"cancelled after {self._handler_timeout}s timeout." command_info.name,
self._handler_timeout,
) )
except Exception as e: except Exception:
logger.exception( logger.exception(
f"Built-in command '{command_info.name}' raised exception: {e}" "Built-in command '%s' raised exception.",
command_info.name,
) )
return return
@@ -544,26 +553,25 @@ class CommandDispatcher:
) )
except TimeoutError: except TimeoutError:
logger.warning( logger.warning(
f"Command handler '{command_info.name}' " "Command handler '%s' from module '%s' cancelled after %ss timeout.",
f"from module '{command_info.module_name}' " command_info.name,
f"cancelled after " command_info.module_name,
f"{self._handler_timeout}s timeout." self._handler_timeout,
) )
except Exception as e: except Exception:
logger.exception( logger.exception(
f"Command handler '{command_info.name}' " "Command handler '%s' from module '%s' raised exception.",
f"from module '{command_info.module_name}' " command_info.name,
f"raised exception: {e}" command_info.module_name,
) )
def _register_builtin_commands(self) -> None: def _register_builtin_commands(self) -> None:
"""Register all built-in commands.""" """Register all built-in commands."""
from .._version import __version__
loaded_modules = self._loaded_modules loaded_modules = self._loaded_modules
async def about_with_modules( async def about_with_modules(
event: ChatEvent, owncast_client: OwncastClient event: ChatEvent, # noqa: ARG001 # required by builtin handler signature
owncast_client: OwncastClient,
) -> None: ) -> None:
module_count = len(loaded_modules) module_count = len(loaded_modules)
module_list = ( module_list = (
@@ -612,7 +620,7 @@ class ModuleCommands:
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False, requires_authenticated: bool = False,
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int = 0,
) -> None: ) -> None:
"""Register a command handler for this module. """Register a command handler for this module.
+15 -16
View File
@@ -26,9 +26,9 @@ import time
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, NamedTuple, cast from typing import TYPE_CHECKING, NamedTuple, cast
from ..api.context import EventContext, PropagationState from owlbot.api.context import EventContext, PropagationState
from ..api.event_types import ChatEvent, Event, EventType, log_event from owlbot.api.event_types import ChatEvent, Event, EventType, log_event
from ..api.events import EventHandler, EventMark, Priority from owlbot.api.events import EventHandler, EventMark, Priority
class HandlerEntry(NamedTuple): class HandlerEntry(NamedTuple):
@@ -45,7 +45,7 @@ if TYPE_CHECKING:
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from types import ModuleType from types import ModuleType
from ..api.context import ModuleContext from owlbot.api.context import ModuleContext
logger = logging.getLogger("owlbot.events") logger = logging.getLogger("owlbot.events")
@@ -344,9 +344,7 @@ class EventDispatcher:
) )
break break
await self._call_handler( await self._call_handler(handler, event, module_name, propagation)
handler, event, event_type, module_name, propagation
)
else: else:
logger.debug("No handlers registered for event type: %s", event_type) logger.debug("No handlers registered for event type: %s", event_type)
@@ -362,14 +360,13 @@ class EventDispatcher:
else: else:
try: try:
await self._command_dispatch(event) await self._command_dispatch(event)
except Exception as e: except Exception:
logger.exception(f"Command dispatch failed: {e}") logger.exception("Command dispatch failed.")
async def _call_handler( async def _call_handler(
self, self,
handler: EventHandler, handler: EventHandler,
event: Event, event: Event,
event_type: EventType,
module_name: str, module_name: str,
propagation: PropagationState, propagation: PropagationState,
) -> None: ) -> None:
@@ -377,7 +374,6 @@ class EventDispatcher:
: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.
:param event_type: The type of event being dispatched.
:param module_name: The module that owns this handler. :param module_name: The module that owns this handler.
:param propagation: Shared propagation state for this dispatch cycle. :param propagation: Shared propagation state for this dispatch cycle.
""" """
@@ -400,13 +396,16 @@ class EventDispatcher:
logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed) logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed)
except TimeoutError: except TimeoutError:
logger.warning( logger.warning(
f"Handler '{handler_name}' from module '{module_name}' " "Handler '%s' from module '%s' cancelled after %ss timeout.",
f"cancelled after {self._handler_timeout}s timeout." handler_name,
module_name,
self._handler_timeout,
) )
except Exception as e: except Exception:
logger.exception( logger.exception(
f"Handler '{handler_name}' from module " "Handler '%s' from module '%s' raised exception.",
f"'{module_name}' raised exception: {e}" handler_name,
module_name,
) )
+13 -10
View File
@@ -30,14 +30,14 @@ import orjson
from aiohttp import web from aiohttp import web
from aiohttp.web import DynamicResource from aiohttp.web import DynamicResource
from ..api.context import RouteContext from owlbot.api.context import RouteContext
from ..api.routes import RouteHandler, RouteInfo, RouteMark from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from types import ModuleType from types import ModuleType
from ..api.context import ModuleContext from owlbot.api.context import ModuleContext
logger = logging.getLogger("owlbot.web") logger = logging.getLogger("owlbot.web")
@@ -270,7 +270,7 @@ class RouteRegistry:
""" """
method = method.upper() method = method.upper()
for group in self._groups: for group in self._groups:
match_dict = group.resource._match(full_path) # no public API alternative match_dict = group.resource._match(full_path) # noqa: SLF001 # no public API alternative
if match_dict is not None: if match_dict is not None:
for handler in group.handlers: for handler in group.handlers:
if method in handler.methods: if method in handler.methods:
@@ -615,20 +615,23 @@ class RouteDispatcher:
) )
# pragma: no branch — defensive against untyped handlers # pragma: no branch — defensive against untyped handlers
mod_logger.error( # type: ignore[unreachable] mod_logger.error( # type: ignore[unreachable]
f"Route handler '{route_info.full_path}' returned " "Route handler '%s' returned unsupported type: %s",
f"unsupported type: {type(result).__name__}" route_info.full_path,
type(result).__name__,
) )
return web.Response(status=500) return web.Response(status=500)
except TimeoutError: except TimeoutError:
mod_logger.warning( mod_logger.warning(
f"Route handler '{route_info.full_path}' timed out " "Route handler '%s' timed out after %ss.",
f"after {self._handler_timeout}s." route_info.full_path,
self._handler_timeout,
) )
return web.Response(status=500) return web.Response(status=500)
except Exception as e: except Exception:
mod_logger.exception( mod_logger.exception(
f"Route handler '{route_info.full_path}' raised exception: {e}" "Route handler '%s' raised exception.",
route_info.full_path,
) )
return web.Response(status=500) return web.Response(status=500)
finally: finally:
+20 -48
View File
@@ -72,60 +72,32 @@ target-version = "py312"
extend-exclude = ["owlbot/_version.py"] # auto-generated by hatch-vcs extend-exclude = ["owlbot/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = ["ALL"]
# Core
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"N", # pep8-naming
"D", # pydocstyle
"I", # isort
"ICN", # flake8-import-conventions
# Correctness & bugs
"B", # flake8-bugbear
"ASYNC", # flake8-async
"DTZ", # flake8-datetimez
"RSE", # flake8-raise
"RET", # flake8-return
"A", # flake8-builtins
"PIE", # flake8-pie
# Modernization & simplification
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"FLY", # flynt (f-string conversion)
"PTH", # flake8-use-pathlib
# Performance
"PERF", # Perflint
# Security
"S", # flake8-bandit
# Code hygiene
"T10", # flake8-debugger
"T20", # flake8-print
"ERA", # eradicate
"PGH", # pygrep-hooks
"TCH", # flake8-type-checking
# Testing
"PT", # flake8-pytest-style
# Ruff-specific
"RUF", # Ruff-specific rules
]
ignore = [ ignore = [
"D203", # incompatible with D211 (no blank line before class docstring) "ANN401", # Any is valid at system boundaries; mypy strict handles real issues
"D213", # incompatible with D212 (summary on first line) "C901", # McCabe complexity: noisy and not actionable
"COM812", # handled by the formatter
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
"EM", # exception message style: inline literals are fine
"PLR0911", # too many return statements: flat early-returns are clear
"PLR0912", # too many branches: inherent in parsers, validators, CLI
"PLR0913", # too many arguments: API surfaces and constructors need them
"PLR0915", # too many statements: inherent in parsers, validators, CLI
"TRY003", # inline exception messages are fine (complements EM ignore)
"TRY301", # raise inside try: guard clauses don't need helper functions
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"owlbot/__init__.py" = ["E402"] # constants defined before imports intentionally "owlbot/__init__.py" = ["E402"] # constants defined before imports intentionally
"owlbot/__main__.py" = ["T201"] # CLI entry point uses print() for user output "owlbot/__main__.py" = ["T201"] # CLI entry point uses print() for user output
"tests/**" = ["S101", "S311"] # assert is standard for pytest; random is fine in tests "tests/**" = [
"S101", # assert is standard for pytest
"S311", # pseudo-random generators are fine in tests
"SLF001", # tests legitimately access private members for verification
"ARG001", # unused args are normal for fixtures and handler stubs
"PLR2004", # magic values are clear in test assertions
]
[tool.codespell] [tool.codespell]
skip = "owlbot/_version.py,uv.lock" skip = "owlbot/_version.py,uv.lock"
+1
View File
@@ -0,0 +1 @@
"""Owlbot test suite."""
+6 -4
View File
@@ -116,6 +116,7 @@ class TestOnCommandDecorator:
self, self,
name: str, name: str,
aliases: list[str] | None, aliases: list[str] | None,
*,
auth: bool, auth: bool,
mod: bool, mod: bool,
cooldown: int, cooldown: int,
@@ -503,7 +504,7 @@ class TestCommandDispatcherDispatch:
module_ctx = make_module_context(storage, module_name) module_ctx = make_module_context(storage, module_name)
dispatcher = CommandDispatcher( dispatcher = CommandDispatcher(
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
owncast_client=stub, # type: ignore[arg-type] owncast_client=stub, # type: ignore[arg-type]
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
loaded_modules=set(), loaded_modules=set(),
@@ -761,7 +762,7 @@ class TestCommandDispatcherDispatch:
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,)) row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
assert row is not None assert row is not None
assert row["name"] == "apple" assert row["name"] == "apple"
assert "raised exception: boom" in caplog.text assert "raised exception." in caplog.text
async def test_timeout_is_logged( async def test_timeout_is_logged(
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
@@ -854,7 +855,7 @@ class TestCommandDispatcherDispatch:
with caplog.at_level(logging.ERROR, logger="owlbot.commands"): with caplog.at_level(logging.ERROR, logger="owlbot.commands"):
await dispatcher.dispatch(_make_chat_event(body="!test")) await dispatcher.dispatch(_make_chat_event(body="!test"))
assert "raised exception: builtin-fail" in caplog.text assert "raised exception." in caplog.text
async def test_builtin_command_timeout( async def test_builtin_command_timeout(
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
@@ -963,6 +964,7 @@ class TestModuleCommands:
other_setup: str | None, other_setup: str | None,
call_module: str, call_module: str,
trigger: str, trigger: str,
*,
expected: bool, expected: bool,
) -> None: ) -> None:
"""ModuleCommands.unregister respects ownership.""" """ModuleCommands.unregister respects ownership."""
@@ -1059,7 +1061,7 @@ class TestCommandContext:
], ],
) )
def test_property_proxying( def test_property_proxying(
self, storage: ModuleStorage, prop: str, use_is: bool self, storage: ModuleStorage, prop: str, *, use_is: bool
) -> None: ) -> None:
"""CommandContext properties proxy to the underlying ModuleContext.""" """CommandContext properties proxy to the underlying ModuleContext."""
cmd_ctx, module_ctx, _, _ = self._make_command_context(storage) cmd_ctx, module_ctx, _, _ = self._make_command_context(storage)
+3 -2
View File
@@ -540,7 +540,7 @@ class _StubModuleCommands:
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False, requires_authenticated: bool = False,
requires_moderator: bool = False, requires_moderator: bool = False,
cooldown: int | float = 0, cooldown: int = 0,
) -> None: ) -> None:
all_new = {name} | set(aliases or []) all_new = {name} | set(aliases or [])
for trigger in all_new: for trigger in all_new:
@@ -551,6 +551,7 @@ class _StubModuleCommands:
self._registered[name] = { self._registered[name] = {
"handler": handler, "handler": handler,
"aliases": set(aliases or []), "aliases": set(aliases or []),
"requires_authenticated": requires_authenticated,
"requires_moderator": requires_moderator, "requires_moderator": requires_moderator,
"cooldown": cooldown, "cooldown": cooldown,
} }
@@ -702,7 +703,7 @@ class TestManagerCreateCommand:
cmd_ctx: ModuleContext, cmd_ctx: ModuleContext,
stub_commands: _StubModuleCommands, stub_commands: _StubModuleCommands,
) -> None: ) -> None:
"""If DB insert fails, the registry registration is rolled back.""" """If DB insert fails, the command is not registered."""
# Create a manager with a repo whose storage is closed. # Create a manager with a repo whose storage is closed.
closed_storage = ModuleStorage(None, "closed") closed_storage = ModuleStorage(None, "closed")
bad_repo = CommandRepository(closed_storage) bad_repo = CommandRepository(closed_storage)
+1 -1
View File
@@ -229,7 +229,7 @@ class TestUser:
pytest.param(["MOD", "MODERATOR"], True, id="moderator-among-others"), pytest.param(["MOD", "MODERATOR"], True, id="moderator-among-others"),
], ],
) )
def test_is_moderator(self, scopes: list[str], expected: bool) -> None: def test_is_moderator(self, scopes: list[str], *, expected: bool) -> None:
"""is_moderator reflects whether MODERATOR is in scopes.""" """is_moderator reflects whether MODERATOR is in scopes."""
user = User.from_dict({"scopes": scopes}) user = User.from_dict({"scopes": scopes})
assert user.is_moderator is expected assert user.is_moderator is expected
+23 -22
View File
@@ -305,6 +305,7 @@ class TestEventRegistry:
def test_unregister( def test_unregister(
self, self,
setup_types: tuple[EventType, ...] | None, setup_types: tuple[EventType, ...] | None,
*,
expected_return: bool, expected_return: bool,
expected_remaining: int, expected_remaining: int,
) -> None: ) -> None:
@@ -437,7 +438,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(handler, (EventType.CHAT,), "mod_a") dispatcher.register(handler, (EventType.CHAT,), "mod_a")
@@ -466,7 +467,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(high_handler, (EventType.CHAT,), "mod_a", Priority.HIGH) dispatcher.register(high_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -492,7 +493,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(first, (EventType.CHAT,), "mod_a", Priority.NORMAL) dispatcher.register(first, (EventType.CHAT,), "mod_a", Priority.NORMAL)
@@ -511,7 +512,7 @@ class TestEventDispatcherDispatch:
module_ctx = make_module_context(storage, "mod_a") module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
with caplog.at_level(logging.DEBUG, logger="owlbot.events"): with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
@@ -539,7 +540,7 @@ class TestEventDispatcherDispatch:
module_ctx = make_module_context(storage, "mod_a") module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
chat_event = _make_chat_event(body="hello") chat_event = _make_chat_event(body="hello")
@@ -567,7 +568,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH) dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -593,7 +594,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH) dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -624,7 +625,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(stopper, (EventType.CHAT,), "mod_a", Priority.HIGH) dispatcher.register(stopper, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -657,7 +658,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(handler, (EventType.CHAT,), "mod_a") dispatcher.register(handler, (EventType.CHAT,), "mod_a")
@@ -687,7 +688,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(handler, (EventType.CHAT,), "mod_a") dispatcher.register(handler, (EventType.CHAT,), "mod_a")
@@ -697,7 +698,7 @@ class TestEventDispatcherStorage:
row = await storage.fetch_one("SELECT val FROM test_tbl") row = await storage.fetch_one("SELECT val FROM test_tbl")
assert row is not None assert row is not None
assert row[0] == "persisted" assert row[0] == "persisted"
assert any("raised exception: boom" in r.message for r in caplog.records) assert any("raised exception." in r.message for r in caplog.records)
async def test_exception_does_not_stop_next_handler( async def test_exception_does_not_stop_next_handler(
self, storage: ModuleStorage self, storage: ModuleStorage
@@ -719,7 +720,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(failing_handler, (EventType.CHAT,), "mod_a", Priority.HIGH) dispatcher.register(failing_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -742,7 +743,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=0.05, handler_timeout=0.05,
) )
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a") dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
@@ -772,7 +773,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=0.05, handler_timeout=0.05,
) )
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a") dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
@@ -802,7 +803,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=0.05, handler_timeout=0.05,
) )
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a") dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
@@ -820,7 +821,7 @@ class TestCommandDispatchPhase:
[ [
pytest.param( pytest.param(
EventType.CHAT, EventType.CHAT,
lambda: _make_chat_event(), _make_chat_event,
1, 1,
id="chat-triggers", id="chat-triggers",
), ),
@@ -853,7 +854,7 @@ class TestCommandDispatchPhase:
module_ctx = make_module_context(storage, "mod_a") module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
event = event_factory() event = event_factory()
@@ -877,7 +878,7 @@ class TestCommandDispatchPhase:
module_ctx = make_module_context(storage, "mod_a") module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
dispatcher.register(stopper, (EventType.CHAT,), "mod_a") dispatcher.register(stopper, (EventType.CHAT,), "mod_a")
@@ -899,12 +900,12 @@ class TestCommandDispatchPhase:
module_ctx = make_module_context(storage, "mod_a") module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher( dispatcher = EventDispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx, get_module_context=lambda _name: module_ctx,
handler_timeout=5.0, handler_timeout=5.0,
) )
with caplog.at_level(logging.ERROR, logger="owlbot.events"): with caplog.at_level(logging.ERROR, logger="owlbot.events"):
await dispatcher.dispatch(EventType.CHAT, _make_chat_event()) await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
assert any("Command dispatch failed: fail" in r.message for r in caplog.records) assert any("Command dispatch failed." in r.message for r in caplog.records)
class TestModuleEvents: class TestModuleEvents:
@@ -974,7 +975,7 @@ class TestModuleEvents:
], ],
) )
def test_unregister( def test_unregister(
self, register_on: str | None, unregister_from: str, expected: bool self, register_on: str | None, unregister_from: str, *, expected: bool
) -> None: ) -> None:
"""ModuleEvents.unregister() respects ownership checks.""" """ModuleEvents.unregister() respects ownership checks."""
@@ -1061,7 +1062,7 @@ class TestEventContext:
], ],
) )
def test_property_proxying( def test_property_proxying(
self, storage: ModuleStorage, prop: str, use_is: bool self, storage: ModuleStorage, prop: str, *, use_is: bool
) -> None: ) -> None:
"""EventContext properties proxy to the underlying ModuleContext.""" """EventContext properties proxy to the underlying ModuleContext."""
module_ctx = make_module_context(storage, "mod_a") module_ctx = make_module_context(storage, "mod_a")
+17 -14
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import asyncio import asyncio
import types import types
from http import HTTPStatus
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import pytest import pytest
@@ -86,6 +87,7 @@ class TestOnRouteDecorator:
self, self,
path: str, path: str,
methods: list[str] | None, methods: list[str] | None,
*,
streaming: bool, streaming: bool,
expected_path: str, expected_path: str,
expected_methods: list[str] | None, expected_methods: list[str] | None,
@@ -123,7 +125,7 @@ class TestRouteInfo:
pytest.param(False, id="streaming-false"), pytest.param(False, id="streaming-false"),
], ],
) )
def test_stores_streaming(self, streaming: bool) -> None: def test_stores_streaming(self, *, streaming: bool) -> None:
"""RouteInfo stores the streaming flag.""" """RouteInfo stores the streaming flag."""
info = RouteInfo( info = RouteInfo(
path="/x", path="/x",
@@ -506,7 +508,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "api" request.match_info["path"] = "api"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 200 assert response.status == HTTPStatus.OK
assert response.content_type == "application/json" assert response.content_type == "application/json"
async def test_dispatch_web_response(self) -> None: async def test_dispatch_web_response(self) -> None:
@@ -523,7 +525,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "page" request.match_info["path"] = "page"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 201 assert response.status == HTTPStatus.CREATED
async def test_dispatch_none_response(self) -> None: async def test_dispatch_none_response(self) -> None:
"""Handler returning None produces 204 No Content.""" """Handler returning None produces 204 No Content."""
@@ -539,7 +541,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "hook" request.match_info["path"] = "hook"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 204 assert response.status == HTTPStatus.NO_CONTENT
async def test_dispatch_404(self) -> None: async def test_dispatch_404(self) -> None:
"""Dispatch to unregistered path returns 404.""" """Dispatch to unregistered path returns 404."""
@@ -550,7 +552,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "nope" request.match_info["path"] = "nope"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 404 assert response.status == HTTPStatus.NOT_FOUND
async def test_dispatch_405(self) -> None: async def test_dispatch_405(self) -> None:
"""Dispatch with wrong method returns 405 with Allow header.""" """Dispatch with wrong method returns 405 with Allow header."""
@@ -562,7 +564,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "data" request.match_info["path"] = "data"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 405 assert response.status == HTTPStatus.METHOD_NOT_ALLOWED
assert "GET" in response.headers["Allow"] assert "GET" in response.headers["Allow"]
async def test_dispatch_handler_exception(self) -> None: async def test_dispatch_handler_exception(self) -> None:
@@ -579,7 +581,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "bad" request.match_info["path"] = "bad"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 500 assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_dispatch_unsupported_return_type(self) -> None: async def test_dispatch_unsupported_return_type(self) -> None:
"""Handler returning unsupported type produces 500.""" """Handler returning unsupported type produces 500."""
@@ -595,7 +597,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "bad" request.match_info["path"] = "bad"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 500 assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_dispatch_timeout(self) -> None: async def test_dispatch_timeout(self) -> None:
"""Non-streaming handler exceeding timeout returns 500.""" """Non-streaming handler exceeding timeout returns 500."""
@@ -612,7 +614,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "slow" request.match_info["path"] = "slow"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 500 assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_dispatch_streaming_bypasses_timeout(self) -> None: async def test_dispatch_streaming_bypasses_timeout(self) -> None:
"""Streaming handler exceeding timeout is not cancelled.""" """Streaming handler exceeding timeout is not cancelled."""
@@ -629,7 +631,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "stream" request.match_info["path"] = "stream"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 200 assert response.status == HTTPStatus.OK
async def test_dispatch_streaming_exception(self) -> None: async def test_dispatch_streaming_exception(self) -> None:
"""Streaming handler that raises returns 500.""" """Streaming handler that raises returns 500."""
@@ -645,7 +647,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "bad" request.match_info["path"] = "bad"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 500 assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_dispatch_streaming_normal_return(self) -> None: async def test_dispatch_streaming_normal_return(self) -> None:
"""Streaming handler returning dict produces JSON response.""" """Streaming handler returning dict produces JSON response."""
@@ -661,7 +663,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "fast" request.match_info["path"] = "fast"
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 200 assert response.status == HTTPStatus.OK
async def test_dispatch_path_params(self) -> None: async def test_dispatch_path_params(self) -> None:
"""Path parameters are populated in RouteContext.match_info.""" """Path parameters are populated in RouteContext.match_info."""
@@ -695,7 +697,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "" request.match_info["path"] = ""
response = await dispatcher.dispatch(request) response = await dispatcher.dispatch(request)
assert response.status == 200 assert response.status == HTTPStatus.OK
class TestRouteDispatcherDrainHandlers: class TestRouteDispatcherDrainHandlers:
@@ -1012,6 +1014,7 @@ class TestModuleRoutes:
) )
def test_exists( def test_exists(
self, self,
*,
register: bool, register: bool,
check_method: str | None, check_method: str | None,
expected: bool, expected: bool,
@@ -1084,7 +1087,7 @@ class TestRouteContext:
pytest.param("templates", True, id="templates"), pytest.param("templates", True, id="templates"),
], ],
) )
def test_property_proxying(self, prop: str, use_is: bool) -> None: def test_property_proxying(self, prop: str, *, use_is: bool) -> None:
"""RouteContext properties proxy to the underlying ModuleContext.""" """RouteContext properties proxy to the underlying ModuleContext."""
ctx, module_ctx = self._make_route_context() ctx, module_ctx = self._make_route_context()
ctx_val = getattr(ctx, prop) ctx_val = getattr(ctx, prop)
+1 -1
View File
@@ -52,7 +52,7 @@ class _StubOwncastClient:
self.sent: list[str] = [] self.sent: list[str] = []
self._fail = fail self._fail = fail
async def send_message(self, body: str, *, unsanitized: bool = False) -> str: async def send_message(self, body: str, **_kwargs: bool) -> str:
"""Record *body* or raise if configured to fail.""" """Record *body* or raise if configured to fail."""
if self._fail: if self._fail:
raise RuntimeError("network error") raise RuntimeError("network error")