Applied idiomatic Python improvements and micro-optimizations across registries and API layer.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 20s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 9s
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 20s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
This commit is contained in:
@@ -68,11 +68,11 @@ class CommandInfo:
|
|||||||
requires_authenticated: bool = False
|
requires_authenticated: bool = False
|
||||||
requires_moderator: bool = False
|
requires_moderator: bool = False
|
||||||
cooldown: int | float = 0
|
cooldown: int | float = 0
|
||||||
|
all_triggers: frozenset[str] = field(init=False, repr=False)
|
||||||
|
|
||||||
@property
|
def __post_init__(self) -> None:
|
||||||
def all_triggers(self) -> frozenset[str]:
|
"""Compute derived fields."""
|
||||||
"""All names that trigger this command (name + aliases)."""
|
self.all_triggers = frozenset({self.name}) | self.aliases
|
||||||
return frozenset({self.name}) | self.aliases
|
|
||||||
|
|
||||||
|
|
||||||
def on_command(
|
def on_command(
|
||||||
|
|||||||
+17
-15
@@ -321,7 +321,7 @@ class Config:
|
|||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
"""Load config from YAML, applying defaults for missing sections."""
|
"""Load config from YAML, applying defaults for missing sections."""
|
||||||
logger.debug(f"Loading configuration from: {self.config_path.absolute()}")
|
logger.debug("Loading configuration from: %s", self.config_path.absolute())
|
||||||
if self.config_path.exists():
|
if self.config_path.exists():
|
||||||
try:
|
try:
|
||||||
with self.config_path.open() as f:
|
with self.config_path.open() as f:
|
||||||
@@ -360,30 +360,32 @@ class Config:
|
|||||||
logger.info("Generated webhook secret and saved to config file.")
|
logger.info("Generated webhook secret and saved to config file.")
|
||||||
|
|
||||||
logger.debug("Webhook Secret: [set]")
|
logger.debug("Webhook Secret: [set]")
|
||||||
logger.debug(f"Server Bind Address: {self.host}:{self.port}")
|
logger.debug("Server Bind Address: %s:%s", self.host, self.port)
|
||||||
logger.debug(f"Owncast Server URL: {self.owncast_url}")
|
logger.debug("Owncast Server URL: %s", self.owncast_url)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Owncast Access Token: "
|
"Owncast Access Token: %s",
|
||||||
f"{'[set]' if self.owncast_access_token else '[unset]'}"
|
"[set]" if self.owncast_access_token else "[unset]",
|
||||||
)
|
)
|
||||||
logger.debug(f"Command Prefix: {self.command_prefix!r}")
|
logger.debug("Command Prefix: %r", self.command_prefix)
|
||||||
logger.debug(f"Handler Timeout: {self.handler_timeout}s")
|
logger.debug("Handler Timeout: %ss", self.handler_timeout)
|
||||||
logger.debug(f"Storage Directory: {self.storage_dir}")
|
logger.debug("Storage Directory: %s", self.storage_dir)
|
||||||
logger.debug(f"Modules Directory: {self.modules_dir}")
|
logger.debug("Modules Directory: %s", self.modules_dir)
|
||||||
logger.debug(f"Log Directory: {self.log_dir or '[unset]'}")
|
logger.debug("Log Directory: %s", self.log_dir or "[unset]")
|
||||||
logger.debug(f"Public Base URL: {self.public_base_url}")
|
logger.debug("Public Base URL: %s", self.public_base_url)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Admin API Enabled: {'[set]' if self.admin_enabled else '[unset]'}"
|
"Admin API Enabled: %s",
|
||||||
|
"[set]" if self.admin_enabled else "[unset]",
|
||||||
)
|
)
|
||||||
if self.admin_enabled:
|
if self.admin_enabled:
|
||||||
logger.debug(f"Admin API Username: {self.admin_username}")
|
logger.debug("Admin API Username: %s", self.admin_username)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Admin API Password: {'[set]' if self.admin_password else '[unset]'}"
|
"Admin API Password: %s",
|
||||||
|
"[set]" if self.admin_password else "[unset]",
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
"""Write current configuration to the YAML file."""
|
"""Write current configuration to the YAML file."""
|
||||||
logger.debug(f"Saving configuration to: {self.config_path.absolute()}")
|
logger.debug("Saving configuration to: %s", self.config_path.absolute())
|
||||||
try:
|
try:
|
||||||
with self.config_path.open("w") as f:
|
with self.config_path.open("w") as f:
|
||||||
yaml.safe_dump(
|
yaml.safe_dump(
|
||||||
|
|||||||
+26
-29
@@ -23,6 +23,8 @@ from datetime import datetime
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
_NANO_RE = re.compile(r"(\.\d{6})\d+")
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.events")
|
logger = logging.getLogger("owlbot.events")
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ def _parse_timestamp(ts: str | None) -> datetime | None:
|
|||||||
# Go's time.Time emits nanosecond-precision fractional seconds (9 digits),
|
# Go's time.Time emits nanosecond-precision fractional seconds (9 digits),
|
||||||
# but Python's datetime only supports microseconds (6 digits). Truncate
|
# but Python's datetime only supports microseconds (6 digits). Truncate
|
||||||
# any excess fractional digits to avoid a ValueError from fromisoformat().
|
# any excess fractional digits to avoid a ValueError from fromisoformat().
|
||||||
ts = re.sub(r"(\.\d{6})\d+", r"\1", ts)
|
ts = _NANO_RE.sub(r"\1", ts)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return datetime.fromisoformat(ts)
|
return datetime.fromisoformat(ts)
|
||||||
@@ -86,7 +88,7 @@ class User:
|
|||||||
name_changed_at: datetime | None
|
name_changed_at: datetime | None
|
||||||
is_bot: bool
|
is_bot: bool
|
||||||
is_authenticated: bool
|
is_authenticated: bool
|
||||||
scopes: list[str]
|
scopes: frozenset[str]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> User:
|
def from_dict(cls, data: dict[str, Any]) -> User:
|
||||||
@@ -104,7 +106,7 @@ class User:
|
|||||||
name_changed_at=_parse_timestamp(data.get("nameChangedAt")),
|
name_changed_at=_parse_timestamp(data.get("nameChangedAt")),
|
||||||
is_bot=data.get("isBot", False),
|
is_bot=data.get("isBot", False),
|
||||||
is_authenticated=data.get("authenticated", False),
|
is_authenticated=data.get("authenticated", False),
|
||||||
scopes=data.get("scopes", []),
|
scopes=frozenset(data.get("scopes", ())),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -423,29 +425,24 @@ def log_event(event_type: EventType, event: Event) -> None:
|
|||||||
:param event_type: The type of event being logged.
|
:param event_type: The type of event being logged.
|
||||||
:param event: The parsed event instance.
|
:param event: The parsed event instance.
|
||||||
"""
|
"""
|
||||||
if isinstance(event, ChatEvent):
|
match event:
|
||||||
logger.info(
|
case ChatEvent(user=user, message_id=mid, body=body):
|
||||||
f"[{event_type}] {event.user.display_name} "
|
logger.info(f"[{event_type}] {user.display_name} ({mid}): {body}")
|
||||||
f"({event.message_id}): {event.body}"
|
case UserJoinedEvent(user=user):
|
||||||
)
|
logger.info(f"[{event_type}] {user.display_name} joined.")
|
||||||
elif isinstance(event, UserJoinedEvent):
|
case UserPartedEvent(user=user):
|
||||||
logger.info(f"[{event_type}] {event.user.display_name} joined.")
|
logger.info(f"[{event_type}] {user.display_name} parted.")
|
||||||
elif isinstance(event, UserPartedEvent):
|
case NameChangedEvent(user=user, new_name=new_name):
|
||||||
logger.info(f"[{event_type}] {event.user.display_name} parted.")
|
logger.info(
|
||||||
elif isinstance(event, NameChangedEvent):
|
f"[{event_type}] {user.display_name} changed name to {new_name}."
|
||||||
logger.info(
|
)
|
||||||
f"[{event_type}] {event.user.display_name} "
|
case StreamStartedEvent(stream_title=title):
|
||||||
f"changed name to {event.new_name}."
|
logger.info(f'[{event_type}] Stream started: "{title}"')
|
||||||
)
|
case StreamStoppedEvent():
|
||||||
elif isinstance(event, StreamStartedEvent):
|
logger.info(f"[{event_type}] Stream ended.")
|
||||||
logger.info(f'[{event_type}] Stream started: "{event.stream_title}"')
|
case StreamTitleUpdatedEvent(stream_title=title):
|
||||||
elif isinstance(event, StreamStoppedEvent):
|
logger.info(f'[{event_type}] Title changed to "{title}"')
|
||||||
logger.info(f"[{event_type}] Stream ended.")
|
case VisibilityUpdateEvent(is_visible=visible, message_ids=ids):
|
||||||
elif isinstance(event, StreamTitleUpdatedEvent):
|
action = "shown" if visible else "hidden"
|
||||||
logger.info(f'[{event_type}] Title changed to "{event.stream_title}"')
|
id_list = ", ".join(ids)
|
||||||
elif isinstance(event, VisibilityUpdateEvent):
|
logger.info(f"[{event_type}] {len(ids)} message(s) {action}: {id_list}")
|
||||||
action = "shown" if event.is_visible else "hidden"
|
|
||||||
ids = ", ".join(event.message_ids)
|
|
||||||
logger.info(
|
|
||||||
f"[{event_type}] {len(event.message_ids)} message(s) {action}: {ids}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class OwncastClient:
|
|||||||
"Authorization": f"Bearer {access_token}"
|
"Authorization": f"Bearer {access_token}"
|
||||||
}
|
}
|
||||||
self._auth: aiohttp.BasicAuth | None = None
|
self._auth: aiohttp.BasicAuth | None = None
|
||||||
self._logger.debug(f"Owncast API client initialized for: {self._base_url}")
|
self._logger.debug("Owncast API client initialized for: %s", self._base_url)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_url(self) -> str:
|
def base_url(self) -> str:
|
||||||
@@ -207,7 +207,7 @@ class OwncastClient:
|
|||||||
:raises OwncastError: If the request fails.
|
:raises OwncastError: If the request fails.
|
||||||
"""
|
"""
|
||||||
url = f"{self._base_url}{endpoint}"
|
url = f"{self._base_url}{endpoint}"
|
||||||
self._logger.debug(f"POST {endpoint}")
|
self._logger.debug("POST %s", endpoint)
|
||||||
try:
|
try:
|
||||||
async with self._http.session.post(
|
async with self._http.session.post(
|
||||||
url,
|
url,
|
||||||
@@ -223,7 +223,7 @@ class OwncastClient:
|
|||||||
f"Error {response.status} on POST {endpoint}: {message}"
|
f"Error {response.status} on POST {endpoint}: {message}"
|
||||||
)
|
)
|
||||||
raise OwncastError(response.status, message)
|
raise OwncastError(response.status, message)
|
||||||
self._logger.debug(f"POST {endpoint} -> {response.status}")
|
self._logger.debug("POST %s -> %d", endpoint, response.status)
|
||||||
if response.content_type == "application/json":
|
if response.content_type == "application/json":
|
||||||
try:
|
try:
|
||||||
result = await response.json()
|
result = await response.json()
|
||||||
@@ -266,7 +266,7 @@ class OwncastClient:
|
|||||||
:raises OwncastError: If the request fails.
|
:raises OwncastError: If the request fails.
|
||||||
"""
|
"""
|
||||||
url = f"{self._base_url}{endpoint}"
|
url = f"{self._base_url}{endpoint}"
|
||||||
self._logger.debug(f"GET {endpoint}")
|
self._logger.debug("GET %s", endpoint)
|
||||||
try:
|
try:
|
||||||
async with self._http.session.get(
|
async with self._http.session.get(
|
||||||
url,
|
url,
|
||||||
@@ -282,7 +282,7 @@ class OwncastClient:
|
|||||||
f"Error {response.status} on GET {endpoint}: {message}"
|
f"Error {response.status} on GET {endpoint}: {message}"
|
||||||
)
|
)
|
||||||
raise OwncastError(response.status, message)
|
raise OwncastError(response.status, message)
|
||||||
self._logger.debug(f"GET {endpoint} -> {response.status}")
|
self._logger.debug("GET %s -> %d", endpoint, response.status)
|
||||||
try:
|
try:
|
||||||
result = await response.json()
|
result = await response.json()
|
||||||
except (ValueError, ContentTypeError):
|
except (ValueError, ContentTypeError):
|
||||||
|
|||||||
+16
-6
@@ -141,7 +141,9 @@ class ModuleStorage:
|
|||||||
:raises StorageError: If execution fails.
|
:raises StorageError: If execution fails.
|
||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(f"Execute: {sql[:80]}{'...' if len(sql) > 80 else ''}")
|
self._logger.debug(
|
||||||
|
"Execute: %s%s", sql[:80], "..." if len(sql) > 80 else ""
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return await conn.execute(sql, parameters)
|
return await conn.execute(sql, parameters)
|
||||||
except aiosqlite.Error as e:
|
except aiosqlite.Error as e:
|
||||||
@@ -164,8 +166,10 @@ class ModuleStorage:
|
|||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
f"Execute many ({len(parameters)} rows): "
|
"Execute many (%d rows): %s%s",
|
||||||
f"{sql[:80]}{'...' if len(sql) > 80 else ''}"
|
len(parameters),
|
||||||
|
sql[:80],
|
||||||
|
"..." if len(sql) > 80 else "",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return await conn.executemany(sql, parameters)
|
return await conn.executemany(sql, parameters)
|
||||||
@@ -187,7 +191,9 @@ class ModuleStorage:
|
|||||||
:raises StorageError: If execution fails.
|
:raises StorageError: If execution fails.
|
||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(f"Fetch one: {sql[:80]}{'...' if len(sql) > 80 else ''}")
|
self._logger.debug(
|
||||||
|
"Fetch one: %s%s", sql[:80], "..." if len(sql) > 80 else ""
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
cursor = await conn.execute(sql, parameters)
|
cursor = await conn.execute(sql, parameters)
|
||||||
return await cursor.fetchone()
|
return await cursor.fetchone()
|
||||||
@@ -209,7 +215,9 @@ class ModuleStorage:
|
|||||||
:raises StorageError: If execution fails.
|
:raises StorageError: If execution fails.
|
||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(f"Fetch all: {sql[:80]}{'...' if len(sql) > 80 else ''}")
|
self._logger.debug(
|
||||||
|
"Fetch all: %s%s", sql[:80], "..." if len(sql) > 80 else ""
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
cursor = await conn.execute(sql, parameters)
|
cursor = await conn.execute(sql, parameters)
|
||||||
return list(await cursor.fetchall())
|
return list(await cursor.fetchall())
|
||||||
@@ -243,7 +251,9 @@ class ModuleStorage:
|
|||||||
await conn.execute("PRAGMA foreign_keys = ON")
|
await conn.execute("PRAGMA foreign_keys = ON")
|
||||||
self._all_connections.append(conn)
|
self._all_connections.append(conn)
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
f"Pool connection created ({len(self._all_connections)}/{self._pool_size})."
|
"Pool connection created (%d/%d).",
|
||||||
|
len(self._all_connections),
|
||||||
|
self._pool_size,
|
||||||
)
|
)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|||||||
@@ -287,14 +287,14 @@ async def _download_segment(
|
|||||||
return False
|
return False
|
||||||
data = await seg_resp.read()
|
data = await seg_resp.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
ctx.logger.debug(f"Network error downloading segment {seq}.", exc_info=True)
|
ctx.logger.debug("Network error downloading segment %d.", seq, exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
chunk_path = cache.segment_path(seq)
|
chunk_path = cache.segment_path(seq)
|
||||||
await asyncio.to_thread(chunk_path.write_bytes, data)
|
await asyncio.to_thread(chunk_path.write_bytes, data)
|
||||||
|
|
||||||
cache.add_chunk(seq)
|
cache.add_chunk(seq)
|
||||||
ctx.logger.debug(f"Cached segment {seq} ({len(data)} bytes).")
|
ctx.logger.debug("Cached segment %d (%d bytes).", seq, len(data))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ async def _polling_loop(
|
|||||||
else 2.0
|
else 2.0
|
||||||
)
|
)
|
||||||
cache.set_target_duration(poll_interval)
|
cache.set_target_duration(poll_interval)
|
||||||
ctx.logger.debug(f"Initial poll interval set to {poll_interval:.1f}s.")
|
ctx.logger.debug("Initial poll interval set to %.1fs.", poll_interval)
|
||||||
|
|
||||||
pending_retries: set[int] = set()
|
pending_retries: set[int] = set()
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async def delclip_command(ctx: CommandContext) -> None:
|
|||||||
|
|
||||||
row = await ctx.storage.fetch_one("SELECT id FROM clips WHERE id = ?", (clip_id,))
|
row = await ctx.storage.fetch_one("SELECT id FROM clips WHERE id = ?", (clip_id,))
|
||||||
if not row:
|
if not row:
|
||||||
ctx.logger.debug(f"Clip {clip_id} not found for deletion.")
|
ctx.logger.debug("Clip %s not found for deletion.", clip_id)
|
||||||
await ctx.owncast_client.send_message(f"Clip {clip_id} not found.")
|
await ctx.owncast_client.send_message(f"Clip {clip_id} not found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class ProcessingManager:
|
|||||||
async with self._ffprobe_semaphore:
|
async with self._ffprobe_semaphore:
|
||||||
duration = await self._probe_duration(output_path)
|
duration = await self._probe_duration(output_path)
|
||||||
|
|
||||||
self._logger.debug(f"Preview generated ({duration:.1f}s).")
|
self._logger.debug("Preview generated (%.1fs).", duration)
|
||||||
return duration
|
return duration
|
||||||
|
|
||||||
async def create_clip(
|
async def create_clip(
|
||||||
@@ -110,8 +110,11 @@ class ProcessingManager:
|
|||||||
"""
|
"""
|
||||||
async with self._ffmpeg_semaphore:
|
async with self._ffmpeg_semaphore:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
f"Cutting clip {start:.1f}-{end:.1f}s "
|
"Cutting clip %.1f-%.1fs from %s -> %s.",
|
||||||
f"from {preview_path} -> {output_path}."
|
start,
|
||||||
|
end,
|
||||||
|
preview_path,
|
||||||
|
output_path,
|
||||||
)
|
)
|
||||||
await self._run_ffprocess(
|
await self._run_ffprocess(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@@ -132,7 +135,7 @@ class ProcessingManager:
|
|||||||
async with self._ffprobe_semaphore:
|
async with self._ffprobe_semaphore:
|
||||||
duration = await self._probe_duration(output_path)
|
duration = await self._probe_duration(output_path)
|
||||||
|
|
||||||
self._logger.debug(f"Clip created ({duration:.1f}s).")
|
self._logger.debug("Clip created (%.1fs).", duration)
|
||||||
return duration
|
return duration
|
||||||
|
|
||||||
async def generate_thumbnail(
|
async def generate_thumbnail(
|
||||||
@@ -171,7 +174,7 @@ class ProcessingManager:
|
|||||||
str(output_path),
|
str(output_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._logger.debug(f"Thumbnail generated: {output_path}.")
|
self._logger.debug("Thumbnail generated: %s.", output_path)
|
||||||
|
|
||||||
async def _run_ffprocess(
|
async def _run_ffprocess(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ def schedule_session_expiry(
|
|||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
if sessions.get(token) is not session:
|
if sessions.get(token) is not session:
|
||||||
return
|
return
|
||||||
logger.debug(f"Session expired: token={token[:8]}...")
|
logger.debug("Session expired: token=%s...", token[:8])
|
||||||
cleanup_session(sessions, token)
|
cleanup_session(sessions, token)
|
||||||
|
|
||||||
session.expiry_task = asyncio.create_task(_expire())
|
session.expiry_task = asyncio.create_task(_expire())
|
||||||
@@ -249,7 +249,7 @@ async def editor_submit(ctx: RouteContext) -> web.Response:
|
|||||||
max_length=max_length,
|
max_length=max_length,
|
||||||
)
|
)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
ctx.logger.debug(f"Clip submit validation failed: {error}")
|
ctx.logger.debug("Clip submit validation failed: %s", error)
|
||||||
return _error_page(ctx, 400, error)
|
return _error_page(ctx, 400, error)
|
||||||
|
|
||||||
# Remove session to prevent double-submission.
|
# Remove session to prevent double-submission.
|
||||||
|
|||||||
@@ -462,13 +462,12 @@ type PlaceholderHandler = Callable[
|
|||||||
[str, list[str], "PlaceholderContext"], Awaitable[str]
|
[str, list[str], "PlaceholderContext"], Awaitable[str]
|
||||||
]
|
]
|
||||||
|
|
||||||
HANDLERS: dict[str, PlaceholderHandler] = {}
|
HANDLERS: dict[str, PlaceholderHandler] = {
|
||||||
|
**{str(i): _evaluate_arg for i in range(1, 10)},
|
||||||
for _i in range(1, 10):
|
"user": _evaluate_user,
|
||||||
HANDLERS[str(_i)] = _evaluate_arg
|
"count": _evaluate_count,
|
||||||
HANDLERS["user"] = _evaluate_user
|
"getcount": _evaluate_getcount,
|
||||||
HANDLERS["count"] = _evaluate_count
|
"rand": _evaluate_rand,
|
||||||
HANDLERS["getcount"] = _evaluate_getcount
|
"countdown": _evaluate_countdown,
|
||||||
HANDLERS["rand"] = _evaluate_rand
|
"countup": _evaluate_countdown,
|
||||||
HANDLERS["countdown"] = _evaluate_countdown
|
}
|
||||||
HANDLERS["countup"] = _evaluate_countdown
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class HttpServer:
|
|||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
|
|
||||||
site = web.TCPSite(self._runner, host, port)
|
site = web.TCPSite(self._runner, host, port)
|
||||||
logger.debug(f"Binding web server to {host}:{port}...")
|
logger.debug("Binding web server to %s:%s...", host, port)
|
||||||
try:
|
try:
|
||||||
await site.start()
|
await site.start()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -146,7 +146,7 @@ class HttpServer:
|
|||||||
return web.Response(status=400)
|
return web.Response(status=400)
|
||||||
|
|
||||||
event_type = data.get("type", "unknown")
|
event_type = data.get("type", "unknown")
|
||||||
logger.debug(f"Received webhook: {event_type}")
|
logger.debug("Received webhook: %s", event_type)
|
||||||
|
|
||||||
result = parse_event(data)
|
result = parse_event(data)
|
||||||
if result is None:
|
if result is None:
|
||||||
|
|||||||
+13
-13
@@ -178,9 +178,9 @@ class ModuleLoader:
|
|||||||
|
|
||||||
:return: Set of user module names.
|
:return: Set of user module names.
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Scanning user modules directory: {self.modules_dir}")
|
logger.debug("Scanning user modules directory: %s", self.modules_dir)
|
||||||
if not self.modules_dir.exists():
|
if not self.modules_dir.exists():
|
||||||
logger.debug(f"User modules directory does not exist: {self.modules_dir}")
|
logger.debug("User modules directory does not exist: %s", self.modules_dir)
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
found: set[str] = set()
|
found: set[str] = set()
|
||||||
@@ -290,7 +290,7 @@ class ModuleLoader:
|
|||||||
f"Module name '{module_name}' is reserved and cannot be used"
|
f"Module name '{module_name}' is reserved and cannot be used"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"Attempting to load module: {module_name}")
|
logger.debug("Attempting to load module: %s", module_name)
|
||||||
|
|
||||||
if not self.config.is_module_enabled(module_name):
|
if not self.config.is_module_enabled(module_name):
|
||||||
raise ModuleLoadError(f"Module '{module_name}' is disabled in config")
|
raise ModuleLoadError(f"Module '{module_name}' is disabled in config")
|
||||||
@@ -313,7 +313,7 @@ class ModuleLoader:
|
|||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
sys.modules[f"owlbot_modules.{module_name}"] = module
|
sys.modules[f"owlbot_modules.{module_name}"] = module
|
||||||
|
|
||||||
logger.debug(f"Executing module: {module_name}")
|
logger.debug("Executing module: %s", module_name)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
module_dir = module_path.parent
|
module_dir = module_path.parent
|
||||||
@@ -350,7 +350,7 @@ class ModuleLoader:
|
|||||||
await self._run_module_setup(module_name)
|
await self._run_module_setup(module_name)
|
||||||
logger.info(f"Loaded module '{module_name}'.")
|
logger.info(f"Loaded module '{module_name}'.")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Imported module '{module_name}' (setup deferred).")
|
logger.debug("Imported module '%s' (setup deferred).", module_name)
|
||||||
|
|
||||||
except ModuleLoadError:
|
except ModuleLoadError:
|
||||||
raise
|
raise
|
||||||
@@ -374,7 +374,7 @@ class ModuleLoader:
|
|||||||
:param module_name: The module to unload.
|
:param module_name: The module to unload.
|
||||||
:return: True if module was unloaded, False if not found.
|
:return: True if module was unloaded, False if not found.
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Unloading module '{module_name}'")
|
logger.debug("Unloading module '%s'", module_name)
|
||||||
if module_name not in self.loaded_modules:
|
if module_name not in self.loaded_modules:
|
||||||
logger.warning(f"Cannot unload '{module_name}': not loaded")
|
logger.warning(f"Cannot unload '{module_name}': not loaded")
|
||||||
return False
|
return False
|
||||||
@@ -390,13 +390,13 @@ class ModuleLoader:
|
|||||||
if teardown_funcs and module_ctx:
|
if teardown_funcs and module_ctx:
|
||||||
for teardown_func in teardown_funcs:
|
for teardown_func in teardown_funcs:
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Running @on_teardown for module: {module_name}")
|
logger.debug("Running @on_teardown for module: %s", module_name)
|
||||||
await teardown_func(module_ctx)
|
await teardown_func(module_ctx)
|
||||||
logger.debug(f"Teardown completed for module: {module_name}")
|
logger.debug("Teardown completed for module: %s", module_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Teardown failed for module '{module_name}': {e}")
|
logger.exception(f"Teardown failed for module '{module_name}': {e}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Module '{module_name}' has no @on_teardown handlers.")
|
logger.debug("Module '%s' has no @on_teardown handlers.", module_name)
|
||||||
|
|
||||||
if module_ctx:
|
if module_ctx:
|
||||||
try:
|
try:
|
||||||
@@ -438,15 +438,15 @@ class ModuleLoader:
|
|||||||
self._collect_lifecycle_handlers(module, "_owlbot_setup") if module else []
|
self._collect_lifecycle_handlers(module, "_owlbot_setup") if module else []
|
||||||
)
|
)
|
||||||
if not setup_funcs:
|
if not setup_funcs:
|
||||||
logger.debug(f"Module '{module_name}' has no @on_setup handlers.")
|
logger.debug("Module '%s' has no @on_setup handlers.", module_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
module_ctx = self._module_contexts[module_name]
|
module_ctx = self._module_contexts[module_name]
|
||||||
try:
|
try:
|
||||||
for setup_func in setup_funcs:
|
for setup_func in setup_funcs:
|
||||||
logger.debug(f"Running @on_setup for module: {module_name}")
|
logger.debug("Running @on_setup for module: %s", module_name)
|
||||||
await setup_func(module_ctx)
|
await setup_func(module_ctx)
|
||||||
logger.debug(f"Setup completed for module: {module_name}")
|
logger.debug("Setup completed for module: %s", module_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await module_ctx.storage._close()
|
await module_ctx.storage._close()
|
||||||
self._cleanup_module(module_name)
|
self._cleanup_module(module_name)
|
||||||
@@ -548,4 +548,4 @@ class ModuleLoader:
|
|||||||
self.event_dispatcher.unregister_by_module(module_name)
|
self.event_dispatcher.unregister_by_module(module_name)
|
||||||
self.command_dispatcher.unregister_by_module(module_name)
|
self.command_dispatcher.unregister_by_module(module_name)
|
||||||
self.route_dispatcher.unregister_by_module(module_name)
|
self.route_dispatcher.unregister_by_module(module_name)
|
||||||
logger.debug(f"Cleaned up module '{module_name}'.")
|
logger.debug("Cleaned up module '%s'.", module_name)
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ from collections.abc import Awaitable, Callable
|
|||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
||||||
|
from ..api.context import CommandContext, EventContext
|
||||||
from ..api.event_types import ChatEvent
|
from ..api.event_types import ChatEvent
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from ..api.context import EventContext, ModuleContext
|
from ..api.context import ModuleContext
|
||||||
from ..api.owncast_client import OwncastClient
|
from ..api.owncast_client import OwncastClient
|
||||||
|
|
||||||
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
||||||
@@ -61,7 +62,7 @@ class CommandRegistry:
|
|||||||
|
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
|
|
||||||
logger.debug(f"CommandRegistry initialized with prefix '{prefix}'.")
|
logger.debug("CommandRegistry initialized with prefix '%s'.", prefix)
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -114,9 +115,13 @@ class CommandRegistry:
|
|||||||
|
|
||||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.commands")
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.commands")
|
||||||
module_logger.debug(
|
module_logger.debug(
|
||||||
f"Registered command '{name_lower}' with aliases {sorted(alias_set)}, "
|
"Registered command '%s' with aliases %s, "
|
||||||
f"authenticated={requires_authenticated}, moderator={requires_moderator}, "
|
"authenticated=%s, moderator=%s, cooldown=%s.",
|
||||||
f"cooldown={cooldown}."
|
name_lower,
|
||||||
|
sorted(alias_set),
|
||||||
|
requires_authenticated,
|
||||||
|
requires_moderator,
|
||||||
|
cooldown,
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister(self, name: str) -> bool:
|
def unregister(self, name: str) -> bool:
|
||||||
@@ -141,7 +146,7 @@ class CommandRegistry:
|
|||||||
del self._commands[primary]
|
del self._commands[primary]
|
||||||
|
|
||||||
module_logger = logging.getLogger(f"owlbot.modules.{info.module_name}.commands")
|
module_logger = logging.getLogger(f"owlbot.modules.{info.module_name}.commands")
|
||||||
module_logger.debug(f"Unregistered command '{primary}'.")
|
module_logger.debug("Unregistered command '%s'.", primary)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get(self, trigger: str) -> CommandInfo | None:
|
def get(self, trigger: str) -> CommandInfo | None:
|
||||||
@@ -183,7 +188,7 @@ class CommandRegistry:
|
|||||||
for name, info in self._commands.items()
|
for name, info in self._commands.items()
|
||||||
if info.module_name == module_name
|
if info.module_name == module_name
|
||||||
]
|
]
|
||||||
module_logger.debug(f"Unregistering all commands ({len(to_remove)} total).")
|
module_logger.debug("Unregistering all commands (%d total).", len(to_remove))
|
||||||
for name in to_remove:
|
for name in to_remove:
|
||||||
self.unregister(name)
|
self.unregister(name)
|
||||||
return len(to_remove)
|
return len(to_remove)
|
||||||
@@ -230,7 +235,7 @@ class CommandRegistry:
|
|||||||
command = parts[0].lower()
|
command = parts[0].lower()
|
||||||
args = parts[1] if len(parts) > 1 else ""
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
logger.debug(f"Parsed command: {command!r} with args: {args!r}")
|
logger.debug("Parsed command: %r with args: %r", command, args)
|
||||||
return command, args
|
return command, args
|
||||||
|
|
||||||
|
|
||||||
@@ -302,7 +307,7 @@ class CommandDispatcher:
|
|||||||
module_name="__builtin__",
|
module_name="__builtin__",
|
||||||
cooldown=60,
|
cooldown=60,
|
||||||
)
|
)
|
||||||
logger.debug(f"Registered built-in command '{name_lower}'.")
|
logger.debug("Registered built-in command '%s'.", name_lower)
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -416,8 +421,6 @@ class CommandDispatcher:
|
|||||||
|
|
||||||
:param event: The chat event to check for commands.
|
:param event: The chat event to check for commands.
|
||||||
"""
|
"""
|
||||||
from ..api.context import CommandContext, EventContext
|
|
||||||
|
|
||||||
parsed = self._command_registry.parse(event.body)
|
parsed = self._command_registry.parse(event.body)
|
||||||
|
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
@@ -429,7 +432,7 @@ class CommandDispatcher:
|
|||||||
|
|
||||||
if command_info is None:
|
if command_info is None:
|
||||||
# Log for debugging but don't spam the chat with "unknown command" errors.
|
# Log for debugging but don't spam the chat with "unknown command" errors.
|
||||||
logger.debug(f"Unknown command: {command_name}")
|
logger.debug("Unknown command: %s", command_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
user = event.user
|
user = event.user
|
||||||
@@ -492,8 +495,9 @@ class CommandDispatcher:
|
|||||||
)
|
)
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Built-in command '{command_info.name}' "
|
"Built-in command '%s' completed in %.1fms.",
|
||||||
f"completed in {elapsed:.1f}ms."
|
command_info.name,
|
||||||
|
elapsed,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -535,7 +539,9 @@ class CommandDispatcher:
|
|||||||
)
|
)
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
|
|
||||||
logger.debug(f"Command '{command_info.name}' completed in {elapsed:.1f}ms.")
|
logger.debug(
|
||||||
|
"Command '%s' completed in %.1fms.", command_info.name, elapsed
|
||||||
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Command handler '{command_info.name}' "
|
f"Command handler '{command_info.name}' "
|
||||||
|
|||||||
+30
-33
@@ -20,11 +20,13 @@ Internal infrastructure for managing event handler registration and dispatch.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import bisect
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import TYPE_CHECKING, NamedTuple, cast
|
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||||
|
|
||||||
from ..api.context import PropagationState
|
from ..api.context import EventContext, PropagationState
|
||||||
from ..api.event_types import ChatEvent, Event, EventType, log_event
|
from ..api.event_types import ChatEvent, Event, EventType, log_event
|
||||||
from ..api.events import EventHandler, EventMark, Priority
|
from ..api.events import EventHandler, EventMark, Priority
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from ..api.context import EventContext, ModuleContext
|
from ..api.context import ModuleContext
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.events")
|
logger = logging.getLogger("owlbot.events")
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ class EventRegistry:
|
|||||||
"""Initialize an empty handler registry."""
|
"""Initialize an empty handler registry."""
|
||||||
# Maps event type values (strings) to lists of
|
# Maps event type values (strings) to lists of
|
||||||
# (handler, module_name, priority) tuples.
|
# (handler, module_name, priority) tuples.
|
||||||
self._handlers: EventHandlerMap = {}
|
self._handlers: EventHandlerMap = defaultdict(list)
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -82,16 +84,19 @@ class EventRegistry:
|
|||||||
for event_type in event_types:
|
for event_type in event_types:
|
||||||
key = event_type.value
|
key = event_type.value
|
||||||
|
|
||||||
if key not in self._handlers:
|
# Insert in descending priority order so the dispatch loop
|
||||||
self._handlers[key] = []
|
# can iterate without a separate sort step. The priority is
|
||||||
|
# negated because bisect works in ascending order. bisect.insort
|
||||||
# Add the handler to the list. Multiple handlers
|
# uses bisect_right, so same-priority handlers stay in
|
||||||
# for the same event are allowed.
|
# registration (FIFO) order.
|
||||||
self._handlers[key].append(HandlerEntry(handler, module_name, priority))
|
entry = HandlerEntry(handler, module_name, priority)
|
||||||
|
bisect.insort(self._handlers[key], entry, key=lambda e: -e.priority)
|
||||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
||||||
module_logger.debug(
|
module_logger.debug(
|
||||||
f"Registered handler '{handler.__name__}' for {event_type.value} "
|
"Registered handler '%s' for %s (priority=%s).",
|
||||||
f"(priority={priority})."
|
handler.__name__,
|
||||||
|
event_type.value,
|
||||||
|
priority,
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister(self, handler: EventHandler) -> bool:
|
def unregister(self, handler: EventHandler) -> bool:
|
||||||
@@ -116,8 +121,9 @@ class EventRegistry:
|
|||||||
if module_name and removed_from:
|
if module_name and removed_from:
|
||||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
||||||
module_logger.debug(
|
module_logger.debug(
|
||||||
f"Unregistered handler '{handler.__name__}' "
|
"Unregistered handler '%s' for %s.",
|
||||||
f"for {', '.join(removed_from)}."
|
handler.__name__,
|
||||||
|
", ".join(removed_from),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -170,7 +176,7 @@ class EventRegistry:
|
|||||||
self._handlers[key] = kept
|
self._handlers[key] = kept
|
||||||
|
|
||||||
if seen:
|
if seen:
|
||||||
module_logger.debug(f"Unregistered all handlers ({len(seen)} total).")
|
module_logger.debug("Unregistered all handlers (%d total).", len(seen))
|
||||||
return len(seen)
|
return len(seen)
|
||||||
|
|
||||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||||
@@ -322,27 +328,19 @@ class EventDispatcher:
|
|||||||
# Shared state for propagation control (all handlers see the same instance).
|
# Shared state for propagation control (all handlers see the same instance).
|
||||||
propagation = PropagationState()
|
propagation = PropagationState()
|
||||||
|
|
||||||
# Phase 1: Event handlers (sequential, sorted by priority descending).
|
# Phase 1: Event handlers (sequential, already sorted by priority descending).
|
||||||
if handler_entries:
|
if handler_entries:
|
||||||
# Sort handlers by priority (highest first).
|
|
||||||
# Python's sort is stable, so handlers at the same priority
|
|
||||||
# run in their original registration order.
|
|
||||||
sorted_handlers = sorted(
|
|
||||||
handler_entries,
|
|
||||||
key=lambda entry: entry.priority,
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Dispatching {event_type} to {len(sorted_handlers)} handler(s)."
|
"Dispatching %s to %d handler(s).", event_type, len(handler_entries)
|
||||||
)
|
)
|
||||||
|
|
||||||
for handler, module_name, _priority in sorted_handlers:
|
for handler, module_name, _priority in handler_entries:
|
||||||
# Has propagation been stopped by a previous handler?
|
# Has propagation been stopped by a previous handler?
|
||||||
if propagation.stopped:
|
if propagation.stopped:
|
||||||
reason = propagation.reason
|
reason = propagation.reason
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Propagation stopped{': ' + reason if reason else '.'}"
|
"Propagation stopped%s",
|
||||||
|
": " + reason if reason else ".",
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -350,7 +348,7 @@ class EventDispatcher:
|
|||||||
handler, event, event_type, module_name, propagation
|
handler, event, event_type, module_name, propagation
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No handlers registered for event type: {event_type}")
|
logger.debug("No handlers registered for event type: %s", event_type)
|
||||||
|
|
||||||
# Phase 2: Command dispatch (CHAT events only, if not cancelled).
|
# Phase 2: Command dispatch (CHAT events only, if not cancelled).
|
||||||
if event_type == EventType.CHAT and isinstance(event, ChatEvent):
|
if event_type == EventType.CHAT and isinstance(event, ChatEvent):
|
||||||
@@ -358,7 +356,8 @@ class EventDispatcher:
|
|||||||
if propagation.stopped:
|
if propagation.stopped:
|
||||||
reason = propagation.reason
|
reason = propagation.reason
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Command dispatch skipped{': ' + reason if reason else '.'}"
|
"Command dispatch skipped%s",
|
||||||
|
": " + reason if reason else ".",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -382,10 +381,8 @@ class EventDispatcher:
|
|||||||
:param module_name: The module that owns this handler.
|
:param module_name: The module that owns this handler.
|
||||||
:param propagation: Shared propagation state for this dispatch cycle.
|
:param propagation: Shared propagation state for this dispatch cycle.
|
||||||
"""
|
"""
|
||||||
from ..api.context import EventContext
|
|
||||||
|
|
||||||
handler_name = handler.__name__
|
handler_name = handler.__name__
|
||||||
logger.debug(f"Calling handler: {handler_name} from module: {module_name}")
|
logger.debug("Calling handler: %s from module: %s", handler_name, module_name)
|
||||||
|
|
||||||
module_ctx = self._get_module_context(module_name)
|
module_ctx = self._get_module_context(module_name)
|
||||||
|
|
||||||
@@ -400,7 +397,7 @@ class EventDispatcher:
|
|||||||
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
|
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
|
|
||||||
logger.debug(f"Handler '{handler_name}' completed in {elapsed:.1f}ms.")
|
logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Handler '{handler_name}' from module '{module_name}' "
|
f"Handler '{handler_name}' from module '{module_name}' "
|
||||||
|
|||||||
+20
-15
@@ -22,12 +22,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, cast, overload
|
from typing import TYPE_CHECKING, cast, overload
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import DynamicResource
|
from aiohttp.web import DynamicResource
|
||||||
|
|
||||||
|
from ..api.context import RouteContext
|
||||||
from ..api.routes import RouteHandler, RouteInfo, RouteMark
|
from ..api.routes import RouteHandler, RouteInfo, RouteMark
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -69,7 +71,7 @@ class RouteRegistry:
|
|||||||
# Each group represents a unique path pattern with one or more handlers.
|
# Each group represents a unique path pattern with one or more handlers.
|
||||||
self._groups: list[_RouteGroup] = []
|
self._groups: list[_RouteGroup] = []
|
||||||
# Maps module_name -> list of full_paths (for cleanup).
|
# Maps module_name -> list of full_paths (for cleanup).
|
||||||
self._module_routes: dict[str, list[str]] = {}
|
self._module_routes: dict[str, list[str]] = defaultdict(list)
|
||||||
logger.debug("RouteRegistry initialized.")
|
logger.debug("RouteRegistry initialized.")
|
||||||
|
|
||||||
def _find_group(self, full_path: str) -> _RouteGroup | None:
|
def _find_group(self, full_path: str) -> _RouteGroup | None:
|
||||||
@@ -139,14 +141,14 @@ class RouteRegistry:
|
|||||||
else:
|
else:
|
||||||
group.handlers.append(info)
|
group.handlers.append(info)
|
||||||
|
|
||||||
if module_name not in self._module_routes:
|
|
||||||
self._module_routes[module_name] = []
|
|
||||||
if full_path not in self._module_routes[module_name]:
|
if full_path not in self._module_routes[module_name]:
|
||||||
self._module_routes[module_name].append(full_path)
|
self._module_routes[module_name].append(full_path)
|
||||||
|
|
||||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||||
module_logger.debug(
|
module_logger.debug(
|
||||||
f"Registered route '{full_path}' [{', '.join(sorted(info.methods))}]."
|
"Registered route '%s' [%s].",
|
||||||
|
full_path,
|
||||||
|
", ".join(sorted(info.methods)),
|
||||||
)
|
)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -179,7 +181,7 @@ class RouteRegistry:
|
|||||||
module_names = {h.module_name for h in group.handlers}
|
module_names = {h.module_name for h in group.handlers}
|
||||||
for name in module_names:
|
for name in module_names:
|
||||||
module_logger = logging.getLogger(f"owlbot.modules.{name}.routes")
|
module_logger = logging.getLogger(f"owlbot.modules.{name}.routes")
|
||||||
module_logger.debug(f"Unregistered route '{full_path}'.")
|
module_logger.debug("Unregistered route '%s'.", full_path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Remove only the handler for the specified method.
|
# Remove only the handler for the specified method.
|
||||||
@@ -206,7 +208,7 @@ class RouteRegistry:
|
|||||||
f"owlbot.modules.{module_name}.routes"
|
f"owlbot.modules.{module_name}.routes"
|
||||||
)
|
)
|
||||||
module_logger.debug(
|
module_logger.debug(
|
||||||
f"Unregistered route '{full_path}' [{method_upper}]."
|
"Unregistered route '%s' [%s].", full_path, method_upper
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -325,7 +327,7 @@ class RouteRegistry:
|
|||||||
self._groups.pop(i)
|
self._groups.pop(i)
|
||||||
|
|
||||||
if count > 0:
|
if count > 0:
|
||||||
module_logger.debug(f"Unregistered all routes ({count} handler(s) total).")
|
module_logger.debug("Unregistered all routes (%d handler(s) total).", count)
|
||||||
|
|
||||||
self._module_routes.pop(module_name, None)
|
self._module_routes.pop(module_name, None)
|
||||||
return count
|
return count
|
||||||
@@ -485,7 +487,7 @@ class RouteDispatcher:
|
|||||||
try:
|
try:
|
||||||
result = self._route_registry.match(full_path, request.method)
|
result = self._route_registry.match(full_path, request.method)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
mod_logger.debug(f"No route registered for '{full_path}'.")
|
mod_logger.debug("No route registered for '%s'.", full_path)
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
if result[0] is None:
|
if result[0] is None:
|
||||||
@@ -493,8 +495,10 @@ class RouteDispatcher:
|
|||||||
_, allowed_methods, _ = result
|
_, allowed_methods, _ = result
|
||||||
allowed = ", ".join(sorted(allowed_methods))
|
allowed = ", ".join(sorted(allowed_methods))
|
||||||
mod_logger.debug(
|
mod_logger.debug(
|
||||||
f"Method {request.method} not allowed for '{full_path}' "
|
"Method %s not allowed for '%s' (allowed: %s)",
|
||||||
f"(allowed: {allowed})"
|
request.method,
|
||||||
|
full_path,
|
||||||
|
allowed,
|
||||||
)
|
)
|
||||||
return web.Response(status=405, headers={"Allow": allowed})
|
return web.Response(status=405, headers={"Allow": allowed})
|
||||||
|
|
||||||
@@ -514,9 +518,6 @@ class RouteDispatcher:
|
|||||||
:param match_info: Captured path parameters from pattern matching.
|
:param match_info: Captured path parameters from pattern matching.
|
||||||
:return: HTTP response.
|
:return: HTTP response.
|
||||||
"""
|
"""
|
||||||
# Import here to avoid circular imports at module load time.
|
|
||||||
from ..api.context import RouteContext
|
|
||||||
|
|
||||||
module_name = route_info.module_name
|
module_name = route_info.module_name
|
||||||
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||||
|
|
||||||
@@ -529,7 +530,9 @@ class RouteDispatcher:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Calling route handler: {route_info.full_path} from module: {module_name}"
|
"Calling route handler: %s from module: %s",
|
||||||
|
route_info.full_path,
|
||||||
|
module_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -540,7 +543,9 @@ class RouteDispatcher:
|
|||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
|
|
||||||
mod_logger.debug(
|
mod_logger.debug(
|
||||||
f"Route handler '{route_info.full_path}' completed in {elapsed:.1f}ms."
|
"Route handler '%s' completed in %.1fms.",
|
||||||
|
route_info.full_path,
|
||||||
|
elapsed,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def _make_user(
|
|||||||
user_id: str = "user-1",
|
user_id: str = "user-1",
|
||||||
display_name: str = "Alice",
|
display_name: str = "Alice",
|
||||||
is_authenticated: bool = False,
|
is_authenticated: bool = False,
|
||||||
scopes: list[str] | None = None,
|
scopes: frozenset[str] | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Build a minimal User for test events."""
|
"""Build a minimal User for test events."""
|
||||||
return User(
|
return User(
|
||||||
@@ -60,7 +60,7 @@ def _make_user(
|
|||||||
name_changed_at=None,
|
name_changed_at=None,
|
||||||
is_bot=False,
|
is_bot=False,
|
||||||
is_authenticated=is_authenticated,
|
is_authenticated=is_authenticated,
|
||||||
scopes=scopes or [],
|
scopes=scopes or frozenset(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -657,7 +657,7 @@ class TestCommandDispatcherDispatch:
|
|||||||
dispatcher.register(
|
dispatcher.register(
|
||||||
"ban", handler, requires_moderator=True, module_name="mod_a"
|
"ban", handler, requires_moderator=True, module_name="mod_a"
|
||||||
)
|
)
|
||||||
user = _make_user(scopes=["MODERATOR"])
|
user = _make_user(scopes=frozenset({"MODERATOR"}))
|
||||||
await dispatcher.dispatch(_make_chat_event(body="!ban", user=user))
|
await dispatcher.dispatch(_make_chat_event(body="!ban", user=user))
|
||||||
|
|
||||||
assert called is True
|
assert called is True
|
||||||
@@ -676,7 +676,7 @@ class TestCommandDispatcherDispatch:
|
|||||||
dispatcher.register(
|
dispatcher.register(
|
||||||
"ping", handler, requires_moderator=True, module_name="mod_a"
|
"ping", handler, requires_moderator=True, module_name="mod_a"
|
||||||
)
|
)
|
||||||
user = _make_user(scopes=[])
|
user = _make_user(scopes=frozenset())
|
||||||
with caplog.at_level(logging.INFO, logger="owlbot.commands"):
|
with caplog.at_level(logging.INFO, logger="owlbot.commands"):
|
||||||
await dispatcher.dispatch(_make_chat_event(body="!ping", user=user))
|
await dispatcher.dispatch(_make_chat_event(body="!ping", user=user))
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class TestUser:
|
|||||||
assert user.name_changed_at == datetime(2025, 7, 1, 12, 0, tzinfo=UTC)
|
assert user.name_changed_at == datetime(2025, 7, 1, 12, 0, tzinfo=UTC)
|
||||||
assert user.is_bot is False
|
assert user.is_bot is False
|
||||||
assert user.is_authenticated is True
|
assert user.is_authenticated is True
|
||||||
assert user.scopes == ["MODERATOR"]
|
assert user.scopes == frozenset({"MODERATOR"})
|
||||||
|
|
||||||
def test_empty_dict(self) -> None:
|
def test_empty_dict(self) -> None:
|
||||||
"""An empty dict produces a User with all defaults."""
|
"""An empty dict produces a User with all defaults."""
|
||||||
@@ -218,7 +218,7 @@ class TestUser:
|
|||||||
assert user.name_changed_at is None
|
assert user.name_changed_at is None
|
||||||
assert user.is_bot is False
|
assert user.is_bot is False
|
||||||
assert user.is_authenticated is False
|
assert user.is_authenticated is False
|
||||||
assert user.scopes == []
|
assert user.scopes == frozenset()
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("scopes", "expected"),
|
("scopes", "expected"),
|
||||||
@@ -606,7 +606,7 @@ class TestLogEvent:
|
|||||||
name_changed_at=None,
|
name_changed_at=None,
|
||||||
is_bot=False,
|
is_bot=False,
|
||||||
is_authenticated=False,
|
is_authenticated=False,
|
||||||
scopes=[],
|
scopes=frozenset(),
|
||||||
),
|
),
|
||||||
client_id=1,
|
client_id=1,
|
||||||
body="Hello",
|
body="Hello",
|
||||||
@@ -630,7 +630,7 @@ class TestLogEvent:
|
|||||||
name_changed_at=None,
|
name_changed_at=None,
|
||||||
is_bot=False,
|
is_bot=False,
|
||||||
is_authenticated=False,
|
is_authenticated=False,
|
||||||
scopes=[],
|
scopes=frozenset(),
|
||||||
),
|
),
|
||||||
client_id=1,
|
client_id=1,
|
||||||
event_id="evt-001",
|
event_id="evt-001",
|
||||||
@@ -651,7 +651,7 @@ class TestLogEvent:
|
|||||||
name_changed_at=None,
|
name_changed_at=None,
|
||||||
is_bot=False,
|
is_bot=False,
|
||||||
is_authenticated=False,
|
is_authenticated=False,
|
||||||
scopes=[],
|
scopes=frozenset(),
|
||||||
),
|
),
|
||||||
client_id=1,
|
client_id=1,
|
||||||
event_id="evt-002",
|
event_id="evt-002",
|
||||||
@@ -672,7 +672,7 @@ class TestLogEvent:
|
|||||||
name_changed_at=None,
|
name_changed_at=None,
|
||||||
is_bot=False,
|
is_bot=False,
|
||||||
is_authenticated=False,
|
is_authenticated=False,
|
||||||
scopes=[],
|
scopes=frozenset(),
|
||||||
),
|
),
|
||||||
client_id=1,
|
client_id=1,
|
||||||
new_name="NewName",
|
new_name="NewName",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def _make_user(
|
|||||||
name_changed_at=None,
|
name_changed_at=None,
|
||||||
is_bot=False,
|
is_bot=False,
|
||||||
is_authenticated=False,
|
is_authenticated=False,
|
||||||
scopes=[],
|
scopes=frozenset(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ class TestEventRegistry:
|
|||||||
assert joined_entries[0].handler is handler
|
assert joined_entries[0].handler is handler
|
||||||
|
|
||||||
def test_register_multiple_handlers_same_type(self) -> None:
|
def test_register_multiple_handlers_same_type(self) -> None:
|
||||||
"""Two handlers for the same type both appear in get()."""
|
"""Two handlers for the same type both appear in get(), sorted by priority."""
|
||||||
registry = EventRegistry()
|
registry = EventRegistry()
|
||||||
|
|
||||||
async def handler_a(ctx: EventContext[Any]) -> None:
|
async def handler_a(ctx: EventContext[Any]) -> None:
|
||||||
@@ -243,8 +243,9 @@ class TestEventRegistry:
|
|||||||
registry.register(handler_b, (EventType.CHAT,), "mod_b", 75)
|
registry.register(handler_b, (EventType.CHAT,), "mod_b", 75)
|
||||||
entries = registry.get(EventType.CHAT)
|
entries = registry.get(EventType.CHAT)
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
assert entries[0].handler is handler_a
|
# Higher priority first (descending order).
|
||||||
assert entries[1].handler is handler_b
|
assert entries[0].handler is handler_b
|
||||||
|
assert entries[1].handler is handler_a
|
||||||
|
|
||||||
def test_get_empty(self) -> None:
|
def test_get_empty(self) -> None:
|
||||||
"""get() on a fresh registry returns an empty list."""
|
"""get() on a fresh registry returns an empty list."""
|
||||||
|
|||||||
Reference in New Issue
Block a user