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

- 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:
2026-04-22 21:49:16 -04:00
parent 32e21a6f9d
commit a671541b13
25 changed files with 8195 additions and 3093 deletions
+459
View File
@@ -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)