Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions .claude/skills/pr-workflow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
name: pr-workflow
description: Create pull requests for python-zeroconf/python-zeroconf. Use when creating PRs, submitting changes, or preparing contributions.
allowed-tools: Read, Bash, Glob, Grep
---

# python-zeroconf PR Workflow

When creating a pull request for `python-zeroconf/python-zeroconf`,
follow these steps. Repo-wide conventions live in
[CLAUDE.md](../../../CLAUDE.md); this skill summarises the parts
that matter at PR-creation time.

## 1. Create branch from origin/master

The default branch is `master`, not `main`. `origin` already
points at `python-zeroconf/python-zeroconf` — there is no fork in
this workflow. Always re-fetch first so the branch is based on
the latest `master`:

```bash
git fetch origin
git checkout -b <branch-name> origin/master
```

If you accidentally branch from `main`, `gh pr create` will fail
because the base branch does not exist.

## 2. There is no PR template

`python-zeroconf` does not ship a `.github/PULL_REQUEST_TEMPLATE.md`
— PR bodies are free-form. Aim for a body that looks roughly like:

```
## Summary
<1–3 sentence prose description of what changed and why>

## Details
<bullets explaining the non-obvious parts: RFC sections cited,
performance characteristics measured, edge cases handled>

## Test plan
- [ ] <how you verified the change>
- [ ] <new tests added under tests/ — point at the file>
```

Cite the relevant RFC section (RFC 6762 / RFC 6763) for any
behaviour change that affects packet contents or timing —
reviewers shouldn't have to reverse-engineer why a constant moved
or a probe interval changed.

## 3. Commit message conventions

Commit messages are linted by `commitlint` with
`@commitlint/config-conventional`, _and_ by `commitizen` in
pre-commit (`stages: [commit-msg]`). Both must pass.

- **Conventional Commits prefix is required.** Pick from:
`feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`,
`ci`, `chore`, `style`, `revert`. The `feat`/`fix`/`perf`
prefixes show up in the release-notes; `chore*` and `ci*` are
excluded by semantic-release (`exclude_commit_patterns` in
`pyproject.toml`), so use those for housekeeping.
- **Imperative-mood subject.** "fix: handle empty answer", not
"fix: handled empty answer".
- **No header length cap.** The commitlint config sets
`header-max-length: [0, "always", Infinity]`, so a slightly
longer subject is fine if it earns the space; don't pad.
- **No `Co-Authored-By` trailers from automated agents.**
- **One logical change per commit.** Let pre-commit run (ruff
lint + format, mypy, flake8, codespell, cython-lint,
pyupgrade). If a hook auto-fixes something, re-stage and
re-commit; the commit-msg hook re-runs on the new commit.

## 4. Cython / `.pxd` discipline

If the PR touches any module listed in `TO_CYTHONIZE`
(`build_ext.py`):

- Update the sibling `.pxd` in the same commit if you changed a
`cdef class` layout or a `cpdef`/`cdef` signature.
- Do not hand-edit the in-tree `.c` files; the build regenerates
them, and they're excluded from sdist (`exclude = ["**/*.c"]`
in `pyproject.toml`).
- Verify the extension still builds locally:
`REQUIRE_CYTHON=1 poetry install` (re-installs in-place,
failing loudly if Cython rejects anything).
- Verify it still works without the extension:
`SKIP_CYTHON=1 poetry install && poetry run pytest tests/`.

## 5. Push and create the PR

```bash
git push -u origin <branch-name>
gh pr create --repo python-zeroconf/python-zeroconf --base master \
--title "<conventional-commit subject>" \
--body-file /tmp/pr-body.md
```

Always pass the body via `--body-file`, never `--body "..."` with
shell-escaping — Markdown backticks, asterisks, and angle
brackets must pass through verbatim.

The PR title should match the commit subject (same Conventional
Commits prefix). If the PR ends up squash-merged, the title
becomes the merged commit message, so it has to satisfy
commitlint on its own.

## 6. After the PR is open

CI runs three jobs:

- `lint` — `pre-commit/action`. If pre-commit passed locally
this passes too.
- `commitlint` — `wagoid/commitlint-github-action`. Validates
every commit on the PR; if you amended after pushing, force-
push the branch so the rewritten commits get linted.
- `test` — the full pytest matrix across CPython 3.9–3.14,
3.14t (free-threaded), and PyPy 3.9 / 3.10, on Linux + macOS +
Windows. The free-threaded entry is the canary for unguarded
shared-state bugs; failures there are often genuine even when
the GIL-enabled rows pass.

CodSpeed also runs on PRs (`CodSpeedHQ/action`) and posts a
benchmark delta as a check. A regression there is signal — if
the PR is a perf change, the comment is the evidence; if not, a
red CodSpeed check usually means the hot path picked up an extra
Python-level branch and wants a second look.
209 changes: 209 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# 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.9"`, `target-version = "py39"` for
ruff; pyupgrade runs `--py39-plus`.

- **Imports**: ruff/isort sorted, `profile = "black"`,
`known_first_party = ["zeroconf", "tests"]`. Prefer
`from __future__ import annotations` so modern type syntax
works on 3.9.

- **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 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-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.9 – 3.14, the free-threaded
3.14t build, and PyPy 3.9 / 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.

## 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 commit message that violates Conventional
Commits.** The commitlint 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.
Loading