This document describes project conventions, architecture, and tooling for contributors and AI agents working on this repository.
fritz_exporter is a Prometheus exporter for AVM Fritz! home network devices (routers, repeaters, etc.). It connects to Fritz! devices via the TR-064 API (using the fritzconnection Python library), discovers supported capabilities, and exposes metrics in Prometheus format.
- Entrypoint:
fritzexporter/__main__.py→main() - Documentation: fritz-exporter.readthedocs.io
- Python version: 3.14+
- The project is written in Python 3.14+.
- Type annotations are required throughout all production code. Every function, method, and class attribute must be annotated.
- Use
from __future__ import annotationsat the top of files to enable postponed evaluation of annotations (this is already the pattern in the codebase). - Use
TYPE_CHECKINGguards for imports that are only needed for type hints to avoid circular imports:from typing import TYPE_CHECKING if TYPE_CHECKING: from fritzexporter.fritzdevice import FritzDevice
- Avoid
Anyexcept where unavoidable (e.g., interfacing with untyped third-party libraries). - ty is used for static type checking. Configuration is in
pyproject.tomlunder[tool.ty].
This project uses the attrs library instead of Python's built-in dataclasses.
- Use
@definefromattrsfor all data/config classes (not@dataclass). - Use
attrs.field()withvalidator=,converter=, andfactory=arguments as needed. - Use
attrs.validators(e.g.,validators.instance_of,validators.min_len,validators.in_) for field validation. - Use
attrs.converters(e.g.,converters.to_bool) for field conversion. - Validator methods on instances are defined as regular methods decorated with
@<field_name>.validator.
attrs validators and converters are the primary defence against bad config input. Both the YAML file path and the env var path ultimately construct the same DeviceConfig / ExporterConfig objects, so validation placed on the attrs field fires for both paths automatically — no need to validate in two places.
The pattern:
- Convert first, then validate. Converters run before validators. Use a converter to coerce raw input (e.g. env var strings) to the right type, then use a validator to check the value is in range.
- Use built-in validators where possible — they compose cleanly and produce clear error messages:
validators.instance_of(T)— type checkvalidators.min_len(n)/validators.max_len(n)— length boundsvalidators.ge(n)/validators.le(n)— numeric boundsvalidators.in_(collection)— allowlistvalidators.optional(v)— appliesvonly when the value is notNonevalidators.and_(v1, v2, ...)— combine multiple validators
- Write a custom
@<field>.validatormethod only when the logic cannot be expressed with built-ins (e.g. cross-field checks, file existence).
Converter pattern for optional typed fields (e.g. connection_timeout):
def _convert_optional_int(value: int | str | None) -> int | None:
if value is None:
return None
timeout = int(value) # coerce str from env vars
if timeout == 0:
return None # treat 0 as "no value" when appropriate
return timeout
@define
class DeviceConfig:
connection_timeout: int | None = field(
default=None,
converter=_convert_optional_int,
validator=validators.optional(validators.ge(1)),
)This handles all of: None (absent in YAML), 0 (explicit "no timeout"), "15" (string from env var), and rejects negatives — all in one place.
Custom validator method pattern (for logic that can't use built-ins):
@define
class DeviceConfig:
password: str | None = field(default=None)
@password.validator
def check_password(self, _: attrs.Attribute, value: str | None) -> None:
if value is not None and len(value) > FRITZ_MAX_PASSWORD_LENGTH:
raise FritzPasswordTooLongErrorKey rule: if you add a new config field, always add a converter and/or validator on the attrs field itself. Do not validate the raw dict in from_config() or in _read_config_from_env() — that duplicates logic and is easy to miss in one of the two config paths.
ruff is the linter. Run it with:
uv run ruff check fritzexporter/Key configuration (see pyproject.toml [tool.ruff]):
- Line length: 100 characters
- Target: Python 3.14
- Tests and docs are excluded from linting (
extend-exclude = ["tests", "docs"]) - A large set of rule groups is enabled (see
pyproject.tomlfor the full list), including:E,W— pycodestyleF— pyflakesI— isort (import ordering)N— PEP8 namingANN— missing type annotations (all public functions/methods must be annotated)S— bandit security checksB,BLE,FBT— bugbear and anti-patterns (no boolean traps, no blind exceptions)UP— pyupgrade (use modern Python syntax)T20— noprint()calls (use logging)PTH— usepathlibinstead ofos.pathPL— pylint checksTRY— try/except anti-patternsRUF— ruff-specific rules
- Ignored rules:
E203,COM812,ISC001,ANN204 - Docstring style convention: Google (
[tool.ruff.lint.pydocstyle] convention = "google") - Use
# noqa: <CODE>to suppress a specific rule where absolutely necessary; avoid blanket# noqa. - Follow the boy scout rule. Do not fix lint in unrelated code. If you are already touching a line or block, clean up lint issues there so you leave it cleaner than you found it.
- Adhere to the ruff rules in production code. Treat
pyproject.tomlas the source of truth for exact lint boundaries, and keepfritzexporter/free of newE501violations. - Tests and docs stay more flexible. They are excluded from ruff, but should still remain reasonably readable.
Tests use pytest. Run with:
uv run pytest- Test files are in the
tests/directory. - Coverage is measured automatically (branch coverage, XML report). Config in
pyproject.tomlunder[tool.coverage]. - Mocking uses
unittest.mock(MagicMock,patch). - A shared
fc_services_mock.pyprovides mock Fritz device service/action data for tests. - Tests are excluded from ruff linting but should still be readable and well-structured.
- Use
pytest.markfor test organization where appropriate.
This project uses Conventional Commits to drive automated versioning via the release-please GitHub Action.
Format: <type>(<optional scope>): <description>
Allowed commit types:
feat— new feature (triggers minor version bump)fix— bug fix (triggers patch version bump)perf— performance improvementrefactor— code refactoringstyle— code style changestest— test changesbuild— build system changesops— operational/CI changesdocs— documentation changesmerge— merge commits
Breaking changes: add BREAKING CHANGE: in the commit body or append ! after the type (e.g., feat!: ...) to trigger a major version bump.
Always use conventional commits. Non-conforming commit messages will be flagged and may not appear in the changelog.
fritzexporter/
__init__.py # Package version
__main__.py # CLI entrypoint and main loop
fritzdevice.py # FritzDevice, FritzCollector, FritzCredentials
fritzcapabilities.py # FritzCapability (ABC) + all concrete capability classes
fritz_aha.py # AHA (smart home) XML parsing
tr064_remote.py # WAN remote TR-064 (/tr064) URL rewriting
action_blacklists.py # TR-064 actions that must never be called
data_donation.py # --donate / --sanitize: collect + upload anonymized device data
exceptions.py # Project-level exceptions
config/
__init__.py
config.py # ExporterConfig, DeviceConfig (attrs @define classes)
exceptions.py # Config-specific exceptions
-
FritzCapability(abstract base class infritzcapabilities.py): Each subclass represents one group of metrics that a Fritz device may or may not support. Subclasses are auto-registered via__init_subclass__. Each capability:- Declares
requirements: list of(service, action)tuples that must exist and be callable on the device. - Implements
create_metrics()— instantiates Prometheus metric families. - Implements
_generate_metric_values(device)— queries the device and populates metrics. - Implements
_get_metric_values()— yields the populated metric families.
- Declares
-
FritzCapabilities(container): holds one instance of everyFritzCapabilitysubclass, keyed by class name. Supports merging across multiple devices. -
FritzDevice: connects to a single Fritz device, probes its capabilities, and holds aFritzCapabilitiesinstance. -
FritzCollector(PrometheusCollector): holds multipleFritzDeviceinstances and implementscollect()to yield all metrics. -
Configuration is loaded either from a YAML file or from environment variables. Config objects are
attrs@defineclasses with validators. See the Dual Configuration section below for the complete mapping and the rule that both paths must stay in sync.
- Add a new subclass of
FritzCapabilityinfritzcapabilities.py. - Set
self.requirementsin__init__with the required TR-064(service, action)pairs. - Implement
create_metrics(),_generate_metric_values(), and_get_metric_values(). - The subclass is automatically discovered — no registration needed.
- Add corresponding tests in
tests/test_fritzcapabilities.py, using the mock infrastructure intests/fc_services_mock.py.
The exporter supports two mutually exclusive configuration methods, both implemented in fritzexporter/config/config.py. Exactly one is active at runtime — there is no merging or layering:
- Config file (
--configflag): YAML, supports multiple devices. Used when a config file path is provided. - Environment variables: single-device only. Used when no config file is passed.
If a config file path is given, environment variables are ignored entirely, and vice versa.
Both paths produce the same ExporterConfig / DeviceConfig attrs objects and go through the same validators. The env path (_read_config_from_env) assembles a plain dict in the same shape as a parsed YAML file, then hands it to the same from_config class methods.
| Scope | YAML key | Environment variable | Default |
|---|---|---|---|
| Exporter | exporter_port |
FRITZ_PORT |
9787 |
| Exporter | log_level |
FRITZ_LOG_LEVEL |
INFO |
| Exporter | listen_address |
FRITZ_LISTEN_ADDRESS |
127.0.0.1 |
| Device | hostname |
FRITZ_HOSTNAME |
fritz.box |
| Device | name |
FRITZ_NAME |
Fritz!Box |
| Device | username |
FRITZ_USERNAME |
(required) |
| Device | password |
FRITZ_PASSWORD |
(required unless password_file set) |
| Device | password_file |
FRITZ_PASSWORD_FILE |
(required unless password set) |
| Device | host_info |
FRITZ_HOST_INFO |
False |
| Device | wifi_client_info |
FRITZ_WIFI_CLIENT_INFO |
False |
| Device | connection_timeout |
FRITZ_CONNECTION_TIMEOUT |
(none — no timeout) |
| Device | use_tls |
FRITZ_USE_TLS |
False |
| Device | port |
FRITZ_DEVICE_PORT |
(none — fritzconnection default: 49000 / TLS 49443) |
| Device | remote_access |
FRITZ_REMOTE_ACCESS |
False |
Whenever you add or change a device or exporter config option, you must update both paths:
- Add the field to
DeviceConfigorExporterConfig(with converter + validator as appropriate). - Add the YAML key read in
DeviceConfig.from_config()/ExporterConfig.from_config(). - Add the corresponding
os.getenv(...)read in_read_config_from_env()and include it in the device/config dict. - Add tests for both paths in
tests/test_config.py(seeTestFileConfigsandTestEnvConfig). - Update
docs/configuration.rst: the env var table and the YAML example block.
Failure to keep the two paths in sync silently degrades the env-based deployment path, which is the primary path for Docker/container users.
- Dependencies are managed with uv (
pyproject.toml,uv.lock). - Install all dependencies:
uv sync - Add a dependency:
uv add <package> - Remove a dependency:
uv remove <package> - Do not update
uv.lockmanually.
mainis a protected branch. Never commit directly tomain.- All changes must go through a feature branch and a pull request, regardless of how small.
- Branch naming convention:
<type>/<short-description>(e.g.fix/logging-cleanup,feat/new-capability,docs/update-readme). - Use conventional commit messages (see below) on every commit.
- Run
uv run pytestanduv run ruff check fritzexporter/before pushing — CI will reject failures.
run-tests.yaml: runs on PRs and pushes tomain; executes linting (ruff check + ruff format) and pytest, uploads to SonarCloud.build-trunk.yaml: builds and publishes Docker images on pushes tomain.release.yaml: triggered byrelease-please; handles versioning and releases.release-please: automatically opens release PRs based on conventional commit history. Do not manually bump version numbers.
For every pull request, review the following areas in addition to the Python code and tests. Update them as needed to reflect the changes introduced by the PR:
- Check whether any user-facing behaviour, configuration options, CLI flags, metrics, or upgrade steps have changed.
- If so, update the relevant
.rstfiles underdocs/(e.g.configuration.rst,running.rst,quickstart.rst,upgrading.rst,docker-images.rst). - Commit documentation changes with the
docstype:docs: ....
- Check whether the base Python image version, build dependencies, or the install/entrypoint commands need updating to match changes in
pyproject.toml,uv.lock, or the package itself. - Verify that no unnecessary files are copied into the image and that the multi-stage build remains clean.
- Commit Dockerfile changes with the
buildtype:build: ....
- Check whether
Chart.yaml(app version, chart version),values.yaml(default image tag, new config keys), or the templates underhelm/fritz-exporter/templates/need updating to reflect new configuration options, environment variables, ports, or other changes. - Follow semantic versioning for the chart version bump in
Chart.yaml. - Commit Helm chart changes with the
buildtype:build(helm): ....
- Use
loggingfor all output — neverprint()(enforced by ruffT20). - Logger names follow the module hierarchy:
logging.getLogger("fritzexporter.<module>"). - Use
pathlib.Pathfor file system operations, notos.path. - Error messages passed to exceptions should be plain strings (not f-strings inline) to satisfy ruff
EMrules. - Use
collections.abctypes for abstract type hints (e.g.,collections.abc.Iterable,Generator,Iterator). - XML parsing must use
defusedxml(not the standardxmllibrary) for security.