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
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:
+23
-21
@@ -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.")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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: "
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Owlbot test suite."""
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user