From 89b88f07475bdd7b6f196ddf135b517b5d9dc4e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 09:57:11 -0500 Subject: [PATCH 1/2] Docs(refactor[agents]): Route instead of restate why: AGENTS.md is read on every task, so a policy that matters to one class of change costs context on all the others. Splitting how we work from how we write lets each be loaded when it applies, and keeps both discoverable to humans rather than only to agents. what: - Replace AGENTS.md body with a router: the project map, universal change discipline, then one pointer per class of change - Add .github/WRITING.md, the fullest "Documented examples that run" section in the fleet: both collection paths (.py delegates to pytest's own DoctestModule; .rst/.md go through DocutilsDocTestFinder), every directive form (doctest_block, .. doctest::/{doctest}, testsetup/testcleanup), and what each registered flag (ALLOW_UNICODE, ALLOW_BYTES, NUMBER, HIDE) actually does versus the stdlib set - Document, verified against source and tests, that testsetup/ testcleanup do not share globals across blocks the way Sphinx's own testsetup does, and that docs/justfile's doctest recipe is not run by CI - Add .github/CONTRIBUTING.md with this repo's real gates, matrix, and release process, read from pyproject.toml, the justfile, and the workflows - Fix README: unverified PyPy support claim, docutils/myst-parser minimum-requirements section against actual pyproject.toml pins, a typo naming the package, and a "We"-voiced sentence; preserve every link and all three `>>> ` prompts - Replace docs/project/contributing.md and code-style.md with pointer pages to the canonical files; an {include} was tried and produces dead cross-file anchors on this repo's Sphinx, confirmed by inspecting the built HTML - Delete docs/AGENTS.md and docs/CLAUDE.md (absorbed into WRITING.md) and the lowercase .github/contributing.md (replaced) --- .github/CONTRIBUTING.md | 198 ++++++++ .github/WRITING.md | 874 +++++++++++++++++++++++++++++++++++ .github/contributing.md | 27 -- AGENTS.md | 806 +++----------------------------- README.md | 27 +- docs/AGENTS.md | 147 ------ docs/CLAUDE.md | 1 - docs/project/code-style.md | 29 +- docs/project/contributing.md | 100 +--- 9 files changed, 1156 insertions(+), 1053 deletions(-) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/WRITING.md delete mode 100644 .github/contributing.md delete mode 100644 docs/AGENTS.md delete mode 120000 docs/CLAUDE.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..ade2d57 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,198 @@ +# Contributing + +Thanks for looking. gp-libs is pre-1.0 (`0.0.x`); a minor version bump may +still carry a breaking change. Bug reports with a reproduction, and notes on +where the documentation misled you, are the most useful contributions right +now. + +How this project writes prose — README, `CHANGES`, release notes, commit +messages, docstrings, source comments, and log messages — 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], [uv], and [just]. + +```console +$ git clone https://github.com/git-pull/gp-libs.git +``` + +```console +$ cd gp-libs +``` + +```console +$ uv sync --all-extras --dev +``` + +[git]: https://git-scm.com/ +[uv]: https://github.com/astral-sh/uv +[just]: https://just.systems/ + +## The gates + +Format: + +```console +$ uv run ruff format . +``` + +Lint: + +```console +$ uv run ruff check . --fix --show-fixes +``` + +Type-check — strict mypy across `src/` and `tests/`: + +```console +$ uv run mypy . +``` + +Test: + +```console +$ uv run pytest +``` + +**Imports.** Namespace stdlib imports — `import enum`, not +`from enum import Enum` — so a call site reads `enum.Enum`. Third-party +packages may use `from X import Y`. For typing, `import typing as t` and +access via the namespace: `t.NamedTuple`, `t.TYPE_CHECKING`. Every file +starts with `from __future__ import annotations`; ruff's isort +`required-imports` (`pyproject.toml`) enforces that one, the rest is +convention ruff does not check. + +Documentation is a gate, not a courtesy. Examples in `src/*.py` docstrings +and under `docs/` are executed by `uv run pytest`, because `testpaths` in +`pyproject.toml` lists `tests`, `docs`, and `src`; `README.md` is not in +`testpaths`, so its three `>>> ` examples do not currently run under CI. +There is no separate doctest step for the paths that are collected — a +green `pytest` is the proof. Which blocks qualify, and the one mistake that +silently removes a test, are in +[WRITING.md](WRITING.md#documented-examples-that-run). + +Before claiming a test or a gate works, show it failing. A gate that has +never been red is an assumption. + +CI (`.github/workflows/tests.yml`) runs the equivalent of the four commands +above (`ruff check .`, `ruff format . --check`, `mypy .`, +`py.test --cov=./ --cov-report=xml`) across a matrix of Python 3.10-3.14, +docutils 0.20 and 0.22.4, and pytest 8 and 9 — gp-libs supports a wider +version span than most consumers of it, because everything downstream +depends on this collector staying compatible. + +## Tests + +Write tests as standalone functions, not classes — no `class TestFoo:` +groupings. Use descriptive function names and file organization instead. + +Prefer fixtures from `tests/conftest.py` over `monkeypatch` and +`unittest.mock` when one exists; document in the test docstring why a +standard fixture was not used for an exceptional case. Use `tmp_path` +(`pathlib.Path`) over `tempfile`, and `monkeypatch` over `unittest.mock` +when you do need one. + +`tests/conftest.py` also provides the Sphinx `app_params`/`make_app_params` +fixtures (via `pytest_plugins = ["sphinx.testing.fixtures", "pytester"]`) +for tests that build a throwaway Sphinx app — used by +`tests/test_linkify_issues.py` and the plugin-suppression tests. +`tests/regressions/` holds one file per historical bug, named for its +issue. + +Run continuously with [pytest-watcher]: + +```console +$ just start +``` + +Or with [entr(1)] when you want a shell-only watcher: + +```console +$ just watch-test +``` + +[pytest-watcher]: https://github.com/olzhasar/pytest-watcher +[entr(1)]: http://eradman.com/entrproject/ + +## Documentation + +Build the docs: + +```console +$ just build-docs +``` + +Start the default preview server, which watches for file changes: + +```console +$ just start-docs +``` + +From inside `docs/`, the local `docs/justfile` has finer-grained recipes: +`just html` builds once, `just serve` serves the built output, `just watch` +rebuilds on change, `just dev` watches and serves together, and +`just design` disables incremental builds while you edit static assets. + +`docs/conf.py` also enables `sphinx.ext.doctest` with a +`doctest_global_setup` that imports `is_allowed_version` and +`pytest_ignore_collect`, so the `{doctest}` blocks under `docs/` are valid +input to Sphinx's own doctest builder as well as to pytest. Run it with +`just -f docs/justfile doctest`. Neither `tests.yml` nor `docs.yml` invokes +that recipe — it is a manual, local-only check, not a CI gate. `just +build-docs` is what CI runs, and it is also the only thing that catches a +broken `{ref}`/`{doc}` cross-reference; the doctests do not. + +## Releasing + +gp-libs is pre-1.0: minor version bumps may include breaking changes. [uv] +handles virtualenv creation, package requirements, versioning, building, +and publishing — there is no `setup.py` or requirements file. + +1. Update `CHANGES` with release notes. +2. Bump the version in `src/gp_libs.py` and `pyproject.toml`. +3. Create the release commit: + + ```console + $ git commit -m 'Tag v0.1.1' + ``` + +4. Push the branch for review: + + ```console + $ git push + ``` + +5. After review, the release owner creates and pushes the tag. 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). + +## 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. + +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. Report it privately +through GitHub: +. diff --git a/.github/WRITING.md b/.github/WRITING.md new file mode 100644 index 0000000..0a79282 --- /dev/null +++ b/.github/WRITING.md @@ -0,0 +1,874 @@ +# Writing + +How this project writes prose, for humans and agents alike. It governs +`README.md`, `CHANGES`, release notes, commit messages, docstrings, source +comments, log messages, and the Markdown and reStructuredText under `docs/` +— every surface a reader reaches. + +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…" | "`DocutilsDocTestFinder.find` now accepts…" | +| "New and improved" | "`linkify_issues` 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 wiring gp-libs into their own project: pointing pytest +at `docs/` with `pytest_doctest_docutils`, running +`python -m doctest_docutils README.md` by hand, or adding `linkify_issues` +to a Sphinx `conf.py`. They are fluent in pytest and Sphinx as users — +`conftest.py`, fixtures, `extensions`, `testpaths` — and write +reStructuredText or Markdown daily, but you cannot assume they know +gp-libs' internals: docutils node traversal, directive registration, or how +the finder decides a block is a doctest. + +A second, smaller reader works *on* gp-libs or against its lower layers: +the doctest finder, the docutils compatibility shims, myst-parser directive +registration, or contributing. Serve them too, but mark their material +opt-in ("for the rarer cases", "advanced") so the default reader knows they +can stop. Never make the common case pay a comprehension tax for the +advanced one. + +Rules that follow: + +- **Second person, present tense, active.** "You point pytest at `docs/`", + not "Files are collected". Address the reader who is doing the thing. +- **Concept before configuration.** Open by saying what the tool *is* and + what it does for the reader. The `conf.py` key, the pytest flag — those + are the last details they need, not the first. A page that opens with + "set these keys" has buried the idea under its mechanics. +- **Say when they can stop.** Lead with the default and the reassurance: + install the plugin and `pytest docs/` just works; `issue_url_tpl` is the + one setting `linkify_issues` needs. 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 + default → the one option a few will tune (a custom `issue_re`, + `--doctest-docutils-modules`) → running `doctest_docutils` directly → the + docutils machinery underneath. Each step is for a smaller audience than + the last. +- **Lean on the pipeline.** The reader thinks in a chain: a `.rst` or `.md` + file is parsed (docutils, with myst-parser for Markdown), its examples + are collected, then run. Reinforce that chain when explaining why + Markdown needs myst-parser or why a fixture needs a visible + `conftest.py`. +- **Name the trade-off.** If a choice costs something — the plugin disables + pytest's standard doctest plugin, Markdown support goes through + myst-parser, fixtures reach only files a `conftest.py` can see — say so, + and say what it buys. State it; do not sell it. +- **Frame by concept, not by mechanism.** Do not headline a feature by its + `conf.py` key or pytest flag in prose; that names the implementation + surface, the reader's last concern. Name the concept. The mechanics + vocabulary — the flag spelling, the default regex — belongs in a + reference block or the API section, and only there. + +`docs/modules/linkify_issues/index.md` is the worked example: a +concept-first intro that says what the extension does (plain-text `#123` +becomes a link) before any `conf.py` key, a two-step default configuration +most readers can stop after, `issue_re` marked as optional tuning for the +smaller audience, an honest close that more complex needs mean forking, +and the API reference last. + +## 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. + +Examples are executable, not illustrative fiction. 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. `--help` already +enumerates flags, and gp-libs has none — it ships two pytest plugin +components, a Sphinx extension, and a CLI entry point on +`doctest_docutils` itself. What prose can say that a flag list cannot is +precedence, which files each collector reaches, and what a passing or +failing run means. + +State defaults explicitly — defaults are API. State negative guarantees +where they exist: "no `doctest_namespace` fixtures are registered for +`docs/`", "the pytest plugin blocks pytest's own doctest collection". 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, and gp-libs is what makes that true: it +ships the collector every other repo in this fleet relies on. This section +documents every format `doctest_docutils` and `pytest_doctest_docutils` +actually support, verified against `src/doctest_docutils.py` and +`src/pytest_doctest_docutils.py` — not the aspirational set. + +**A fence tag is cosmetic; only a `>>> ` prompt executes.** A block written +as + + ```python + finder = DocutilsDocTestFinder() + ``` + +is prose that looks like a test. Nothing collects it, nothing runs it, and +it can be wrong for years. The same block written with prompts is a test: + + ```python + >>> finder = DocutilsDocTestFinder() + ``` + +This is the single most expensive mistake available when editing +documentation, because removing the prompts leaves a green test suite and +a silently deleted test. When editing a file that contains examples, count +the prompts before and after. + +**The fence tag is `python`.** Not `pycon`, not bare. This is uniform +across the fleet and tooling depends on it — even though, for gp-libs +specifically, the underlying finder is looser than the convention: it +matches any fenced block whose content matches doctest's own prompt regex, +regardless of the language tag on the fence. Do not rely on that; a `text` +or `pycon` fence with a working `>>> ` example is an accident other tools +in the fleet will not replicate. Use `python`. + +**Two independent collection paths.** `.py` files are handed straight to +pytest's own `DoctestModule` — the same class pytest's built-in `doctest` +plugin uses, called directly rather than through that plugin's hooks. This +repo's plugin registers under the `pytest11` entry-point key `sphinx` (see +`[project.entry-points.pytest11]` in `pyproject.toml`), and on +`pytest_configure` it blocks pytest's own `doctest` plugin +(`config.pluginmanager.set_blocked("doctest")`) so a `.py` docstring is +never collected twice. `.rst` and `.md` files go through +`DocutilsDocTestFinder` instead: it parses the file with docutils (`.rst`) +or myst-parser (`.md`), then walks the resulting tree for anything +doctest-shaped. A file's presence in `testpaths` (`pyproject.toml`) makes +it eligible for either path; only prompts make a block executable. + +**Where examples run in this repo.** `testpaths` lists `tests`, `docs`, +and `src`, so `.py` docstrings under `src/`, `.md` files under `docs/`, +and `.py` files under `tests/` are all collected. `README.md` is *not* +listed in `testpaths`, so pytest never reaches any of its three `>>> ` +prompts today — do not describe the README as tested. Keep the prompts as +prompts anyway: dropping a `>>> ` silently deletes what would otherwise be +a test, and the count (`rg -c '^\s*>>> ' README.md`) is the guard against +that. Note that adding `README.md` to `testpaths` would not make all three +runnable as-is — two of them are `>>> ` lines nested inside a fence that +is itself illustrating `.. doctest::`/`{doctest}` syntax as text, one +fence-level too deep for the finder's regex fallback to see past. Only the +bare `doctest_block` example would collect. Making the other two +executable, not just illustrative, would need its own follow-up. + +**Formats the finder collects**, for `.rst` and `.md`: + +- **A bare `>>> ` prompt.** Docutils' own `doctest_block` node type — a + plain paragraph starting with `>>> ` needs no directive in + reStructuredText. In Markdown this needs a fence (` ```python `); myst + gives you the same node. +- **The `.. doctest::` directive** (reStructuredText) or the + ` ```{doctest} ``` ` fence (MyST Markdown). Both register through the + same `DoctestDirective` class. Options, all read from `self.options` on + the directive: + - `:options:` — inline doctest flags, e.g. `+ELLIPSIS -NORMALIZE_WHITESPACE`. + - `:pyversion:` — a PEP 440 specifier (checked with `is_allowed_version`); + the example is skipped when the running interpreter does not satisfy + it. + - `:skipif:` — an expression the runner evaluates to decide whether to + skip. + - `:trim-doctest-flags:` / `:no-trim-doctest-flags:` — whether + `# doctest: +FLAG` comments are stripped from the rendered code before + display. + - `` in the block content is doctest's own convention for an + intentional blank line in expected output; it works here exactly as in + stdlib doctest, and the directive additionally pretty-prints it back to + a real blank line in the built HTML. +- **`.. testsetup::` / `.. testcleanup::`** (or the ` ```{testsetup} ``` ` + / ` ```{testcleanup} ``` ` fences). These render as hidden comments + instead of a visible code block, which is the entire effect they have: + each one is still an independent doctest that needs its own `>>> ` + prompts to execute, and — unlike Sphinx's `sphinx.ext.doctest` — it does + **not** share globals with the doctest blocks around it. Each collected + block gets its own copy of the file's globals + (`doctest.DocTest.__init__` copies `globs`), so a variable a + `testsetup` block assigns is gone by the next block. Nothing in this + repo's own documentation currently uses `testsetup`/`testcleanup`; the + only exercise they get is in `tests/`. Treat them as "hide this from the + rendered page" rather than "shared fixture for the examples that + follow" — write the setup you need directly into each example instead. + +**Flags.** `ELLIPSIS` and `NORMALIZE_WHITESPACE` are enabled globally via +`doctest_optionflags` in `pyproject.toml`, so `...` elides variable output +and whitespace differences do not fail a comparison. The stdlib flag set +(`ELLIPSIS`, `NORMALIZE_WHITESPACE`, `IGNORE_EXCEPTION_DETAIL`, +`DONT_ACCEPT_BLANKLINE`, `SKIP`, and the rest of +`doctest.OPTIONFLAGS_BY_NAME`) all work as usual. `pytest_doctest_docutils` +additionally registers, via `doctest.register_optionflag`: + +- `ALLOW_UNICODE` / `ALLOW_BYTES` — ignore a `u''`/`b''` string prefix + mismatch. These are not gp-libs inventions: they are the same flags + pytest's own (now-blocked) `doctest` plugin defines, re-registered here + so `.rst`/`.md` collection — which builds its own runner and reuses + pytest's checker (`_pytest.doctest._get_checker()`) — gets identical + behaviour to a `.py` doctest. +- `NUMBER` — also borrowed from pytest's checker: ignores floating-point + precision beyond what the literal in the expected output states. +- `HIDE` — the one flag that is actually gp-libs' own. It is a **no-op for + execution**: the output checker never consults it, so `+HIDE` never + changes whether an example passes. Registering it exists for one reason + — so `# doctest: +HIDE` *parses* instead of raising + `ValueError: invalid option`. It is a signal for documentation tooling + that wants to know "this example should run as a test but not appear in + rendered output"; gp-libs itself does not act on that signal anywhere. + `HIDE` is registered eagerly in `pytest_configure`, before any + docstring is parsed, because the `.py` collection path never calls the + function (`_get_flag_lookup`) that registers `ALLOW_UNICODE`, + `ALLOW_BYTES`, and `NUMBER` — without the eager registration, a `.py` + docstring using `+HIDE` would fail to parse. + +**`# doctest: +SKIP` is not permitted** in this fleet's own documentation. +It is a workaround that tests nothing. `doctest_docutils` supports it +mechanically — `tests/test_doctest_options.py` proves the mechanism works, +because gp-libs is the tool and has to prove its own flags function — but +that is a test of the tool, not licence to write `+SKIP` into a page. Use +the fixtures, `pyversion`, or `skipif` instead. + +**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. + +**No `doctest_namespace` fixtures anywhere in this repo.** A `conftest.py` +only reaches files inside its own subtree, and the only `conftest.py` in +this repo lives at `tests/conftest.py` — invisible to `src/` and `docs/`. +Add objects to `doctest_namespace` from a fixture when you need shared +helpers for a group of `.rst`/`.md` examples: + +```python +import pytest + + +@pytest.fixture +def add_helpers(doctest_namespace): + def add(left, right): + return left + right + + doctest_namespace["add"] = add +``` + +— but until such a fixture exists and is visible to the file you are +editing, keep documentation examples self-contained: import what you use +inside the block. + +**Docstring examples** use the NumPy `Examples` section: + + Examples + -------- + >>> is_allowed_version('3.3', '<=3.5') + True + +**gp-libs dogfoods two more mechanisms most repos in this fleet do not.** +`docs/conf.py` adds `sphinx.ext.doctest` to `extra_extensions` and sets +`doctest_global_setup` (importing `is_allowed_version` and +`pytest_ignore_collect`), so the `{doctest}` blocks under `docs/` are also +valid input to Sphinx's own doctest builder, not only to pytest. Running +that builder is `just -f docs/justfile doctest` — a real recipe, but one +`tests.yml` and `docs.yml` never invoke. Treat it as a manual, +local-only check: useful when you are debugging a `docs/conf.py` change, +not a gate anything is blocked on. `uv run pytest` is what actually proves +a documentation example runs in CI. + +## The changelog + +`CHANGES` is the changelog. Not `CHANGELOG.md`. It is rendered as +[the project's changelog page](https://gp-libs.git-pull.com/history.html) +via `docs/history.md`. + +A ledger, not a narrative. It is scanned, and the question a reader is +asking is whether an entry affects them. Modeled on Django's release-notes +shape — deliverables get titles and prose, not bullets. + +**Release entry boilerplate.** Every release header is +`## gp-libs X.Y.Z (YYYY-MM-DD)`. The file opens with a +`## gp-libs X.Y.Z (unreleased)` placeholder block fenced by +`` and +`` HTML comments — new release entries land +immediately below the END marker, never above it. + +**Open with a multi-sentence lead paragraph.** Plain prose, no italic. +Open with the version as sentence subject ("gp-libs 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. + +**Unreleased entries carry no lead paragraph and no version summary.** +Speaking for a 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 paragraph 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 (#NN)` 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 isn't ready. Mechanism — helper internals, byte counters, +schema-validation locations — belongs in pull request 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 in each deliverable's `####` 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 (#NN)` heading with a 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. Private symbols (leading-underscore +identifiers) and algorithm names exposed for the first time. Walls of text +dressed up as bullets. Breaking changes buried mid-entry instead of given +their own subheading at the top. + +**Always link autodoc'd APIs.** Any class, method, function, exception, or +attribute that has its own rendered page must be cited with its role +(`{class}`, `{meth}`, `{func}`, `{exc}`, `{attr}`) — never plain backticks. +Doc pages without an explicit ref label use `{doc}`. Plain backticks are +correct for code syntax, environment variables, parameter names, and file +paths that are not doc pages — anything without an autodoc destination. + +**Summarization style.** When asked "what changed in the latest version?", +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 asked for source links. Do not invent +versions, dates, or numbers not present in `CHANGES`. Do not quote line +numbers or file offsets — those shift as the file evolves. + +## Release notes + +`CHANGES` is the permanent ledger; a release page is editorial. Lead with +one paragraph naming the headline change, then three to five highlights, +then link the full changelog. + +Numbers over adjectives. A list of merged commit subjects is a merge log +wearing a release-note hat. Put the hand-written highlights above it. + +Versions are PEP 440 identifiers. Semantic-versioning meaning applies to +the documented public API — including `doctest_docutils`'s CLI arguments, +the `pytest11` entry point, `linkify_issues`' `conf.py` keys, and the +registered doctest flags, not only imported Python symbols. gp-libs is +pre-1.0: a minor version bump may still include a breaking change. + +## Docstrings + +New public functions and methods carry a doctest that exercises them — +doctests are both documentation and a test, and gp-libs is the tool that +runs them. This guides new work; not every existing function conforms +today, and fixing that opportunistically is welcome but is not a +prerequisite for an unrelated change. + +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_test_name(node: Node) -> str: + """Get the test's name. + + Parameters + ---------- + node : Node + The node. + + 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 docutils or myst-parser version. +- **Security boundary.** What is executed, and what is only read. + +Follow [NumPy docstring style](https://numpydoc.readthedocs.io/en/latest/format.html) +for every public function, method, and class — enforced by ruff's +`pydocstyle` rules (`convention = "numpy"` in `pyproject.toml`), not +relitigated in review. 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. + +**Classes with fields** — `NamedTuple`, dataclasses — document every field +in an `Attributes` section: + +```python +class ConsoleExample(t.NamedTuple): + """Console example collected from a Markdown page. + + Attributes + ---------- + path : pathlib.Path + Markdown file the example was collected from. + """ +``` + +Autodoc renders every field whether or not you describe it, so an +undocumented `NamedTuple` field ships to the API docs as "Alias for field +number 0" and a dataclass field ships bare. Document all of them — a class +with three fields and two documented still ships a stub for the third. + +## Logging + +These rules guide future logging changes; existing code may not yet +conform. + +**Logger setup.** 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. + +**Structured context via `extra`.** Pass structured data on every log call +where useful for filtering, searching, or test assertions. + +Core keys (stable, scalar, safe at any log level): + +| Key | Type | Context | +|-----|------|---------| +| `doctest_source_file` | `str` | doctest source path (`.rst`, `.md`, `.py`) | +| `doctest_block_type` | `str` | block type (`doctest_block`, code fence) | +| `sphinx_extension` | `str` | Sphinx extension name | + +Treat established keys as compatibility-sensitive — downstream users may +build dashboards and alerts on them. Change deliberately. Keys are +`snake_case`, not dotted, with project-specific prefixes (`doctest_`, +`sphinx_`). Prefer stable scalars; avoid ad-hoc objects. + +**Lazy formatting.** `logger.debug("msg %s", val)`, not f-strings. Two +reasons: deferred string interpolation is skipped entirely when the level +is filtered, and aggregators group by message template — `"Running %s"` is +one signature grouped ×10,000, while f-strings make every line unique. +When computing `val` itself is expensive, guard with +`if logger.isEnabledFor(logging.DEBUG)`. + +**`stacklevel` for wrappers.** Increment for each wrapper layer so +`%(filename)s:%(lineno)d` and OTel `code.filepath` point to the real +caller. Verify whenever call depth changes. + +**Log levels.** + +| Level | Use for | Examples | +|-------|---------|----------| +| `DEBUG` | Internal mechanics | Doctest parsing, node traversal steps | +| `INFO` | Lifecycle, user-visible operations | Extension loaded, document processed | +| `WARNING` | Recoverable issues, deprecation | Deprecated directive, missing optional dependency | +| `ERROR` | Failures that stop an operation | Parse error, invalid configuration | + +**Message style.** Lowercase, past tense for events: "extension loaded", +"parse error". No trailing punctuation. Keep messages short; put details +in `extra`, not the message string. + +**Exception logging.** Use `logger.exception()` only inside `except` +blocks when not re-raising. Use `logger.error(..., exc_info=True)` when +the traceback is needed outside an `except` block. Avoid +`logger.exception()` followed by `raise` — it duplicates the traceback. +Either add context via `extra` that would otherwise be lost, or let the +exception propagate. + +**Testing logs.** Assert on `caplog.records` attributes, not string +matching on `caplog.text`. Scope capture with +`caplog.at_level(logging.DEBUG, logger="doctest_docutils")`. Filter +records rather than index by position. Assert on schema +(`record.sphinx_extension == "doctest_docutils"`), not substring matching. +`caplog.record_tuples` cannot access extra fields — always use +`caplog.records`. + +**Avoid:** f-strings or `.format()` in log calls; unguarded logging in hot +loops; catch-log-reraise without adding new context; `print()` for +diagnostics; logging secret environment variable values (log key names +only); non-scalar ad-hoc objects in `extra`; custom `extra` fields +referenced in format strings without safe defaults (a missing key raises +`KeyError`). + +## 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. + +### 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 `Parameters`/`Returns`/`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 `NamedTuple` or dataclass field +whether or not you describe it, and a doctest that runs is also a test. + +## Terminology and capitalization + +Pick the domain noun and keep it. This project's own vocabulary is +`doctest_docutils` (the module), `pytest_doctest_docutils` (the plugin), +and `linkify_issues` (the Sphinx extension) — do not call the finder a +"scanner" in one paragraph and a "collector" in the next, and do not +alternate "issue link" with "reference link" once `linkify_issues` has +established the term. + +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 symbols exist, 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. + +## 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 ("how the finder decides", "the docutils machinery") — not as a +standalone footnote the eye skips. Use `{class}`, `{meth}`, `{func}`, +`{mod}`, `{exc}`, `{attr}` for API objects; `{ref}` or `{doc}` for +documentation pages and section anchors; a Markdown link or reference link +for external projects. A `{ref}` must match its target's anchor exactly — +anchors mix underscore and hyphen forms across pages (`doctest_docutils`, +`linkify-issues`). + +Link the first prose mention of any symbol that has a useful destination +on that page. After the first linked mention, 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. + +`just build-docs` catches a broken cross-reference; the doctests do not — +build the docs before you commit a page with a `{ref}` or `{doc}` role on +it. + +## 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**, **fix**, **refactor**, **docs**, **chore**, +**test**, **style**, **ci**, **py(deps)**, **py(deps[dev])**, +**ai(rules[AGENTS])**, **ai(claude[rules])**, **ai(claude[command])**. + +Example: + +``` +doctest_docutils(feat[parse]): Add support for myst-parser code blocks + +why: Enable doctest execution in Markdown documentation files + +what: +- Add detection for ```{doctest} fence syntax +- Register myst directives automatically +- Add tests for Markdown doctest parsing +``` + +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 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 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 (the + published-release test below). +- **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 in shipped + text. Internal agent bookkeeping may use ids; shipped text may not. + +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, release notes, or lockfiles. + +### 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/master/…` +links rot silently — the file moves, lines shift, and the anchor lands on +unrelated code while still resolving. + +- Prefer a release tag (`blob/v0.0.19/…`). 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/master/…` for living documents meant to always show the + latest state, such as this file and `CONTRIBUTING.md`. +- Line anchors (`#L120-L145`) are only safe on a pinned ref. + +### 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 behaviour, or bug? If no, it +is branch-internal narrative — move it to the commit message and describe +only the final state in the artefact. + +Keep in shipped artefacts: 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 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. For in-branch +commits, prefer `fixup!` commits with `git rebase --autosquash` to address +each causal commit at its source, or a single cleanup commit at branch +tip. Default to leaving trunk or parent-branch commits alone; act on them +only on explicit instruction, and fold any resulting cleanup into a single +commit at branch tip rather than rewriting shared history. diff --git a/.github/contributing.md b/.github/contributing.md deleted file mode 100644 index c0eddac..0000000 --- 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 69083c9..0b2184e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,751 +1,65 @@ # 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-libs is a Python library providing internal utilities and extensions for git-pull projects. It focuses on extending Sphinx documentation and pytest functionality with support for docutils-compatible markup formats. - -Key features: -- **doctest_docutils**: Reimplementation of Python's doctest with support for reStructuredText and Markdown -- **pytest_doctest_docutils**: pytest plugin for running doctests in documentation files -- **linkify_issues**: Sphinx extension that converts issue references (e.g., `#123`) to hyperlinks -- Supports testing doctest examples in `.rst` and `.md` files -- Powers documentation testing across the git-pull ecosystem - -## Development Environment - -This project uses: -- Python 3.10+ -- [just](https://github.com/casey/just) for command running (see also https://just.systems/) -- [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 pip install --editable . -uv pip sync - -# Install with development dependencies -uv pip install --editable . -G 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_doctest_docutils.py - -# Run a specific test -uv run pytest tests/test_doctest_docutils.py::test_function_name - -# 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 (see `.cursor/rules/dev-loop.mdc`): - -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 - -# Update documentation CSS/JS -just design-docs -``` - -## Code Architecture - -gp-libs provides utilities for documentation testing and Sphinx extensions: - -``` -src/ -├── doctest_docutils.py # Core doctest reimplementation -├── pytest_doctest_docutils.py # pytest plugin -├── linkify_issues.py # Sphinx extension -├── docutils_compat.py # Compatibility layer -└── gp_libs.py # Package metadata -``` - -### Core Modules - -1. **doctest_docutils** (`src/doctest_docutils.py`) - - Reimplementation of Python's standard library `doctest` module - - Supports docutils-compatible markup (reStructuredText and Markdown) - - Handles `doctest_block`, `.. doctest::` directive, and ` ```{doctest} ` code blocks - - PEP-440 version specifier support for conditional tests - - Can be run directly: `python -m doctest_docutils README.md -v` - -2. **pytest_doctest_docutils** (`src/pytest_doctest_docutils.py`) - - pytest plugin integrating doctest_docutils with pytest - - Collects and runs doctests from `.rst` and `.md` files - - Full pytest fixture and conftest.py support - - Registered as `pytest11` entry point - -3. **linkify_issues** (`src/linkify_issues.py`) - - Sphinx extension for automatic issue linking - - Converts `#123` references to clickable hyperlinks - - Configured via `issue_url_tpl` in Sphinx conf.py - -4. **docutils_compat** (`src/docutils_compat.py`) - - Compatibility layer for cross-version docutils support - - Provides `findall()` abstraction for different docutils versions - -5. **gp_libs** (`src/gp_libs.py`) - - Package metadata (version, title, author, URLs) - -## Testing Strategy - -gp-libs uses pytest for testing with custom fixtures. The test suite includes: - -- Unit tests for doctest parsing and execution -- Integration tests for pytest plugin functionality -- Sphinx app factory for testing extensions - -### Test Structure - -``` -tests/ -├── test_doctest_docutils.py # Tests for doctest module -├── test_pytest_doctest_docutils.py # Tests for pytest plugin -├── test_linkify_issues.py # Tests for linkify extension -├── conftest.py # Fixtures and sphinx app factory -└── regressions/ # Regression tests -``` - -### Testing Guidelines - -1. **Use functional tests only**: Write tests as standalone functions, not classes. Avoid `class TestFoo:` groupings - use descriptive function names and file organization instead. - -2. **Use existing fixtures over mocks** (see `.cursor/rules/dev-loop.mdc`) - - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available - - Document in test docstrings why standard fixtures weren't used for exceptional cases - -3. **Preferred pytest patterns** - - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` - - Use `monkeypatch` fixture over `unittest.mock` - -4. **Running tests continuously** - - Use pytest-watcher during development: `uv run ptw .` - - For doctests: `uv run ptw . --now --doctest-modules` - -## Coding Standards - -For detailed coding standards, refer to `.cursor/rules/dev-loop.mdc`. Key highlights: - -### Imports - -- **Use namespace imports for stdlib**: `import enum` instead of `from enum import Enum`; 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 - -### Docstrings - -Follow NumPy docstring style for all functions and methods (see `.cursor/rules/dev-loop.mdc`): - -```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 -""" -``` - -**Classes with fields** — `NamedTuple`, dataclasses — document every field in -an `Attributes` section: - -```python -class ConsoleExample(t.NamedTuple): - """Console example collected from a Markdown page. - - Attributes - ---------- - path : pathlib.Path - Markdown file the example was collected from. - """ -``` - -Autodoc renders every field whether or not you describe it, so an -undocumented `NamedTuple` field ships to the API docs as "Alias for field -number 0" and a dataclass field ships bare. Document all of them — a class -with three fields and two documented still ships a stub for the third. - -### 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 use placeholder output -- 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: `tmp_path` (add more via `conftest.py`) -- Ellipsis for variable output: `# doctest: +ELLIPSIS` -- PEP-440 version specifiers via `is_allowed_version()` for version-conditional tests - -**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. Use the fixtures and ellipsis patterns properly. - -**Simple doctest example:** -```python ->>> is_allowed_version('3.3', '<=3.5') -True ->>> is_allowed_version('3.3', '>3.2, <4.0') -True -``` - -**When output varies, use ellipsis:** -```python ->>> parse_document(content) # doctest: +ELLIPSIS - -``` - -**Additional guidelines:** -1. Use narrative descriptions for test sections rather than inline comments -2. Move complex examples to dedicated test files at `tests/examples//test_.py` -3. Keep doctests simple and focused on demonstrating usage -4. Add blank lines between test sections for improved readability - -### 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 - -#### Structured context via `extra` - -Pass structured data on every log call where useful for filtering, searching, or test assertions. - -**Core keys** (stable, scalar, safe at any log level): - -| Key | Type | Context | -|-----|------|---------| -| `doctest_source_file` | `str` | doctest source path (.rst, .md, .py) | -| `doctest_block_type` | `str` | block type (doctest_block, code fence) | -| `sphinx_extension` | `str` | Sphinx extension name | - -Treat established keys as compatibility-sensitive — downstream users may build dashboards and alerts on them. Change deliberately. - -#### Key naming rules - -- `snake_case`, not dotted; project-specific prefixes (`doctest_`, `sphinx_`) -- Prefer stable scalars; avoid ad-hoc objects - -#### 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 ×10,000; f-strings make each line unique - -When computing `val` itself is expensive, guard with `if logger.isEnabledFor(logging.DEBUG)`. - -#### stacklevel for wrappers - -Increment for each wrapper layer so `%(filename)s:%(lineno)d` and OTel `code.filepath` point to the real caller. Verify whenever call depth changes. - -#### Log levels - -| Level | Use for | Examples | -|-------|---------|----------| -| `DEBUG` | Internal mechanics | Doctest parsing, node traversal steps | -| `INFO` | Lifecycle, user-visible operations | Extension loaded, document processed | -| `WARNING` | Recoverable issues, deprecation | Deprecated directive, missing optional dependency | -| `ERROR` | Failures that stop an operation | Parse error, invalid configuration | - -#### Message style - -- Lowercase, past tense for events: `"extension loaded"`, `"parse error"` -- 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. Either add context via `extra` that would otherwise be lost, or let the exception propagate - -#### Testing logs - -Assert on `caplog.records` attributes, not string matching on `caplog.text`: -- Scope capture: `caplog.at_level(logging.DEBUG, logger="doctest_docutils")` -- Filter records rather than index by position: `[r for r in caplog.records if hasattr(r, "doctest_source_file")]` -- Assert on schema: `record.sphinx_extension == "doctest_docutils"` not `"doctest_docutils" in caplog.text` -- `caplog.record_tuples` cannot access extra fields — always use `caplog.records` - -#### Avoid - -- f-strings/`.format()` in log calls -- Unguarded logging in hot loops (guard with `isEnabledFor()`) -- Catch-log-reraise without adding new context -- `print()` for diagnostics -- Logging secret env var values (log key names only) -- Non-scalar ad-hoc objects in `extra` -- Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`) - -### Git Commit Standards - -See `.cursor/rules/git-commits.mdc` for detailed commit message 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: -``` -doctest_docutils(feat[parse]): Add support for myst-parser code blocks - -why: Enable doctest execution in Markdown documentation files - -what: -- Add detection for ```{doctest} fence syntax -- Register myst directives automatically -- Add tests for Markdown doctest parsing -``` -#### 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. Older entries used a flat `### Section` + bullet shape; new entries follow the Django shape below. - -**Release entry boilerplate.** Every release header is `## gp-libs X.Y.Z (YYYY-MM-DD)`. The file opens with a `## gp-libs X.Y.Z (unreleased)` placeholder block fenced by `` and `` HTML comments — new release entries land immediately below the END marker, never above it. - -**Open with a multi-sentence lead paragraph.** Plain prose, no italic. Open with the version as sentence subject (*"gp-libs 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 (#NN)` 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 in each deliverable's `####` 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 (#NN)` 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 - -See `.cursor/rules/avoid-debug-loops.mdc` for detailed debugging guidance. - -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) - -## Sphinx/Docutils-Specific Considerations - -### Directive Registration - -- Use `_ensure_directives_registered()` to auto-register required directives -- Supports myst-parser directives (`{doctest}`, `{tab}`) -- Handles both reStructuredText and Markdown syntax - -### Document Parsing - -- Uses docutils for parsing `.rst` files -- Uses myst-parser for parsing `.md` files -- Both formats support doctest blocks - -### linkify_issues Configuration - -In your Sphinx `conf.py`: -```python -extensions = ["linkify_issues"] -issue_url_tpl = "https://github.com/git-pull/gp-libs/issues/{issue_id}" -``` - -## References - -- Documentation: https://gp-libs.git-pull.com/ -- GitHub: https://github.com/git-pull/gp-libs -- PyPI: https://pypi.org/project/gp-libs/ - -## 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. - -## 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/master/…` -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/master/…` 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 - -- 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. +gp-libs is the doctest and documentation-testing tooling for the git-pull +fleet: it makes `>>> ` examples in `.py`, `.rst`, and `.md` files runnable +as pytest tests, and links issue references in Sphinx docs. Other repos in +the fleet depend on its collector; treat its public behaviour as shared +infrastructure, not this repo's private concern. + +Follow the conventions already in the tree, and keep a change scoped to +what was asked for. + +## What is here + +| Path | What it is | +| ---- | ---------- | +| `src/doctest_docutils.py` | doctest reimplementation that parses reStructuredText and Markdown | +| `src/pytest_doctest_docutils.py` | pytest plugin (`pytest11` entry point, key `sphinx`) that collects those doctests | +| `src/linkify_issues.py` | Sphinx extension: `#123` becomes an issue link | +| `src/docutils_compat.py` | cross-version docutils compatibility shim (`findall`) | +| `src/gp_libs.py` | package metadata (version, title, URLs) | +| `tests/` | unit and regression tests; `tests/conftest.py` provides Sphinx app fixtures | +| `docs/` | Sphinx/MyST documentation; dogfoods the doctest collector it documents | +| `CHANGES` | changelog, rendered at `docs/history.md` | + +## Which policy applies + +- Documentation, user-facing text, `CHANGES`, release notes, 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, 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 + 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. + +`.py` files delegate straight to pytest's own `DoctestModule`; `.rst` and +`.md` files go through this package's own `DocutilsDocTestFinder`. Of the +non-stdlib doctest flags the pytest plugin registers, only `HIDE` is a +gp-libs invention — `ALLOW_UNICODE`, `ALLOW_BYTES`, and `NUMBER` are +borrowed from pytest's own (blocked) doctest plugin to keep behaviour +consistent. See +[Documented examples that run](.github/WRITING.md#documented-examples-that-run) +before changing collection, directive, or flag behaviour — it is the +fullest account of the mechanism in the fleet, and other repos rely on it +staying accurate. -Treat this file like code and prune it. +## References -- 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. +- Documentation: +- GitHub: +- PyPI: diff --git a/README.md b/README.md index 850dfc9..0c3411d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # gp-libs · [![Python Package](https://img.shields.io/pypi/v/gp-libs.svg)](https://pypi.org/project/gp-libs/) [![License](https://img.shields.io/github/license/git-pull/gp-libs.svg)](https://github.com/git-pull/gp-libs/blob/master/LICENSE) [![Code Coverage](https://codecov.io/gh/git-pull/gp-libs/branch/master/graph/badge.svg)](https://codecov.io/gh/git-pull/gp-libs) -Incubating / [dogfooding] some sphinx extensions and pytest plugins on -git-pull projects, e.g. [cihai], [vcs-python], or [tmux-python]. +Sphinx extensions and pytest plugins shared across git-pull's projects, +developed by [dogfooding] them on [cihai], [vcs-python], and +[tmux-python]. [dogfooding]: https://en.wikipedia.org/wiki/Eating_your_own_dog_food [cihai]: https://github.com/cihai @@ -84,7 +85,7 @@ $ python -m doctest_docutils README.md -v ### pytest plugin -_This plugin disables [pytest's standard `doctest` plugin]._ +_This plugin blocks [pytest's standard `doctest` plugin]._ This plugin integrates `doctest_docutils` with pytest so documentation examples run with the surrounding `conftest.py` setup. @@ -104,9 +105,10 @@ See more: ### Plain-text issue linker (`linkify-issues`) -We need to parse plain text, e.g. #99999, to point to the project tracker at -https://github.com/git-pull/gp-libs/issues/99999. This way the markdown looks -good anywhere you render it, including GitHub and GitLab. +`linkify_issues` turns a plain-text issue reference, e.g. #99999, into a link +to the project tracker at https://github.com/git-pull/gp-libs/issues/99999. +The source text stays plain, so it still reads correctly wherever it is +rendered unprocessed, including GitHub and GitLab. #### Configuration @@ -141,7 +143,7 @@ $ pip install --user gp-libs ### Developmental releases -You can test the unpublished version of g before its released. +You can test the unpublished version of gp-libs before it's released. - [pip](https://pip.pypa.io/en/stable/): @@ -152,17 +154,16 @@ You can test the unpublished version of g before its released. # Minimum requirements To lift the development burden of supporting legacy APIs, as this package is -lightly used, minimum constraints have been pinned: +lightly used, a minimum constraint is pinned in `pyproject.toml`: -- docutils: 0.20.1+ -- myst-parser: 2.0.0+ +- docutils: 0.20+ -If you have even passing interested in supporting legacy versions, file an -issue on the tracker. +`myst-parser` has no minimum version pinned. If you have a passing interest in +supporting legacy versions, file an issue on the tracker. # More information -- Python support: >= 3.10, pypy +- Python support: >= 3.10 - Source: - Docs: - Changelog: diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index 456e4b7..0000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,147 +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, doctest rules, changelog conventions, and MyST roles. -When the two overlap, the root file wins; this one only answers the -question it leaves open: how should the prose sound? - -## Who you are writing for - -The default reader is wiring gp-libs into their own project: pointing -pytest at `docs/` with `pytest_doctest_docutils`, running -`python -m doctest_docutils README.md` by hand, or adding -`linkify_issues` to a Sphinx `conf.py`. They are fluent in pytest and -Sphinx as users — `conftest.py`, fixtures, `extensions`, `testpaths` — -and write reStructuredText or Markdown daily, but you cannot assume -they know gp-libs' internals: docutils node traversal, directive -registration, or how the finder decides a block is a doctest. - -A second, smaller reader works *on* gp-libs or against its lower -layers: the doctest finder, the docutils compatibility shims, -myst-parser directive registration, or contributing. Serve them too, -but mark their material opt-in ("for the rarer cases", "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 point pytest at - `docs/`", not "Files are collected". Address the reader who is doing - the thing. -- **Concept before configuration.** Open by saying what the tool *is* - and what it does for the reader. The `conf.py` key, the pytest - flag — those are the last details they need, not the first. A page - that opens with "set these keys" has buried the idea under its - mechanics. -- **Say when they can stop.** Lead with the default and the - reassurance: install the plugin and `pytest docs/` just works; - `issue_url_tpl` is the one setting `linkify_issues` needs. Let a - skimmer leave after one paragraph. -- **Progressive disclosure.** Order by how many readers need it: the - default → the one option a few will tune (a custom `issue_re`, - `--doctest-docutils-modules`) → running `doctest_docutils` directly - → the docutils machinery underneath. Each step is for a smaller - audience than the last. -- **Lean on the pipeline.** The reader thinks in a chain: a `.rst` or - `.md` file is parsed (docutils, with myst-parser for Markdown), its - examples are collected, then run. It is the mental model the whole - toolkit hangs on; reinforce that chain when you explain why Markdown - needs myst-parser or why a fixture needs a visible `conftest.py`. -- **Name the trade-off.** If a choice costs something — the plugin - disables pytest's standard doctest plugin, Markdown support goes - through myst-parser, fixtures reach only files a `conftest.py` can - see — say so, and say what it buys. State it; don't sell it. -- **Frame by concept, not by mechanism.** Don't headline a feature by - its `conf.py` key or pytest flag in prose; that names the - implementation surface, the reader's last concern. Name the concept. - The mechanics vocabulary — the flag spelling, the default regex — - belongs in a reference block or the API section, and only there. - -## Examples that run - -Prose examples under `docs/` are doctests, and they actually execute — -`testpaths` in `pyproject.toml` includes `docs`, and pytest collects -them with this repo's own `pytest_doctest_docutils` plugin. The docs -dogfood the tool they describe; a broken example is a failing test. - -- Fence a `>>>` session as a ```` ```python ```` block or a - ```` ```{doctest} ```` directive — the finder collects both, plus - bare doctest blocks in reST. Use ```` ```console ```` for shell - commands at a `$` prompt. -- `ELLIPSIS` and `NORMALIZE_WHITESPACE` are on globally via - `doctest_optionflags`, so variable output can elide with `...` - without a per-example flag. -- No `doctest_namespace` fixtures are registered for `docs/` — no - `conftest.py` is visible to it — so keep examples self-contained: - import what you use inside the block. -- Console fences are checked by - `tests/test_docs_console_examples.py`. Keep one `$` prompt command per - block; safe `python -m doctest_docutils ...` examples with existing - local targets run in a temp-home sandbox, while install, watch, - server, git, and full-suite commands are policy-validated only. - -## What stays precise - -Warm the framing, never the facts. Resolution-order lists, default -regex patterns like `issue_re`, exact flag spellings, error strings, -and class or function 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. - -## 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 ("how the finder decides", "the docutils machinery") — not as -a standalone footnote the eye skips. Use the MyST roles listed in the -root `AGENTS.md` (`{class}`, `{meth}`, `{func}`, `{exc}`, `{attr}`, -`{ref}`, `{doc}`). A `{ref}` must match its target's anchor exactly — -anchors mix underscore and hyphen forms across pages -(`doctest_docutils`, `linkify-issues`). `just build-docs` catches a -broken cross-reference; the doctests do not — so build the docs before -you commit. - -Link the first prose mention of any symbol that has a useful -destination on that page. This includes Python objects, gp-libs APIs, -pytest and Sphinx concepts with intersphinx destinations, topic pages, -and external tools or projects. Use the most specific target -available: `{class}`, `{meth}`, `{func}`, `{mod}`, `{exc}`, or -`{attr}` for API objects; `{ref}` or `{doc}` for documentation pages -and section anchors; and a Markdown link or reference 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/modules/linkify_issues/index.md` is the worked example: a concept-first -intro that says what the extension does (plain-text `#123` becomes a -link) before any `conf.py` key, a two-step default configuration most -readers can stop after, `issue_re` marked as optional tuning for the -smaller audience, an honest close that more complex needs mean -forking, and the API reference last. Read it before reshaping another -page. - -## Before you commit - -- Does the page open with what the feature *is*, or with how to - configure it? -- Can a reader who needs only the default stop after the first - paragraph? -- Is anything framed as "the key/the flag" that should be named by - concept instead? -- Are the advanced and internals-level parts clearly marked opt-in? -- Do the doctests still run — `uv run pytest docs/` — and did you - leave every code block, pattern, and cross-reference exact? -- Did `just build-docs` stay clean — no new warning, no broken - cross-reference? diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- 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 18dd730..122a536 100644 --- a/docs/project/code-style.md +++ b/docs/project/code-style.md @@ -1,26 +1,7 @@ # Code Style -## Formatting - -gp-libs 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. +Code style split across two documents. Formatting, linting, and +type-checking commands are in the +[gates section of `.github/CONTRIBUTING.md`](https://github.com/git-pull/gp-libs/blob/master/.github/CONTRIBUTING.md#the-gates). +Docstring conventions are in the +[docstrings section of `.github/WRITING.md`](https://github.com/git-pull/gp-libs/blob/master/.github/WRITING.md#docstrings). diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 09ca4e9..74df72e 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -1,97 +1,7 @@ # Contributing -Install [git], [uv], and [just]. - -Clone: - -```console -$ git clone https://github.com/git-pull/gp-libs.git -``` - -```console -$ cd gp-libs -``` - -Install packages: - -```console -$ uv sync --all-extras --dev -``` - -## Tests - -```console -$ just test -``` - -### Automatically run tests on file save - -Use [pytest-watcher]: - -```console -$ just start -``` - -Use [entr(1)] when you want a shell-only watcher: - -```console -$ just watch-test -``` - -[pytest-watcher]: https://github.com/olzhasar/pytest-watcher - -## Documentation - -Build the docs: - -```console -$ just build-docs -``` - -Start the default preview server: - -```console -$ just start-docs -``` - -[sphinx-autobuild] builds the docs, watches for file changes, and launches the -preview server. - -From inside `docs/`, run the local docs justfile directly: - -```console -$ just start -``` - -[sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild - -### Manual documentation - -Build from inside `docs/`: - -```console -$ just html -``` - -Serve the built HTML: - -```console -$ just serve -``` - -Watch and rebuild on file changes: - -```console -$ just watch -``` - -Watch and serve in one terminal: - -```console -$ just dev -``` - -[git]: https://git-scm.com/ -[uv]: https://github.com/astral-sh/uv -[just]: https://just.systems/ -[entr(1)]: http://eradman.com/entrproject/ +Contributing guidelines — environment setup, the gates, tests, documentation +builds, releases, and pull requests — moved to +[`.github/CONTRIBUTING.md`](https://github.com/git-pull/gp-libs/blob/master/.github/CONTRIBUTING.md). +Prose conventions moved to +[`.github/WRITING.md`](https://github.com/git-pull/gp-libs/blob/master/.github/WRITING.md). From 57da9fb00109270b8f6c2a08070bcd34861c008c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 12:29:44 -0500 Subject: [PATCH 2/2] test(fix[docs]): Drop the unreachable AGENTS.md skip why: iter_documentation_markdown_files walks README.md and docs/**, and this branch removes docs/AGENTS.md and docs/CLAUDE.md, so the name guard can no longer match anything. Code nothing can reach is the kind this project's own writing guide says to delete. what: - Remove the AGENTS.md/CLAUDE.md skip from the documentation walk --- tests/test_docs_console_examples.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_docs_console_examples.py b/tests/test_docs_console_examples.py index 273104b..040adbe 100644 --- a/tests/test_docs_console_examples.py +++ b/tests/test_docs_console_examples.py @@ -76,8 +76,6 @@ def iter_documentation_markdown_files( relative = path.relative_to(repo_root) if "_build" in relative.parts: continue - if path.name in {"AGENTS.md", "CLAUDE.md"}: - continue yield path