From 3834d8c221a52a1c47e4339200f74eb991bf2130 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 04:47:05 -0500 Subject: [PATCH 1/5] docs(style[voice]): Align docs with guidance why: The docs had stale role syntax, mechanics-first copy, and old just/release workflow instructions that conflicted with the docs voice guide and current project commands. what: - Rewrite doctest and linkify pages around concept-first flow and MyST roles - Update contributor commands to just recipes - Align release docs with current commit/tag handoff guidance --- docs/doctest/index.md | 47 +++++-------- docs/doctest/pytest.md | 123 +++++++++++++++-------------------- docs/linkify_issues/index.md | 36 +++++----- docs/project/contributing.md | 69 +++++++++++++++----- docs/project/releasing.md | 25 +++---- docs/quickstart.md | 11 ++-- 6 files changed, 157 insertions(+), 154 deletions(-) diff --git a/docs/doctest/index.md b/docs/doctest/index.md index 2edfe8f..d30c7dc 100644 --- a/docs/doctest/index.md +++ b/docs/doctest/index.md @@ -1,9 +1,12 @@ (doctest_docutils)= (doctest_docutils_module)= -# Doctest w/ docutils +# Documentation Doctests -Built on {mod}`doctest`. +{mod}`doctest_docutils` runs Python {mod}`doctest` examples from +documentation files. It parses reStructuredText through [docutils] and +Markdown through [myst-parser], then collects the examples the same way a +reader sees them in the page. ::::{grid} 1 1 2 2 :gutter: 2 2 3 3 @@ -16,39 +19,25 @@ Run doctests in `.rst` and `.md` files via pytest. :::: -:::{note} - -Before you begin, acquaint yourself with: - -- {mod}`doctest` - - - normal usage via: - - ```console - $ python -m doctest [file] - ``` - -- Know what [docutils] does: parses reStructuredText (.rst). With the helper of [myst-parser], it also parses markdown (.md) - -::: - ## reStructuredText +Run the command against a reStructuredText file: + ```console $ python -m doctest_docutils README.rst ``` -That's what `doctest` does by design. Pass `-v` for verbose output. +No output means the examples passed. Pass `-v` for verbose output. ## Markdown -If you install [myst-parser], doctest will run on .md files. +Install [myst-parser] when you want the same collection behavior for Markdown: ```console $ python -m doctest_docutils README.md ``` -As with the reST example above, no output. +No output means the Markdown examples passed too. ```{toctree} :hidden: @@ -57,30 +46,26 @@ pytest ``` [docutils]: https://www.docutils.org/ +[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ ## Internals :::{note} -To get a deeper understanding, dig into: +For the rarer cases where you need the lower layers, start with: - {mod}`doctest` - - All console arguments by seeing the help command: + - Command-line options: ```console $ python -m doctest --help ``` - - data structures, e.g. {class}`doctest.DocTest` - - - source code: https://github.com/python/cpython/blob/3.11/Lib/doctest.py - - documentation: https://docs.python.org/3/library/doctest.html - - - typings: https://github.com/python/typeshed/blob/master/stdlib/doctest.pyi + - Data structures such as {class}`doctest.DocTest` -- [docutils]: which parses reStructuredText (.rst) and markdown (.md, with the - help of [myst-parser]) +- [docutils], which parses reStructuredText +- [myst-parser], which lets docutils parse Markdown ::: diff --git a/docs/doctest/pytest.md b/docs/doctest/pytest.md index d5e7907..f148da3 100644 --- a/docs/doctest/pytest.md +++ b/docs/doctest/pytest.md @@ -3,117 +3,95 @@ # pytest plugin -:::{note} +{mod}`pytest_doctest_docutils` lets pytest collect doctests from `.rst` and +`.md` files. Point [pytest] at a documentation file or directory, and the +plugin parses the page through {ref}`doctest_docutils` before pytest runs the +examples. -This plugin disables {ref}`pytest's standard doctest plugin `. +The plugin disables {ref}`pytest's standard doctest plugin ` by +default so the same examples are not collected twice. -::: - -The pytest plugin is built on top of the {ref}`doctest_docutils` module, which is in -turn compatible with {mod}`doctest`. - -## Using fixtures - -Normal pytest convention apply, {ref}`a visible conftest.py must be available ` -for the file being tested. - -This requires a understanding of `confest.py` - in the same way {ref}`pytest's vanilla doctest plugin ` does. - -You know your project's package structure best, the follow just serve as examples of new places conftest.py will be needed: +## File support -### Example: Root-level `README` +### reStructuredText (`.rst`) -In other words, if you want to test a project's `README.md` in the project root, a `conftest.py` would be needed at the root-level. This also has the benefit of reducing duplication: +Run a single reStructuredText file: - conftest.py - # content of conftest.py - import pytest - - @pytest.fixture - def username(): - return 'username' +```console +$ pytest README.rst +``` - README.rst - Our project - ----------- +Run a documentation directory: - .. doctest:: +```console +$ pytest docs/ +``` - # content of tests/test_something.py - def test_username(username): - assert username == 'username' +### Markdown (`.md`) -Now you can do: +Install [myst-parser] when you want pytest to collect doctests from Markdown: ```console -$ pytest README.rst +$ pytest README.md ``` -### Examples: `docs/` +Run Markdown and reStructuredText files together from `docs/`: -Let's assume `.md` and `.rst` files in `docs/`, this means you need to import `conftest.py` - - docs/ - conftest.py - # import conftest of project - import pytest +```console +$ pytest docs/ +``` - @pytest.fixture - def username(): - return 'username' +[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ - usage.rst - Our project - ----------- +## Using fixtures - .. doctest:: +Normal pytest fixture visibility applies: {ref}`a visible conftest.py must be +available ` for the documentation file being tested. If +`README.md` lives in the project root, put the fixture in a root-level +`conftest.py`: - # content of tests/test_something.py - def test_username(username): - assert username == 'username' +```python +import pytest -Now you can do: -```console -$ pytest docs/ +@pytest.fixture +def username(): + return "username" ``` -## File support +Then a root-level `README.rst` can use the fixture in its doctest examples. -### reStructuredText (`.rst`) +Run the file directly: ```console $ pytest README.rst ``` -```console -$ pytest docs/ -``` - -### Markdown (`.md`) +For doctests under `docs/`, put the fixtures in `docs/conftest.py` or another +`conftest.py` that pytest can see from that directory: -If you install [myst-parser], doctest will run on .md files. - -```console -$ pytest README.md +```text +docs/ + conftest.py + usage.rst ``` +Run the directory: + ```console $ pytest docs/ ``` -[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ - -## Scanning python files +## Python module doctests -You can retain {ref}`pytest's standard doctest plugin ` of -running doctests in `.py` by passing `--doctest-docutils-modules`: +Use `--doctest-docutils-modules` when you also want this plugin to collect +Python-module doctests: ```console $ py.test src/ --doctest-docutils-modules ``` -You can disable it via `--no-doctest-docutils-modules`: +Disable Python-module collection explicitly with `--no-doctest-docutils-modules`: ```console $ py.test src/ --no-doctest-docutils-modules @@ -123,7 +101,7 @@ $ py.test src/ --no-doctest-docutils-modules - [libtmux] ([source](https://github.com/tmux-python/libtmux/tree/v0.15.0post0)): - - Documentation w/ docutils: + - Documentation doctests: - [README.md](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/README.md) ([raw](https://github.com/tmux-python/libtmux/raw/v0.15.0post0/README.md)) @@ -133,11 +111,12 @@ $ py.test src/ --no-doctest-docutils-modules - Configuration: - - Doctests support pytest fixtures through [`doctest_namespace`](https://docs.pytest.org/en/7.1.x/how-to/doctest.html#doctest-namespace-fixture) + - Doctests support pytest fixtures through {ref}`doctest_namespace ` See [`add_doctest_fixtures()`](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/src/libtmux/conftest.py#L15-L26) in [`src/libtmux/conftest.py`](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/src/libtmux/conftest.py) [libtmux]: https://libtmux.git-pull.com/ +[pytest]: https://docs.pytest.org/en/stable/ ## API diff --git a/docs/linkify_issues/index.md b/docs/linkify_issues/index.md index cae8cc8..c6dec99 100644 --- a/docs/linkify_issues/index.md +++ b/docs/linkify_issues/index.md @@ -3,17 +3,19 @@ # Autolink GitHub issues -Automatically link plaintext issues, e.g. `\#1`, as -[#1](https://github.com/git-pull/gp-libs/issues/1). +The {mod}`linkify_issues` [Sphinx] extension turns plain issue references such +as `\#1` into links such as [#1](https://github.com/git-pull/gp-libs/issues/1). +Add the extension and set `issue_url_tpl`; the default pattern already handles +numbered GitHub issues. -This is a perfectly legitimately request, even sphinx's own conf.py does a -[non-docutils -hack](https://github.com/sphinx-doc/sphinx/blob/v5.1.1/doc/conf.py#L151-L170) to -link plain-text nodes. +This keeps issue references readable in source while making rendered Sphinx +pages easier to navigate. Sphinx's own `conf.py` uses a +[non-docutils hook](https://github.com/sphinx-doc/sphinx/blob/v5.1.1/doc/conf.py#L151-L170) +for the same plain-text linking problem. ## Configuration -In your _conf.py_: +In your `conf.py`: 1. Add `'linkify_issues'` to `extensions` @@ -36,38 +38,40 @@ In your _conf.py_: ### Issue pattern -`issue_re` is available to optionally adjust the pattern plain text is matched -against. By default it is :var:`linkify_issues` +`issue_re` optionally adjusts the plain-text pattern. By default it matches +numbered references: ```python r"#(?P\d+)" ``` -Where `^\` negates matches (as seen below) and numbers are matched via `\d`. +The named `issue_id` group is passed into `issue_url_tpl`. Numbers are matched +with `\d`. -You can pass a `str` - which is automatically upcasted when parsing - or a :class:`re.Pattern`. In conf.py, to catch letters and dashes too: +You can pass a {class}`str`, which is compiled while parsing, or a +{class}`re.Pattern`. In `conf.py`, catch letters and dashes too: ```python issue_re = r"#(?P[\da-z-]+)" ``` -That will match patterns like #ISSUE-34, where `'ISSUE-34'` will be captured. +That matches patterns like `#ISSUE-34`, where `'ISSUE-34'` is captured. What you may prefer is just capturing the `34`: ```python issue_re = r"#ISSUE-(?P\d+)" ``` -`issue_url_tpl`'s regex patterns can be extended and passed into `issue_re`'s string formatting: +Named groups from `issue_re` are also available to `issue_url_tpl`: ```python issue_re = r"#(?P(issue|pull)+)-(?P\d+)" issue_url_tpl = "https://github.com/git-pull/gp-libs/{page_type}/{issue_id}" ``` -\#issue-1 will be [#issue-1](https://github.com/git-pull/gp-libs/issue/1) +`\#issue-1` becomes [#issue-1](https://github.com/git-pull/gp-libs/issue/1). -\#pull-1 will be [#pull-1](https://github.com/git-pull/gp-libs/pull/1) +`\#pull-1` becomes [#pull-1](https://github.com/git-pull/gp-libs/pull/1). If your needs are more complex, you may need to fork it for yourself or suggest a PR. @@ -79,3 +83,5 @@ If your needs are more complex, you may need to fork it for yourself or suggest :show-inheritance: :undoc-members: ``` + +[Sphinx]: https://www.sphinx-doc.org/ diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 4c6a633..09ca4e9 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -1,6 +1,6 @@ # Contributing -Install [git] and [uv]. +Install [git], [uv], and [just]. Clone: @@ -21,40 +21,77 @@ $ uv sync --all-extras --dev ## Tests ```console -$ uv run py.test +$ just test ``` ### Automatically run tests on file save -1. `make start` (via [pytest-watcher]) -2. `make watch_test` (requires installing [entr(1)]) +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 -Default preview server: http://localhost:8034 +Build the docs: -[sphinx-autobuild] will automatically build the docs, watch for file changes and launch a server. +```console +$ just build-docs +``` + +Start the default preview server: -From home directory: `make start_docs` -From inside `docs/`: `make start` +```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 (the hard way) +### Manual documentation + +Build from inside `docs/`: + +```console +$ just html +``` + +Serve the built HTML: -`cd docs/` and `make html` to build. `make serve` to start http server. +```console +$ just serve +``` -Helpers: -`make build_docs`, `make serve_docs` +Watch and rebuild on file changes: -Rebuild docs on file change: `make watch_docs` (requires [entr(1)]) +```console +$ just watch +``` -Rebuild docs and run server via one terminal: `make dev_docs` (requires above, and a -`make(1)` with `-J` support, e.g. GNU Make) +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/ -[`entr(1)`]: http://eradman.com/entrproject/ diff --git a/docs/project/releasing.md b/docs/project/releasing.md index 1ec7baa..a1754b9 100644 --- a/docs/project/releasing.md +++ b/docs/project/releasing.md @@ -1,36 +1,31 @@ # Releasing -## Version Policy +## Version policy gp-libs is pre-1.0. Minor version bumps may include breaking changes. -## Release Process +## Release process [uv] handles virtualenv creation, package requirements, versioning, -building, and publishing. There is no setup.py or requirements files. +building, and publishing. There is no `setup.py` or requirements file. -1. Update `CHANGES` with release notes +1. Update `CHANGES` with release notes. -2. Bump version in `src/gp_libs.py` and `pyproject.toml` +2. Bump version in `src/gp_libs.py` and `pyproject.toml`. -3. Commit and tag: +3. Create the release commit: ```console - $ git commit -m 'build(gp_libs): Tag v0.1.1' + $ git commit -m 'Tag v0.1.1' ``` - ```console - $ git tag v0.1.1 - ``` - -4. Push: +4. Push the branch for review: ```console $ git push ``` - ```console - $ git push --tags - ``` +5. After review, the release owner creates and pushes the `v0.1.1` tag. Tags + trigger the publish workflow. [uv]: https://github.com/astral-sh/uv diff --git a/docs/quickstart.md b/docs/quickstart.md index 3845684..8ef04be 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -4,13 +4,13 @@ ## Installation -For latest official version: +Install the latest official release: ```console $ pip install --user gp-libs ``` -Upgrading: +Upgrade an existing install: ```console $ pip install --user --upgrade gp-libs @@ -21,8 +21,9 @@ $ pip install --user --upgrade gp-libs ### Developmental releases New versions of gp-libs are published to PyPI as alpha, beta, or release candidates. -In their versions you will see notification like `a1`, `b1`, and `rc1`, respectively. -`1.10.0b4` would mean the 4th beta release of `1.10.0` before general availability. +Their versions include markers such as `a1`, `b1`, and `rc1`, respectively. +`1.10.0b4` means the fourth beta release of `1.10.0` before general +availability. - [pip]\: @@ -48,7 +49,7 @@ In their versions you will see notification like `a1`, `b1`, and `rc1`, respecti $ uvx --from 'gp-libs' --prerelease allow gp-libs ``` -via trunk (can break easily): +Install from trunk when you need unreleased work. These installs can break: - [pip]\: From 36c4e8b9cbc4678d5d952d1bdf2d5507d53ede8e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:11:18 -0500 Subject: [PATCH 2/5] docs(style[voice]): Tighten first mentions why: Index cards and one pytest page heading still exposed symbolic names before the linked prose form, which weakened the first-mention rule in docs/AGENTS.md. what: - Retitle cards and headings to reader-facing concepts - Move Sphinx and pytest links to first prose mentions - Keep project index summaries tool-neutral --- docs/doctest/index.md | 4 ++-- docs/doctest/pytest.md | 4 ++-- docs/index.md | 10 ++++++---- docs/project/index.md | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/doctest/index.md b/docs/doctest/index.md index d30c7dc..9e166d2 100644 --- a/docs/doctest/index.md +++ b/docs/doctest/index.md @@ -11,10 +11,10 @@ reader sees them in the page. ::::{grid} 1 1 2 2 :gutter: 2 2 3 3 -:::{grid-item-card} pytest plugin +:::{grid-item-card} Test Runner Integration :link: pytest :link-type: doc -Run doctests in `.rst` and `.md` files via pytest. +Collect `.rst` and `.md` doctests from the test suite. ::: :::: diff --git a/docs/doctest/pytest.md b/docs/doctest/pytest.md index f148da3..c3f56cc 100644 --- a/docs/doctest/pytest.md +++ b/docs/doctest/pytest.md @@ -1,9 +1,9 @@ (pytest_plugin)= (pytest_doctest_docutils)= -# pytest plugin +# Documentation Doctest Plugin -{mod}`pytest_doctest_docutils` lets pytest collect doctests from `.rst` and +{mod}`pytest_doctest_docutils` lets [pytest] collect doctests from `.rst` and `.md` files. Point [pytest] at a documentation file or directory, and the plugin parses the page through {ref}`doctest_docutils` before pytest runs the examples. diff --git a/docs/index.md b/docs/index.md index 4bc2178..e3c5a2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,16 +13,16 @@ Test and documentation utilities for [git-pull](https://github.com/git-pull) pro Install and get started in minutes. ::: -:::{grid-item-card} doctest_docutils +:::{grid-item-card} Documentation Doctests :link: doctest/index :link-type: doc Run doctests in reStructuredText and Markdown files. ::: -:::{grid-item-card} linkify_issues +:::{grid-item-card} Autolink GitHub Issues :link: linkify_issues/index :link-type: doc -Automatically link `#123` to GitHub issues in Sphinx docs. +Turn `#123` into rendered issue links. ::: :::{grid-item-card} Contributing @@ -51,7 +51,7 @@ Run doctests in Markdown and reStructuredText files: $ python -m doctest_docutils README.md -v ``` -Auto-link issue references in Sphinx documentation: +Auto-link issue references in [Sphinx] documentation: ```python # conf.py @@ -69,3 +69,5 @@ project/index history GitHub ``` + +[Sphinx]: https://www.sphinx-doc.org/ diff --git a/docs/project/index.md b/docs/project/index.md index 2b60711..5986ada 100644 --- a/docs/project/index.md +++ b/docs/project/index.md @@ -16,7 +16,7 @@ Development setup, running tests, submitting PRs. :::{grid-item-card} Code Style :link: code-style :link-type: doc -Ruff, mypy, NumPy docstrings, import conventions. +Formatting, type checking, docstrings, import conventions. ::: :::{grid-item-card} Releasing From 48fa670d656b043e4dbe82fc572a08281846cc04 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:15:21 -0500 Subject: [PATCH 3/5] docs(style[voice]): Link remaining first mentions why: A follow-up docs audit found external concepts named before their linked prose form, which left a few pages short of the first-mention rule in docs/AGENTS.md. what: - Link Markdown and reStructuredText from landing-page prose - Move markup names out of the landing-page card teaser - Link PyPI from the quickstart release prose --- docs/index.md | 6 ++++-- docs/quickstart.md | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index e3c5a2c..8965010 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ Install and get started in minutes. :::{grid-item-card} Documentation Doctests :link: doctest/index :link-type: doc -Run doctests in reStructuredText and Markdown files. +Run examples from documentation files. ::: :::{grid-item-card} Autolink GitHub Issues @@ -45,7 +45,7 @@ $ uv add gp-libs ## At a glance -Run doctests in Markdown and reStructuredText files: +Run doctests in [Markdown] and [reStructuredText] files: ```console $ python -m doctest_docutils README.md -v @@ -71,3 +71,5 @@ GitHub ``` [Sphinx]: https://www.sphinx-doc.org/ +[Markdown]: https://commonmark.org/ +[reStructuredText]: https://docutils.sourceforge.io/rst.html diff --git a/docs/quickstart.md b/docs/quickstart.md index 8ef04be..9afdecd 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -20,7 +20,8 @@ $ pip install --user --upgrade gp-libs ### Developmental releases -New versions of gp-libs are published to PyPI as alpha, beta, or release candidates. +New versions of gp-libs are published to [PyPI] as alpha, beta, or release +candidates. Their versions include markers such as `a1`, `b1`, and `rc1`, respectively. `1.10.0b4` means the fourth beta release of `1.10.0` before general availability. @@ -71,5 +72,6 @@ Install from trunk when you need unreleased work. These installs can break: [pip]: https://pip.pypa.io/en/stable/ [pipx]: https://pypa.github.io/pipx/docs/ +[PyPI]: https://pypi.org/ [uv]: https://docs.astral.sh/uv/ [uvx]: https://docs.astral.sh/uv/guides/tools/ From 9ea46b597b6c7cc7e6ed96381ce3a6584f2f402c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:22:01 -0500 Subject: [PATCH 4/5] docs(style[history]): Link gp-sphinx API why: The history page renders CHANGES as documentation, so first mentions and API-shaped references there need the same links as docs/ pages. what: - Add gp-sphinx to the docs intersphinx mapping - Link the gp-sphinx and just first mentions in the 0.0.18 entry - Cross-reference merge_sphinx_config through the gp-sphinx inventory --- CHANGES | 4 ++-- docs/conf.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 3b484ad..76c80d4 100644 --- a/CHANGES +++ b/CHANGES @@ -34,7 +34,7 @@ _Notes on upcoming releases will be added here_ ## gp-libs 0.0.18 (2026-06-27) -gp-libs 0.0.18 refreshes the project documentation and maintainer workflow around the shared git-pull docs stack. The docs now use the Library Skeleton page shape, the gp-sphinx theme and API presentation packages, and a just-based command surface that matches the rest of the workspace. Readers get a more navigable site; maintainers get reusable commands and fewer repo-local Sphinx assets to keep in sync. +gp-libs 0.0.18 refreshes the project documentation and maintainer workflow around the shared git-pull docs stack. The docs now use the Library Skeleton page shape, the [gp-sphinx](https://gp-sphinx.git-pull.com/) theme and API presentation packages, and a [just](https://just.systems/)-based command surface that matches the rest of the workspace. Readers get a more navigable site; maintainers get reusable commands and fewer repo-local Sphinx assets to keep in sync. ### What's new @@ -52,7 +52,7 @@ The history page still includes this file directly, so release notes continue to #### Shared gp-sphinx docs platform (#66) -The docs site moved from repo-local Sphinx theme glue, templates, CSS, and JavaScript to the published gp-sphinx package stack. `docs/conf.py` now delegates common configuration to `gp_sphinx.config.merge_sphinx_config()`, while gp-libs keeps only the project-specific values such as intersphinx mappings, logos, linkcode, and redirects. +The docs site moved from repo-local Sphinx theme glue, templates, CSS, and JavaScript to the published gp-sphinx package stack. `docs/conf.py` now delegates common configuration to {func}`~gp_sphinx.config.merge_sphinx_config`, while gp-libs keeps only the project-specific values such as intersphinx mappings, logos, linkcode, and redirects. This removes bundled docs support files that are now owned by shared packages and keeps gp-libs aligned with the broader git-pull documentation platform. diff --git a/docs/conf.py b/docs/conf.py index d0b753c..2d4a0a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,7 @@ dark_logo="img/icons/logo-dark.svg", extra_extensions=["sphinx_autodoc_api_style"], intersphinx_mapping={ + "gp_sphinx": ("https://gp-sphinx.git-pull.com/", None), "py": ("https://docs.python.org/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), "sphinx": ("https://www.sphinx-doc.org/en/master/", None), From 225cbc6d4201d82d558e7ecdac4b5994f7aa940b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:01:50 -0500 Subject: [PATCH 5/5] docs(feat[modules]): Add module guide trees why: Downstream readers need first-level module navigation and tested examples for the three public gp-libs documentation surfaces. what: - Move doctest_docutils, pytest_doctest_docutils, and linkify_issues docs under docs/modules with hub pages and redirects - Add sandboxed console-example validation for authored Markdown docs - Enable Sphinx doctest setup for API-reference examples --- CHANGES | 4 +- README.md | 15 +- docs/AGENTS.md | 11 +- docs/conf.py | 9 +- docs/doctest/index.md | 79 ---- docs/doctest/pytest.md | 128 ------- docs/index.md | 20 +- docs/linkify_issues/index.md | 87 ----- docs/modules/doctest_docutils/examples.md | 36 ++ docs/modules/doctest_docutils/how-to.md | 40 ++ docs/modules/doctest_docutils/index.md | 64 ++++ docs/modules/doctest_docutils/reference.md | 10 + docs/modules/doctest_docutils/tutorial.md | 32 ++ docs/modules/index.md | 30 ++ .../modules/linkify_issues/custom-patterns.md | 30 ++ docs/modules/linkify_issues/examples.md | 24 ++ docs/modules/linkify_issues/how-to.md | 20 + docs/modules/linkify_issues/index.md | 49 +++ docs/modules/linkify_issues/reference.md | 10 + .../pytest_doctest_docutils/examples.md | 30 ++ .../pytest_doctest_docutils/fixtures.md | 36 ++ .../modules/pytest_doctest_docutils/how-to.md | 44 +++ docs/modules/pytest_doctest_docutils/index.md | 61 ++++ .../pytest_doctest_docutils/reference.md | 10 + .../pytest_doctest_docutils/tutorial.md | 23 ++ docs/redirects.txt | 3 + tests/test_docs_console_examples.py | 345 ++++++++++++++++++ 27 files changed, 937 insertions(+), 313 deletions(-) delete mode 100644 docs/doctest/index.md delete mode 100644 docs/doctest/pytest.md delete mode 100644 docs/linkify_issues/index.md create mode 100644 docs/modules/doctest_docutils/examples.md create mode 100644 docs/modules/doctest_docutils/how-to.md create mode 100644 docs/modules/doctest_docutils/index.md create mode 100644 docs/modules/doctest_docutils/reference.md create mode 100644 docs/modules/doctest_docutils/tutorial.md create mode 100644 docs/modules/index.md create mode 100644 docs/modules/linkify_issues/custom-patterns.md create mode 100644 docs/modules/linkify_issues/examples.md create mode 100644 docs/modules/linkify_issues/how-to.md create mode 100644 docs/modules/linkify_issues/index.md create mode 100644 docs/modules/linkify_issues/reference.md create mode 100644 docs/modules/pytest_doctest_docutils/examples.md create mode 100644 docs/modules/pytest_doctest_docutils/fixtures.md create mode 100644 docs/modules/pytest_doctest_docutils/how-to.md create mode 100644 docs/modules/pytest_doctest_docutils/index.md create mode 100644 docs/modules/pytest_doctest_docutils/reference.md create mode 100644 docs/modules/pytest_doctest_docutils/tutorial.md create mode 100644 tests/test_docs_console_examples.py diff --git a/CHANGES b/CHANGES index 76c80d4..983ce93 100644 --- a/CHANGES +++ b/CHANGES @@ -46,7 +46,7 @@ The CI docs workflow, `.tmuxp.yaml`, and agent instructions were updated to call #### Documentation now follows the Library Skeleton pattern (#65) -The documentation was reorganized around a landing page, topic cards, and a dedicated project section. The new structure keeps the user-facing extensions visible first: {doc}`doctest/index`, {doc}`doctest/pytest`, and {doc}`linkify_issues/index`. +The documentation was reorganized around a landing page, topic cards, and a dedicated project section. The new structure keeps the user-facing modules visible first: {doc}`modules/doctest_docutils/index`, {doc}`modules/pytest_doctest_docutils/index`, and {doc}`modules/linkify_issues/index`. The history page still includes this file directly, so release notes continue to render as part of the docs site rather than living only in the repository. @@ -393,7 +393,7 @@ $ python -m doctest_docutils README.md -v ### Documentation -Initial documentation, README examples, Sphinx configuration, and package metadata shipped with the stable release. See {doc}`doctest/index`, {doc}`doctest/pytest`, and {doc}`linkify_issues/index` for the current guides. +Initial documentation, README examples, Sphinx configuration, and package metadata shipped with the stable release. See {doc}`modules/doctest_docutils/index`, {doc}`modules/pytest_doctest_docutils/index`, and {doc}`modules/linkify_issues/index` for the current guides. ### Development diff --git a/README.md b/README.md index 66b1bfb..4de135a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Two components: This extends standard library `doctest` to support anything docutils can parse. It can parse reStructuredText (.rst) and markdown (.md). -See more: +See more: #### Supported styles @@ -51,7 +51,7 @@ It supports two barebones directives: 4 ``` - Markdown (requires [myst-parser]): + Markdown: ````markdown ```{doctest} @@ -76,7 +76,7 @@ That's what `doctest` does by design. ##### Markdown -If you install [myst-parser], doctest will run on .md files. +Markdown files run through [myst-parser], which is installed with gp-libs. ```console $ python -m doctest_docutils README.md -v @@ -86,7 +86,8 @@ $ python -m doctest_docutils README.md -v _This plugin disables [pytest's standard `doctest` plugin]._ -This plugin integrates with the `doctest_docutils` module with pytest to enable seamless testing of docs, `conftest.py` fixtures and all. +This plugin integrates `doctest_docutils` with pytest so documentation examples +run with the surrounding `conftest.py` setup. ```console $ pytest docs/ @@ -95,7 +96,7 @@ $ pytest docs/ Like the above module, it supports docutils' own `doctest_block` and a basic `.. doctest::` directive. -See more: +See more: [pytest's standard `doctest` plugin]: https://docs.pytest.org/en/stable/how-to/doctest.html#doctest @@ -127,10 +128,10 @@ In your _conf.py_: issue_url_tpl = 'https://github.com/git-pull/gp-libs/issues/{issue_id}' ``` - The config variable is formatted via {meth}`str.format` where `issue_id` is + The config variable is formatted via `str.format()` where `issue_id` is `42` if the text is \#42. -See more: +See more: ## Install diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 8cdd459..456e4b7 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -50,8 +50,8 @@ a comprehension tax for the advanced one. 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 needs - myst-parser installed, fixtures reach only files a `conftest.py` can + 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 @@ -76,6 +76,11 @@ dogfood the tool they describe; a broken example is a failing test. - 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 @@ -119,7 +124,7 @@ surrounding prose instead. ## A page that does this -`docs/linkify_issues/index.md` is the worked example: a concept-first +`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 diff --git a/docs/conf.py b/docs/conf.py index 2d4a0a2..95dfb3e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,7 +29,7 @@ source_branch="master", light_logo="img/icons/logo.svg", dark_logo="img/icons/logo-dark.svg", - extra_extensions=["sphinx_autodoc_api_style"], + extra_extensions=["sphinx.ext.doctest", "sphinx_autodoc_api_style"], intersphinx_mapping={ "gp_sphinx": ("https://gp-sphinx.git-pull.com/", None), "py": ("https://docs.python.org/", None), @@ -45,3 +45,10 @@ exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md"], ) globals().update(conf) + +doctest_global_setup = """ +import pathlib + +from doctest_docutils import is_allowed_version +from pytest_doctest_docutils import pytest_ignore_collect +""" diff --git a/docs/doctest/index.md b/docs/doctest/index.md deleted file mode 100644 index 9e166d2..0000000 --- a/docs/doctest/index.md +++ /dev/null @@ -1,79 +0,0 @@ -(doctest_docutils)= -(doctest_docutils_module)= - -# Documentation Doctests - -{mod}`doctest_docutils` runs Python {mod}`doctest` examples from -documentation files. It parses reStructuredText through [docutils] and -Markdown through [myst-parser], then collects the examples the same way a -reader sees them in the page. - -::::{grid} 1 1 2 2 -:gutter: 2 2 3 3 - -:::{grid-item-card} Test Runner Integration -:link: pytest -:link-type: doc -Collect `.rst` and `.md` doctests from the test suite. -::: - -:::: - -## reStructuredText - -Run the command against a reStructuredText file: - -```console -$ python -m doctest_docutils README.rst -``` - -No output means the examples passed. Pass `-v` for verbose output. - -## Markdown - -Install [myst-parser] when you want the same collection behavior for Markdown: - -```console -$ python -m doctest_docutils README.md -``` - -No output means the Markdown examples passed too. - -```{toctree} -:hidden: - -pytest -``` - -[docutils]: https://www.docutils.org/ -[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ - -## Internals - -:::{note} - -For the rarer cases where you need the lower layers, start with: - -- {mod}`doctest` - - - Command-line options: - - ```console - $ python -m doctest --help - ``` - - - Data structures such as {class}`doctest.DocTest` - -- [docutils], which parses reStructuredText -- [myst-parser], which lets docutils parse Markdown - -::: - -## API - -```{eval-rst} -.. automodule:: doctest_docutils - :members: - :show-inheritance: - :undoc-members: -``` diff --git a/docs/doctest/pytest.md b/docs/doctest/pytest.md deleted file mode 100644 index c3f56cc..0000000 --- a/docs/doctest/pytest.md +++ /dev/null @@ -1,128 +0,0 @@ -(pytest_plugin)= -(pytest_doctest_docutils)= - -# Documentation Doctest Plugin - -{mod}`pytest_doctest_docutils` lets [pytest] collect doctests from `.rst` and -`.md` files. Point [pytest] at a documentation file or directory, and the -plugin parses the page through {ref}`doctest_docutils` before pytest runs the -examples. - -The plugin disables {ref}`pytest's standard doctest plugin ` by -default so the same examples are not collected twice. - -## File support - -### reStructuredText (`.rst`) - -Run a single reStructuredText file: - -```console -$ pytest README.rst -``` - -Run a documentation directory: - -```console -$ pytest docs/ -``` - -### Markdown (`.md`) - -Install [myst-parser] when you want pytest to collect doctests from Markdown: - -```console -$ pytest README.md -``` - -Run Markdown and reStructuredText files together from `docs/`: - -```console -$ pytest docs/ -``` - -[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ - -## Using fixtures - -Normal pytest fixture visibility applies: {ref}`a visible conftest.py must be -available ` for the documentation file being tested. If -`README.md` lives in the project root, put the fixture in a root-level -`conftest.py`: - -```python -import pytest - - -@pytest.fixture -def username(): - return "username" -``` - -Then a root-level `README.rst` can use the fixture in its doctest examples. - -Run the file directly: - -```console -$ pytest README.rst -``` - -For doctests under `docs/`, put the fixtures in `docs/conftest.py` or another -`conftest.py` that pytest can see from that directory: - -```text -docs/ - conftest.py - usage.rst -``` - -Run the directory: - -```console -$ pytest docs/ -``` - -## Python module doctests - -Use `--doctest-docutils-modules` when you also want this plugin to collect -Python-module doctests: - -```console -$ py.test src/ --doctest-docutils-modules -``` - -Disable Python-module collection explicitly with `--no-doctest-docutils-modules`: - -```console -$ py.test src/ --no-doctest-docutils-modules -``` - -## Case examples - -- [libtmux] ([source](https://github.com/tmux-python/libtmux/tree/v0.15.0post0)): - - - Documentation doctests: - - - [README.md](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/README.md) ([raw](https://github.com/tmux-python/libtmux/raw/v0.15.0post0/README.md)) - - _Note: `pytest README.md` requires you have a `conftest.py` directly in the project root_ - - - [docs/topics/traversal.md](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/docs/topics/traversal.md) ([raw](https://github.com/tmux-python/libtmux/raw/v0.15.0post0/docs/topics/traversal.md)) - - - Configuration: - - - Doctests support pytest fixtures through {ref}`doctest_namespace ` - - See [`add_doctest_fixtures()`](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/src/libtmux/conftest.py#L15-L26) in [`src/libtmux/conftest.py`](https://github.com/tmux-python/libtmux/blob/v0.15.0post0/src/libtmux/conftest.py) - -[libtmux]: https://libtmux.git-pull.com/ -[pytest]: https://docs.pytest.org/en/stable/ - -## API - -```{eval-rst} -.. automodule:: pytest_doctest_docutils - :members: - :show-inheritance: - :undoc-members: -``` diff --git a/docs/index.md b/docs/index.md index 8965010..aba9e4c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,13 +14,19 @@ Install and get started in minutes. ::: :::{grid-item-card} Documentation Doctests -:link: doctest/index +:link: modules/doctest_docutils/index :link-type: doc -Run examples from documentation files. +Run examples from documentation files with `doctest_docutils`. ::: -:::{grid-item-card} Autolink GitHub Issues -:link: linkify_issues/index +:::{grid-item-card} Pytest Doctests +:link: modules/pytest_doctest_docutils/index +:link-type: doc +Collect `.rst`, `.md`, and Python doctests through pytest. +::: + +:::{grid-item-card} Issue Links +:link: modules/linkify_issues/index :link-type: doc Turn `#123` into rendered issue links. ::: @@ -63,8 +69,10 @@ issue_url_tpl = "https://github.com/myorg/myrepo/issues/{issue_id}" :hidden: quickstart -doctest/index -linkify_issues/index +modules/index +modules/doctest_docutils/index +modules/pytest_doctest_docutils/index +modules/linkify_issues/index project/index history GitHub diff --git a/docs/linkify_issues/index.md b/docs/linkify_issues/index.md deleted file mode 100644 index c6dec99..0000000 --- a/docs/linkify_issues/index.md +++ /dev/null @@ -1,87 +0,0 @@ -(linkify_issues)= -(linkify-issues)= - -# Autolink GitHub issues - -The {mod}`linkify_issues` [Sphinx] extension turns plain issue references such -as `\#1` into links such as [#1](https://github.com/git-pull/gp-libs/issues/1). -Add the extension and set `issue_url_tpl`; the default pattern already handles -numbered GitHub issues. - -This keeps issue references readable in source while making rendered Sphinx -pages easier to navigate. Sphinx's own `conf.py` uses a -[non-docutils hook](https://github.com/sphinx-doc/sphinx/blob/v5.1.1/doc/conf.py#L151-L170) -for the same plain-text linking problem. - -## Configuration - -In your `conf.py`: - -1. Add `'linkify_issues'` to `extensions` - - ```python - extensions = [ - # ... - "linkify_issues", - ] - ``` - -2. Configure your issue URL, `issue_url_tpl`: - - ```python - # linkify_issues - issue_url_tpl = 'https://github.com/git-pull/gp-libs/issues/{issue_id}' - ``` - - The config variable is formatted via {meth}`str.format` where `issue_id` is - `42` if the text is \#42. - -### Issue pattern - -`issue_re` optionally adjusts the plain-text pattern. By default it matches -numbered references: - -```python -r"#(?P\d+)" -``` - -The named `issue_id` group is passed into `issue_url_tpl`. Numbers are matched -with `\d`. - -You can pass a {class}`str`, which is compiled while parsing, or a -{class}`re.Pattern`. In `conf.py`, catch letters and dashes too: - -```python -issue_re = r"#(?P[\da-z-]+)" -``` - -That matches patterns like `#ISSUE-34`, where `'ISSUE-34'` is captured. -What you may prefer is just capturing the `34`: - -```python -issue_re = r"#ISSUE-(?P\d+)" -``` - -Named groups from `issue_re` are also available to `issue_url_tpl`: - -```python -issue_re = r"#(?P(issue|pull)+)-(?P\d+)" -issue_url_tpl = "https://github.com/git-pull/gp-libs/{page_type}/{issue_id}" -``` - -`\#issue-1` becomes [#issue-1](https://github.com/git-pull/gp-libs/issue/1). - -`\#pull-1` becomes [#pull-1](https://github.com/git-pull/gp-libs/pull/1). - -If your needs are more complex, you may need to fork it for yourself or suggest a PR. - -## API - -```{eval-rst} -.. automodule:: linkify_issues - :members: - :show-inheritance: - :undoc-members: -``` - -[Sphinx]: https://www.sphinx-doc.org/ diff --git a/docs/modules/doctest_docutils/examples.md b/docs/modules/doctest_docutils/examples.md new file mode 100644 index 0000000..0f55c93 --- /dev/null +++ b/docs/modules/doctest_docutils/examples.md @@ -0,0 +1,36 @@ +(doctest_docutils-examples)= + +# Examples + +The docs test suite executes the prompt examples on this page. That keeps the +examples aligned with {class}`doctest_docutils.DocutilsDocTestFinder`. + +## Markdown prompt block + +```python +>>> import doctest_docutils +>>> finder = doctest_docutils.DocutilsDocTestFinder() +>>> source = "```python\n>>> 2 + 2\n4\n```\n" +>>> tests = finder.find(source, "example.md") +>>> len(tests) +1 +``` + +## MyST doctest directive + +```{doctest} +>>> sorted({"rst", "md"}) +['md', 'rst'] +``` + +## Finder result names + +{class}`~doctest_docutils.DocutilsDocTestFinder` names collected examples with +the file path you pass in: + +```python +>>> import doctest_docutils +>>> finder = doctest_docutils.DocutilsDocTestFinder() +>>> [test.name for test in finder.find(">>> 'docs'\n'docs'\n", "guide.rst")] +['guide.rst[0]'] +``` diff --git a/docs/modules/doctest_docutils/how-to.md b/docs/modules/doctest_docutils/how-to.md new file mode 100644 index 0000000..da5be6f --- /dev/null +++ b/docs/modules/doctest_docutils/how-to.md @@ -0,0 +1,40 @@ +(doctest_docutils-how-to)= + +# How-to + +## Run a Markdown file + +Use the module command with the file path: + +```console +$ python -m doctest_docutils README.md +``` + +## Run a reStructuredText file + +Use the same command for `.rst` files: + +```console +$ python -m doctest_docutils README.rst +``` + +## See collected examples + +Pass `-v` for verbose standard-library doctest output: + +```console +$ python -m doctest_docutils README.md -v +``` + +## Compare with stdlib doctest + +Use the stdlib command when you are checking Python modules or plain text that +does not need docutils parsing: + +```console +$ python -m doctest --help +``` + +Use {mod}`doctest_docutils` when the examples live inside Markdown or +reStructuredText structure that {mod}`doctest` would otherwise treat as plain +text. diff --git a/docs/modules/doctest_docutils/index.md b/docs/modules/doctest_docutils/index.md new file mode 100644 index 0000000..e63866e --- /dev/null +++ b/docs/modules/doctest_docutils/index.md @@ -0,0 +1,64 @@ +(doctest_docutils)= +(doctest_docutils_module)= + +# doctest_docutils + +{mod}`doctest_docutils` runs Python {mod}`doctest` examples from documentation +files. It parses reStructuredText through [docutils] and Markdown through +[myst-parser], then collects the examples a reader sees in the page. + +Use this module when you want a direct command for a single documentation file. +Use {doc}`../pytest_doctest_docutils/index` when you want pytest collection, +fixtures, and suite-level reporting. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Tutorial +:link: tutorial +:link-type: doc +Run your first documentation doctest from a Markdown page. +::: + +:::{grid-item-card} How-to +:link: how-to +:link-type: doc +Choose files, run verbose output, and map the command to stdlib doctest. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +See the supported Markdown and reStructuredText example shapes. +::: + +:::{grid-item-card} API Reference +:link: reference +:link-type: doc +Inspect finder, runner, directive, and CLI APIs. +::: + +:::: + +## Default command + +Run a Markdown page: + +```console +$ python -m doctest_docutils README.md +``` + +No output means the examples passed. Add `-v` when you want the standard +doctest transcript. + +```{toctree} +:hidden: + +tutorial +how-to +examples +reference +``` + +[docutils]: https://www.docutils.org/ +[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ diff --git a/docs/modules/doctest_docutils/reference.md b/docs/modules/doctest_docutils/reference.md new file mode 100644 index 0000000..795e672 --- /dev/null +++ b/docs/modules/doctest_docutils/reference.md @@ -0,0 +1,10 @@ +(doctest_docutils-reference)= + +# API reference + +```{eval-rst} +.. automodule:: doctest_docutils + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/modules/doctest_docutils/tutorial.md b/docs/modules/doctest_docutils/tutorial.md new file mode 100644 index 0000000..5512f6f --- /dev/null +++ b/docs/modules/doctest_docutils/tutorial.md @@ -0,0 +1,32 @@ +(doctest_docutils-tutorial)= + +# Tutorial + +Start with a Markdown file that contains one Python prompt example: + +````markdown +```python +>>> 2 + 2 +4 +``` +```` + +Run {mod}`doctest_docutils` against the file: + +```console +$ python -m doctest_docutils README.md +``` + +The command exits successfully when every collected example matches its expected +output. Add `-v` to see which examples ran: + +```console +$ python -m doctest_docutils README.md -v +``` + +The module keeps the standard-library {mod}`doctest` comparison rules and +changes the input layer: documentation is parsed through [docutils], with +[myst-parser] handling Markdown. + +[docutils]: https://www.docutils.org/ +[myst-parser]: https://myst-parser.readthedocs.io/en/latest/ diff --git a/docs/modules/index.md b/docs/modules/index.md new file mode 100644 index 0000000..d452d81 --- /dev/null +++ b/docs/modules/index.md @@ -0,0 +1,30 @@ +(modules)= + +# Module guides + +Each public gp-libs module has a small guide tree: start with the hub, follow a +tutorial or how-to when you need a workflow, then use examples and API +reference pages for details. + +::::{grid} 1 1 2 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} doctest_docutils +:link: doctest_docutils/index +:link-type: doc +Run Python doctests from Markdown and reStructuredText documents. +::: + +:::{grid-item-card} pytest_doctest_docutils +:link: pytest_doctest_docutils/index +:link-type: doc +Let pytest collect documentation doctests beside Python tests. +::: + +:::{grid-item-card} linkify_issues +:link: linkify_issues/index +:link-type: doc +Turn plain issue references into Sphinx links. +::: + +:::: diff --git a/docs/modules/linkify_issues/custom-patterns.md b/docs/modules/linkify_issues/custom-patterns.md new file mode 100644 index 0000000..e2bcdf2 --- /dev/null +++ b/docs/modules/linkify_issues/custom-patterns.md @@ -0,0 +1,30 @@ +(linkify_issues-custom-patterns)= + +# Custom patterns + +`issue_re` optionally changes which plain-text references become links. The +pattern must expose exactly one capturing group. Use a named group when your +`issue_url_tpl` references `{issue_id}`. + +The default pattern captures numbered references: + +```python +issue_re = r"#(?P\d+)" +``` + +Capture project-prefixed IDs by keeping one named group: + +```python +issue_re = r"#(?P[A-Z]+-\d+)" +issue_url_tpl = "https://github.com/git-pull/gp-libs/issues/{issue_id}" +``` + +Capture only the numeric part when the prefix should not appear in the URL: + +```python +issue_re = r"#ISSUE-(?P\d+)" +issue_url_tpl = "https://github.com/git-pull/gp-libs/issues/{issue_id}" +``` + +If you need multiple URL fields, use a project-local transform or propose an +extension change; the current transform validates one capturing group. diff --git a/docs/modules/linkify_issues/examples.md b/docs/modules/linkify_issues/examples.md new file mode 100644 index 0000000..7f1ebb6 --- /dev/null +++ b/docs/modules/linkify_issues/examples.md @@ -0,0 +1,24 @@ +(linkify_issues-examples)= + +# Examples + +## Default GitHub issues + +```python +extensions = ["linkify_issues"] +issue_url_tpl = "https://github.com/git-pull/gp-libs/issues/{issue_id}" +``` + +With the default pattern, source text such as `#123` links to the matching issue +URL. + +## Prefixed issue keys + +```python +extensions = ["linkify_issues"] +issue_re = r"#(?P[A-Z]+-\d+)" +issue_url_tpl = "https://github.com/git-pull/gp-libs/issues/{issue_id}" +``` + +This captures a single value such as `ISSUE-123` and formats it into the URL +template. diff --git a/docs/modules/linkify_issues/how-to.md b/docs/modules/linkify_issues/how-to.md new file mode 100644 index 0000000..32bfa69 --- /dev/null +++ b/docs/modules/linkify_issues/how-to.md @@ -0,0 +1,20 @@ +(linkify_issues-how-to)= + +# How-to + +In your Sphinx `conf.py`, add the extension: + +```python +extensions = [ + "linkify_issues", +] +``` + +Then configure the issue URL template: + +```python +issue_url_tpl = "https://github.com/git-pull/gp-libs/issues/{issue_id}" +``` + +The default `issue_re` captures the number from text such as `#42`, and +{mod}`linkify_issues` formats `issue_url_tpl` with that captured `issue_id`. diff --git a/docs/modules/linkify_issues/index.md b/docs/modules/linkify_issues/index.md new file mode 100644 index 0000000..8d0e952 --- /dev/null +++ b/docs/modules/linkify_issues/index.md @@ -0,0 +1,49 @@ +(linkify_issues)= +(linkify-issues)= + +# linkify_issues + +{mod}`linkify_issues` is a [Sphinx] extension that turns plain issue references +such as `#1` into links such as [#1](https://github.com/git-pull/gp-libs/issues/1). +Add the extension and set `issue_url_tpl`; the default pattern already handles +numbered GitHub issues. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} How-to +:link: how-to +:link-type: doc +Configure the default GitHub issue-link workflow. +::: + +:::{grid-item-card} Custom Patterns +:link: custom-patterns +:link-type: doc +Tune the one-group issue pattern safely. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +See default and custom issue-link snippets. +::: + +:::{grid-item-card} API Reference +:link: reference +:link-type: doc +Inspect the Sphinx transform and setup hook. +::: + +:::: + +```{toctree} +:hidden: + +how-to +custom-patterns +examples +reference +``` + +[Sphinx]: https://www.sphinx-doc.org/ diff --git a/docs/modules/linkify_issues/reference.md b/docs/modules/linkify_issues/reference.md new file mode 100644 index 0000000..2cb9e72 --- /dev/null +++ b/docs/modules/linkify_issues/reference.md @@ -0,0 +1,10 @@ +(linkify_issues-reference)= + +# API reference + +```{eval-rst} +.. automodule:: linkify_issues + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/modules/pytest_doctest_docutils/examples.md b/docs/modules/pytest_doctest_docutils/examples.md new file mode 100644 index 0000000..4253744 --- /dev/null +++ b/docs/modules/pytest_doctest_docutils/examples.md @@ -0,0 +1,30 @@ +(pytest_doctest_docutils-examples)= + +# Examples + +## Markdown file + +Run a Markdown documentation file directly: + +```console +$ pytest README.md +``` + +## Documentation directory + +Run every collected documentation doctest under `docs/`: + +```console +$ pytest docs/ +``` + +## Python modules + +Collect Python-module doctests through gp-libs' plugin: + +```console +$ py.test src/ --doctest-docutils-modules +``` + +The module flag is opt-in so documentation files stay the default path for this +plugin. diff --git a/docs/modules/pytest_doctest_docutils/fixtures.md b/docs/modules/pytest_doctest_docutils/fixtures.md new file mode 100644 index 0000000..9e5fb8e --- /dev/null +++ b/docs/modules/pytest_doctest_docutils/fixtures.md @@ -0,0 +1,36 @@ +(pytest_doctest_docutils-fixtures)= + +# Fixtures + +Documentation doctests do not receive ordinary named fixtures as direct +function arguments. Share values through {ref}`doctest_namespace +` or through autouse fixtures in a visible +`conftest.py`. + +## doctest_namespace + +Add objects to `doctest_namespace` from a fixture: + +```python +import pytest + + +@pytest.fixture +def add_helpers(doctest_namespace): + def add(left, right): + return left + right + + doctest_namespace["add"] = add +``` + +Then the documentation page can use the helper by name: + +```python +add(2, 3) +``` + +## Autouse fixtures + +Autouse fixtures in a visible `conftest.py` are parsed for `.rst` and `.md` +doctest files. Use them for setup that should happen before every example, and +keep the example itself self-contained for the reader. diff --git a/docs/modules/pytest_doctest_docutils/how-to.md b/docs/modules/pytest_doctest_docutils/how-to.md new file mode 100644 index 0000000..396c49c --- /dev/null +++ b/docs/modules/pytest_doctest_docutils/how-to.md @@ -0,0 +1,44 @@ +(pytest_doctest_docutils-how-to)= + +# How-to + +## Run documentation files + +Run one Markdown file: + +```console +$ pytest README.md +``` + +Run a directory: + +```console +$ pytest docs/ +``` + +## Include Python module doctests + +Use `--doctest-docutils-modules` when you also want this plugin to collect +Python-module doctests: + +```console +$ py.test src/ --doctest-docutils-modules +``` + +Disable Python-module collection explicitly with +`--no-doctest-docutils-modules`: + +```console +$ py.test src/ --no-doctest-docutils-modules +``` + +## Keep pytest's built-in doctest plugin disabled + +The gp-libs plugin blocks pytest's built-in doctest plugin by default. Keep +`-p no:doctest` in local examples when you are demonstrating explicit pytest +configuration: + +```ini +[pytest] +addopts = -p no:doctest +``` diff --git a/docs/modules/pytest_doctest_docutils/index.md b/docs/modules/pytest_doctest_docutils/index.md new file mode 100644 index 0000000..309c68d --- /dev/null +++ b/docs/modules/pytest_doctest_docutils/index.md @@ -0,0 +1,61 @@ +(pytest_plugin)= +(pytest_doctest_docutils)= + +# pytest_doctest_docutils + +{mod}`pytest_doctest_docutils` lets [pytest] collect doctests from `.rst` and +`.md` files. Point pytest at a documentation file or directory, and the plugin +parses each page through {ref}`doctest_docutils` before pytest runs the +examples. + +The plugin blocks {ref}`pytest's standard doctest plugin ` by +default so the same examples are not collected twice. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Tutorial +:link: tutorial +:link-type: doc +Run documentation doctests through pytest. +::: + +:::{grid-item-card} How-to +:link: how-to +:link-type: doc +Collect docs, Python modules, and option-flagged examples. +::: + +:::{grid-item-card} Fixtures +:link: fixtures +:link-type: doc +Share setup through `doctest_namespace` and autouse fixtures. +::: + +:::{grid-item-card} API Reference +:link: reference +:link-type: doc +Inspect collector, runner, and pytest hook APIs. +::: + +:::: + +## Default command + +Run documentation doctests under `docs/`: + +```console +$ pytest docs/ +``` + +```{toctree} +:hidden: + +tutorial +how-to +fixtures +examples +reference +``` + +[pytest]: https://docs.pytest.org/en/stable/ diff --git a/docs/modules/pytest_doctest_docutils/reference.md b/docs/modules/pytest_doctest_docutils/reference.md new file mode 100644 index 0000000..80ad569 --- /dev/null +++ b/docs/modules/pytest_doctest_docutils/reference.md @@ -0,0 +1,10 @@ +(pytest_doctest_docutils-reference)= + +# API reference + +```{eval-rst} +.. automodule:: pytest_doctest_docutils + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/modules/pytest_doctest_docutils/tutorial.md b/docs/modules/pytest_doctest_docutils/tutorial.md new file mode 100644 index 0000000..3018fcc --- /dev/null +++ b/docs/modules/pytest_doctest_docutils/tutorial.md @@ -0,0 +1,23 @@ +(pytest_doctest_docutils-tutorial)= + +# Tutorial + +Install gp-libs, put doctest examples in a Markdown or reStructuredText file, +and point [pytest] at the file: + +```console +$ pytest README.md +``` + +Run a documentation directory the same way: + +```console +$ pytest docs/ +``` + +{mod}`pytest_doctest_docutils` parses each matching documentation file with +{mod}`doctest_docutils`, then reports each collected doctest as a pytest item. +That gives documentation examples the same pass/fail surface as the rest of +your suite. + +[pytest]: https://docs.pytest.org/en/stable/ diff --git a/docs/redirects.txt b/docs/redirects.txt index ab45772..db1d3f1 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1 +1,4 @@ "developing.md" "project/contributing.md" +"doctest/index.md" "modules/doctest_docutils/index.md" +"doctest/pytest.md" "modules/pytest_doctest_docutils/index.md" +"linkify_issues/index.md" "modules/linkify_issues/index.md" diff --git a/tests/test_docs_console_examples.py b/tests/test_docs_console_examples.py new file mode 100644 index 0000000..273104b --- /dev/null +++ b/tests/test_docs_console_examples.py @@ -0,0 +1,345 @@ +"""Tests for documentation console examples.""" + +from __future__ import annotations + +import os +import pathlib +import re +import shlex +import subprocess +import sys +import textwrap +import typing as t + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +CommandPolicy = t.Literal["execute", "validate", "unknown"] + + +class ConsoleExample(t.NamedTuple): + """Console example collected from a Markdown page.""" + + path: pathlib.Path + line_number: int + commands: tuple[str, ...] + + +class ConsolePolicyCase(t.NamedTuple): + """Console command classification case.""" + + test_id: str + command: str + expected_policy: CommandPolicy + + +POLICY_CASES: tuple[ConsolePolicyCase, ...] = ( + ConsolePolicyCase( + test_id="existing-doctest-docutils-target", + command="python -m doctest_docutils README.md -v", + expected_policy="execute", + ), + ConsolePolicyCase( + test_id="missing-doctest-docutils-target", + command="python -m doctest_docutils README.rst -v", + expected_policy="validate", + ), + ConsolePolicyCase( + test_id="pytest-command", + command="pytest docs/", + expected_policy="validate", + ), + ConsolePolicyCase( + test_id="install-command", + command="uv add gp-libs", + expected_policy="validate", + ), + ConsolePolicyCase( + test_id="stdlib-doctest-help", + command="python -m doctest --help", + expected_policy="validate", + ), + ConsolePolicyCase( + test_id="shell-control", + command="uv run ruff format . && uv run pytest", + expected_policy="unknown", + ), +) + + +def iter_documentation_markdown_files( + repo_root: pathlib.Path, +) -> t.Iterator[pathlib.Path]: + """Yield Markdown files that are part of the authored documentation.""" + candidates = [repo_root / "README.md", *(repo_root / "docs").rglob("*.md")] + for path in sorted(candidates): + relative = path.relative_to(repo_root) + if "_build" in relative.parts: + continue + if path.name in {"AGENTS.md", "CLAUDE.md"}: + continue + yield path + + +def collect_console_examples(path: pathlib.Path) -> t.Iterator[ConsoleExample]: + """Collect prompt commands from Markdown console fences.""" + in_console = False + in_other_fence = False + fence = "" + fence_line_number = 0 + command_line_number = 0 + commands: list[str] = [] + + lines = path.read_text(encoding="utf-8").splitlines() + for line_number, line in enumerate(lines, 1): + stripped = line.lstrip() + if not in_console: + if in_other_fence: + if stripped.startswith(fence) and stripped[len(fence) :].strip() == "": + in_other_fence = False + continue + + match = re.match(r"^(`{3,}|~{3,})\s*console\s*$", stripped) + if match is not None: + fence = match.group(1) + in_console = True + fence_line_number = line_number + command_line_number = 0 + commands = [] + continue + + match = re.match(r"^(`{3,}|~{3,})", stripped) + if match is None: + continue + + fence = match.group(1) + in_other_fence = True + continue + + if stripped.startswith(fence) and stripped[len(fence) :].strip() == "": + yield ConsoleExample( + path=path, + line_number=command_line_number or fence_line_number, + commands=tuple(commands), + ) + in_console = False + continue + + if stripped.startswith("$ "): + if command_line_number == 0: + command_line_number = line_number + commands.append(stripped[2:]) + + +def classify_console_command( + repo_root: pathlib.Path, + command: str, +) -> CommandPolicy: + """Classify whether a docs command is safe to execute in pytest.""" + if _has_shell_control(command): + return "unknown" + + try: + parts = shlex.split(command) + except ValueError: + return "unknown" + + if not parts: + return "unknown" + + if len(parts) >= 3 and parts[:3] == ["python", "-m", "doctest_docutils"]: + target = _first_non_option(parts[3:]) + if target is not None and (repo_root / target).is_file(): + return "execute" + return "validate" + + if len(parts) >= 3 and parts[:3] == ["python", "-m", "doctest"]: + return "validate" + + validate_commands = { + "cd", + "git", + "just", + "pip", + "pipx", + "py.test", + "pytest", + "uv", + "uvx", + } + if parts[0] in validate_commands: + return "validate" + + return "unknown" + + +def _has_shell_control(command: str) -> bool: + """Return whether a command uses shell control syntax.""" + return bool(re.search(r"&&|\|\||[;|<>]|\$\(|`", command)) + + +def _first_non_option(parts: list[str]) -> str | None: + """Return the first command part that is not an option.""" + for part in parts: + if not part.startswith("-"): + return part + return None + + +def run_safe_console_example( + *, + repo_root: pathlib.Path, + example: ConsoleExample, + tmp_path: pathlib.Path, +) -> str: + """Execute an allowlisted console example in a temp-home sandbox.""" + command = example.commands[0] + parts = shlex.split(command) + target = _first_non_option(parts[3:]) + if target is None: + return f"{example.path}:{example.line_number}: missing doctest target" + + sandbox = tmp_path / f"{example.path.stem}-{example.line_number}" + sandbox.mkdir() + sandbox_home = sandbox / "home" + sandbox_home.mkdir() + + source = repo_root / target + if not source.is_file(): + return f"{example.path}:{example.line_number}: missing doctest target {target}" + + target_path = sandbox / target + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + pythonpath_parts = [str(repo_root / "src")] + if existing_pythonpath: + pythonpath_parts.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts) + env["HOME"] = str(sandbox_home) + + completed = subprocess.run( + [sys.executable, "-m", "doctest_docutils", *parts[3:]], + cwd=sandbox, + env=env, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + if completed.returncode == 0: + return "" + + return ( + f"{example.path}:{example.line_number}: {command!r} exited " + f"{completed.returncode}\nstdout:\n{completed.stdout}\nstderr:\n" + f"{completed.stderr}" + ) + + +@pytest.mark.parametrize( + ConsolePolicyCase._fields, + POLICY_CASES, + ids=[case.test_id for case in POLICY_CASES], +) +def test_classify_console_commands( + test_id: str, + command: str, + expected_policy: CommandPolicy, +) -> None: + """Classify executable examples separately from policy-only examples.""" + assert classify_console_command(REPO_ROOT, command) == expected_policy + + +def test_collect_console_examples_from_markdown(tmp_path: pathlib.Path) -> None: + """Collect one prompt command per console fence with source line numbers.""" + page = tmp_path / "usage.md" + page.write_text( + textwrap.dedent( + """ + # Usage + + ```console + $ python -m doctest_docutils README.md -v + ``` + + ```python + >>> 2 + 2 + 4 + ``` + """, + ).lstrip(), + encoding="utf-8", + ) + + examples = list(collect_console_examples(page)) + + assert [(example.line_number, example.commands) for example in examples] == [ + (4, ("python -m doctest_docutils README.md -v",)), + ] + + +def test_collect_console_examples_ignores_nested_markdown_fences( + tmp_path: pathlib.Path, +) -> None: + """Ignore illustrative nested console fences inside another fenced block.""" + page = tmp_path / "usage.md" + page.write_text( + textwrap.dedent( + """ + # Usage + + ````markdown + ```console + $ unknown nested command + ``` + ```` + """, + ).lstrip(), + encoding="utf-8", + ) + + assert list(collect_console_examples(page)) == [] + + +def test_documentation_console_examples_are_testable( + tmp_path: pathlib.Path, +) -> None: + """Validate every published console example and execute safe local examples.""" + failures: list[str] = [] + examples = [ + example + for path in iter_documentation_markdown_files(REPO_ROOT) + for example in collect_console_examples(path) + ] + assert examples + + for example in examples: + if len(example.commands) != 1: + failures.append( + f"{example.path}:{example.line_number}: " + "console blocks must contain exactly one prompt command", + ) + continue + + command = example.commands[0] + policy = classify_console_command(REPO_ROOT, command) + if policy == "unknown": + failures.append( + f"{example.path}:{example.line_number}: " + f"unclassified console command: {command}", + ) + continue + + if policy == "execute": + result = run_safe_console_example( + repo_root=REPO_ROOT, + example=example, + tmp_path=tmp_path, + ) + if result: + failures.append(result) + + assert failures == []