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 for the user-facing intro.
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).
-
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 whatzeroconf/__init__.pyandzeroconf/asyncio.pyre-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"]. Preferfrom __future__ import annotationsso modern type syntax works on 3.10. -
Generated
.cfiles are not lint-targets.*.cfiles next to each cythonized module are Cython output — never hand- edit them. They are excluded from sdist (exclude = ["**/*.c"]inpyproject.toml) and regenerated by the build.
- Conventional Commits are enforced. CI runs commitlint with
@commitlint/config-conventional, and pre-commit runs commitizen on the commit message. The header has no length cap (header-max-length = [0, "always", Infinity]), but the type prefix is required:feat:,fix:,chore:,ci:,docs:,refactor:,test:,perf:,build:, etc.semantic-releaseexcludeschore*andci*from the changelog, so use those prefixes for housekeeping and reservefeat/fix/perffor user-visible changes. - No
Co-Authored-Bytrailers 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.mdin this repo — the PR body is free-form. Thepr-workflowskill (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
lintjob is justpre-commit/action, so a green local pre-commit run = a green CI lint job.
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).
- Cython is optional but expected in wheels.
build_ext.pycythonizes every module listed inTO_CYTHONIZE. The build is driven bypoetry-core(generate-setup-file = true,script = "build_ext.py");BuildExt.build_extensionsswallows build failures so source installs fall back to pure Python.SKIP_CYTHON=1skips the Cython step entirely;REQUIRE_CYTHON=1re-raises so a missing extension fails the build loudly (CI wheel builds use this). - Modules that get Cythonized ship a sibling
.pxdfor type declarations. When changing the signature of a Cythonized function — or adding a new attribute to acdef class— update the.pxdin the same commit, or the extension will pick up a stale declaration and the in-tree.cwill be regenerated with the wrong layout. - Adding a new module to
TO_CYTHONIZEis a deliberate decision: the module must be hot enough to matter, must not rely on Python-only constructs that Cython refuses (the existingPERF401,PYI032,PYI041ruff ignores exist because Cython rejects closures and PEP 604 unions incpdef), and must stay free-threading-safe. compiler_directives = {"language_level": "3"}. The build pipeline does not currently setfreethreading_compatible, but the test matrix exercises 3.14t, so any new Cython module needs to keep working there.
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. Declaringcdef unsigned int _ANSWER_STRATEGY_POINTERinquery_handler.pxdmakes Cython treat_ANSWER_STRATEGY_POINTER = 1inquery_handler.pyas a C int assignment; the Python module dict never gets the binding.from zeroconf._handlers.query_handler import _ANSWER_STRATEGY_POINTERsucceeds in pure-Python but raisesImportErrorunder Cython. If you need the value visible from Python (e.g. a test wants to assert on it), define both names — a publicANSWER_STRATEGY_POINTER = 1Python binding plus acdef-typed_ANSWER_STRATEGY_POINTER = ANSWER_STRATEGY_POINTERalias for hot-path comparisons. -
Match the existing
unsigned intconvention for length, TTL, type/class, and offset fields._protocol/incoming.pxd,_cache.pxd, and_handlers/*.pxdalready declare these asunsigned intend-to-end. Introducing acdef intreturn that carries a value originally decoded intounsigned intflips 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 acdef intboundary becomes negative and trips< 0sentinel branches. Stay withunsigned intacross 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_AsLongon every hot-path comparison.if record.type == _TYPE_PTRcompiles to a Python attribute lookup +PyLong_AsLongper call when_TYPE_PTRis just a.py-level binding. The repo already follows the right pattern —_cache.pxd/record_manager.pxddeclarecdef unsigned int _TYPE_PTR,_DNS_PTR_MIN_TTL,_MIN_SCHEDULED_RECORD_EXPIRATION, etc. When adding a new size / TTL / type constant fromconst.pyto acdefhot path in_protocol/,_cache,_handlers/, or_listener, add thecdef-typed alias to the corresponding.pxdat the same time. -
Sign-compare warnings in generated C are real.
gcc/clangwarns when comparingunsigned intwithintbecause 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 isunsigned int, declare the constant ascdef unsigned int; if the local isint, declare itcdef 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 inTO_CYTHONIZE; rebuild in place withREQUIRE_CYTHON=1 poetry install --only=main,devbefore pushing if perf-sensitive code changed.
Suspected security vulnerabilities go through GitHub's private vulnerability reporting, not public issues or pull requests. The policy is spelled out in 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.
| 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 |
- Don't hand-edit the generated
.cfiles 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 classlayout or acpdef/cdefsignature 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-Bytrailers from automated agents to commits in this repo. - Don't introduce a commit message that violates Conventional Commits. The commitlint job will fail the PR.
- Don't tighten timings or constants in
const.pywithout an RFC citation in the commit message. mDNS interop with Avahi / Bonjour / Windows hinges on those numbers. - Don't bypass
BuildExt's exception swallowing inbuild_ext.pywithout 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.