diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..984bbef3 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,333 @@ +# Contributing + +Thanks for looking. gp-sphinx is alpha (`0.1.0a37`) — the most useful +thing right now is a bug report with a reproduction, or a note on where +a `docs/` page misled you. + +How this project writes prose — README, `CHANGES`, commit messages, +docstrings, source comments, and every `docs/` page — is set out +separately in [WRITING.md](WRITING.md). Read that before changing any of +it. The constraints every change is held to, and the map of what is +where, are in [AGENTS.md](../AGENTS.md). + +## Getting set up + +Install [git] and [uv]: + +```console +$ git clone https://github.com/git-pull/gp-sphinx.git +``` + +```console +$ cd gp-sphinx +``` + +```console +$ uv sync --all-packages --all-extras --group dev +``` + +`gp-furo-theme`'s build backend (`sphinx_vite_builder.build`) runs +`pnpm exec vite build` during that install, so it needs [pnpm] and +[Node] on `PATH`. Working on Python only, without a JS toolchain, set +the documented escape hatch first — it makes the backend short-circuit +instead of failing: + +```console +$ export SPHINX_VITE_BUILDER_SKIP=1 +``` + +[git]: https://git-scm.com/ +[uv]: https://github.com/astral-sh/uv +[pnpm]: https://pnpm.io/installation +[Node]: https://nodejs.org/ + +## The gates + +CI is the order of record (`.github/workflows/tests.yml`); every gate it +runs has to pass before a change is done. + +Format: + +```console +$ uv run ruff format . --check +``` + +Lint: + +```console +$ uv run ruff check . +``` + +Type-check: + +```console +$ uv run mypy . +``` + +Test: + +```console +$ uv run pytest +``` + +Documentation is a gate, not a courtesy. Examples in docstrings and in +the `docs/_ext/` demo modules are executed by `pytest`; the doctest +flags live in `pyproject.toml`, so there is no separate doctest step and +a green `pytest` is the proof for those. Markdown pages under `docs/` +are not executed — see +[WRITING.md](WRITING.md#documented-examples-that-run) for exactly which +blocks run and the one mistake that silently removes a test. + +Before claiming a test or a gate works, show it failing. A gate that has +never been red is an assumption. + +## Code style + +`ruff` and `mypy` catch most of this automatically; the rest is +convention the linters do not enforce. + +- **Standard library imports are namespaced**: `import enum`, not + `from enum import Enum`. Third-party packages may use `from X import + Y`. `dataclasses` is the one standard-library exception, for the + cleaner `from dataclasses import dataclass, field` decorator syntax. +- **Typing uses `import typing as t`**, accessed via the namespace: + `t.NamedTuple`, `t.Callable`, and so on. +- **`from __future__ import annotations`** is required at the top of + every Python file; `ruff`'s `required-imports` enforces it. +- **Prefer the typed `env.domains._domain` accessors** over + `env.get_domain("")` — `env.domains.python_domain`, not + `env.get_domain("py")`. The typed accessors return the concrete + domain subclass, so mypy sees subclass-specific attributes + (`progoptions`, `data["objects"]`, …) without a cast. They require + Sphinx 8.1's `_DomainsContainer`, which is this workspace's floor. + +### Logging + +- Use `logging.getLogger(__name__)` in every module; add a + `NullHandler` in library `__init__.py` files. Never configure + handlers, levels, or formatters in library code — that is the + application's job. +- Use lazy formatting — `logger.debug("msg %s", val)`, not an f-string — + so interpolation is skipped when the level is filtered and log + aggregators group messages by template instead of by literal string. + Guard an expensive `val` with `if logger.isEnabledFor(logging.DEBUG)`. +- Messages are lowercase, past tense, and end without punctuation: + `"config merged"`, not `"Config merged."`. Keep the message short; put + details in `extra`. +- Use `logger.exception()` only inside an `except` block you are not + re-raising from. Use `logger.error(..., exc_info=True)` for a + traceback outside an `except` block. `logger.exception()` followed by + `raise` duplicates the traceback. +- Assert on `caplog.records`, not `caplog.text` — `caplog.record_tuples` + cannot see `extra` fields. Scope capture with + `caplog.at_level(logging.DEBUG, logger="gp_sphinx.config")`. + +## Tests + +Preferred local commands use a fixed pytest temp root under `.cache/` +and disable tmp-path retention for speed: + +```console +$ just test +``` + +Use raw `uv run pytest` for the conservative direct runner without that +local optimization — this is also CI's own command. + +Fast local loop, without `--doctest-modules` or integration tests: + +```console +$ just test-fast +``` + +Do not use the fast lane to reason about full-suite coverage or total +suite performance; it is intentionally deselected for local iteration. +`just test` (or plain `pytest`) is the coverage-complete lane. + +Run continuously while developing: + +```console +$ just start +``` + +Requires [pytest-watcher]; `just start-fast` runs the fast lane +continuously instead, and `just watch-test` (requires [entr(1)]) is a +third option. + +[pytest-watcher]: https://github.com/olzhasar/pytest-watcher +[entr(1)]: http://eradman.com/entrproject/ + +### Test level hierarchy + +Pick the **lightest** level that exercises the behaviour. Never reach +for a full Sphinx build when a docutils node test suffices — an +integration build takes 2-10 s, a node test runs in microseconds. + +| Level | When to use | +| --- | --- | +| Pure unit | Transforming strings, dicts, dataclasses — no nodes, no Sphinx | +| Docutils tree unit | Testing transforms/visitors/renderers by constructing `nodes.*` directly | +| Snapshot unit | Same as docutils tree, but output is large or complex — assert via `snapshot_doctree` | +| Sphinx integration (`@pytest.mark.integration`) | Any test that constructs a `Sphinx` app, including `buildername="dummy"`, walks a built doctree, or asserts on `result.warnings` | + +All tests are plain `def test_*` functions — no `class TestFoo:` +groupings. Every test function and every `NamedTuple` fixture class is +fully type-annotated; mypy runs over `tests/` in CI. + +### Parametrizing with NamedTuple + +Use `t.NamedTuple` for any parametrized test with three or more inputs. +Two wiring styles are in use; pick whichever reads more clearly for the +case at hand — unpack all fields as separate parameters (dominant, +self-documenting signature), or pass the whole struct as `case` when it +is reused in assertion messages or has many fields. `test_id: str` is +always the first field; the fixture list is `_FOO_FIXTURES` +(module-private, all-caps); the fixture class is `FooFixture` or +`FooCase`, never `TestFoo`. + +### Docutils tree unit tests + +Construct `docutils.nodes` and `sphinx.addnodes` objects directly to +test transforms, visitors, and renderers without a Sphinx build — follow +the pattern in `tests/ext/layout/test_transforms.py`. Put `_make_*()` +builder helpers at the top of the test file. Never import +`sphinx.application.Sphinx` in a pure tree test. + +### Snapshot tests + +[syrupy] backs three fixtures (`tests/_snapshots.py`, loaded via +`pytest_plugins`) that normalize their inputs before asserting, so +build-path churn and docutils version noise do not cause spurious +failures: `snapshot_doctree`, `snapshot_html_fragment`, and +`snapshot_warnings`. Update stored snapshots after an intentional output +change: + +```console +$ uv run pytest --snapshot-update +``` + +[syrupy]: https://github.com/toptal/syrupy + +### Integration tests (full Sphinx build) + +Use the harness in `tests/_sphinx_scenarios.py`: +`SphinxScenario`/`ScenarioFile` describe a synthetic project; +`build_shared_sphinx_result()` builds once per content-hash digest and +`build_isolated_sphinx_result()` builds fresh per test for mutating +assertions; `get_doctree()` and `read_output()` read back the result. +Always use a **module-** or **session-scoped** fixture for the build, +never function-scoped, so the expensive build is shared across the +module's tests — follow `tests/ext/typehints_gp/test_integration.py`. +Mark every such test `@pytest.mark.integration`. + +`build_shared_sphinx_result()`'s content-hash caching is why the full +suite runs in seconds rather than tens of seconds; see +`notes/test-analysis.md` for the profiling data and the per-package +migration history behind that harness. + +### Available fixtures + +| Fixture | Source | When to use | +| --- | --- | --- | +| `tmp_path`, `tmp_path_factory` | pytest | Per-test / per-session temp directories | +| `monkeypatch` | pytest | Env vars, module attributes, `sys.modules` patching | +| `caplog` | pytest | Log assertions — use `.records`, not `.text` | +| `snapshot_doctree`, `snapshot_html_fragment`, `snapshot_warnings` | `tests/_snapshots.py` | Normalized snapshot assertions | +| `spf_suite_root`, `spf_doctree_root`, `spf_html_root` | `tests/ext/pytest_fixtures/conftest.py` | Session roots for the pytest-fixtures extension tests | +| `simple_parser`, `parser_with_groups`, … | `tests/ext/argparse/conftest.py` | `ArgumentParser` permutations for argparse tests | + +### Anti-patterns + +No `class TestFoo:` groupings. No `unittest.mock.patch` — use +`monkeypatch`. No `tempfile.mkdtemp()` — use `tmp_path`. No `Sphinx()` +instantiation in a unit test — build docutils nodes directly. No +unannotated test functions. No inline tuples in `parametrize` with three +or more fields — use `NamedTuple`. No function-scoped Sphinx build +fixtures. + +## Documentation + +Default preview server: . + +[sphinx-autobuild] builds the docs, watches for file changes, and serves +them: + +```console +$ just start-docs +``` + +Build once: + +```console +$ just build-docs +``` + +Both are repository-root wrappers around `docs/justfile`; run +`just html`, `just serve`, `just watch-docs` (requires [entr(1)]), or +`just dev-docs` directly from inside `docs/` for the individual steps. + +CI builds with warnings as errors: + +```console +$ uv run sphinx-build -W -b dirhtml docs docs/_build/html +``` + +`docs/packages//` pages, the API reference, and the changelog page +are generated from live workspace metadata and `CHANGES` respectively — +see [WRITING.md](WRITING.md#generated-pages) before hand-editing one. + +[sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild + +## Releasing + +Never create tags. Never push tags. The owner handles tagging and tag +pushes, because a tag triggers the publish workflow. See +[Release commits](WRITING.md#release-commits). + +All publishable workspace packages share one lockstep version. Bump it +everywhere with: + +```console +$ just bump-version +``` + +That updates every `pyproject.toml` and exposed `__version__`, relocks, +and validates the result via `scripts/ci/package_tools.py +check-versions`. Update `CHANGES` before or alongside the bump. The +release commit itself is plain and short (`Tag v`), per +[Release commits](WRITING.md#release-commits) — the release manager +creates and pushes the tag after review; that push is what triggers +`release.yml` to build every package and publish to PyPI. See +[the releasing reference](https://github.com/git-pull/gp-sphinx/blob/main/docs/project/releasing.md) +for the full checklist. + +## Pull requests + +One subject per pull request. Unrelated cleanup found along the way +belongs in its own commit, and usually in its own pull request. + +Discuss a substantial change via an issue before making it. + +You may merge once you have the sign-off of one other developer. If you +do not have permission to merge, ask a reviewer to merge it for you. + +Commit format is in [WRITING.md](WRITING.md#commits). + +## Decorum + +- Participants will be tolerant of opposing views. +- Participants must ensure that their language and actions are free of + personal attacks and disparaging personal remarks. +- When interpreting the words and actions of others, participants + should always assume good intentions. +- Behaviour which can be reasonably considered harassment will not be + tolerated. + +Based on [Ruby's Community Conduct Guideline](https://www.ruby-lang.org/en/conduct/). + +## Security + +Please do not open a public issue for a vulnerability. Use GitHub's +private reporting instead: open a +[new security advisory](https://github.com/git-pull/gp-sphinx/security/advisories/new) +on this repository. diff --git a/.github/WRITING.md b/.github/WRITING.md new file mode 100644 index 00000000..4d17dd44 --- /dev/null +++ b/.github/WRITING.md @@ -0,0 +1,782 @@ +# Writing + +How this project writes prose, for humans and agents alike. It governs +`README.md`, `CHANGES`, commit messages, docstrings, source comments, and +every `docs/` page — including the MyST and Sphinx conventions downstream +repositories inherit through `merge_sphinx_config()`. + +For environment setup, the gates, and pull request workflow, see +[CONTRIBUTING.md](CONTRIBUTING.md). + +## Voice + +Three surfaces, one voice. A docstring says what a caller may rely on; a +`CHANGES` entry says what changed; prose says what happens. All three are +present tense, lead with the thing being described, and stop. Why it was +built that way belongs in the commit message, which is timestamped and +attached to the diff. + +The most useful editing operation is deleting the introductory sentence. + +Lead with verbs and name concrete things. Put identifiers in backticks. +Prefer short declarative sentences, one operational fact each. Do not +explain Python to Python developers; do explain this project's semantics. + +Type annotations describe shape. Documentation describes meaning. A +sentence that restates a signature has said nothing. + +Use MUST, SHOULD, and MAY only where the normative sense is meant. Say what +actually happens rather than that something is "supported". + +| Instead of | Prefer | +| --------------------------------- | --------------------------------- | +| "We added…" | "`merge_sphinx_config()` now accepts…" | +| "New and improved" | "`DEFAULT_EXTENSIONS` now…" | +| "powerful", "seamless" | state the capability | +| "easily", "simply", "just" | omit | +| "simple", "obvious", "intuitive" | omit | +| "robust" | name the failure that is handled | +| "comprehensive" | name what is covered | +| "production-ready" | state the guarantee | +| "optimized", "blazingly fast" | give the magnitude | +| "various fixes" | name the components | +| "under the hood" | omit unless observable | +| "please note that", "note that" | state the fact | +| "leverage", "utilize" | "use" | +| "delve into" | "read", or omit | +| "best practices" | name the practice | +| "in order to" | "to" | + +## Who you are writing for + +The default reader is fluent in Python and new to this workspace. They can +read a signature; they cannot guess gp-sphinx's semantics. Serve them +first. + +A second, smaller reader works *on* the workspace: adding a package, +extending an autodoc extension, touching `gp-furo-theme`'s `web/src` +assets or the `sphinx-vite-builder` backend. Serve them too, but mark +their material opt-in — "for workspace contributors", "advanced" — so the +default reader knows they can stop. + +Rules that follow: + +- **Second person, present tense, active.** "You pass `docs_url`", not "SEO + values are derived". Address the reader doing the thing. +- **Concept before API surface.** Open by saying what the object or + function *is* and what it does for the reader. The signature, or the + kwarg list, is the last detail they need, not the first. A page that + opens with "pass these keyword arguments" has buried the idea under its + mechanics. +- **Say when they can stop.** Lead with the default and the reassurance: + the ~10-line `merge_sphinx_config()` call is the whole integration; + everything past it is optional. Let a skimmer leave after one + paragraph. +- **Grant permission, do not demand attention.** "Reach for this when…" + tells readers they are in the right place without implying they must + read on. +- **Progressive disclosure.** Order by how many readers need it: the + coordinator call → the one kwarg a few will tune (`docs_url`, + `extra_extensions`) → a single package's own options → workspace + internals. Each step is for a smaller audience than the last. +- **Lean on the merge order.** The reader's mental model of + `merge_sphinx_config()` is a pipeline: shared `DEFAULT_*` constants, + then values auto-computed from `source_repository` and `docs_url`, then + `**overrides` applied last — an explicit value always wins. Reinforce + that order when explaining who sets what. On package pages the + equivalent is the tier map: shared infrastructure → autodoc extensions + → theme and coordinator. +- **Name the trade-off.** If a call or option costs something — an extra + round trip, a stale object needing a refresh, a polling wait — say so, + and say what it buys. `vite_orchestration=True` spawns a pnpm/Vite + watcher under `sphinx-autobuild`: contributors need Node, wheel + consumers don't. State it; do not sell it. +- **Frame by concept, not by mechanism.** Do not headline a feature by its + kwarg or CSS custom property in prose; that names the implementation + surface, the reader's last concern. Name the concept. The mechanics + vocabulary — a Parameter/Type/Default table, a `DEFAULT_*` constant — + is correct in `docs/configuration.md`'s reference tables, and only + there. + +### What stays precise + +Warm the framing, never the facts. Parameter tables, auto-computed value +mappings, `DEFAULT_*` constant tables, exact extension names, and +cross-references carry meaning in their exact form — leave them alone. +The friendly voice belongs in the sentences *around* a precise block, +introducing it, not inside it paraphrasing it into vagueness. + +### Keeping examples honest + +The `conf.py` snippets on `docs/` pages are illustrative — no test +executes them (see +[Documented examples that run](#documented-examples-that-run)), so every +kwarg shown must exist in `merge_sphinx_config()`'s real signature. The +nearest thing to a test: this site is its own flagship consumer, so +building the docs exercises what the snippets promise. What *does* run: +the gallery renders live from the demo modules in `docs/_ext/` — nothing +is mocked — and those modules' doctests execute as part of `pytest`. +Keep them passing. + +### Generated pages + +Every `docs/packages//` page gets its "Copyable config snippet" and +"Package metadata" sections from the `{package-landing}` and +`{package-reference}` directives (`docs/_ext/package_reference.py`), +which read live workspace metadata — do not hand-write what they +generate; a new `packages//pyproject.toml` appears on the next +build with no code change. Surface documentation for config values, +directives, and roles belongs to `autoconfigvalues`, `autodirective`, +and `autorole`; invoke them instead of transcribing it into prose. + +### Cross-references + +Point the advanced reader at the deep-dive rather than inlining it, and +put the link where their interest peaks — on the phrase that made them +curious ("write your own autodoc extension") — not as a footnote the eye +skips. See [Sphinx and MyST conventions](#sphinx-and-myst-conventions) +for which role to reach for. + +Link the first prose mention of any symbol that has a useful destination +on that page: Python objects, gp-sphinx APIs, workspace package pages, +configuration anchors, and external tools or projects. After the first +linked mention on a page, later mentions can stay plain unless distance +or context makes another link useful. Do not rely on a later reference +section to satisfy the first-mention rule — if the first occurrence would +be a heading, grid-card teaser, or introductory sentence, link that +occurrence or retitle the heading. Leave command examples, code blocks, +and literal configuration values as code; link the surrounding prose +instead. + +A `{ref}` must match its target's anchor exactly — anchors mix hyphen and +underscore forms, sometimes inside one anchor (`from-docs_url`). Building +the docs catches a broken `{ref}`; nothing else does. A py-domain role +(`{py:class}`, `{py:data}`, …) is not covered by that check — +`nitpicky` is unset, so an unresolved one renders as plain text and the +build stays silent. Confirm by opening the built page and checking the +name sits inside an ``. + +### A page that does this + +`docs/packages/gp-sphinx/how-to.md` is the worked example — the concept +before the config, the trade-off named, the deep dive linked instead of +inlined. Read it before reshaping another page. + +## README + +A README is the shortest path from "what is this?" to competent use, not +the project's autobiography. + +The first sentence is a contract. It says what abstraction the reader has +been handed, concretely enough to tell this package apart from the +neighbouring one. + +Get to a runnable command or snippet before anything the reader can skip. +A logo, a mission statement, a comparison matrix and three paragraphs of +history in front of the install line all cost the same thing. + +State the minimum Python version and meaningful platform constraints in +prose, not only in badges. `requires-python` in `pyproject.toml` is the +authority; the README must agree with it. + +Name the distribution, the import, and the executable separately wherever +they differ. That distinction prevents a Python-specific class of +confusion. + +Examples are executable, not illustrative fiction. Never +`your-command `. See +[Documented examples that run](#documented-examples-that-run) for which +blocks are executed and how to write one that qualifies. + +Document the semantic model, not the flag list. What it cannot say is +precedence, filesystem effects, what goes to stdout versus stderr, and +what a non-zero exit means. + +State defaults explicitly — defaults are API. State negative guarantees +where they exist: "does not modify your configuration file", "no network +access", "never writes outside the destination". They establish +boundaries faster than any amount of description. + +Headings stay conventional and stable, because people deep-link them. +Badges are few and load-bearing. + +## Documented examples that run + +Examples in this fleet are tests, where the collector reaches them. This +section is the contract for writing one the test suite can actually see +**in this repository**. + +**A fence tag is cosmetic. Only a `>>> ` prompt executes, and only inside +a Python module.** `pytest`'s `--doctest-modules` flag (set in +`pyproject.toml`'s `addopts`) collects doctests from importable `.py` +files under `testpaths` — the workspace `tests/`, `docs/` (its `_ext/` +demo and extension modules), and every `packages/*/src`. It does **not** +collect Markdown or reStructuredText: gp-sphinx has no `doctest-glob` or +RST doctest plugin configured, so a `>>> ` block inside `README.md` or a +page under `docs/` is prose that looks like a test. Nothing runs it. + +This matters more here than elsewhere in the fleet, because gp-sphinx's +own docs (`docs/packages/*/how-to.md`) are full of fenced `python` +blocks written to *look* like the docstring examples they document. That +resemblance is intentional — see +[Keeping examples honest](#keeping-examples-honest) — but it is +illustration, not a test. Do not add a `>>> ` prompt to a Markdown page +expecting it to run; add it to the docstring the page is illustrating. + +**The fence tag is `python`.** Not `pycon`, not bare. This stays uniform +even for illustrative blocks, so a reader cannot tell test from +illustration by fence tag alone — only by whether the file is a `.py` +module under `testpaths`. + +**`# doctest: +SKIP` is not permitted.** It is a workaround that tests +nothing. Use the fixtures. + +**Do not downgrade a doctest to a non-executed block to make it pass.** A +`.. code-block::` or an unprompted fence does not run. If an example +cannot pass, fix the example or fix the code. + +**Option flags.** `ELLIPSIS` and `NORMALIZE_WHITESPACE` are enabled +globally, so `...` elides variable output and whitespace differences do +not fail a comparison. Reach for an inline `# doctest: +FLAG` only for +the block that needs it. `sphinx-autodoc-typehints-gp` additionally +recognizes `# doctest: +HIDE` to drop incidental setup lines — socket +paths, environment scaffolding — from the *rendered* docstring on an API +page without touching the source; it has no effect under plain `pytest`. + +**Docstring examples** use the NumPy `Examples` section. A public +function carries one where a short call demonstrates the behaviour +clearly: + + Examples + -------- + >>> from gp_sphinx.defaults import DEFAULT_EXTENSIONS + >>> "myst_parser" in DEFAULT_EXTENSIONS + True + +Treat a function without one as unfinished documentation, not as a +policy violation to block on — nothing in CI enforces a doctest on every +function, and inventing one that exercises nothing but plumbing is worse +than no example. + +**The doctest namespace is scoped to `tests/`, not the fleet.** A +`doctest_namespace` fixture in `tests/conftest.py` injects `tmp_path` (a +session-scoped writable directory) for doctests collected under `tests/`. +pytest's conftest discovery does not cross into sibling trees, so a +doctest under `packages/*/src` or `docs/_ext/` cannot rely on it — those +examples must stay self-contained or build what they need inline. Add a +name to `tests/conftest.py`'s `_doctest_namespace` fixture only if you +also confirm, by running the suite, which directories can actually see +it. + +## The changelog + +`CHANGES` is the changelog. Not `CHANGELOG.md`. It is rendered as the +project's changelog page, and follows Django's release-notes shape: +deliverables get titles and prose, not bullets. + +**Release entry boilerplate.** Every release header is +`## gp-sphinx X.Y.Z (YYYY-MM-DD)`. The file opens with a +`## gp-sphinx X.Y.Z (unreleased)` block prefaced by a single +`` HTML comment — new release entries land +below the most recent released entry, never between the comment and the +unreleased header. + +**Open with a multi-sentence lead paragraph.** Plain prose, no italic. +Open with the version as sentence subject ("gp-sphinx X.Y.Z ships …") so +the lead is self-contained when excerpted. Two to four sentences telling +the reader what shipped and who cares — user-visible takeaways, not +internal mechanism. Cross-reference detail docs with `{ref}` to keep the +lead compact. + +**Lead paragraphs are release-time material — off-limits to branches and +PRs.** The unreleased entry carries no lead paragraph and no version +summary: sections only (`### Breaking changes`, `### What's new` +deliverables, `### Fixes`, …). Speaking for the release — what the +version "is", "ships", or "focuses on" — is presumptuous before its +scope is final; only the person cutting the release writes that, and +only when the user explicitly asks to release. Never write or edit a +lead from a feature branch, and never ask or imply that a release should +happen. + +**Each deliverable is a section, not a bullet.** Inside `### What's new`, +every distinct deliverable gets a `#### Deliverable title` heading naming +it in user vocabulary, followed by one to three prose paragraphs +explaining what shipped. Do not wrap a paragraph in `- ` — bullets are +for enumerable lists, not paragraph containers. Cross-link detail docs +("See {ref}`foo` for details.") so prose stays focused. + +**The deliverable test.** Before writing an entry, ask: "What's the +deliverable, in user vocabulary?" If you cannot answer in one sentence, +the entry is not ready. Mechanism — helper internals, byte counters, +schema-validation locations — belongs in PR descriptions and code +comments, not the changelog. + +**Fixed subheadings**, in this order when present: `### Breaking +changes`, `### Dependencies`, `### What's new`, `### Fixes`, +`### Documentation`, `### Development`. Dev tooling (helper scripts, +internal automation) lives under `### Development`. For breaking +changes, show the migration path with concrete inline code (a `# Before` +/ `# After` fenced block). Dependency floor bumps use the form +`` Minimum `pkg>=X.Y.Z` (was `>=X.Y.W`) ``. + +**PR refs `(#NN)`** sit at the end of each deliverable's prose body, not +in the `####` heading. + +**When bullets are appropriate.** Catch-all sections (`### Fixes`, +occasionally `### Documentation`) with three or more genuinely small +items use bullets — one line each, never paragraphs. If a bullet swells +past two lines, promote it to a `#### Title` heading with prose body. + +**Anti-patterns.** Fragile metrics that go stale silently — token +ceilings, third-party version pins, percent benchmarks, exact byte +counts. Describe the capability, not the math. Internal jargon: private +symbols (leading-underscore identifiers), algorithm names exposed for +the first time, backend scaffolding. Walls of text dressed up as +bullets. Buried breaking changes — give them their own subheading at the +top of the entry. + +**Always link autodoc'd APIs.** Any class, method, function, exception, +or attribute with its own rendered page is cited via the matching role — +never plain backticks. Doc pages without an explicit ref label use +`{doc}`. Plain backticks are correct for code syntax, env vars, +parameter names, and file paths that are not doc pages. See +[Sphinx and MyST conventions](#sphinx-and-myst-conventions) for the role +list. + +## Docstrings + +The prime directive: never restate the type. The annotation is the +source of truth; the docstring carries what the annotation cannot. + +This is documentation debt wearing a docstring: + + def get_project_name(config: dict[str, str]) -> str: + """Get the project name. + + Parameters + ---------- + config : dict[str, str] + The config. + + Returns + ------- + str + The name. + """ + +Document instead the dimensions the type system cannot encode: + +- **Mutation.** What it changes in place. +- **Ownership.** What the caller must close, release, or keep alive. +- **Ordering.** Whether results come back in a guaranteed order. +- **Timing.** What has finished by the time the call returns. +- **Failure.** Which exceptions are raised and what triggers each. +- **Idempotence.** Whether calling twice does anything the second time. +- **Concurrency.** Whether calls are coalesced, queued, or independent. +- **Units and ranges.** What a number means and what values are + accepted. +- **Boundary behaviour.** What zero, empty, and the maximum do. +- **Platform.** Behaviour that differs by Sphinx or docutils version — + see `_compat.py` for the floors this workspace already works around. +- **Security boundary.** What is executed, and what is only read — load + bearing wherever a directive introspects a user's own modules + (autodoc, argparse, fastmcp, pytest-fixtures extensions). + +The first sentence stands alone; tooling truncates there. PEP 257 +applies: triple double quotes, an imperative one-line summary ending in +a period, a blank line before any extended description. Do not repeat an +introspectable signature. + +NumPy docstring style is the one dialect this repository uses, enforced +by `ruff`'s `pydocstyle` convention rather than relitigated in review: + + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1. + + Returns + ------- + type + Description of return value. + """ + +**Class-level names get exactly one description each.** Every name a +class declares that autodoc renders — `NamedTuple` fields, dataclass +fields including `InitVar`, `TypedDict` keys, `Enum` members, `ClassVar`s, +and plain constants — needs a description. The shape does not change the +rule. Three styles count, and `tests/docs/test_docstring_policy.py` +enforces them: a NumPy `Attributes` entry in the class docstring, a +docstring under the assignment, or a `#:` comment above it. Prefer +`Attributes`: + + class ToctreeSection(t.NamedTuple): + """One section of pages grouped by toctree caption. + + Attributes + ---------- + caption : str | None + Toctree caption, or ``None`` for an uncaptioned toctree. + docnames : list[str] + Docnames listed under the caption, in toctree order. + """ + +A `Parameters` section does **not** describe attributes — it documents +the initializer, and the attribute entries still render bare beneath it. +A field nobody describes reaches the reference as a bare name. A +`ClassVar` nobody describes is withheld from it entirely, so an +undescribed one is silently missing rather than visibly empty. + +## Sphinx and MyST conventions + +gp-sphinx owns `DEFAULT_EXTENSIONS` +(`packages/gp-sphinx/src/gp_sphinx/defaults.py`) — the extension list +every repository in the fleet inherits through `merge_sphinx_config()`. +This section is the authoring contract that list implies for `docs/` +pages here and, by inheritance, downstream. + +**MyST roles.** Class references use `{class}` (or the explicit +`{py:class}` this repository prefers on its own pages), methods +`{meth}`, functions `{func}`, exceptions `{exc}`, attributes `{attr}`, +modules `{py:mod}`, config values `{py:data}` or `{confval}`, internal +anchors `{ref}`, doc-path links `{doc}`. Use the most specific target +available; a Markdown link is correct only for something with no +autodoc destination — an external project or tool. + +**MyST parser extensions.** `DEFAULT_EXTENSIONS` turns on `colon_fence` +(the `:::` admonition and grid-card syntax used throughout `docs/`), +`substitution`, `replacements`, `strikethrough`, and `linkify`. Grid +cards (`sphinx_design`) are the standard way to present a set of +sibling links — see `docs/project/index.md` or any `docs/packages/*` +landing page for the pattern. + +**`{include}` does not carry relative links across a directory +boundary.** MyST rewrites an included file's relative links, but if the +included file lives outside the including page's directory tree, +Sphinx resolves the rewritten targets as internal cross-references and +emits dead anchors instead of following them out. `AGENTS.md` and +`CLAUDE.md` are also in `docs/conf.py`'s `exclude_patterns`, which rules +out including either directly. Where a docs page used to host content +that now lives in `.github/`, make it a short pointer page instead: keep +any `(label)=` anchor and the page title, then link the canonical file +on GitHub. + +**Generated surface documentation.** Config values, directives, and +roles that ship in this workspace are documented via `autoconfigvalues`, +`autodirective`, and `autorole` rather than transcribed into prose — +see [Generated pages](#generated-pages). + +## CSS and directive naming + +Every class, custom property, and MyST directive name a workspace +package adds lives under the `gp-sphinx-*` namespace — the naming +vocabulary downstream themes and doc authors read and depend on: + +- **Tier A (shared concepts)** — `gp-sphinx-` (`gp-sphinx-badge`, + `gp-sphinx-toolbar`). Used by multiple packages. +- **Tier B (package-owned)** — `gp-sphinx-__` BEM-style + (`gp-sphinx-fastmcp__safety-readonly`, + `gp-sphinx-pytest-fixtures__fixture-index`). +- **Modifiers** — axis-value pairs `---` + (`gp-sphinx-badge--size-xs`, `gp-sphinx-badge--type-function`). +- **Custom properties** mirror the class namespace: + `--gp-sphinx--`. Furo-owned variables (`--color-api-*`, + `--font-stack--*`) stay untouched. + +A package's own CSS must style every class its Python code emits. +Cross-package **reuse** of a shared class is fine; cross-package +**dependence** — a feature rendering correctly only because a sibling +package happens to be loaded — is not. A downstream user installing one +extension standalone must get the correct visual result. + +## Terminology and capitalization + +Pick the domain noun and keep it. If the code calls something a +`docname`, do not call it a "page path" in one paragraph and a "doc ID" +in the next. If the function is `merge_sphinx_config`, write "merge" +everywhere rather than alternating with "combine", "build", and +"assemble". + +Stable vocabulary is what makes search, deep links, and an agent's +retrieval work at all. + +Python and PyPI keep their own capitalisation. Distribution names are +written as they are published. + +Do not write counts into prose — how many packages this workspace has, +how many tests there are. They go stale silently and no reader needs +them. Counts that pin a fixture or guard an invariant are different, and +belong in code. + +## Markdown + +Prose wraps at 80 columns. Table rows, badge lines, and long links are +exempt, because breaking them harms rendering. A pull request or issue +body does not wrap at all: GitHub renders a single newline as a space in +a file and as a line break in a comment, so a wrapped comment body +arrives as ragged stubs. + +GitHub alert blocks — `> [!NOTE]`, `> [!WARNING]` — render as literal +text outside GitHub, so reserve them for at most one load-bearing +warning per document. Write the sentence so it carries the fact on its +own, and a renderer that drops the marker loses nothing. + +Do not use a local absolute path or an email address in anything +published. + +## Code blocks + +Code blocks are paste-and-run units: pasting one block runs exactly one +intended action. Executed examples are exempt — the test suite runs +them, nobody pastes them. + +- **One command per block.** Multiple steps may share a block only when + explicitly chained with `&&`, `;`, or `\` continuations — the chain is + then one logical command. +- **Explanations go in prose above the block**, never as `#` comments + inside it. +- **Command menus are per-command blocks with prose lead-ins**, not + tables. +- **Shell commands use the `console` tag with a `$ ` prefix.** This + separates interactive commands from scripts and enables prompt-aware + copy. +- **Split long commands with `\`** — one flag or flag+value pair per + indented continuation line, positional arguments last. + +Good — show the last ten commits as a graph: + +```console +$ git log \ + --max-count=10 \ + --graph \ + --oneline +``` + +Bad: + +```console +# Show the last ten commits as a graph +$ git log --max-count=10 --graph --oneline +``` + +## Commits + +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. + +what: +- Specific technical changes made +- Focused on a single topic +``` + +Keep the subject to 50 characters or fewer, excluding any trailing +`(#NN)` pull request reference, and wrap body lines at 72. Separate the +`why:` and `what:` blocks with a blank line. + +Routine maintenance commits drop the colon and take a capitalised +description, which is what distinguishes them at a glance in +`git log --oneline`: + +``` +py(deps[dev]) Bump dev packages +ai(rules[AGENTS]) Judge comments by three gates +``` + +Everything that changes behaviour keeps the colon. + +Common types: + +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **ci**: Workflow and pipeline changes +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev dependencies +- **ai(rules[AGENTS])**: AI rule updates +- **ai(claude[rules])**: Claude Code rules (`CLAUDE.md`) +- **ai(claude[command])**: Claude Code command changes + +Example: + +``` +config(feat[merge]): Add deep-merge support for theme options + +why: Enable per-project theme overrides without replacing entire dict. + +what: +- Add deep_merge() helper for nested dict merging +- Update merge_sphinx_config() to deep-merge theme_options +- Add tests for nested override behavior +``` + +For a multi-line message, use a heredoc so the formatting survives: + +```console +$ git commit -m "$(cat <<'EOF' +Scope(feat[detail]): Concise description + +why: Explanation of the change. + +what: +- First change +- Second change +EOF +)" +``` + +### Release commits + +Never create tags. Never push tags. The owner handles tagging and tag +pushes, because a tag triggers the PyPI publish workflow. + +A release commit subject is plain and short: `Tag v`. The +detailed why and what go in the body. Do not use the +`Scope(type[detail]):` format for a release — it buries the lede. + +## Slop prevention + +Treat AI slop as review-hostile noise, not as proof that text or code is +wrong. The goal is to maximise information density. + +- **AI signatures.** No "Generated by", no conversational filler, no + unexplained emoji, no tool metadata. +- **Brittle references.** No hard-coded line numbers, fragile file + counts, dated "as of" claims, bare SHAs, or local absolute paths — + unless they are strict evidentiary artefacts such as a benchmark log. +- **Diff narration.** Do not restate what moved, was renamed, or was + removed in anything the reader holds alongside the diff: code, + docstrings, README, `CHANGES`, or a pull request description. The diff + and the commit message already carry it. +- **Branch-internal narrative.** Do not mention intermediate states, + abandoned approaches, or "no longer" behaviour unless users of a + published release actually experienced the old state — did users of + the most recently published release ever experience this old name, + old behaviour, or bug? If not, it belongs in the commit message, not + the artefact. +- **Low-value scaffolding.** No ownerless TODOs, unused + future-proofing, debug artefacts, or defensive wrappers around + failure modes nothing can reach. +- **Prose inflation.** The diction table under [Voice](#voice) governs; + replace an inflated word with a concrete description of behaviour, + constraints, or trade-offs. +- **Coded labels.** Write rules and findings as plain imperatives. No + `[R1]`, `Option B`, or any index a reader has to decode. + +Preserve the "why". Never delete a comment documenting an invariant, a +protocol constraint, a platform quirk, or an upstream workaround — those +are the facts [Source comments](#source-comments) keeps, and every other +comment is judged by it. Preserve exact counts, dates, and SHAs when they +serve as evidence in benchmark results, `CHANGES` entries, or lockfiles. + +### Durable source links + +Link to a pinned revision, never to trunk, when citing source in prose an +agent or contributor will read later — a `blob/main/…` link rots +silently as the file moves and lines shift while the link keeps +resolving, landing on unrelated code. + +- Prefer a release tag (`blob/v0.1.0a37/…`). Most durable, and it tells + the reader which released version the claim held for. +- Otherwise use a 7-character commit SHA (`blob/9a29b1a/…`) reachable + from `main`. Use when there is no tag or the claim is about + unreleased code. Never a PR-head SHA — it can be rebased or + garbage-collected. +- Reserve `blob/main/…` for living documents meant to always show the + latest state, such as this file or `CONTRIBUTING.md`. +- Line anchors (`#L120-L145`) are only safe on a pinned ref. + +## Source comments + +A comment ships only if it passes all three gates. Fail any: delete or +rewrite. Borderline: delete — borderline means the information is +reconstructible, which is what makes deletion cheap. + +**Loss.** Three years from now, would losing this cost a maintainer real +time rediscovering intent, an invariant, a constraint, or a failure mode +the code and tests do not already make obvious? + +**Elite.** Would SQLite, Redis, the Go standard library, or CPython +write this comment, at this length? Those projects state the constraint +and stop. They do not argue with an imagined objector. + +**Upkeep.** Will it stay true without maintenance? A comment that +hand-syncs a value the code owns — a count, an offset, a line reference, +a duplicated constant — is false the first time that value moves. + +### Ceiling + +One or two lines. A comment reaching four is either carrying several +facts, in which case split it, or arguing, in which case cut it to the +fact. + +Rationale, alternatives weighed, and the story of how the code got here +belong in the commit message: timestamped, attached to the exact diff, +and free to maintain. + +A comment often holds both a constraint and the deliberation that found +it. Keep the constraint, cut the deliberation. "Runs at most once per +second" survives; "this is the right trade for now" does not. + +### Keep + +- Why over how: upstream quirks, protocol and compatibility constraints, + performance tradeoffs still part of the contract. +- Invariants, preconditions, ordering, lifetime, and concurrency + requirements that types and tests cannot express. +- Code that looks wrong but is not, so a later cleanup does not + reintroduce the bug. +- A high-level sketch of an algorithm whose local operations do not + reveal the whole. + +### Delete + +- Narration of the next lines; code translated into English. +- Restated names, types, defaults, or control flow. +- Values duplicated from the code and hand-synced. +- Justification, hedging, or apology for a choice. +- Speculation about future requirements. +- History version control already holds, including commented-out code. +- Ticket and issue numbers. They say nothing to a reader without tracker + access, and they rot when the tracker moves. Unfinished work goes in + the tracker, not the source. +- Transient observations — "currently", "for now", "the latest + release" — that go stale with no nearby edit. + +### The upkeep gate in practice + +It reaches values that track our own code. It does not reach frozen +external facts. + +Bad (Delete): + +```python +# There are 321 tests to complete for servers. +``` + +Good (Keep): + +```python +# Sphinx < 8.1 has no typed env.domains accessors, so this branch +# falls back to env.get_domain("py"). +``` + +### Documentation exception + +Doctests, minimal usage examples, and `Parameters`, `Returns`, and +`Attributes` entries on public API are exempt from the loss gate — they +serve the caller, not the maintainer. They are exempt from nothing else. +Ceiling: a good man page entry. Autodoc ships every field whether or not +you describe it, and a doctest that runs is also a test, so an +undescribed field or an unrun example is a documentation gap, not a +slop violation. diff --git a/.github/contributing.md b/.github/contributing.md deleted file mode 100644 index c0eddac2..00000000 --- a/.github/contributing.md +++ /dev/null @@ -1,27 +0,0 @@ -# Contributing - -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the maintainers of this repository before making a change. - -See [developing](../docs/developing.md) for environment setup and [AGENTS.md](../AGENTS.md) for -detailed coding standards. - -## Pull Request Process - -1. **Format and lint**: `uv run ruff format .` then `uv run ruff check . --fix --show-fixes` -2. **Type check**: `uv run mypy` -3. **Test**: `uv run pytest` — all tests must pass before submitting -4. **Document**: Update docs if your change affects the public interface -5. You may merge the Pull Request once you have the sign-off of one other developer. If you - do not have permission to do that, you may request a reviewer to merge it for you. - -## Decorum - -- Participants will be tolerant of opposing views. -- Participants must ensure that their language and actions are free of personal - attacks and disparaging personal remarks. -- When interpreting the words and actions of others, participants should always - assume good intentions. -- Behaviour which can be reasonably considered harassment will not be tolerated. - -Based on [Ruby's Community Conduct Guideline](https://www.ruby-lang.org/en/conduct/) diff --git a/AGENTS.md b/AGENTS.md index 5ae360b0..01a4a724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,985 +1,66 @@ # AGENTS.md -This file provides guidance to AI agents (including Claude Code, Cursor, and other LLM-powered tools) when working with code in this repository. - -## CRITICAL REQUIREMENTS - -### Test Success -- ALL tests MUST pass for code to be considered complete and working -- Never describe code as "working as expected" if there are ANY failing tests -- Even if specific feature tests pass, failing tests elsewhere indicate broken functionality -- Changes that break existing tests must be fixed before considering implementation complete -- A successful implementation must pass linting, type checking, AND all existing tests - -## Project Overview - -gp-sphinx (`gp_sphinx`) is a shared Sphinx documentation platform for Python projects. It consolidates duplicated docs configuration, extensions, theme settings, and workarounds from 14+ repositories into a single reusable package. - -Key features: -- `merge_sphinx_config()` API for shared defaults with per-project overrides -- Shared extension list (autodoc, intersphinx, myst_parser, sphinx_design, etc.) -- Shared Furo theme configuration (CSS variables, fonts, sidebar, footer) -- Bundled workarounds (tabs.js removal, spa-nav.js injection) -- Shared font configuration (IBM Plex via Fontsource) - -## Development Environment - -This project uses: -- Python 3.10+ -- Sphinx 8.1+ (required for the typed `env.domains._domain` accessors) -- [uv](https://github.com/astral-sh/uv) for dependency management -- [ruff](https://github.com/astral-sh/ruff) for linting and formatting -- [mypy](https://github.com/python/mypy) for type checking -- [pytest](https://docs.pytest.org/) for testing - - [pytest-watcher](https://github.com/olzhasar/pytest-watcher) for continuous testing - -## Common Commands - -### Setting Up Environment - -```bash -# Install dependencies -uv sync --all-packages - -# Install with development dependencies -uv sync --all-packages --all-extras --group dev -``` - -### Running Tests - -```bash -# Run all tests -just test -# or directly with pytest -uv run pytest - -# Run a single test file -uv run pytest tests/test_config.py - -# Run a specific test -uv run pytest tests/test_config.py::test_merge_sphinx_config - -# Run tests with test watcher -just start -# or -uv run ptw . - -# Run tests with doctests -uv run ptw . --now --doctest-modules -``` - -### Linting and Type Checking - -```bash -# Run ruff for linting -just ruff -# or directly -uv run ruff check . - -# Format code with ruff -just ruff-format -# or directly -uv run ruff format . - -# Run ruff linting with auto-fixes -uv run ruff check . --fix --show-fixes - -# Run mypy for type checking -just mypy -# or directly -uv run mypy src tests - -# Watch mode for linting (using entr) -just watch-ruff -just watch-mypy -``` - -### Development Workflow - -Follow this workflow for code changes: - -1. **Format First**: `uv run ruff format .` -2. **Run Tests**: `uv run pytest` -3. **Run Linting**: `uv run ruff check . --fix --show-fixes` -4. **Check Types**: `uv run mypy` -5. **Verify Tests Again**: `uv run pytest` - -### Documentation - -```bash -# Build documentation -just build-docs - -# Start documentation server with auto-reload -just start-docs -``` - -## Code Architecture - -gp-sphinx provides a shared configuration layer for Sphinx documentation: - -``` -gp_sphinx/ - __init__.py # Package entry point - config.py # merge_sphinx_config() and config building logic - defaults.py # Default extensions, theme options, MyST config, fonts - assets/ # Shared JS/CSS (spa-nav.js, workarounds) - _compat.py # Sphinx/docutils version compatibility -``` - -### Core Modules - -1. **Config** (`src/gp_sphinx/config.py`) - - `merge_sphinx_config()` API for building complete Sphinx config - - Deep-merge support for theme options - - Per-project override mechanism - -2. **Defaults** (`src/gp_sphinx/defaults.py`) - - `DEFAULT_EXTENSIONS` list - - `DEFAULT_THEME_OPTIONS` dict - - `DEFAULT_MYST_EXTENSIONS` list - - `DEFAULT_FONT_FAMILIES` dict - - Shared sidebar configuration - -### Package CSS self-containment - -A workspace package's own CSS must style every class its Python code -emits. If a directive appends `SAB.X` (or any gp-sphinx-* class) to a -node, the package's own CSS file carries a rule targeting `SAB.X`. -Cross-package **reuse** of a shared class (e.g., `gp-sphinx-badge` -styled in `sphinx-ux-badges`) is fine; cross-package **dependence** — -where your feature only renders correctly because a sibling package -happens to be loaded — is not. A downstream user installing a single -extension standalone must get the correct visual result. - -## Testing Strategy - -All tests are plain functions (`def test_*`). No `class TestFoo:` groupings. Every test -function and every `NamedTuple` fixture class must be fully type-annotated; mypy runs as -part of CI. - -Run continuously while developing: - -```console -$ uv run ptw . -``` - -Include doctests: - -```console -$ uv run ptw . --now --doctest-modules -``` - -### Test Level Hierarchy - -Pick the **lightest** level that exercises the behavior. Never reach for a full Sphinx -build when a docutils node test suffices — an integration build takes 2–10 s, a node -test runs in microseconds. - -| Level | When to use | -|---|---| -| **Pure unit** | Transforming strings, dicts, dataclasses — no nodes, no Sphinx | -| **Docutils tree unit** | Testing transforms/visitors/renderers by constructing `nodes.*` directly | -| **Snapshot unit** | Same as docutils tree, but output is large or complex — assert via `snapshot_doctree` | -| **Sphinx integration** (`@pytest.mark.integration`) | **Any test that constructs a `Sphinx` app.** `build_shared_sphinx_result` / `build_isolated_sphinx_result` with any builder — *including `buildername="dummy"`* — counts. If the test touches `env.domains.*`, walks a built doctree, or asserts on `result.warnings`, it is integration. | - -### Type Annotations (required everywhere) - -Every test function must annotate all parameters and the return type: - -```python -def test_something(value: str, expected: int) -> None: - assert compute(value) == expected -``` - -Every `NamedTuple` fixture class must annotate all fields. - -### NamedTuple Parametrization - -Use `t.NamedTuple` for any parametrized test with three or more inputs. Two wiring -styles are in use — pick whichever reads more clearly for the case at hand. - -**Style A — unpack all fields** (dominant; used in `test_unit.py`, lexer tests, etc.) - -Each field becomes a typed parameter in the test function, which makes the signature -self-documenting: - -```python -import typing as t - -import pytest - - -class FooFixture(t.NamedTuple): - """Test case for foo().""" - - test_id: str # always the first field - input: str - expected: str - - -_FOO_FIXTURES: list[FooFixture] = [ - FooFixture(test_id="basic", input="a", expected="A"), - FooFixture(test_id="empty", input="", expected=""), -] - - -@pytest.mark.parametrize( - list(FooFixture._fields), - _FOO_FIXTURES, - ids=[f.test_id for f in _FOO_FIXTURES], -) -def test_foo(test_id: str, input: str, expected: str) -> None: - """foo() uppercases its input.""" - assert foo(input) == expected -``` - -**Style B — pass whole struct as `case`** (used in `test_directives.py`, -`test_nodes.py`, when the struct is reused in assertion messages or has many fields): - -```python -@pytest.mark.parametrize( - "case", - _FOO_FIXTURES, - ids=lambda c: c.test_id, -) -def test_foo(case: FooFixture) -> None: - """foo() uppercases its input.""" - assert foo(case.input) == case.expected -``` - -Naming conventions: - -- `test_id: str` is **always the first field** -- Fixture list: `_FOO_FIXTURES` (module-private, all-caps) -- Fixture class: `FooFixture` or `FooCase` — never `TestFoo` - -### Docutils Tree Unit Tests (no Sphinx build) - -Test transforms, visitors, and renderers by constructing `docutils.nodes` and -`sphinx.addnodes` objects directly. Follow the pattern in -`tests/ext/layout/test_transforms.py`: - -```python -from docutils import nodes -from sphinx import addnodes - - -def _make_desc( - *content_children: nodes.Node, - domain: str = "py", - objtype: str = "function", -) -> addnodes.desc: - desc = addnodes.desc(domain=domain, objtype=objtype) - desc += addnodes.desc_signature() - content = addnodes.desc_content() - for child in content_children: - content += child - desc += content - return desc - - -def test_transform_wraps_content_runs() -> None: - """_wrap_content_runs groups consecutive content nodes.""" - desc = _make_desc(nodes.paragraph("", "summary"), nodes.field_list()) - _wrap_content_runs(desc) - assert any(isinstance(n, ContentGroup) for n in desc[1]) -``` - -- Put `_make_*()` builder helpers at the top of the test file, near the tests that use - them. -- Never import `sphinx.application.Sphinx` in a pure tree test. -- Use `nodes.document()` (with a minimal `settings` object from - `docutils.frontend.OptionParser`) only when the transform requires a real document - root. - -### Snapshot Tests (syrupy) - -Use when the expected output is too large or fragile to inline. The three fixtures -(from `tests/_snapshots.py`, loaded automatically via `pytest_plugins`) normalize their -inputs before asserting so that build-path churn and docutils version noise do not cause -spurious failures: - -- `snapshot_doctree(doctree, *, name=None, roots=())` — normalizes a `nodes.Node` -- `snapshot_html_fragment(fragment, *, name=None, roots=())` — strips ANSI, normalizes whitespace -- `snapshot_warnings(warnings, *, name=None, roots=())` — strips noise lines and ANSI codes - -```python -import typing as t - - -def test_layout_render( - snapshot_doctree: t.Callable[..., None], -) -> None: - """Transform produces a stable doctree.""" - desc = _make_large_signature_desc() - on_doctree_resolved(desc) - snapshot_doctree(desc) -``` - -Update stored snapshots after intentional output changes: - -```console -$ uv run pytest --snapshot-update -``` - -### Integration Tests (full Sphinx build) - -Use the harness in `tests/_sphinx_scenarios.py`. The key types and helpers: - -- `SphinxScenario(files=(...), confoverrides={}, buildername="html")` — describes the - synthetic project; `buildername` defaults to `"html"`, override for text builds -- `ScenarioFile(relative_path, contents, substitute_srcdir=False)` — one source file -- `build_shared_sphinx_result(cache_root, scenario, *, purge_modules=())` — builds - once per content-hash digest; `purge_modules` removes named modules from `sys.modules` - before the initial build to prevent stale import cache — required when scenario files - inject a Python module into `sys.path` -- `build_isolated_sphinx_result(cache_root, tmp_path, scenario, *, purge_modules=())` - — fresh build per test, for mutating assertions -- `derive_sphinx_scenario_cache_root(tmp_path)` — derives a stable per-session cache - root from any `tmp_path` by using its parent directory -- `copy_scenario_tree(cache_root, scenario, destination_root)` — materialize source - files into a directory without running a Sphinx build -- `get_doctree(result, docname, post_transforms=False)` — deep-copied doctree from - the built environment -- `read_output(result, filename)` — reads a built output file as a string - -Always use a **module-scoped** (or session-scoped) fixture for the build — never -function-scoped — so the expensive Sphinx build is shared across all tests in the -module. Follow the pattern in `tests/ext/typehints_gp/test_integration.py`: - -```python -import textwrap - -import pytest - -from tests._sphinx_scenarios import ( - SCENARIO_SRCDIR_TOKEN, - ScenarioFile, - SharedSphinxResult, - SphinxScenario, - build_shared_sphinx_result, - read_output, -) - -_CONF_PY = textwrap.dedent( - """\ - import sys - sys.path.insert(0, r"__SCENARIO_SRCDIR__") - extensions = ["sphinx.ext.autodoc", "my_extension"] - """ -) - -_INDEX_RST = textwrap.dedent( - """\ - Demo - ==== - - .. autofunction:: my_module.my_function - """ -) - - -@pytest.fixture(scope="module") -def my_html_result( - tmp_path_factory: pytest.TempPathFactory, -) -> SharedSphinxResult: - """Build a minimal Sphinx project using my_extension.""" - cache_root = tmp_path_factory.mktemp("my-ext-html") - scenario = SphinxScenario( - files=( - ScenarioFile("index.rst", _INDEX_RST), - ScenarioFile( - "conf.py", - _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), - substitute_srcdir=True, - ), - ), - ) - return build_shared_sphinx_result( - cache_root, - scenario, - purge_modules=("my_module", "my_extension"), - ) - - -@pytest.mark.integration -def test_my_feature_appears_in_html(my_html_result: SharedSphinxResult) -> None: - """Extension renders the expected markup.""" - html = read_output(my_html_result, "index.html") - assert "my-feature" in html -``` - -Rules: -- Always mark with `@pytest.mark.integration` -- Always `scope="module"` or `scope="session"` on the build fixture — never - `scope="function"` -- Use `textwrap.dedent("""...""")` for inline source strings -- Use `SCENARIO_SRCDIR_TOKEN` + `substitute_srcdir=True` for `sys.path` injection in - `conf.py` - -> **See also:** `notes/test-analysis.md` — profiling data, 9.5x speedup rationale, -> and the per-package migration history for the shared autodoc stack. - -### Available Fixtures Reference - -| Fixture | Source | When to use | -|---|---|---| -| `tmp_path` | pytest built-in | Per-test temp directory | -| `tmp_path_factory` | pytest built-in | Session/module fixtures that create temp dirs | -| `monkeypatch` | pytest built-in | Env vars, module attributes, `sys.modules` patching | -| `caplog` | pytest built-in | Log assertions; use `caplog.records`, not `caplog.text` | -| `snapshot_doctree` | `tests/_snapshots.py` | Normalized doctree snapshot assertion | -| `snapshot_html_fragment` | `tests/_snapshots.py` | Normalized HTML string snapshot assertion | -| `snapshot_warnings` | `tests/_snapshots.py` | Normalized Sphinx warning snapshot assertion | -| `spf_suite_root`, `spf_doctree_root`, `spf_html_root` | `tests/ext/pytest_fixtures/conftest.py` | Session roots for sphinx-pytest-fixture ext tests | -| `simple_parser`, `parser_with_groups`, … | `tests/ext/argparse/conftest.py` | `ArgumentParser` permutations for argparse tests | - -### Anti-Patterns - -- **No `class TestFoo:` groupings** — use descriptive function names and file - organization instead -- **No `unittest.mock.patch`** — use `monkeypatch` -- **No `tempfile.mkdtemp()`** — use `tmp_path` -- **No `Sphinx()` instantiation in a unit test** — build docutils nodes directly -- **No unannotated test functions** — every parameter and `-> None` must be typed -- **No `# doctest: +SKIP`** in module doctests (see Doctests section) -- **No inline tuples in `parametrize`** when there are three or more fields — use - `NamedTuple` -- **No function-scoped Sphinx build fixtures** — always module- or session-scoped - -## CSS Standards - -All CSS classes, custom properties, and MyST directive names added by a -workspace package live under the `gp-sphinx-*` namespace: - -- **Tier A (shared concepts)** — `gp-sphinx-` (e.g., - `gp-sphinx-badge`, `gp-sphinx-toolbar`). Used by multiple packages. -- **Tier B (package-owned)** — `gp-sphinx-__` BEM-style - (e.g., `gp-sphinx-fastmcp__safety-readonly`, - `gp-sphinx-pytest-fixtures__fixture-index`). -- **Modifiers** — axis-value pairs `---` (e.g., - `gp-sphinx-badge--size-xs`, `gp-sphinx-badge--type-function`). -- **Custom properties** — mirror the class namespace: - `--gp-sphinx--`. Furo-owned variables (`--color-api-*`, - `--font-stack--*`, etc.) stay untouched. -- **Specificity** — prefer chained class selectors - (`.gp-sphinx-badge.gp-sphinx-badge--dense`); keep selectors at 0,3,0 - max. - -## Coding Standards - -Key highlights: - -### Imports - -- **Use namespace imports for standard library modules**: `import enum` instead of `from enum import Enum` - - **Exception**: `dataclasses` module may use `from dataclasses import dataclass, field` for cleaner decorator syntax - - This rule applies to Python standard library only; third-party packages may use `from X import Y` -- **For typing**, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. -- **Use `from __future__ import annotations`** at the top of all Python files - -### Sphinx domain access - -Prefer the typed accessors on `env.domains` over `env.get_domain()`: - -- `env.domains.standard_domain` — not `env.get_domain("std")` -- `env.domains.python_domain` — not `env.get_domain("py")` -- Similarly: `c_domain`, `cpp_domain`, `javascript_domain`, - `restructuredtext_domain`, `changeset_domain`, `citation_domain`, - `index_domain`, `math_domain` - -The typed accessors return the concrete domain subclass -(`StandardDomain`, `PythonDomain`, etc.), so mypy sees subclass-specific -attributes (`progoptions`, `add_program_option`, `data["objects"]`, …) -without `t.cast` or `# type: ignore`. The accessors were added in Sphinx -8.1 (`_DomainsContainer`), which is the workspace floor. - -### Docstrings - -Follow NumPy docstring style for all functions and methods: - -```python -"""Short description of the function or class. - -Detailed description using reStructuredText format. - -Parameters ----------- -param1 : type - Description of param1 -param2 : type - Description of param2 - -Returns -------- -type - Description of return value -""" -``` - -**Class-level names** — every name a class declares that autodoc renders gets -exactly one description. That covers `NamedTuple` fields, dataclass fields -including `InitVar`, `TypedDict` keys, `Enum` members, `ClassVar`s, and plain -constants. The shape does not change the rule. - -Three styles count, and `tests/docs/test_docstring_policy.py` enforces them: a -NumPy `Attributes` entry in the class docstring, a docstring under the -assignment, or a `#:` comment above it. Prefer `Attributes`: - -```python -class ToctreeSection(t.NamedTuple): - """One section of pages grouped by toctree caption. - - Attributes - ---------- - caption : str | None - Toctree caption, or ``None`` for an uncaptioned toctree. - docnames : list[str] - Docnames listed under the caption, in toctree order. - """ -``` - -A `Parameters` section does **not** describe attributes — it documents the -initializer, and the attribute entries still render bare beneath it. - -A field nobody describes reaches the reference as a bare name. A `ClassVar` -nobody describes is withheld from it entirely, so an undescribed one is -silently missing rather than visibly empty. - -### Doctests - -**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests. - -**CRITICAL RULES:** -- Doctests MUST actually execute - never comment out function calls or similar -- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run) -- If you cannot create a working doctest, **STOP and ask for help** - -**Available tools for doctests:** -- `doctest_namespace` fixtures (from conftest.py): `tmp_path` -- Ellipsis for variable output: `# doctest: +ELLIPSIS` -- Update `conftest.py` to add new fixtures to `doctest_namespace` - -**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. - -**When output varies, use ellipsis:** -```python ->>> result = merge_sphinx_config(project="test", version="1.0", copyright="2026") ->>> result["project"] -'test' ->>> len(result["extensions"]) > 10 # doctest: +ELLIPSIS -True -``` - -### Logging Standards - -These rules guide future logging changes; existing code may not yet conform. - -#### Logger setup - -- Use `logging.getLogger(__name__)` in every module -- Add `NullHandler` in library `__init__.py` files -- Never configure handlers, levels, or formatters in library code -- that's the application's job - -#### Lazy formatting - -`logger.debug("msg %s", val)` not f-strings. Two rationales: -- Deferred string interpolation: skipped entirely when level is filtered -- Aggregator message template grouping: `"Running %s"` is one signature grouped x10,000; f-strings make each line unique - -When computing `val` itself is expensive, guard with `if logger.isEnabledFor(logging.DEBUG)`. - -#### Log levels - -| Level | Use for | Examples | -|-------|---------|----------| -| `DEBUG` | Internal mechanics | Config merge steps, extension resolution | -| `INFO` | User-visible operations | Config loaded, extensions resolved | -| `WARNING` | Recoverable issues, deprecation | Unknown extension, deprecated option | -| `ERROR` | Failures that stop an operation | Invalid config, missing dependency | - -#### Message style - -- Lowercase, past tense for events: `"config merged"`, `"extension resolved"` -- No trailing punctuation -- Keep messages short; put details in `extra`, not the message string - -#### Exception logging - -- Use `logger.exception()` only inside `except` blocks when you are **not** re-raising -- Use `logger.error(..., exc_info=True)` when you need the traceback outside an `except` block -- Avoid `logger.exception()` followed by `raise` -- this duplicates the traceback - -#### Testing logs - -Assert on `caplog.records` attributes, not string matching on `caplog.text`: -- Scope capture: `caplog.at_level(logging.DEBUG, logger="gp_sphinx.config")` -- Filter records rather than index by position -- `caplog.record_tuples` cannot access extra fields -- always use `caplog.records` - -#### Avoid - -- f-strings/`.format()` in log calls -- Catch-log-reraise without adding new context -- `print()` for diagnostics -- Logging secret env var values (log key names only) - -### Git Commit Standards - -Format commit messages as: -``` -Scope(type[detail]): concise description - -why: Explanation of necessity or impact. - -what: -- Specific technical changes made -- Focused on a single topic -``` - -Keep the subject ≤50 chars (excluding any trailing `(#NN)` PR ref); wrap -body lines at ≤72 chars. Separate the `why:` and `what:` blocks with a -blank line. - -Common commit types: -- **feat**: New features or enhancements -- **fix**: Bug fixes -- **refactor**: Code restructuring without functional change -- **docs**: Documentation updates -- **chore**: Maintenance (dependencies, tooling, config) -- **test**: Test-related updates -- **style**: Code style and formatting -- **py(deps)**: Dependencies -- **py(deps[dev])**: Dev Dependencies -- **ai(rules[AGENTS])**: AI rule updates -- **ai(claude[rules])**: Claude Code rules (CLAUDE.md) -- **ai(claude[command])**: Claude Code command changes - -Example: -``` -config(feat[merge]): Add deep-merge support for theme options - -why: Enable per-project theme overrides without replacing entire dict - -what: -- Add deep_merge() helper for nested dict merging -- Update merge_sphinx_config() to deep-merge theme_options -- Add tests for nested override behavior -``` -#### Release commits - -Never create tags. Never push tags. The user handles tagging and tag -pushes (tags trigger the CI publish workflow). - -Release commit subjects are plain and short: `Tag v`. Put -the detailed why/what in the commit body. Don't use the -`Scope(type[detail]):` format for releases — don't bury the lede. - -For multi-line commits, use heredoc to preserve formatting: -```bash -git commit -m "$(cat <<'EOF' -feat(Component[method]) add feature description - -why: Explanation of the change. - -what: -- First change -- Second change -EOF -)" -``` - -## Documentation Standards - -### Code Blocks - -Code blocks are paste-and-run units: pasting one block runs exactly one -intended action. Doctests and other executed examples are exempt — the test -suite runs them, nobody pastes them. - -- **One command per block.** Multiple steps may share a block only when - explicitly chained with `&&`, `;`, or `\` continuations — the chain is - then one logical command. -- **Explanations go in prose above the block**, never as `#` comments inside it. -- **Command menus are per-command blocks with prose lead-ins**, not tables. -- **Shell commands use the `console` tag with a `$ ` prefix.** This separates - interactive commands from scripts and enables prompt-aware copy. -- **Split long commands with `\`** — one flag or flag+value pair per indented - continuation line, positional arguments last. - -Good: - -Show the last ten commits as a graph: - -```console -$ git log \ - --max-count=10 \ - --graph \ - --oneline -``` - -Bad: - -```console -# Show the last ten commits as a graph -$ git log --max-count=10 --graph --oneline -``` - -### Changelog Conventions - -These rules apply when authoring entries in `CHANGES`, which is rendered as the Sphinx changelog page. Modeled on Django's release-notes shape — deliverables get titles and prose, not bullets. - -**Release entry boilerplate.** Every release header is `## gp-sphinx X.Y.Z (YYYY-MM-DD)`. The file opens with a `## gp-sphinx X.Y.Z (unreleased)` block prefaced by a single `` HTML comment — new release entries land below the most recent released entry, never between the comment and the unreleased header. - -**Open with a multi-sentence lead paragraph.** Plain prose, no italic. Open with the version as sentence subject (*"gp-sphinx X.Y.Z ships …"*) so the lead is self-contained when excerpted. Two to four sentences telling the reader what shipped and who cares — user-visible takeaways, not internal mechanism. Cross-reference detail docs with `{ref}` to keep the lead compact. - -**Lead paragraphs are release-time material — off-limits to branches and PRs.** The unreleased entry carries no lead paragraph and no version summary: sections only (`### Breaking changes`, `### What's new` deliverables, `### Fixes`, …). Speaking for the release — what the version "is", "ships", or "focuses on" — is presumptuous before its scope is final; only the person cutting the release writes that, and only when the user explicitly asks to release. Never write or edit a lead from a feature branch, and never ask or imply that a release should happen. - -**Each deliverable is a section, not a bullet.** Inside `### What's new`, every distinct deliverable gets a `#### Deliverable title` heading naming it in user vocabulary, followed by 1-3 prose paragraphs explaining what shipped. Don't wrap a paragraph in `- ` — bullets are for enumerable lists, not paragraph containers. Cross-link detail docs (`See {ref}\`foo\` for details.`) so prose stays focused. - -**The deliverable test.** Before writing an entry, ask: "What's the deliverable, in user vocabulary?" If you can't answer in one sentence, the entry isn't ready. Mechanism (helper internals, byte counters, schema-validation locations) belongs in PR descriptions and code comments, not the changelog. - -**Fixed subheadings**, in this order when present: `### Breaking changes`, `### Dependencies`, `### What's new`, `### Fixes`, `### Documentation`, `### Development`. Dev tooling (helper scripts, internal automation) lives under `### Development`. For breaking changes, show the migration path with concrete inline code (e.g. a `# Before` / `# After` fenced code block). Dependency floor bumps use the form ``Minimum `pkg>=X.Y.Z` (was `>=X.Y.W`)``. - -**PR refs `(#NN)`** sit at the end of each deliverable's prose body, not in the `####` heading. - -**When bullets are appropriate.** Catch-all sections (`### Fixes`, occasionally `### Documentation`) with 3+ genuinely small items use bullets — one line each, never paragraphs. If a bullet swells past two lines, promote it to a `#### Title` heading with prose body. - -**Anti-patterns.** - -- Fragile metrics: token ceilings, third-party version pins, percent benchmarks, exact byte counts. Describe the *capability*, not the math. -- Internal jargon: private symbols (leading-underscore identifiers), algorithm names exposed for the first time, backend scaffolding. -- Walls of text dressed up as bullets. -- Buried breaking changes — they get their own subheading at the top of the entry. - -**Always link autodoc'd APIs.** Any class, method, function, exception, or attribute that has its own rendered page must be cited via the appropriate role (`{class}`, `{meth}`, `{func}`, `{exc}`, `{attr}`) — never with plain backticks. Doc pages without explicit ref labels use `{doc}`. Plain backticks are correct for code syntax, env vars, parameter names, and file paths that aren't doc pages — anything without an autodoc destination. - -**MyST roles.** Class references use `{class}`, methods use `{meth}`, functions use `{func}`, exceptions use `{exc}`, attributes use `{attr}`, internal anchors use `{ref}`, doc-path links use `{doc}`. - -**Summarization style.** When a user asks "what changed in the latest version?" or similar, lead with the entry's lead paragraph (paraphrased if needed), followed by each `####` deliverable heading under `### What's new` with a one-sentence summary. Cite `(#NN)` only if the user asks for source links. Don't invent versions, dates, or numbers not present in `CHANGES`. Don't quote line numbers or file offsets — those shift as the file evolves. - -## Debugging Tips - -When stuck in debugging loops: - -1. **Pause and acknowledge the loop** -2. **Minimize to MVP**: Remove all debugging cruft and experimental code -3. **Document the issue** comprehensively for a fresh approach -4. **Format for portability** (using quadruple backticks) - -## Comments earn their maintenance cost - -A comment ships only if it passes all three gates. Fail any: delete or rewrite. -Borderline: delete — borderline means the information is reconstructible, which -is what makes deletion cheap. - -**Loss.** Three years from now, would losing this cost a maintainer real time -rediscovering intent, an invariant, a constraint, or a failure mode the code and -tests do not already make obvious? - -**Elite.** Would SQLite, Redis, the Go standard library, or CPython write this -comment, at this length? Those projects state the constraint and stop. They do -not argue with an imagined objector. - -**Upkeep.** Will it stay true without maintenance? A comment that hand-syncs a -value the code owns — a count, an offset, a line reference, a duplicated -constant — is false the first time that value moves. - -### Ceiling - -One or two lines. A comment reaching four is either carrying several facts, in -which case split it, or arguing, in which case cut it to the fact. - -Rationale, alternatives weighed, and the story of how the code got here belong -in the commit message: timestamped, attached to the exact diff, and free to -maintain. - -A comment often holds both a constraint and the deliberation that found it. Keep -the constraint, cut the deliberation. "Runs at most once per second" survives; -"this is the right trade for now" does not. - -### Keep - -- Why over how: upstream quirks, protocol and compatibility constraints, - performance tradeoffs still part of the contract. -- Invariants, preconditions, ordering, lifetime, and concurrency requirements - that types and tests cannot express. -- Code that looks wrong but is not, so a later cleanup does not reintroduce the - bug. -- A high-level sketch of an algorithm whose local operations do not reveal the - whole. - -### Delete - -- Narration of the next lines; code translated into English. -- Restated names, types, defaults, or control flow. -- Values duplicated from the code and hand-synced. -- Justification, hedging, or apology for a choice. -- Speculation about future requirements. -- History version control already holds, including commented-out code. -- Ticket and issue numbers. They say nothing to a reader without tracker access, - and they rot when the tracker moves. Unfinished work goes in the tracker, not - the source. -- Transient observations — "currently", "for now", "the latest release" — - that go stale with no nearby edit. - -### The upkeep gate in practice - -It reaches values that track our own code. It does not reach frozen external -facts. - -Bad (Delete): - -```python -# There are 321 tests to complete for servers. -``` - -Good (Keep): - -```python -# CPython < 3.11 has no ExceptionGroup, so this branch stays. -``` - -### Documentation exception - -Doctests, minimal usage examples, and param, return, and raises lines on public -API are exempt from the loss gate — they serve the caller, not the maintainer. -They are exempt from nothing else. Ceiling: a good man page entry. - -NumPy-style `Parameters`, `Returns`, and `Attributes` sections and executable -doctests fall under this exception — autodoc ships every field whether or not -you describe it, and a doctest that runs is also a test. TSDoc summaries, -`@param` and `@returns` tags, and the compiled examples fall under this -exception. - -## AI Slop Prevention - -Treat AI slop as **review-hostile noise**, not as proof that text or -code is wrong. The goal is to maximize information density by removing -artifacts that make the repository harder to trust or navigate. - -### The Anti-Slop Rubric - -Before committing, audit all AI-assisted changes for these noise -patterns: - -- **AI Signatures:** Remove "Generated by", footers, conversational - filler ("Certainly!", "Here is..."), unexplained emojis (🤖, ✨), and - AI-tool metadata. -- **Brittle References:** Avoid hard-coded line numbers, fragile - file/test counts, dated "as of" claims, bare SHAs, and local - absolute paths unless they are strict evidentiary artifacts (e.g., - benchmark logs). -- **Diff Narration:** Do not restate what moved, was renamed, or was - removed in artifacts the downstream reader holds: code, docstrings, - README, CHANGES, PR descriptions, or release notes. The diff and - commit message already carry this history. -- **Branch-Internal Narrative:** Do not mention intermediate branch - states, abandoned approaches, or "no longer" behavior unless users - of a published release actually experienced the old state (**The - Published-Release Test**). -- **Low-Value Scaffolding:** Remove ownerless TODOs (`TODO: revisit`), - unused future-proofing, debug artifacts, and defensive wrappers that - do not protect a currently reachable failure mode. -- **Prose Inflation:** Replace generic AI "tells" like *comprehensive, - robust, seamless, production-ready, leverage, delve, tapestry,* and - *best practices* with concrete descriptions of behavior, - constraints, or trade-offs. -- **Coded Labels:** Write rules, options, and findings as plain - imperatives. Don't tag them with codes like `[R1]`, `A1`, or - `Option B` in artifacts a human reads — the reader shouldn't have to - decode an index. Internal agent bookkeeping may use ids; shipped text - may not. - -### Durable Source Links - -Link to a pinned revision, never to trunk. A pinned permalink is not a -brittle reference; an unlinked SHA dropped into prose is. `blob/main/…` -links rot silently — the file moves, lines shift, and the anchor lands -on unrelated code while still resolving. - -- Prefer a release tag (`blob/v1.4.0/…`). Most durable, and it tells - the reader which released version the claim held for. -- Otherwise use a 7-char commit ref (`blob/9a29b1a/…`) reachable from - trunk. Use when there is no tag or the claim is about unreleased - code. Never a PR-head SHA — it can be rebased or garbage-collected. -- Reserve `blob/main/…` for living documents meant to always show the - latest state, such as a contributing guide. -- Line anchors (`#L120-L145`) are only safe on a pinned ref. - -### Preservation & Context - -Subjective cleanup must never remove load-bearing rationale. Adjudicate -comments with the comment policy above; borderline cases are deleted, not -kept. - -- **Preserve the "Why":** You MUST NOT delete comments that document - invariants, protocol constraints, platform quirks, security - boundaries, and upstream workarounds. -- **Evidence is Immune:** Preserve exact counts, dates, and SHAs when - they serve as evidence in benchmark results, release notes, stack - traces, or lockfiles. -- **Behavior Over Inventory:** A useful description explains what - changed for the *system or user*; it does not provide an inventory - of files or functions the diff already shows. - -### The Published-Release Test - -Long-running branches accumulate tactical decisions — renames, -refactors, attempts-then-reverts. When deciding what counts as -branch-internal, use trunk or the parent branch as the baseline — not -intermediate states inside the current branch. Ask: - -> Did users of the most recently published release ever experience -> this old name, old behavior, or bug? - -If the answer is **no**, it is branch-internal narrative. Move it to -the commit message and describe only the final state in the artifact. - -**Keep in shipped artifacts:** -- Deprecations and migration guides for symbols that actually shipped. -- `### Fixes` entries for bugs that affected users of a published - release. -- Comments explaining *why the current code looks this way* - (invariants, platform quirks) that make sense to a reader who never - saw the previous version. - -### Cleanup in Hindsight - -When applying these rules retroactively from inside a feature branch, -first establish scope by diffing against the parent branch (or trunk) -to identify which commits this branch actually introduced. Then: - -- **In-branch commits:** Prompt the user with two options: `fixup!` - commits with `git rebase --autosquash` to address each causal commit - at its source, or a single cleanup commit at branch tip. -- **Trunk/Parent commits:** Default to leaving them alone. Act only on - explicit user instruction. If the user opts in, fold the cleanup - into a single commit at branch tip; do not rewrite shared history. -- **Scope guard:** If cleaning prior slop would touch a colleague's - work or expand the branch beyond its stated goal, stay in lane: - protect the current goal and leave prior slop alone. - -### Change Discipline +gp-sphinx is a uv workspace of Sphinx documentation-platform packages: a +coordinator (`merge_sphinx_config()`), autodoc extensions, a Furo-based +theme, and the SEO/build tooling that ships around them for the +git-pull fleet. + +Follow the conventions already in the tree, and keep a change scoped to +what was asked for. + +## What is here + +| Path | What it is | +| --- | --- | +| `packages/gp-sphinx/` | Coordinator: `merge_sphinx_config()`, `DEFAULT_EXTENSIONS` | +| `packages/sphinx-gp-theme/`, `packages/gp-furo-theme/` | Furo-based theme; `gp-furo-theme/web/` is the Vite/CSS source | +| `packages/gp-furo-tokens/` | Design tokens (TS, pnpm-only, excluded from the uv workspace) | +| `packages/sphinx-autodoc-*/` | Autodoc extensions: api-style, argparse, docutils, fastmcp, pytest-fixtures, sphinx, typehints-gp | +| `packages/sphinx-ux-*/`, `sphinx-fonts/` | Shared layout, badges, fonts | +| `packages/sphinx-gp-{opengraph,sitemap,llms}/` | SEO, auto-loaded when `docs_url` is set | +| `packages/sphinx-gp-{mermaid,highlighting}/` | Diagrams and syntax highlighting | +| `packages/sphinx-vite-builder/` | PEP 517 build backend + Sphinx extension; own `AGENTS.md` | +| `src/gp_sphinx_workspace/` | Bootstrap package for the workspace root | +| `docs/` | This project's own docs site — its own flagship consumer | +| `tests/` | pytest suite: `_sphinx_scenarios.py`, `_snapshots.py`, `docs/` policy tests | +| `scripts/ci/` | Version bump and release-metadata tooling | +| `CHANGES` | The changelog | + +## Which policy applies + +- Documentation, user-facing text, `CHANGES`, commit messages, + docstrings, and source comments: + [.github/WRITING.md](.github/WRITING.md) +- Environment, the gates, tests, documentation builds, releases, and + pull requests: [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) + +Each of those is the single home for its subject. Where a rule seems to +be stated twice, the file listed above is the one that governs. + +## Change discipline - Make the smallest coherent change that solves the verified problem; keep unrelated cleanup out of it. -- Reuse an existing file, component, helper, API, or test before adding - a new one. Modify in place when the change fits the file's - responsibility. -- Keep new APIs private until a caller outside the module needs them. +- Reuse an existing file, helper, API, or test before adding a new one. - Add a file only for a durable boundary — a distinct responsibility, - independent reuse, or splitting an oversized high-touch module — not - for a single-use helper or a one-line re-export. - -### Keep Instructions Lean - -Treat this file like code and prune it. - -- Delete a line whose removal would not cause a mistake. -- Move multi-step procedures into skills, path-specific rules into - nested AGENTS.md files, and hard limits into hooks or CI. -- Keep only non-obvious, broadly applicable defaults here. Anything a - reader can infer from the code, a manifest, or a linter does not - belong. + independent reuse, or splitting an oversized module — not for a + single-use helper or a one-line re-export. +- Add a test for every user-visible behaviour change, and a `CHANGES` + entry for every change to the public API, CLI, configuration, or + output. +- A passing gate is evidence only once it has been shown capable of + failing. Pair a new test with a deliberate break that proves it bites. + +`pytest --doctest-modules` collects doctests from `.py` files under +`testpaths` only: `packages/*/src`, `docs/_ext/`, and `tests/`. A +`>>> ` prompt on a Markdown page under `docs/` or in `README.md` does +not run — see +[WRITING.md](.github/WRITING.md#documented-examples-that-run). All +publishable packages share one lockstep version, bumped with +`just bump-version `. + +## References + +- Changelog: [CHANGES](CHANGES) +- Docs: +- Source: diff --git a/README.md b/README.md index b3cb992e..3d5b1506 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) An integrated autodoc design system for Sphinx that replaces ~300 lines -of duplicated `docs/conf.py` with ~10 lines and produces beautiful, -consistent API documentation. +of duplicated `docs/conf.py` with ~10 lines and produces consistent, +cross-referenced API documentation. + +> [!WARNING] +> **Alpha.** Releases carry an `-alpha` prerelease tag. The API is not +> settled, and any release may change or remove exported identifiers +> without a deprecation period. Pin an exact version. Not recommended +> for production. ## Requirements @@ -48,24 +54,39 @@ globals().update(conf) Out of the box, `merge_sphinx_config()` activates: -- **Componentized layouts** (`sphinx-ux-autodoc-layout`) — card containers, parameter folding, managed signatures -- **Clean type hints** (`sphinx-autodoc-typehints-gp`) — simplified annotations with cross-referenced links, replacing `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` -- **Unified badge system** (`sphinx-ux-badges`) — type and modifier badges with a shared colour palette -- **Autodoc extensions** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values +- **Componentized layouts** (`sphinx-ux-autodoc-layout`) — card + containers, parameter folding, managed signatures +- **Clean type hints** (`sphinx-autodoc-typehints-gp`) — simplified + annotations with cross-referenced links, replacing + `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` +- **Unified badge system** (`sphinx-ux-badges`) — type and modifier + badges with a shared colour palette +- **Autodoc extensions** — Python API, argparse CLIs, pytest fixtures, + FastMCP tools, docutils directives, Sphinx config values - **IBM Plex fonts** via Fontsource with preloaded web fonts - **Full dark mode** theming via CSS custom properties -See the [Gallery](https://gp-sphinx.git-pull.com/gallery.html) for live demos of every component. +See the [Gallery](https://gp-sphinx.git-pull.com/gallery.html) for live +demos of every component. ## Workspace architecture Lower layers never depend on higher ones: -- **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp`, `sphinx-fonts` -- **Autodoc extensions** — `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` -- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend + hatchling build hook + Sphinx extension that runs Vite via pnpm; publishable to PyPI for use outside this workspace) -- **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, `gp-furo-theme` -- **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by `gp-sphinx` when `docs_url` is set) +- **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, + `sphinx-autodoc-typehints-gp`, `sphinx-fonts` +- **Autodoc extensions** — `sphinx-autodoc-api-style`, + `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, + `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, + `sphinx-autodoc-sphinx` +- **Build utils** — `sphinx-vite-builder` + ([PEP 517](https://peps.python.org/pep-0517/) backend + hatchling + build hook + Sphinx extension that runs Vite via pnpm; publishable to + PyPI for use outside this workspace) +- **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, + `gp-furo-theme` +- **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by + `gp-sphinx` when `docs_url` is set) See the [Architecture](https://gp-sphinx.git-pull.com/architecture.html) and [Packages](https://gp-sphinx.git-pull.com/packages/) pages for the diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index e6b8c1c3..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,156 +0,0 @@ -# Documentation voice - -This file covers the *voice* of prose under `docs/` — how to frame a -page so a reader meets the idea before its configuration. It -complements the repository-root `AGENTS.md`, which already governs -code blocks, shell-command formatting, doctests, changelog -conventions, and MyST roles. When the two overlap, the root file -wins; this one only answers: how should the prose sound? - -## Who you are writing for - -The default reader maintains another project's docs site and wires -`merge_sphinx_config()` into that project's `docs/conf.py`. They are -fluent in Sphinx itself — `conf.py`, extensions, themes, MyST -Markdown — but you cannot assume they know gp-sphinx's internals: the -workspace tier map, the merge order (`DEFAULT_*` constants, then -values auto-computed from `source_repository` and `docs_url`, then -`**overrides`), or the theme's Vite asset pipeline. - -A second, smaller reader works *on* the workspace: adding a package, -extending an autodoc extension, touching `gp-furo-theme`'s `web/src` -assets or the `sphinx-vite-builder` backend. Serve them too, but mark -their material opt-in ("for workspace contributors", "advanced") so -the default reader knows they can stop. Never make the common case -pay a comprehension tax for the advanced one. - -## Voice - -- **Second person, present tense, active.** "You pass `docs_url`", - not "SEO values are derived". Address the reader doing the thing. -- **Concept before configuration.** Open by saying what the thing - *is* and what it does for the reader's site. The kwarg surface is - the last detail they need, not the first. A page that opens with - "pass these keyword arguments" has buried the idea under its - mechanics. -- **Say when they can stop.** Lead with the default and the - reassurance: the ~10-line `merge_sphinx_config()` call is the whole - integration; everything past it is optional. Let a skimmer leave - after one paragraph. -- **Progressive disclosure.** Order by how many readers need it: the - coordinator call → the one kwarg a few will tune (`docs_url`, - `extra_extensions`) → a single package's own options → workspace - internals. Each step is for a smaller audience than the last. -- **Lean on the merge order.** The reader's mental model of - `merge_sphinx_config()` is a pipeline: shared defaults, then values - auto-computed from `source_repository` and `docs_url`, then - `**overrides` applied last — an explicit value always wins. - Reinforce that order when you explain who sets what. On package - pages the equivalent is the tier map: shared infrastructure → - autodoc extensions → theme and coordinator. -- **Name the trade-off.** If an option costs something, say so, and - say what it buys: `vite_orchestration=True` spawns a pnpm/Vite - watcher under `sphinx-autobuild` — contributors need Node, wheel - consumers don't. State it; don't sell it. -- **Frame by concept, not by mechanism.** Don't headline a feature by - its kwarg or CSS custom property in prose; that names the - implementation surface, the reader's last concern. Name the - concept. The mechanics vocabulary — a Parameter/Type/Default table, - a `DEFAULT_*` constant — is correct in the `docs/configuration.md` - reference tables, and only there. - -## What stays precise - -Warm the framing, never the facts. Parameter tables, auto-computed -value mappings, `DEFAULT_*` constant tables, exact extension names, -and cross-references carry meaning in their exact form — leave them -alone. The friendly voice belongs in the sentences *around* a precise -block, introducing it, not inside it paraphrasing it into vagueness. - -## Keeping examples honest - -The `conf.py` snippets on docs pages are illustrative — no test -executes them, so every kwarg you show must exist in -`merge_sphinx_config()`'s real signature. The nearest thing to a -test: this site is its own flagship consumer, so `just build-docs` -exercises what the snippets promise. What *does* run: the gallery -renders live from the demo modules in `docs/_ext/` (nothing is -mocked), and pytest collects those modules' doctests (`testpaths` -includes `docs`; `addopts` carries `--doctest-modules`) — keep them -passing. - -## Generated pages - -Every `docs/packages//` page gets its "Copyable config snippet" -and "Package metadata" sections from the `{package-landing}` and -`{package-reference}` directives (`docs/_ext/package_reference.py`), -which read live workspace metadata — don't hand-write what they -generate; a new `packages//pyproject.toml` appears on the next -build with no code change. Surface documentation — config values, -directives, roles — belongs to `autoconfigvalues`, `autodirective`, -and `autorole`; invoke them instead of transcribing it into prose. - -## Cross-references - -Point the advanced reader at the deep-dive rather than inlining it, -and put the link where their interest peaks — on the phrase that made -them curious ("write your own autodoc extension") — not as a footnote -the eye skips. Use the MyST roles listed in the -root `AGENTS.md`; docs pages here usually spell the py-domain forms -explicitly (`{py:func}`, `{py:data}`, `{py:mod}`). A `{ref}` must -match its target's anchor exactly — anchors mix hyphen and underscore -forms, sometimes inside one anchor (`from-docs_url`). `just -build-docs` catches a broken `{ref}`; nothing else does — so build the -docs before you commit. - -A py-domain role is not covered by that build check. `nitpicky` is -unset, so an unresolved `{py:class}` or `{py:data}` renders as plain -text and the build stays silent — a clean build is no evidence the -link works. Two ways to get one wrong: citing a symbol nothing -autodocs (add the directive, or drop the role), and citing a project -absent from `intersphinx_mapping` in `docs/conf.py`, which currently -maps only `py` and `sphinx` — every other project is a Markdown link. -Confirm by opening the built page and checking the name sits inside an -``. - -Link the first prose mention of any symbol that has a useful -destination on that page. This includes Python objects, gp-sphinx -APIs, workspace package pages, configuration anchors, and external -tools or projects. Use the most specific target available: -`{py:func}`, `{py:class}`, `{py:mod}`, or `{py:data}` for API -objects; `{ref}` or `{doc}` for documentation pages and section -anchors; and a Markdown link for external projects. After the first -linked mention on a page, later mentions can stay plain unless the -distance or context makes another link useful. - -Do not rely on a later reference section to satisfy the first-mention -rule. If the first occurrence would be a heading, grid-card teaser, -or introductory sentence, link that occurrence or retitle the heading -so the first prose mention can carry the link. Leave command -examples, code blocks, and literal configuration values as code; link -the surrounding prose instead. - -## A page that does this - -`docs/packages/gp-sphinx/how-to.md` is the worked example: it opens -with the exact downstream `conf.py` the default reader came to copy, -says what the call injects in reader vocabulary, reassures that -passing `docs_url` is the only step SEO needs, defers precise key -mappings to the configuration reference, and ends with a live-example -admonition pointing at this site's own `docs/conf.py`. Read it before -reshaping another page. - -## Before you commit - -- Does the page open with what the feature *is*, or how to configure - it? -- Can a reader who needs only the ~10-line call stop after one - paragraph? -- Is anything framed as "the kwargs" that should be named by concept? -- Are the workspace-internal and advanced parts marked opt-in? -- Did you leave every table, anchor, extension name, and - cross-reference exact — and generated sections to their directives? -- Did `just build-docs` stay clean — no new warning, no broken - cross-reference? -- Did you open the built page and confirm each new py-domain role - rendered as a link? A silent build does not prove it. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 120000 index 47dc3e3d..00000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/project/code-style.md b/docs/project/code-style.md index 28975674..d664af0b 100644 --- a/docs/project/code-style.md +++ b/docs/project/code-style.md @@ -1,26 +1,10 @@ # Code Style -## Formatting - -gp-sphinx uses [ruff](https://github.com/astral-sh/ruff) for both linting and formatting. - -```console -$ uv run ruff format . -``` - -```console -$ uv run ruff check . --fix --show-fixes -``` - -## Type Checking - -Strict [mypy](https://mypy-lang.org/) is enforced across `src/` and `tests/`. - -```console -$ uv run mypy . -``` - -## Docstrings - -Follow [NumPy docstring style](https://numpydoc.readthedocs.io/en/latest/format.html) -for all public functions, methods, and classes. +This page split in two when the repository's agent and contributor +documentation moved to `.github/`: + +- Formatting, linting, type-checking, imports, and Sphinx-domain-access + conventions are in + [`.github/CONTRIBUTING.md`](https://github.com/git-pull/gp-sphinx/blob/main/.github/CONTRIBUTING.md#code-style). +- Docstring style and content is in + [`.github/WRITING.md`](https://github.com/git-pull/gp-sphinx/blob/main/.github/WRITING.md#docstrings). diff --git a/docs/project/contributing.md b/docs/project/contributing.md index c9bf3660..a6f2b942 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -1,177 +1,20 @@ # Contributing -Install [git] and [uv]. - -Clone: - -```console -$ git clone https://github.com/git-pull/gp-sphinx.git -``` - -```console -$ cd gp-sphinx -``` - -Install packages: - -```console -$ uv sync --all-packages --all-extras --group dev -``` - -## Tests - -Preferred local commands use a fixed pytest temp root under `.cache/` and disable -tmp-path retention for speed. `just test` keeps full coverage, while -`just test-fast` is a feedback loop only and intentionally excludes -`integration` tests: - -```console -$ just test -``` - -```console -$ uv run pytest -``` - -Use raw `uv run pytest` when you want the conservative direct runner without the -local temp-dir optimization. - -Fast local loop without doctest-modules or integration tests: - -```console -$ just test-fast -``` - -Canonical direct pytest command for the same fast lane: - -```console -$ uv run pytest \ - -o "addopts=--tb=short --no-header --showlocals" \ - -o tmp_path_retention_policy=none \ - --basetemp="$(pwd)/.cache/pytest-fast-direct" \ - -q \ - --capture=tee-sys \ - tests \ - -m "not integration" -``` - -Do not use the fast lane to reason about full-suite coverage or total suite -performance; it is intentionally deselected for local iteration. - -### Automatically run tests on file save - -1. `just start` (via [pytest-watcher], full local lane) -2. `just start-fast` for the fast local loop -3. `just watch-test` (requires installing [entr(1)]) - -[pytest-watcher]: https://github.com/olzhasar/pytest-watcher +Development setup, the gates, and pull request workflow now live in +the repository's own contributing guide, so the same file governs +GitHub's contributing prompt and this published page. Read it on +GitHub: +. ## Documentation -Default preview server: http://localhost:3124 - -[sphinx-autobuild] will automatically build the docs, watch for file changes and launch a server. - -From the repository root: - -```console -$ just start-docs -``` - -From inside `docs/`: - -```console -$ just start -``` - -[sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild - -### Manual documentation (the hard way) - -Build from inside `docs/`: - -```console -$ cd docs && just html -``` - -Start the static file server: - -```console -$ just serve -``` - -Repository-root helpers: - -```console -$ just build-docs -``` - -```console -$ just serve-docs -``` - -Rebuild docs on file change: - -```console -$ just watch-docs -``` - -Requires [entr(1)]. - -Rebuild docs and run the server in one terminal: - -```console -$ just dev-docs -``` - -Requires [entr(1)]. +Building, previewing, and serving the docs is +[the Documentation section](https://github.com/git-pull/gp-sphinx/blob/main/.github/CONTRIBUTING.md#documentation) +of that guide. ## Test hierarchy -Pick the **lightest** level that exercises the behavior: - -| Level | When to use | Speed | -|---|---|---| -| **Pure unit** | Strings, dicts, dataclasses — no nodes, no Sphinx | microseconds | -| **Docutils tree unit** | Constructing `docutils.nodes.*` or `sphinx.addnodes.*` directly | microseconds | -| **Snapshot unit** | Large or complex output — assert via `snapshot_doctree` | microseconds | -| **Sphinx integration** (`@pytest.mark.integration`) | Must verify actual HTML output or Sphinx event wiring | 2–10 s | - -The `just test-fast` lane skips integration tests for rapid feedback. -The full `just test` lane runs everything. - -### Scenario caching - -Integration tests use the harness in `tests/_sphinx_scenarios.py`. -`build_shared_sphinx_result()` caches builds by a SHA-256 content-hash -digest, achieving a **9.5x speedup** (~40 s to ~4.2 s for 916 tests). - -Key rules: - -- Always `scope="module"` or `scope="session"` on build fixtures — never - `scope="function"` -- Use `purge_modules` to remove synthetic Python modules from `sys.modules` - before the initial build -- Use `SCENARIO_SRCDIR_TOKEN` + `substitute_srcdir=True` for `sys.path` - injection in scenario `conf.py` files - -### Snapshot testing - -The project uses [syrupy](https://github.com/toptal/syrupy) for snapshot -assertions. Three custom fixtures (from `tests/_snapshots.py`) normalize -their inputs before asserting: - -- `snapshot_doctree(doctree)` — normalizes a `nodes.Node` -- `snapshot_html_fragment(html)` — strips ANSI, normalizes whitespace -- `snapshot_warnings(warnings)` — strips noise lines and ANSI codes - -Update stored snapshots after intentional output changes: - -```console -$ uv run pytest --snapshot-update -``` - -[git]: https://git-scm.com/ -[uv]: https://github.com/astral-sh/uv -[entr(1)]: http://eradman.com/entrproject/ -[`entr(1)`]: http://eradman.com/entrproject/ +The pytest level hierarchy — pure unit, docutils tree, snapshot, and +Sphinx integration — is +[the Tests section](https://github.com/git-pull/gp-sphinx/blob/main/.github/CONTRIBUTING.md#tests) +of that guide. diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index 8526087e..0148e8e2 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -1,39 +1,49 @@ # AGENTS.md — `sphinx-vite-builder` -Guidance for AI agents (Claude Code, Cursor, Copilot, Codex, …) and -human contributors working on this package. Mirrors the higher-level -guidance at `gp-sphinx/CLAUDE.md`; `packages/sphinx-vite-builder/CLAUDE.md` -points here so Claude Code reads the same content as other agent runners. +Package-specific rules only. The workspace project map, prose policy, +and workflow are at the repository root — see +[../../AGENTS.md](../../AGENTS.md), +[../../.github/WRITING.md](../../.github/WRITING.md), and +[../../.github/CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Those +govern docstrings, doctests, typing, and commits here too; nothing +below repeats them. ## What this package is Two orthogonal entry points sharing one subprocess core: -1. **PEP 517 build backend** at `sphinx_vite_builder.build`. Runs - `pnpm exec vite build` before delegating wheel/sdist construction - to `hatchling.build`. Consumer packages declare it via - `[build-system].build-backend = "sphinx_vite_builder.build"`. +1. **PEP 517 build backend** at `sphinx_vite_builder.build` (and a + hatchling-build-hook variant at `hatch_plugin.py` for consumers who + keep `build-backend = "hatchling.build"`). Runs `pnpm exec vite + build` before delegating wheel/sdist construction to + `hatchling.build`. Consumer packages declare it via + `[build-system].build-backend = "sphinx_vite_builder.build"`, or via + `[tool.hatch.build.hooks.vite]` for the hook variant. 2. **Sphinx extension** at `sphinx_vite_builder:setup`. Hooks - `builder-inited` and `build-finished` so `sphinx-build` / - `sphinx-autobuild` automatically run vite — one-shot for prod, a - long-lived `vite build --watch` child process for autobuild — with - graceful teardown on signal / `atexit`. + `builder-inited` to run `pnpm exec vite build` synchronously, + blocking until it finishes, then `build-finished` to log any + exception. Both `sphinx-build` and `sphinx-autobuild` take this same + path: each `sphinx-autobuild` rebuild is a fresh subprocess, so a + synchronous one-shot vite per rebuild — not a persistent watcher — + is what keeps Sphinx's `copy_static_files` phase from racing stale + assets. `vite_watch_command()` exists in `_internal/vite.py` but is + not currently called by either head. Both heads consume the smart-subprocess core under `sphinx_vite_builder._internal/`: `process.py` (`AsyncProcess` — asyncio subprocess wrapper with POSIX session isolation, SIGTERM-then-SIGKILL teardown, line-buffered stdout/stderr drainers, captured stderr for error surfacing), `bus.py` (`AsyncioBus` — single -asyncio loop in a daemon thread for sync↔async bridging), -`vite.py` (orchestration: detect `web/`, check pnpm via `shutil.which`, -spawn install/build), and `errors.py` (`PnpmMissingError`, -`NodeModulesInstallError`, `ViteFailedError`). +asyncio loop in a daemon thread that lets the synchronous Sphinx hook +block on the async subprocess call), `config.py` (mode detection from +`argv`/`SPHINX_AUTOBUILD`/parent command line — pure functions, no +Sphinx fixture needed to test them), `vite.py` (orchestration: detect +`web/`, check pnpm via `shutil.which`, spawn install/build), and +`errors.py` (`PnpmMissingError`, `NodeModulesInstallError`, +`ViteFailedError`). -**Phase 1 status:** The PEP 517 backend is fully implemented and -tested. The Sphinx extension `setup()` is a placeholder — it -registers cleanly in `conf.py` but doesn't yet hook the docs build -lifecycle. The full extension implementation (event handlers, vite -watch, teardown) lands in a follow-up release. +Both entry points are fully implemented and tested — this is not a +staged rollout. ## The design contract — keep this invariant @@ -114,14 +124,17 @@ new row here AND a corresponding test in ``` sphinx_vite_builder/ -├── __init__.py Sphinx extension entry: setup(app) -├── build.py PEP 517/660 hooks (delegate to hatchling) +├── __init__.py Sphinx extension entry: setup(app) +├── build.py PEP 517/660 hooks (delegate to hatchling) +├── hatch_plugin.py Hatchling build-hook variant (ViteBuildHook) ├── py.typed └── _internal/ - ├── errors.py SphinxViteBuilderError + 3 subclasses - ├── process.py AsyncProcess (asyncio subprocess wrapper) - ├── bus.py AsyncioBus (sync↔async bridge) - └── vite.py run_vite_build() + CI detection + hint formatters + ├── errors.py SphinxViteBuilderError + 3 subclasses + ├── process.py AsyncProcess (asyncio subprocess wrapper) + ├── bus.py AsyncioBus (sync/async bridge) + ├── config.py Mode detection + config dataclass + ├── hooks.py builder-inited / build-finished handlers + └── vite.py run_vite_build() + CI detection + hint formatters ``` Neither head calls the other; both consume `_internal/`. The PEP 517 @@ -131,15 +144,6 @@ Optional hooks (`get_requires_for_build_*`, `prepare_metadata_for_build_*`) alias to hatchling by identity — vite has no influence on dependency resolution or distribution metadata, so wrapping them would be wrong. -## When you add a new public function - -- Add doctests. Every public function MUST have working doctests - per the workspace convention. Use ELLIPSIS for variable output. -- Add NumPy-style docstrings: short summary, Parameters, Returns, - Raises, Examples. -- Add type annotations everywhere, including return types - (`-> None` on test functions). mypy runs strict mode. - ## When you add a new error path - Add a new `*Error` subclass in `errors.py` if the failure has a @@ -165,7 +169,7 @@ motivated this whole package). The required steps are: - uses: pnpm/action-setup@v6 with: version: 10 -- uses: actions/setup-node@v6 +- uses: actions/setup-node@v7 with: node-version: 22 ``` diff --git a/packages/sphinx-vite-builder/CLAUDE.md b/packages/sphinx-vite-builder/CLAUDE.md deleted file mode 100644 index 75740456..00000000 --- a/packages/sphinx-vite-builder/CLAUDE.md +++ /dev/null @@ -1,8 +0,0 @@ -# CLAUDE.md - -Claude Code reads this file. Other agent runners (Cursor, Copilot, -Codex, …) read [`AGENTS.md`](AGENTS.md). The two files have identical -content via this passthrough — every guideline lives in `AGENTS.md`, -and edits go there. - -→ See [`AGENTS.md`](AGENTS.md). diff --git a/packages/sphinx-vite-builder/CLAUDE.md b/packages/sphinx-vite-builder/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/packages/sphinx-vite-builder/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file