# Notes for LLM contributors
A short orientation file for an LLM working in this repo. Skim
before making changes; keep edits consistent with what's described
here. Read [README.rst](README.rst) for the user-facing intro.
## What this project is
`python-zeroconf` is a pure-Python implementation of multicast DNS
service discovery (mDNS / DNS-SD, RFC 6762 + RFC 6763). It is the
mDNS engine behind Home Assistant and a long list of other Python
projects that need to announce or discover services on the local
network. Public API is exported from the top-level `zeroconf`
package; an async API lives at `zeroconf.asyncio`.
There is no external protocol owner the on-the-wire format is
the mDNS / DNS-SD RFCs. Behaviour changes that affect packet
contents or timing should cite the relevant RFC section.
Hot paths (`_cache`, `_dns`, `_history`, `_listener`,
`_record_update`, `_updates`, `_protocol/{incoming,outgoing}`,
`_handlers/*`, `_services/*`, `_utils/{ipaddress,time}`) are
Cythonized at build time for throughput. They keep working as
pure Python `SKIP_CYTHON=1` disables the extension build but
production wheels ship compiled and CodSpeed benchmarks track that
path. The authoritative list of cythonized modules lives in
`build_ext.py` (`TO_CYTHONIZE`).
## Code style
- **Docstrings: terse, default to single-line.** A docstring is
the function's _contract_, not its narrative. Almost every
docstring should be one line `"""Summary."""` describing
what the function does and what the caller can pass. Multi-line
is the exception, only justified when there is non-obvious
caller-visible behaviour the type signature and parameter names
don't already convey.
**What does NOT belong in docstrings or comments:**
- Rationale / motivation / "why we used to do X" that's the
PR description and the commit message. Git already remembers.
- Cross-references to issue numbers ("closes #N", "follow-up
to #M") the PR body carries those.
- Restatement of the function body in prose. If the next line
of the docstring is just describing what the next line of
code does, delete the docstring line.
- Test docstrings retelling the production-side story. A test
docstring should name what the test pins, in one sentence
not re-explain the bug, the fix, or the surrounding flow.
- **Comments**: same bar. Default to writing no comments. Add
one only when the _why_ is non-obvious: a hidden constraint, a
subtle invariant, a workaround for a specific bug, behaviour
that would surprise a reader. RFC citations are useful when the
reason for a timing constant or framing decision is "the spec
says so" leave those in. If removing the comment wouldn't
confuse a future reader, don't write it.
**Don't remove existing comments** unless the code they
describe is gone the original author left them for a reason.
- **Don't pad commits, docstrings, or comments with cross-
references** to old codepaths or issue numbers unless there's
a clear reason a future reader needs that link.
- **Method order**: public API at the top, private helpers
(`_underscore_prefixed`) at the bottom. Modules whose names
start with `_` (`_cache`, `_dns`, `_handlers/`, etc.) are
internal; the supported surface is what `zeroconf/__init__.py`
and `zeroconf/asyncio.py` re-export.
- **Line length**: 110 (ruff `line-length = 110`).
`requires-python = ">=3.10"`, `target-version = "py310"` for
ruff; pyupgrade runs `--py310-plus`.
- **Imports**: ruff/isort sorted, `profile = "black"`,
`known_first_party = ["zeroconf", "tests"]`. Prefer
`from __future__ import annotations` so modern type syntax
works on 3.10.
- **Generated `.c` files are not lint-targets.** `*.c` files
next to each cythonized module are Cython output never hand-
edit them. They are excluded from sdist (`exclude = ["**/*.c"]`
in `pyproject.toml`) and regenerated by the build.
## Commit / PR conventions
- **Conventional Commits PR title, lowercase subject.** PRs are
squash-merged, so the **PR title** becomes the commit on
`master` and is the only string that has to parse as a
Conventional Commit. The repo enforces this via the `pr-title`
CI job in `ci.yml` using `amannn/action-semantic-pull-request`.
Accepted types: `feat`, `fix`, `chore`, `ci`, `docs`,
`refactor`, `test`, `perf`, `build`, etc. The subject (text
after `type(scope):`) must start lowercase (enforced by
`subjectPattern: ^(?![A-Z]).+$`). Per-commit messages on the
PR branch are **not** linted; they get collapsed at squash-
merge. `semantic-release` excludes `chore*` and `ci*` from the
changelog, so use those prefixes for housekeeping and reserve
`feat`/`fix`/`perf` for user-visible changes.
- **No `Co-Authored-By` trailers from automated agents.** Project
preference.
- Imperative-mood subject after the type prefix ("fix: handle
empty answer", not "fix: handled empty answer").
- There is no `.github/PULL_REQUEST_TEMPLATE.md` in this repo
the PR body is free-form. The `pr-workflow` skill (under
`.claude/skills/pr-workflow/`) walks through the conventions
that do apply: conventional-commit subject, RFC citations for
protocol-affecting changes, a test-plan section.
- Pre-commit runs ruff (lint + format), mypy, flake8, codespell,
cython-lint, and pyupgrade. Run pre-commit locally before
pushing; the CI `lint` job is just `pre-commit/action`, so a
green local pre-commit run = a green CI lint job.
## Running tests
```bash
poetry run pytest --durations=20 --timeout=60 -v tests
```
or `make test`, which runs the same command. Test discovery
defaults from `pyproject.toml` already pass `--cov=zeroconf`
and `pythonpath = ["src"]`. `pytest-asyncio` is used in the
default per-test mode (no auto mode); async tests are marked
explicitly with `@pytest.mark.asyncio`.
CodSpeed benchmarks live under `tests/benchmarks/` and run in CI
through `CodSpeedHQ/action`. Ad-hoc microbenchmarks for manual
profiling live under `bench/` those don't run in CI.
The CI matrix includes CPython 3.10 3.14, the free-threaded
3.14t build, and PyPy 3.10. Don't add anything that breaks
on the free-threaded build (no module-level mutable globals
mutated from multiple threads without locks; no
`PyDict_Next`-style escape hatches in Cython).
## Build conventions
- **Cython is optional but expected in wheels.** `build_ext.py`
cythonizes every module listed in `TO_CYTHONIZE`. The build is
driven by `poetry-core` (`generate-setup-file = true`,
`script = "build_ext.py"`); `BuildExt.build_extensions`
swallows build failures so source installs fall back to pure
Python. `SKIP_CYTHON=1` skips the Cython step entirely;
`REQUIRE_CYTHON=1` re-raises so a missing extension fails the
build loudly (CI wheel builds use this).
- **Modules that get Cythonized ship a sibling `.pxd`** for type
declarations. When changing the signature of a Cythonized
function or adding a new attribute to a `cdef class` update
the `.pxd` in the same commit, or the extension will pick up a
stale declaration and the in-tree `.c` will be regenerated with
the wrong layout.
- Adding a new module to `TO_CYTHONIZE` is a deliberate decision:
the module must be hot enough to matter, must not rely on
Python-only constructs that Cython refuses (the existing
`PERF401`, `PYI032`, `PYI041` ruff ignores exist because Cython
rejects closures and PEP 604 unions in `cpdef`), and must stay
free-threading-safe.
- `compiler_directives = {"language_level": "3"}`. The build
pipeline does not currently set `freethreading_compatible`,
but the test matrix exercises 3.14t, so any new Cython module
needs to keep working there.
## Cython gotchas
Non-obvious traps in the `.py` + `.pxd` setup that work fine in
pure-Python mode but break or silently misbehave in the shipped
Cython wheels. Distilled from the patterns that already exist in
this repo's `.pxd` files and from incidents in sibling Cython-
accelerated projects.
- **`cdef`-typed module constants are not Python-importable.**
Declaring `cdef unsigned int _ANSWER_STRATEGY_POINTER` in
`query_handler.pxd` makes Cython treat
`_ANSWER_STRATEGY_POINTER = 1` in `query_handler.py` as a C int
assignment; the Python module dict never gets the binding.
`from zeroconf._handlers.query_handler import
_ANSWER_STRATEGY_POINTER` succeeds in pure-Python but raises
`ImportError` under Cython. If you need the value visible from
Python (e.g. a test wants to assert on it), define both names
a public `ANSWER_STRATEGY_POINTER = 1` Python binding plus a
`cdef`-typed `_ANSWER_STRATEGY_POINTER = ANSWER_STRATEGY_POINTER`
alias for hot-path comparisons.
- **Match the existing `unsigned int` convention for length, TTL,
type/class, and offset fields.** `_protocol/incoming.pxd`,
`_cache.pxd`, and `_handlers/*.pxd` already declare these as
`unsigned int` end-to-end. Introducing a `cdef int` return that
carries a value originally decoded into `unsigned int` flips
sign for any value with bit 31 set TTL is a 32-bit DNS field
(RFC 1035 3.2.1, interpreted as unsigned), so a large TTL
passed back through a `cdef int` boundary becomes negative and
trips `< 0` sentinel branches. Stay with `unsigned int` across
the whole call chain; if you need a real sentinel, return an
explicit value (`UINT_MAX`, a dedicated constant) and check for
it by equality.
- **Module-level Python int constants force `PyLong_AsLong` on
every hot-path comparison.** `if record.type == _TYPE_PTR`
compiles to a Python attribute lookup + `PyLong_AsLong` per
call when `_TYPE_PTR` is just a `.py`-level binding. The repo
already follows the right pattern `_cache.pxd` /
`record_manager.pxd` declare `cdef unsigned int _TYPE_PTR`,
`_DNS_PTR_MIN_TTL`, `_MIN_SCHEDULED_RECORD_EXPIRATION`, etc.
When adding a new size / TTL / type constant from `const.py`
to a `cdef` hot path in `_protocol/`, `_cache`, `_handlers/`,
or `_listener`, add the `cdef`-typed alias to the corresponding
`.pxd` at the same time.
- **Sign-compare warnings in generated C are real.** `gcc`/
`clang` warns when comparing `unsigned int` with `int` because
the signed value is implicitly converted to unsigned for the
compare a negative value becomes a huge positive. Match the
signedness of compared operands in the `.pxd` (e.g. if the
local is `unsigned int`, declare the constant as
`cdef unsigned int`; if the local is `int`, declare it
`cdef int`). The warning predicts the unsigned -> signed
overflow class of bug.
- **CodSpeed regressions only show up in the Cython build.**
Pure-Python (`SKIP_CYTHON=1`) tests can pass while production
wire-format hot paths regress. Trust the CodSpeed check on PRs
that touch any file in `TO_CYTHONIZE`; rebuild in place with
`REQUIRE_CYTHON=1 poetry install --only=main,dev` before
pushing if perf-sensitive code changed.
## Reporting security issues
Suspected security vulnerabilities go through GitHub's [private
vulnerability reporting][gh-report], not public issues or pull
requests. The policy is spelled out in [SECURITY.md](SECURITY.md).
If a user describes what sounds like a vulnerability in chat,
point them at that route instead of opening a public issue, PR,
or commit that names the bug class and the affected code path.
[gh-report]: https://github.com/python-zeroconf/python-zeroconf/security/advisories/new
## Useful entry points
| Path | What |
| -------------------------------- | ------------------------------------------------------------------------------ |
| `src/zeroconf/__init__.py` | Public package re-exports `Zeroconf`, `ServiceBrowser`, `ServiceInfo`, etc. |
| `src/zeroconf/asyncio.py` | Async API: `AsyncZeroconf`, `AsyncServiceBrowser`, `AsyncZeroconfServiceTypes` |
| `src/zeroconf/_core.py` | `Zeroconf` core socket setup, send/recv loop, registration/probing |
| `src/zeroconf/_engine.py` | Asyncio engine driving the listener |
| `src/zeroconf/_listener.py` | Cython-accelerated packet listener |
| `src/zeroconf/_cache.py` | DNS record cache (Cythonized) |
| `src/zeroconf/_dns.py` | DNS record / question classes (Cythonized) |
| `src/zeroconf/_history.py` | Outgoing-question history for known-answer suppression |
| `src/zeroconf/_record_update.py` | Record-update dataclass passed to listeners |
| `src/zeroconf/_protocol/` | `DNSIncoming` / `DNSOutgoing` wire codec (Cythonized) |
| `src/zeroconf/_handlers/` | Query / answer / multicast queueing (Cythonized) |
| `src/zeroconf/_services/` | `ServiceBrowser`, `ServiceInfo`, `ServiceRegistry`, types |
| `src/zeroconf/_updates.py` | `RecordUpdateListener` base class (Cythonized) |
| `src/zeroconf/_utils/` | `ipaddress`, `time`, `net`, `name`, `asyncio` helpers |
| `src/zeroconf/const.py` | Timeouts, intervals, multicast group constants |
| `src/zeroconf/_exceptions.py` | Public exception hierarchy |
| `tests/` | Pytest suite |
| `tests/benchmarks/` | CodSpeed benchmarks |
| `bench/` | Manual microbenchmarks (not run in CI) |
| `build_ext.py` | `TO_CYTHONIZE` list + `poetry-core` build hook |
## Things not to do
- **Don't hand-edit the generated `.c` files** next to Cythonized
modules. They are build output; modify the `.py` (and `.pxd`)
and let Cython regenerate.
- **Don't change a Cythonized module's `cdef class` layout or a
`cpdef`/`cdef` signature without updating its `.pxd`** the
extension build will silently pick up a stale declaration and
the resulting wheel will crash at import time.
- **Don't add `Co-Authored-By` trailers from automated agents
to commits** in this repo.
- **Don't introduce a PR title that violates Conventional
Commits.** The `pr-title` job will fail the PR.
- **Don't tighten timings or constants in `const.py` without an
RFC citation in the commit message.** mDNS interop with
Avahi / Bonjour / Windows hinges on those numbers.
- **Don't bypass `BuildExt`'s exception swallowing in
`build_ext.py` without thought.** Pure-Python fallback is a
feature for source installs on platforms without a compiler
(and for the PyPy matrix entries, which never load the C
extensions).
- **Don't break the free-threaded test matrix entry (`3.14t`).**
CPython 3.14t exercises this code without the GIL; module-
level mutable state and unguarded cross-thread Cython attribute
access will surface as flakiness there before anywhere else.