Thank you for considering a contribution. This document covers how to set up the development environment, run checks, write tests, and submit changes.
Python 3.14 and uv are required.
The easiest path is the bootstrap script, which installs uv if not present and provisions Python 3.14 automatically:
./setup.shOr provision manually:
uv python install 3.14
uv sync --python 3.14 --all-extrasDependencies are locked via uv.lock. Regenerate only when dependency inputs in pyproject.toml change:
uv lockAlways run the full suite before opening a pull request:
uv run ruff check .
uv run ruff format --check .
uv run mypy axis
uv run pytestFor faster iteration, target only the files you changed:
uv run pytest tests/test_<area>.py
uv run ruff check axis/<file>.pyRun broader checks only when your change affects shared behavior (for example, base classes, event parsing, or configuration).
Coverage must stay at or above 95% overall. All new code you introduce must have 100% test coverage. axis/stream_transport.py is excluded from the threshold. TYPE_CHECKING blocks are automatically excluded.
The repository ships local hooks that run ruff check --fix, ruff format, and mypy on every commit. Install them after setup:
uv run pre-commit installIf a hook modifies files, stage the changes and re-run the commit:
git add -u && git commitThe hooks require an active .venv — run ./setup.sh or uv sync --all-extras first if they report a missing environment.
The library is split into three layers. Keep changes inside the appropriate boundary:
| Layer | Path | Responsibility |
|---|---|---|
| Models | axis/models/ |
Request/response dataclasses, enums, XML/event parsing |
| Interfaces | axis/interfaces/ |
API handlers, transport-facing logic, VAPIX calls |
| Orchestration | axis/device.py, axis/interfaces/vapix.py |
Device lifecycle, handler registry, phase-based initialization |
Handlers declare the phases they participate in via handler_groups on the ApiHandler subclass. The three phases are:
API_DISCOVERY— initialized after API discovery completes.PARAM_CGI_FALLBACK— initialized from parameter CGI support when not listed in API discovery.APPLICATION— initialized after application loading.
Override should_initialize_in_group() to customize eligibility within a phase. See axis/interfaces/light_control.py for a concrete fallback example.
- Add a model in
axis/models/<name>.py— dataclass(es), enums with_missing_fallbacks, and any parsing helpers. - Add a handler in
axis/interfaces/<name>.py— extendApiHandler, declareapi_id,handler_groups, and implement_api_request(). - Register the handler on
Vapixinaxis/interfaces/vapix.py. - Add tests in
tests/test_<name>.pyusing the async fixtures and HTTP mocking layers fromtests/conftest.py.
Always provide a _missing_ fallback that returns a safe sentinel (.UNKNOWN) and logs a debug-level warning for unrecognized values. Do not raise:
@classmethod
def _missing_(cls, value: object) -> MyEnum:
"""Set default enum member if an unknown value is provided."""
LOGGER.debug("Unsupported value %s", value)
return MyEnum.UNKNOWNNormalize and coerce enum fields at the constructor boundary, typically in __post_init__:
def __post_init__(self) -> None:
self.web_proto = WebProtocol(self.web_proto)
self.auth_scheme = AuthScheme(self.auth_scheme)- Use
xmltodict.parse()withprocess_namespaces=Trueand the relevantnamespacesmapping. - Always normalize the parsed root to a
dictbefore traversing — never assume a fixed shape. - Use the
traverse()helper inaxis/models/event.pyfor nested key access.
All code must pass strict mypy (see [tool.mypy] in pyproject.toml). Key requirements:
disallow_untyped_defs = true— annotate every function/method.disallow_any_generics = true— avoid barelist,dict,tuple; use parameterized forms.- Guard imports only needed for type checking with
if TYPE_CHECKING:.
Tests live in tests/ and mirror the axis/ structure. Use the nearest relevant test module for any behavior change.
Reuse the async device fixtures from tests/conftest.py:
axis_devicefor single-device tests.axis_companion_devicefor companion/multi-device tests.
Choose the fixture layer based on test scope and assertion needs:
- Prefer
aiohttp_mock_serverfor most new direct endpoint tests. - Prefer
http_route_mockfor single-device route-registration tests. - Use
http_route_mock_factoryonly when you need explicit multi-device binding.
| Fixture | Use when | Avoid when |
|---|---|---|
aiohttp_mock_server |
Direct endpoint/static payload tests, custom handler tests, payload/body capture tests | Complex route-sequence tests that benefit from fluent route registration |
http_route_mock |
Common single-device route-registration tests with call-history assertions | Multi-device tests |
http_route_mock_factory |
Multi-device or explicit device-binding route-registration tests | Single-device tests where http_route_mock is simpler |
Use aiohttp_mock_server for most new direct endpoint tests:
async def test_something(aiohttp_mock_server, axis_device):
server, requests = await aiohttp_mock_server(
"/axis-cgi/example.cgi",
response={"data": []},
device=axis_device,
)
assert server.port == axis_device.config.port
assert requests is not NoneUse http_route_mock for route-registration tests:
async def test_handler(http_route_mock):
http_route_mock.post("/axis-cgi/example.cgi").respond(
json={"apiVersion": "1.0", "data": []}
)Use http_route_mock_factory for multi-device tests:
async def test_multi_device(
http_route_mock_factory,
axis_device,
axis_companion_device,
):
mock = await http_route_mock_factory(
axis_device,
axis_companion_device,
)
mock.post("/axis-cgi/example.cgi").respond(json={"data": []})When registering a route with data=..., body matching is strict. A request body
that does not match the registered payload will not hit the route (it will return
404 from the mock server). Use data= only when you intend to assert request-body
shape; otherwise omit it to match only method/path.
When using Route.respond(), map response type to the correct keyword:
| Content-Type | respond() kwarg |
|---|---|
application/json |
json= |
text/plain |
text= |
text/xml |
text= |
http_route_mock.post("/axis-cgi/...").respond(json={"data": ...})For advanced options like capture_payload, capture_body, and route-spec dictionaries, use tests/conftest.py as the source of truth.
If you override shared fixtures in a test module (for example http_route_mock),
document the reason in the fixture docstring so the scope difference is explicit to
future contributors.
asyncio_mode = "auto" is configured — write async def test_* without any extra decorator.
The current recommended architecture is the hybrid pattern already in this repository:
- Shared route/dispatch behavior in support modules (for example
tests/http_route_mock.py). - Fixture exposure and loading in
tests/conftest.py.
Do not extract test support to a standalone pytest plugin yet. Revisit extraction only when all gates are met:
- Shared fixtures are adopted in roughly 30-40% of eligible tests.
- Fixture API remains stable for at least two weeks (no semantic/parameter changes).
- A clear maintenance owner is identified.
- There is concrete reuse demand outside this repository, or a proven local scaling issue that cannot be addressed by reorganizing
tests/conftest.py.
If extraction is justified later, extract locally first while keeping tests/conftest.py as the loading surface. Separate packaging/release workflows are out of scope until local extraction proves stable.
-
All changes go through a feature branch and pull request. Never commit directly to
master. -
Create a branch from the latest
master:git checkout master && git pull git checkout -b feat/<short-description>
-
Keep each PR focused. Don't mix unrelated fixes or refactors.
-
If pre-existing tests, typing, or linting fail for reasons unrelated to your change, note that clearly in the PR description rather than fixing unrelated code.
-
Ensure all required checks pass (Ruff, mypy, pytest with ≥95% coverage) before requesting review.