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()
overrides: dict[str, Any] = {}
if args.host is not None:
overrides["host"] = args.host
if args.port is not None:
overrides["port"] = args.port
if args.modules is not None:
overrides["modules_dir"] = args.modules
if args.storage_dir is not None:
overrides["storage_dir"] = args.storage_dir
if args.log_dir is not None:
overrides["log_dir"] = args.log_dir
_cli_override_map = {
"host": "host",
"port": "port",
"modules": "modules_dir",
"storage_dir": "storage_dir",
"log_dir": "log_dir",
}
overrides: dict[str, Any] = {
v: getattr(args, k)
for k, v in _cli_override_map.items()
if getattr(args, k) is not None
}
if args.webhook_path:
try:
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)
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
else:
base = f"http://{config.host}:{config.port}"
@@ -225,7 +227,7 @@ Examples:
logger = logging.getLogger("owlbot")
logger.info(f"Log level: {logging.getLevelName(log_level)}")
logger.info("Log level: %s", logging.getLevelName(log_level))
try:
bot = Owlbot(
@@ -233,8 +235,8 @@ Examples:
overrides=overrides,
skip_api_check=args.skip_api_check,
)
except StartupError as e:
logger.error(str(e))
except StartupError:
logger.exception("Bot initialization failed.")
sys.exit(1)
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.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:
logger.warning(f"Could not set up file logging: {e}")
logger.warning("Could not set up file logging: %s", e)
async def _run() -> None:
loop = asyncio.get_running_loop()
@@ -267,7 +269,7 @@ Examples:
wd_task: asyncio.Task[None] | None = None
if wd_interval is not None:
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(
_watchdog_loop(wd_interval, stop_event),
@@ -285,8 +287,8 @@ Examples:
try:
uvloop.run(_run())
except StartupError as e:
logger.error(str(e))
except StartupError:
logger.exception("Startup failed.")
sys.exit(1)
except Exception:
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).
# Module-scoped service wrappers (re-exported from registries).
from ..registries.commands import ModuleCommands
from ..registries.events import ModuleEvents
from ..registries.routes import ModuleRoutes
from owlbot.registries.commands import ModuleCommands
from owlbot.registries.events import ModuleEvents
from owlbot.registries.routes import ModuleRoutes
# Command system (module-facing).
from .commands import (
+5 -4
View File
@@ -40,7 +40,7 @@ class CommandMark(TypedDict):
aliases: list[str] | tuple[str, ...] | None
requires_authenticated: bool
requires_moderator: bool
cooldown: int | float
cooldown: int
@dataclass(frozen=True, slots=True)
@@ -67,7 +67,7 @@ class CommandInfo:
aliases: frozenset[str] = field(default_factory=frozenset)
requires_authenticated: bool = False
requires_moderator: bool = False
cooldown: int | float = 0
cooldown: int = 0
all_triggers: frozenset[str] = field(init=False, repr=False)
def __post_init__(self) -> None:
@@ -81,7 +81,7 @@ def on_command(
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
cooldown: int = 0,
) -> Callable[[CommandHandler], CommandHandler]:
"""Register a command handler.
@@ -97,7 +97,8 @@ def on_command(
def decorator(func: CommandHandler) -> CommandHandler:
# Mark the function with command info for deferred registration.
# 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,
aliases=aliases,
requires_authenticated=requires_authenticated,
+12 -12
View File
@@ -63,7 +63,7 @@ class Config:
self,
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
):
) -> None:
"""Initialize the configuration manager.
:param config_path: Path to the YAML config file.
@@ -340,13 +340,13 @@ class Config:
try:
with self.config_path.open() as f:
self._data = _yaml.load(f) or {}
except YAMLError as e:
logger.error(f"Failed to parse config file: {e}")
except YAMLError:
logger.exception("Failed to parse config file.")
raise
except OSError as e:
logger.error(f"Failed to read config file: {e}")
except OSError:
logger.exception("Failed to read config file.")
raise
logger.info(f"Configuration loaded from: {self.config_path.absolute()}")
logger.info("Configuration loaded from: %s", self.config_path.absolute())
else:
raise FileNotFoundError(f"Config file not found: {self.config_path}")
@@ -403,13 +403,13 @@ class Config:
try:
with self.config_path.open("w") as f:
_yaml.dump(self._data, f)
except YAMLError as e:
logger.error(f"Failed to serialize config data: {e}")
except YAMLError:
logger.exception("Failed to serialize config data.")
raise
except OSError as e:
logger.error(f"Failed to write config file: {e}")
except OSError:
logger.exception("Failed to write config file.")
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:
"""Check if a module is enabled.
@@ -505,7 +505,7 @@ class Config:
class ModuleConfig:
"""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.
:param config: The parent Config object.
+4 -3
View File
@@ -30,9 +30,10 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from aiohttp import web
from ..registries.commands import ModuleCommands
from ..registries.events import ModuleEvents
from ..registries.routes import ModuleRoutes
from owlbot.registries.commands import ModuleCommands
from owlbot.registries.events import ModuleEvents
from owlbot.registries.routes import ModuleRoutes
from .commands import CommandEvent
from .config import ModuleConfig
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)
except ValueError:
# 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
@@ -438,22 +438,24 @@ def log_event(event_type: EventType, event: Event) -> None:
"""
match event:
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):
logger.info(f"[{event_type}] {user.display_name} joined.")
logger.info("[%s] %s joined.", event_type, user.display_name)
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):
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):
logger.info(f'[{event_type}] Stream started: "{title}"')
logger.info('[%s] Stream started: "%s"', event_type, title)
case StreamStoppedEvent():
logger.info(f"[{event_type}] Stream ended.")
logger.info("[%s] Stream ended.", event_type)
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):
action = "shown" if visible else "hidden"
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:
# Mark the function with event info for deferred registration.
# 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
)
return func
+3 -8
View File
@@ -25,6 +25,8 @@ from typing import Any
import aiohttp
import orjson
from owlbot._version import __version__
logger = logging.getLogger("owlbot.http")
@@ -56,14 +58,7 @@ class HttpClient:
return self._session
async def _start(self) -> None:
"""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__
"""Create the underlying aiohttp session."""
connector = aiohttp.TCPConnector(keepalive_timeout=120)
timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10)
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.
: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
@@ -65,5 +66,6 @@ def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
:param func: The teardown function to mark.
: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
+37 -34
View File
@@ -185,7 +185,7 @@ class OwncastAdminClient(OwncastClient):
def __init__(
self, base_url: str, username: str, password: str, http_client: HttpClient
):
) -> None:
"""Initialize the admin client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
@@ -198,7 +198,7 @@ class OwncastAdminClient(OwncastClient):
self._headers = None
self._auth = aiohttp.BasicAuth(username, password)
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]:
@@ -265,7 +265,7 @@ class OwncastAdminClient(OwncastClient):
return list(await self._get("/api/admin/chat/clients"))
async def set_message_visibility(
self, message_ids: list[str], visible: bool
self, message_ids: list[str], *, visible: bool
) -> str:
"""Hide or show chat messages.
@@ -275,13 +275,13 @@ class OwncastAdminClient(OwncastClient):
"""
action = "Showing" if visible else "Hiding"
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(
"/api/admin/chat/messagevisibility",
{"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.
:param user_id: The user ID to modify.
@@ -289,7 +289,7 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server.
"""
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(
"/api/admin/chat/users/setenabled",
{"userId": user_id, "enabled": enabled},
@@ -302,7 +302,7 @@ class OwncastAdminClient(OwncastClient):
"""
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.
:param user_id: The user ID to modify.
@@ -310,7 +310,7 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server.
"""
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(
"/api/admin/chat/users/setmoderator",
{"userId": user_id, "isModerator": is_mod},
@@ -329,7 +329,7 @@ class OwncastAdminClient(OwncastClient):
:param ip: The IP address to ban.
: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})
async def unban_ip_address(self, ip: str) -> str:
@@ -338,7 +338,7 @@ class OwncastAdminClient(OwncastClient):
:param ip: The IP address to unban.
: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})
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"
)
async def set_chat_disabled(self, disabled: bool) -> str:
async def set_chat_disabled(self, *, disabled: bool) -> str:
"""Enable or disable the chat.
: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"
)
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.
: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"
)
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.
:param enabled: True to enable established mode.
@@ -520,7 +520,7 @@ class OwncastAdminClient(OwncastClient):
"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.
:param enabled: True to enable spam protection.
@@ -532,7 +532,7 @@ class OwncastAdminClient(OwncastClient):
"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.
:param enabled: True to enable the slur filter.
@@ -542,7 +542,7 @@ class OwncastAdminClient(OwncastClient):
"/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.
: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")
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.
:param enabled: True to enable directory listing.
@@ -560,7 +560,7 @@ class OwncastAdminClient(OwncastClient):
"/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.
:param hide: True to hide the viewer count.
@@ -570,7 +570,7 @@ class OwncastAdminClient(OwncastClient):
"/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.
:param disabled: True to disable search indexing.
@@ -630,7 +630,7 @@ class OwncastAdminClient(OwncastClient):
"""
return await self._set_config_value(
"/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",
)
@@ -642,7 +642,7 @@ class OwncastAdminClient(OwncastClient):
"""
return await self._set_config_value(
"/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",
)
@@ -654,7 +654,7 @@ class OwncastAdminClient(OwncastClient):
"""
return await self._set_config_value(
"/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",
)
@@ -666,12 +666,13 @@ class OwncastAdminClient(OwncastClient):
"""
return await self._set_config_value(
"/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",
)
async def set_s3_config(
self,
*,
enabled: bool,
endpoint: str,
access_key: str,
@@ -704,6 +705,7 @@ class OwncastAdminClient(OwncastClient):
async def set_discord_notifications(
self,
*,
enabled: bool,
webhook: str,
go_live_message: str,
@@ -727,6 +729,7 @@ class OwncastAdminClient(OwncastClient):
async def set_browser_notifications(
self,
*,
enabled: bool,
go_live_message: str,
) -> str:
@@ -795,7 +798,7 @@ class OwncastAdminClient(OwncastClient):
"/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).
:param enabled: True to enable federation.
@@ -845,7 +848,7 @@ class OwncastAdminClient(OwncastClient):
:param data_base64: Base64-encoded image data for the emoji.
:return: Success message from the server.
"""
self._logger.info(f"Uploading emoji: {name}")
self._logger.info("Uploading emoji: %s", name)
return await self._post(
"/api/admin/emoji/upload", {"name": name, "data": data_base64}
)
@@ -856,7 +859,7 @@ class OwncastAdminClient(OwncastClient):
:param name: The emoji name to delete.
: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})
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.
: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(
"/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.
: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})
async def get_access_tokens(self) -> list[dict[str, Any]]:
@@ -901,7 +904,7 @@ class OwncastAdminClient(OwncastClient):
:param scopes: List of permission scope strings.
: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(
"/api/admin/accesstokens/create", {"name": name, "scopes": scopes}
)
@@ -912,7 +915,7 @@ class OwncastAdminClient(OwncastClient):
:param token: The token string to delete.
: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})
@@ -943,7 +946,7 @@ class OwncastAdminClient(OwncastClient):
"""
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.
:param actor_iri: The ActivityPub actor IRI of the follower.
@@ -951,7 +954,7 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server.
"""
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(
"/api/admin/followers/approve",
{"actorIRI": actor_iri, "approved": approved},
@@ -963,7 +966,7 @@ class OwncastAdminClient(OwncastClient):
:param message: The message text to send.
: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})
async def get_logs(self) -> list[dict[str, Any]]:
@@ -998,5 +1001,5 @@ class OwncastAdminClient(OwncastClient):
:return: Success message from the server.
: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})
+28 -21
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
import aiohttp
@@ -52,7 +53,7 @@ def _extract_error(text: str) -> str:
class OwncastError(Exception):
"""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.
: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.
"""
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.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
@@ -118,7 +121,7 @@ class OwncastClient:
"""
if not unsanitized:
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})
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
@@ -136,7 +139,7 @@ class OwncastClient:
"""
if not unsanitized:
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})
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
@@ -154,7 +157,7 @@ class OwncastClient:
"""
if not unsanitized:
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})
async def send_system_message_to_client(
@@ -175,13 +178,13 @@ class OwncastClient:
"""
if not unsanitized:
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(
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
)
async def set_message_visibility(
self, message_ids: list[str], visible: bool
self, message_ids: list[str], *, visible: bool
) -> str:
"""Hide or show chat messages (moderation).
@@ -194,7 +197,7 @@ class OwncastClient:
"""
action = "Showing" if visible else "Hiding"
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(
"/api/integrations/chat/messagevisibility",
{"idArray": message_ids, "visible": visible},
@@ -222,7 +225,7 @@ class OwncastClient:
:param title: The new stream title.
: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})
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
@@ -246,11 +249,11 @@ class OwncastClient:
auth=self._auth,
allow_redirects=False,
) as response:
if response.status >= 400:
if response.status >= HTTPStatus.BAD_REQUEST:
text = await response.text()
message = _extract_error(text)
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)
self._logger.debug("POST %s -> %d", endpoint, response.status)
@@ -259,32 +262,36 @@ class OwncastClient:
result = await response.json(loads=orjson.loads)
except (ValueError, ContentTypeError):
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
if isinstance(result, dict):
# Is there an error field? Owncast returns
# {"error": "..."} for internal errors.
if "error" in result:
self._logger.error(
f"Error on POST {endpoint}: {result['error']}"
"Error on POST %s: %s", endpoint, result["error"]
)
raise OwncastError(response.status, result["error"])
# Does the success flag indicate failure?
if "success" in result and not result["success"]:
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)
# Is this a simple success response? Extract the message.
if "success" in result:
return str(result.get("message", ""))
# Unknown response shape from Owncast.
self._logger.warning(
f"Unknown response on POST {endpoint}: {result}"
"Unknown response on POST %s: %s", endpoint, result
)
return ""
return ""
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
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
@@ -305,11 +312,11 @@ class OwncastClient:
auth=self._auth,
allow_redirects=False,
) as response:
if response.status >= 400:
if response.status >= HTTPStatus.BAD_REQUEST:
text = await response.text()
message = _extract_error(text)
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)
self._logger.debug("GET %s -> %d", endpoint, response.status)
@@ -317,14 +324,14 @@ class OwncastClient:
result = await response.json(loads=orjson.loads)
except (ValueError, ContentTypeError):
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
# Is there an error field? Owncast returns
# {"error": "..."} for internal errors.
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"])
return result
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
+2 -1
View File
@@ -79,7 +79,8 @@ def on_route(
def decorator(func: RouteHandler) -> RouteHandler:
# Mark the function with route info for deferred registration.
# 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
)
return func
+13 -20
View File
@@ -22,7 +22,7 @@ import contextvars
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Self
import aiosqlite
@@ -53,7 +53,7 @@ class ModuleStorage:
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.
: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")
async def __aenter__(self) -> ModuleStorage:
async def __aenter__(self) -> Self:
"""Enter an async context manager that closes the storage on exit.
Enables ``async with ModuleStorage(...) as s:`` for scoped usage
@@ -141,13 +141,11 @@ class ModuleStorage:
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(
"Execute: %s%s", sql[:80], "..." if len(sql) > 80 else ""
)
self._logger.debug("Execute: %s", sql)
try:
return await conn.execute(sql, parameters)
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
async def execute_many(
@@ -166,15 +164,14 @@ class ModuleStorage:
"""
async with self._connection() as conn:
self._logger.debug(
"Execute many (%d rows): %s%s",
"Execute many (%d rows): %s",
len(parameters),
sql[:80],
"..." if len(sql) > 80 else "",
sql,
)
try:
return await conn.executemany(sql, parameters)
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
async def fetch_one(
@@ -191,14 +188,12 @@ class ModuleStorage:
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(
"Fetch one: %s%s", sql[:80], "..." if len(sql) > 80 else ""
)
self._logger.debug("Fetch one: %s", sql)
try:
cursor = await conn.execute(sql, parameters)
return await cursor.fetchone()
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
async def fetch_all(
@@ -215,14 +210,12 @@ class ModuleStorage:
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(
"Fetch all: %s%s", sql[:80], "..." if len(sql) > 80 else ""
)
self._logger.debug("Fetch all: %s", sql)
try:
cursor = await conn.execute(sql, parameters)
return list(await cursor.fetchall())
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
async def fetch_value(
@@ -264,7 +257,7 @@ class ModuleStorage:
await asyncio.shield(conn.close())
raise
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
@asynccontextmanager
+46 -33
View File
@@ -17,7 +17,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Self
from ruamel.yaml.error import YAMLError
@@ -45,15 +45,16 @@ class Owlbot:
self,
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
*,
skip_api_check: bool = False,
):
) -> None:
"""Initialize Owlbot.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides passed to the config manager.
: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
@@ -66,8 +67,9 @@ class Owlbot:
self.modules_dir = self.config.modules_dir
if not self.modules_dir.exists():
logger.info(
f"User modules directory not found: {self.modules_dir} "
f"(only built-in modules will be loaded)"
"User modules directory not found: %s "
"(only built-in modules will be loaded)",
self.modules_dir,
)
# Shared HTTP client (owns the aiohttp session lifecycle).
@@ -88,12 +90,12 @@ class Owlbot:
logger.debug("Owlbot initialization complete.")
async def __aenter__(self) -> Owlbot:
async def __aenter__(self) -> Self:
"""Start the bot as an async context manager."""
await self.start()
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."""
await self.stop()
@@ -114,7 +116,8 @@ class Owlbot:
try:
# 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:
logger.info("Skipping startup connection tests.")
@@ -155,7 +158,8 @@ class Owlbot:
await self.module_loader.unload_all_modules()
# 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.")
@@ -176,16 +180,19 @@ class Owlbot:
status = await owncast_client.get_status()
except OwncastError as e:
if e.status == 0:
logger.error(
"Startup connection test failed: "
f"Could not connect to {base_url}: {e.message}"
logger.exception(
"Startup connection test failed: Could not connect to %s: %s",
base_url,
e.message,
)
raise StartupError(
f"Could not connect to {base_url}: {e.message}"
) from e
logger.error(
"Startup connection test failed: "
f"{base_url} returned HTTP {e.status}: {e.message}"
logger.exception(
"Startup connection test failed: %s returned HTTP %s: %s",
base_url,
e.status,
e.message,
)
raise StartupError(
"Owncast version check failed: "
@@ -195,9 +202,10 @@ class Owlbot:
version = status.get("versionNumber") if isinstance(status, dict) else None
if not version:
logger.error(
f"Startup connection test failed: {base_url} "
"Startup connection test failed: %s "
"does not appear to be an Owncast instance "
"(no versionNumber in response)"
"(no versionNumber in response)",
base_url,
)
raise StartupError(
f"{base_url} does not appear to be an Owncast instance "
@@ -207,12 +215,15 @@ class Owlbot:
# Warn on version mismatch but continue startup.
if version != OWNCAST_TARGET_VERSION:
logger.warning(
f"Owncast version {version} detected at {base_url}, "
f"but this build of Owlbot is designed for {OWNCAST_TARGET_VERSION}. "
"Startup will continue, but some things may not behave as expected."
"Owncast version %s detected at %s, "
"but this build of Owlbot is designed for %s. "
"Startup will continue, but some things may not behave as expected.",
version,
base_url,
OWNCAST_TARGET_VERSION,
)
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...")
try:
@@ -220,17 +231,18 @@ class Owlbot:
logger.debug("Owncast integrations API is accessible.")
except OwncastError as e:
if e.status == 0:
logger.error(
logger.exception(
"Startup connection test failed: Could not "
f"connect to integrations API: {e.message}"
"connect to integrations API: %s",
e.message,
)
raise StartupError(
f"Could not connect to integrations API: {e.message}"
) from e
logger.error(
"Startup connection test failed: "
"Integrations API returned "
f"HTTP {e.status}: {e.message}"
logger.exception(
"Startup connection test failed: Integrations API returned HTTP %s: %s",
e.status,
e.message,
)
raise StartupError(
"Owncast integrations API check failed: "
@@ -244,17 +256,18 @@ class Owlbot:
logger.info("Owncast admin API is enabled.")
except OwncastError as e:
if e.status == 0:
logger.error(
logger.exception(
"Startup connection test failed: Could "
f"not connect to admin API: {e.message}"
"not connect to admin API: %s",
e.message,
)
raise StartupError(
f"Could not connect to admin API: {e.message}"
) from e
logger.error(
"Startup connection test failed: "
"Admin API returned "
f"HTTP {e.status}: {e.message}"
logger.exception(
"Startup connection test failed: Admin API returned HTTP %s: %s",
e.status,
e.message,
)
raise StartupError(
"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
import asyncio
import shutil
from pathlib import Path
@@ -95,7 +96,7 @@ async def setup(ctx: ModuleContext) -> None:
processor = VideoProcessor(ctx.logger)
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)
ctx.state["manager"] = manager
@@ -106,7 +107,7 @@ async def setup(ctx: ModuleContext) -> None:
if status.get("online", False):
ctx.logger.info("Stream is already live. Starting HLS cache.")
await manager.start_caching()
except Exception:
except Exception: # noqa: BLE001 # best-effort; non-fatal if Owncast is unreachable during setup
ctx.logger.warning(
"Could not check Owncast status during setup.", exc_info=True
)
+10 -8
View File
@@ -27,6 +27,7 @@ import re
import shutil
import tempfile
from contextlib import asynccontextmanager
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import urljoin
@@ -140,7 +141,7 @@ class ChunkCache:
with contextlib.suppress(asyncio.CancelledError):
await self._task
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()
async def _download_segment(self, segment_url: str, seq: int) -> bool:
@@ -152,7 +153,7 @@ class ChunkCache:
"""
try:
async with self._http.get(segment_url) as seg_resp:
if seg_resp.status != 200:
if seg_resp.status != HTTPStatus.OK:
return False
data = await seg_resp.read()
except asyncio.CancelledError:
@@ -177,7 +178,7 @@ class ChunkCache:
while True:
try:
async with self._http.get(self._variant_url) as resp:
if resp.status != 200:
if resp.status != HTTPStatus.OK:
self._logger.warning(
"Variant playlist returned HTTP %d.", resp.status
)
@@ -233,7 +234,7 @@ class ChunkCache:
oldest = min(self._sequences)
self._sequences.discard(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:
self._logger.debug(
"Pruned %d segment(s). Cache: %d chunks, %.1fs total.",
@@ -245,7 +246,7 @@ class ChunkCache:
except asyncio.CancelledError:
raise
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)
@@ -338,11 +339,12 @@ async def start_caching(
cache.start()
logger.info("HLS caching started. Cache dir: %s", cache_dir)
return cache
except BaseException:
shutil.rmtree(cache_dir, ignore_errors=True)
raise
else:
return cache
async def _fetch_playlist(
@@ -364,7 +366,7 @@ async def _fetch_playlist(
exc_info = False
try:
async with http.get(url) as resp:
if resp.status != 200:
if resp.status != HTTPStatus.OK:
reason = f"HTTP {resp.status}"
else:
content = await resp.text()
@@ -409,7 +411,7 @@ async def _download_init_segment(
exc_info = False
try:
async with http.get(init_url) as resp:
if resp.status != 200:
if resp.status != HTTPStatus.OK:
reason = f"HTTP {resp.status}"
else:
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."
)
return
except Exception:
except Exception: # noqa: BLE001 # catch-all after specific exceptions; ffmpeg/IO failures are unpredictable
await ctx.owncast_client.send_message(
"Failed to create clip preview. Please try again."
)
+10 -10
View File
@@ -192,8 +192,8 @@ class ClipManager:
preview_path, self._cache
)
except Exception:
self._ctx.logger.error("Failed to generate clip preview.", exc_info=True)
await asyncio.to_thread(shutil.rmtree, work_dir, True)
self._ctx.logger.exception("Failed to generate clip preview.")
await asyncio.to_thread(shutil.rmtree, work_dir, ignore_errors=True)
raise
token = secrets.token_urlsafe(32)
@@ -303,7 +303,7 @@ class ClipManager:
thumbnail_path,
duration=actual_duration,
)
except Exception:
except Exception: # noqa: BLE001 # best-effort; clip already saved, thumbnail is optional
self._ctx.logger.warning(
"Thumbnail generation failed for clip %d.",
clip.id,
@@ -311,14 +311,12 @@ class ClipManager:
)
thumbnail_path.unlink(missing_ok=True)
return clip
except Exception:
# Clean up partial state to avoid orphaned DB rows or files.
if clip_id is not None:
try:
await self._repo.delete(clip_id)
except Exception:
except Exception: # noqa: BLE001 # last-resort; cleanup itself failed, just log it
self._ctx.logger.warning(
"Failed to clean up DB row for clip %d.",
clip_id,
@@ -327,8 +325,10 @@ class ClipManager:
if clip_path is not None:
clip_path.unlink(missing_ok=True)
raise
else:
return clip
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:
"""Remove a session and clean up its working directory.
@@ -339,7 +339,7 @@ class ClipManager:
if session is not None:
if session.expiry_task is not None:
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:
"""Start HLS caching from the Owncast stream.
@@ -357,7 +357,7 @@ class ClipManager:
logger=self._ctx.logger,
)
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:
"""Stop HLS caching and clean up cached files."""
@@ -434,5 +434,5 @@ def get_manager(ctx: ModuleContext) -> ClipManager:
manager = ctx.state.get("manager")
if not isinstance(manager, ClipManager):
msg = "ClipManager is not initialized."
raise RuntimeError(msg)
raise RuntimeError(msg) # noqa: TRY004 # state error, not a type error
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"])
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.
:param ctx: The route context.
@@ -155,7 +155,7 @@ async def editor_submit(ctx: RouteContext) -> web.Response:
except InvalidClipParamsError as e:
return _error_page(ctx, 400, e.reason)
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.")
clip_url = ctx.routes.url_for(f"/view/{clip.id}")
@@ -45,7 +45,7 @@ async def addcommand(ctx: CommandContext) -> None:
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message(
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
)
@@ -53,7 +53,7 @@ async def addcommand(ctx: CommandContext) -> None:
name = _clean_name(args[0], prefix)
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(
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
)
@@ -87,7 +87,7 @@ async def editcommand(ctx: CommandContext) -> None:
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message(
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
)
@@ -95,7 +95,7 @@ async def editcommand(ctx: CommandContext) -> None:
name = _clean_name(args[0], prefix)
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(
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
)
@@ -166,7 +166,7 @@ async def commandmodonly(ctx: CommandContext) -> None:
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message(
f"Usage: {prefix}commandmodonly {prefix}<name> <on|off>"
)
@@ -241,7 +241,7 @@ async def editcounter(ctx: CommandContext) -> None:
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) != 2:
if len(args) != 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message(
f"Usage: {prefix}editcounter <name> <value>"
)
@@ -291,7 +291,7 @@ async def commandcooldown(ctx: CommandContext) -> None:
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message(
f"Usage: {prefix}commandcooldown {prefix}<name> <seconds>"
)
@@ -344,7 +344,7 @@ async def addalias(ctx: CommandContext) -> None:
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
if len(args) < 2: # noqa: PLR2004 # just checking argument count
await ctx.owncast_client.send_message(
f"Usage: {prefix}addalias {prefix}<command> {prefix}<alias>"
)
@@ -127,16 +127,12 @@ class CommandManager:
raise CommandAlreadyExistsError(name)
default_cooldown: int = self._ctx.config.get("default_cooldown", 5)
command = await self._repo.create(name, response, default_cooldown)
self._commands.register(
name=name,
handler=custom_command_handler,
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)
return command
@@ -336,7 +332,7 @@ def get_manager(ctx: ModuleContext) -> CommandManager:
"""
manager = ctx.state.get("manager")
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
@@ -262,7 +262,7 @@ def _parse_placeholder_date(date_str: str) -> datetime | None:
"""
# Split off the timezone abbreviation (last token).
parts = date_str.rsplit(maxsplit=1)
if len(parts) != 2:
if len(parts) != 2: # noqa: PLR2004 # just checking argument count
return None
date_part, tz_abbrev = parts
@@ -321,7 +321,7 @@ async def _evaluate_arg(
async def _evaluate_user(
name: str,
name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str],
ctx: PlaceholderContext,
) -> str:
@@ -332,7 +332,7 @@ async def _evaluate_user(
async def _evaluate_count(
name: str,
name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str],
ctx: PlaceholderContext,
) -> str:
@@ -350,7 +350,7 @@ async def _evaluate_count(
)
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(
"Invalid $(count): too many arguments, expected $(count name [modifier])"
)
@@ -369,7 +369,7 @@ async def _evaluate_count(
async def _evaluate_getcount(
name: str,
name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str],
ctx: PlaceholderContext,
) -> str:
@@ -391,16 +391,16 @@ async def _evaluate_getcount(
async def _evaluate_rand(
name: str,
name: str, # noqa: ARG001 # required by placeholder handler signature
args: list[str],
ctx: PlaceholderContext,
ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
) -> str:
"""``$(rand start stop)`` -- random integer in range."""
if len(args) < 2:
if len(args) < 2: # noqa: PLR2004 # just checking argument count
raise PlaceholderError(
"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(
"Invalid $(rand): too many arguments, expected $(rand start stop)"
)
@@ -411,13 +411,13 @@ async def _evaluate_rand(
raise PlaceholderError(
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
) 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(
name: str,
args: list[str],
ctx: PlaceholderContext,
ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
) -> str:
"""``$(countdown date)`` / ``$(countup date)`` -- time delta."""
date_str = " ".join(args)
@@ -204,6 +204,7 @@ def _parse_nodes(
template: str,
pos: int,
depth: int,
*,
inside_placeholder: bool,
max_depth: int,
) -> tuple[list[Node], int, bool]:
@@ -372,7 +372,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
await asyncio.sleep(15)
try:
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.")
break
finally:
@@ -383,7 +383,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
@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.
:param ctx: The route context.
+8 -7
View File
@@ -36,6 +36,7 @@ from .types import (
MAX_QUESTION_LENGTH,
MIN_DURATION,
MIN_OPTIONS,
REMINDER_THRESHOLD,
RESULT_EXPIRY,
STREAM_GRACE_PERIOD,
ActivePoll,
@@ -256,10 +257,10 @@ class PollManager:
# so clearing the creation reference has no effect on it.
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:
if duration > 60:
await asyncio.sleep(duration - 60)
if duration > REMINDER_THRESHOLD:
await asyncio.sleep(duration - REMINDER_THRESHOLD)
if poll.allow_multiple:
instructions = "Use <strong>!vote</strong> to get a voting link."
else:
@@ -269,13 +270,13 @@ class PollManager:
escaped_question = escape(poll.question)
reminder = (
f"<strong>60 seconds remaining:</strong>"
f"<strong>{REMINDER_THRESHOLD} seconds remaining:</strong>"
f" {escaped_question}<br>{instructions}"
)
await self._ctx.owncast_client.send_system_message(
reminder, unsanitized=True
)
await asyncio.sleep(60)
await asyncio.sleep(REMINDER_THRESHOLD)
else:
await asyncio.sleep(duration)
await self.end()
@@ -283,7 +284,7 @@ class PollManager:
poll.timer_task = asyncio.create_task(_poll_timer())
# Announce in chat.
if duration >= 60:
if duration >= REMINDER_THRESHOLD:
minutes = duration // 60
time_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
else:
@@ -692,5 +693,5 @@ def get_manager(ctx: ModuleContext) -> PollManager:
"""
manager = ctx.state.get("manager")
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
+4 -4
View File
@@ -339,7 +339,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
remaining = poll.time_remaining
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
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])
break
finally:
@@ -439,7 +439,7 @@ async def results_page(ctx: RouteContext) -> web.Response:
@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.
: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"])
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.
: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"])
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.
:param ctx: The route context.
+1
View File
@@ -38,6 +38,7 @@ MAX_OPTIONS = 10
DEFAULT_DURATION = 240
MIN_DURATION = 30
MAX_DURATION = 600
REMINDER_THRESHOLD = 60
CREATION_TOKEN_TIMEOUT = 15 * 60
STREAM_GRACE_PERIOD = 5 * 60
RESULT_EXPIRY = 3600
+1 -1
View File
@@ -92,5 +92,5 @@ def get_manager(ctx: ModuleContext) -> QuoteManager:
"""
manager = ctx.state.get("manager")
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
+1 -1
View File
@@ -82,7 +82,7 @@ async def setup(ctx: ModuleContext) -> None:
ctx.logger.info("Stream is live. Timers started.")
else:
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(
"Could not check stream status. Timers will start on stream start.",
exc_info=True,
+3 -3
View File
@@ -67,7 +67,7 @@ async def settimermessage(ctx: CommandContext) -> None:
:param ctx: The command context.
"""
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(
"Usage: !settimermessage <id|name> <message>"
)
@@ -95,7 +95,7 @@ async def settimerinterval(ctx: CommandContext) -> None:
:param ctx: The command context.
"""
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(
"Usage: !settimerinterval <id|name> <interval>"
)
@@ -132,7 +132,7 @@ async def settimerlines(ctx: CommandContext) -> None:
:param ctx: The command context.
"""
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>")
return
+1 -1
View File
@@ -455,5 +455,5 @@ def get_manager(ctx: ModuleContext) -> TimerManager:
"""
manager = ctx.state.get("manager")
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
+10 -8
View File
@@ -60,7 +60,7 @@ class HttpServer:
config: Config,
event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher,
):
) -> None:
"""Initialize the web server.
:param config: Bot configuration.
@@ -112,11 +112,11 @@ class HttpServer:
logger.debug("Binding web server to %s:%s...", host, port)
try:
await site.start()
except OSError as e:
logger.error(f"Failed to bind web server to {host}:{port}: {e}")
except OSError:
logger.exception("Failed to bind web server to %s:%s.", host, port)
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:
"""Stop the HTTP server."""
@@ -129,8 +129,8 @@ class HttpServer:
"""Wait for all pending webhook dispatch tasks to complete."""
if self._pending_tasks:
logger.info(
f"Waiting for {len(self._pending_tasks)} "
"pending event(s) to complete..."
"Waiting for %d pending event(s) to complete...",
len(self._pending_tasks),
)
await asyncio.gather(*self._pending_tasks)
logger.debug("All pending events drained.")
@@ -146,7 +146,7 @@ class HttpServer:
except ValueError as e:
# Invalid JSON received. This shouldn't happen
# 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)
event_type = data.get("type", "unknown")
@@ -156,7 +156,9 @@ class HttpServer:
if result is None:
# Owncast may have added a new webhook we don't handle yet.
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)
+41 -34
View File
@@ -22,6 +22,7 @@ import sys
from pathlib import Path
from typing import TYPE_CHECKING, cast
from . import builtin_modules
from .api.config import Config, ModuleConfig
from .api.context import ModuleContext
from .api.owncast_admin_client import OwncastAdminClient
@@ -56,7 +57,7 @@ class ModuleLoader:
modules_dir: str | Path,
config: Config,
http_client: HttpClient,
):
) -> None:
"""Initialize the module loader.
:param modules_dir: Path to the user modules directory.
@@ -105,7 +106,8 @@ class ModuleLoader:
self._core_template_dir = Path(__file__).resolve().parent / "templates"
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:
@@ -146,15 +148,16 @@ class ModuleLoader:
reserved = user_names & RESERVED_MODULE_NAMES
for name in sorted(reserved):
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
overrides = user_names & BUILTIN_MODULE_NAMES
for name in sorted(overrides):
logger.info(
f"User module '{name}' found; built-in module of "
f"the same name will be skipped."
"User module '%s' found; built-in module of "
"the same name will be skipped.",
name,
)
merged = BUILTIN_MODULE_NAMES | user_names
@@ -162,9 +165,11 @@ class ModuleLoader:
builtin_count = len(BUILTIN_MODULE_NAMES - user_names)
user_count = len(user_names)
logger.info(
f"Discovered {len(modules)} module(s) "
f"({builtin_count} built-in, {user_count} user): "
f"{', '.join(modules) if modules else 'none'}"
"Discovered %d module(s) (%d built-in, %d user): %s",
len(modules),
builtin_count,
user_count,
", ".join(modules) if modules else "none",
)
return modules
@@ -200,7 +205,7 @@ class ModuleLoader:
if found:
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
@@ -220,7 +225,7 @@ class ModuleLoader:
:return: Names of successfully loaded modules.
"""
discovered = self.discover_module_names()
logger.info(f"Loading {len(discovered)} module(s)...")
logger.info("Loading %d module(s)...", len(discovered))
imported: list[str] = []
loaded: list[str] = []
@@ -236,21 +241,21 @@ class ModuleLoader:
if "disabled in config" in str(e):
disabled_count += 1
else:
logger.error(str(e))
logger.exception("Module import failed.")
failed_count += 1
# Phase 2: Run @on_setup hooks for each imported module.
logger.debug(
f"Import phase complete ({len(imported)} imported). "
f"Running setup handlers..."
"Import phase complete (%d imported). Running setup handlers...",
len(imported),
)
for module_name in imported:
try:
await self._run_module_setup(module_name)
loaded.append(module_name)
logger.info(f"Loaded module '{module_name}'.")
except ModuleLoadError as e:
logger.error(str(e))
logger.info("Loaded module '%s'.", module_name)
except ModuleLoadError:
logger.exception("Module setup failed.")
failed_count += 1
parts = [f"{len(loaded)} loaded"]
@@ -258,7 +263,7 @@ class ModuleLoader:
parts.append(f"{disabled_count} disabled")
if failed_count:
parts.append(f"{failed_count} failed")
logger.info(f"Module loading complete: {', '.join(parts)}")
logger.info("Module loading complete: %s", ", ".join(parts))
return loaded
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
@@ -346,7 +351,7 @@ class ModuleLoader:
if _run_setup:
await self._run_module_setup(module_name)
logger.info(f"Loaded module '{module_name}'.")
logger.info("Loaded module '%s'.", module_name)
else:
logger.debug("Imported module '%s' (setup deferred).", module_name)
@@ -357,11 +362,13 @@ class ModuleLoader:
cleanup_ctx = self._module_contexts.get(module_name)
if cleanup_ctx:
try:
await cleanup_ctx.storage._close()
except Exception as close_err:
logger.error(
f"Failed to close storage for module '{module_name}' "
f"during load error cleanup: {close_err}"
# Framework lifecycle; private to module authors.
await cleanup_ctx.storage._close() # noqa: SLF001
except Exception:
logger.exception(
"Failed to close storage for module '%s' "
"during load error cleanup.",
module_name,
)
self._cleanup_module(module_name)
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)
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
module_ctx = self._module_contexts.get(module_name)
@@ -391,21 +398,22 @@ class ModuleLoader:
logger.debug("Running @on_teardown for module: %s", module_name)
await teardown_func(module_ctx)
logger.debug("Teardown completed for module: %s", module_name)
except Exception as e:
logger.exception(f"Teardown failed for module '{module_name}': {e}")
except Exception:
logger.exception("Teardown failed for module '%s'.", module_name)
else:
logger.debug("Module '%s' has no @on_teardown handlers.", module_name)
if module_ctx:
try:
await module_ctx.storage._close()
except Exception as e:
# Framework lifecycle; private to module authors.
await module_ctx.storage._close() # noqa: SLF001
except 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)
logger.info(f"Unloaded module '{module_name}'.")
logger.info("Unloaded module '%s'.", module_name)
return True
async def unload_all_modules(self) -> None:
@@ -416,7 +424,7 @@ class ModuleLoader:
if not self.loaded_modules:
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):
await self.unload_module(module_name)
logger.info("All module unload complete.")
@@ -446,7 +454,8 @@ class ModuleLoader:
await setup_func(module_ctx)
logger.debug("Setup completed for module: %s", module_name)
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)
raise ModuleLoadError(
f"Setup failed for module '{module_name}': {e}"
@@ -486,8 +495,6 @@ class ModuleLoader:
:param module_name: Name of the built-in module.
:return: Path to the module's ``__init__.py``.
"""
from . import builtin_modules
return (
Path(builtin_modules.__file__).resolve().parent
/ module_name
+39 -31
View File
@@ -26,15 +26,16 @@ import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, cast
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
from ..api.context import CommandContext, EventContext
from ..api.event_types import ChatEvent
from owlbot._version import __version__
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
from owlbot.api.context import CommandContext, EventContext
from owlbot.api.event_types import ChatEvent
if TYPE_CHECKING:
from types import ModuleType
from ..api.context import ModuleContext
from ..api.owncast_client import OwncastClient
from owlbot.api.context import ModuleContext
from owlbot.api.owncast_client import OwncastClient
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
@@ -72,7 +73,7 @@ class CommandRegistry:
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
cooldown: int = 0,
module_name: str,
) -> None:
"""Register a command handler.
@@ -317,7 +318,7 @@ class CommandDispatcher:
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
cooldown: int = 0,
module_name: str,
) -> None:
"""Register a command handler.
@@ -438,14 +439,17 @@ class CommandDispatcher:
user = event.user
logger.info(
f"Command '{command_info.name}' invoked by {user.display_name}"
f" with args: {args!r}"
"Command '%s' invoked by %s with args: %r",
command_info.name,
user.display_name,
args,
)
if command_info.requires_authenticated and not user.is_authenticated:
logger.info(
f"Command '{command_name}' denied for {user.display_name}: "
"authentication required"
"Command '%s' denied for %s: authentication required",
command_name,
user.display_name,
)
await self._owncast_client.send_system_message_to_client(
event.client_id,
@@ -455,8 +459,9 @@ class CommandDispatcher:
if command_info.requires_moderator and not user.is_moderator:
logger.info(
f"Command '{command_name}' denied for {user.display_name}: "
"moderator required"
"Command '%s' denied for %s: moderator required",
command_name,
user.display_name,
)
await self._owncast_client.send_system_message_to_client(
event.client_id,
@@ -470,8 +475,10 @@ class CommandDispatcher:
if last is not None and now - last < command_info.cooldown:
remaining = math.ceil(command_info.cooldown - (now - last))
logger.info(
f"Command '{command_name}' denied for {user.display_name}: "
f"on cooldown ({remaining}s remaining)"
"Command '%s' denied for %s: on cooldown (%ds remaining)",
command_name,
user.display_name,
remaining,
)
await self._owncast_client.send_system_message_to_client(
event.client_id,
@@ -501,12 +508,14 @@ class CommandDispatcher:
)
except TimeoutError:
logger.warning(
f"Built-in command '{command_info.name}' "
f"cancelled after {self._handler_timeout}s timeout."
"Built-in command '%s' cancelled after %ss timeout.",
command_info.name,
self._handler_timeout,
)
except Exception as e:
except Exception:
logger.exception(
f"Built-in command '{command_info.name}' raised exception: {e}"
"Built-in command '%s' raised exception.",
command_info.name,
)
return
@@ -544,26 +553,25 @@ class CommandDispatcher:
)
except TimeoutError:
logger.warning(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"cancelled after "
f"{self._handler_timeout}s timeout."
"Command handler '%s' from module '%s' cancelled after %ss timeout.",
command_info.name,
command_info.module_name,
self._handler_timeout,
)
except Exception as e:
except Exception:
logger.exception(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"raised exception: {e}"
"Command handler '%s' from module '%s' raised exception.",
command_info.name,
command_info.module_name,
)
def _register_builtin_commands(self) -> None:
"""Register all built-in commands."""
from .._version import __version__
loaded_modules = self._loaded_modules
async def about_with_modules(
event: ChatEvent, owncast_client: OwncastClient
event: ChatEvent, # noqa: ARG001 # required by builtin handler signature
owncast_client: OwncastClient,
) -> None:
module_count = len(loaded_modules)
module_list = (
@@ -612,7 +620,7 @@ class ModuleCommands:
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
cooldown: int = 0,
) -> None:
"""Register a command handler for this module.
+15 -16
View File
@@ -26,9 +26,9 @@ import time
from collections import defaultdict
from typing import TYPE_CHECKING, NamedTuple, cast
from ..api.context import EventContext, PropagationState
from ..api.event_types import ChatEvent, Event, EventType, log_event
from ..api.events import EventHandler, EventMark, Priority
from owlbot.api.context import EventContext, PropagationState
from owlbot.api.event_types import ChatEvent, Event, EventType, log_event
from owlbot.api.events import EventHandler, EventMark, Priority
class HandlerEntry(NamedTuple):
@@ -45,7 +45,7 @@ if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from types import ModuleType
from ..api.context import ModuleContext
from owlbot.api.context import ModuleContext
logger = logging.getLogger("owlbot.events")
@@ -344,9 +344,7 @@ class EventDispatcher:
)
break
await self._call_handler(
handler, event, event_type, module_name, propagation
)
await self._call_handler(handler, event, module_name, propagation)
else:
logger.debug("No handlers registered for event type: %s", event_type)
@@ -362,14 +360,13 @@ class EventDispatcher:
else:
try:
await self._command_dispatch(event)
except Exception as e:
logger.exception(f"Command dispatch failed: {e}")
except Exception:
logger.exception("Command dispatch failed.")
async def _call_handler(
self,
handler: EventHandler,
event: Event,
event_type: EventType,
module_name: str,
propagation: PropagationState,
) -> None:
@@ -377,7 +374,6 @@ class EventDispatcher:
:param handler: The handler function to call.
: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 propagation: Shared propagation state for this dispatch cycle.
"""
@@ -400,13 +396,16 @@ class EventDispatcher:
logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed)
except TimeoutError:
logger.warning(
f"Handler '{handler_name}' from module '{module_name}' "
f"cancelled after {self._handler_timeout}s timeout."
"Handler '%s' from module '%s' cancelled after %ss timeout.",
handler_name,
module_name,
self._handler_timeout,
)
except Exception as e:
except Exception:
logger.exception(
f"Handler '{handler_name}' from module "
f"'{module_name}' raised exception: {e}"
"Handler '%s' from module '%s' raised exception.",
handler_name,
module_name,
)
+13 -10
View File
@@ -30,14 +30,14 @@ import orjson
from aiohttp import web
from aiohttp.web import DynamicResource
from ..api.context import RouteContext
from ..api.routes import RouteHandler, RouteInfo, RouteMark
from owlbot.api.context import RouteContext
from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from ..api.context import ModuleContext
from owlbot.api.context import ModuleContext
logger = logging.getLogger("owlbot.web")
@@ -270,7 +270,7 @@ class RouteRegistry:
"""
method = method.upper()
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:
for handler in group.handlers:
if method in handler.methods:
@@ -615,20 +615,23 @@ class RouteDispatcher:
)
# pragma: no branch — defensive against untyped handlers
mod_logger.error( # type: ignore[unreachable]
f"Route handler '{route_info.full_path}' returned "
f"unsupported type: {type(result).__name__}"
"Route handler '%s' returned unsupported type: %s",
route_info.full_path,
type(result).__name__,
)
return web.Response(status=500)
except TimeoutError:
mod_logger.warning(
f"Route handler '{route_info.full_path}' timed out "
f"after {self._handler_timeout}s."
"Route handler '%s' timed out after %ss.",
route_info.full_path,
self._handler_timeout,
)
return web.Response(status=500)
except Exception as e:
except 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)
finally:
+20 -48
View File
@@ -72,60 +72,32 @@ target-version = "py312"
extend-exclude = ["owlbot/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint]
select = [
# 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
]
select = ["ALL"]
ignore = [
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
"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]
"owlbot/__init__.py" = ["E402"] # constants defined before imports intentionally
"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]
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,
name: str,
aliases: list[str] | None,
*,
auth: bool,
mod: bool,
cooldown: int,
@@ -503,7 +504,7 @@ class TestCommandDispatcherDispatch:
module_ctx = make_module_context(storage, module_name)
dispatcher = CommandDispatcher(
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
owncast_client=stub, # type: ignore[arg-type]
handler_timeout=handler_timeout,
loaded_modules=set(),
@@ -761,7 +762,7 @@ class TestCommandDispatcherDispatch:
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
assert row is not None
assert row["name"] == "apple"
assert "raised exception: boom" in caplog.text
assert "raised exception." in caplog.text
async def test_timeout_is_logged(
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
@@ -854,7 +855,7 @@ class TestCommandDispatcherDispatch:
with caplog.at_level(logging.ERROR, logger="owlbot.commands"):
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(
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
@@ -963,6 +964,7 @@ class TestModuleCommands:
other_setup: str | None,
call_module: str,
trigger: str,
*,
expected: bool,
) -> None:
"""ModuleCommands.unregister respects ownership."""
@@ -1059,7 +1061,7 @@ class TestCommandContext:
],
)
def test_property_proxying(
self, storage: ModuleStorage, prop: str, use_is: bool
self, storage: ModuleStorage, prop: str, *, use_is: bool
) -> None:
"""CommandContext properties proxy to the underlying ModuleContext."""
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,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
cooldown: int = 0,
) -> None:
all_new = {name} | set(aliases or [])
for trigger in all_new:
@@ -551,6 +551,7 @@ class _StubModuleCommands:
self._registered[name] = {
"handler": handler,
"aliases": set(aliases or []),
"requires_authenticated": requires_authenticated,
"requires_moderator": requires_moderator,
"cooldown": cooldown,
}
@@ -702,7 +703,7 @@ class TestManagerCreateCommand:
cmd_ctx: ModuleContext,
stub_commands: _StubModuleCommands,
) -> 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.
closed_storage = ModuleStorage(None, "closed")
bad_repo = CommandRepository(closed_storage)
+1 -1
View File
@@ -229,7 +229,7 @@ class TestUser:
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."""
user = User.from_dict({"scopes": scopes})
assert user.is_moderator is expected
+23 -22
View File
@@ -305,6 +305,7 @@ class TestEventRegistry:
def test_unregister(
self,
setup_types: tuple[EventType, ...] | None,
*,
expected_return: bool,
expected_remaining: int,
) -> None:
@@ -437,7 +438,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
@@ -466,7 +467,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(high_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -492,7 +493,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(first, (EventType.CHAT,), "mod_a", Priority.NORMAL)
@@ -511,7 +512,7 @@ class TestEventDispatcherDispatch:
module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
@@ -539,7 +540,7 @@ class TestEventDispatcherDispatch:
module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
chat_event = _make_chat_event(body="hello")
@@ -567,7 +568,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -593,7 +594,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -624,7 +625,7 @@ class TestEventDispatcherDispatch:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(stopper, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -657,7 +658,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
@@ -687,7 +688,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
@@ -697,7 +698,7 @@ class TestEventDispatcherStorage:
row = await storage.fetch_one("SELECT val FROM test_tbl")
assert row is not None
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(
self, storage: ModuleStorage
@@ -719,7 +720,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(failing_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
@@ -742,7 +743,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=0.05,
)
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
@@ -772,7 +773,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=0.05,
)
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
@@ -802,7 +803,7 @@ class TestEventDispatcherStorage:
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=0.05,
)
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
@@ -820,7 +821,7 @@ class TestCommandDispatchPhase:
[
pytest.param(
EventType.CHAT,
lambda: _make_chat_event(),
_make_chat_event,
1,
id="chat-triggers",
),
@@ -853,7 +854,7 @@ class TestCommandDispatchPhase:
module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
event = event_factory()
@@ -877,7 +878,7 @@ class TestCommandDispatchPhase:
module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
dispatcher.register(stopper, (EventType.CHAT,), "mod_a")
@@ -899,12 +900,12 @@ class TestCommandDispatchPhase:
module_ctx = make_module_context(storage, "mod_a")
dispatcher = EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda name: module_ctx,
get_module_context=lambda _name: module_ctx,
handler_timeout=5.0,
)
with caplog.at_level(logging.ERROR, logger="owlbot.events"):
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:
@@ -974,7 +975,7 @@ class TestModuleEvents:
],
)
def test_unregister(
self, register_on: str | None, unregister_from: str, expected: bool
self, register_on: str | None, unregister_from: str, *, expected: bool
) -> None:
"""ModuleEvents.unregister() respects ownership checks."""
@@ -1061,7 +1062,7 @@ class TestEventContext:
],
)
def test_property_proxying(
self, storage: ModuleStorage, prop: str, use_is: bool
self, storage: ModuleStorage, prop: str, *, use_is: bool
) -> None:
"""EventContext properties proxy to the underlying ModuleContext."""
module_ctx = make_module_context(storage, "mod_a")
+17 -14
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import asyncio
import types
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
import pytest
@@ -86,6 +87,7 @@ class TestOnRouteDecorator:
self,
path: str,
methods: list[str] | None,
*,
streaming: bool,
expected_path: str,
expected_methods: list[str] | None,
@@ -123,7 +125,7 @@ class TestRouteInfo:
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."""
info = RouteInfo(
path="/x",
@@ -506,7 +508,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "api"
response = await dispatcher.dispatch(request)
assert response.status == 200
assert response.status == HTTPStatus.OK
assert response.content_type == "application/json"
async def test_dispatch_web_response(self) -> None:
@@ -523,7 +525,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "page"
response = await dispatcher.dispatch(request)
assert response.status == 201
assert response.status == HTTPStatus.CREATED
async def test_dispatch_none_response(self) -> None:
"""Handler returning None produces 204 No Content."""
@@ -539,7 +541,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "hook"
response = await dispatcher.dispatch(request)
assert response.status == 204
assert response.status == HTTPStatus.NO_CONTENT
async def test_dispatch_404(self) -> None:
"""Dispatch to unregistered path returns 404."""
@@ -550,7 +552,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "nope"
response = await dispatcher.dispatch(request)
assert response.status == 404
assert response.status == HTTPStatus.NOT_FOUND
async def test_dispatch_405(self) -> None:
"""Dispatch with wrong method returns 405 with Allow header."""
@@ -562,7 +564,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "data"
response = await dispatcher.dispatch(request)
assert response.status == 405
assert response.status == HTTPStatus.METHOD_NOT_ALLOWED
assert "GET" in response.headers["Allow"]
async def test_dispatch_handler_exception(self) -> None:
@@ -579,7 +581,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "bad"
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:
"""Handler returning unsupported type produces 500."""
@@ -595,7 +597,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "bad"
response = await dispatcher.dispatch(request)
assert response.status == 500
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_dispatch_timeout(self) -> None:
"""Non-streaming handler exceeding timeout returns 500."""
@@ -612,7 +614,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "slow"
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:
"""Streaming handler exceeding timeout is not cancelled."""
@@ -629,7 +631,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "stream"
response = await dispatcher.dispatch(request)
assert response.status == 200
assert response.status == HTTPStatus.OK
async def test_dispatch_streaming_exception(self) -> None:
"""Streaming handler that raises returns 500."""
@@ -645,7 +647,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "bad"
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:
"""Streaming handler returning dict produces JSON response."""
@@ -661,7 +663,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = "fast"
response = await dispatcher.dispatch(request)
assert response.status == 200
assert response.status == HTTPStatus.OK
async def test_dispatch_path_params(self) -> None:
"""Path parameters are populated in RouteContext.match_info."""
@@ -695,7 +697,7 @@ class TestRouteDispatcherDispatch:
request.match_info["path"] = ""
response = await dispatcher.dispatch(request)
assert response.status == 200
assert response.status == HTTPStatus.OK
class TestRouteDispatcherDrainHandlers:
@@ -1012,6 +1014,7 @@ class TestModuleRoutes:
)
def test_exists(
self,
*,
register: bool,
check_method: str | None,
expected: bool,
@@ -1084,7 +1087,7 @@ class TestRouteContext:
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."""
ctx, module_ctx = self._make_route_context()
ctx_val = getattr(ctx, prop)
+1 -1
View File
@@ -52,7 +52,7 @@ class _StubOwncastClient:
self.sent: list[str] = []
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."""
if self._fail:
raise RuntimeError("network error")