Added owlbot.testing package with pytest plugin, event factories, and recording Owncast stubs for module tests.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 19s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 19s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
- owlbot.testing.plugin: fixtures that mirror ModuleLoader's wiring (module_context, module_lifecycle, command/event/route dispatchers, in-memory storage, real Config on a tmp YAML, HttpClient with aioresponses interception, and an aiohttp route_client). - owlbot.testing.helpers: make_user, make_chat_event, and factories for every Owncast event type, with sensible defaults. - owlbot.testing.stubs: RecordingOwncastClient and RecordingOwncastAdminClient that log each call to a .calls list in place of hitting the API. - pyproject.toml: added a 'testing' extra for downstream module authors (pytest, pytest-asyncio, pytest-aiohttp, aioresponses). - tests: migrated in-tree tests to the new helpers, moved module tests under tests/builtin_modules/, and swapped freezegun for time-machine.
This commit is contained in:
+1
-1
Submodule docs updated: 6e1596f3a6...eb0d4e37b5
@@ -0,0 +1,49 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Testing utilities for Owlbot modules."""
|
||||
|
||||
from owlbot.testing.helpers import (
|
||||
make_chat_event,
|
||||
make_name_changed_event,
|
||||
make_stream_started_event,
|
||||
make_stream_status,
|
||||
make_stream_stopped_event,
|
||||
make_stream_title_updated_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
make_user_parted_event,
|
||||
make_visibility_update_event,
|
||||
)
|
||||
from owlbot.testing.stubs import (
|
||||
RecordedCall,
|
||||
RecordingOwncastAdminClient,
|
||||
RecordingOwncastClient,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordedCall",
|
||||
"RecordingOwncastAdminClient",
|
||||
"RecordingOwncastClient",
|
||||
"make_chat_event",
|
||||
"make_name_changed_event",
|
||||
"make_stream_started_event",
|
||||
"make_stream_status",
|
||||
"make_stream_stopped_event",
|
||||
"make_stream_title_updated_event",
|
||||
"make_user",
|
||||
"make_user_joined_event",
|
||||
"make_user_parted_event",
|
||||
"make_visibility_update_event",
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Factory functions for building test event and user objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owlbot.api.event_types import (
|
||||
ChatEvent,
|
||||
NameChangedEvent,
|
||||
StreamStartedEvent,
|
||||
StreamStatus,
|
||||
StreamStoppedEvent,
|
||||
StreamTitleUpdatedEvent,
|
||||
User,
|
||||
UserJoinedEvent,
|
||||
UserPartedEvent,
|
||||
VisibilityUpdateEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def make_user(
|
||||
*,
|
||||
id: str = "test-user-id", # noqa: A002
|
||||
display_name: str = "TestUser",
|
||||
display_color: int = 0,
|
||||
created_at: datetime | None = None,
|
||||
previous_names: list[str] | None = None,
|
||||
name_changed_at: datetime | None = None,
|
||||
is_bot: bool = False,
|
||||
is_authenticated: bool = False,
|
||||
is_moderator: bool = False,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> User:
|
||||
"""Build a User with sensible test defaults.
|
||||
|
||||
:param id: User identifier.
|
||||
:param display_name: Display name shown in chat.
|
||||
:param display_color: Color index for the user's name.
|
||||
:param created_at: Account creation timestamp.
|
||||
:param previous_names: List of previous display names.
|
||||
:param name_changed_at: Timestamp of last name change.
|
||||
:param is_bot: Whether the user is a bot.
|
||||
:param is_authenticated: Whether the user is authenticated.
|
||||
:param is_moderator: When True, adds ``"MODERATOR"`` to scopes.
|
||||
:param scopes: Scope set for the user; defaults to empty.
|
||||
:return: A populated User instance.
|
||||
"""
|
||||
resolved_scopes = scopes if scopes is not None else frozenset()
|
||||
if is_moderator:
|
||||
resolved_scopes = resolved_scopes | frozenset({"MODERATOR"})
|
||||
|
||||
return User(
|
||||
id=id,
|
||||
display_name=display_name,
|
||||
display_color=display_color,
|
||||
created_at=created_at,
|
||||
previous_names=previous_names if previous_names is not None else [],
|
||||
name_changed_at=name_changed_at,
|
||||
is_bot=is_bot,
|
||||
is_authenticated=is_authenticated,
|
||||
scopes=resolved_scopes,
|
||||
)
|
||||
|
||||
|
||||
def make_chat_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
raw_body: str = "test message",
|
||||
body: str | None = None,
|
||||
client_id: int = 0,
|
||||
message_id: str = "test-msg-id",
|
||||
is_visible: bool = True,
|
||||
timestamp: datetime | None = None,
|
||||
) -> ChatEvent:
|
||||
"""Build a ChatEvent with sensible test defaults.
|
||||
|
||||
``raw_body`` is the primary field because it is what the command
|
||||
dispatcher parses; tests dispatching commands should set it to the
|
||||
command text. ``body`` defaults to a literal copy of ``raw_body``. It
|
||||
is **not** Owncast-rendered HTML, so tests that need the rendered form
|
||||
must pass ``body=`` explicitly.
|
||||
|
||||
:param user: The chat user; defaults to ``make_user()``.
|
||||
:param raw_body: Original user input; also used as the body default.
|
||||
:param body: Rendered message body; defaults to *raw_body*.
|
||||
:param client_id: Client connection identifier.
|
||||
:param message_id: Unique message identifier.
|
||||
:param is_visible: Whether the message is visible. Defaults to True;
|
||||
chat messages are normally shown.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated ChatEvent instance.
|
||||
"""
|
||||
return ChatEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
body=body if body is not None else raw_body,
|
||||
raw_body=raw_body,
|
||||
client_id=client_id,
|
||||
message_id=message_id,
|
||||
is_visible=is_visible,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_user_joined_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
client_id: int = 0,
|
||||
event_id: str = "test-join-id",
|
||||
timestamp: datetime | None = None,
|
||||
) -> UserJoinedEvent:
|
||||
"""Build a UserJoinedEvent with sensible test defaults.
|
||||
|
||||
:param user: The joining user; defaults to ``make_user()``.
|
||||
:param client_id: Client connection identifier.
|
||||
:param event_id: Unique event identifier.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated UserJoinedEvent instance.
|
||||
"""
|
||||
return UserJoinedEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
client_id=client_id,
|
||||
event_id=event_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_user_parted_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
client_id: int = 0,
|
||||
event_id: str = "test-part-id",
|
||||
timestamp: datetime | None = None,
|
||||
) -> UserPartedEvent:
|
||||
"""Build a UserPartedEvent with sensible test defaults.
|
||||
|
||||
:param user: The departing user; defaults to ``make_user()``.
|
||||
:param client_id: Client connection identifier.
|
||||
:param event_id: Unique event identifier.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated UserPartedEvent instance.
|
||||
"""
|
||||
return UserPartedEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
client_id=client_id,
|
||||
event_id=event_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_name_changed_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
client_id: int = 0,
|
||||
new_name: str = "NewName",
|
||||
event_id: str = "test-name-change-id",
|
||||
timestamp: datetime | None = None,
|
||||
) -> NameChangedEvent:
|
||||
"""Build a NameChangedEvent with sensible test defaults.
|
||||
|
||||
:param user: The user changing names; defaults to ``make_user()``.
|
||||
:param client_id: Client connection identifier.
|
||||
:param new_name: The new display name.
|
||||
:param event_id: Unique event identifier.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated NameChangedEvent instance.
|
||||
"""
|
||||
return NameChangedEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
client_id=client_id,
|
||||
new_name=new_name,
|
||||
event_id=event_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_started_event(
|
||||
*,
|
||||
server_id: str = "test-server-id",
|
||||
server_name: str = "Test Server",
|
||||
stream_title: str = "Test Stream",
|
||||
summary: str = "",
|
||||
timestamp: datetime | None = None,
|
||||
) -> StreamStartedEvent:
|
||||
"""Build a StreamStartedEvent with sensible test defaults.
|
||||
|
||||
:param server_id: Server identifier.
|
||||
:param server_name: Server display name.
|
||||
:param stream_title: Current stream title.
|
||||
:param summary: Stream summary text.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated StreamStartedEvent instance.
|
||||
"""
|
||||
return StreamStartedEvent(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
stream_title=stream_title,
|
||||
summary=summary,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_stopped_event(
|
||||
*,
|
||||
server_id: str = "test-server-id",
|
||||
server_name: str = "Test Server",
|
||||
stream_title: str = "Test Stream",
|
||||
summary: str = "",
|
||||
timestamp: datetime | None = None,
|
||||
) -> StreamStoppedEvent:
|
||||
"""Build a StreamStoppedEvent with sensible test defaults.
|
||||
|
||||
:param server_id: Server identifier.
|
||||
:param server_name: Server display name.
|
||||
:param stream_title: Current stream title.
|
||||
:param summary: Stream summary text.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated StreamStoppedEvent instance.
|
||||
"""
|
||||
return StreamStoppedEvent(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
stream_title=stream_title,
|
||||
summary=summary,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_status(
|
||||
*,
|
||||
last_connect_time: datetime | None = None,
|
||||
last_disconnect_time: datetime | None = None,
|
||||
version_number: str = "",
|
||||
stream_title: str = "Test Stream",
|
||||
viewer_count: int = 0,
|
||||
overall_max_viewer_count: int = 0,
|
||||
session_max_viewer_count: int = 0,
|
||||
is_online: bool = False,
|
||||
) -> StreamStatus:
|
||||
"""Build a StreamStatus with sensible test defaults.
|
||||
|
||||
:param last_connect_time: Timestamp of the last stream connect.
|
||||
:param last_disconnect_time: Timestamp of the last stream disconnect.
|
||||
:param version_number: Owncast server version string.
|
||||
:param stream_title: Current stream title.
|
||||
:param viewer_count: Current viewer count.
|
||||
:param overall_max_viewer_count: All-time peak viewer count.
|
||||
:param session_max_viewer_count: Current-session peak viewer count.
|
||||
:param is_online: Whether the stream is currently live.
|
||||
:return: A populated StreamStatus instance.
|
||||
"""
|
||||
return StreamStatus(
|
||||
last_connect_time=last_connect_time,
|
||||
last_disconnect_time=last_disconnect_time,
|
||||
version_number=version_number,
|
||||
stream_title=stream_title,
|
||||
viewer_count=viewer_count,
|
||||
overall_max_viewer_count=overall_max_viewer_count,
|
||||
session_max_viewer_count=session_max_viewer_count,
|
||||
is_online=is_online,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_title_updated_event(
|
||||
*,
|
||||
server_id: str = "test-server-id",
|
||||
server_name: str = "Test Server",
|
||||
stream_title: str = "Test Stream",
|
||||
summary: str = "",
|
||||
status: StreamStatus | None = None,
|
||||
timestamp: datetime | None = None,
|
||||
) -> StreamTitleUpdatedEvent:
|
||||
"""Build a StreamTitleUpdatedEvent with sensible test defaults.
|
||||
|
||||
:param server_id: Server identifier.
|
||||
:param server_name: Server display name.
|
||||
:param stream_title: Current stream title.
|
||||
:param summary: Stream summary text.
|
||||
:param status: Optional stream status snapshot.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated StreamTitleUpdatedEvent instance.
|
||||
"""
|
||||
return StreamTitleUpdatedEvent(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
stream_title=stream_title,
|
||||
summary=summary,
|
||||
status=status,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_visibility_update_event(
|
||||
*,
|
||||
event_id: str = "test-vis-id",
|
||||
message_ids: list[str] | None = None,
|
||||
is_visible: bool = False,
|
||||
timestamp: datetime | None = None,
|
||||
) -> VisibilityUpdateEvent:
|
||||
"""Build a VisibilityUpdateEvent with sensible test defaults.
|
||||
|
||||
:param event_id: Unique event identifier.
|
||||
:param message_ids: List of affected message IDs.
|
||||
:param is_visible: Whether the messages are now visible. Defaults to
|
||||
False; visibility updates are primarily used to hide messages.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated VisibilityUpdateEvent instance.
|
||||
"""
|
||||
return VisibilityUpdateEvent(
|
||||
event_id=event_id,
|
||||
message_ids=message_ids if message_ids is not None else [],
|
||||
is_visible=is_visible,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
@@ -0,0 +1,459 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Pytest plugin for Owlbot module testing.
|
||||
|
||||
The fixture layering mirrors :class:`~owlbot.module_loader.ModuleLoader`:
|
||||
|
||||
* A shared ``_context_registry`` maps module names to ``ModuleContext``
|
||||
instances (production equivalent: ``ModuleLoader._module_contexts``).
|
||||
* A shared ``_loaded_modules`` set is wired into the dispatchers and
|
||||
mutated as modules are registered (production equivalent:
|
||||
``ModuleLoader.loaded_modules``).
|
||||
* Dispatchers close over the registry for lookups, raising
|
||||
:class:`pytest.UsageError` for unknown names.
|
||||
* ``module_context`` builds the default module's context on demand
|
||||
(mirroring ``ModuleLoader.load_module``) and caches it in the registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
from aioresponses import aioresponses
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
import owlbot
|
||||
from owlbot.api.config import Config, ModuleConfig
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.http_client import HttpClient
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
from owlbot.api.templates import ModuleTemplates
|
||||
from owlbot.module_loader import ModuleLoader
|
||||
from owlbot.registries.commands import CommandDispatcher, ModuleCommands
|
||||
from owlbot.registries.events import EventDispatcher, ModuleEvents
|
||||
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher
|
||||
|
||||
from .stubs import RecordingOwncastAdminClient, RecordingOwncastClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from types import ModuleType
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
|
||||
|
||||
def _make_context_lookup(
|
||||
registry: dict[str, ModuleContext],
|
||||
) -> Callable[[str], ModuleContext]:
|
||||
"""Build a ``get_module_context`` callable that reads from the registry.
|
||||
|
||||
Unknown names raise :class:`pytest.UsageError` with a message pointing
|
||||
the reader at ``module_context`` to correct the setup.
|
||||
"""
|
||||
|
||||
def lookup(name: str) -> ModuleContext:
|
||||
try:
|
||||
return registry[name]
|
||||
except KeyError as exc:
|
||||
msg = (
|
||||
f"No module context registered for {name!r}. Use the "
|
||||
f"module_context fixture to register the default module."
|
||||
)
|
||||
raise pytest.UsageError(msg) from exc
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg() -> ModuleType | None:
|
||||
"""Package of the Owlbot module under test.
|
||||
|
||||
Override with the imported package (e.g., ``owlbot.builtin_modules.quotes``
|
||||
or ``owlbot_modules.my_module``) to auto-wire the module name, templates,
|
||||
and the ``module_lifecycle`` / ``registered_handlers`` fixtures for that
|
||||
module.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_name(module_pkg: ModuleType | None) -> str:
|
||||
"""Canonical name of the default module under test.
|
||||
|
||||
Defaults to the last dotted segment of ``module_pkg.__name__``, or
|
||||
``"test_module"`` when ``module_pkg`` is unset. Override this fixture
|
||||
directly to set a specific name without importing a package.
|
||||
"""
|
||||
if module_pkg is None:
|
||||
return "test_module"
|
||||
return module_pkg.__name__.rsplit(".", 1)[-1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client_enabled() -> bool:
|
||||
"""Whether the admin client is wired into the module context."""
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_prefix() -> str:
|
||||
"""Prefix character used for command parsing."""
|
||||
return "!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler_timeout() -> float:
|
||||
"""Timeout in seconds for handler execution."""
|
||||
return 30.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_data() -> dict[str, Any]:
|
||||
"""Seed YAML content for the real :class:`~owlbot.api.config.Config`.
|
||||
|
||||
Override to customize any section (e.g. set ``owlbot.public_base_url``
|
||||
or a ``modules.<name>`` block) before ``Config`` loads.
|
||||
"""
|
||||
return {
|
||||
"owncast": {"url": "http://localhost:8080"},
|
||||
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_path(tmp_path: Path, config_data: dict[str, Any]) -> Path:
|
||||
"""Write ``config_data`` to ``tmp_path/config.yaml`` and return its path."""
|
||||
path = tmp_path / "config.yaml"
|
||||
yaml = YAML(typ="safe")
|
||||
with path.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(config_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
|
||||
"""Construct a real ``Config`` backed by a tmp YAML file.
|
||||
|
||||
``OWLBOT_*`` environment variables are cleared for the duration of the
|
||||
test so CI-level overrides do not bleed into the fixture.
|
||||
"""
|
||||
for name in list(os.environ):
|
||||
if name.startswith("OWLBOT_"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
return Config(config_path)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def storage(module_name: str) -> AsyncIterator[ModuleStorage]:
|
||||
"""Yield an open in-memory ModuleStorage for the default module."""
|
||||
async with ModuleStorage(None, module_name) as s:
|
||||
yield s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owncast_client(config: Config) -> RecordingOwncastClient:
|
||||
"""Return a recording stub for the Owncast client.
|
||||
|
||||
The stub's ``base_url`` is seeded from ``config.owncast_url`` so it stays
|
||||
consistent with any ``config_data`` override the test sets.
|
||||
"""
|
||||
return RecordingOwncastClient(base_url=config.owncast_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(
|
||||
config: Config,
|
||||
*,
|
||||
admin_client_enabled: bool,
|
||||
) -> RecordingOwncastAdminClient:
|
||||
"""Return a recording stub for the Owncast admin client.
|
||||
|
||||
The stub's ``base_url`` is seeded from ``config.owncast_url`` so it stays
|
||||
consistent with any ``config_data`` override the test sets.
|
||||
|
||||
:raises pytest.UsageError: If ``admin_client_enabled`` has not been
|
||||
overridden to ``True``. Prevents tests from asserting on a
|
||||
disconnected stub that handlers never see.
|
||||
"""
|
||||
if not admin_client_enabled:
|
||||
raise pytest.UsageError(
|
||||
"admin_client requires admin_client_enabled=True; override the "
|
||||
"admin_client_enabled fixture to enable the admin client"
|
||||
)
|
||||
return RecordingOwncastAdminClient(base_url=config.owncast_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_config(config: Config, module_name: str) -> ModuleConfig:
|
||||
"""Return a real ``ModuleConfig`` scoped to ``module_name``."""
|
||||
return ModuleConfig(config, module_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def templates(module_pkg: ModuleType | None, tmp_path: Path) -> ModuleTemplates:
|
||||
"""Return a Jinja2 ``ModuleTemplates`` renderer for the default module.
|
||||
|
||||
Derives from ``module_pkg`` when set (module templates + core). When unset,
|
||||
falls back to a core-only renderer (backed by an empty ``tmp_path``) so
|
||||
module-specific templates resolve to ``TemplateNotFound`` rather than
|
||||
``AttributeError``.
|
||||
"""
|
||||
core_dir = Path(owlbot.__file__).parent / "templates"
|
||||
if module_pkg is None:
|
||||
return ModuleTemplates(tmp_path, core_dir)
|
||||
if module_pkg.__file__ is None:
|
||||
msg = f"module package {module_pkg.__name__!r} has no __file__"
|
||||
raise RuntimeError(msg)
|
||||
return ModuleTemplates(Path(module_pkg.__file__).parent, core_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_http() -> Iterator[aioresponses]:
|
||||
"""Yield an ``aioresponses`` controller intercepting aiohttp requests.
|
||||
|
||||
Active for every test that uses the ``http`` fixture (directly or via
|
||||
``module_context``). Register canned responses on the yielded object to
|
||||
simulate external APIs. Requests to unregistered URLs raise
|
||||
``ConnectionError``, so tests cannot accidentally reach the network.
|
||||
|
||||
Loopback addresses (``127.0.0.1`` and ``localhost``) pass through
|
||||
unmocked so the ``route_client`` fixture can reach its own aiohttp
|
||||
test server.
|
||||
"""
|
||||
with aioresponses(passthrough=["http://127.0.0.1", "http://localhost"]) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def http(mocked_http: aioresponses) -> AsyncIterator[HttpClient]:
|
||||
"""Yield a started :class:`HttpClient`.
|
||||
|
||||
Depends on ``mocked_http`` so every request is intercepted by the
|
||||
aioresponses patch. The underlying aiohttp session is closed when the
|
||||
test finishes.
|
||||
"""
|
||||
client = HttpClient()
|
||||
await client._start() # noqa: SLF001 # framework lifecycle reused for tests
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client._close() # noqa: SLF001 # framework lifecycle reused for tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _context_registry() -> dict[str, ModuleContext]:
|
||||
"""Shared cache of ModuleContexts keyed by module name.
|
||||
|
||||
Mirrors :attr:`owlbot.module_loader.ModuleLoader._module_contexts`. The
|
||||
dispatcher fixtures close over this dict for lookups, and
|
||||
``module_context`` populates it on demand.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _loaded_modules() -> set[str]:
|
||||
"""Shared set of module names currently loaded.
|
||||
|
||||
Mirrors :attr:`owlbot.module_loader.ModuleLoader.loaded_modules`. Wired
|
||||
into ``command_dispatcher`` and mutated by ``module_context`` and
|
||||
``registered_handlers`` as modules are registered.
|
||||
"""
|
||||
return set()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_dispatcher(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
_loaded_modules: set[str],
|
||||
owncast_client: RecordingOwncastClient,
|
||||
handler_timeout: float,
|
||||
command_prefix: str,
|
||||
) -> CommandDispatcher:
|
||||
"""Build a CommandDispatcher that resolves contexts via ``_context_registry``."""
|
||||
return CommandDispatcher(
|
||||
get_module_context=_make_context_lookup(_context_registry),
|
||||
owncast_client=owncast_client,
|
||||
handler_timeout=handler_timeout,
|
||||
loaded_modules=_loaded_modules,
|
||||
command_prefix=command_prefix,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_dispatcher(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
command_dispatcher: CommandDispatcher,
|
||||
handler_timeout: float,
|
||||
) -> EventDispatcher:
|
||||
"""Build an EventDispatcher that resolves contexts via ``_context_registry``."""
|
||||
return EventDispatcher(
|
||||
command_dispatch=command_dispatcher.dispatch,
|
||||
get_module_context=_make_context_lookup(_context_registry),
|
||||
handler_timeout=handler_timeout,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def route_dispatcher(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
handler_timeout: float,
|
||||
) -> RouteDispatcher:
|
||||
"""Build a RouteDispatcher that resolves contexts via ``_context_registry``."""
|
||||
return RouteDispatcher(
|
||||
get_module_context=_make_context_lookup(_context_registry),
|
||||
handler_timeout=handler_timeout,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_context(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
_loaded_modules: set[str],
|
||||
module_name: str,
|
||||
command_dispatcher: CommandDispatcher,
|
||||
event_dispatcher: EventDispatcher,
|
||||
route_dispatcher: RouteDispatcher,
|
||||
storage: ModuleStorage,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
module_config: ModuleConfig,
|
||||
templates: ModuleTemplates,
|
||||
http: HttpClient,
|
||||
request: pytest.FixtureRequest,
|
||||
*,
|
||||
admin_client_enabled: bool,
|
||||
) -> ModuleContext:
|
||||
"""Return the ModuleContext for the default module.
|
||||
|
||||
Mirrors the context-building half of
|
||||
:meth:`owlbot.module_loader.ModuleLoader.load_module`: wires the
|
||||
name-scoped ``ModuleCommands`` / ``ModuleEvents`` / ``ModuleRoutes``
|
||||
against the dispatchers, registers the context in ``_context_registry``,
|
||||
and adds the name to ``_loaded_modules``.
|
||||
"""
|
||||
admin_client = (
|
||||
request.getfixturevalue("admin_client") if admin_client_enabled else None
|
||||
)
|
||||
ctx = ModuleContext(
|
||||
module_name=module_name,
|
||||
config=module_config,
|
||||
owncast_client=owncast_client,
|
||||
storage=storage,
|
||||
commands=ModuleCommands(command_dispatcher, module_name),
|
||||
events=ModuleEvents(event_dispatcher, module_name),
|
||||
routes=ModuleRoutes(
|
||||
route_dispatcher, module_name, module_config.public_base_url
|
||||
),
|
||||
http=http,
|
||||
templates=templates,
|
||||
admin_client=admin_client,
|
||||
)
|
||||
_context_registry[module_name] = ctx
|
||||
_loaded_modules.add(module_name)
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def route_client(
|
||||
route_dispatcher: RouteDispatcher,
|
||||
aiohttp_client: Any,
|
||||
) -> TestClient[Any, Any]:
|
||||
"""Return an aiohttp test client wired to the RouteDispatcher."""
|
||||
app = web.Application()
|
||||
app.router.add_route(
|
||||
"*", "/owlbot/{module_name}/{path:.*}", route_dispatcher.dispatch
|
||||
)
|
||||
app.router.add_route("*", "/owlbot/{module_name}", route_dispatcher.dispatch)
|
||||
|
||||
client: TestClient[Any, Any] = await aiohttp_client(app)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_handlers(
|
||||
module_pkg: ModuleType | None,
|
||||
module_name: str,
|
||||
command_dispatcher: CommandDispatcher,
|
||||
event_dispatcher: EventDispatcher,
|
||||
route_dispatcher: RouteDispatcher,
|
||||
_loaded_modules: set[str],
|
||||
) -> None:
|
||||
"""Register all @on_command, @on_event, and @on_route handlers from module_pkg.
|
||||
|
||||
Mirrors the registration half of
|
||||
:meth:`owlbot.module_loader.ModuleLoader.load_module`: the module's name
|
||||
is added to ``_loaded_modules`` after registration so dispatch routes
|
||||
commands correctly.
|
||||
|
||||
Prefer ``module_lifecycle`` unless a test specifically needs to skip
|
||||
``@on_setup`` / ``@on_teardown`` hooks (e.g., when the module's setup has
|
||||
side effects the test cannot tolerate).
|
||||
|
||||
:raises pytest.UsageError: If ``module_pkg`` has not been overridden.
|
||||
"""
|
||||
if module_pkg is None:
|
||||
raise pytest.UsageError(
|
||||
"registered_handlers requires module_pkg to be set; override the "
|
||||
"module_pkg fixture with the module package under test"
|
||||
)
|
||||
command_dispatcher.register_from_module(module_pkg, module_name)
|
||||
event_dispatcher.register_from_module(module_pkg, module_name)
|
||||
route_dispatcher.register_from_module(module_pkg, module_name)
|
||||
_loaded_modules.add(module_name)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def module_lifecycle(
|
||||
module_pkg: ModuleType | None,
|
||||
module_context: ModuleContext,
|
||||
registered_handlers: None, # transitively wires decorator registration
|
||||
) -> AsyncIterator[None]:
|
||||
"""Load ``module_pkg`` the way ``ModuleLoader`` does at runtime.
|
||||
|
||||
Depends on ``registered_handlers`` to wire ``@on_command``, ``@on_event``,
|
||||
and ``@on_route`` decorators, runs every ``@on_setup`` hook, yields, then
|
||||
runs every ``@on_teardown`` hook on test exit.
|
||||
|
||||
Recording-stub call logs are preserved across the yield, so tests can
|
||||
observe what ``@on_setup`` did. Tests that want a clean baseline for
|
||||
handler-initiated calls should clear ``owncast_client.calls`` (and
|
||||
``admin_client.calls`` when the admin client is enabled) in a local
|
||||
fixture that depends on this one.
|
||||
|
||||
:raises pytest.UsageError: If ``module_pkg`` has not been overridden.
|
||||
"""
|
||||
if module_pkg is None:
|
||||
raise pytest.UsageError(
|
||||
"module_lifecycle requires module_pkg to be set; override the "
|
||||
"module_pkg fixture with the module package under test"
|
||||
)
|
||||
setup_hooks = ModuleLoader._collect_lifecycle_handlers( # noqa: SLF001 # reuses the real loader's scan
|
||||
module_pkg, "_owlbot_setup"
|
||||
)
|
||||
for hook in setup_hooks:
|
||||
await hook(module_context)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
teardown_hooks = ModuleLoader._collect_lifecycle_handlers( # noqa: SLF001 # reuses the real loader's scan
|
||||
module_pkg, "_owlbot_teardown"
|
||||
)
|
||||
for hook in teardown_hooks:
|
||||
await hook(module_context)
|
||||
@@ -0,0 +1,608 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Recording stubs for the Owncast HTTP clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.owncast_admin_client import (
|
||||
ExternalAction,
|
||||
OwncastAdminClient,
|
||||
SocialHandle,
|
||||
StreamKey,
|
||||
VideoVariant,
|
||||
)
|
||||
from owlbot.api.owncast_client import OwncastClient
|
||||
|
||||
# Stubs inherit from the real classes at type-check time so that code
|
||||
# passing a stub where the real instance is expected (notably plugin.py)
|
||||
# type-checks. At runtime the base is ``object`` to avoid the real
|
||||
# ``__init__`` requiring live HTTP/config dependencies.
|
||||
_ClientBase = OwncastClient
|
||||
_AdminBase = OwncastAdminClient
|
||||
else:
|
||||
_ClientBase = object
|
||||
_AdminBase = object
|
||||
|
||||
|
||||
class RecordedCall(NamedTuple):
|
||||
"""A single recorded method invocation on a recording stub.
|
||||
|
||||
Compares equal to a plain ``(name, kwargs)`` tuple, so assertions written
|
||||
against tuple literals continue to work.
|
||||
"""
|
||||
|
||||
name: str
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
class RecordingOwncastClient(_ClientBase):
|
||||
"""Stub mirroring :class:`~owlbot.api.owncast_client.OwncastClient`.
|
||||
|
||||
Each public async method records ``(method_name, kwargs_dict)`` to
|
||||
:attr:`calls` and returns an empty default of the declared return type.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8080") -> None:
|
||||
"""Initialize with an empty call log and the given base URL."""
|
||||
self.calls: list[RecordedCall] = []
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The Owncast server base URL passed at construction."""
|
||||
return self._base_url
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get the public server status."""
|
||||
self.calls.append(RecordedCall("get_status", {}))
|
||||
return {}
|
||||
|
||||
async def send_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||
"""Send a chat message visible to all viewers."""
|
||||
self.calls.append(
|
||||
RecordedCall("send_message", {"body": body, "unsanitized": unsanitized})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||
"""Send a system message visible to all viewers."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"send_system_message", {"body": body, "unsanitized": unsanitized}
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
|
||||
"""Send an action message (like IRC /me)."""
|
||||
self.calls.append(
|
||||
RecordedCall("send_action", {"body": body, "unsanitized": unsanitized})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_system_message_to_client(
|
||||
self, client_id: int, body: str, *, unsanitized: bool = False
|
||||
) -> str:
|
||||
"""Send a private system message to a specific viewer."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"send_system_message_to_client",
|
||||
{"client_id": client_id, "body": body, "unsanitized": unsanitized},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_message_visibility(
|
||||
self, message_ids: list[str], *, visible: bool
|
||||
) -> str:
|
||||
"""Hide or show chat messages (moderation)."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_message_visibility",
|
||||
{"message_ids": message_ids, "visible": visible},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_chat_history(self) -> list[dict[str, Any]]:
|
||||
"""Fetch recent chat messages."""
|
||||
self.calls.append(RecordedCall("get_chat_history", {}))
|
||||
return []
|
||||
|
||||
async def get_connected_clients(self) -> list[dict[str, Any]]:
|
||||
"""Get list of currently connected viewers."""
|
||||
self.calls.append(RecordedCall("get_connected_clients", {}))
|
||||
return []
|
||||
|
||||
async def set_stream_title(self, title: str) -> str:
|
||||
"""Update the stream title."""
|
||||
self.calls.append(RecordedCall("set_stream_title", {"title": title}))
|
||||
return ""
|
||||
|
||||
|
||||
class RecordingOwncastAdminClient(_AdminBase):
|
||||
"""Stub mirroring :class:`~owlbot.api.owncast_admin_client.OwncastAdminClient`.
|
||||
|
||||
Each public async method records ``(method_name, kwargs_dict)`` to
|
||||
:attr:`calls` and returns an empty default of the declared return type.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8080") -> None:
|
||||
"""Initialize with an empty call log and the given base URL."""
|
||||
self.calls: list[RecordedCall] = []
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The Owncast server base URL passed at construction."""
|
||||
return self._base_url
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get the current server status including stream info and viewer count."""
|
||||
self.calls.append(RecordedCall("get_status", {}))
|
||||
return {}
|
||||
|
||||
async def get_active_viewers(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of currently active viewers."""
|
||||
self.calls.append(RecordedCall("get_active_viewers", {}))
|
||||
return []
|
||||
|
||||
async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]:
|
||||
"""Get viewer count data over time for charting."""
|
||||
self.calls.append(
|
||||
RecordedCall("get_viewers_over_time", {"window_start": window_start})
|
||||
)
|
||||
return []
|
||||
|
||||
async def get_hardware_stats(self) -> dict[str, Any]:
|
||||
"""Get server hardware statistics (CPU, memory, disk)."""
|
||||
self.calls.append(RecordedCall("get_hardware_stats", {}))
|
||||
return {}
|
||||
|
||||
async def get_server_config(self) -> dict[str, Any]:
|
||||
"""Get the full server configuration."""
|
||||
self.calls.append(RecordedCall("get_server_config", {}))
|
||||
return {}
|
||||
|
||||
async def disconnect_stream(self) -> dict[str, Any]:
|
||||
"""Disconnect the current inbound stream."""
|
||||
self.calls.append(RecordedCall("disconnect_stream", {}))
|
||||
return {}
|
||||
|
||||
async def get_playback_metrics(self) -> dict[str, Any]:
|
||||
"""Get playback quality metrics."""
|
||||
self.calls.append(RecordedCall("get_playback_metrics", {}))
|
||||
return {}
|
||||
|
||||
async def get_logs(self) -> list[dict[str, Any]]:
|
||||
"""Get server logs."""
|
||||
self.calls.append(RecordedCall("get_logs", {}))
|
||||
return []
|
||||
|
||||
async def get_warnings(self) -> list[dict[str, Any]]:
|
||||
"""Get server warning and error logs."""
|
||||
self.calls.append(RecordedCall("get_warnings", {}))
|
||||
return []
|
||||
|
||||
async def get_chat_messages(self) -> list[dict[str, Any]]:
|
||||
"""Get chat messages from the admin perspective."""
|
||||
self.calls.append(RecordedCall("get_chat_messages", {}))
|
||||
return []
|
||||
|
||||
async def get_connected_chat_clients(self) -> list[dict[str, Any]]:
|
||||
"""Get currently connected chat clients."""
|
||||
self.calls.append(RecordedCall("get_connected_chat_clients", {}))
|
||||
return []
|
||||
|
||||
async def set_message_visibility(
|
||||
self, message_ids: list[str], *, visible: bool
|
||||
) -> str:
|
||||
"""Hide or show chat messages."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_message_visibility",
|
||||
{"message_ids": message_ids, "visible": visible},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_user_enabled(self, user_id: str, *, enabled: bool) -> str:
|
||||
"""Enable or disable a chat user."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_user_enabled", {"user_id": user_id, "enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_disabled_users(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of disabled chat users."""
|
||||
self.calls.append(RecordedCall("get_disabled_users", {}))
|
||||
return []
|
||||
|
||||
async def set_user_moderator(self, user_id: str, *, is_mod: bool) -> str:
|
||||
"""Grant or revoke moderator status for a user."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_user_moderator", {"user_id": user_id, "is_mod": is_mod})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_moderators(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of moderator users."""
|
||||
self.calls.append(RecordedCall("get_moderators", {}))
|
||||
return []
|
||||
|
||||
async def ban_ip_address(self, ip: str) -> str:
|
||||
"""Ban an IP address from chat."""
|
||||
self.calls.append(RecordedCall("ban_ip_address", {"ip": ip}))
|
||||
return ""
|
||||
|
||||
async def unban_ip_address(self, ip: str) -> str:
|
||||
"""Remove an IP address ban."""
|
||||
self.calls.append(RecordedCall("unban_ip_address", {"ip": ip}))
|
||||
return ""
|
||||
|
||||
async def get_ip_address_bans(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of banned IP addresses."""
|
||||
self.calls.append(RecordedCall("get_ip_address_bans", {}))
|
||||
return []
|
||||
|
||||
async def set_stream_title(self, title: str) -> str:
|
||||
"""Set the stream title."""
|
||||
self.calls.append(RecordedCall("set_stream_title", {"title": title}))
|
||||
return ""
|
||||
|
||||
async def set_server_name(self, name: str) -> str:
|
||||
"""Set the server name."""
|
||||
self.calls.append(RecordedCall("set_server_name", {"name": name}))
|
||||
return ""
|
||||
|
||||
async def set_server_summary(self, summary: str) -> str:
|
||||
"""Set the server summary."""
|
||||
self.calls.append(RecordedCall("set_server_summary", {"summary": summary}))
|
||||
return ""
|
||||
|
||||
async def set_welcome_message(self, message: str) -> str:
|
||||
"""Set the welcome message shown to new viewers."""
|
||||
self.calls.append(RecordedCall("set_welcome_message", {"message": message}))
|
||||
return ""
|
||||
|
||||
async def set_offline_message(self, message: str) -> str:
|
||||
"""Set the message shown when the stream is offline."""
|
||||
self.calls.append(RecordedCall("set_offline_message", {"message": message}))
|
||||
return ""
|
||||
|
||||
async def set_page_content(self, content: str) -> str:
|
||||
"""Set the custom page content (HTML/markdown below the player)."""
|
||||
self.calls.append(RecordedCall("set_page_content", {"content": content}))
|
||||
return ""
|
||||
|
||||
async def set_admin_password(self, password: str) -> str:
|
||||
"""Change the admin password."""
|
||||
self.calls.append(RecordedCall("set_admin_password", {"password": password}))
|
||||
return ""
|
||||
|
||||
async def set_server_url(self, url: str) -> str:
|
||||
"""Set the public server URL."""
|
||||
self.calls.append(RecordedCall("set_server_url", {"url": url}))
|
||||
return ""
|
||||
|
||||
async def set_logo(self, base64_data_url: str) -> str:
|
||||
"""Set the server logo from a base64 data URL."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_logo", {"base64_data_url": base64_data_url})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_custom_styles(self, css: str) -> str:
|
||||
"""Set custom CSS styles for the web interface."""
|
||||
self.calls.append(RecordedCall("set_custom_styles", {"css": css}))
|
||||
return ""
|
||||
|
||||
async def set_custom_javascript(self, js: str) -> str:
|
||||
"""Set custom JavaScript for the web interface."""
|
||||
self.calls.append(RecordedCall("set_custom_javascript", {"js": js}))
|
||||
return ""
|
||||
|
||||
async def set_color_variables(self, variables: dict[str, Any]) -> str:
|
||||
"""Set the custom color variables for the web interface."""
|
||||
self.calls.append(RecordedCall("set_color_variables", {"variables": variables}))
|
||||
return ""
|
||||
|
||||
async def set_tags(self, tags: list[str]) -> str:
|
||||
"""Set the server tags."""
|
||||
self.calls.append(RecordedCall("set_tags", {"tags": tags}))
|
||||
return ""
|
||||
|
||||
async def set_social_handles(self, handles: list[SocialHandle]) -> str:
|
||||
"""Set the social media handles displayed on the page."""
|
||||
self.calls.append(RecordedCall("set_social_handles", {"handles": handles}))
|
||||
return ""
|
||||
|
||||
async def set_external_actions(self, actions: list[ExternalAction]) -> str:
|
||||
"""Set the external actions (buttons/links in the player)."""
|
||||
self.calls.append(RecordedCall("set_external_actions", {"actions": actions}))
|
||||
return ""
|
||||
|
||||
async def set_chat_disabled(self, *, disabled: bool) -> str:
|
||||
"""Enable or disable the chat."""
|
||||
self.calls.append(RecordedCall("set_chat_disabled", {"disabled": disabled}))
|
||||
return ""
|
||||
|
||||
async def set_chat_join_messages_enabled(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable chat join messages."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_chat_join_messages_enabled", {"enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_chat_established_mode(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable established user mode for chat."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_chat_established_mode", {"enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_chat_spam_protection(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable chat spam protection."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_chat_spam_protection", {"enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_chat_slur_filter(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable the chat slur filter."""
|
||||
self.calls.append(RecordedCall("set_chat_slur_filter", {"enabled": enabled}))
|
||||
return ""
|
||||
|
||||
async def set_forbidden_usernames(self, names: list[str]) -> str:
|
||||
"""Set the list of forbidden usernames."""
|
||||
self.calls.append(RecordedCall("set_forbidden_usernames", {"names": names}))
|
||||
return ""
|
||||
|
||||
async def set_suggested_usernames(self, names: list[str]) -> str:
|
||||
"""Set the list of suggested usernames for new viewers."""
|
||||
self.calls.append(RecordedCall("set_suggested_usernames", {"names": names}))
|
||||
return ""
|
||||
|
||||
async def set_nsfw(self, *, nsfw: bool) -> str:
|
||||
"""Set the NSFW flag for the server."""
|
||||
self.calls.append(RecordedCall("set_nsfw", {"nsfw": nsfw}))
|
||||
return ""
|
||||
|
||||
async def set_directory_enabled(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable listing in the Owncast directory."""
|
||||
self.calls.append(RecordedCall("set_directory_enabled", {"enabled": enabled}))
|
||||
return ""
|
||||
|
||||
async def set_hide_viewer_count(self, *, hide: bool) -> str:
|
||||
"""Show or hide the viewer count."""
|
||||
self.calls.append(RecordedCall("set_hide_viewer_count", {"hide": hide}))
|
||||
return ""
|
||||
|
||||
async def set_disable_search_indexing(self, *, disabled: bool) -> str:
|
||||
"""Enable or disable search engine indexing."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_disable_search_indexing", {"disabled": disabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_stream_keys(self, keys: list[StreamKey]) -> str:
|
||||
"""Set the stream keys."""
|
||||
self.calls.append(RecordedCall("set_stream_keys", {"keys": keys}))
|
||||
return ""
|
||||
|
||||
async def set_video_variants(self, variants: list[VideoVariant]) -> str:
|
||||
"""Set the video output variants (quality levels)."""
|
||||
self.calls.append(RecordedCall("set_video_variants", {"variants": variants}))
|
||||
return ""
|
||||
|
||||
async def set_video_codec(self, codec: str) -> str:
|
||||
"""Set the video codec."""
|
||||
self.calls.append(RecordedCall("set_video_codec", {"codec": codec}))
|
||||
return ""
|
||||
|
||||
async def set_video_serving_endpoint(self, endpoint: str) -> str:
|
||||
"""Set the video serving endpoint (e.g., for CDN)."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_video_serving_endpoint", {"endpoint": endpoint})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_stream_latency(self, level: int) -> str:
|
||||
"""Set the stream latency level."""
|
||||
self.calls.append(RecordedCall("set_stream_latency", {"level": level}))
|
||||
return ""
|
||||
|
||||
async def set_ffmpeg_path(self, path: str) -> str:
|
||||
"""Set the path to the ffmpeg binary."""
|
||||
self.calls.append(RecordedCall("set_ffmpeg_path", {"path": path}))
|
||||
return ""
|
||||
|
||||
async def set_socket_host_override(self, host: str) -> str:
|
||||
"""Set the WebSocket host override."""
|
||||
self.calls.append(RecordedCall("set_socket_host_override", {"host": host}))
|
||||
return ""
|
||||
|
||||
async def set_rtmp_port(self, port: int) -> str:
|
||||
"""Set the RTMP server port."""
|
||||
self.calls.append(RecordedCall("set_rtmp_port", {"port": port}))
|
||||
return ""
|
||||
|
||||
async def set_web_server_port(self, port: int) -> str:
|
||||
"""Set the web server port."""
|
||||
self.calls.append(RecordedCall("set_web_server_port", {"port": port}))
|
||||
return ""
|
||||
|
||||
async def set_web_server_ip(self, ip: str) -> str:
|
||||
"""Set the web server bind IP address."""
|
||||
self.calls.append(RecordedCall("set_web_server_ip", {"ip": ip}))
|
||||
return ""
|
||||
|
||||
async def set_s3_config(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
endpoint: str,
|
||||
access_key: str,
|
||||
secret: str,
|
||||
bucket: str,
|
||||
region: str,
|
||||
) -> str:
|
||||
"""Set the S3 storage configuration."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_s3_config",
|
||||
{
|
||||
"enabled": enabled,
|
||||
"endpoint": endpoint,
|
||||
"access_key": access_key,
|
||||
"secret": secret,
|
||||
"bucket": bucket,
|
||||
"region": region,
|
||||
},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_discord_notifications(
|
||||
self, *, enabled: bool, webhook: str, go_live_message: str
|
||||
) -> str:
|
||||
"""Set the Discord notification configuration."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_discord_notifications",
|
||||
{
|
||||
"enabled": enabled,
|
||||
"webhook": webhook,
|
||||
"go_live_message": go_live_message,
|
||||
},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_browser_notifications(
|
||||
self, *, enabled: bool, go_live_message: str
|
||||
) -> str:
|
||||
"""Set the browser notification configuration."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_browser_notifications",
|
||||
{"enabled": enabled, "go_live_message": go_live_message},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def upload_emoji(self, name: str, data_base64: str) -> str:
|
||||
"""Upload a custom emoji."""
|
||||
self.calls.append(
|
||||
RecordedCall("upload_emoji", {"name": name, "data_base64": data_base64})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def delete_emoji(self, name: str) -> str:
|
||||
"""Delete a custom emoji."""
|
||||
self.calls.append(RecordedCall("delete_emoji", {"name": name}))
|
||||
return ""
|
||||
|
||||
async def get_webhooks(self) -> list[dict[str, Any]]:
|
||||
"""Get all registered webhooks."""
|
||||
self.calls.append(RecordedCall("get_webhooks", {}))
|
||||
return []
|
||||
|
||||
async def create_webhook(self, url: str, events: list[str]) -> str:
|
||||
"""Create a new webhook."""
|
||||
self.calls.append(
|
||||
RecordedCall("create_webhook", {"url": url, "events": events})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def delete_webhook(self, webhook_id: int) -> str:
|
||||
"""Delete a webhook."""
|
||||
self.calls.append(RecordedCall("delete_webhook", {"webhook_id": webhook_id}))
|
||||
return ""
|
||||
|
||||
async def get_access_tokens(self) -> list[dict[str, Any]]:
|
||||
"""Get all access tokens."""
|
||||
self.calls.append(RecordedCall("get_access_tokens", {}))
|
||||
return []
|
||||
|
||||
async def create_access_token(self, name: str, scopes: list[str]) -> str:
|
||||
"""Create a new access token."""
|
||||
self.calls.append(
|
||||
RecordedCall("create_access_token", {"name": name, "scopes": scopes})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def delete_access_token(self, token: str) -> str:
|
||||
"""Delete an access token."""
|
||||
self.calls.append(RecordedCall("delete_access_token", {"token": token}))
|
||||
return ""
|
||||
|
||||
async def get_followers(self, offset: int = 0, limit: int = 25) -> dict[str, Any]:
|
||||
"""Get a paginated list of followers."""
|
||||
self.calls.append(
|
||||
RecordedCall("get_followers", {"offset": offset, "limit": limit})
|
||||
)
|
||||
return {}
|
||||
|
||||
async def get_pending_follow_requests(self) -> list[dict[str, Any]]:
|
||||
"""Get pending follow requests."""
|
||||
self.calls.append(RecordedCall("get_pending_follow_requests", {}))
|
||||
return []
|
||||
|
||||
async def get_blocked_followers(self) -> list[dict[str, Any]]:
|
||||
"""Get blocked and rejected followers."""
|
||||
self.calls.append(RecordedCall("get_blocked_followers", {}))
|
||||
return []
|
||||
|
||||
async def approve_follower(self, actor_iri: str, *, approved: bool) -> str:
|
||||
"""Approve or reject a follow request."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"approve_follower", {"actor_iri": actor_iri, "approved": approved}
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_federated_message(self, message: str) -> str:
|
||||
"""Send a message to all followers via federation."""
|
||||
self.calls.append(RecordedCall("send_federated_message", {"message": message}))
|
||||
return ""
|
||||
|
||||
async def set_federation_enabled(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable federation (ActivityPub)."""
|
||||
self.calls.append(RecordedCall("set_federation_enabled", {"enabled": enabled}))
|
||||
return ""
|
||||
|
||||
async def set_federation_username(self, name: str) -> str:
|
||||
"""Set the federation (ActivityPub) username."""
|
||||
self.calls.append(RecordedCall("set_federation_username", {"name": name}))
|
||||
return ""
|
||||
|
||||
async def set_federation_go_live_message(self, message: str) -> str:
|
||||
"""Set the message sent to followers when going live."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_federation_go_live_message", {"message": message})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_federation_blocked_domains(self, domains: list[str]) -> str:
|
||||
"""Set the list of blocked federation domains."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_federation_blocked_domains", {"domains": domains})
|
||||
)
|
||||
return ""
|
||||
+20
-6
@@ -13,8 +13,8 @@ dependencies = [
|
||||
"cronsim>=2.7",
|
||||
"emoji>=2.15.0",
|
||||
"jinja2>=3.1.6",
|
||||
"markupsafe>=3.0.3",
|
||||
"m3u8>=6.0.0",
|
||||
"markupsafe>=3.0.3",
|
||||
"orjson>=3.11.8",
|
||||
"ruamel.yaml>=0.19.1",
|
||||
"uvloop>=0.22.1",
|
||||
@@ -23,19 +23,29 @@ dependencies = [
|
||||
[project.scripts]
|
||||
owlbot = "owlbot.__main__:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
testing = [
|
||||
"aioresponses>=0.7.8",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-aiohttp>=1.1.0",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.logal.dev/LogalDeveloper/Owlbot"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"aioresponses>=0.7.8",
|
||||
"codespell>=2.4.2",
|
||||
"mypy>=1.20.2",
|
||||
"pip-audit>=2.10.0",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-aiohttp>=1.1.0",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"freezegun>=1.5.5",
|
||||
"mypy>=1.20.1",
|
||||
"ruff>=0.15.10",
|
||||
"pip-audit>=2.10.0",
|
||||
"codespell>=2.4.2",
|
||||
"ruff>=0.15.11",
|
||||
"time-machine>=3.2.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -99,6 +109,10 @@ ignore = [
|
||||
"PLR2004", # magic values are clear in test assertions
|
||||
]
|
||||
|
||||
"owlbot/testing/**" = [
|
||||
"ARG001", # fixture args injected for side effects (e.g., mocked_http)
|
||||
]
|
||||
|
||||
[tool.codespell]
|
||||
skip = "owlbot/_version.py,uv.lock"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for built-in Owlbot modules."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the custom_commands built-in module."""
|
||||
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -25,7 +25,7 @@ from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
import time_machine
|
||||
|
||||
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
|
||||
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
|
||||
@@ -612,7 +612,7 @@ class TestCountdownCountup:
|
||||
self, placeholder_storage: ModuleStorage
|
||||
) -> None:
|
||||
"""$(countdown) with a future date returns the formatted duration."""
|
||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
||||
now = datetime.now(UTC)
|
||||
# Dec 25 2099 00:00:00 EST = Dec 25 2099 05:00:00 UTC
|
||||
target = datetime(2099, 12, 25, 5, 0, 0, tzinfo=UTC)
|
||||
@@ -624,7 +624,7 @@ class TestCountdownCountup:
|
||||
|
||||
async def test_countup_past_date(self, placeholder_storage: ModuleStorage) -> None:
|
||||
"""$(countup) with a past date returns the formatted elapsed duration."""
|
||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
||||
now = datetime.now(UTC)
|
||||
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
expected = _format_duration(int((now - target).total_seconds()))
|
||||
@@ -673,7 +673,7 @@ class TestCountdownCountup:
|
||||
self, placeholder_storage: ModuleStorage
|
||||
) -> None:
|
||||
"""Fractional offset like IST (+5:30) is applied.""" # codespell:ignore ist
|
||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
||||
now = datetime.now(UTC)
|
||||
# IST = Dec 31 2019 18:30:00 UTC # codespell:ignore ist
|
||||
target = datetime(2019, 12, 31, 18, 30, 0, tzinfo=UTC)
|
||||
@@ -939,7 +939,7 @@ class TestCaseInsensitivity:
|
||||
self, placeholder_storage: ModuleStorage
|
||||
) -> None:
|
||||
"""$(COUNTUP) resolves the same as $(countup)."""
|
||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
||||
now = datetime.now(UTC)
|
||||
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
expected = _format_duration(int((now - target).total_seconds()))
|
||||
@@ -0,0 +1,513 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the quotes module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot.api.event_types import EventType
|
||||
from owlbot.builtin_modules import quotes as quotes_pkg
|
||||
from owlbot.builtin_modules.quotes import setup, teardown
|
||||
from owlbot.builtin_modules.quotes.manager import QuoteManager, get_manager
|
||||
from owlbot.builtin_modules.quotes.repository import QuoteRepository
|
||||
from owlbot.builtin_modules.quotes.types import (
|
||||
Quote,
|
||||
QuoteError,
|
||||
QuoteNotFoundError,
|
||||
)
|
||||
from owlbot.testing import make_chat_event, make_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
from owlbot.registries.events import EventDispatcher
|
||||
from owlbot.testing import RecordingOwncastClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg() -> ModuleType:
|
||||
"""Auto-wire module_name, templates, and handler registration for quotes."""
|
||||
return quotes_pkg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(storage: ModuleStorage) -> QuoteRepository:
|
||||
"""QuoteRepository backed by storage, with schema initialized."""
|
||||
repo = QuoteRepository(storage)
|
||||
await repo.setup()
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(module_context: ModuleContext, repo: QuoteRepository) -> QuoteManager:
|
||||
"""QuoteManager built directly for unit tests; does not run @on_setup."""
|
||||
return QuoteManager(module_context, repo)
|
||||
|
||||
|
||||
class TestTypes:
|
||||
"""Quote dataclass conversion, date formatting, and domain errors."""
|
||||
|
||||
def test_builds_from_row(self) -> None:
|
||||
"""Quote.from_row constructs a Quote with correct field values."""
|
||||
row = {
|
||||
"id": 5,
|
||||
"text": "Test quote",
|
||||
"added_by": "someone",
|
||||
"created_at": "2026-03-15T10:00:00+00:00",
|
||||
}
|
||||
quote = Quote.from_row(row) # type: ignore[arg-type]
|
||||
assert quote.id == 5
|
||||
assert quote.text == "Test quote"
|
||||
assert quote.added_by == "someone"
|
||||
assert quote.created_at == "2026-03-15T10:00:00+00:00"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("created_at", "expected"),
|
||||
[
|
||||
pytest.param("2026-03-15T10:00:00+00:00", "2026-03-15", id="utc"),
|
||||
pytest.param(
|
||||
"2026-03-15T01:00:00+05:30",
|
||||
"2026-03-15",
|
||||
id="local-date-preserved-across-utc-boundary",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_date(self, created_at: str, expected: str) -> None:
|
||||
"""Quote.date returns the local calendar date, not the UTC date."""
|
||||
quote = Quote(id=1, text="t", added_by="a", created_at=created_at)
|
||||
assert quote.date == expected
|
||||
|
||||
def test_not_found_inherits_from_quote_error(self) -> None:
|
||||
"""QuoteNotFoundError is a QuoteError."""
|
||||
assert issubclass(QuoteNotFoundError, QuoteError)
|
||||
|
||||
def test_not_found_stores_quote_id(self) -> None:
|
||||
"""QuoteNotFoundError stores the quote ID on .quote_id."""
|
||||
err = QuoteNotFoundError(42)
|
||||
assert err.quote_id == 42
|
||||
|
||||
def test_not_found_str_contains_id(self) -> None:
|
||||
"""QuoteNotFoundError's str contains the quote ID."""
|
||||
err = QuoteNotFoundError(42)
|
||||
assert "42" in str(err)
|
||||
|
||||
|
||||
class TestRepository:
|
||||
"""QuoteRepository CRUD behavior against real storage."""
|
||||
|
||||
async def test_create_returns_quote(self, repo: QuoteRepository) -> None:
|
||||
"""Create returns a populated Quote snapshot."""
|
||||
quote = await repo.create("Hello, world!", "tester")
|
||||
assert quote.id == 1
|
||||
assert quote.text == "Hello, world!"
|
||||
assert quote.added_by == "tester"
|
||||
assert quote.created_at is not None
|
||||
|
||||
async def test_create_auto_increments(self, repo: QuoteRepository) -> None:
|
||||
"""Sequential creates get sequential IDs."""
|
||||
q1 = await repo.create("First", "tester")
|
||||
q2 = await repo.create("Second", "tester")
|
||||
assert q2.id == q1.id + 1
|
||||
|
||||
async def test_get_existing(self, repo: QuoteRepository) -> None:
|
||||
"""Get returns the matching quote."""
|
||||
created = await repo.create("Test", "tester")
|
||||
fetched = await repo.get(created.id)
|
||||
assert fetched.id == created.id
|
||||
assert fetched.text == "Test"
|
||||
|
||||
async def test_get_not_found(self, repo: QuoteRepository) -> None:
|
||||
"""Get raises QuoteNotFoundError for an unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError) as exc_info:
|
||||
await repo.get(999)
|
||||
assert exc_info.value.quote_id == 999
|
||||
|
||||
async def test_get_random_returns_quote(self, repo: QuoteRepository) -> None:
|
||||
"""get_random returns the lone quote when the table has one row."""
|
||||
await repo.create("Only quote", "tester")
|
||||
quote = await repo.get_random()
|
||||
assert quote is not None
|
||||
assert quote.text == "Only quote"
|
||||
|
||||
async def test_get_random_empty(self, repo: QuoteRepository) -> None:
|
||||
"""get_random returns None when the table is empty."""
|
||||
assert await repo.get_random() is None
|
||||
|
||||
async def test_delete_returns_quote(self, repo: QuoteRepository) -> None:
|
||||
"""Delete returns the deleted snapshot."""
|
||||
created = await repo.create("Doomed", "tester")
|
||||
deleted = await repo.delete(created.id)
|
||||
assert deleted.id == created.id
|
||||
assert deleted.text == "Doomed"
|
||||
|
||||
async def test_delete_removes_from_db(self, repo: QuoteRepository) -> None:
|
||||
"""A deleted quote is no longer retrievable."""
|
||||
created = await repo.create("Doomed", "tester")
|
||||
await repo.delete(created.id)
|
||||
with pytest.raises(QuoteNotFoundError):
|
||||
await repo.get(created.id)
|
||||
|
||||
async def test_delete_not_found(self, repo: QuoteRepository) -> None:
|
||||
"""Delete raises QuoteNotFoundError for an unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError) as exc_info:
|
||||
await repo.delete(999)
|
||||
assert exc_info.value.quote_id == 999
|
||||
|
||||
async def test_list_all(self, repo: QuoteRepository) -> None:
|
||||
"""list_all returns quotes ordered by ID."""
|
||||
await repo.create("First", "alice")
|
||||
await repo.create("Second", "bob")
|
||||
result = await repo.list_all()
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "First"
|
||||
assert result[1].text == "Second"
|
||||
|
||||
async def test_list_all_empty(self, repo: QuoteRepository) -> None:
|
||||
"""list_all returns an empty list when no quotes exist."""
|
||||
assert await repo.list_all() == []
|
||||
|
||||
|
||||
class TestManager:
|
||||
"""QuoteManager forwarding behavior and the get_manager helper."""
|
||||
|
||||
async def test_add_quote(self, manager: QuoteManager) -> None:
|
||||
"""add_quote returns a populated Quote with correct fields."""
|
||||
quote = await manager.add_quote("Test quote", "tester")
|
||||
assert quote.id == 1
|
||||
assert quote.text == "Test quote"
|
||||
assert quote.added_by == "tester"
|
||||
|
||||
async def test_delete_quote(self, manager: QuoteManager) -> None:
|
||||
"""delete_quote removes the quote and returns its snapshot."""
|
||||
created = await manager.add_quote("Doomed", "tester")
|
||||
deleted = await manager.delete_quote(created.id)
|
||||
assert deleted.id == created.id
|
||||
assert deleted.text == "Doomed"
|
||||
assert await manager.list_quotes() == []
|
||||
|
||||
async def test_delete_quote_not_found(self, manager: QuoteManager) -> None:
|
||||
"""delete_quote raises QuoteNotFoundError for an unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError):
|
||||
await manager.delete_quote(999)
|
||||
|
||||
async def test_get_quote(self, manager: QuoteManager) -> None:
|
||||
"""get_quote returns the matching quote."""
|
||||
created = await manager.add_quote("Test", "tester")
|
||||
fetched = await manager.get_quote(created.id)
|
||||
assert fetched.id == created.id
|
||||
assert fetched.text == "Test"
|
||||
|
||||
async def test_get_quote_not_found(self, manager: QuoteManager) -> None:
|
||||
"""get_quote raises QuoteNotFoundError for an unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError):
|
||||
await manager.get_quote(999)
|
||||
|
||||
async def test_get_random_quote(self, manager: QuoteManager) -> None:
|
||||
"""get_random_quote returns a quote when quotes exist."""
|
||||
await manager.add_quote("Only one", "tester")
|
||||
quote = await manager.get_random_quote()
|
||||
assert quote is not None
|
||||
assert quote.text == "Only one"
|
||||
|
||||
async def test_get_random_quote_empty(self, manager: QuoteManager) -> None:
|
||||
"""get_random_quote returns None when no quotes exist."""
|
||||
assert await manager.get_random_quote() is None
|
||||
|
||||
async def test_list_quotes(self, manager: QuoteManager) -> None:
|
||||
"""list_quotes returns all quotes ordered by ID."""
|
||||
await manager.add_quote("First", "alice")
|
||||
await manager.add_quote("Second", "bob")
|
||||
result = await manager.list_quotes()
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "First"
|
||||
assert result[1].text == "Second"
|
||||
|
||||
async def test_list_quotes_empty(self, manager: QuoteManager) -> None:
|
||||
"""list_quotes returns an empty list when no quotes exist."""
|
||||
assert await manager.list_quotes() == []
|
||||
|
||||
def test_get_manager_returns_stored_manager(
|
||||
self, module_context: ModuleContext, repo: QuoteRepository
|
||||
) -> None:
|
||||
"""get_manager returns the QuoteManager stored in ctx.state."""
|
||||
mgr = QuoteManager(module_context, repo)
|
||||
module_context.state["manager"] = mgr
|
||||
assert get_manager(module_context) is mgr
|
||||
|
||||
def test_get_manager_raises_if_not_initialized(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""get_manager raises RuntimeError when manager is missing."""
|
||||
with pytest.raises(RuntimeError, match="QuoteManager is not initialized"):
|
||||
get_manager(module_context)
|
||||
|
||||
|
||||
class TestModuleLifecycle:
|
||||
"""@on_setup and @on_teardown hooks."""
|
||||
|
||||
async def test_setup_creates_table_and_manager(
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""Setup creates the quotes table and stashes a QuoteManager in state."""
|
||||
await setup(module_context)
|
||||
|
||||
row = await storage.fetch_one(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='quotes'"
|
||||
)
|
||||
assert row is not None
|
||||
assert row["name"] == "quotes"
|
||||
|
||||
mgr = module_context.state["manager"]
|
||||
assert isinstance(mgr, QuoteManager)
|
||||
|
||||
async def test_teardown_clears_manager(self, module_context: ModuleContext) -> None:
|
||||
"""Teardown clears the manager from state."""
|
||||
await setup(module_context)
|
||||
await teardown(module_context)
|
||||
assert module_context.state["manager"] is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestQuoteCommand:
|
||||
"""!quote command handler."""
|
||||
|
||||
async def test_quote_empty_db(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!quote with no stored quotes reports empty."""
|
||||
event = make_chat_event(raw_body="!quote")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert (
|
||||
owncast_client.calls[0].kwargs["body"] == "No quotes have been added yet."
|
||||
)
|
||||
|
||||
async def test_quote_random(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!quote with no ID returns a random quote."""
|
||||
await get_manager(module_context).add_quote("The only one", "tester")
|
||||
event = make_chat_event(raw_body="!quote")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == '"The only one" (#1)'
|
||||
|
||||
async def test_quote_by_id(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!quote <id> returns the formatted detail for that quote."""
|
||||
seeded = await get_manager(module_context).add_quote("Hello", "ModUser")
|
||||
event = make_chat_event(raw_body="!quote 1")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == (
|
||||
f'Quote #1: "Hello" - Added by ModUser on {seeded.date}'
|
||||
)
|
||||
|
||||
async def test_quote_not_found(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!quote <unknown-id> reports not found."""
|
||||
event = make_chat_event(raw_body="!quote 999")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Quote #999 not found."
|
||||
|
||||
async def test_quote_invalid_id(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!quote <non-int> sends usage."""
|
||||
event = make_chat_event(raw_body="!quote abc")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Usage: !quote [id]"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestAddQuoteCommand:
|
||||
"""!addquote command handler."""
|
||||
|
||||
async def test_addquote_attributes_to_user(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!addquote creates the quote attributed to the moderator's display name."""
|
||||
user = make_user(display_name="ModUser", is_moderator=True)
|
||||
event = make_chat_event(raw_body="!addquote Hello there", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Quote #1 added."
|
||||
|
||||
stored = await get_manager(module_context).list_quotes()
|
||||
assert len(stored) == 1
|
||||
assert stored[0].text == "Hello there"
|
||||
assert stored[0].added_by == "ModUser"
|
||||
|
||||
async def test_addquote_no_text(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!addquote with no text sends usage."""
|
||||
user = make_user(is_moderator=True)
|
||||
event = make_chat_event(raw_body="!addquote", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Usage: !addquote <quote text>"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestDeleteQuoteCommand:
|
||||
"""!deletequote command handler."""
|
||||
|
||||
async def test_deletequote_removes(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!deletequote <id> removes the quote and confirms."""
|
||||
manager = get_manager(module_context)
|
||||
await manager.add_quote("Doomed", "tester")
|
||||
user = make_user(is_moderator=True)
|
||||
event = make_chat_event(raw_body="!deletequote 1", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Quote #1 deleted."
|
||||
assert await manager.list_quotes() == []
|
||||
|
||||
async def test_deletequote_no_args(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!deletequote with no args sends usage."""
|
||||
user = make_user(is_moderator=True)
|
||||
event = make_chat_event(raw_body="!deletequote", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Usage: !deletequote <id>"
|
||||
|
||||
async def test_deletequote_invalid_id(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!deletequote <non-int> sends usage."""
|
||||
user = make_user(is_moderator=True)
|
||||
event = make_chat_event(raw_body="!deletequote abc", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Usage: !deletequote <id>"
|
||||
|
||||
async def test_deletequote_not_found(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!deletequote <unknown-id> reports not found."""
|
||||
user = make_user(is_moderator=True)
|
||||
event = make_chat_event(raw_body="!deletequote 999", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert owncast_client.calls[0].kwargs["body"] == "Quote #999 not found."
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestListQuotesCommand:
|
||||
"""!listquotes command handler."""
|
||||
|
||||
async def test_listquotes_sends_url(
|
||||
self,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""!listquotes sends the public URL of the /list route."""
|
||||
event = make_chat_event(raw_body="!listquotes")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert (
|
||||
owncast_client.calls[0].kwargs["body"]
|
||||
== "Quotes: http://localhost:8081/owlbot/quotes/list"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestQuotesListRoute:
|
||||
"""/list HTML route."""
|
||||
|
||||
async def test_list_empty(
|
||||
self,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""GET /list with no quotes serves the empty-state message."""
|
||||
resp = await route_client.get("/owlbot/quotes/list")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "text/html"
|
||||
body = await resp.text()
|
||||
assert "No quotes have been added yet." in body
|
||||
|
||||
async def test_list_populated(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""GET /list with quotes includes each quote's text and attribution."""
|
||||
manager = get_manager(module_context)
|
||||
await manager.add_quote("First quote", "alice")
|
||||
await manager.add_quote("Second quote", "bob")
|
||||
|
||||
resp = await route_client.get("/owlbot/quotes/list")
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
assert "First quote" in body
|
||||
assert "Second quote" in body
|
||||
assert "alice" in body
|
||||
assert "bob" in body
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestModeratorPermissions:
|
||||
"""Moderator-required commands deny non-moderators."""
|
||||
|
||||
@pytest.mark.parametrize("command", ["addquote", "deletequote"])
|
||||
async def test_non_moderator_denied(
|
||||
self,
|
||||
command: str,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""Non-moderator invocation triggers the dispatcher denial."""
|
||||
user = make_user(is_moderator=False)
|
||||
event = make_chat_event(raw_body=f"!{command}", user=user)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
denials = [
|
||||
c.kwargs["body"]
|
||||
for c in owncast_client.calls
|
||||
if c.name == "send_system_message_to_client"
|
||||
]
|
||||
assert denials == [f"Only moderators can use !{command}."]
|
||||
File diff suppressed because it is too large
Load Diff
+6
-35
@@ -12,41 +12,12 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Shared test fixtures for the Owlbot test suite."""
|
||||
"""Shared test fixtures for the Owlbot test suite.
|
||||
|
||||
The ``storage`` fixture is provided by the ``owlbot.testing`` pytest plugin,
|
||||
loaded via ``pytest_plugins`` below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def make_module_context(
|
||||
storage: ModuleStorage | None = None, module_name: str = "mod_a"
|
||||
) -> ModuleContext:
|
||||
"""Build a stub ModuleContext backed by real or None storage."""
|
||||
return ModuleContext(
|
||||
module_name=module_name,
|
||||
config=None, # type: ignore[arg-type]
|
||||
owncast_client=None, # type: ignore[arg-type]
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
commands=None, # type: ignore[arg-type]
|
||||
events=None, # type: ignore[arg-type]
|
||||
routes=None, # type: ignore[arg-type]
|
||||
http=None, # type: ignore[arg-type]
|
||||
templates=None, # type: ignore[arg-type]
|
||||
admin_client=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storage() -> AsyncIterator[ModuleStorage]:
|
||||
"""Yield an open in-memory ModuleStorage."""
|
||||
async with ModuleStorage(None, "test_module") as s:
|
||||
yield s
|
||||
pytest_plugins = ["owlbot.testing.plugin", "pytester"]
|
||||
|
||||
+189
-186
@@ -32,72 +32,19 @@ import pytest
|
||||
|
||||
from owlbot.api.commands import CommandEvent, CommandInfo, CommandMark, on_command
|
||||
from owlbot.api.context import CommandContext, EventContext
|
||||
from owlbot.api.event_types import ChatEvent, User
|
||||
from owlbot.registries.commands import (
|
||||
CommandDispatcher,
|
||||
CommandRegistry,
|
||||
ModuleCommands,
|
||||
)
|
||||
from tests.conftest import make_module_context
|
||||
from owlbot.testing import RecordingOwncastClient, make_chat_event, make_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.event_types import ChatEvent
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
|
||||
|
||||
def _make_user(
|
||||
*,
|
||||
user_id: str = "user-1",
|
||||
display_name: str = "Alice",
|
||||
is_authenticated: bool = False,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> User:
|
||||
"""Build a minimal User for test events."""
|
||||
return User(
|
||||
id=user_id,
|
||||
display_name=display_name,
|
||||
display_color=0,
|
||||
created_at=None,
|
||||
previous_names=[],
|
||||
name_changed_at=None,
|
||||
is_bot=False,
|
||||
is_authenticated=is_authenticated,
|
||||
scopes=scopes or frozenset(),
|
||||
)
|
||||
|
||||
|
||||
def _make_chat_event(
|
||||
*,
|
||||
body: str = "hello",
|
||||
user: User | None = None,
|
||||
client_id: int = 42,
|
||||
) -> ChatEvent:
|
||||
"""Build a minimal ChatEvent for test dispatches."""
|
||||
return ChatEvent(
|
||||
user=user or _make_user(),
|
||||
client_id=client_id,
|
||||
body=body,
|
||||
raw_body=body,
|
||||
message_id="msg-001",
|
||||
is_visible=True,
|
||||
timestamp=None,
|
||||
)
|
||||
|
||||
|
||||
class _StubOwncastClient:
|
||||
"""Recording stub for OwncastClient used in dispatcher tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.system_messages: list[tuple[int, str]] = []
|
||||
self.messages: list[str] = []
|
||||
|
||||
async def send_system_message_to_client(self, client_id: int, body: str) -> None:
|
||||
self.system_messages.append((client_id, body))
|
||||
|
||||
async def send_message(self, body: str) -> None:
|
||||
self.messages.append(body)
|
||||
|
||||
|
||||
class TestOnCommandDecorator:
|
||||
"""Tests the @on_command decorator from owlbot.api.commands."""
|
||||
|
||||
@@ -155,7 +102,7 @@ class TestCommandEvent:
|
||||
|
||||
def test_fields_accessible(self) -> None:
|
||||
"""Construct with known values; assert each field matches."""
|
||||
event = _make_chat_event()
|
||||
event = make_chat_event(client_id=42)
|
||||
cmd_event = CommandEvent(
|
||||
command="greet",
|
||||
args="hello world",
|
||||
@@ -492,36 +439,33 @@ class TestCommandDispatcherDispatch:
|
||||
|
||||
def _make_dispatcher(
|
||||
self,
|
||||
storage: ModuleStorage,
|
||||
module_context: ModuleContext,
|
||||
*,
|
||||
owncast_client: _StubOwncastClient | None = None,
|
||||
owncast_client: RecordingOwncastClient | None = None,
|
||||
handler_timeout: float = 5.0,
|
||||
command_prefix: str = "!",
|
||||
module_name: str = "mod_a",
|
||||
) -> tuple[CommandDispatcher, ModuleContext, _StubOwncastClient]:
|
||||
"""Build a CommandDispatcher with a real storage-backed ModuleContext."""
|
||||
stub = owncast_client or _StubOwncastClient()
|
||||
module_ctx = make_module_context(storage, module_name)
|
||||
|
||||
) -> tuple[CommandDispatcher, ModuleContext, RecordingOwncastClient]:
|
||||
"""Build a CommandDispatcher wired to the given ModuleContext."""
|
||||
stub = owncast_client or RecordingOwncastClient()
|
||||
dispatcher = CommandDispatcher(
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
owncast_client=stub, # type: ignore[arg-type]
|
||||
get_module_context=lambda _name: module_context,
|
||||
owncast_client=stub,
|
||||
handler_timeout=handler_timeout,
|
||||
loaded_modules=set(),
|
||||
command_prefix=command_prefix,
|
||||
)
|
||||
return dispatcher, module_ctx, stub
|
||||
return dispatcher, module_context, stub
|
||||
|
||||
async def test_dispatches_command(self, storage: ModuleStorage) -> None:
|
||||
async def test_dispatches_command(self, module_context: ModuleContext) -> None:
|
||||
"""Handler receives a CommandContext with correct command and chat_event."""
|
||||
captured: list[CommandContext] = []
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
captured.append(ctx)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, module_name="mod_a")
|
||||
event = _make_chat_event(body="!ping")
|
||||
event = make_chat_event(client_id=42, raw_body="!ping")
|
||||
await dispatcher.dispatch(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
@@ -529,34 +473,38 @@ class TestCommandDispatcherDispatch:
|
||||
assert captured[0].args == ""
|
||||
assert captured[0].chat_event is event
|
||||
|
||||
async def test_dispatches_with_args(self, storage: ModuleStorage) -> None:
|
||||
async def test_dispatches_with_args(self, module_context: ModuleContext) -> None:
|
||||
"""Handler receives parsed args and args_list."""
|
||||
captured: list[CommandContext] = []
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
captured.append(ctx)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("greet", handler, module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="!greet hello world"))
|
||||
await dispatcher.dispatch(
|
||||
make_chat_event(client_id=42, raw_body="!greet hello world")
|
||||
)
|
||||
|
||||
assert captured[0].args == "hello world"
|
||||
assert captured[0].args_list == ["hello", "world"]
|
||||
|
||||
async def test_dispatches_via_alias(self, storage: ModuleStorage) -> None:
|
||||
async def test_dispatches_via_alias(self, module_context: ModuleContext) -> None:
|
||||
"""Alias resolves to canonical name in ctx.command."""
|
||||
captured: list[CommandContext] = []
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
captured.append(ctx)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, aliases=["p"], module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="!p"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!p"))
|
||||
|
||||
assert captured[0].command == "ping"
|
||||
|
||||
async def test_non_command_message_ignored(self, storage: ModuleStorage) -> None:
|
||||
async def test_non_command_message_ignored(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""A regular message does not invoke the handler."""
|
||||
called = False
|
||||
|
||||
@@ -564,14 +512,16 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="hello everyone"))
|
||||
await dispatcher.dispatch(
|
||||
make_chat_event(client_id=42, raw_body="hello everyone")
|
||||
)
|
||||
|
||||
assert called is False
|
||||
|
||||
async def test_unknown_command_ignored(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unknown command is ignored and logged at DEBUG level."""
|
||||
called = False
|
||||
@@ -580,15 +530,19 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, module_name="mod_a")
|
||||
with caplog.at_level(logging.DEBUG, logger="owlbot.commands"):
|
||||
await dispatcher.dispatch(_make_chat_event(body="!nonexistent"))
|
||||
await dispatcher.dispatch(
|
||||
make_chat_event(client_id=42, raw_body="!nonexistent")
|
||||
)
|
||||
|
||||
assert called is False
|
||||
assert "Unknown command: nonexistent" in caplog.text
|
||||
|
||||
async def test_requires_authenticated_allowed(self, storage: ModuleStorage) -> None:
|
||||
async def test_requires_authenticated_allowed(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Authenticated user can invoke a requires_authenticated command."""
|
||||
called = False
|
||||
|
||||
@@ -596,17 +550,19 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register(
|
||||
"ping", handler, requires_authenticated=True, module_name="mod_a"
|
||||
)
|
||||
user = _make_user(is_authenticated=True)
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ping", user=user))
|
||||
user = make_user(is_authenticated=True)
|
||||
await dispatcher.dispatch(
|
||||
make_chat_event(client_id=42, raw_body="!ping", user=user)
|
||||
)
|
||||
|
||||
assert called is True
|
||||
|
||||
async def test_requires_authenticated_denied(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Unauthenticated user is denied and gets a system message."""
|
||||
called = False
|
||||
@@ -615,22 +571,30 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, stub = self._make_dispatcher(storage)
|
||||
dispatcher, _, stub = self._make_dispatcher(module_context)
|
||||
dispatcher.register(
|
||||
"ping", handler, requires_authenticated=True, module_name="mod_a"
|
||||
)
|
||||
user = _make_user(is_authenticated=False)
|
||||
user = make_user(display_name="Alice", is_authenticated=False)
|
||||
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(client_id=42, raw_body="!ping", user=user)
|
||||
)
|
||||
|
||||
assert called is False
|
||||
assert stub.system_messages[0] == (
|
||||
42,
|
||||
"You must be authenticated to use !ping.",
|
||||
assert stub.calls[0] == (
|
||||
"send_system_message_to_client",
|
||||
{
|
||||
"client_id": 42,
|
||||
"body": "You must be authenticated to use !ping.",
|
||||
"unsanitized": False,
|
||||
},
|
||||
)
|
||||
assert "Command 'ping' denied for Alice: authentication required" in caplog.text
|
||||
|
||||
async def test_requires_moderator_allowed(self, storage: ModuleStorage) -> None:
|
||||
async def test_requires_moderator_allowed(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Moderator user can invoke a requires_moderator command."""
|
||||
called = False
|
||||
|
||||
@@ -638,17 +602,19 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register(
|
||||
"ban", handler, requires_moderator=True, module_name="mod_a"
|
||||
)
|
||||
user = _make_user(scopes=frozenset({"MODERATOR"}))
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ban", user=user))
|
||||
user = make_user(scopes=frozenset({"MODERATOR"}))
|
||||
await dispatcher.dispatch(
|
||||
make_chat_event(client_id=42, raw_body="!ban", user=user)
|
||||
)
|
||||
|
||||
assert called is True
|
||||
|
||||
async def test_requires_moderator_denied(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Non-moderator user is denied and gets a system message."""
|
||||
called = False
|
||||
@@ -657,19 +623,30 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, stub = self._make_dispatcher(storage)
|
||||
dispatcher, _, stub = self._make_dispatcher(module_context)
|
||||
dispatcher.register(
|
||||
"ping", handler, requires_moderator=True, module_name="mod_a"
|
||||
)
|
||||
user = _make_user(scopes=frozenset())
|
||||
user = make_user(display_name="Alice", scopes=frozenset())
|
||||
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(client_id=42, raw_body="!ping", user=user)
|
||||
)
|
||||
|
||||
assert called is False
|
||||
assert stub.system_messages[0] == (42, "Only moderators can use !ping.")
|
||||
assert stub.calls[0] == (
|
||||
"send_system_message_to_client",
|
||||
{
|
||||
"client_id": 42,
|
||||
"body": "Only moderators can use !ping.",
|
||||
"unsanitized": False,
|
||||
},
|
||||
)
|
||||
assert "Command 'ping' denied for Alice: moderator required" in caplog.text
|
||||
|
||||
async def test_cooldown_allows_first_call(self, storage: ModuleStorage) -> None:
|
||||
async def test_cooldown_allows_first_call(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""First invocation of a command with cooldown is allowed."""
|
||||
called = False
|
||||
|
||||
@@ -677,14 +654,14 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, cooldown=60, module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ping"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!ping"))
|
||||
|
||||
assert called is True
|
||||
|
||||
async def test_cooldown_blocks_second_call(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Second invocation within cooldown is blocked with a message."""
|
||||
call_count = 0
|
||||
@@ -693,19 +670,22 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
dispatcher, _, stub = self._make_dispatcher(storage)
|
||||
dispatcher, _, stub = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, cooldown=60, module_name="mod_a")
|
||||
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ping"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!ping"))
|
||||
with caplog.at_level(logging.INFO, logger="owlbot.commands"):
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ping"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!ping"))
|
||||
|
||||
assert call_count == 1
|
||||
assert len(stub.system_messages) == 1
|
||||
assert "!ping can be used every 60s" in stub.system_messages[0][1]
|
||||
assert len(stub.calls) == 1
|
||||
assert stub.calls[0].name == "send_system_message_to_client"
|
||||
assert "!ping can be used every 60s" in stub.calls[0].kwargs["body"]
|
||||
assert "on cooldown" in caplog.text
|
||||
|
||||
async def test_cooldown_zero_no_throttle(self, storage: ModuleStorage) -> None:
|
||||
async def test_cooldown_zero_no_throttle(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Commands with cooldown=0 are never throttled."""
|
||||
call_count = 0
|
||||
|
||||
@@ -713,15 +693,17 @@ class TestCommandDispatcherDispatch:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("ping", handler, cooldown=0, module_name="mod_a")
|
||||
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ping"))
|
||||
await dispatcher.dispatch(_make_chat_event(body="!ping"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!ping"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!ping"))
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
async def test_success_commits(self, storage: ModuleStorage) -> None:
|
||||
async def test_success_commits(
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""A successful handler's database changes are committed."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
@@ -732,16 +714,19 @@ class TestCommandDispatcherDispatch:
|
||||
"INSERT INTO items (id, name) VALUES (?, ?)", (1, "apple")
|
||||
)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("add", handler, module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="!add"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!add"))
|
||||
|
||||
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
|
||||
assert row is not None
|
||||
assert row["name"] == "apple"
|
||||
|
||||
async def test_exception_does_not_roll_back_committed_writes(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
storage: ModuleStorage,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A handler that raises does not undo already-committed writes."""
|
||||
await storage.execute(
|
||||
@@ -754,10 +739,10 @@ class TestCommandDispatcherDispatch:
|
||||
)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("add", handler, module_name="mod_a")
|
||||
with caplog.at_level(logging.ERROR, logger="owlbot.commands"):
|
||||
await dispatcher.dispatch(_make_chat_event(body="!add"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!add"))
|
||||
|
||||
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
|
||||
assert row is not None
|
||||
@@ -765,17 +750,17 @@ class TestCommandDispatcherDispatch:
|
||||
assert "raised exception." in caplog.text
|
||||
|
||||
async def test_timeout_is_logged(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A timed-out handler is cancelled and logged."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage, handler_timeout=0.05)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context, handler_timeout=0.05)
|
||||
dispatcher.register("slow", handler, module_name="mod_a")
|
||||
with caplog.at_level(logging.WARNING, logger="owlbot.commands"):
|
||||
await dispatcher.dispatch(_make_chat_event(body="!slow"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!slow"))
|
||||
|
||||
assert any(
|
||||
"cancelled after" in r.message and "timeout" in r.message
|
||||
@@ -783,7 +768,7 @@ class TestCommandDispatcherDispatch:
|
||||
)
|
||||
|
||||
async def test_timeout_does_not_roll_back_committed_writes(
|
||||
self, storage: ModuleStorage
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""A timed-out handler's already-committed writes persist."""
|
||||
await storage.execute(
|
||||
@@ -796,16 +781,16 @@ class TestCommandDispatcherDispatch:
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage, handler_timeout=0.05)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context, handler_timeout=0.05)
|
||||
dispatcher.register("slow", handler, module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="!slow"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!slow"))
|
||||
|
||||
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
|
||||
assert row is not None
|
||||
assert row["name"] == "apple"
|
||||
|
||||
async def test_timeout_rolls_back_explicit_transaction(
|
||||
self, storage: ModuleStorage
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""A timed-out handler's uncommitted transaction is rolled back."""
|
||||
await storage.execute(
|
||||
@@ -819,23 +804,25 @@ class TestCommandDispatcherDispatch:
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage, handler_timeout=0.05)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context, handler_timeout=0.05)
|
||||
dispatcher.register("slow", handler, module_name="mod_a")
|
||||
await dispatcher.dispatch(_make_chat_event(body="!slow"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!slow"))
|
||||
|
||||
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
|
||||
assert row is None
|
||||
|
||||
async def test_builtin_command_dispatched(self, storage: ModuleStorage) -> None:
|
||||
async def test_builtin_command_dispatched(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""A built-in command receives (event, owncast_client)."""
|
||||
captured: list[tuple[Any, Any]] = []
|
||||
|
||||
async def handler(event: ChatEvent, owncast_client: Any) -> None:
|
||||
captured.append((event, owncast_client))
|
||||
|
||||
dispatcher, _, stub = self._make_dispatcher(storage)
|
||||
dispatcher, _, stub = self._make_dispatcher(module_context)
|
||||
dispatcher.register_builtin("test", handler)
|
||||
event = _make_chat_event(body="!test")
|
||||
event = make_chat_event(client_id=42, raw_body="!test")
|
||||
await dispatcher.dispatch(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
@@ -843,48 +830,48 @@ class TestCommandDispatcherDispatch:
|
||||
assert captured[0][1] is stub
|
||||
|
||||
async def test_builtin_command_exception_caught(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A built-in handler that raises does not propagate the exception."""
|
||||
|
||||
async def handler(event: ChatEvent, owncast_client: Any) -> None:
|
||||
raise RuntimeError("builtin-fail")
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register_builtin("test", handler)
|
||||
with caplog.at_level(logging.ERROR, logger="owlbot.commands"):
|
||||
await dispatcher.dispatch(_make_chat_event(body="!test"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
|
||||
|
||||
assert "raised exception." in caplog.text
|
||||
|
||||
async def test_builtin_command_timeout(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A built-in handler that times out is cancelled and logged."""
|
||||
|
||||
async def handler(event: ChatEvent, owncast_client: Any) -> None:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage, handler_timeout=0.05)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context, handler_timeout=0.05)
|
||||
dispatcher.register_builtin("test", handler)
|
||||
with caplog.at_level(logging.WARNING, logger="owlbot.commands"):
|
||||
await dispatcher.dispatch(_make_chat_event(body="!test"))
|
||||
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
|
||||
|
||||
assert any(
|
||||
"cancelled after" in r.message and "timeout" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
async def test_command_event_fields(self, storage: ModuleStorage) -> None:
|
||||
async def test_command_event_fields(self, module_context: ModuleContext) -> None:
|
||||
"""Dispatched CommandContext carries correct CommandEvent fields."""
|
||||
captured: list[CommandContext] = []
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
captured.append(ctx)
|
||||
|
||||
dispatcher, _, _ = self._make_dispatcher(storage)
|
||||
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||
dispatcher.register("greet", handler, module_name="mod_a")
|
||||
event = _make_chat_event(body="!greet world")
|
||||
event = make_chat_event(client_id=42, raw_body="!greet world")
|
||||
await dispatcher.dispatch(event)
|
||||
|
||||
assert captured[0].command_event.command == "greet"
|
||||
@@ -899,43 +886,53 @@ class TestModuleCommands:
|
||||
|
||||
def _make_dispatcher_and_mod_cmds(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
*module_names: str,
|
||||
) -> tuple[CommandDispatcher, dict[str, ModuleCommands]]:
|
||||
"""Build a dispatcher and ModuleCommands wrappers for given modules."""
|
||||
stub = _StubOwncastClient()
|
||||
"""Build a dispatcher and ModuleCommands wrappers for given modules.
|
||||
|
||||
These tests exercise registration and ownership only; commands are
|
||||
never dispatched, so a single ``module_context`` is reused for every
|
||||
lookup.
|
||||
"""
|
||||
stub = RecordingOwncastClient()
|
||||
dispatcher = CommandDispatcher(
|
||||
get_module_context=lambda name: make_module_context(None, name),
|
||||
owncast_client=stub, # type: ignore[arg-type]
|
||||
get_module_context=lambda _name: module_context,
|
||||
owncast_client=stub,
|
||||
handler_timeout=5.0,
|
||||
loaded_modules=set(),
|
||||
)
|
||||
mod_cmds = {name: ModuleCommands(dispatcher, name) for name in module_names}
|
||||
return dispatcher, mod_cmds
|
||||
|
||||
def test_register_auto_supplies_module_name(self) -> None:
|
||||
def test_register_auto_supplies_module_name(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""ModuleCommands.register auto-supplies module_name."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pass
|
||||
|
||||
dispatcher, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a")
|
||||
dispatcher, mod_cmds = self._make_dispatcher_and_mod_cmds(
|
||||
module_context, "mod_a"
|
||||
)
|
||||
mod_cmds["mod_a"].register("ping", handler)
|
||||
info = dispatcher.get("ping")
|
||||
assert info is not None
|
||||
assert info.module_name == "mod_a"
|
||||
|
||||
def test_prefix_property(self) -> None:
|
||||
def test_prefix_property(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleCommands.prefix delegates to the dispatcher's prefix."""
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(module_context, "mod_a")
|
||||
assert mod_cmds["mod_a"].prefix == "!"
|
||||
|
||||
def test_module_commands_property(self) -> None:
|
||||
def test_module_commands_property(self, module_context: ModuleContext) -> None:
|
||||
"""module_commands returns dict with both registered commands."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pass
|
||||
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(module_context, "mod_a")
|
||||
mod_cmds["mod_a"].register("ping", handler)
|
||||
mod_cmds["mod_a"].register("pong", handler)
|
||||
result = mod_cmds["mod_a"].module_commands
|
||||
@@ -943,9 +940,11 @@ class TestModuleCommands:
|
||||
assert "pong" in result
|
||||
assert len(result) == 2
|
||||
|
||||
def test_module_commands_property_empty(self) -> None:
|
||||
def test_module_commands_property_empty(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""module_commands returns empty dict when nothing registered."""
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(module_context, "mod_a")
|
||||
assert mod_cmds["mod_a"].module_commands == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -960,6 +959,7 @@ class TestModuleCommands:
|
||||
)
|
||||
def test_unregister(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
owner: str,
|
||||
other_setup: str | None,
|
||||
call_module: str,
|
||||
@@ -975,7 +975,7 @@ class TestModuleCommands:
|
||||
modules = [owner]
|
||||
if other_setup:
|
||||
modules.append(other_setup)
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(*modules)
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(module_context, *modules)
|
||||
|
||||
mod_cmds[owner].register("ping", handler)
|
||||
if other_setup:
|
||||
@@ -983,40 +983,44 @@ class TestModuleCommands:
|
||||
|
||||
assert mod_cmds[call_module].unregister(trigger) is expected
|
||||
|
||||
def test_get_own_command(self) -> None:
|
||||
def test_get_own_command(self, module_context: ModuleContext) -> None:
|
||||
"""get() returns CommandInfo for own commands."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pass
|
||||
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(module_context, "mod_a")
|
||||
mod_cmds["mod_a"].register("ping", handler)
|
||||
info = mod_cmds["mod_a"].get("ping")
|
||||
assert info is not None
|
||||
assert info.name == "ping"
|
||||
|
||||
def test_get_other_modules_command(self) -> None:
|
||||
def test_get_other_modules_command(self, module_context: ModuleContext) -> None:
|
||||
"""get() returns None for another module's command."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pass
|
||||
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a", "mod_b")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(
|
||||
module_context, "mod_a", "mod_b"
|
||||
)
|
||||
mod_cmds["mod_b"].register("quote", handler)
|
||||
assert mod_cmds["mod_a"].get("quote") is None
|
||||
|
||||
def test_get_unknown(self) -> None:
|
||||
def test_get_unknown(self, module_context: ModuleContext) -> None:
|
||||
"""get() returns None for unknown commands."""
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(module_context, "mod_a")
|
||||
assert mod_cmds["mod_a"].get("nope") is None
|
||||
|
||||
def test_exists_sees_all_modules(self) -> None:
|
||||
def test_exists_sees_all_modules(self, module_context: ModuleContext) -> None:
|
||||
"""exists() checks across all modules, not scoped to own."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pass
|
||||
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds("mod_a", "mod_b")
|
||||
_, mod_cmds = self._make_dispatcher_and_mod_cmds(
|
||||
module_context, "mod_a", "mod_b"
|
||||
)
|
||||
mod_cmds["mod_b"].register("quote", handler)
|
||||
assert mod_cmds["mod_a"].exists("quote") is True
|
||||
|
||||
@@ -1026,11 +1030,10 @@ class TestCommandContext:
|
||||
|
||||
def _make_command_context(
|
||||
self,
|
||||
storage: ModuleStorage,
|
||||
) -> tuple[CommandContext, ModuleContext, CommandEvent, EventContext[ChatEvent]]:
|
||||
"""Build a CommandContext with all stub parts."""
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
event = _make_chat_event(body="!greet world")
|
||||
module_ctx: ModuleContext,
|
||||
) -> tuple[CommandContext, CommandEvent, EventContext[ChatEvent]]:
|
||||
"""Build a CommandContext wrapping the given ModuleContext."""
|
||||
event = make_chat_event(client_id=42, raw_body="!greet world")
|
||||
cmd_event = CommandEvent(
|
||||
command="greet",
|
||||
args="world",
|
||||
@@ -1046,7 +1049,7 @@ class TestCommandContext:
|
||||
event_context=event_ctx,
|
||||
module=module_ctx,
|
||||
)
|
||||
return cmd_ctx, module_ctx, cmd_event, event_ctx
|
||||
return cmd_ctx, cmd_event, event_ctx
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prop", "use_is"),
|
||||
@@ -1061,48 +1064,48 @@ class TestCommandContext:
|
||||
],
|
||||
)
|
||||
def test_property_proxying(
|
||||
self, storage: ModuleStorage, prop: str, *, use_is: bool
|
||||
self, module_context: ModuleContext, prop: str, *, use_is: bool
|
||||
) -> None:
|
||||
"""CommandContext properties proxy to the underlying ModuleContext."""
|
||||
cmd_ctx, module_ctx, _, _ = self._make_command_context(storage)
|
||||
cmd_ctx, _, _ = self._make_command_context(module_context)
|
||||
ctx_val = getattr(cmd_ctx, prop)
|
||||
mod_val = getattr(module_ctx, prop)
|
||||
mod_val = getattr(module_context, prop)
|
||||
if use_is:
|
||||
assert ctx_val is mod_val
|
||||
else:
|
||||
assert ctx_val == mod_val
|
||||
|
||||
def test_command_property(self, storage: ModuleStorage) -> None:
|
||||
def test_command_property(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.command matches the CommandEvent's command."""
|
||||
cmd_ctx, _, cmd_event, _ = self._make_command_context(storage)
|
||||
cmd_ctx, cmd_event, _ = self._make_command_context(module_context)
|
||||
assert cmd_ctx.command == cmd_event.command
|
||||
|
||||
def test_args_property(self, storage: ModuleStorage) -> None:
|
||||
def test_args_property(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.args matches the CommandEvent's args."""
|
||||
cmd_ctx, _, cmd_event, _ = self._make_command_context(storage)
|
||||
cmd_ctx, cmd_event, _ = self._make_command_context(module_context)
|
||||
assert cmd_ctx.args == cmd_event.args
|
||||
|
||||
def test_args_list_property(self, storage: ModuleStorage) -> None:
|
||||
def test_args_list_property(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.args_list matches the CommandEvent's args_list."""
|
||||
cmd_ctx, _, cmd_event, _ = self._make_command_context(storage)
|
||||
cmd_ctx, cmd_event, _ = self._make_command_context(module_context)
|
||||
assert cmd_ctx.args_list == cmd_event.args_list
|
||||
|
||||
def test_prefix_property(self, storage: ModuleStorage) -> None:
|
||||
def test_prefix_property(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.prefix matches the CommandEvent's prefix."""
|
||||
cmd_ctx, _, cmd_event, _ = self._make_command_context(storage)
|
||||
cmd_ctx, cmd_event, _ = self._make_command_context(module_context)
|
||||
assert cmd_ctx.prefix == cmd_event.prefix
|
||||
|
||||
def test_chat_event_property(self, storage: ModuleStorage) -> None:
|
||||
def test_chat_event_property(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.chat_event is the CommandEvent's chat_event."""
|
||||
cmd_ctx, _, cmd_event, _ = self._make_command_context(storage)
|
||||
cmd_ctx, cmd_event, _ = self._make_command_context(module_context)
|
||||
assert cmd_ctx.chat_event is cmd_event.chat_event
|
||||
|
||||
def test_user_property(self, storage: ModuleStorage) -> None:
|
||||
def test_user_property(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.user is the chat_event's user."""
|
||||
cmd_ctx, _, cmd_event, _ = self._make_command_context(storage)
|
||||
cmd_ctx, cmd_event, _ = self._make_command_context(module_context)
|
||||
assert cmd_ctx.user is cmd_event.chat_event.user
|
||||
|
||||
def test_event_context_attribute(self, storage: ModuleStorage) -> None:
|
||||
def test_event_context_attribute(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.event_context is the EventContext passed at construction."""
|
||||
cmd_ctx, _, _, event_ctx = self._make_command_context(storage)
|
||||
cmd_ctx, _, event_ctx = self._make_command_context(module_context)
|
||||
assert cmd_ctx.event_context is event_ctx
|
||||
|
||||
@@ -1,991 +0,0 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the custom_commands module: types, repository, and manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.storage import ModuleStorage, StorageError
|
||||
from owlbot.builtin_modules.custom_commands.manager import (
|
||||
CommandManager,
|
||||
get_manager,
|
||||
)
|
||||
from owlbot.builtin_modules.custom_commands.repository import CommandRepository
|
||||
from owlbot.builtin_modules.custom_commands.types import (
|
||||
NAME_RE,
|
||||
AliasAlreadyExistsError,
|
||||
AliasIsCanonicalNameError,
|
||||
CannotDeleteByAliasError,
|
||||
Command,
|
||||
CommandAlreadyExistsError,
|
||||
CommandError,
|
||||
CommandNotFoundError,
|
||||
InvalidNameError,
|
||||
NotCustomCommandError,
|
||||
)
|
||||
|
||||
|
||||
class TestCommandFromRow:
|
||||
"""Command.from_row() database row conversion."""
|
||||
|
||||
def test_builds_from_row(self) -> None:
|
||||
"""from_row constructs a Command with correct field values."""
|
||||
row = {
|
||||
"name": "greet",
|
||||
"response": "Hello $(user)!",
|
||||
"use_count": 5,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-02T00:00:00+00:00",
|
||||
"requires_moderator": 0,
|
||||
"cooldown": 10,
|
||||
}
|
||||
cmd = Command.from_row(row) # type: ignore[arg-type]
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello $(user)!"
|
||||
assert cmd.use_count == 5
|
||||
assert cmd.requires_moderator is False
|
||||
assert cmd.cooldown == 10
|
||||
assert cmd.aliases == frozenset()
|
||||
|
||||
def test_builds_with_aliases(self) -> None:
|
||||
"""from_row populates aliases and coerces requires_moderator to bool."""
|
||||
row = {
|
||||
"name": "greet",
|
||||
"response": "Hello!",
|
||||
"use_count": 0,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"requires_moderator": 1,
|
||||
"cooldown": 0,
|
||||
}
|
||||
aliases = frozenset({"hi", "hey"})
|
||||
cmd = Command.from_row(row, aliases) # type: ignore[arg-type]
|
||||
assert cmd.requires_moderator is True
|
||||
assert cmd.aliases == {"hi", "hey"}
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
"""Command instances are immutable (frozen dataclass)."""
|
||||
row = {
|
||||
"name": "x",
|
||||
"response": "y",
|
||||
"use_count": 0,
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"requires_moderator": 0,
|
||||
"cooldown": 0,
|
||||
}
|
||||
cmd = Command.from_row(row) # type: ignore[arg-type]
|
||||
with pytest.raises(AttributeError):
|
||||
cmd.name = "z" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestCommandErrors:
|
||||
"""Domain error hierarchy and stored context."""
|
||||
|
||||
def test_all_inherit_from_command_error(self) -> None:
|
||||
"""All domain errors are subclasses of CommandError."""
|
||||
assert issubclass(CommandNotFoundError, CommandError)
|
||||
assert issubclass(InvalidNameError, CommandError)
|
||||
assert issubclass(CommandAlreadyExistsError, CommandError)
|
||||
assert issubclass(NotCustomCommandError, CommandError)
|
||||
assert issubclass(AliasAlreadyExistsError, CommandError)
|
||||
assert issubclass(AliasIsCanonicalNameError, CommandError)
|
||||
assert issubclass(CannotDeleteByAliasError, CommandError)
|
||||
|
||||
def test_not_found_stores_identifier(self) -> None:
|
||||
"""CommandNotFoundError stores the identifier and includes it in str."""
|
||||
err = CommandNotFoundError("greet")
|
||||
assert err.identifier == "greet"
|
||||
assert "greet" in str(err)
|
||||
|
||||
def test_invalid_name_stores_name(self) -> None:
|
||||
"""InvalidNameError stores the invalid name."""
|
||||
err = InvalidNameError("BAD NAME!")
|
||||
assert err.name == "BAD NAME!"
|
||||
|
||||
def test_already_exists_stores_name(self) -> None:
|
||||
"""CommandAlreadyExistsError stores the conflicting name."""
|
||||
err = CommandAlreadyExistsError("greet")
|
||||
assert err.name == "greet"
|
||||
|
||||
def test_not_custom_stores_name(self) -> None:
|
||||
"""NotCustomCommandError stores the built-in command name."""
|
||||
err = NotCustomCommandError("about")
|
||||
assert err.name == "about"
|
||||
|
||||
def test_alias_already_exists_stores_context(self) -> None:
|
||||
"""AliasAlreadyExistsError stores alias and owner."""
|
||||
err = AliasAlreadyExistsError("hi", "greet")
|
||||
assert err.alias == "hi"
|
||||
assert err.owner == "greet"
|
||||
|
||||
def test_alias_is_canonical_stores_alias(self) -> None:
|
||||
"""AliasIsCanonicalNameError stores the alias."""
|
||||
err = AliasIsCanonicalNameError("greet")
|
||||
assert err.alias == "greet"
|
||||
|
||||
def test_cannot_delete_alias_stores_context(self) -> None:
|
||||
"""CannotDeleteByAliasError stores alias and canonical name."""
|
||||
err = CannotDeleteByAliasError("hi", "greet")
|
||||
assert err.alias == "hi"
|
||||
assert err.canonical == "greet"
|
||||
|
||||
|
||||
class TestNamePattern:
|
||||
"""NAME_RE validates identifiers."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
pytest.param("greet", id="alpha"),
|
||||
pytest.param("hello_world", id="underscore"),
|
||||
pytest.param("cmd1", id="trailing_digit"),
|
||||
pytest.param("a", id="single_char"),
|
||||
pytest.param("test_123", id="mixed"),
|
||||
],
|
||||
)
|
||||
def test_valid_names(self, name: str) -> None:
|
||||
"""NAME_RE matches valid lowercase alphanumeric identifiers."""
|
||||
assert NAME_RE.match(name) is not None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
pytest.param("", id="empty"),
|
||||
pytest.param("Hello", id="uppercase"),
|
||||
pytest.param("has space", id="space"),
|
||||
pytest.param("special!", id="special_char"),
|
||||
pytest.param("with-dash", id="dash"),
|
||||
pytest.param("UPPER", id="all_upper"),
|
||||
],
|
||||
)
|
||||
def test_invalid_names(self, name: str) -> None:
|
||||
"""NAME_RE rejects names with invalid characters or patterns."""
|
||||
assert NAME_RE.match(name) is None
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
# ---- Repository fixtures ----
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cmd_storage() -> AsyncIterator[ModuleStorage]:
|
||||
"""Yield an open in-memory ModuleStorage."""
|
||||
async with ModuleStorage(None, "custom_commands") as storage:
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(cmd_storage: ModuleStorage) -> CommandRepository:
|
||||
"""CommandRepository backed by cmd_storage, with schema initialized."""
|
||||
r = CommandRepository(cmd_storage)
|
||||
await r.setup()
|
||||
return r
|
||||
|
||||
|
||||
# ---- Repository command tests ----
|
||||
|
||||
|
||||
class TestRepositoryCreate:
|
||||
"""CommandRepository.create() inserts new commands."""
|
||||
|
||||
async def test_create_returns_command(self, repo: CommandRepository) -> None:
|
||||
"""create() returns a Command snapshot with correct fields."""
|
||||
cmd = await repo.create("greet", "Hello!", 5)
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello!"
|
||||
assert cmd.use_count == 0
|
||||
assert cmd.requires_moderator is False
|
||||
assert cmd.cooldown == 5
|
||||
assert cmd.aliases == frozenset()
|
||||
assert cmd.created_at is not None
|
||||
assert cmd.updated_at is not None
|
||||
|
||||
async def test_create_duplicate_raises(self, repo: CommandRepository) -> None:
|
||||
"""create() raises on duplicate command name."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
with pytest.raises(StorageError, match="UNIQUE constraint"):
|
||||
await repo.create("greet", "Hi!", 5)
|
||||
|
||||
|
||||
class TestRepositoryGet:
|
||||
"""CommandRepository.get() fetches by name or alias."""
|
||||
|
||||
async def test_get_by_name(self, repo: CommandRepository) -> None:
|
||||
"""get() retrieves a command by its canonical name."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
cmd = await repo.get("greet")
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello!"
|
||||
|
||||
async def test_get_not_found(self, repo: CommandRepository) -> None:
|
||||
"""get() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError) as exc_info:
|
||||
await repo.get("nonexistent")
|
||||
assert exc_info.value.identifier == "nonexistent"
|
||||
|
||||
|
||||
class TestRepositoryDelete:
|
||||
"""CommandRepository.delete() removes commands."""
|
||||
|
||||
async def test_delete_returns_command(self, repo: CommandRepository) -> None:
|
||||
"""delete() returns the deleted command snapshot."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
deleted = await repo.delete("greet")
|
||||
assert deleted.name == "greet"
|
||||
|
||||
async def test_delete_removes_from_db(self, repo: CommandRepository) -> None:
|
||||
"""delete() makes the command unreachable via get()."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.delete("greet")
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.get("greet")
|
||||
|
||||
async def test_delete_not_found(self, repo: CommandRepository) -> None:
|
||||
"""delete() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.delete("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryUpdateResponse:
|
||||
"""CommandRepository.update_response() changes a command's response."""
|
||||
|
||||
async def test_update_response(self, repo: CommandRepository) -> None:
|
||||
"""update_response() returns updated Command with new response."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_response("greet", "Hi there!")
|
||||
assert updated.response == "Hi there!"
|
||||
assert updated.name == "greet"
|
||||
|
||||
async def test_update_response_changes_updated_at(
|
||||
self, repo: CommandRepository
|
||||
) -> None:
|
||||
"""update_response() bumps updated_at timestamp."""
|
||||
created = await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_response("greet", "Hi!")
|
||||
assert updated.updated_at != created.updated_at
|
||||
|
||||
async def test_update_response_not_found(self, repo: CommandRepository) -> None:
|
||||
"""update_response() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.update_response("nonexistent", "Hi!")
|
||||
|
||||
|
||||
class TestRepositoryUpdateModeratorFlag:
|
||||
"""CommandRepository.update_moderator_flag() changes the mod-only flag."""
|
||||
|
||||
async def test_enable(self, repo: CommandRepository) -> None:
|
||||
"""update_moderator_flag() sets requires_moderator to True."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_moderator_flag("greet", enabled=True)
|
||||
assert updated.requires_moderator is True
|
||||
assert updated.cooldown == 5
|
||||
|
||||
async def test_disable(self, repo: CommandRepository) -> None:
|
||||
"""update_moderator_flag() sets requires_moderator to False."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.update_moderator_flag("greet", enabled=True)
|
||||
updated = await repo.update_moderator_flag("greet", enabled=False)
|
||||
assert updated.requires_moderator is False
|
||||
|
||||
async def test_not_found(self, repo: CommandRepository) -> None:
|
||||
"""update_moderator_flag() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.update_moderator_flag("nonexistent", enabled=True)
|
||||
|
||||
|
||||
class TestRepositoryUpdateCooldown:
|
||||
"""CommandRepository.update_cooldown() changes the cooldown duration."""
|
||||
|
||||
async def test_update(self, repo: CommandRepository) -> None:
|
||||
"""update_cooldown() sets the cooldown value."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_cooldown("greet", seconds=30)
|
||||
assert updated.cooldown == 30
|
||||
assert updated.requires_moderator is False
|
||||
|
||||
async def test_not_found(self, repo: CommandRepository) -> None:
|
||||
"""update_cooldown() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.update_cooldown("nonexistent", seconds=5)
|
||||
|
||||
|
||||
class TestRepositoryResetUseCount:
|
||||
"""CommandRepository.reset_use_count() zeroes the counter."""
|
||||
|
||||
async def test_reset_use_count(self, repo: CommandRepository) -> None:
|
||||
"""reset_use_count() sets use_count back to 0."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.increment_use_count("greet")
|
||||
await repo.increment_use_count("greet")
|
||||
reset = await repo.reset_use_count("greet")
|
||||
assert reset.use_count == 0
|
||||
|
||||
async def test_reset_not_found(self, repo: CommandRepository) -> None:
|
||||
"""reset_use_count() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.reset_use_count("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryIncrementUseCount:
|
||||
"""CommandRepository.increment_use_count() bumps the counter."""
|
||||
|
||||
async def test_increment(self, repo: CommandRepository) -> None:
|
||||
"""increment_use_count() increases use_count by 1 each call."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
cmd = await repo.increment_use_count("greet")
|
||||
assert cmd.use_count == 1
|
||||
cmd = await repo.increment_use_count("greet")
|
||||
assert cmd.use_count == 2
|
||||
|
||||
async def test_increment_not_found(self, repo: CommandRepository) -> None:
|
||||
"""increment_use_count() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.increment_use_count("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryListAll:
|
||||
"""CommandRepository.list_all() returns all commands."""
|
||||
|
||||
async def test_list_all(self, repo: CommandRepository) -> None:
|
||||
"""list_all() returns commands sorted by name."""
|
||||
await repo.create("bravo", "B", 5)
|
||||
await repo.create("alpha", "A", 5)
|
||||
result = await repo.list_all()
|
||||
assert len(result) == 2
|
||||
assert result[0].name == "alpha"
|
||||
assert result[1].name == "bravo"
|
||||
|
||||
async def test_list_all_empty(self, repo: CommandRepository) -> None:
|
||||
"""list_all() returns empty list when no commands exist."""
|
||||
assert await repo.list_all() == []
|
||||
|
||||
|
||||
# ---- Repository alias tests ----
|
||||
|
||||
|
||||
class TestRepositoryGetByAlias:
|
||||
"""CommandRepository.get() resolves aliases."""
|
||||
|
||||
async def test_get_by_alias(self, repo: CommandRepository) -> None:
|
||||
"""get() resolves an alias to its canonical command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
cmd = await repo.get("hi")
|
||||
assert cmd.name == "greet"
|
||||
assert "hi" in cmd.aliases
|
||||
|
||||
|
||||
class TestRepositoryAddAlias:
|
||||
"""CommandRepository.add_alias() adds aliases."""
|
||||
|
||||
async def test_add_alias(self, repo: CommandRepository) -> None:
|
||||
"""add_alias() adds an alias and returns refreshed command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
cmd = await repo.add_alias("greet", "hi")
|
||||
assert "hi" in cmd.aliases
|
||||
|
||||
async def test_add_multiple_aliases(self, repo: CommandRepository) -> None:
|
||||
"""add_alias() accumulates multiple aliases."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
cmd = await repo.add_alias("greet", "hey")
|
||||
assert cmd.aliases == frozenset({"hi", "hey"})
|
||||
|
||||
async def test_add_alias_duplicate(self, repo: CommandRepository) -> None:
|
||||
"""add_alias() raises AliasAlreadyExistsError for duplicate aliases."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
with pytest.raises(AliasAlreadyExistsError) as exc_info:
|
||||
await repo.add_alias("greet", "hi")
|
||||
assert exc_info.value.alias == "hi"
|
||||
assert exc_info.value.owner == "greet"
|
||||
|
||||
|
||||
class TestRepositoryRemoveAlias:
|
||||
"""CommandRepository.remove_alias() removes aliases."""
|
||||
|
||||
async def test_remove_alias(self, repo: CommandRepository) -> None:
|
||||
"""remove_alias() removes the alias and returns refreshed command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
alias, cmd = await repo.remove_alias("hi")
|
||||
assert alias == "hi"
|
||||
assert "hi" not in cmd.aliases
|
||||
|
||||
async def test_remove_alias_not_found(self, repo: CommandRepository) -> None:
|
||||
"""remove_alias() raises CommandNotFoundError for missing aliases."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.remove_alias("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryDeleteCascade:
|
||||
"""Deleting a command cascades to its aliases."""
|
||||
|
||||
async def test_delete_cascades_aliases(self, repo: CommandRepository) -> None:
|
||||
"""delete() removes associated aliases via CASCADE."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
await repo.delete("greet")
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.get("hi")
|
||||
|
||||
|
||||
class TestRepositoryListAllWithAliases:
|
||||
"""CommandRepository.list_all() includes aliases."""
|
||||
|
||||
async def test_list_all_includes_aliases(self, repo: CommandRepository) -> None:
|
||||
"""list_all() populates alias sets for each command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
await repo.add_alias("greet", "hey")
|
||||
result = await repo.list_all()
|
||||
assert len(result) == 1
|
||||
assert result[0].aliases == frozenset({"hi", "hey"})
|
||||
|
||||
|
||||
# ---- Repository counter tests ----
|
||||
|
||||
|
||||
class TestRepositoryGetCounter:
|
||||
"""CommandRepository.get_counter() reads counter values."""
|
||||
|
||||
async def test_get_counter_missing(self, repo: CommandRepository) -> None:
|
||||
"""get_counter() returns 0 for nonexistent counters."""
|
||||
assert await repo.get_counter("deaths") == 0
|
||||
|
||||
async def test_get_counter_existing(self, repo: CommandRepository) -> None:
|
||||
"""get_counter() returns the stored value."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
assert await repo.get_counter("deaths") == 10
|
||||
|
||||
|
||||
class TestRepositorySetCounter:
|
||||
"""CommandRepository.set_counter() sets absolute values."""
|
||||
|
||||
async def test_set_counter_new(self, repo: CommandRepository) -> None:
|
||||
"""set_counter() inserts a new counter with the given value."""
|
||||
result = await repo.set_counter("deaths", 42)
|
||||
assert result == 42
|
||||
assert await repo.get_counter("deaths") == 42
|
||||
|
||||
async def test_set_counter_overwrite(self, repo: CommandRepository) -> None:
|
||||
"""set_counter() overwrites an existing counter."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
result = await repo.set_counter("deaths", 99)
|
||||
assert result == 99
|
||||
|
||||
|
||||
class TestRepositoryUpdateCounter:
|
||||
"""CommandRepository.update_counter() applies relative deltas."""
|
||||
|
||||
async def test_update_counter_new(self, repo: CommandRepository) -> None:
|
||||
"""update_counter() creates a counter if it does not exist."""
|
||||
result = await repo.update_counter("deaths", 5)
|
||||
assert result == 5
|
||||
|
||||
async def test_update_counter_increment(self, repo: CommandRepository) -> None:
|
||||
"""update_counter() adds a positive delta to existing value."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
result = await repo.update_counter("deaths", 3)
|
||||
assert result == 13
|
||||
|
||||
async def test_update_counter_decrement(self, repo: CommandRepository) -> None:
|
||||
"""update_counter() subtracts a negative delta from existing value."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
result = await repo.update_counter("deaths", -3)
|
||||
assert result == 7
|
||||
|
||||
|
||||
# ---- Manager stubs and fixtures ----
|
||||
|
||||
|
||||
class _StubModuleCommands:
|
||||
"""Minimal ModuleCommands stand-in that tracks registrations."""
|
||||
|
||||
def __init__(self, prefix: str = "!") -> None:
|
||||
self.prefix = prefix
|
||||
self._registered: dict[str, dict[str, Any]] = {}
|
||||
self._all_triggers: set[str] = set()
|
||||
self._external_triggers: set[str] = set()
|
||||
|
||||
def seed_external(self, name: str) -> None:
|
||||
"""Pre-register a trigger as belonging to another module."""
|
||||
self._external_triggers.add(name)
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Any,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
cooldown: int = 0,
|
||||
) -> None:
|
||||
all_new = {name} | set(aliases or [])
|
||||
for trigger in all_new:
|
||||
if trigger in self._all_triggers or trigger in self._external_triggers:
|
||||
raise ValueError(
|
||||
f"Command trigger '{trigger}' conflicts with existing command"
|
||||
)
|
||||
self._registered[name] = {
|
||||
"handler": handler,
|
||||
"aliases": set(aliases or []),
|
||||
"requires_authenticated": requires_authenticated,
|
||||
"requires_moderator": requires_moderator,
|
||||
"cooldown": cooldown,
|
||||
}
|
||||
self._all_triggers.update(all_new)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
if name not in self._registered:
|
||||
return False
|
||||
info = self._registered.pop(name)
|
||||
self._all_triggers.discard(name)
|
||||
for a in info["aliases"]:
|
||||
self._all_triggers.discard(a)
|
||||
return True
|
||||
|
||||
def exists(self, trigger: str) -> bool:
|
||||
return trigger in self._all_triggers or trigger in self._external_triggers
|
||||
|
||||
|
||||
class _StubConfig:
|
||||
"""Minimal ModuleConfig stand-in."""
|
||||
|
||||
def __init__(self, values: dict[str, Any] | None = None) -> None:
|
||||
self._values: dict[str, Any] = values or {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
def register_defaults(self, defaults: dict[str, Any]) -> None:
|
||||
for key, value in defaults.items():
|
||||
self._values.setdefault(key, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_commands() -> _StubModuleCommands:
|
||||
"""Return a fresh stub command registry."""
|
||||
return _StubModuleCommands()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmd_ctx(
|
||||
cmd_storage: ModuleStorage, stub_commands: _StubModuleCommands
|
||||
) -> ModuleContext:
|
||||
"""ModuleContext with real storage and stub commands/config."""
|
||||
return ModuleContext(
|
||||
module_name="custom_commands",
|
||||
config=_StubConfig({"default_cooldown": 5, "max_nesting_depth": 4}), # type: ignore[arg-type]
|
||||
owncast_client=None, # type: ignore[arg-type]
|
||||
storage=cmd_storage,
|
||||
commands=stub_commands, # type: ignore[arg-type]
|
||||
events=None, # type: ignore[arg-type]
|
||||
routes=None, # type: ignore[arg-type]
|
||||
http=None, # type: ignore[arg-type]
|
||||
templates=None, # type: ignore[arg-type]
|
||||
admin_client=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(cmd_ctx: ModuleContext, repo: CommandRepository) -> CommandManager:
|
||||
"""CommandManager backed by real repo and stub commands."""
|
||||
return CommandManager(cmd_ctx, repo)
|
||||
|
||||
|
||||
# ---- Manager tests ----
|
||||
|
||||
|
||||
class TestManagerLoadAll:
|
||||
"""CommandManager.load_all() loads commands into the registry."""
|
||||
|
||||
async def test_load_all(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
repo: CommandRepository,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""load_all registers all commands from the database."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.create("bye", "Goodbye!", 0)
|
||||
await manager.load_all()
|
||||
assert stub_commands.exists("greet")
|
||||
assert stub_commands.exists("bye")
|
||||
|
||||
async def test_load_all_skips_conflicts(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
repo: CommandRepository,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""load_all skips commands that conflict with existing triggers."""
|
||||
stub_commands.seed_external("greet")
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.create("bye", "Goodbye!", 0)
|
||||
await manager.load_all()
|
||||
assert not stub_commands._registered.get("greet")
|
||||
assert stub_commands.exists("bye")
|
||||
|
||||
async def test_load_all_with_aliases(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
repo: CommandRepository,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""load_all registers commands together with their aliases."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
await manager.load_all()
|
||||
assert stub_commands.exists("greet")
|
||||
assert stub_commands.exists("hi")
|
||||
|
||||
async def test_load_all_empty(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""load_all does nothing when the database is empty."""
|
||||
await manager.load_all()
|
||||
assert len(stub_commands._registered) == 0
|
||||
|
||||
|
||||
class TestManagerCreateCommand:
|
||||
"""CommandManager.create_command() creates and registers commands."""
|
||||
|
||||
async def test_create_command(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""create_command creates and registers a command."""
|
||||
cmd = await manager.create_command("greet", "Hello!")
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello!"
|
||||
assert cmd.cooldown == 5 # default_cooldown from config
|
||||
assert stub_commands.exists("greet")
|
||||
|
||||
async def test_create_rejects_invalid_name(self, manager: CommandManager) -> None:
|
||||
"""create_command raises InvalidNameError for bad names."""
|
||||
with pytest.raises(InvalidNameError):
|
||||
await manager.create_command("BAD!", "Hello!")
|
||||
|
||||
async def test_create_rejects_conflict(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""create_command raises CommandAlreadyExistsError on conflict."""
|
||||
stub_commands.seed_external("about")
|
||||
with pytest.raises(CommandAlreadyExistsError) as exc_info:
|
||||
await manager.create_command("about", "Info")
|
||||
assert exc_info.value.name == "about"
|
||||
|
||||
async def test_create_rollback_on_db_failure(
|
||||
self,
|
||||
cmd_ctx: ModuleContext,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""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)
|
||||
mgr = CommandManager(cmd_ctx, bad_repo)
|
||||
with pytest.raises((StorageError, AttributeError)):
|
||||
await mgr.create_command("greet", "Hello!")
|
||||
assert not stub_commands.exists("greet")
|
||||
|
||||
|
||||
class TestManagerEditCommand:
|
||||
"""CommandManager.edit_command() updates a command's response."""
|
||||
|
||||
async def test_edit_command(self, manager: CommandManager) -> None:
|
||||
"""edit_command updates the response text."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.edit_command("greet", "Hi there!")
|
||||
assert updated.response == "Hi there!"
|
||||
|
||||
async def test_edit_resolves_alias(self, manager: CommandManager) -> None:
|
||||
"""edit_command resolves an alias to the canonical command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.add_alias("greet", "hi")
|
||||
updated = await manager.edit_command("hi", "Hi there!")
|
||||
assert updated.name == "greet"
|
||||
assert updated.response == "Hi there!"
|
||||
|
||||
async def test_edit_not_found(self, manager: CommandManager) -> None:
|
||||
"""edit_command raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.edit_command("nonexistent", "Hi!")
|
||||
|
||||
async def test_edit_not_custom(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""edit_command raises NotCustomCommandError for built-in commands."""
|
||||
stub_commands.seed_external("about")
|
||||
with pytest.raises(NotCustomCommandError):
|
||||
await manager.edit_command("about", "Hacked!")
|
||||
|
||||
|
||||
class TestManagerDeleteCommand:
|
||||
"""CommandManager.delete_command() deletes and unregisters commands."""
|
||||
|
||||
async def test_delete_command(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""delete_command removes the command and unregisters its trigger."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
deleted = await manager.delete_command("greet")
|
||||
assert deleted.name == "greet"
|
||||
assert not stub_commands.exists("greet")
|
||||
|
||||
async def test_delete_rejects_alias(self, manager: CommandManager) -> None:
|
||||
"""delete_command raises CannotDeleteByAliasError for aliases."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.add_alias("greet", "hi")
|
||||
with pytest.raises(CannotDeleteByAliasError) as exc_info:
|
||||
await manager.delete_command("hi")
|
||||
assert exc_info.value.alias == "hi"
|
||||
assert exc_info.value.canonical == "greet"
|
||||
|
||||
async def test_delete_not_found(self, manager: CommandManager) -> None:
|
||||
"""delete_command raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.delete_command("nonexistent")
|
||||
|
||||
|
||||
class TestManagerSetModOnly:
|
||||
"""CommandManager.set_mod_only() updates moderator access."""
|
||||
|
||||
async def test_set_mod_only_on(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""set_mod_only enables moderator-only access."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.set_mod_only("greet", enabled=True)
|
||||
assert updated.requires_moderator is True
|
||||
assert stub_commands._registered["greet"]["requires_moderator"] is True
|
||||
|
||||
async def test_set_mod_only_off(self, manager: CommandManager) -> None:
|
||||
"""set_mod_only disables moderator-only access."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.set_mod_only("greet", enabled=True)
|
||||
updated = await manager.set_mod_only("greet", enabled=False)
|
||||
assert updated.requires_moderator is False
|
||||
|
||||
|
||||
class TestManagerSetCooldown:
|
||||
"""CommandManager.set_cooldown() updates cooldown."""
|
||||
|
||||
async def test_set_cooldown(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""set_cooldown updates the cooldown and re-registers the command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.set_cooldown("greet", 30)
|
||||
assert updated.cooldown == 30
|
||||
assert stub_commands._registered["greet"]["cooldown"] == 30
|
||||
|
||||
async def test_set_cooldown_zero(self, manager: CommandManager) -> None:
|
||||
"""set_cooldown accepts zero to disable cooldown."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.set_cooldown("greet", 0)
|
||||
assert updated.cooldown == 0
|
||||
|
||||
|
||||
class TestManagerResetUseCount:
|
||||
"""CommandManager.reset_use_count() zeroes the counter."""
|
||||
|
||||
async def test_reset_use_count(self, manager: CommandManager) -> None:
|
||||
"""reset_use_count sets the counter back to zero."""
|
||||
await manager.create_command("greet", "$(count)")
|
||||
await manager.execute_command("greet", [], "tester")
|
||||
reset = await manager.reset_use_count("greet")
|
||||
assert reset.use_count == 0
|
||||
|
||||
|
||||
class TestManagerAddAlias:
|
||||
"""CommandManager.add_alias() adds and re-registers aliases."""
|
||||
|
||||
async def test_add_alias(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""add_alias registers a new alias for the command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
cmd = await manager.add_alias("greet", "hi")
|
||||
assert "hi" in cmd.aliases
|
||||
assert stub_commands.exists("hi")
|
||||
|
||||
async def test_add_alias_rejects_canonical(self, manager: CommandManager) -> None:
|
||||
"""add_alias raises AliasIsCanonicalNameError for the canonical name."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
with pytest.raises(AliasIsCanonicalNameError):
|
||||
await manager.add_alias("greet", "greet")
|
||||
|
||||
async def test_add_alias_rejects_conflict(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""add_alias raises CommandAlreadyExistsError on trigger conflict."""
|
||||
stub_commands.seed_external("about")
|
||||
await manager.create_command("greet", "Hello!")
|
||||
with pytest.raises(CommandAlreadyExistsError):
|
||||
await manager.add_alias("greet", "about")
|
||||
|
||||
async def test_add_alias_invalid_name(self, manager: CommandManager) -> None:
|
||||
"""add_alias raises InvalidNameError for bad alias names."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
with pytest.raises(InvalidNameError):
|
||||
await manager.add_alias("greet", "BAD!")
|
||||
|
||||
|
||||
class TestManagerRemoveAlias:
|
||||
"""CommandManager.remove_alias() removes and re-registers aliases."""
|
||||
|
||||
async def test_remove_alias(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""remove_alias removes the alias and keeps the canonical command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.add_alias("greet", "hi")
|
||||
cmd = await manager.remove_alias("hi")
|
||||
assert "hi" not in cmd.aliases
|
||||
assert not stub_commands.exists("hi")
|
||||
assert stub_commands.exists("greet")
|
||||
|
||||
async def test_remove_alias_not_found(self, manager: CommandManager) -> None:
|
||||
"""remove_alias raises CommandNotFoundError for missing aliases."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.remove_alias("nonexistent")
|
||||
|
||||
|
||||
class TestManagerExecuteCommand:
|
||||
"""CommandManager.execute_command() increments count and processes response."""
|
||||
|
||||
async def test_execute_plain(self, manager: CommandManager) -> None:
|
||||
"""execute_command returns the plain response text."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
response = await manager.execute_command("greet", [], "tester")
|
||||
assert response == "Hello!"
|
||||
|
||||
async def test_execute_increments_use_count(self, manager: CommandManager) -> None:
|
||||
"""execute_command increments use_count on each call."""
|
||||
await manager.create_command("greet", "Count: $(count)")
|
||||
r1 = await manager.execute_command("greet", [], "tester")
|
||||
assert r1 == "Count: 1"
|
||||
r2 = await manager.execute_command("greet", [], "tester")
|
||||
assert r2 == "Count: 2"
|
||||
|
||||
async def test_execute_user_placeholder(self, manager: CommandManager) -> None:
|
||||
"""execute_command substitutes the $(user) placeholder."""
|
||||
await manager.create_command("greet", "Hello $(user)!")
|
||||
response = await manager.execute_command("greet", [], "Alice")
|
||||
assert response == "Hello Alice!"
|
||||
|
||||
async def test_execute_not_found(self, manager: CommandManager) -> None:
|
||||
"""execute_command raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.execute_command("nonexistent", [], "tester")
|
||||
|
||||
|
||||
class TestManagerEditCounter:
|
||||
"""CommandManager.edit_counter() sets/adjusts named counters."""
|
||||
|
||||
async def test_edit_counter_absolute(self, manager: CommandManager) -> None:
|
||||
"""edit_counter sets an absolute counter value."""
|
||||
result = await manager.edit_counter("deaths", value=42, relative=False)
|
||||
assert result == 42
|
||||
|
||||
async def test_edit_counter_relative_increment(
|
||||
self, manager: CommandManager
|
||||
) -> None:
|
||||
"""edit_counter increments a counter with a positive delta."""
|
||||
await manager.edit_counter("deaths", value=10, relative=False)
|
||||
result = await manager.edit_counter("deaths", value=5, relative=True)
|
||||
assert result == 15
|
||||
|
||||
async def test_edit_counter_relative_decrement(
|
||||
self, manager: CommandManager
|
||||
) -> None:
|
||||
"""edit_counter decrements a counter with a negative delta."""
|
||||
await manager.edit_counter("deaths", value=10, relative=False)
|
||||
result = await manager.edit_counter("deaths", value=-3, relative=True)
|
||||
assert result == 7
|
||||
|
||||
async def test_edit_counter_invalid_name(self, manager: CommandManager) -> None:
|
||||
"""edit_counter raises InvalidNameError for bad counter names."""
|
||||
with pytest.raises(InvalidNameError):
|
||||
await manager.edit_counter("BAD!", value=5, relative=False)
|
||||
|
||||
|
||||
class TestManagerCounterAccessor:
|
||||
"""CommandManager as CounterAccessor protocol for placeholders."""
|
||||
|
||||
async def test_get_counter(self, manager: CommandManager) -> None:
|
||||
"""Return 0 for missing counter, correct value for existing."""
|
||||
assert await manager.get_counter("deaths") == 0
|
||||
await manager.set_counter("deaths", 10)
|
||||
assert await manager.get_counter("deaths") == 10
|
||||
|
||||
async def test_set_counter(self, manager: CommandManager) -> None:
|
||||
"""Set a counter to an absolute value."""
|
||||
result = await manager.set_counter("deaths", 42)
|
||||
assert result == 42
|
||||
|
||||
async def test_adjust_counter(self, manager: CommandManager) -> None:
|
||||
"""Adjust a counter by a relative delta."""
|
||||
await manager.set_counter("deaths", 10)
|
||||
result = await manager.adjust_counter("deaths", 5)
|
||||
assert result == 15
|
||||
|
||||
async def test_execute_with_named_counter(self, manager: CommandManager) -> None:
|
||||
"""Execute a command that uses a named counter placeholder."""
|
||||
await manager.create_command("deaths", "Deaths: $(count deaths)")
|
||||
r1 = await manager.execute_command("deaths", [], "tester")
|
||||
assert r1 == "Deaths: 1"
|
||||
r2 = await manager.execute_command("deaths", [], "tester")
|
||||
assert r2 == "Deaths: 2"
|
||||
|
||||
async def test_execute_with_getcount(self, manager: CommandManager) -> None:
|
||||
"""Execute a command that reads a named counter."""
|
||||
await manager.create_command("show", "Deaths: $(getcount deaths)")
|
||||
await manager.set_counter("deaths", 42)
|
||||
response = await manager.execute_command("show", [], "tester")
|
||||
assert response == "Deaths: 42"
|
||||
|
||||
|
||||
class TestGetManager:
|
||||
"""get_manager() helper."""
|
||||
|
||||
def test_returns_manager(
|
||||
self, cmd_ctx: ModuleContext, repo: CommandRepository
|
||||
) -> None:
|
||||
"""get_manager returns the manager stored in context state."""
|
||||
mgr = CommandManager(cmd_ctx, repo)
|
||||
cmd_ctx.state["manager"] = mgr
|
||||
assert get_manager(cmd_ctx) is mgr
|
||||
|
||||
def test_raises_if_not_initialized(self, cmd_ctx: ModuleContext) -> None:
|
||||
"""get_manager raises RuntimeError when no manager is stored."""
|
||||
with pytest.raises(RuntimeError, match="CommandManager is not initialized"):
|
||||
get_manager(cmd_ctx)
|
||||
+22
-93
@@ -41,6 +41,17 @@ from owlbot.api.event_types import (
|
||||
log_event,
|
||||
parse_event,
|
||||
)
|
||||
from owlbot.testing import (
|
||||
make_chat_event,
|
||||
make_name_changed_event,
|
||||
make_stream_started_event,
|
||||
make_stream_stopped_event,
|
||||
make_stream_title_updated_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
make_user_parted_event,
|
||||
make_visibility_update_event,
|
||||
)
|
||||
|
||||
|
||||
def _user_dict(
|
||||
@@ -596,148 +607,66 @@ class TestLogEvent:
|
||||
[
|
||||
pytest.param(
|
||||
EventType.CHAT,
|
||||
ChatEvent(
|
||||
user=User(
|
||||
id="abc123",
|
||||
display_name="Alice",
|
||||
display_color=3,
|
||||
created_at=None,
|
||||
previous_names=[],
|
||||
name_changed_at=None,
|
||||
is_bot=False,
|
||||
is_authenticated=False,
|
||||
scopes=frozenset(),
|
||||
),
|
||||
client_id=1,
|
||||
make_chat_event(
|
||||
user=make_user(display_name="Alice"),
|
||||
body="Hello",
|
||||
raw_body="<p>Hello</p>",
|
||||
message_id="msg-001",
|
||||
is_visible=True,
|
||||
timestamp=None,
|
||||
),
|
||||
"[CHAT] Alice (msg-001): Hello",
|
||||
id="chat",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.USER_JOINED,
|
||||
UserJoinedEvent(
|
||||
user=User(
|
||||
id="bob456",
|
||||
display_name="Bob",
|
||||
display_color=0,
|
||||
created_at=None,
|
||||
previous_names=[],
|
||||
name_changed_at=None,
|
||||
is_bot=False,
|
||||
is_authenticated=False,
|
||||
scopes=frozenset(),
|
||||
),
|
||||
client_id=1,
|
||||
event_id="evt-001",
|
||||
timestamp=None,
|
||||
),
|
||||
make_user_joined_event(user=make_user(display_name="Bob")),
|
||||
"[USER_JOINED] Bob joined.",
|
||||
id="user-joined",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.USER_PARTED,
|
||||
UserPartedEvent(
|
||||
user=User(
|
||||
id="carol789",
|
||||
display_name="Carol",
|
||||
display_color=0,
|
||||
created_at=None,
|
||||
previous_names=[],
|
||||
name_changed_at=None,
|
||||
is_bot=False,
|
||||
is_authenticated=False,
|
||||
scopes=frozenset(),
|
||||
),
|
||||
client_id=1,
|
||||
event_id="evt-002",
|
||||
timestamp=None,
|
||||
),
|
||||
make_user_parted_event(user=make_user(display_name="Carol")),
|
||||
"[USER_PARTED] Carol parted.",
|
||||
id="user-parted",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.NAME_CHANGE,
|
||||
NameChangedEvent(
|
||||
user=User(
|
||||
id="abc123",
|
||||
display_name="OldName",
|
||||
display_color=0,
|
||||
created_at=None,
|
||||
previous_names=[],
|
||||
name_changed_at=None,
|
||||
is_bot=False,
|
||||
is_authenticated=False,
|
||||
scopes=frozenset(),
|
||||
),
|
||||
client_id=1,
|
||||
make_name_changed_event(
|
||||
user=make_user(display_name="OldName"),
|
||||
new_name="NewName",
|
||||
event_id="evt-003",
|
||||
timestamp=None,
|
||||
),
|
||||
"[NAME_CHANGE] OldName changed name to NewName.",
|
||||
id="name-change",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.STREAM_STARTED,
|
||||
StreamStartedEvent(
|
||||
server_id="srv-001",
|
||||
server_name="My Stream",
|
||||
stream_title="Playing Minecraft",
|
||||
summary="A fun stream",
|
||||
timestamp=None,
|
||||
),
|
||||
make_stream_started_event(stream_title="Playing Minecraft"),
|
||||
'[STREAM_STARTED] Stream started: "Playing Minecraft"',
|
||||
id="stream-started",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.STREAM_STOPPED,
|
||||
StreamStoppedEvent(
|
||||
server_id="srv-001",
|
||||
server_name="My Stream",
|
||||
stream_title="Playing Minecraft",
|
||||
summary="A fun stream",
|
||||
timestamp=None,
|
||||
),
|
||||
make_stream_stopped_event(),
|
||||
"[STREAM_STOPPED] Stream ended.",
|
||||
id="stream-stopped",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.STREAM_TITLE_UPDATED,
|
||||
StreamTitleUpdatedEvent(
|
||||
server_id="srv-001",
|
||||
server_name="My Stream",
|
||||
stream_title="New Title",
|
||||
summary="",
|
||||
status=None,
|
||||
timestamp=None,
|
||||
),
|
||||
make_stream_title_updated_event(stream_title="New Title"),
|
||||
'[STREAM_TITLE_UPDATED] Title changed to "New Title"',
|
||||
id="stream-title-updated",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.VISIBILITY_UPDATE,
|
||||
VisibilityUpdateEvent(
|
||||
event_id="evt-004",
|
||||
make_visibility_update_event(
|
||||
message_ids=["msg-1", "msg-2"],
|
||||
is_visible=True,
|
||||
timestamp=None,
|
||||
),
|
||||
"[VISIBILITY-UPDATE] 2 message(s) shown: msg-1, msg-2",
|
||||
id="visibility-shown",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.VISIBILITY_UPDATE,
|
||||
VisibilityUpdateEvent(
|
||||
event_id="evt-005",
|
||||
message_ids=["msg-3"],
|
||||
is_visible=False,
|
||||
timestamp=None,
|
||||
),
|
||||
make_visibility_update_event(message_ids=["msg-3"]),
|
||||
"[VISIBILITY-UPDATE] 1 message(s) hidden: msg-3",
|
||||
id="visibility-hidden",
|
||||
),
|
||||
|
||||
+89
-161
@@ -33,8 +33,6 @@ from owlbot.api.context import EventContext, PropagationState
|
||||
from owlbot.api.event_types import (
|
||||
ChatEvent,
|
||||
EventType,
|
||||
User,
|
||||
UserJoinedEvent,
|
||||
)
|
||||
from owlbot.api.events import EventMark, Priority, on_event
|
||||
from owlbot.registries.events import (
|
||||
@@ -43,49 +41,13 @@ from owlbot.registries.events import (
|
||||
HandlerEntry,
|
||||
ModuleEvents,
|
||||
)
|
||||
from tests.conftest import make_module_context
|
||||
from owlbot.testing import make_chat_event, make_user_joined_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
|
||||
|
||||
def _make_user(
|
||||
*,
|
||||
user_id: str = "user-1",
|
||||
display_name: str = "TestUser",
|
||||
) -> User:
|
||||
"""Build a minimal User for test events."""
|
||||
return User(
|
||||
id=user_id,
|
||||
display_name=display_name,
|
||||
display_color=0,
|
||||
created_at=None,
|
||||
previous_names=[],
|
||||
name_changed_at=None,
|
||||
is_bot=False,
|
||||
is_authenticated=False,
|
||||
scopes=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
def _make_chat_event(
|
||||
*,
|
||||
body: str = "hello",
|
||||
user: User | None = None,
|
||||
) -> ChatEvent:
|
||||
"""Build a minimal ChatEvent for test dispatches."""
|
||||
return ChatEvent(
|
||||
user=user or _make_user(),
|
||||
client_id=1,
|
||||
body=body,
|
||||
raw_body=f"<p>{body}</p>",
|
||||
message_id="msg-001",
|
||||
is_visible=True,
|
||||
timestamp=None,
|
||||
)
|
||||
|
||||
|
||||
def _unused_get_module_context(name: str) -> ModuleContext:
|
||||
"""Stub for EventDispatcher tests that never invoke get_module_context."""
|
||||
raise AssertionError("get_module_context should not be called")
|
||||
@@ -424,30 +386,28 @@ class TestEventRegistry:
|
||||
class TestEventDispatcherDispatch:
|
||||
"""Tests dispatch() from EventDispatcher."""
|
||||
|
||||
async def test_calls_handler(self, storage: ModuleStorage) -> None:
|
||||
async def test_calls_handler(self, module_context: ModuleContext) -> None:
|
||||
"""Handler receives an EventContext where ctx.event is the dispatched event."""
|
||||
received: list[Any] = []
|
||||
|
||||
async def handler(ctx: EventContext[Any]) -> None:
|
||||
received.append(ctx.event)
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
||||
event = _make_chat_event()
|
||||
event = make_chat_event()
|
||||
await dispatcher.dispatch(EventType.CHAT, event)
|
||||
assert len(received) == 1
|
||||
assert received[0] is event
|
||||
|
||||
async def test_priority_order(self, storage: ModuleStorage) -> None:
|
||||
async def test_priority_order(self, module_context: ModuleContext) -> None:
|
||||
"""Handlers execute in priority order: HIGH before NORMAL before LOW."""
|
||||
call_order: list[str] = []
|
||||
|
||||
@@ -460,23 +420,23 @@ class TestEventDispatcherDispatch:
|
||||
async def low_handler(ctx: EventContext[Any]) -> None:
|
||||
call_order.append("low")
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(high_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||
dispatcher.register(normal_handler, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
dispatcher.register(low_handler, (EventType.CHAT,), "mod_a", Priority.LOW)
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert call_order == ["high", "normal", "low"]
|
||||
|
||||
async def test_stable_order_same_priority(self, storage: ModuleStorage) -> None:
|
||||
async def test_stable_order_same_priority(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Handlers at the same priority run in registration order."""
|
||||
call_order: list[str] = []
|
||||
|
||||
@@ -486,71 +446,64 @@ class TestEventDispatcherDispatch:
|
||||
async def second(ctx: EventContext[Any]) -> None:
|
||||
call_order.append("second")
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(first, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
dispatcher.register(second, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert call_order == ["first", "second"]
|
||||
|
||||
async def test_no_handlers(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Dispatch with no handlers does not raise; debug log emitted."""
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
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_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(
|
||||
EventType.USER_JOINED,
|
||||
UserJoinedEvent(
|
||||
user=_make_user(),
|
||||
client_id=1,
|
||||
event_id="evt-001",
|
||||
timestamp=None,
|
||||
),
|
||||
make_user_joined_event(client_id=1, event_id="evt-001"),
|
||||
)
|
||||
assert any(
|
||||
"No handlers registered for event type" in r.message for r in caplog.records
|
||||
)
|
||||
|
||||
async def test_logs_event(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Dispatch logs the event via log_event() at INFO level."""
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
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_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
chat_event = _make_chat_event(body="hello")
|
||||
chat_event = make_chat_event(raw_body="hello")
|
||||
with caplog.at_level(logging.INFO, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(EventType.CHAT, chat_event)
|
||||
assert any(
|
||||
r.message == "[CHAT] TestUser (msg-001): hello" for r in caplog.records
|
||||
r.message == "[CHAT] TestUser (test-msg-id): hello" for r in caplog.records
|
||||
)
|
||||
|
||||
async def test_propagation_stops_remaining(self, storage: ModuleStorage) -> None:
|
||||
async def test_propagation_stops_remaining(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Handler A stops propagation; handler B is never called."""
|
||||
call_order: list[str] = []
|
||||
|
||||
@@ -561,23 +514,21 @@ class TestEventDispatcherDispatch:
|
||||
async def handler_b(ctx: EventContext[Any]) -> None:
|
||||
call_order.append("b")
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||
dispatcher.register(handler_b, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert call_order == ["a"]
|
||||
|
||||
async def test_propagation_reason_logged(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Propagation stop reason appears in debug log."""
|
||||
|
||||
@@ -587,23 +538,21 @@ class TestEventDispatcherDispatch:
|
||||
async def handler_b(ctx: EventContext[Any]) -> None:
|
||||
pass
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||
dispatcher.register(handler_b, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert any("Propagation stopped: blocked" in r.message for r in caplog.records)
|
||||
|
||||
async def test_independent_dispatches(self, storage: ModuleStorage) -> None:
|
||||
async def test_independent_dispatches(self, module_context: ModuleContext) -> None:
|
||||
"""Stop propagation in first dispatch; second dispatch runs all."""
|
||||
call_order: list[str] = []
|
||||
stop_once = True
|
||||
@@ -618,31 +567,31 @@ class TestEventDispatcherDispatch:
|
||||
async def follower(ctx: EventContext[Any]) -> None:
|
||||
call_order.append("follower")
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(stopper, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||
dispatcher.register(follower, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert call_order == ["stopper"]
|
||||
|
||||
call_order.clear()
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert call_order == ["stopper", "follower"]
|
||||
|
||||
|
||||
class TestEventDispatcherStorage:
|
||||
"""Tests _call_handler() storage behaviour in EventDispatcher."""
|
||||
"""Tests _call_handler() storage behavior in EventDispatcher."""
|
||||
|
||||
async def test_success_commits(self, storage: ModuleStorage) -> None:
|
||||
async def test_success_commits(
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""A successful handler's database changes are committed."""
|
||||
|
||||
async def handler(ctx: EventContext[Any]) -> None:
|
||||
@@ -651,25 +600,26 @@ class TestEventDispatcherStorage:
|
||||
"INSERT INTO test_tbl (val) VALUES (?)", ("committed",)
|
||||
)
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
|
||||
row = await storage.fetch_one("SELECT val FROM test_tbl")
|
||||
assert row is not None
|
||||
assert row[0] == "committed"
|
||||
|
||||
async def test_exception_does_not_roll_back_committed_writes(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
storage: ModuleStorage,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A handler that raises does not undo already-committed writes."""
|
||||
# Pre-create the table outside the handler.
|
||||
@@ -681,19 +631,17 @@ class TestEventDispatcherStorage:
|
||||
)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
||||
with caplog.at_level(logging.ERROR, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
|
||||
row = await storage.fetch_one("SELECT val FROM test_tbl")
|
||||
assert row is not None
|
||||
@@ -701,7 +649,7 @@ class TestEventDispatcherStorage:
|
||||
assert any("raised exception." in r.message for r in caplog.records)
|
||||
|
||||
async def test_exception_does_not_stop_next_handler(
|
||||
self, storage: ModuleStorage
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""A handler that raises does not prevent subsequent handlers from running."""
|
||||
call_order: list[str] = []
|
||||
@@ -713,49 +661,45 @@ class TestEventDispatcherStorage:
|
||||
async def second_handler(ctx: EventContext[Any]) -> None:
|
||||
call_order.append("second")
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(failing_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||
dispatcher.register(second_handler, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert call_order == ["failing", "second"]
|
||||
|
||||
async def test_timeout_is_logged(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A handler that exceeds the timeout is cancelled and logged."""
|
||||
|
||||
async def slow_handler(ctx: EventContext[Any]) -> None:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=0.05,
|
||||
)
|
||||
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
||||
with caplog.at_level(logging.WARNING, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert any(
|
||||
"cancelled after" in r.message and "0.05s timeout" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
async def test_timeout_does_not_roll_back_committed_writes(
|
||||
self, storage: ModuleStorage
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""A timed-out handler's already-committed writes persist."""
|
||||
await storage.execute("CREATE TABLE IF NOT EXISTS test_tbl (val TEXT)")
|
||||
@@ -766,25 +710,23 @@ class TestEventDispatcherStorage:
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=0.05,
|
||||
)
|
||||
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
|
||||
row = await storage.fetch_one("SELECT val FROM test_tbl")
|
||||
assert row is not None
|
||||
assert row[0] == "persisted"
|
||||
|
||||
async def test_timeout_rolls_back_explicit_transaction(
|
||||
self, storage: ModuleStorage
|
||||
self, module_context: ModuleContext, storage: ModuleStorage
|
||||
) -> None:
|
||||
"""A timed-out handler's uncommitted transaction is rolled back."""
|
||||
await storage.execute("CREATE TABLE IF NOT EXISTS test_tbl (val TEXT)")
|
||||
@@ -796,18 +738,16 @@ class TestEventDispatcherStorage:
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = EventDispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_ctx,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=0.05,
|
||||
)
|
||||
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
|
||||
row = await storage.fetch_one("SELECT val FROM test_tbl")
|
||||
assert row is None
|
||||
@@ -821,18 +761,13 @@ class TestCommandDispatchPhase:
|
||||
[
|
||||
pytest.param(
|
||||
EventType.CHAT,
|
||||
_make_chat_event,
|
||||
make_chat_event,
|
||||
1,
|
||||
id="chat-triggers",
|
||||
),
|
||||
pytest.param(
|
||||
EventType.USER_JOINED,
|
||||
lambda: UserJoinedEvent(
|
||||
user=_make_user(),
|
||||
client_id=1,
|
||||
event_id="evt-001",
|
||||
timestamp=None,
|
||||
),
|
||||
lambda: make_user_joined_event(client_id=1, event_id="evt-001"),
|
||||
0,
|
||||
id="non-chat-skips",
|
||||
),
|
||||
@@ -840,7 +775,7 @@ class TestCommandDispatchPhase:
|
||||
)
|
||||
async def test_command_dispatch_trigger(
|
||||
self,
|
||||
storage: ModuleStorage,
|
||||
module_context: ModuleContext,
|
||||
event_type: EventType,
|
||||
event_factory: Any,
|
||||
expected_calls: int,
|
||||
@@ -851,10 +786,9 @@ class TestCommandDispatchPhase:
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
command_calls.append(event)
|
||||
|
||||
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_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
event = event_factory()
|
||||
@@ -864,7 +798,7 @@ class TestCommandDispatchPhase:
|
||||
assert command_calls[0] is event
|
||||
|
||||
async def test_propagation_skips_commands(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Stopped propagation skips command dispatch."""
|
||||
command_calls: list[Any] = []
|
||||
@@ -875,36 +809,34 @@ class TestCommandDispatchPhase:
|
||||
async def stopper(ctx: EventContext[Any]) -> None:
|
||||
ctx.stop_propagation("spam")
|
||||
|
||||
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_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
dispatcher.register(stopper, (EventType.CHAT,), "mod_a")
|
||||
with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert command_calls == []
|
||||
assert any(
|
||||
"Command dispatch skipped: spam" in r.message for r in caplog.records
|
||||
)
|
||||
|
||||
async def test_command_dispatch_exception_caught(
|
||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||
self, module_context: ModuleContext, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An exception in command_dispatch is caught and logged, not propagated."""
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
raise RuntimeError("fail")
|
||||
|
||||
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_context,
|
||||
handler_timeout=5.0,
|
||||
)
|
||||
with caplog.at_level(logging.ERROR, logger="owlbot.events"):
|
||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||
assert any("Command dispatch failed." in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@@ -1062,80 +994,76 @@ class TestEventContext:
|
||||
],
|
||||
)
|
||||
def test_property_proxying(
|
||||
self, storage: ModuleStorage, prop: str, *, use_is: bool
|
||||
self, module_context: ModuleContext, prop: str, *, use_is: bool
|
||||
) -> None:
|
||||
"""EventContext properties proxy to the underlying ModuleContext."""
|
||||
module_ctx = make_module_context(storage, "mod_a")
|
||||
event = _make_chat_event()
|
||||
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_ctx)
|
||||
event = make_chat_event()
|
||||
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context)
|
||||
ctx_val = getattr(ctx, prop)
|
||||
mod_val = getattr(module_ctx, prop)
|
||||
mod_val = getattr(module_context, prop)
|
||||
if use_is:
|
||||
assert ctx_val is mod_val
|
||||
else:
|
||||
assert ctx_val == mod_val
|
||||
|
||||
def test_event_attribute(self) -> None:
|
||||
def test_event_attribute(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.event is the event object passed at construction."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
event = _make_chat_event()
|
||||
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_ctx)
|
||||
event = make_chat_event()
|
||||
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context)
|
||||
assert ctx.event is event
|
||||
|
||||
def test_propagation_stopped_default(self) -> None:
|
||||
def test_propagation_stopped_default(self, module_context: ModuleContext) -> None:
|
||||
"""propagation_stopped is False by default."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx
|
||||
event=make_chat_event(), module=module_context
|
||||
)
|
||||
assert ctx.propagation_stopped is False
|
||||
|
||||
def test_stop_propagation(self) -> None:
|
||||
def test_stop_propagation(self, module_context: ModuleContext) -> None:
|
||||
"""After stop_propagation(), propagation_stopped is True."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx
|
||||
event=make_chat_event(), module=module_context
|
||||
)
|
||||
ctx.stop_propagation()
|
||||
assert ctx.propagation_stopped is True
|
||||
|
||||
def test_stop_propagation_stores_reason(self) -> None:
|
||||
def test_stop_propagation_stores_reason(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""stop_propagation() stores the reason string."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx
|
||||
event=make_chat_event(), module=module_context
|
||||
)
|
||||
ctx.stop_propagation("blocked")
|
||||
assert ctx._propagation.reason == "blocked"
|
||||
|
||||
def test_first_reason_wins(self) -> None:
|
||||
def test_first_reason_wins(self, module_context: ModuleContext) -> None:
|
||||
"""Only the first non-empty reason is stored."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx
|
||||
event=make_chat_event(), module=module_context
|
||||
)
|
||||
ctx.stop_propagation("first")
|
||||
ctx.stop_propagation("second")
|
||||
assert ctx._propagation.reason == "first"
|
||||
|
||||
def test_none_reason_leaves_empty(self) -> None:
|
||||
def test_none_reason_leaves_empty(self, module_context: ModuleContext) -> None:
|
||||
"""stop_propagation(None) leaves reason as empty string."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx
|
||||
event=make_chat_event(), module=module_context
|
||||
)
|
||||
ctx.stop_propagation(None)
|
||||
assert ctx._propagation.reason == ""
|
||||
|
||||
def test_shared_propagation_across_contexts(self) -> None:
|
||||
def test_shared_propagation_across_contexts(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Two contexts sharing a PropagationState see each other's mutations."""
|
||||
module_ctx = make_module_context(None, "mod_a")
|
||||
propagation = PropagationState()
|
||||
ctx_a: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx, _propagation=propagation
|
||||
event=make_chat_event(), module=module_context, _propagation=propagation
|
||||
)
|
||||
ctx_b: EventContext[ChatEvent] = EventContext(
|
||||
event=_make_chat_event(), module=module_ctx, _propagation=propagation
|
||||
event=make_chat_event(), module=module_context, _propagation=propagation
|
||||
)
|
||||
ctx_a.stop_propagation("blocked")
|
||||
assert ctx_b.propagation_stopped is True
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the quotes module: types, repository, and manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
from owlbot.builtin_modules.quotes.manager import QuoteManager, get_manager
|
||||
from owlbot.builtin_modules.quotes.repository import QuoteRepository
|
||||
from owlbot.builtin_modules.quotes.types import (
|
||||
Quote,
|
||||
QuoteError,
|
||||
QuoteNotFoundError,
|
||||
)
|
||||
from tests.conftest import make_module_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def quote_storage() -> AsyncIterator[ModuleStorage]:
|
||||
"""Yield an open in-memory ModuleStorage."""
|
||||
async with ModuleStorage(None, "quotes") as storage:
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(quote_storage: ModuleStorage) -> QuoteRepository:
|
||||
"""QuoteRepository backed by quote_storage, with schema initialized."""
|
||||
r = QuoteRepository(quote_storage)
|
||||
await r.setup()
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quote_ctx(quote_storage: ModuleStorage) -> ModuleContext:
|
||||
"""ModuleContext backed by quote_storage."""
|
||||
return make_module_context(storage=quote_storage, module_name="quotes")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(quote_ctx: ModuleContext, repo: QuoteRepository) -> QuoteManager:
|
||||
"""QuoteManager backed by real repo."""
|
||||
return QuoteManager(quote_ctx, repo)
|
||||
|
||||
|
||||
class TestQuoteFromRow:
|
||||
"""Quote.from_row() database row conversion."""
|
||||
|
||||
async def test_builds_from_row(self) -> None:
|
||||
"""from_row constructs a Quote with correct field values."""
|
||||
row = {
|
||||
"id": 5,
|
||||
"text": "Test quote",
|
||||
"added_by": "someone",
|
||||
"created_at": "2026-03-15T10:00:00+00:00",
|
||||
}
|
||||
quote = Quote.from_row(row) # type: ignore[arg-type]
|
||||
assert quote.id == 5
|
||||
assert quote.text == "Test quote"
|
||||
assert quote.added_by == "someone"
|
||||
assert quote.created_at == "2026-03-15T10:00:00+00:00"
|
||||
|
||||
|
||||
class TestQuoteErrors:
|
||||
"""Quote domain error classes."""
|
||||
|
||||
def test_base_error_hierarchy(self) -> None:
|
||||
"""All quote errors inherit from QuoteError."""
|
||||
assert issubclass(QuoteNotFoundError, QuoteError)
|
||||
|
||||
def test_not_found_stores_quote_id(self) -> None:
|
||||
"""QuoteNotFoundError stores the quote ID."""
|
||||
err = QuoteNotFoundError(42)
|
||||
assert err.quote_id == 42
|
||||
assert "42" in str(err)
|
||||
|
||||
|
||||
class TestRepositoryCreate:
|
||||
"""QuoteRepository.create() inserts new quotes."""
|
||||
|
||||
async def test_create_returns_quote(self, repo: QuoteRepository) -> None:
|
||||
"""Creating a quote returns a Quote snapshot with correct fields."""
|
||||
quote = await repo.create("Hello, world!", "tester")
|
||||
assert quote.id == 1
|
||||
assert quote.text == "Hello, world!"
|
||||
assert quote.added_by == "tester"
|
||||
assert quote.created_at is not None
|
||||
|
||||
async def test_create_auto_increments(self, repo: QuoteRepository) -> None:
|
||||
"""Each created quote gets a sequential ID."""
|
||||
q1 = await repo.create("First", "tester")
|
||||
q2 = await repo.create("Second", "tester")
|
||||
assert q2.id == q1.id + 1
|
||||
|
||||
|
||||
class TestRepositoryGet:
|
||||
"""QuoteRepository.get() fetches by ID."""
|
||||
|
||||
async def test_get_existing(self, repo: QuoteRepository) -> None:
|
||||
"""Fetches a quote by its ID."""
|
||||
created = await repo.create("Test", "tester")
|
||||
fetched = await repo.get(created.id)
|
||||
assert fetched.id == created.id
|
||||
assert fetched.text == "Test"
|
||||
|
||||
async def test_get_not_found(self, repo: QuoteRepository) -> None:
|
||||
"""Raises QuoteNotFoundError for an unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError) as exc_info:
|
||||
await repo.get(999)
|
||||
assert exc_info.value.quote_id == 999
|
||||
|
||||
|
||||
class TestRepositoryGetRandom:
|
||||
"""QuoteRepository.get_random() fetches a random quote."""
|
||||
|
||||
async def test_get_random_returns_quote(self, repo: QuoteRepository) -> None:
|
||||
"""Returns a quote when the table is not empty."""
|
||||
await repo.create("Only quote", "tester")
|
||||
quote = await repo.get_random()
|
||||
assert quote is not None
|
||||
assert quote.text == "Only quote"
|
||||
|
||||
async def test_get_random_empty_table(self, repo: QuoteRepository) -> None:
|
||||
"""Returns None when no quotes exist."""
|
||||
result = await repo.get_random()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRepositoryDelete:
|
||||
"""QuoteRepository.delete() removes records."""
|
||||
|
||||
async def test_delete_returns_quote(self, repo: QuoteRepository) -> None:
|
||||
"""Deleting a quote returns its snapshot."""
|
||||
created = await repo.create("Doomed", "tester")
|
||||
deleted = await repo.delete(created.id)
|
||||
assert deleted.id == created.id
|
||||
assert deleted.text == "Doomed"
|
||||
|
||||
async def test_delete_removes_from_db(self, repo: QuoteRepository) -> None:
|
||||
"""Deleted quote is no longer retrievable."""
|
||||
created = await repo.create("Doomed", "tester")
|
||||
await repo.delete(created.id)
|
||||
with pytest.raises(QuoteNotFoundError):
|
||||
await repo.get(created.id)
|
||||
|
||||
async def test_delete_not_found(self, repo: QuoteRepository) -> None:
|
||||
"""Raises QuoteNotFoundError for an unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError) as exc_info:
|
||||
await repo.delete(999)
|
||||
assert exc_info.value.quote_id == 999
|
||||
|
||||
|
||||
class TestRepositoryListAll:
|
||||
"""QuoteRepository.list_all() returns all quotes."""
|
||||
|
||||
async def test_list_all(self, repo: QuoteRepository) -> None:
|
||||
"""Returns all quotes ordered by ID."""
|
||||
await repo.create("First", "alice")
|
||||
await repo.create("Second", "bob")
|
||||
result = await repo.list_all()
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "First"
|
||||
assert result[1].text == "Second"
|
||||
|
||||
async def test_list_all_empty(self, repo: QuoteRepository) -> None:
|
||||
"""Returns an empty list when no quotes exist."""
|
||||
assert await repo.list_all() == []
|
||||
|
||||
|
||||
class TestQuoteManagerAddQuote:
|
||||
"""QuoteManager.add_quote() creates quotes."""
|
||||
|
||||
async def test_add_quote(self, manager: QuoteManager) -> None:
|
||||
"""add_quote creates a quote and returns its snapshot."""
|
||||
quote = await manager.add_quote("Test quote", "tester")
|
||||
assert quote.id == 1
|
||||
assert quote.text == "Test quote"
|
||||
assert quote.added_by == "tester"
|
||||
|
||||
|
||||
class TestQuoteManagerDeleteQuote:
|
||||
"""QuoteManager.delete_quote() removes quotes."""
|
||||
|
||||
async def test_delete_quote(self, manager: QuoteManager) -> None:
|
||||
"""delete_quote removes the quote and returns its snapshot."""
|
||||
created = await manager.add_quote("Doomed", "tester")
|
||||
deleted = await manager.delete_quote(created.id)
|
||||
assert deleted.id == created.id
|
||||
assert deleted.text == "Doomed"
|
||||
assert await manager.list_quotes() == []
|
||||
|
||||
async def test_delete_quote_not_found(self, manager: QuoteManager) -> None:
|
||||
"""delete_quote raises QuoteNotFoundError for unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError):
|
||||
await manager.delete_quote(999)
|
||||
|
||||
|
||||
class TestQuoteManagerGetQuote:
|
||||
"""QuoteManager.get_quote() fetches by ID."""
|
||||
|
||||
async def test_get_quote(self, manager: QuoteManager) -> None:
|
||||
"""get_quote returns the matching quote."""
|
||||
created = await manager.add_quote("Test", "tester")
|
||||
fetched = await manager.get_quote(created.id)
|
||||
assert fetched.id == created.id
|
||||
assert fetched.text == "Test"
|
||||
|
||||
async def test_get_quote_not_found(self, manager: QuoteManager) -> None:
|
||||
"""get_quote raises QuoteNotFoundError for unknown ID."""
|
||||
with pytest.raises(QuoteNotFoundError):
|
||||
await manager.get_quote(999)
|
||||
|
||||
|
||||
class TestQuoteManagerGetRandomQuote:
|
||||
"""QuoteManager.get_random_quote() fetches random quotes."""
|
||||
|
||||
async def test_get_random_quote(self, manager: QuoteManager) -> None:
|
||||
"""get_random_quote returns a quote when quotes exist."""
|
||||
await manager.add_quote("Only one", "tester")
|
||||
quote = await manager.get_random_quote()
|
||||
assert quote is not None
|
||||
assert quote.text == "Only one"
|
||||
|
||||
async def test_get_random_quote_empty(self, manager: QuoteManager) -> None:
|
||||
"""get_random_quote returns None when no quotes exist."""
|
||||
result = await manager.get_random_quote()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestQuoteManagerListQuotes:
|
||||
"""QuoteManager.list_quotes() returns all quotes."""
|
||||
|
||||
async def test_list_quotes(self, manager: QuoteManager) -> None:
|
||||
"""list_quotes returns all quotes ordered by ID."""
|
||||
await manager.add_quote("First", "alice")
|
||||
await manager.add_quote("Second", "bob")
|
||||
result = await manager.list_quotes()
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "First"
|
||||
assert result[1].text == "Second"
|
||||
|
||||
async def test_list_quotes_empty(self, manager: QuoteManager) -> None:
|
||||
"""list_quotes returns an empty list when no quotes exist."""
|
||||
assert await manager.list_quotes() == []
|
||||
|
||||
|
||||
class TestGetManager:
|
||||
"""get_manager() helper."""
|
||||
|
||||
def test_returns_manager(self) -> None:
|
||||
"""get_manager returns the QuoteManager from ctx.state."""
|
||||
ctx = make_module_context()
|
||||
repo_stub = QuoteRepository(ctx.storage)
|
||||
mgr = QuoteManager(ctx, repo_stub)
|
||||
ctx.state["manager"] = mgr
|
||||
assert get_manager(ctx) is mgr
|
||||
|
||||
def test_raises_if_not_initialized(self) -> None:
|
||||
"""get_manager raises RuntimeError when manager is missing."""
|
||||
ctx = make_module_context()
|
||||
with pytest.raises(RuntimeError, match="QuoteManager is not initialized"):
|
||||
get_manager(ctx)
|
||||
+130
-101
@@ -34,18 +34,18 @@ from aiohttp.test_utils import make_mocked_request
|
||||
from owlbot.api.context import RouteContext
|
||||
from owlbot.api.routes import RouteInfo, RouteMark, on_route
|
||||
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher, RouteRegistry
|
||||
from tests.conftest import make_module_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.context import ModuleContext
|
||||
|
||||
|
||||
def _make_dispatcher(
|
||||
module_context: ModuleContext,
|
||||
handler_timeout: float = 0.1,
|
||||
) -> RouteDispatcher:
|
||||
"""Build a RouteDispatcher with a controllable timeout."""
|
||||
"""Build a RouteDispatcher wired to the given ModuleContext."""
|
||||
return RouteDispatcher(
|
||||
get_module_context=lambda name: make_module_context(module_name=name),
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=handler_timeout,
|
||||
)
|
||||
|
||||
@@ -494,13 +494,13 @@ class TestRouteRegistry:
|
||||
class TestRouteDispatcherDispatch:
|
||||
"""Tests RouteDispatcher.dispatch() from owlbot.registries.routes."""
|
||||
|
||||
async def test_dispatch_dict_response(self) -> None:
|
||||
async def test_dispatch_dict_response(self, module_context: ModuleContext) -> None:
|
||||
"""Handler returning dict produces JSON 200 response."""
|
||||
|
||||
async def handler(ctx: RouteContext) -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/api", handler, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/api")
|
||||
@@ -511,13 +511,13 @@ class TestRouteDispatcherDispatch:
|
||||
assert response.status == HTTPStatus.OK
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
async def test_dispatch_web_response(self) -> None:
|
||||
async def test_dispatch_web_response(self, module_context: ModuleContext) -> None:
|
||||
"""Handler returning web.Response is passed through."""
|
||||
|
||||
async def handler(ctx: RouteContext) -> web.Response:
|
||||
return web.Response(text="hello", status=201)
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/page", handler, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/page")
|
||||
@@ -527,13 +527,13 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.CREATED
|
||||
|
||||
async def test_dispatch_none_response(self) -> None:
|
||||
async def test_dispatch_none_response(self, module_context: ModuleContext) -> None:
|
||||
"""Handler returning None produces 204 No Content."""
|
||||
|
||||
async def handler(ctx: RouteContext) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/hook", handler, methods=["POST"], module_name="mod")
|
||||
|
||||
request = make_mocked_request("POST", "/owlbot/mod/hook")
|
||||
@@ -543,9 +543,9 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.NO_CONTENT
|
||||
|
||||
async def test_dispatch_404(self) -> None:
|
||||
async def test_dispatch_404(self, module_context: ModuleContext) -> None:
|
||||
"""Dispatch to unregistered path returns 404."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/nope")
|
||||
request.match_info["module_name"] = "mod"
|
||||
@@ -554,9 +554,9 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.NOT_FOUND
|
||||
|
||||
async def test_dispatch_405(self) -> None:
|
||||
async def test_dispatch_405(self, module_context: ModuleContext) -> None:
|
||||
"""Dispatch with wrong method returns 405 with Allow header."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/data", _noop, methods=["GET"], module_name="mod")
|
||||
|
||||
request = make_mocked_request("POST", "/owlbot/mod/data")
|
||||
@@ -567,13 +567,15 @@ class TestRouteDispatcherDispatch:
|
||||
assert response.status == HTTPStatus.METHOD_NOT_ALLOWED
|
||||
assert "GET" in response.headers["Allow"]
|
||||
|
||||
async def test_dispatch_handler_exception(self) -> None:
|
||||
async def test_dispatch_handler_exception(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Handler that raises returns 500."""
|
||||
|
||||
async def bad_handler(ctx: RouteContext) -> web.Response:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/bad", bad_handler, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/bad")
|
||||
@@ -583,13 +585,15 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
async def test_dispatch_unsupported_return_type(self) -> None:
|
||||
async def test_dispatch_unsupported_return_type(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Handler returning unsupported type produces 500."""
|
||||
|
||||
async def bad_return(ctx: RouteContext) -> str:
|
||||
return "not a valid return type"
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/bad", bad_return, module_name="mod") # type: ignore[arg-type]
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/bad")
|
||||
@@ -599,14 +603,14 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
async def test_dispatch_timeout(self) -> None:
|
||||
async def test_dispatch_timeout(self, module_context: ModuleContext) -> None:
|
||||
"""Non-streaming handler exceeding timeout returns 500."""
|
||||
|
||||
async def slow(ctx: RouteContext) -> web.Response:
|
||||
await asyncio.sleep(0.3)
|
||||
return web.Response(text="done")
|
||||
|
||||
dispatcher = _make_dispatcher(handler_timeout=0.1)
|
||||
dispatcher = _make_dispatcher(module_context, handler_timeout=0.1)
|
||||
dispatcher.register("/slow", slow, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/slow")
|
||||
@@ -616,14 +620,16 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
async def test_dispatch_streaming_bypasses_timeout(self) -> None:
|
||||
async def test_dispatch_streaming_bypasses_timeout(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Streaming handler exceeding timeout is not cancelled."""
|
||||
|
||||
async def slow_stream(ctx: RouteContext) -> web.Response:
|
||||
await asyncio.sleep(0.3)
|
||||
return web.Response(text="done")
|
||||
|
||||
dispatcher = _make_dispatcher(handler_timeout=0.1)
|
||||
dispatcher = _make_dispatcher(module_context, handler_timeout=0.1)
|
||||
dispatcher.register("/stream", slow_stream, module_name="mod", streaming=True)
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/stream")
|
||||
@@ -633,13 +639,15 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.OK
|
||||
|
||||
async def test_dispatch_streaming_exception(self) -> None:
|
||||
async def test_dispatch_streaming_exception(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Streaming handler that raises returns 500."""
|
||||
|
||||
async def bad_stream(ctx: RouteContext) -> web.Response:
|
||||
raise RuntimeError("stream broke")
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/bad", bad_stream, module_name="mod", streaming=True)
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/bad")
|
||||
@@ -649,13 +657,15 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
async def test_dispatch_streaming_normal_return(self) -> None:
|
||||
async def test_dispatch_streaming_normal_return(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Streaming handler returning dict produces JSON response."""
|
||||
|
||||
async def fast_stream(ctx: RouteContext) -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/fast", fast_stream, module_name="mod", streaming=True)
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/fast")
|
||||
@@ -665,14 +675,14 @@ class TestRouteDispatcherDispatch:
|
||||
response = await dispatcher.dispatch(request)
|
||||
assert response.status == HTTPStatus.OK
|
||||
|
||||
async def test_dispatch_path_params(self) -> None:
|
||||
async def test_dispatch_path_params(self, module_context: ModuleContext) -> None:
|
||||
"""Path parameters are populated in RouteContext.match_info."""
|
||||
captured: list[dict[str, str]] = []
|
||||
|
||||
async def handler(ctx: RouteContext) -> None:
|
||||
captured.append(dict(ctx.match_info))
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/users/{id}", handler, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/users/42")
|
||||
@@ -683,13 +693,13 @@ class TestRouteDispatcherDispatch:
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["id"] == "42"
|
||||
|
||||
async def test_dispatch_root_path(self) -> None:
|
||||
async def test_dispatch_root_path(self, module_context: ModuleContext) -> None:
|
||||
"""Dispatch to module root (no path segment) works."""
|
||||
|
||||
async def handler(ctx: RouteContext) -> dict[str, str]:
|
||||
return {"root": "yes"}
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/", handler, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/")
|
||||
@@ -703,7 +713,9 @@ class TestRouteDispatcherDispatch:
|
||||
class TestRouteDispatcherDrainHandlers:
|
||||
"""Tests RouteDispatcher.drain_handlers() from owlbot.registries.routes."""
|
||||
|
||||
async def test_drain_handlers_cancels_active_handler(self) -> None:
|
||||
async def test_drain_handlers_cancels_active_handler(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""drain_handlers() cancels a running streaming handler."""
|
||||
entered = asyncio.Event()
|
||||
|
||||
@@ -712,7 +724,7 @@ class TestRouteDispatcherDrainHandlers:
|
||||
await asyncio.sleep(3600)
|
||||
return web.Response(text="done")
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register(
|
||||
"/stream", forever_stream, module_name="mod", streaming=True
|
||||
)
|
||||
@@ -733,19 +745,23 @@ class TestRouteDispatcherDrainHandlers:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
async def test_drain_handlers_noop_when_empty(self) -> None:
|
||||
async def test_drain_handlers_noop_when_empty(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""drain_handlers() with no active handlers completes immediately."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
await dispatcher.drain_handlers()
|
||||
assert len(dispatcher._streaming_tasks) == 0
|
||||
|
||||
async def test_streaming_task_cleaned_up_on_normal_return(self) -> None:
|
||||
async def test_streaming_task_cleaned_up_on_normal_return(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Streaming task is removed from tracking after normal completion."""
|
||||
|
||||
async def quick_stream(ctx: RouteContext) -> dict[str, str]:
|
||||
return {"ok": "true"}
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/quick", quick_stream, module_name="mod", streaming=True)
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/quick")
|
||||
@@ -755,13 +771,15 @@ class TestRouteDispatcherDrainHandlers:
|
||||
await dispatcher.dispatch(request)
|
||||
assert len(dispatcher._streaming_tasks) == 0
|
||||
|
||||
async def test_streaming_task_cleaned_up_on_exception(self) -> None:
|
||||
async def test_streaming_task_cleaned_up_on_exception(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Streaming task is removed from tracking after handler exception."""
|
||||
|
||||
async def bad_stream(ctx: RouteContext) -> web.Response:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/bad", bad_stream, module_name="mod", streaming=True)
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/bad")
|
||||
@@ -771,7 +789,9 @@ class TestRouteDispatcherDrainHandlers:
|
||||
await dispatcher.dispatch(request)
|
||||
assert len(dispatcher._streaming_tasks) == 0
|
||||
|
||||
async def test_drain_handlers_multiple_handlers(self) -> None:
|
||||
async def test_drain_handlers_multiple_handlers(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""drain_handlers() cancels all active streaming handlers."""
|
||||
entered = asyncio.Event()
|
||||
count = 0
|
||||
@@ -784,7 +804,7 @@ class TestRouteDispatcherDrainHandlers:
|
||||
await asyncio.sleep(3600)
|
||||
return web.Response(text="done")
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/s1", forever_stream, module_name="mod", streaming=True)
|
||||
dispatcher.register("/s2", forever_stream, module_name="mod", streaming=True)
|
||||
|
||||
@@ -808,13 +828,15 @@ class TestRouteDispatcherDrainHandlers:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task2
|
||||
|
||||
async def test_nonstreaming_task_tracked_and_cleaned_up(self) -> None:
|
||||
async def test_nonstreaming_task_tracked_and_cleaned_up(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Non-streaming handler tasks are tracked and cleaned up."""
|
||||
|
||||
async def handler(ctx: RouteContext) -> dict[str, str]:
|
||||
return {"ok": "true"}
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/api", handler, module_name="mod")
|
||||
|
||||
request = make_mocked_request("GET", "/owlbot/mod/api")
|
||||
@@ -824,7 +846,9 @@ class TestRouteDispatcherDrainHandlers:
|
||||
await dispatcher.dispatch(request)
|
||||
assert len(dispatcher._handler_tasks) == 0
|
||||
|
||||
async def test_drain_handlers_waits_for_nonstreaming_first(self) -> None:
|
||||
async def test_drain_handlers_waits_for_nonstreaming_first(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""drain_handlers() waits for non-streaming handlers before cancelling."""
|
||||
order: list[str] = []
|
||||
handler_entered = asyncio.Event()
|
||||
@@ -845,7 +869,7 @@ class TestRouteDispatcherDrainHandlers:
|
||||
raise
|
||||
return web.Response(text="done")
|
||||
|
||||
dispatcher = _make_dispatcher(handler_timeout=5.0)
|
||||
dispatcher = _make_dispatcher(module_context, handler_timeout=5.0)
|
||||
dispatcher.register("/api", slow_handler, module_name="mod")
|
||||
dispatcher.register(
|
||||
"/stream", forever_stream, module_name="mod", streaming=True
|
||||
@@ -875,37 +899,37 @@ class TestRouteDispatcherDrainHandlers:
|
||||
class TestRouteDispatcherDelegation:
|
||||
"""Tests RouteDispatcher delegation methods from owlbot.registries.routes."""
|
||||
|
||||
def test_unregister(self) -> None:
|
||||
def test_unregister(self, module_context: ModuleContext) -> None:
|
||||
"""RouteDispatcher.unregister() delegates to registry."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/x", _noop, module_name="mod")
|
||||
assert dispatcher.unregister("/owlbot/mod/x") is True
|
||||
assert dispatcher.unregister("/owlbot/mod/x") is False
|
||||
|
||||
def test_get_no_method(self) -> None:
|
||||
def test_get_no_method(self, module_context: ModuleContext) -> None:
|
||||
"""RouteDispatcher.get() without method returns list."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/x", _noop, module_name="mod")
|
||||
result = dispatcher.get("/owlbot/mod/x")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_with_method(self) -> None:
|
||||
def test_get_with_method(self, module_context: ModuleContext) -> None:
|
||||
"""RouteDispatcher.get() with method returns RouteInfo or None."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/x", _noop, module_name="mod")
|
||||
assert dispatcher.get("/owlbot/mod/x", method="GET") is not None
|
||||
assert dispatcher.get("/owlbot/mod/x", method="POST") is None
|
||||
|
||||
def test_get_by_module(self) -> None:
|
||||
def test_get_by_module(self, module_context: ModuleContext) -> None:
|
||||
"""RouteDispatcher.get_by_module() delegates to registry."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/a", _noop, module_name="mod_a")
|
||||
dispatcher.register("/b", _noop, module_name="mod_b")
|
||||
result = dispatcher.get_by_module("mod_a")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_register_from_module(self) -> None:
|
||||
def test_register_from_module(self, module_context: ModuleContext) -> None:
|
||||
"""RouteDispatcher.register_from_module() delegates to registry."""
|
||||
|
||||
@on_route("/scan")
|
||||
@@ -915,13 +939,13 @@ class TestRouteDispatcherDelegation:
|
||||
mod = types.ModuleType("fake")
|
||||
mod.handler = handler # type: ignore[attr-defined]
|
||||
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register_from_module(mod, "fake")
|
||||
assert len(dispatcher.get_by_module("fake")) == 1
|
||||
|
||||
def test_unregister_by_module(self) -> None:
|
||||
def test_unregister_by_module(self, module_context: ModuleContext) -> None:
|
||||
"""RouteDispatcher.unregister_by_module() delegates to registry."""
|
||||
dispatcher = _make_dispatcher()
|
||||
dispatcher = _make_dispatcher(module_context)
|
||||
dispatcher.register("/a", _noop, module_name="mod")
|
||||
dispatcher.register("/b", _noop, module_name="mod")
|
||||
assert dispatcher.unregister_by_module("mod") == 2
|
||||
@@ -932,75 +956,77 @@ class TestModuleRoutes:
|
||||
"""Tests ModuleRoutes from owlbot.registries.routes."""
|
||||
|
||||
def _make_mod_routes(
|
||||
self, *module_names: str
|
||||
self, module_context: ModuleContext, *module_names: str
|
||||
) -> tuple[RouteDispatcher, dict[str, ModuleRoutes]]:
|
||||
"""Build a dispatcher and ModuleRoutes wrappers for given modules."""
|
||||
dispatcher = _make_dispatcher(handler_timeout=5.0)
|
||||
dispatcher = _make_dispatcher(module_context, handler_timeout=5.0)
|
||||
mod_routes = {
|
||||
name: ModuleRoutes(dispatcher, name, "https://example.com")
|
||||
for name in module_names
|
||||
}
|
||||
return dispatcher, mod_routes
|
||||
|
||||
def test_register_auto_supplies_module_name(self) -> None:
|
||||
def test_register_auto_supplies_module_name(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""ModuleRoutes.register() auto-supplies module_name."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
info = mod_routes["mod_a"].register("/stats", _noop)
|
||||
assert info.module_name == "mod_a"
|
||||
assert info.full_path == "/owlbot/mod_a/stats"
|
||||
|
||||
def test_register_streaming(self) -> None:
|
||||
def test_register_streaming(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.register() passes streaming through."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
info = mod_routes["mod_a"].register("/sse", _noop, streaming=True)
|
||||
assert info.streaming is True
|
||||
|
||||
def test_register_methods(self) -> None:
|
||||
def test_register_methods(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.register() passes methods through."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
info = mod_routes["mod_a"].register("/data", _noop, methods=["GET", "POST"])
|
||||
assert info.methods == frozenset({"GET", "POST"})
|
||||
|
||||
def test_unregister_by_path(self) -> None:
|
||||
def test_unregister_by_path(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.unregister() removes all handlers at path."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
mod_routes["mod_a"].register("/stats", _noop)
|
||||
assert mod_routes["mod_a"].unregister("/stats") is True
|
||||
assert mod_routes["mod_a"].get("/stats") == []
|
||||
|
||||
def test_unregister_by_method(self) -> None:
|
||||
def test_unregister_by_method(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.unregister() with method removes only that handler."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
mod_routes["mod_a"].register("/data", _noop, methods=["GET"])
|
||||
mod_routes["mod_a"].register("/data", _noop, methods=["POST"])
|
||||
assert mod_routes["mod_a"].unregister("/data", method="GET") is True
|
||||
remaining = mod_routes["mod_a"].get("/data")
|
||||
assert len(remaining) == 1
|
||||
|
||||
def test_unregister_unknown(self) -> None:
|
||||
def test_unregister_unknown(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.unregister() returns False for unknown path."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
assert mod_routes["mod_a"].unregister("/nope") is False
|
||||
|
||||
def test_get_no_method(self) -> None:
|
||||
def test_get_no_method(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.get() without method returns list."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
mod_routes["mod_a"].register("/data", _noop, methods=["GET"])
|
||||
mod_routes["mod_a"].register("/data", _noop, methods=["POST"])
|
||||
result = mod_routes["mod_a"].get("/data")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_get_with_method(self) -> None:
|
||||
def test_get_with_method(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.get() with method returns RouteInfo or None."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
mod_routes["mod_a"].register("/data", _noop, methods=["GET"])
|
||||
assert mod_routes["mod_a"].get("/data", method="GET") is not None
|
||||
assert mod_routes["mod_a"].get("/data", method="POST") is None
|
||||
|
||||
def test_get_unknown_path(self) -> None:
|
||||
def test_get_unknown_path(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.get() for unknown path returns empty list."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
assert mod_routes["mod_a"].get("/nope") == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1014,13 +1040,14 @@ class TestModuleRoutes:
|
||||
)
|
||||
def test_exists(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
*,
|
||||
register: bool,
|
||||
check_method: str | None,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""ModuleRoutes.exists() checks for route presence."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
if register:
|
||||
mod_routes["mod_a"].register("/data", _noop)
|
||||
if check_method is not None:
|
||||
@@ -1028,30 +1055,30 @@ class TestModuleRoutes:
|
||||
else:
|
||||
assert mod_routes["mod_a"].exists("/data") is expected
|
||||
|
||||
def test_url_for(self) -> None:
|
||||
def test_url_for(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.url_for() builds correct public URL."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
url = mod_routes["mod_a"].url_for("/list")
|
||||
assert url == "https://example.com/owlbot/mod_a/list"
|
||||
|
||||
def test_url_for_normalizes_path(self) -> None:
|
||||
def test_url_for_normalizes_path(self, module_context: ModuleContext) -> None:
|
||||
"""ModuleRoutes.url_for() adds leading slash if missing."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
url = mod_routes["mod_a"].url_for("list")
|
||||
assert url == "https://example.com/owlbot/mod_a/list"
|
||||
|
||||
def test_module_routes_property(self) -> None:
|
||||
def test_module_routes_property(self, module_context: ModuleContext) -> None:
|
||||
"""module_routes returns only this module's routes."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a", "mod_b")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a", "mod_b")
|
||||
mod_routes["mod_a"].register("/a", _noop)
|
||||
mod_routes["mod_b"].register("/b", _noop)
|
||||
routes = mod_routes["mod_a"].module_routes
|
||||
assert len(routes) == 1
|
||||
assert routes[0].module_name == "mod_a"
|
||||
|
||||
def test_module_routes_property_empty(self) -> None:
|
||||
def test_module_routes_property_empty(self, module_context: ModuleContext) -> None:
|
||||
"""module_routes returns empty list when nothing registered."""
|
||||
_, mod_routes = self._make_mod_routes("mod_a")
|
||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||
assert mod_routes["mod_a"].module_routes == []
|
||||
|
||||
|
||||
@@ -1060,16 +1087,15 @@ class TestRouteContext:
|
||||
|
||||
def _make_route_context(
|
||||
self,
|
||||
) -> tuple[RouteContext, ModuleContext]:
|
||||
"""Build a RouteContext with stub parts."""
|
||||
module_ctx = make_module_context(module_name="mod_a")
|
||||
request = make_mocked_request("GET", "/owlbot/mod_a/test")
|
||||
ctx = RouteContext(
|
||||
module_ctx: ModuleContext,
|
||||
) -> RouteContext:
|
||||
"""Build a RouteContext wrapping the given ModuleContext."""
|
||||
request = make_mocked_request("GET", f"/owlbot/{module_ctx.module_name}/test")
|
||||
return RouteContext(
|
||||
request=request,
|
||||
module=module_ctx,
|
||||
match_info={"id": "42"},
|
||||
)
|
||||
return ctx, module_ctx
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prop", "use_is"),
|
||||
@@ -1087,30 +1113,33 @@ class TestRouteContext:
|
||||
pytest.param("templates", True, id="templates"),
|
||||
],
|
||||
)
|
||||
def test_property_proxying(self, prop: str, *, use_is: bool) -> None:
|
||||
def test_property_proxying(
|
||||
self, module_context: ModuleContext, prop: str, *, use_is: bool
|
||||
) -> None:
|
||||
"""RouteContext properties proxy to the underlying ModuleContext."""
|
||||
ctx, module_ctx = self._make_route_context()
|
||||
ctx = self._make_route_context(module_context)
|
||||
ctx_val = getattr(ctx, prop)
|
||||
mod_val = getattr(module_ctx, prop)
|
||||
mod_val = getattr(module_context, prop)
|
||||
if use_is:
|
||||
assert ctx_val is mod_val
|
||||
else:
|
||||
assert ctx_val == mod_val
|
||||
|
||||
def test_request_accessible(self) -> None:
|
||||
def test_request_accessible(self, module_context: ModuleContext) -> None:
|
||||
"""RouteContext.request is the aiohttp Request."""
|
||||
ctx, _ = self._make_route_context()
|
||||
ctx = self._make_route_context(module_context)
|
||||
assert ctx.request is not None
|
||||
assert ctx.request.method == "GET"
|
||||
|
||||
def test_match_info_accessible(self) -> None:
|
||||
def test_match_info_accessible(self, module_context: ModuleContext) -> None:
|
||||
"""RouteContext.match_info contains path parameters."""
|
||||
ctx, _ = self._make_route_context()
|
||||
ctx = self._make_route_context(module_context)
|
||||
assert ctx.match_info == {"id": "42"}
|
||||
|
||||
def test_match_info_defaults_empty(self) -> None:
|
||||
def test_match_info_defaults_empty(self, module_context: ModuleContext) -> None:
|
||||
"""RouteContext.match_info defaults to empty dict."""
|
||||
module_ctx = make_module_context(module_name="mod_a")
|
||||
request = make_mocked_request("GET", "/owlbot/mod_a/test")
|
||||
ctx = RouteContext(request=request, module=module_ctx)
|
||||
request = make_mocked_request(
|
||||
"GET", f"/owlbot/{module_context.module_name}/test"
|
||||
)
|
||||
ctx = RouteContext(request=request, module=module_context)
|
||||
assert ctx.match_info == {}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for owlbot.testing helper factory functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot import testing as testing_pkg
|
||||
from owlbot.api.event_types import (
|
||||
_EVENT_TYPE_MAP,
|
||||
ChatEvent,
|
||||
NameChangedEvent,
|
||||
StreamStartedEvent,
|
||||
StreamStatus,
|
||||
StreamStoppedEvent,
|
||||
StreamTitleUpdatedEvent,
|
||||
User,
|
||||
UserJoinedEvent,
|
||||
UserPartedEvent,
|
||||
VisibilityUpdateEvent,
|
||||
)
|
||||
from owlbot.testing import (
|
||||
make_chat_event,
|
||||
make_name_changed_event,
|
||||
make_stream_started_event,
|
||||
make_stream_status,
|
||||
make_stream_stopped_event,
|
||||
make_stream_title_updated_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
make_user_parted_event,
|
||||
make_visibility_update_event,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class TestMakeUser:
|
||||
"""Exercises the make_user() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid User with expected fields."""
|
||||
user = make_user()
|
||||
assert isinstance(user, User)
|
||||
assert user.id == "test-user-id"
|
||||
assert user.display_name == "TestUser"
|
||||
assert user.display_color == 0
|
||||
assert user.created_at is None
|
||||
assert user.previous_names == []
|
||||
assert user.name_changed_at is None
|
||||
assert user.is_bot is False
|
||||
assert user.is_authenticated is False
|
||||
assert user.scopes == frozenset()
|
||||
assert user.is_moderator is False
|
||||
|
||||
def test_is_authenticated_flag(self) -> None:
|
||||
"""is_authenticated=True sets is_authenticated on the User."""
|
||||
user = make_user(is_authenticated=True)
|
||||
assert user.is_authenticated is True
|
||||
|
||||
def test_is_moderator_shorthand(self) -> None:
|
||||
"""is_moderator=True adds MODERATOR to scopes."""
|
||||
user = make_user(is_moderator=True)
|
||||
assert user.is_moderator is True
|
||||
assert "MODERATOR" in user.scopes
|
||||
|
||||
def test_is_moderator_with_explicit_scopes(self) -> None:
|
||||
"""is_moderator=True unions with explicitly provided scopes."""
|
||||
user = make_user(is_moderator=True, scopes=frozenset({"CUSTOM"}))
|
||||
assert user.scopes == frozenset({"MODERATOR", "CUSTOM"})
|
||||
assert user.is_moderator is True
|
||||
|
||||
def test_explicit_scopes_without_is_moderator(self) -> None:
|
||||
"""Explicit scopes without is_moderator=True are used as-is."""
|
||||
user = make_user(scopes=frozenset({"CUSTOM"}))
|
||||
assert user.scopes == frozenset({"CUSTOM"})
|
||||
assert user.is_moderator is False
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
user = make_user(
|
||||
id="custom-id",
|
||||
display_name="CustomUser",
|
||||
display_color=5,
|
||||
is_bot=True,
|
||||
previous_names=["OldName"],
|
||||
)
|
||||
assert user.id == "custom-id"
|
||||
assert user.display_name == "CustomUser"
|
||||
assert user.display_color == 5
|
||||
assert user.is_bot is True
|
||||
assert user.previous_names == ["OldName"]
|
||||
|
||||
|
||||
class TestMakeChatEvent:
|
||||
"""Exercises the make_chat_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid ChatEvent."""
|
||||
event = make_chat_event()
|
||||
assert isinstance(event, ChatEvent)
|
||||
assert isinstance(event.user, User)
|
||||
assert event.body == "test message"
|
||||
assert event.raw_body == "test message"
|
||||
assert event.client_id == 0
|
||||
assert event.message_id == "test-msg-id"
|
||||
assert event.is_visible is True
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_body_defaults_to_raw_body(self) -> None:
|
||||
"""When body is unspecified, it mirrors the raw_body value."""
|
||||
event = make_chat_event(raw_body="custom body")
|
||||
assert event.raw_body == "custom body"
|
||||
assert event.body == "custom body"
|
||||
|
||||
def test_body_explicit(self) -> None:
|
||||
"""An explicit body overrides the raw_body mirror."""
|
||||
event = make_chat_event(raw_body="rendered", body="<p>rendered</p>")
|
||||
assert event.raw_body == "rendered"
|
||||
assert event.body == "<p>rendered</p>"
|
||||
|
||||
def test_custom_user(self) -> None:
|
||||
"""A custom User object is used instead of the default."""
|
||||
custom_user = make_user(display_name="Alice")
|
||||
event = make_chat_event(user=custom_user)
|
||||
assert event.user.display_name == "Alice"
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
ts = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
event = make_chat_event(
|
||||
raw_body="hello",
|
||||
client_id=42,
|
||||
message_id="msg-42",
|
||||
is_visible=False,
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.raw_body == "hello"
|
||||
assert event.body == "hello"
|
||||
assert event.client_id == 42
|
||||
assert event.message_id == "msg-42"
|
||||
assert event.is_visible is False
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeUserJoinedEvent:
|
||||
"""Exercises the make_user_joined_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid UserJoinedEvent."""
|
||||
event = make_user_joined_event()
|
||||
assert isinstance(event, UserJoinedEvent)
|
||||
assert isinstance(event.user, User)
|
||||
assert event.client_id == 0
|
||||
assert event.event_id == "test-join-id"
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
custom_user = make_user(display_name="Bob")
|
||||
ts = datetime(2026, 3, 1, tzinfo=UTC)
|
||||
event = make_user_joined_event(
|
||||
user=custom_user,
|
||||
client_id=7,
|
||||
event_id="custom-join",
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.user.display_name == "Bob"
|
||||
assert event.client_id == 7
|
||||
assert event.event_id == "custom-join"
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeUserPartedEvent:
|
||||
"""Exercises the make_user_parted_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid UserPartedEvent."""
|
||||
event = make_user_parted_event()
|
||||
assert isinstance(event, UserPartedEvent)
|
||||
assert isinstance(event.user, User)
|
||||
assert event.client_id == 0
|
||||
assert event.event_id == "test-part-id"
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
custom_user = make_user(display_name="Carol")
|
||||
ts = datetime(2026, 4, 1, tzinfo=UTC)
|
||||
event = make_user_parted_event(
|
||||
user=custom_user,
|
||||
client_id=9,
|
||||
event_id="custom-part",
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.user.display_name == "Carol"
|
||||
assert event.client_id == 9
|
||||
assert event.event_id == "custom-part"
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeNameChangedEvent:
|
||||
"""Exercises the make_name_changed_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid NameChangedEvent."""
|
||||
event = make_name_changed_event()
|
||||
assert isinstance(event, NameChangedEvent)
|
||||
assert isinstance(event.user, User)
|
||||
assert event.client_id == 0
|
||||
assert event.new_name == "NewName"
|
||||
assert event.event_id == "test-name-change-id"
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
custom_user = make_user(display_name="OldName")
|
||||
ts = datetime(2026, 5, 1, tzinfo=UTC)
|
||||
event = make_name_changed_event(
|
||||
user=custom_user,
|
||||
client_id=5,
|
||||
new_name="BrandNew",
|
||||
event_id="custom-name-change",
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.user.display_name == "OldName"
|
||||
assert event.new_name == "BrandNew"
|
||||
assert event.client_id == 5
|
||||
assert event.event_id == "custom-name-change"
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeStreamStartedEvent:
|
||||
"""Exercises the make_stream_started_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid StreamStartedEvent."""
|
||||
event = make_stream_started_event()
|
||||
assert isinstance(event, StreamStartedEvent)
|
||||
assert event.server_id == "test-server-id"
|
||||
assert event.server_name == "Test Server"
|
||||
assert event.stream_title == "Test Stream"
|
||||
assert event.summary == ""
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
ts = datetime(2026, 6, 1, tzinfo=UTC)
|
||||
event = make_stream_started_event(
|
||||
server_id="srv-42",
|
||||
server_name="My Server",
|
||||
stream_title="Playing Games",
|
||||
summary="A fun stream",
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.server_id == "srv-42"
|
||||
assert event.server_name == "My Server"
|
||||
assert event.stream_title == "Playing Games"
|
||||
assert event.summary == "A fun stream"
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeStreamStoppedEvent:
|
||||
"""Exercises the make_stream_stopped_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid StreamStoppedEvent."""
|
||||
event = make_stream_stopped_event()
|
||||
assert isinstance(event, StreamStoppedEvent)
|
||||
assert event.server_id == "test-server-id"
|
||||
assert event.server_name == "Test Server"
|
||||
assert event.stream_title == "Test Stream"
|
||||
assert event.summary == ""
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
ts = datetime(2026, 7, 1, tzinfo=UTC)
|
||||
event = make_stream_stopped_event(
|
||||
server_id="srv-99",
|
||||
server_name="Other Server",
|
||||
stream_title="Done Streaming",
|
||||
summary="It was great",
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.server_id == "srv-99"
|
||||
assert event.server_name == "Other Server"
|
||||
assert event.stream_title == "Done Streaming"
|
||||
assert event.summary == "It was great"
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeStreamStatus:
|
||||
"""Exercises the make_stream_status() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid StreamStatus."""
|
||||
status = make_stream_status()
|
||||
assert isinstance(status, StreamStatus)
|
||||
assert status.last_connect_time is None
|
||||
assert status.last_disconnect_time is None
|
||||
assert status.version_number == ""
|
||||
assert status.stream_title == "Test Stream"
|
||||
assert status.viewer_count == 0
|
||||
assert status.overall_max_viewer_count == 0
|
||||
assert status.session_max_viewer_count == 0
|
||||
assert status.is_online is False
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
connect_ts = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
disconnect_ts = datetime(2026, 1, 2, tzinfo=UTC)
|
||||
status = make_stream_status(
|
||||
last_connect_time=connect_ts,
|
||||
last_disconnect_time=disconnect_ts,
|
||||
version_number="0.2.0",
|
||||
stream_title="Playing Games",
|
||||
viewer_count=42,
|
||||
overall_max_viewer_count=100,
|
||||
session_max_viewer_count=75,
|
||||
is_online=True,
|
||||
)
|
||||
assert status.last_connect_time == connect_ts
|
||||
assert status.last_disconnect_time == disconnect_ts
|
||||
assert status.version_number == "0.2.0"
|
||||
assert status.stream_title == "Playing Games"
|
||||
assert status.viewer_count == 42
|
||||
assert status.overall_max_viewer_count == 100
|
||||
assert status.session_max_viewer_count == 75
|
||||
assert status.is_online is True
|
||||
|
||||
|
||||
class TestMakeStreamTitleUpdatedEvent:
|
||||
"""Exercises the make_stream_title_updated_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid StreamTitleUpdatedEvent."""
|
||||
event = make_stream_title_updated_event()
|
||||
assert isinstance(event, StreamTitleUpdatedEvent)
|
||||
assert event.server_id == "test-server-id"
|
||||
assert event.server_name == "Test Server"
|
||||
assert event.stream_title == "Test Stream"
|
||||
assert event.summary == ""
|
||||
assert event.status is None
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
ts = datetime(2026, 6, 15, tzinfo=UTC)
|
||||
status = StreamStatus(
|
||||
last_connect_time=ts,
|
||||
last_disconnect_time=None,
|
||||
version_number="0.2.0",
|
||||
stream_title="Playing Games",
|
||||
viewer_count=10,
|
||||
overall_max_viewer_count=50,
|
||||
session_max_viewer_count=20,
|
||||
is_online=True,
|
||||
)
|
||||
event = make_stream_title_updated_event(
|
||||
server_id="srv-42",
|
||||
server_name="My Server",
|
||||
stream_title="Playing Games",
|
||||
summary="A fun stream",
|
||||
status=status,
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.server_id == "srv-42"
|
||||
assert event.server_name == "My Server"
|
||||
assert event.stream_title == "Playing Games"
|
||||
assert event.summary == "A fun stream"
|
||||
assert event.status is status
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestMakeVisibilityUpdateEvent:
|
||||
"""Exercises the make_visibility_update_event() factory function."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
"""All default values produce a valid VisibilityUpdateEvent."""
|
||||
event = make_visibility_update_event()
|
||||
assert isinstance(event, VisibilityUpdateEvent)
|
||||
assert event.event_id == "test-vis-id"
|
||||
assert event.message_ids == []
|
||||
assert event.is_visible is False
|
||||
assert event.timestamp is None
|
||||
|
||||
def test_custom_overrides(self) -> None:
|
||||
"""All parameters can be overridden."""
|
||||
ts = datetime(2026, 8, 1, tzinfo=UTC)
|
||||
event = make_visibility_update_event(
|
||||
event_id="vis-42",
|
||||
message_ids=["msg-1", "msg-2"],
|
||||
is_visible=True,
|
||||
timestamp=ts,
|
||||
)
|
||||
assert event.event_id == "vis-42"
|
||||
assert event.message_ids == ["msg-1", "msg-2"]
|
||||
assert event.is_visible is True
|
||||
assert event.timestamp == ts
|
||||
|
||||
|
||||
class TestDefaultsMatchFromDict:
|
||||
"""Non-identifier factory defaults match each dataclass's from_dict({})."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "dataclass_cls", "identifier_fields"),
|
||||
[
|
||||
pytest.param(make_user, User, {"id", "display_name"}, id="user"),
|
||||
pytest.param(
|
||||
make_chat_event,
|
||||
ChatEvent,
|
||||
{"body", "raw_body", "message_id", "user"},
|
||||
id="chat-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_user_joined_event,
|
||||
UserJoinedEvent,
|
||||
{"event_id", "user"},
|
||||
id="user-joined-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_user_parted_event,
|
||||
UserPartedEvent,
|
||||
{"event_id", "user"},
|
||||
id="user-parted-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_name_changed_event,
|
||||
NameChangedEvent,
|
||||
{"event_id", "new_name", "user"},
|
||||
id="name-changed-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_stream_started_event,
|
||||
StreamStartedEvent,
|
||||
{"server_id", "server_name", "stream_title"},
|
||||
id="stream-started-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_stream_stopped_event,
|
||||
StreamStoppedEvent,
|
||||
{"server_id", "server_name", "stream_title"},
|
||||
id="stream-stopped-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_stream_status,
|
||||
StreamStatus,
|
||||
{"stream_title"},
|
||||
id="stream-status",
|
||||
),
|
||||
pytest.param(
|
||||
make_stream_title_updated_event,
|
||||
StreamTitleUpdatedEvent,
|
||||
{"server_id", "server_name", "stream_title"},
|
||||
id="stream-title-updated-event",
|
||||
),
|
||||
pytest.param(
|
||||
make_visibility_update_event,
|
||||
VisibilityUpdateEvent,
|
||||
{"event_id"},
|
||||
id="visibility-update-event",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_defaults_match(
|
||||
self,
|
||||
factory: Callable[..., Any],
|
||||
dataclass_cls: Any,
|
||||
identifier_fields: set[str],
|
||||
) -> None:
|
||||
"""Every non-identifier default must equal dataclass_cls.from_dict({})."""
|
||||
from_factory = factory()
|
||||
from_empty_dict = dataclass_cls.from_dict({})
|
||||
for field in dataclasses.fields(dataclass_cls):
|
||||
if field.name in identifier_fields:
|
||||
continue
|
||||
assert getattr(from_factory, field.name) == getattr(
|
||||
from_empty_dict, field.name
|
||||
), f"{dataclass_cls.__name__}.{field.name}"
|
||||
|
||||
|
||||
class TestEventTypeFactoryCoverage:
|
||||
"""Guards against EventType additions without a matching owlbot.testing factory."""
|
||||
|
||||
def test_every_event_type_has_a_factory(self) -> None:
|
||||
"""Every EventType must map to a ``make_<snake_case_dataclass>`` factory.
|
||||
|
||||
Derives the expected factory name from the dataclass name
|
||||
(``StreamStartedEvent`` -> ``make_stream_started_event``) so no
|
||||
separate lookup table has to stay in sync.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
for event_type, dataclass_cls in _EVENT_TYPE_MAP.items():
|
||||
snake = re.sub(r"(?<!^)(?=[A-Z])", "_", dataclass_cls.__name__).lower()
|
||||
factory_name = f"make_{snake}"
|
||||
if not hasattr(testing_pkg, factory_name):
|
||||
missing.append(
|
||||
f"{event_type.name} -> {dataclass_cls.__name__}: "
|
||||
f"no {factory_name!r} in owlbot.testing"
|
||||
)
|
||||
assert not missing, "Missing factories:\n " + "\n ".join(missing)
|
||||
@@ -0,0 +1,620 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for owlbot.testing.plugin fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from aiohttp import ClientConnectionError
|
||||
|
||||
from owlbot.api.config import Config, ModuleConfig
|
||||
from owlbot.api.event_types import EventType
|
||||
from owlbot.api.events import Priority
|
||||
from owlbot.api.http_client import HttpClient
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
from owlbot.api.templates import ModuleTemplates
|
||||
from owlbot.builtin_modules import quotes as quotes_pkg
|
||||
from owlbot.registries.events import EventDispatcher
|
||||
from owlbot.testing import (
|
||||
make_chat_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
)
|
||||
from owlbot.testing.stubs import (
|
||||
RecordingOwncastAdminClient,
|
||||
RecordingOwncastClient,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owlbot.api.context import CommandContext, EventContext, ModuleContext
|
||||
from owlbot.api.event_types import UserJoinedEvent
|
||||
from owlbot.registries.commands import CommandDispatcher
|
||||
from owlbot.registries.routes import RouteDispatcher
|
||||
|
||||
|
||||
class TestConfigurationFixtures:
|
||||
"""Verify default values of overridable configuration fixtures."""
|
||||
|
||||
def test_module_pkg_default(self, module_pkg: ModuleType | None) -> None:
|
||||
"""module_pkg defaults to None."""
|
||||
assert module_pkg is None
|
||||
|
||||
def test_admin_client_enabled_default(self, *, admin_client_enabled: bool) -> None:
|
||||
"""admin_client_enabled defaults to False."""
|
||||
assert admin_client_enabled is False
|
||||
|
||||
def test_command_prefix_default(self, command_prefix: str) -> None:
|
||||
"""command_prefix defaults to '!'."""
|
||||
assert command_prefix == "!"
|
||||
|
||||
def test_handler_timeout_default(self, handler_timeout: float) -> None:
|
||||
"""handler_timeout defaults to 30.0."""
|
||||
assert handler_timeout == 30.0
|
||||
|
||||
def test_config_data_default(self, config_data: dict[str, Any]) -> None:
|
||||
"""Config_data seeds owncast.url and a public_base_url matching Owlbot."""
|
||||
assert config_data == {
|
||||
"owncast": {"url": "http://localhost:8080"},
|
||||
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||
}
|
||||
|
||||
|
||||
class TestConfigFixtures:
|
||||
"""Verify the config_path / config fixture chain."""
|
||||
|
||||
def test_config_path_file_exists(self, config_path: Path) -> None:
|
||||
"""config_path writes the seed YAML to a real file."""
|
||||
assert config_path.is_file()
|
||||
assert config_path.read_text().strip()
|
||||
|
||||
def test_config_is_real_instance(self, config: Config) -> None:
|
||||
"""Config fixture returns a real Config backed by config_path."""
|
||||
assert isinstance(config, Config)
|
||||
assert config.owncast_url == "http://localhost:8080"
|
||||
|
||||
def test_module_config_set_persists_to_disk(
|
||||
self, module_config: ModuleConfig, config_path: Path
|
||||
) -> None:
|
||||
"""module_config.set writes through to the backing YAML file."""
|
||||
module_config.set("greeting", "hi")
|
||||
assert "greeting: hi" in config_path.read_text()
|
||||
|
||||
|
||||
class TestConfigDataOverride:
|
||||
"""Overriding config_data flows into the real Config + ModuleConfig."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_data(self) -> dict[str, Any]:
|
||||
"""Override config_data to set a custom public_base_url."""
|
||||
return {
|
||||
"owncast": {"url": "http://localhost:8080"},
|
||||
"owlbot": {"public_base_url": "https://bot.example.com"},
|
||||
}
|
||||
|
||||
def test_module_config_reads_override(self, module_config: ModuleConfig) -> None:
|
||||
"""module_config.public_base_url reflects the seeded YAML value."""
|
||||
assert module_config.public_base_url == "https://bot.example.com"
|
||||
|
||||
def test_module_context_routes_use_override(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""ModuleRoutes.url_for builds URLs from the overridden base."""
|
||||
assert module_context.routes.url_for("list").startswith(
|
||||
"https://bot.example.com/"
|
||||
)
|
||||
|
||||
|
||||
class TestOwncastClientBaseUrl:
|
||||
"""owncast_client.base_url tracks config.owncast_url, including overrides."""
|
||||
|
||||
def test_default_matches_default_config(
|
||||
self, owncast_client: RecordingOwncastClient, config: Config
|
||||
) -> None:
|
||||
"""With default config_data, stub base_url equals config.owncast_url."""
|
||||
assert owncast_client.base_url == config.owncast_url
|
||||
|
||||
class TestWithOverride:
|
||||
"""Overriding config_data.owncast.url propagates to the stub."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_data(self) -> dict[str, Any]:
|
||||
"""Override owncast.url to a distinct value."""
|
||||
return {
|
||||
"owncast": {"url": "https://stream.example.com"},
|
||||
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||
}
|
||||
|
||||
def test_stub_base_url_follows_override(
|
||||
self, owncast_client: RecordingOwncastClient
|
||||
) -> None:
|
||||
"""Stub base_url reflects the overridden owncast.url."""
|
||||
assert owncast_client.base_url == "https://stream.example.com"
|
||||
|
||||
class TestTrailingSlashStripped:
|
||||
"""Stub strips trailing slashes from owncast_url, matching the real client."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_data(self) -> dict[str, Any]:
|
||||
"""Seed owncast.url with a trailing slash."""
|
||||
return {
|
||||
"owncast": {"url": "https://stream.example.com/"},
|
||||
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||
}
|
||||
|
||||
def test_stub_strips_trailing_slash(
|
||||
self, owncast_client: RecordingOwncastClient
|
||||
) -> None:
|
||||
"""Stub base_url drops the trailing slash like OwncastClient does."""
|
||||
assert owncast_client.base_url == "https://stream.example.com"
|
||||
|
||||
|
||||
class TestModulePkgDerivation:
|
||||
"""Overriding module_pkg auto-wires derived fixtures."""
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg(self) -> ModuleType:
|
||||
"""Override module_pkg with the quotes package."""
|
||||
return quotes_pkg
|
||||
|
||||
def test_module_name_derives_from_module_pkg(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""module_context.module_name is the last segment of the package name."""
|
||||
assert module_context.module_name == "quotes"
|
||||
|
||||
def test_templates_renders_module_template(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""module_context.templates resolves module-specific files."""
|
||||
page = module_context.templates.render("list.html", quotes=[])
|
||||
assert "No quotes have been added yet." in page
|
||||
|
||||
|
||||
class TestTemplatesFallsBackToCore:
|
||||
"""A module without its own templates/ directory still renders core templates."""
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg(self, tmp_path: Any) -> ModuleType:
|
||||
"""Override module_pkg with a fake package pointing at an empty tmp_path."""
|
||||
fake = types.ModuleType("fake_module_no_templates")
|
||||
fake.__file__ = str(tmp_path / "__init__.py")
|
||||
return fake
|
||||
|
||||
def test_renders_core_template(self, templates: ModuleTemplates) -> None:
|
||||
"""base.html resolves from the core templates directory."""
|
||||
assert templates.render("base.html")
|
||||
|
||||
|
||||
class TestFixturesValidateConfiguration:
|
||||
"""Fixtures raise pytest.UsageError when their prerequisite knobs are unset.
|
||||
|
||||
Each test spins up a nested pytest run via ``pytester`` so the failure
|
||||
surfaces through the real fixture resolution path instead of calling the
|
||||
fixture function directly.
|
||||
"""
|
||||
|
||||
def _run_plugin_test(
|
||||
self, pytester: pytest.Pytester, body: str, *, asyncio_mode: bool = False
|
||||
) -> pytest.RunResult:
|
||||
"""Run a one-test pytester session that loads the Owlbot testing plugin."""
|
||||
ini = "[pytest]\n"
|
||||
if asyncio_mode:
|
||||
ini += "asyncio_mode = auto\n"
|
||||
pytester.makeini(ini)
|
||||
pytester.makeconftest('pytest_plugins = ["owlbot.testing.plugin"]\n')
|
||||
pytester.makepyfile(body)
|
||||
return pytester.runpytest()
|
||||
|
||||
def test_registered_handlers_raises_without_module_pkg(
|
||||
self, pytester: pytest.Pytester
|
||||
) -> None:
|
||||
"""registered_handlers raises pytest.UsageError when module_pkg is None."""
|
||||
result = self._run_plugin_test(
|
||||
pytester,
|
||||
"""
|
||||
def test_uses_handlers(registered_handlers):
|
||||
pass
|
||||
""",
|
||||
)
|
||||
result.assert_outcomes(errors=1)
|
||||
assert "module_pkg" in result.stdout.str()
|
||||
|
||||
def test_module_lifecycle_raises_without_module_pkg(
|
||||
self, pytester: pytest.Pytester
|
||||
) -> None:
|
||||
"""module_lifecycle raises pytest.UsageError when module_pkg is None."""
|
||||
result = self._run_plugin_test(
|
||||
pytester,
|
||||
"""
|
||||
async def test_uses_lifecycle(module_lifecycle):
|
||||
pass
|
||||
""",
|
||||
asyncio_mode=True,
|
||||
)
|
||||
result.assert_outcomes(errors=1)
|
||||
assert "module_pkg" in result.stdout.str()
|
||||
|
||||
def test_admin_client_raises_when_disabled(self, pytester: pytest.Pytester) -> None:
|
||||
"""admin_client raises pytest.UsageError when admin_client_enabled is False."""
|
||||
result = self._run_plugin_test(
|
||||
pytester,
|
||||
"""
|
||||
def test_uses_admin_client(admin_client):
|
||||
pass
|
||||
""",
|
||||
)
|
||||
result.assert_outcomes(errors=1)
|
||||
assert "admin_client_enabled" in result.stdout.str()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("registered_handlers")
|
||||
class TestRegisteredHandlersWithPkg:
|
||||
"""With module_pkg set, registered_handlers registers everything."""
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg(self) -> ModuleType:
|
||||
"""Override module_pkg with the quotes package."""
|
||||
return quotes_pkg
|
||||
|
||||
def test_commands_registered(self, command_dispatcher: CommandDispatcher) -> None:
|
||||
"""Quotes module commands are registered."""
|
||||
registered = command_dispatcher.get_by_module("quotes")
|
||||
assert "quote" in registered
|
||||
|
||||
def test_routes_registered(self, route_dispatcher: RouteDispatcher) -> None:
|
||||
"""Quotes module routes are registered."""
|
||||
assert route_dispatcher.get_by_module("quotes") != []
|
||||
|
||||
def test_state_not_populated(self, module_context: ModuleContext) -> None:
|
||||
"""registered_handlers skips @on_setup; ctx.state stays empty."""
|
||||
assert "manager" not in module_context.state
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestModuleLifecycleWithPkg:
|
||||
"""With module_pkg set, module_lifecycle runs handlers + @on_setup."""
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg(self) -> ModuleType:
|
||||
"""Override module_pkg with the quotes package."""
|
||||
return quotes_pkg
|
||||
|
||||
def test_commands_registered(self, command_dispatcher: CommandDispatcher) -> None:
|
||||
"""Decorator-registered commands are wired (via registered_handlers)."""
|
||||
assert "quote" in command_dispatcher.get_by_module("quotes")
|
||||
|
||||
def test_setup_populates_state(self, module_context: ModuleContext) -> None:
|
||||
"""@on_setup ran and populated ctx.state."""
|
||||
assert module_context.state.get("manager") is not None
|
||||
|
||||
|
||||
class TestBuildingBlockFixtures:
|
||||
"""Verify building block fixtures produce usable instances."""
|
||||
|
||||
async def test_storage_is_open_and_usable(self, storage: ModuleStorage) -> None:
|
||||
"""Storage fixture yields an open in-memory database."""
|
||||
await storage.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)")
|
||||
await storage.execute("INSERT INTO t (val) VALUES (?)", ("hello",))
|
||||
row = await storage.fetch_one("SELECT val FROM t WHERE id = 1")
|
||||
assert row is not None
|
||||
assert row[0] == "hello"
|
||||
|
||||
def test_owncast_client_is_recording(
|
||||
self, owncast_client: RecordingOwncastClient
|
||||
) -> None:
|
||||
"""owncast_client fixture returns a RecordingOwncastClient with empty calls."""
|
||||
assert isinstance(owncast_client, RecordingOwncastClient)
|
||||
assert owncast_client.calls == []
|
||||
|
||||
def test_module_config_is_real_instance(self, module_config: ModuleConfig) -> None:
|
||||
"""module_config fixture returns a real ModuleConfig with enabled=True."""
|
||||
assert isinstance(module_config, ModuleConfig)
|
||||
assert module_config.enabled is True
|
||||
|
||||
|
||||
class TestModuleContext:
|
||||
"""Verify the module_context fixture wires everything together."""
|
||||
|
||||
def test_module_name_defaults_to_test_module(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""module_context.module_name is 'test_module' when no module_pkg is set."""
|
||||
assert module_context.module_name == "test_module"
|
||||
|
||||
def test_storage_is_wired(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.storage is a ModuleStorage instance."""
|
||||
assert isinstance(module_context.storage, ModuleStorage)
|
||||
|
||||
def test_owncast_client_is_recording(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.owncast_client is a RecordingOwncastClient."""
|
||||
assert isinstance(module_context.owncast_client, RecordingOwncastClient)
|
||||
|
||||
def test_config_is_real_module_config(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.config is a real ModuleConfig."""
|
||||
assert isinstance(module_context.config, ModuleConfig)
|
||||
|
||||
def test_admin_client_none_by_default(self, module_context: ModuleContext) -> None:
|
||||
"""admin_client is None when admin_client_enabled is False."""
|
||||
assert module_context.admin_client is None
|
||||
|
||||
def test_templates_is_core_only_renderer_by_default(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""module_context.templates is a core-only renderer without module_pkg."""
|
||||
assert isinstance(module_context.templates, ModuleTemplates)
|
||||
# Core layout should resolve even without a module package.
|
||||
assert module_context.templates.env.get_template("base.html") is not None
|
||||
|
||||
def test_state_is_empty_dict(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.state starts as an empty dict."""
|
||||
assert module_context.state == {}
|
||||
|
||||
def test_commands_wired(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.commands is not None."""
|
||||
assert module_context.commands is not None
|
||||
|
||||
def test_events_wired(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.events is not None."""
|
||||
assert module_context.events is not None
|
||||
|
||||
def test_routes_wired(self, module_context: ModuleContext) -> None:
|
||||
"""module_context.routes is not None."""
|
||||
assert module_context.routes is not None
|
||||
|
||||
def test_event_dispatcher_available(
|
||||
self, event_dispatcher: EventDispatcher
|
||||
) -> None:
|
||||
"""event_dispatcher fixture is an EventDispatcher instance."""
|
||||
assert isinstance(event_dispatcher, EventDispatcher)
|
||||
|
||||
|
||||
class TestAdminClientEnabled:
|
||||
"""Tests that admin_client_enabled=True wires the admin client."""
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client_enabled(self) -> bool:
|
||||
"""Override admin_client_enabled to True."""
|
||||
return True
|
||||
|
||||
def test_admin_client_wired(self, module_context: ModuleContext) -> None:
|
||||
"""admin_client is a RecordingOwncastAdminClient when enabled."""
|
||||
assert module_context.admin_client is not None
|
||||
assert isinstance(module_context.admin_client, RecordingOwncastAdminClient)
|
||||
|
||||
|
||||
class TestHttpFixtures:
|
||||
"""Tests for the http and mocked_http fixtures."""
|
||||
|
||||
async def test_http_is_httpclient(self, http: HttpClient) -> None:
|
||||
"""The http fixture yields a started HttpClient."""
|
||||
assert isinstance(http, HttpClient)
|
||||
assert not http.session.closed
|
||||
|
||||
async def test_module_context_http_is_wired(
|
||||
self, module_context: ModuleContext, http: HttpClient
|
||||
) -> None:
|
||||
"""module_context.http is the same HttpClient as the http fixture."""
|
||||
assert module_context.http is http
|
||||
|
||||
async def test_mocked_response_is_returned(
|
||||
self, http: HttpClient, mocked_http: aioresponses
|
||||
) -> None:
|
||||
"""Registered URLs return the canned response."""
|
||||
mocked_http.get("https://example.test/data", payload={"ok": True})
|
||||
async with http.get("https://example.test/data") as resp:
|
||||
assert resp.status == 200
|
||||
assert await resp.json() == {"ok": True}
|
||||
|
||||
async def test_unregistered_url_raises_connection_error(
|
||||
self, http: HttpClient
|
||||
) -> None:
|
||||
"""Unregistered URLs raise ClientConnectionError rather than hitting the net."""
|
||||
with pytest.raises(ClientConnectionError):
|
||||
async with http.get("https://unregistered.test/data"):
|
||||
pass
|
||||
|
||||
|
||||
class TestEventDispatcher:
|
||||
"""Integration tests exercising the full event dispatch pipeline."""
|
||||
|
||||
async def test_command_dispatch_through_events(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""Dispatch a CHAT event for a registered command; verify it ran."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
await ctx.owncast_client.send_message("pong")
|
||||
|
||||
module_context.commands.register("ping", handler)
|
||||
|
||||
event = make_chat_event(raw_body="!ping")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
|
||||
assert len(owncast_client.calls) == 1
|
||||
assert owncast_client.calls[0][0] == "send_message"
|
||||
assert owncast_client.calls[0][1]["body"] == "pong"
|
||||
|
||||
async def test_event_handler_receives_event(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""Register an event handler for USER_JOINED, verify it receives the event."""
|
||||
received: list[UserJoinedEvent] = []
|
||||
|
||||
async def handler(ctx: EventContext[UserJoinedEvent]) -> None:
|
||||
received.append(ctx.event)
|
||||
|
||||
module_context.events.register(handler, (EventType.USER_JOINED,))
|
||||
|
||||
event = make_user_joined_event()
|
||||
await event_dispatcher.dispatch(EventType.USER_JOINED, event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0] is event
|
||||
|
||||
async def test_propagation_stops_command_dispatch(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""A HIGHEST-priority event handler that stops propagation blocks commands."""
|
||||
|
||||
async def blocker(ctx: EventContext[object]) -> None:
|
||||
ctx.stop_propagation("blocked")
|
||||
|
||||
module_context.events.register(
|
||||
blocker, (EventType.CHAT,), priority=Priority.HIGHEST
|
||||
)
|
||||
|
||||
async def cmd_handler(ctx: CommandContext) -> None:
|
||||
await ctx.owncast_client.send_message("should not run")
|
||||
|
||||
module_context.commands.register("test", cmd_handler)
|
||||
|
||||
event = make_chat_event(raw_body="!test")
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
|
||||
assert owncast_client.calls == []
|
||||
|
||||
async def test_permission_denied_sends_system_message(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
) -> None:
|
||||
"""Authenticated-only command from unauth user sends denial."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
await ctx.owncast_client.send_message("should not run")
|
||||
|
||||
module_context.commands.register("secret", handler, requires_authenticated=True)
|
||||
|
||||
user = make_user(is_authenticated=False)
|
||||
event = make_chat_event(raw_body="!secret", user=user, client_id=42)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
|
||||
assert len(owncast_client.calls) == 1
|
||||
assert owncast_client.calls[0][0] == "send_system_message_to_client"
|
||||
|
||||
|
||||
class TestRouteClient:
|
||||
"""Integration tests exercising the route_client fixture."""
|
||||
|
||||
async def test_get_registered_route(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""GET a registered route; verify 200 and JSON body."""
|
||||
|
||||
async def handler(ctx: Any) -> dict[str, int]:
|
||||
return {"total": 42}
|
||||
|
||||
module_context.routes.register("/stats", handler, methods=["GET"])
|
||||
|
||||
resp = await route_client.get("/owlbot/test_module/stats")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body == {"total": 42}
|
||||
|
||||
async def test_unregistered_route_returns_404(
|
||||
self,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""GET a path with no registered handler; verify 404."""
|
||||
resp = await route_client.get("/owlbot/test_module/nonexistent")
|
||||
assert resp.status == 404
|
||||
|
||||
async def test_module_root_reachable_without_trailing_slash(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""A root-registered handler is reachable via /owlbot/{module_name}.
|
||||
|
||||
Production registers two aiohttp routes for the catch-all so that
|
||||
root handlers respond to both ``/owlbot/{module_name}`` and
|
||||
``/owlbot/{module_name}/``; the plugin must mirror that or modules
|
||||
with root routes will silently 404 in tests but work in production.
|
||||
"""
|
||||
|
||||
async def handler(ctx: Any) -> dict[str, str]:
|
||||
return {"status": "root"}
|
||||
|
||||
module_context.routes.register("/", handler, methods=["GET"])
|
||||
|
||||
resp = await route_client.get("/owlbot/test_module")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body == {"status": "root"}
|
||||
|
||||
async def test_post_route(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""POST to a registered route; verify 200."""
|
||||
|
||||
async def handler(ctx: Any) -> dict[str, str]:
|
||||
return {"status": "created"}
|
||||
|
||||
module_context.routes.register("/items", handler, methods=["POST"])
|
||||
|
||||
resp = await route_client.post("/owlbot/test_module/items")
|
||||
assert resp.status == 200
|
||||
|
||||
async def test_method_not_allowed(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""Register a POST-only route, GET it; verify 405."""
|
||||
|
||||
async def handler(ctx: Any) -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
module_context.routes.register("/submit", handler, methods=["POST"])
|
||||
|
||||
resp = await route_client.get("/owlbot/test_module/submit")
|
||||
assert resp.status == 405
|
||||
|
||||
async def test_none_response_returns_204(
|
||||
self,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient[Any, Any],
|
||||
) -> None:
|
||||
"""Handler returning None; verify 204."""
|
||||
|
||||
async def handler(ctx: Any) -> None:
|
||||
return None
|
||||
|
||||
module_context.routes.register("/noop", handler, methods=["GET"])
|
||||
|
||||
resp = await route_client.get("/owlbot/test_module/noop")
|
||||
assert resp.status == 204
|
||||
@@ -0,0 +1,392 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for owlbot.testing recording stubs and stub config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any, get_origin, get_type_hints
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot.api.owncast_admin_client import OwncastAdminClient
|
||||
from owlbot.api.owncast_client import OwncastClient
|
||||
from owlbot.testing.stubs import (
|
||||
RecordedCall,
|
||||
RecordingOwncastAdminClient,
|
||||
RecordingOwncastClient,
|
||||
)
|
||||
|
||||
|
||||
def _public_async_methods(cls: type) -> set[str]:
|
||||
"""Return the set of public (non-underscore) async method names on cls."""
|
||||
return {
|
||||
name
|
||||
for name, method in inspect.getmembers(cls, predicate=inspect.isfunction)
|
||||
if not name.startswith("_") and inspect.iscoroutinefunction(method)
|
||||
}
|
||||
|
||||
|
||||
def _synthesize_value(param_name: str, annotation: Any) -> Any:
|
||||
"""Build a distinct, type-appropriate value for a parameter.
|
||||
|
||||
Values differ per parameter so that a transposed key or value inside a
|
||||
stub's ``RecordedCall`` dict literal is caught by equality against the
|
||||
originally-passed kwargs.
|
||||
"""
|
||||
origin = get_origin(annotation)
|
||||
if origin is list:
|
||||
return []
|
||||
if origin is dict:
|
||||
return {}
|
||||
if annotation is str:
|
||||
return f"stub:{param_name}"
|
||||
if annotation is int:
|
||||
return hash(param_name) & 0xFFFF
|
||||
if annotation is bool:
|
||||
return True
|
||||
msg = f"Unsupported annotation {annotation!r} for parameter {param_name!r}"
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "stub"),
|
||||
[
|
||||
pytest.param(OwncastClient, RecordingOwncastClient, id="owncast-client"),
|
||||
pytest.param(
|
||||
OwncastAdminClient, RecordingOwncastAdminClient, id="owncast-admin-client"
|
||||
),
|
||||
],
|
||||
)
|
||||
class TestStubClientParity:
|
||||
"""Guards against drift between real clients and their recording stubs.
|
||||
|
||||
These tests fail when the real client gains, loses, or changes a public
|
||||
async method without a matching update to the stub. The stub is meant
|
||||
to be a hand-written 1:1 mirror; CI enforces that invariant here.
|
||||
"""
|
||||
|
||||
def test_stub_covers_every_public_async_method(
|
||||
self, source: type, stub: type
|
||||
) -> None:
|
||||
"""Every public async method on the source must exist on the stub."""
|
||||
missing = _public_async_methods(source) - _public_async_methods(stub)
|
||||
assert not missing, f"Stub missing methods: {sorted(missing)}"
|
||||
|
||||
def test_stub_has_no_extra_public_async_methods(
|
||||
self, source: type, stub: type
|
||||
) -> None:
|
||||
"""The stub must not expose methods the real client does not have."""
|
||||
extra = _public_async_methods(stub) - _public_async_methods(source)
|
||||
assert not extra, f"Stub has extra methods: {sorted(extra)}"
|
||||
|
||||
def test_stub_signatures_match(self, source: type, stub: type) -> None:
|
||||
"""Each stub method's signature must match the source's exactly."""
|
||||
mismatches: list[str] = []
|
||||
for name in sorted(_public_async_methods(source)):
|
||||
source_sig = inspect.signature(getattr(source, name))
|
||||
stub_sig = inspect.signature(getattr(stub, name))
|
||||
if source_sig != stub_sig:
|
||||
mismatches.append(
|
||||
f" {name}:\n source: {source_sig}\n stub: {stub_sig}"
|
||||
)
|
||||
assert not mismatches, "Signature mismatches:\n" + "\n".join(mismatches)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "stub"),
|
||||
[
|
||||
pytest.param(OwncastClient, RecordingOwncastClient, id="owncast-client"),
|
||||
pytest.param(
|
||||
OwncastAdminClient, RecordingOwncastAdminClient, id="owncast-admin-client"
|
||||
),
|
||||
],
|
||||
)
|
||||
class TestStubRecordingFidelity:
|
||||
"""Ensures each stub method records the exact args it received.
|
||||
|
||||
``TestStubClientParity`` guards method signatures. These tests guard the
|
||||
hand-written ``RecordedCall(name, {...})`` literals inside each stub body:
|
||||
call every public async method with distinct synthesized kwargs and
|
||||
confirm the recorded call name and kwargs dict match exactly. Catches
|
||||
typos in keys (``"user": user_id`` vs ``"user_id": user_id``) and value
|
||||
transpositions that signature parity cannot see.
|
||||
"""
|
||||
|
||||
async def test_every_method_records_passed_kwargs_faithfully(
|
||||
self, source: type, stub: type
|
||||
) -> None:
|
||||
"""Every public async method records its method name and kwargs."""
|
||||
instance = stub()
|
||||
mismatches: list[str] = []
|
||||
for name in sorted(_public_async_methods(source)):
|
||||
hints = get_type_hints(getattr(source, name))
|
||||
sig = inspect.signature(getattr(source, name))
|
||||
kwargs = {
|
||||
p_name: _synthesize_value(p_name, hints[p_name])
|
||||
for p_name in sig.parameters
|
||||
if p_name != "self"
|
||||
}
|
||||
instance.calls.clear()
|
||||
await getattr(instance, name)(**kwargs)
|
||||
if len(instance.calls) != 1:
|
||||
mismatches.append(
|
||||
f"{name}: expected 1 recorded call, got {len(instance.calls)}"
|
||||
)
|
||||
continue
|
||||
call = instance.calls[0]
|
||||
if call.name != name:
|
||||
mismatches.append(f"{name}: recorded under wrong name {call.name!r}")
|
||||
if call.kwargs != kwargs:
|
||||
mismatches.append(
|
||||
f"{name}:\n expected: {kwargs}\n recorded: {call.kwargs}"
|
||||
)
|
||||
assert not mismatches, "Recording fidelity failures:\n" + "\n".join(mismatches)
|
||||
|
||||
|
||||
class TestRecordingOwncastClient:
|
||||
"""Exercises the RecordingOwncastClient stub."""
|
||||
|
||||
async def test_send_message_records_call(self) -> None:
|
||||
"""send_message appends the method name and all args to calls."""
|
||||
client = RecordingOwncastClient()
|
||||
result = await client.send_message("hello")
|
||||
assert result == ""
|
||||
assert client.calls == [
|
||||
("send_message", {"body": "hello", "unsanitized": False})
|
||||
]
|
||||
|
||||
async def test_unsanitized_true_is_recorded(self) -> None:
|
||||
"""unsanitized=True is included in the kwargs dict."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.send_message("hello", unsanitized=True)
|
||||
assert client.calls == [
|
||||
("send_message", {"body": "hello", "unsanitized": True})
|
||||
]
|
||||
|
||||
async def test_unsanitized_false_is_recorded(self) -> None:
|
||||
"""unsanitized=False (the default) is recorded explicitly."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.send_message("hello", unsanitized=False)
|
||||
assert client.calls == [
|
||||
("send_message", {"body": "hello", "unsanitized": False})
|
||||
]
|
||||
|
||||
async def test_send_system_message_to_client_records_args(self) -> None:
|
||||
"""send_system_message_to_client records all arguments."""
|
||||
client = RecordingOwncastClient()
|
||||
result = await client.send_system_message_to_client(42, "whisper")
|
||||
assert result == ""
|
||||
assert client.calls == [
|
||||
(
|
||||
"send_system_message_to_client",
|
||||
{"client_id": 42, "body": "whisper", "unsanitized": False},
|
||||
)
|
||||
]
|
||||
|
||||
async def test_multiple_calls_recorded_in_order(self) -> None:
|
||||
"""Multiple calls are recorded in the order they happen."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.send_message("first")
|
||||
await client.send_system_message("second")
|
||||
await client.send_action("third")
|
||||
assert len(client.calls) == 3
|
||||
assert client.calls[0] == (
|
||||
"send_message",
|
||||
{"body": "first", "unsanitized": False},
|
||||
)
|
||||
assert client.calls[1] == (
|
||||
"send_system_message",
|
||||
{"body": "second", "unsanitized": False},
|
||||
)
|
||||
assert client.calls[2] == (
|
||||
"send_action",
|
||||
{"body": "third", "unsanitized": False},
|
||||
)
|
||||
|
||||
async def test_get_chat_history_returns_empty_list(self) -> None:
|
||||
"""get_chat_history returns [] and records the call."""
|
||||
client = RecordingOwncastClient()
|
||||
result = await client.get_chat_history()
|
||||
assert result == []
|
||||
assert client.calls == [("get_chat_history", {})]
|
||||
|
||||
async def test_get_connected_clients_returns_empty_list(self) -> None:
|
||||
"""get_connected_clients returns [] and records the call."""
|
||||
client = RecordingOwncastClient()
|
||||
result = await client.get_connected_clients()
|
||||
assert result == []
|
||||
assert client.calls == [("get_connected_clients", {})]
|
||||
|
||||
async def test_get_status_returns_empty_dict(self) -> None:
|
||||
"""get_status returns {} and records the call."""
|
||||
client = RecordingOwncastClient()
|
||||
result = await client.get_status()
|
||||
assert result == {}
|
||||
assert client.calls == [("get_status", {})]
|
||||
|
||||
async def test_post_methods_return_empty_string(self) -> None:
|
||||
"""All post-style methods return empty string."""
|
||||
client = RecordingOwncastClient()
|
||||
assert await client.send_message("x") == ""
|
||||
assert await client.send_system_message("x") == ""
|
||||
assert await client.send_action("x") == ""
|
||||
assert await client.send_system_message_to_client(1, "x") == ""
|
||||
assert await client.set_message_visibility(["a"], visible=True) == ""
|
||||
assert await client.set_stream_title("t") == ""
|
||||
|
||||
def test_base_url_property(self) -> None:
|
||||
"""base_url returns the stub default."""
|
||||
client = RecordingOwncastClient()
|
||||
assert client.base_url == "http://localhost:8080"
|
||||
|
||||
async def test_set_message_visibility_records_args(self) -> None:
|
||||
"""set_message_visibility records message_ids and visible."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.set_message_visibility(["msg-1", "msg-2"], visible=False)
|
||||
assert client.calls == [
|
||||
(
|
||||
"set_message_visibility",
|
||||
{"message_ids": ["msg-1", "msg-2"], "visible": False},
|
||||
)
|
||||
]
|
||||
|
||||
async def test_set_stream_title_records_args(self) -> None:
|
||||
"""set_stream_title records the title argument."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.set_stream_title("New Title")
|
||||
assert client.calls == [("set_stream_title", {"title": "New Title"})]
|
||||
|
||||
async def test_send_system_message_records_call(self) -> None:
|
||||
"""send_system_message records body."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.send_system_message("sys msg")
|
||||
assert client.calls == [
|
||||
("send_system_message", {"body": "sys msg", "unsanitized": False})
|
||||
]
|
||||
|
||||
async def test_send_action_records_call(self) -> None:
|
||||
"""send_action records body."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.send_action("does a thing")
|
||||
assert client.calls == [
|
||||
("send_action", {"body": "does a thing", "unsanitized": False})
|
||||
]
|
||||
|
||||
async def test_recorded_call_exposes_name_and_kwargs_attributes(self) -> None:
|
||||
"""Recorded calls expose .name and .kwargs for attribute-style access."""
|
||||
client = RecordingOwncastClient()
|
||||
await client.send_message("hello")
|
||||
call = client.calls[0]
|
||||
assert call.name == "send_message"
|
||||
assert call.kwargs == {"body": "hello", "unsanitized": False}
|
||||
|
||||
def test_recorded_call_equals_plain_tuple(self) -> None:
|
||||
"""RecordedCall compares equal to a (name, kwargs) tuple, per its docstring.
|
||||
|
||||
Many tests assert against tuple literals
|
||||
(e.g. ``client.calls == [("send_message", {...})]``); this test locks
|
||||
the contract directly so a refactor of RecordedCall surfaces here
|
||||
rather than as scattered per-method failures.
|
||||
"""
|
||||
assert RecordedCall("x", {"a": 1}) == ("x", {"a": 1})
|
||||
assert RecordedCall("x", {}) != ("x", {"a": 1})
|
||||
assert RecordedCall("x", {"a": 1}) != ("y", {"a": 1})
|
||||
|
||||
|
||||
class TestRecordingOwncastAdminClient:
|
||||
"""Exercises the RecordingOwncastAdminClient stub."""
|
||||
|
||||
async def test_get_status_records_and_returns_dict(self) -> None:
|
||||
"""get_status records the call and returns {}."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.get_status()
|
||||
assert result == {}
|
||||
assert client.calls == [("get_status", {})]
|
||||
|
||||
async def test_set_user_enabled_records_args(self) -> None:
|
||||
"""set_user_enabled records user_id and enabled."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
await client.set_user_enabled("user-123", enabled=False)
|
||||
assert client.calls == [
|
||||
("set_user_enabled", {"user_id": "user-123", "enabled": False})
|
||||
]
|
||||
|
||||
async def test_set_user_moderator_records_args(self) -> None:
|
||||
"""set_user_moderator records user_id and is_mod."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
await client.set_user_moderator("user-456", is_mod=True)
|
||||
assert client.calls == [
|
||||
("set_user_moderator", {"user_id": "user-456", "is_mod": True})
|
||||
]
|
||||
|
||||
def test_base_url_property(self) -> None:
|
||||
"""base_url returns the stub default."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
assert client.base_url == "http://localhost:8080"
|
||||
|
||||
async def test_get_active_viewers_returns_empty_list(self) -> None:
|
||||
"""get_active_viewers returns [] and records the call."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.get_active_viewers()
|
||||
assert result == []
|
||||
assert client.calls == [("get_active_viewers", {})]
|
||||
|
||||
async def test_get_hardware_stats_returns_empty_dict(self) -> None:
|
||||
"""get_hardware_stats returns {} and records the call."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.get_hardware_stats()
|
||||
assert result == {}
|
||||
assert client.calls == [("get_hardware_stats", {})]
|
||||
|
||||
async def test_get_server_config_returns_empty_dict(self) -> None:
|
||||
"""get_server_config returns {} and records the call."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.get_server_config()
|
||||
assert result == {}
|
||||
assert client.calls == [("get_server_config", {})]
|
||||
|
||||
async def test_ban_ip_address_records_args(self) -> None:
|
||||
"""ban_ip_address records the ip argument."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
await client.ban_ip_address("192.168.1.1")
|
||||
assert client.calls == [("ban_ip_address", {"ip": "192.168.1.1"})]
|
||||
|
||||
async def test_unban_ip_address_records_args(self) -> None:
|
||||
"""unban_ip_address records the ip argument."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
await client.unban_ip_address("10.0.0.1")
|
||||
assert client.calls == [("unban_ip_address", {"ip": "10.0.0.1"})]
|
||||
|
||||
async def test_disconnect_stream_returns_empty_dict(self) -> None:
|
||||
"""disconnect_stream returns {} and records the call."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.disconnect_stream()
|
||||
assert result == {}
|
||||
assert client.calls == [("disconnect_stream", {})]
|
||||
|
||||
async def test_get_logs_returns_empty_list(self) -> None:
|
||||
"""get_logs returns [] and records the call."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.get_logs()
|
||||
assert result == []
|
||||
assert client.calls == [("get_logs", {})]
|
||||
|
||||
async def test_get_warnings_returns_empty_list(self) -> None:
|
||||
"""get_warnings returns [] and records the call."""
|
||||
client = RecordingOwncastAdminClient()
|
||||
result = await client.get_warnings()
|
||||
assert result == []
|
||||
assert client.calls == [("get_warnings", {})]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
@@ -96,6 +100,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aioresponses"
|
||||
version = "0.7.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
@@ -156,84 +173,84 @@ filecache = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.6"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -382,23 +399,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.25.2"
|
||||
version = "3.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freezegun"
|
||||
version = "1.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -492,11 +497,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -522,62 +527,62 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -830,7 +835,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.1"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
@@ -838,37 +843,37 @@ dependencies = [
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/1b/75a7c825a02781ca10bc2f2f12fba2af5202f6d6005aad8d2d1f264d8d78/mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51", size = 14494077, upload-time = "2026-04-13T02:45:55.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/54/5e5a569ea5c2b4d48b729fb32aa936eeb4246e4fc3e6f5b3d36a2dfbefb9/mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28", size = 13319495, upload-time = "2026-04-13T02:45:29.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a4/a1945b19f33e91721b59deee3abb484f2fa5922adc33bb166daf5325d76d/mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f", size = 13696948, upload-time = "2026-04-13T02:46:15.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/c6/75e969781c2359b2f9c15b061f28ec6d67c8b61865ceda176e85c8e7f2de/mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37", size = 14706744, upload-time = "2026-04-13T02:46:00.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6e/b221b1de981fc4262fe3e0bf9ec272d292dfe42394a689c2d49765c144c4/mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237", size = 14949035, upload-time = "2026-04-13T02:45:06.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/4b/298ba2de0aafc0da3ff2288da06884aae7ba6489bc247c933f87847c41b3/mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d", size = 10883216, upload-time = "2026-04-13T02:45:47.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/f9/5e25b8f0b8cb92f080bfed9c21d3279b2a0b6a601cdca369a039ba84789d/mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019", size = 9814299, upload-time = "2026-04-13T02:45:21.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/e8/ef0991aa24c8f225df10b034f3c2681213cb54cf247623c6dec9a5744e70/mypy-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f3886c03e40afefd327bd70b3f634b39ea82e87f314edaa4d0cce4b927ddcc1", size = 14500739, upload-time = "2026-04-13T02:46:05.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/416ebec3047636ed89fa871dc8c54bf05e9e20aa9499da59790d7adb312d/mypy-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e860eb3904f9764e83bafd70c8250bdffdc7dde6b82f486e8156348bf7ceb184", size = 13314735, upload-time = "2026-04-13T02:46:47.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/1e/1505022d9c9ac2e014a384eb17638fb37bf8e9d0a833ea60605b66f8f7ba/mypy-1.20.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4b5aac6e785719da51a84f5d09e9e843d473170a9045b1ea7ea1af86225df4b", size = 13704356, upload-time = "2026-04-13T02:45:19.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/91/275b01f5eba5c467a3318ec214dd865abb66e9c811231c8587287b92876a/mypy-1.20.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f37b6cd0fe2ad3a20f05ace48ca3523fc52ff86940e34937b439613b6854472e", size = 14696420, upload-time = "2026-04-13T02:45:24.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/57/b3779e134e1b7250d05f874252780d0a88c068bc054bcff99ca20a3a2986/mypy-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4bbb0f6b54ce7cc350ef4a770650d15fa70edd99ad5267e227133eda9c94218", size = 14936093, upload-time = "2026-04-13T02:45:32.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/33/81b64991b0f3f278c3b55c335888794af190b2d59031a5ad1401bcb69f1e/mypy-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3dc20f8ec76eecd77148cdd2f1542ed496e51e185713bf488a414f862deb8f2", size = 10889659, upload-time = "2026-04-13T02:46:02.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/fd/7adcb8053572edf5ef8f3db59599dfeeee3be9cc4c8c97e2d28f66f42ac5/mypy-1.20.1-cp313-cp313-win_arm64.whl", hash = "sha256:a9d62bbac5d6d46718e2b0330b25e6264463ed832722b8f7d4440ff1be3ca895", size = 9815515, upload-time = "2026-04-13T02:46:32.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cd/db831e84c81d57d4886d99feee14e372f64bbec6a9cb1a88a19e243f2ef5/mypy-1.20.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:12927b9c0ed794daedcf1dab055b6c613d9d5659ac511e8d936d96f19c087d12", size = 14483064, upload-time = "2026-04-13T02:45:26.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/82/74e62e7097fa67da328ac8ece8de09133448c04d20ddeaeba251a3000f01/mypy-1.20.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:752507dd481e958b2c08fc966d3806c962af5a9433b5bf8f3bdd7175c20e34fe", size = 13335694, upload-time = "2026-04-13T02:46:12.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c4/97e9a0abe4f3cdbbf4d079cb87a03b786efeccf5bf2b89fe4f96939ab2e6/mypy-1.20.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c614655b5a065e56274c6cbbe405f7cf7e96c0654db7ba39bc680238837f7b08", size = 13726365, upload-time = "2026-04-13T02:45:17.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/aa/a19d884a8d28fcd3c065776323029f204dbc774e70ec9c85eba228b680de/mypy-1.20.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c3f6221a76f34d5100c6d35b3ef6b947054123c3f8d6938a4ba00b1308aa572", size = 14693472, upload-time = "2026-04-13T02:46:41.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/44/cc9324bd21cf786592b44bf3b5d224b3923c1230ec9898d508d00241d465/mypy-1.20.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4bdfc06303ac06500af71ea0cdbe995c502b3c9ba32f3f8313523c137a25d1b6", size = 14919266, upload-time = "2026-04-13T02:46:28.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/dc/779abb25a8c63e8f44bf5a336217fa92790fa17e0c40e0c725d10cb01bbd/mypy-1.20.1-cp314-cp314-win_amd64.whl", hash = "sha256:0131edd7eba289973d1ba1003d1a37c426b85cdef76650cd02da6420898a5eb3", size = 11049713, upload-time = "2026-04-13T02:45:57.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/08/4172be2ad7de9119b5a92ca36abbf641afdc5cb1ef4ae0c3a8182f29674f/mypy-1.20.1-cp314-cp314-win_arm64.whl", hash = "sha256:33f02904feb2c07e1fdf7909026206396c9deeb9e6f34d466b4cfedb0aadbbe4", size = 9999819, upload-time = "2026-04-13T02:46:35.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/af/af9e46b0c8eabbce9fc04a477564170f47a1c22b308822282a59b7ff315f/mypy-1.20.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:168472149dd8cc505c98cefd21ad77e4257ed6022cd5ed2fe2999bed56977a5a", size = 15547508, upload-time = "2026-04-13T02:46:25.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/cd/39c9e4ad6ba33e069e5837d772a9e6c304b4a5452a14a975d52b36444650/mypy-1.20.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb674600309a8f22790cca883a97c90299f948183ebb210fbef6bcee07cb1986", size = 14399557, upload-time = "2026-04-13T02:46:10.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c1/3fd71bdc118ffc502bf57559c909927bb7e011f327f7bb8e0488e98a5870/mypy-1.20.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef2b2e4cc464ba9795459f2586923abd58a0055487cbe558cb538ea6e6bc142a", size = 15045789, upload-time = "2026-04-13T02:45:10.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/73/6f07ff8b57a7d7b3e6e5bf34685d17632382395c8bb53364ec331661f83e/mypy-1.20.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee461d396dd46b3f0ed5a098dbc9b8860c81c46ad44fa071afcfbc149f167c9", size = 15850795, upload-time = "2026-04-13T02:45:03.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e2/f7dffec1c7767078f9e9adf0c786d1fe0ff30964a77eb213c09b8b58cb76/mypy-1.20.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e364926308b3e66f1361f81a566fc1b2f8cd47fc8525e8136d4058a65a4b4f02", size = 16088539, upload-time = "2026-04-13T02:46:17.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/76/e0dee71035316e75a69d73aec2f03c39c21c967b97e277fd0ef8fd6aec66/mypy-1.20.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0c17fbd746d38c70cbc42647cfd884f845a9708a4b160a8b4f7e70d41f4d7fa", size = 12575567, upload-time = "2026-04-13T02:45:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a8/7ed43c9d9c3d1468f86605e323a5d97e411a448790a00f07e779f3211a46/mypy-1.20.1-cp314-cp314t-win_arm64.whl", hash = "sha256:db2cb89654626a912efda69c0d5c1d22d948265e2069010d3dde3abf751c7d08", size = 10378823, upload-time = "2026-04-13T02:45:13.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/28/926bd972388e65a39ee98e188ccf67e81beb3aacfd5d6b310051772d974b/mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06", size = 2636553, upload-time = "2026-04-13T02:46:30.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -949,21 +954,32 @@ dependencies = [
|
||||
{ name = "uvloop" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
testing = [
|
||||
{ name = "aioresponses" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-aiohttp" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "aioresponses" },
|
||||
{ name = "codespell" },
|
||||
{ name = "freezegun" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pip-audit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-aiohttp" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "time-machine" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.13.5" },
|
||||
{ name = "aioresponses", marker = "extra == 'testing'", specifier = ">=0.7.8" },
|
||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||
{ name = "cronsim", specifier = ">=2.7" },
|
||||
{ name = "emoji", specifier = ">=2.15.0" },
|
||||
@@ -971,20 +987,26 @@ requires-dist = [
|
||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||
{ name = "markupsafe", specifier = ">=3.0.3" },
|
||||
{ name = "orjson", specifier = ">=3.11.8" },
|
||||
{ name = "pytest", marker = "extra == 'testing'", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-aiohttp", marker = "extra == 'testing'", specifier = ">=1.1.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'testing'", specifier = ">=1.3.0" },
|
||||
{ name = "ruamel-yaml", specifier = ">=0.19.1" },
|
||||
{ name = "uvloop", specifier = ">=0.22.1" },
|
||||
]
|
||||
provides-extras = ["testing"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "aioresponses", specifier = ">=0.7.8" },
|
||||
{ name = "codespell", specifier = ">=2.4.2" },
|
||||
{ name = "freezegun", specifier = ">=1.5.5" },
|
||||
{ name = "mypy", specifier = ">=1.20.1" },
|
||||
{ name = "mypy", specifier = ">=1.20.2" },
|
||||
{ name = "pip-audit", specifier = ">=2.10.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-aiohttp", specifier = ">=1.1.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.10" },
|
||||
{ name = "ruff", specifier = ">=0.15.11" },
|
||||
{ name = "time-machine", specifier = ">=3.2.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -998,11 +1020,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
version = "26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1071,11 +1093,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.4"
|
||||
version = "4.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1217,6 +1239,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-aiohttp"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
@@ -1244,21 +1280,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.0"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1266,22 +1290,22 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1295,36 +1319,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.10"
|
||||
version = "0.15.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1336,6 +1351,69 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-machine"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
|
||||
Reference in New Issue
Block a user