Source code for click_extra.sphinx.matrix

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Release compatibility matrices derived from a project's git history.

Walk every `vX.Y.Z` tag in a project's git repository, extract each release's
declared support for some axis, group consecutive tags that agree, and render a
GitHub-flavored markdown matrix suitable for a project's `install.md`.

Two axes are supported:

- **Python** (``{matrix} python``): per-tag support comes from the
  `Programming Language :: Python :: X.Y` classifier list in
  `pyproject.toml` (the explicit tested grid), falling back in priority order
  to PEP 621 `requires-python`, Poetry `[tool.poetry.dependencies].python`,
  and `setup.py`'s `python_requires`. A floor-only declaration is capped at
  the latest Python released while the range was current, so the `βœ…` set does
  not over-claim support for Pythons that did not yet exist. Cells are
  three-valued: the classifier list drives `βœ…`, `requires-python` drives
  `❌`, and a version neither attested nor ruled out renders as `–` (see
  {data}`UNDECLARED_CELL`).
- **A dependency** (``{matrix} <distribution>``, like ``{matrix} click``): the
  per-tag constraint is that distribution's requirement specifier; each
  column covers a run of that distribution's releases on PyPI (plus the
  `uv.lock` resolved version) that every range treats alike, and each βœ… / ❌
  cell is computed with `packaging`.

The rendered tables back the always-on `matrix` Sphinx directive (see
{class}`MatrixDirective`), so a project's `install.md` can embed a live matrix
kept current by the `click-extra refresh-directives` command instead of a
static table maintained by hand. The generation functions shell out to `git`;
they carry no runtime CLI relevance and are kept out of the `click_extra`
public API.
"""

from __future__ import annotations

import contextlib
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.client import HTTPException
from pathlib import Path
from typing import NamedTuple
from urllib.request import urlopen

from docutils import nodes
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version
from sphinx.directives import directives
from sphinx.util import logging

from ..blocks import (
    FenceSpan,
    fence_spans,
    marker_res,
    region_markers,
    split_options,
    update_blocks,
)
from ..table import TableFormat, corner_header, render_table
from ._base import ContentOffsetMixin, parse_into_section

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore[import-not-found]

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
    from typing import Any, ClassVar

    from sphinx.application import Sphinx
    from sphinx.util.typing import OptionSpec


logger = logging.getLogger(__name__)


PYTHON_RELEASE_DATES: dict[str, str] = {
    "2.7": "2010-07-03",
    "3.0": "2008-12-03",
    "3.1": "2009-06-27",
    "3.2": "2011-02-20",
    "3.3": "2012-09-29",
    "3.4": "2014-03-16",
    "3.5": "2015-09-13",
    "3.6": "2016-12-23",
    "3.7": "2018-06-27",
    "3.8": "2019-10-14",
    "3.9": "2020-10-05",
    "3.10": "2021-10-04",
    "3.11": "2022-10-24",
    "3.12": "2023-10-02",
    "3.13": "2024-10-07",
    "3.14": "2025-10-07",
}
"""ISO release date of each `X.Y` Python final release.

Used to cap `βœ…` cells when a release range's Python support is derived from
a `requires-python`-style floor rather than an explicit classifier list: a
floor without upper bound would otherwise over-claim support for Pythons that
did not yet exist while the range was current. Update this table each October
when a new final release ships.
"""

SUPPORTED_CELL: str = "βœ…"
"""Cell marking a version the release declares support for."""

FORBIDDEN_CELL: str = "❌"
"""Cell marking a version the release's own metadata rules out.

Reserved for a *hard* incompatibility: a version an installer refuses
outright because it falls below the declared floor, on or above the declared
ceiling, or inside an exclusion clause.
"""

UNDECLARED_CELL: str = "–"
"""Cell marking a version the release neither supports nor forbids.

Covers the two flavours of *absence of evidence*: a Python that had not been
released yet when the range shipped, and one that was released but never
added to the classifier list. Neither is a statement of incompatibility, so
spelling them {data}`FORBIDDEN_CELL` would over-claim.

```{note}
Only the `python` axis produces this cell. A dependency's requirement
specifier is the whole truth about that dependency, with no second,
informational source to disagree with it, so its cells stay binary.
```
"""

DEFAULT_TAG_PATTERN: str = r"^v\d+\.\d+\.\d+$"
"""Default regex for release tags (`vMAJOR.MINOR.PATCH`)."""

DEFAULT_TAGS_SORT: str = "version:refname"
"""Default `git tag --sort` argument."""

NEWEST_FIRST: str = "newest-first"
"""Ordering value: highest version / most recent release first."""

OLDEST_FIRST: str = "oldest-first"
"""Ordering value: lowest version / oldest release first."""

ORDER_CHOICES: tuple[str, ...] = (NEWEST_FIRST, OLDEST_FIRST)
"""Allowed values of the `column-order` and `row-order` matrix options."""


[docs] class PythonMatrixGroup(NamedTuple): """A contiguous run of release tags sharing the same Python support set.""" first_tag: str """First tag in the group (in `git tag --sort=version:refname` order).""" last_tag: str """Last tag in the group.""" first_date: str """ISO `YYYY-MM-DD` date of the first tag's commit.""" python_versions: tuple[str, ...] """The `X.Y` Python versions this range supports, sorted ascending.""" spec: str = "" """The range's declared `requires-python`, raw and unparsed. Kept alongside the supported set because the two answer different questions: the set is what the release *claims* (its classifier list), while the spec is what an installer *enforces*. A version outside the spec is a hard incompatibility, a version merely missing from the set is an undeclared one. See {data}`UNDECLARED_CELL`. """
def _validate_order(value: str, option: str) -> None: """Raise {class}`ValueError` when `value` is not a recognized ordering. `option` names the offending option in its hyphenated, user-facing form (`column-order` / `row-order`), shared by the directive, the marker region, and the Python keyword arguments. """ if value not in ORDER_CHOICES: msg = f"invalid {option} {value!r}: choose from {', '.join(ORDER_CHOICES)}" raise ValueError(msg) def _validate_orders(column_order: str, row_order: str) -> None: """Reject an unrecognized axis ordering before any history is read.""" _validate_order(column_order, "column-order") _validate_order(row_order, "row-order") def _version_sort_key(version: str) -> tuple[int, ...]: """Numeric sort key for `X.Y` version strings. A leading `v` tag namespace is tolerated so the same helper compares both bare versions (`3.10`) and release tags (`v3.10.2`). """ return tuple(int(part) for part in version.lstrip("v").split("."))
[docs] def python_versions_released_by( cutoff_date: str, release_dates: dict[str, str] | None = None, ) -> list[str]: """Return Python `X.Y` versions released on or before `cutoff_date`. :param cutoff_date: ISO `YYYY-MM-DD` date. :param release_dates: mapping of `X.Y` β†’ ISO release date. Defaults to {data}`PYTHON_RELEASE_DATES`. :return: sorted list of `X.Y` strings. """ if release_dates is None: release_dates = PYTHON_RELEASE_DATES return sorted( (v for v, d in release_dates.items() if d <= cutoff_date), key=_version_sort_key, )
[docs] def parse_python_spec(spec: str) -> tuple[str, str, set[str]]: """Parse a Python version spec into `(floor, ceiling, excluded)`. Supports: - PEP 440: `>=3.10`, `>=3.10,<3.14`, `>=2.7, !=3.0.*, !=3.1.*`. - Poetry caret: `^3.7` expands to `>=3.7, <4.0`. - Poetry tilde: `~3.7` expands to `>=3.7, <3.8`. - `setup.py`'s `python_requires` (uses the same PEP 440 syntax as `requires-python`). :return: `(floor, ceiling, excluded)` where each of `floor` and `ceiling` is a bare `"X.Y"` string (empty if unspecified) and `excluded` is the set of bare `"X.Y"` values one per `!=X.Y.*` clause. The ceiling is *exclusive*: `<3.14` means 3.14 itself is not supported. Returns `("", "", set())` when no floor is found. """ spec = spec.strip() if not spec: return "", "", set() # Poetry caret: `^X.Y` β†’ floor X.Y, ceiling (X+1).0 (exclusive). m = re.match(r"^\^(\d+)\.(\d+)", spec) if m: return f"{m.group(1)}.{m.group(2)}", f"{int(m.group(1)) + 1}.0", set() # Poetry tilde: `~X.Y` β†’ floor X.Y, ceiling X.(Y+1) (exclusive). m = re.match(r"^~(\d+)\.(\d+)", spec) if m: return ( f"{m.group(1)}.{m.group(2)}", f"{m.group(1)}.{int(m.group(2)) + 1}", set(), ) # PEP 440 comma-separated clauses. floor = "" ceiling = "" excluded: set[str] = set() for part in spec.split(","): part = part.strip() m = re.match(r">=?\s*(\d+\.\d+)", part) if m: floor = m.group(1) continue m = re.match(r"<\s*(\d+\.\d+)", part) if m: ceiling = m.group(1) continue m = re.match(r"!=\s*(\d+\.\d+)(?:\.\*)?", part) if m: excluded.add(m.group(1)) return floor, ceiling, excluded
def _git(project_root: Path, *args: str) -> str: """Run a `git` command in `project_root` and return its captured stdout. The single place the module shells out to git for text, so every history read uses the same working tree and decoding. A non-zero exit raises. `_read_release_files` is the one read made as bytes, and runs its own call. A missing git binary or a non-repository path surfaces as an {class}`OSError` / {class}`subprocess.SubprocessError`, which callers let propagate: {class}`MatrixDirective` turns it into a build warning, and {func}`_regenerate` swallows it to leave a block untouched. """ return subprocess.run( ["git", *args], capture_output=True, encoding="utf-8", check=True, cwd=project_root, ).stdout _RELEASE_FILES: tuple[str, ...] = ("pyproject.toml", "setup.py") """The files of a release tag the two axes read their declarations from.""" @dataclass class _HistoryMemo: """The git reads of one refresh, kept for the blocks that repeat them.""" tags: dict[tuple[Path, str], list[tuple[str, str]]] = field(default_factory=dict) """Each working tree's tag listing, with its dates, per sort order.""" files: dict[tuple[Path, str], tuple[str, str]] = field(default_factory=dict) """Each tag's `(pyproject, setup_py)` content, per working tree.""" _history_memo: _HistoryMemo | None = None """Memo of the git reads of the refresh under way, `None` outside one. Installed by `_memoized_history`. Outside a refresh every read goes to git, and a throwaway memo stands in so the readers below have one shape of code. """ def _memo() -> _HistoryMemo: """The memo of the refresh under way, or a fresh one nothing keeps.""" return _history_memo if _history_memo is not None else _HistoryMemo() @contextlib.contextmanager def _memoized_history() -> Generator[None, None, None]: """Serve every git read made inside from one memo. A refresh renders a block per axis on every page of a project, and each block walks the same tags of the same working tree. Inside this context the tag listing and each tag's release files are read once, whatever the floor or pattern each block applies. The memo lives no longer than the context: a tag pushed between two refreshes is seen by the second. Nesting reuses the outer memo. """ global _history_memo if _history_memo is not None: yield return _history_memo = _HistoryMemo() try: yield finally: _history_memo = None def _tag_history(project_root: Path, tags_sort: str) -> list[tuple[str, str]]: """Return `(tag, iso_date)` for every tag of `project_root`, in `tags_sort` order. One `git for-each-ref` call lists the tags with the author date of the commit each points at, read through the tag object of an annotated tag: the date `git log -1 --format=%as` prints for the tag. """ memo = _memo() history = memo.tags.get((project_root, tags_sort)) if history is None: listing = _git( project_root, "for-each-ref", f"--sort={tags_sort}", "--format=%(refname:strip=2)%09%(authordate:short)%09%(*authordate:short)", "refs/tags", ) history = [] for line in listing.splitlines(): tag, date, peeled_date = line.split("\t") history.append((tag, peeled_date or date)) memo.tags[project_root, tags_sort] = history return history def _read_release_files( project_root: Path, tags: Sequence[str] ) -> list[tuple[str, str]]: """Return the `(pyproject, setup_py)` content of each tag, empty when absent. One `git cat-file --batch` call answers every blob. The batch is read as bytes, since each blob is sized in bytes before it is decoded, and a blob then gets the UTF-8 decoding and newline translation `git show` would have given it in text mode. A tag lacking a file answers `missing`, which reads as the empty string `git show` prints with its error discarded. """ requests = "".join(f"{tag}:{name}\n" for tag in tags for name in _RELEASE_FILES) output = subprocess.run( ["git", "cat-file", "--batch"], input=requests.encode("utf-8"), capture_output=True, check=True, cwd=project_root, ).stdout contents: list[str] = [] position = 0 for _ in range(len(tags) * len(_RELEASE_FILES)): end = output.index(b"\n", position) header = output[position:end] position = end + 1 if header.endswith((b" missing", b" ambiguous")): contents.append("") continue size = int(header.rsplit(b" ", 1)[1]) blob = output[position : position + size].decode("utf-8") contents.append(blob.replace("\r\n", "\n").replace("\r", "\n")) position += size + 1 # The answers come in request order: the two files of a tag, tag after tag. return [ (contents[index], contents[index + 1]) for index in range(0, len(contents), len(_RELEASE_FILES)) ] def _release_files( project_root: Path, tags: Sequence[str] ) -> dict[str, tuple[str, str]]: """Map each of `tags` to the content of its release files. Reads only the tags the memo of the refresh under way does not hold yet, in one batch, so a second block walking the same history reads nothing. """ memo = _memo() missing = [tag for tag in tags if (project_root, tag) not in memo.files] if missing: for tag, files in zip( missing, _read_release_files(project_root, missing), strict=True ): memo.files[project_root, tag] = files return {tag: memo.files[project_root, tag] for tag in tags} def _walk_tags( project_root: Path, *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, version_floor: str = "", ) -> list[tuple[str, str, str, str]]: """Return `(tag, iso_date, pyproject, setup_py)` for each release tag. Lists the tags in `tags_sort` order, keeps the ones matching `tag_pattern` and at or above `version_floor`, and reads each kept tag's `pyproject.toml` and `setup.py` (empty string when absent). Shared by the Python and dependency axes so both read history identically, and in two git calls whatever the number of tags: see `_tag_history` and `_release_files`. """ tag_re = re.compile(tag_pattern) floor_key = _version_sort_key(version_floor) if version_floor else None # Drop tags below the package-version floor before any blob is read: no # point paying that cost for excluded releases. selected = [ (tag, iso_date) for tag, iso_date in _tag_history(project_root, tags_sort) if tag_re.match(tag) and (floor_key is None or _version_sort_key(tag) >= floor_key) ] files = _release_files(project_root, [tag for tag, _ in selected]) return [(tag, iso_date, *files[tag]) for tag, iso_date in selected] _PYTHON_CLASSIFIER_RE = re.compile(r"Programming Language :: Python :: ([23]\.\d+)") """A `Programming Language :: Python :: X.Y` trove classifier, its version captured.""" _REQUIRES_PYTHON_RE = re.compile(r'requires-python\s*=\s*["\']([^"\']+)["\']') """A PEP 621 `requires-python` declaration, its specifier captured.""" _POETRY_PYTHON_RE = re.compile( r'\[tool\.poetry\.dependencies\][^\[]*?python\s*=\s*["\']([^"\']+)["\']', re.DOTALL, ) """The `python` entry of Poetry's `[tool.poetry.dependencies]` table.""" _PYTHON_REQUIRES_RE = re.compile(r'python_requires\s*=\s*["\']([^"\']+)["\']') """A `setup.py` `python_requires` keyword, its specifier captured."""
[docs] def python_matrix_groups( project_root: Path, *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, version_floor: str = "", release_dates: dict[str, str] | None = None, ) -> list[PythonMatrixGroup]: """Walk every release tag in `project_root` and group consecutive releases that declare the same effective set of Python versions. :param project_root: git working tree to walk. :param tag_pattern: regex matching release tags. Defaults to {data}`DEFAULT_TAG_PATTERN` (`vMAJOR.MINOR.PATCH`). :param tags_sort: value passed to `git tag --sort`. Defaults to {data}`DEFAULT_TAGS_SORT` (`version:refname`). :param version_floor: drop every release tag below this bare version (`"4.9.0"`, `"4.9"`). Empty (the default) keeps all tags. Applied before grouping so the oldest surviving group starts at the floor. :param release_dates: Python-version release-date table used for the cap. Defaults to {data}`PYTHON_RELEASE_DATES`. :return: List of {class}`PythonMatrixGroup`, in chronological order. Tags with no Python declaration in any recognized form are skipped. """ if release_dates is None: release_dates = PYTHON_RELEASE_DATES # Pass 1: per-tag (tag, iso_date, classifiers, spec). tag_data: list[tuple[str, str, tuple[str, ...], str]] = [] for tag, iso_date, pyproject, setup_py in _walk_tags( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, ): classifiers = tuple( sorted( set(_PYTHON_CLASSIFIER_RE.findall(pyproject)), key=_version_sort_key ), ) spec = "" for pat, content in ( (_REQUIRES_PYTHON_RE, pyproject), (_POETRY_PYTHON_RE, pyproject), (_PYTHON_REQUIRES_RE, setup_py), ): if not content: continue m = pat.search(content) if m: spec = m.group(1) break if not classifiers and not spec: continue tag_data.append((tag, iso_date, classifiers, spec)) if not tag_data: return [] # Pass 2: group consecutive tags with same raw (classifiers, spec). # Same-classifier neighbours with different spec strings (a floor bump # from `>=3.8` to `>=3.8.6`) get split here and re-merge in Pass 4 # when their effective versions coincide. raw_groups: list[list] = [] for tag, iso_date, cls, spec in tag_data: key = (cls, spec) if raw_groups and raw_groups[-1][3] == key: raw_groups[-1][1] = tag else: raw_groups.append([tag, tag, iso_date, key]) # Pass 3: resolve effective versions per group. Classifiers win when # present; otherwise apply the parsed floor / ceiling / excluded with cap. today_iso = datetime.now(tz=timezone.utc).date().isoformat() resolved: list[tuple[str, str, str, tuple[str, ...], str]] = [] for idx, group in enumerate(raw_groups): first_tag, last_tag, first_date, (cls, spec) = group if cls: versions: tuple[str, ...] = cls else: floor, ceiling, excluded = parse_python_spec(spec) if not floor: continue end_date = ( raw_groups[idx + 1][2] if idx + 1 < len(raw_groups) else today_iso ) existing = python_versions_released_by( end_date, release_dates=release_dates, ) floor_ver_key = _version_sort_key(floor) ceiling_key = _version_sort_key(ceiling) if ceiling else None versions = tuple( v for v in existing if _version_sort_key(v) >= floor_ver_key and v not in excluded and (ceiling_key is None or _version_sort_key(v) < ceiling_key) ) if not versions: continue resolved.append((first_tag, last_tag, first_date, versions, spec)) # Pass 4: re-merge consecutive resolved groups whose effective versions # collapsed to identical sets. The declared spec has to agree too, since # it is what separates a forbidden version from an undeclared one; it is # compared parsed, so re-spacing `>= 3.10` into `>=3.10` does not split # an otherwise continuous range. merged: list[list] = [] for first_tag, last_tag, first_date, versions, spec in resolved: same = merged and merged[-1][3] == versions if same and parse_python_spec(merged[-1][4]) == parse_python_spec(spec): merged[-1][1] = last_tag else: merged.append([first_tag, last_tag, first_date, versions, spec]) return [PythonMatrixGroup(f, l, d, v, s) for f, l, d, v, s in merged]
def _spans_full_major( first_tag: str, last_tag: str, next_first_tag: str | None ) -> bool: """Whether a group covers an entire major series (so it labels as `X.x`). True when the group stays within one major, starts at that major's `.0` minor, and the chronologically newer group has moved on to a higher major (or there is none, for the latest group). `next_first_tag` is the first tag of that newer group, or `None`. The `.0` start guards against a floored partial major (`4.9.x` onward is not `4.x`), and the higher-major successor guards against a major that is split across several groups. A bare major carries no minor to read, and `9` starts major 9 just as `9.0` does. {func}`_column_candidates` builds one from a `>=9` floor whenever the release list is empty, which is the documented offline path. """ first = first_tag.lstrip("v").split(".") last = last_tag.lstrip("v").split(".") first_minor = first[1] if len(first) > 1 else "0" if first[0] != last[0] or first_minor != "0": return False if next_first_tag is None: return True return int(next_first_tag.lstrip("v").split(".")[0]) > int(first[0]) def _range_label( first_tag: str, last_tag: str, *, is_latest: bool, full_major: bool = False, split_start: bool = False, split_end: bool = False, first_date: str = "", ) -> str: """Render the version-range label for a matrix group. A group covering an entire major series collapses to `X.x` (see `full_major`). For the most recent multi-major group, the upper bound collapses to the major-version wildcard (like `8.x`) so the label is stable across new minor releases. A single-release group shows its exact `X.Y.Z` version rather than a patch wildcard, so two adjacent patch releases never collapse to the same ambiguous label. Other closed groups keep precise minor-version bounds. `split_start` and `split_end` flag a bound whose minor series the neighboring group shares, where `X.Y.x` would name both groups. That bound shows its exact version instead, and a group inside a single split minor shows both of its versions: `4.6.2` β†’ `4.6.3`. `first_date` follows the lower bound, which is the release it dates: `6.0.x` (2025-09-25) β†’ `9.x`. """ first = first_tag.lstrip("v") last = last_tag.lstrip("v") date = f" ({first_date})" if first_date else "" if first == last: return f"`{first}`{date}" if full_major and not split_start: return f"`{first.split('.')[0]}.x`{date}" first_minor = ".".join(first.split(".")[:2]) last_minor = ".".join(last.split(".")[:2]) same_minor = first_minor == last_minor if same_minor and not (split_start or split_end): return f"`{first_minor}.x`{date}" low = first if split_start or same_minor else f"{first_minor}.x" if is_latest: high = f"{last.split('.')[0]}.x" elif split_end or same_minor: high = last else: high = f"{last_minor}.x" return f"`{low}`{date} β†’ `{high}`" def _same_minor(tag: str, other_tag: str | None) -> bool: """Whether `other_tag` exists and shares the `X.Y` minor series of `tag`.""" if other_tag is None: return False return tag.lstrip("v").split(".")[:2] == other_tag.lstrip("v").split(".")[:2] def _range_labels(bounds: list[tuple[str, str, str]]) -> list[str]: """Label each `(first_tag, last_tag, first_date)` group of a newest-first list. Each label depends on both neighbors: the newer group decides whether this one is the latest or spans a full major, and either neighbor can split a minor series with it (see {func}`_range_label`). """ labels = [] for index, (first_tag, last_tag, first_date) in enumerate(bounds): newer_first = bounds[index - 1][0] if index else None older_last = bounds[index + 1][1] if index + 1 < len(bounds) else None labels.append( _range_label( first_tag, last_tag, is_latest=index == 0, full_major=_spans_full_major(first_tag, last_tag, newer_first), split_start=_same_minor(first_tag, older_last), split_end=_same_minor(last_tag, newer_first), first_date=first_date, ), ) return labels def _corner_cell(label: str, axis: str) -> str: """Name the release rows and the version columns in the top-left cell. The backslash {func}`~click_extra.table.corner_header` places is doubled: `mdformat` escapes a lone one that way, and a refreshed table must survive a formatting pass unchanged. Both spellings render as a single backslash. """ return corner_header(f"`{label}`", axis).replace("\\", "\\\\") def _render_matrix( label: str, axis: str, groups: Sequence[tuple[str, str, str, Sequence[str]]], column_labels: Sequence[str], *, row_order: str, leading_headers: Sequence[str] = (), ) -> str: """Render the release ranges of a matrix as a GitHub-flavored markdown table. `groups` come oldest first, each a `(first_tag, last_tag, first_date, cells)` whose `cells` are the row's cells after its label: a `Spec` cell when the dependency axis shows one, then one cell per column. An empty `first_date` leaves the label undated, see `_range_labels`. `column_labels` head the version columns, centered, and `leading_headers` the cells before them, left-aligned like the label. Rows sit newest first unless `row_order` is {data}`OLDEST_FIRST`. """ ordered = list(reversed(groups)) labels = _range_labels([(first, last, date) for first, last, date, _ in ordered]) rows = [ [row_label, *cells] for row_label, (*_, cells) in zip(labels, ordered, strict=True) ] if row_order == OLDEST_FIRST: rows.reverse() headers = [_corner_cell(label, axis), *leading_headers, *column_labels] colalign = ( "left", *("left",) * len(leading_headers), *("center",) * len(column_labels), ) return render_table( rows, headers=headers, table_format=TableFormat.GITHUB, colalign=colalign, ) def _python_cell(version: str, group: PythonMatrixGroup) -> str: """Classify one Python version against one release range. Three outcomes, from the two independent sources a release declares (see {data}`UNDECLARED_CELL`): the classifier list attests support, the `requires-python` spec rules a version out, or neither says anything. A range that declared no spec at all can forbid nothing, so every version it does not claim comes out undeclared. """ if version in group.python_versions: return SUPPORTED_CELL floor, ceiling, excluded = parse_python_spec(group.spec) if not floor: return UNDECLARED_CELL version_key = _version_sort_key(version) forbidden = ( version_key < _version_sort_key(floor) or version in excluded or (bool(ceiling) and version_key >= _version_sort_key(ceiling)) ) return FORBIDDEN_CELL if forbidden else UNDECLARED_CELL
[docs] def python_matrix_table( project_root: Path, label: str, *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, python_floor: str = "", version_floor: str = "", column_order: str = NEWEST_FIRST, row_order: str = NEWEST_FIRST, show_date: bool = False, release_dates: dict[str, str] | None = None, ) -> str: """Render the Python compatibility matrix as a GitHub-flavored markdown table. By default newest releases sit on top and newest Python versions on the left, so the most recent compatibility information always sits in the upper-left corner of the table; `row_order` and `column_order` flip either axis. :param project_root: git working tree to walk. :param label: the header column name (usually the package name, like `"click-extra"` or `"repomatic"`). Rendered in backticks. :param tag_pattern: passed to {func}`python_matrix_groups`. :param tags_sort: passed to {func}`python_matrix_groups`. :param python_floor: drop every Python `X.Y` column below this bare version (`"3.9"`). Empty (the default) keeps all columns. Trims columns only; combine with `version_floor` to also drop the old release rows that supported nothing above the floor. :param version_floor: passed to {func}`python_matrix_groups` to drop release rows below a bare package version. :param column_order: left-to-right ordering of the Python columns: {data}`NEWEST_FIRST` (default) or {data}`OLDEST_FIRST`. :param row_order: top-to-bottom ordering of the release rows: {data}`NEWEST_FIRST` (default) or {data}`OLDEST_FIRST`. :param show_date: follow each row label with the release date of its first tag, like `6.0.x (2025-09-25) β†’ 9.x`. :param release_dates: passed to {func}`python_matrix_groups`. :return: rendered markdown table, or the empty string when no group was collected. :raises ValueError: on an unrecognized `column_order` or `row_order`. """ _validate_orders(column_order, row_order) groups = python_matrix_groups( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, release_dates=release_dates, ) if not groups: return "" all_versions = sorted( {v for g in groups for v in g.python_versions}, key=_version_sort_key, reverse=column_order == NEWEST_FIRST, ) if python_floor: python_floor_key = _version_sort_key(python_floor) all_versions = [ v for v in all_versions if _version_sort_key(v) >= python_floor_key ] if not all_versions: return "" return _render_matrix( label, "Python", [ ( group.first_tag, group.last_tag, group.first_date if show_date else "", [_python_cell(version, group) for version in all_versions], ) for group in groups ], [f"`{version}`" for version in all_versions], row_order=row_order, )
[docs] class DependencyMatrixGroup(NamedTuple): """A contiguous run of release tags declaring the same dependency spec.""" first_tag: str """First tag in the group (in `git tag --sort=version:refname` order).""" last_tag: str """Last tag in the group.""" first_date: str """ISO `YYYY-MM-DD` date of the first tag's commit.""" spec: str """The raw requirement specifier declared for the dependency at this range."""
def _safe_version(text: str) -> Version | None: """Parse `text` into a {class}`~packaging.version.Version`, or `None`.""" try: return Version(text) except InvalidVersion: return None def _requirement_spec(requirement: str, dep_name: str) -> str: """Return the specifier `requirement` declares for `dep_name`, or `""`. `requirement` is a [PEP 508](https://peps.python.org/pep-0508/) dependency string. Parsing it, rather than pattern-matching the file it came from, is what keeps an extras bracket and an environment marker out of the result: `tabulate[widechars]>=0.9` and `tomli>=2; python_version<'3.11'` both yield just their version specifier. The specifier is sliced out of the original text instead of read back from {attr}`~packaging.requirements.Requirement.specifier`, which normalizes and re-sorts its clauses: the `Spec` column shows each release's own spelling of the range. """ try: parsed = Requirement(requirement) except InvalidRequirement: return "" if canonicalize_name(parsed.name) != canonicalize_name(dep_name): return "" # Drop the environment marker, then take everything from the first # comparison operator on. The parse above guarantees what is left is the # specifier, bar the optional wrapping parentheses core metadata allows. head = requirement.split(";", 1)[0].strip() m = re.search(r"[<>=!~]", head) return head[m.start() :].strip().rstrip(")") if m else "" def _pyproject_data(pyproject: str) -> dict[str, Any]: """Parse a release's `pyproject.toml`, an unparsable one reading as empty. An unparsable file only costs its tag the row, the same as a tag that never declared the dependency at all. """ try: data: dict[str, Any] = tomllib.loads(pyproject) except (tomllib.TOMLDecodeError, ValueError): return {} return data def _iter_declared_requirements( data: Mapping[str, Any], setup_py: str ) -> Iterator[str]: """Yield every PEP 508 requirement string declared by a release. `data` is the release's parsed `pyproject.toml`. Covers a project's runtime dependencies and the optional ones behind an extra, in that order. Dependency groups ([PEP 735](https://peps.python.org/pep-0735/)) are deliberately skipped: they hold development tooling, which no installer resolves for a consumer and which therefore says nothing about the release's compatibility. `setup.py` is Python source rather than a table, so its string literals are harvested wholesale and left for {func}`_requirement_spec` to sort out. """ project = data.get("project", {}) yield from project.get("dependencies", []) for extra_deps in project.get("optional-dependencies", {}).values(): yield from extra_deps yield from re.findall(r'["\']([^"\']+)["\']', setup_py) def _extract_requirement(pyproject: str, setup_py: str, dep_name: str) -> str: """Return the version specifier declared for `dep_name`, or `""`. Reads the PEP 621 and `setup.py` requirement strings first, then a Poetry `[tool.poetry.dependencies]` entry, which may be a bare version string (`click = "^8.1"`) or an inline table (`click = { version = "^8.1" }`). Poetry's caret and tilde ranges are not PEP 508, so they are returned raw for {func}`_to_specifier_set` to translate. """ data = _pyproject_data(pyproject) for requirement in _iter_declared_requirements(data, setup_py): spec = _requirement_spec(requirement, dep_name) if spec: return spec canonical = canonicalize_name(dep_name) poetry_deps = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) for name, declaration in poetry_deps.items(): if canonicalize_name(name) != canonical: continue if isinstance(declaration, dict): declaration = declaration.get("version", "") return declaration.strip() if isinstance(declaration, str) else "" return "" POETRY_VERSION_PATTERN: str = ( r"(?P<release>\d+(?:\.\d+){0,2})" r"(?P<suffix>(?:[-_.]?[a-z]+\d*)*)" ) """The version a Poetry caret or tilde range opens on. Up to three release components, then an optional PEP 440 pre-, post- or dev-release suffix. The suffix stays on the floor and plays no part in the ceiling, as poetry-core reads it: `^2.0.0.post1` means `>=2.0.0.post1,<3.0.0`. """ POETRY_CARET_RE = re.compile(rf"^\^\s*{POETRY_VERSION_PATTERN}$", re.IGNORECASE) """Poetry's [caret range](https://python-poetry.org/docs/dependency-specification/#caret-requirements).""" POETRY_TILDE_RE = re.compile(rf"^~\s*{POETRY_VERSION_PATTERN}$", re.IGNORECASE) """Poetry's [tilde range](https://python-poetry.org/docs/dependency-specification/#tilde-requirements). Deliberately also matches a bare `~X`. It cannot swallow PEP 440's `~=`, whose `=` is not a digit. """ POETRY_WILDCARD_RE = re.compile(r"^(?:\*|(\d+)(?:\.(\d+))?\.\*)$") """Poetry's [wildcard range](https://python-poetry.org/docs/dependency-specification/#wildcard-requirements). A bare `*` allows any version at all. `X.*` and `X.Y.*` pin their series, without PEP 440's leading `==`. """ def _caret_ceiling(parts: tuple[int, ...]) -> str: """Return the exclusive ceiling of a Poetry caret range. Poetry bumps the *leftmost non-zero* component of the declared version and zeroes everything after it. So `^1.2.3` caps at `2.0.0`, but `^0.2.3` caps at `0.3.0` and `^0.0.3` at `0.0.4`: under a `0.` prefix every release is allowed to break compatibility, and a caret there must not claim the whole major series. When every declared component is zero, the last one declared is the one that bumps, so `^0.0` caps at `0.1.0` while `^0` caps at `1.0.0`. """ index = next((i for i, part in enumerate(parts) if part), len(parts) - 1) bumped = [*parts[:index], parts[index] + 1] return ".".join(str(part) for part in bumped + [0] * (3 - len(bumped))) def _series_ceiling(parts: tuple[int, ...]) -> str: """Return the exclusive ceiling of a Poetry tilde or wildcard range. Both pin the series named by the *last component declared* and bump it, zeroing the rest. So `~1` and `1.*` cap at `2.0.0`, while `~1.2`, `~1.2.3` and `1.2.*` all cap at `1.3.0`: a third component narrows what the range accepts, never which series caps it. """ if len(parts) == 1: return f"{parts[0] + 1}.0.0" return f"{parts[0]}.{parts[1] + 1}.0" def _poetry_to_pep440(spec: str) -> str | None: """Translate a Poetry-only range into PEP 440, or `None` if it is not one. `None` means the specifier is already PEP 440 (or is nothing this module recognizes), so it should be handed to {mod}`packaging` untouched. """ m = POETRY_CARET_RE.match(spec) if m: parts = tuple(int(part) for part in m["release"].split(".")) return f">={m['release']}{m['suffix']},<{_caret_ceiling(parts)}" m = POETRY_TILDE_RE.match(spec) if m: parts = tuple(int(part) for part in m["release"].split(".")) return f">={m['release']}{m['suffix']},<{_series_ceiling(parts)}" m = POETRY_WILDCARD_RE.match(spec) if m: parts = tuple(int(part) for part in m.groups() if part is not None) if not parts: # A bare `*`: the empty specifier set allows every version. return "" floor = ".".join(str(part) for part in parts) return f">={floor},<{_series_ceiling(parts)}" return None def _to_specifier_set(spec: str) -> SpecifierSet | None: """Convert a specifier (PEP 440 or Poetry) to a `SpecifierSet`. Poetry's caret, tilde and wildcard ranges are translated first (see {func}`_poetry_to_pep440`); PEP 440 specifiers (`>=`, `~=`, `==`, commas) pass through. Returns `None` for an unparsable specifier, which renders as an all-`❌` row rather than raising out of a documentation build. """ spec = spec.strip() translated = _poetry_to_pep440(spec) if translated is not None: spec = translated try: return SpecifierSet(spec.replace(" ", "")) except InvalidSpecifier: return None def _same_spec(spec: str, other: str) -> bool: """Whether two specifiers accept the same versions, whatever their spelling. Both are compared parsed (see {func}`_to_specifier_set`), so Poetry's `^2.0` matches PEP 440's `>=2.0,<3.0.0`. An unparsable specifier only matches the same text, spaces aside. """ spec_set = _to_specifier_set(spec) if spec_set is None: return spec.replace(" ", "") == other.replace(" ", "") return spec_set == _to_specifier_set(other) def _spec_floor(spec: str) -> Version | None: """Return the lowest version a specifier names, or `None`. Without PyPI's release list, the floors of the ranges stand in for the releases the columns bin (see {func}`_column_candidates`). A specifier with no lower bound at all, like a lone ceiling, names none. """ spec = spec.strip() m = POETRY_CARET_RE.match(spec) or POETRY_TILDE_RE.match(spec) if m: return _safe_version(m["release"] + m["suffix"]) m = POETRY_WILDCARD_RE.match(spec) if m: parts = [p for p in m.groups() if p is not None] return _safe_version(".".join(parts)) if parts else None m = re.match(r"^~=\s*(\d+(?:\.\d+){1,2})", spec) if m: return _safe_version(m.group(1)) # An exact or wildcard pin: its floor is the version it names. m = re.match(r"^={2,3}\s*(\d+(?:\.\d+){0,2})(?:\.\*)?$", spec) if m: return _safe_version(m.group(1)) m = re.search(r">=?\s*(\d+(?:\.\d+){0,2})", spec) return _safe_version(m.group(1)) if m else None PYPI_JSON_URL: str = "https://pypi.org/pypi/{name}/json" """PyPI's JSON API endpoint for a project, formatted with its normalized name.""" PYPI_TIMEOUT: float = 10 """Seconds to wait for PyPI before the release lookup gives up."""
[docs] class ReleasesUnreadable(Exception): """PyPI could not answer for a distribution's releases. Raised by {func}`_pypi_releases` only when its caller asked for the read to be required, which the source refresh in {func}`_regenerate` does. Live rendering never asks, and keeps the fallback columns. """
def _pypi_releases(dep_name: str, *, required: bool = False) -> tuple[Version, ...]: """Return every stable release of `dep_name` on PyPI, oldest first. Reads the `releases` mapping of PyPI's JSON API. A release stays when at least one of its files is not yanked, and goes when it is a pre-release, a development release, or not a valid version. An error or a malformed answer yields no release, and the table goes without the columns they add. No cooldown applies. The matrix states what an installer accepts, and an installer applies no cooldown by default, so a release is installable as soon as it is published. Reading its version number installs nothing. :param required: raise {class}`ReleasesUnreadable` instead of returning nothing, for a caller that cannot use the fallback table. """ url = PYPI_JSON_URL.format(name=canonicalize_name(dep_name)) try: with urlopen(url, timeout=PYPI_TIMEOUT) as response: releases = json.load(response)["releases"] stable = sorted( version for version, files in ( (_safe_version(str(tag)), files) for tag, files in releases.items() ) if version is not None and not version.is_prerelease and any(not file.get("yanked") for file in files) ) except ( AttributeError, HTTPException, KeyError, OSError, TypeError, ValueError, ) as error: logger.warning( "click_extra.sphinx: matrix cannot read the %s releases from PyPI: %s", dep_name, error, ) if required: raise ReleasesUnreadable(dep_name) from error return () return tuple(stable) def _latest_locked_version(project_root: Path, dep_name: str) -> str: """Return `dep_name`'s resolved version from `uv.lock`, or `""`. Reads the lockfile in the working tree, not per tag. """ try: text = (project_root / "uv.lock").read_text(encoding="utf-8") except OSError: return "" m = re.search(rf'name = "{re.escape(dep_name)}"\nversion = "([^"]+)"', text) return m.group(1) if m else "" def _column_candidates( specs: Iterable[str], releases: Iterable[Version], locked: str ) -> set[Version]: """Return the versions of a dependency its matrix columns bin. Every stable release on PyPI (see {func}`_pypi_releases`), plus the version `uv.lock` resolves, which shows what the project tests against. Without the release list, the floors of the ranges stand in for it, so a table rendered offline still shows where each range starts. """ candidates = set(releases) if not candidates: candidates.update(filter(None, map(_spec_floor, specs))) locked_version = _safe_version(locked) if locked else None if locked_version is not None: candidates.add(locked_version) return candidates def _dependency_columns( spec_sets: Sequence[SpecifierSet | None], candidates: Iterable[Version] ) -> list[tuple[Version, Version, tuple[bool, ...]]]: """Bin `candidates` into columns of versions every range treats alike. Each candidate gets the vector of the ranges accepting it, one entry per item of `spec_sets` (`None` for an unparsable range, which accepts nothing). Consecutive candidates sharing a vector share a column, so a range accepts every version of a column or none of them, and each βœ… / ❌ cell is exact. :return: `(first, last, accepted)` bins, oldest first. """ bins: list[tuple[Version, Version, tuple[bool, ...]]] = [] for version in sorted(set(candidates)): accepted = tuple( spec_set is not None and spec_set.contains(version, prereleases=True) for spec_set in spec_sets ) if bins and bins[-1][2] == accepted: bins[-1] = (bins[-1][0], version, accepted) else: bins.append((version, version, accepted)) return bins
[docs] def dependency_matrix_groups( project_root: Path, dep_name: str, *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, version_floor: str = "", ) -> list[DependencyMatrixGroup]: """Group consecutive release tags declaring the same `dep_name` spec. :param dep_name: the distribution whose requirement specifier is tracked (like `"click"`). :return: {class}`DependencyMatrixGroup` list in chronological order; tags with no declared requirement for `dep_name` are skipped. """ groups: list[DependencyMatrixGroup] = [] for tag, iso_date, pyproject, setup_py in _walk_tags( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, ): spec = _extract_requirement(pyproject, setup_py, dep_name) if not spec: continue if groups and groups[-1].spec == spec: groups[-1] = groups[-1]._replace(last_tag=tag) else: groups.append(DependencyMatrixGroup(tag, tag, iso_date, spec)) return groups
[docs] def dependency_matrix_table( project_root: Path, label: str, dep_name: str, *, show_spec: bool = False, show_date: bool = False, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, version_floor: str = "", column_order: str = NEWEST_FIRST, row_order: str = NEWEST_FIRST, require_releases: bool = False, ) -> str: """Render the `dep_name` compatibility matrix as a markdown table. Each column covers a run of `dep_name` releases that every range treats alike (see {func}`_column_candidates` and {func}`_dependency_columns`), labeled like the rows; each βœ… / ❌ cell is computed with `packaging`. Consecutive ranges whose cells coincide are re-merged into one row. By default newest releases sit on top and newest dependency versions on the left, matching the Python axis; `row_order` and `column_order` flip either axis. :param label: header column name (the documented package, in backticks). :param dep_name: the tracked distribution (`"click"`). :param show_spec: add a `Spec` column with each range's raw specifier. A merged row shows the specifier of its oldest range, so ranges then merge only when they also accept the same versions (see {func}`_same_spec`). :param show_date: follow each row label with the release date of its first tag, like `6.0.x (2025-09-25) β†’ 9.x`. :param column_order: left-to-right ordering of the version columns: {data}`NEWEST_FIRST` (default) or {data}`OLDEST_FIRST`. :param row_order: top-to-bottom ordering of the release rows: {data}`NEWEST_FIRST` (default) or {data}`OLDEST_FIRST`. :param require_releases: refuse the offline fallback columns, for a caller comparing the result against a checked-in table. :return: rendered markdown table, or `""` when nothing was collected. :raises ValueError: on an unrecognized `column_order` or `row_order`. :raises ReleasesUnreadable: under `require_releases`, when PyPI cannot answer for `dep_name`. """ _validate_orders(column_order, row_order) groups = dependency_matrix_groups( project_root, dep_name, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, ) if not groups: return "" specs = [g.spec for g in groups] spec_sets = [_to_specifier_set(spec) for spec in specs] candidates = _column_candidates( specs, _pypi_releases(dep_name, required=require_releases), _latest_locked_version(project_root, dep_name), ) bins = _dependency_columns(spec_sets, candidates) if not bins: return "" # Label the columns the way the rows are labeled, newest first. newest_first = list(reversed(bins)) column_labels = _range_labels([ (str(first), str(last), "") for first, last, _ in newest_first ]) columns = [ (column_label, accepted) for column_label, (*_, accepted) in zip( column_labels, newest_first, strict=True ) ] # Drop the oldest columns no range accepts: they only say that the history # starts later. Labeled before the drop, the oldest column left still # names the exact release it starts at. if any(any(accepted) for _, accepted in columns): while not any(columns[-1][1]): columns.pop() if column_order == OLDEST_FIRST: columns.reverse() # Read each range's βœ… / ❌ vector off the bins, then re-merge consecutive # ranges whose vectors coincide: they accept the same releases. A merged # row shows the Spec cell of its oldest range, so with that column the # ranges must also accept the same versions beyond the newest release. merged: list[list] = [] for index, group in enumerate(groups): cells = tuple( SUPPORTED_CELL if accepted[index] else FORBIDDEN_CELL for _, accepted in columns ) same = merged and merged[-1][4] == cells if same and (not show_spec or _same_spec(merged[-1][3], group.spec)): merged[-1][1] = group.last_tag else: merged.append( [group.first_tag, group.last_tag, group.first_date, group.spec, cells], ) return _render_matrix( label, f"`{dep_name}`", [ ( first_tag, last_tag, first_date if show_date else "", [*([f"`{spec.replace(' ', '')}`"] if show_spec else []), *cells], ) for first_tag, last_tag, first_date, spec, cells in merged ], [column_label for column_label, _ in columns], row_order=row_order, leading_headers=["Spec"] if show_spec else [], )
def _find_git_root(start: Path) -> Path: """Walk up from `start` to the first directory holding a `.git` entry. Falls back to `start` when none is found; the caller then gets an empty matrix (no tags) rather than an exception. """ for candidate in (start, *start.parents): if (candidate / ".git").exists(): return candidate return start def _resolve_root(path_opt: str | None, base_dir: Path) -> Path: """Resolve the git working tree to walk from a `:path:` option value. `base_dir` anchors both the git-root search and any relative `path_opt`: the docs source directory for the live directive, the Markdown file's parent for the offline updater. An absolute `path_opt` is used verbatim. """ # Resolve to an absolute path so a relative `base_dir` (like the `docs` # the CLI passes) yields a git root whose `.name` is the real repo folder, # not an empty string from `Path(".")`. git_root = _find_git_root(base_dir.resolve()) if not path_opt: return git_root candidate = Path(path_opt) if not candidate.is_absolute(): candidate = git_root / candidate return candidate.resolve() def _project_name(project_root: Path) -> str: """Read the name a project declares in its working-tree `pyproject.toml`. The PEP 621 `[project]` table is read first, then Poetry's `[tool.poetry]` one. Returns the empty string when the file is missing or unreadable, or declares no name, so the caller can fall back on the folder name. The folder name alone is not a stable label: a clone, a fork or a git worktree under another name each renders a different table, and `refresh-directives --check` then reports the checked-in one as stale. """ try: text = (project_root / "pyproject.toml").read_text(encoding="utf-8") data = tomllib.loads(text) except (OSError, tomllib.TOMLDecodeError, ValueError): return "" tool = data.get("tool") poetry = tool.get("poetry") if isinstance(tool, dict) else None for table in (data.get("project"), poetry): name = table.get("name") if isinstance(table, dict) else None if name and isinstance(name, str): return name return "" def _render_block( axis: str, options: Mapping[str, str], base_dir: Path, *, require_releases: bool = False, ) -> str: """Render the table for a ``{matrix} <axis>`` block. Dispatches on `axis`: `"python"` renders the interpreter matrix; any other value names a distribution and renders its dependency matrix. Shared by {class}`MatrixDirective` (live rendering) and {func}`update_matrix_blocks` (offline source refresh) so both resolve the package, path, and floors identically. `require_releases` reaches the dependency axis alone: the Python axis reads git history and a static table, so it renders the same answer offline. """ root = _resolve_root(options.get("path"), base_dir) package = options.get("package") or _project_name(root) or root.name tag_pattern = options.get("tag-pattern") or DEFAULT_TAG_PATTERN version_floor = options.get("version-floor", "") column_order = options.get("column-order") or NEWEST_FIRST row_order = options.get("row-order") or NEWEST_FIRST if axis == "python": return python_matrix_table( root, package, python_floor=options.get("python-floor", ""), version_floor=version_floor, tag_pattern=tag_pattern, column_order=column_order, row_order=row_order, show_date="show-date" in options, ) return dependency_matrix_table( root, package, axis, show_spec="show-spec" in options, show_date="show-date" in options, version_floor=version_floor, tag_pattern=tag_pattern, column_order=column_order, row_order=row_order, require_releases=require_releases, ) def _order_option(argument: str) -> str: """Validate a `column-order` / `row-order` directive option value.""" return directives.choice(argument, ORDER_CHOICES) # type: ignore[no-any-return]
[docs] class MatrixDirective(ContentOffsetMixin): """Render a package's compatibility matrix for a given axis. ``{matrix} python`` renders the interpreter matrix (release ranges Γ— Python versions). ``{matrix} <distribution>`` (like ``{matrix} click``) renders a dependency matrix (release ranges Γ— that dependency's versions, from its requirement specifier across the git history). Both emit a GitHub-flavored table parsed by the host document's parser, so it lands as a real `<table>`. The table normally lives *inside* the block as its content, kept current by the offline updater ({func}`update_matrix_blocks`, exposed as the `click-extra refresh-directives` command). Rendering that embedded copy needs no git access at build time, so shallow clones and read-only build hosts still show the matrix. An empty block falls back to generating from the working tree's git tags, so a freshly authored block renders before its first refresh. Argument: the axis, `python` or a distribution name. Options: - `:package:` β€” header column label. Defaults to the name declared in the project's `pyproject.toml`, then to the name of its folder. - `:path:` β€” git working tree to walk, absolute or relative to the documented project's git root. Defaults to that git root. - `:version-floor:` β€” drop release rows below this package version. - `:tag-pattern:` β€” regex selecting release tags. Defaults to {data}`DEFAULT_TAG_PATTERN`. - `:column-order:` β€” left-to-right ordering of the version columns: `newest-first` (default) or `oldest-first`. - `:row-order:` β€” top-to-bottom ordering of the release rows: `newest-first` (default) or `oldest-first`. - `:python-floor:` β€” (python axis) drop Python columns below `X.Y`. - `:show-spec:` β€” (dependency axis) add a raw-specifier `Spec` column. - `:show-date:` β€” follow each row label with its first release date. The git fallback is resilient: a missing git binary, a non-repository path, or a tag-less repository logs a build warning and renders nothing rather than aborting the build. """ has_content = True required_arguments = 1 optional_arguments = 0 option_spec: ClassVar[OptionSpec] = { "package": directives.unchanged, "path": directives.unchanged, "python-floor": directives.unchanged, "version-floor": directives.unchanged, "tag-pattern": directives.unchanged, "column-order": _order_option, "row-order": _order_option, "show-spec": directives.flag, "show-date": directives.flag, }
[docs] def run(self) -> list[nodes.Node]: axis = self.arguments[0].strip() # Prefer the embedded table (kept fresh by update_matrix_blocks): it # renders without touching git, so the build works on shallow clones. if self.content: table_lines = list(self.content) else: # Empty block: generate from the git tags as a first-render # fallback until the updater populates the source. try: table = _render_block(axis, self.options, Path(self.env.srcdir)) except (OSError, subprocess.SubprocessError) as error: logger.warning( "click_extra.sphinx: matrix %s could not read git tags: %s", axis, error, location=self.get_location(), ) return [] if not table: logger.warning( "click_extra.sphinx: matrix %s found no release data and " "the block embeds no table", axis, location=self.get_location(), ) return [] table_lines = table.splitlines() # Parse the table with the host document's parser (MyST inside .md, # reST inside .rst) so it lands as a real table node rather than a # literal code block. return parse_into_section(self, table_lines)
[docs] def setup(app: Sphinx) -> None: """Register the always-on `matrix` directive on `app`. Called from {func}`click_extra.sphinx.setup` so projects only need to list `"click_extra.sphinx"` in their `extensions`. Unlike the `click:*` / `python:*` families, the directive is registered unconditionally: it runs a canned matrix generator, not user-supplied Python, so it needs no opt-in. """ app.add_directive("matrix", MatrixDirective)
_FENCE_OPEN_RE = re.compile( r"^(?P<indent>[ \t]*)(?P<fence>`{3,})\{matrix\}[ \t]+(?P<axis>\S+)[ \t]*$", ) """Opening fence of a ``{matrix} <axis>`` directive block in a Markdown source.""" # `<!-- matrix <axis> [opts] -->` … `<!-- matrix-end -->` region markers, in the # shared grammar from `blocks.marker_res`. Unlike the directive fence (which # GitHub shows as a code block), this marker form renders as a real table on # GitHub and natively in Sphinx. Args are the axis followed by # whitespace-separated `key=value` pairs and bare flags (like `show-spec` # or `show-date`). _MARKER_OPEN_RE, _MARKER_CLOSE_RE = marker_res("matrix") _MARKER_CLOSE = region_markers("matrix")[1] def _parse_marker_options(tokens: Iterable[str]) -> dict[str, str]: """Parse a marker's `key=value` / bare-flag option tokens into a mapping.""" options: dict[str, str] = {} for token in tokens: key, _, value = token.partition("=") options[key] = value return options def _regenerate(axis: str, options: Mapping[str, str], base_dir: Path) -> str: """Render a block, returning `""` on any git/OS failure (non-destructive). An unreadable PyPI counts as such a failure here, where it does not for live rendering: the caller writes this table into the source, or compares it against what is already there, and the fallback columns would drift every checked-in dependency block whenever the network hiccups. """ try: return _render_block(axis, options, base_dir, require_releases=True) except (OSError, ReleasesUnreadable, subprocess.SubprocessError): return "" def _refresh_fence_block( match: re.Match[str], lines: list[str], index: int, close: int, base_dir: Path, ) -> list[str]: """Regenerate one ``{matrix} <axis>`` directive fence closing at `close`. Returns the replacement lines. Keeps the block verbatim when generation fails. """ indent = match.group("indent") fence = match.group("fence") axis = match.group("axis") options, _body = split_options(lines[index + 1 : close]) table = _regenerate(axis, options, base_dir) if not table: return lines[index : close + 1] out = [f"{indent}{fence}{{matrix}} {axis}"] for key, value in options.items(): out.append(f"{indent}:{key}: {value}" if value else f"{indent}:{key}:") out.append("") out.extend(f"{indent}{row}" if row else row for row in table.splitlines()) out.append(f"{indent}{fence}") return out def _refresh_marker_region( match: re.Match[str], lines: list[str], index: int, spans: Mapping[int, FenceSpan], base_dir: Path, ) -> tuple[list[str], int]: """Regenerate one `<!-- matrix <axis> -->` region starting at `index`. The start marker is kept verbatim; the raw table between the markers is replaced, blank-line padded so `mdformat` does not ping-pong on the surrounding HTML comments. The closing-marker search skips fence spans, so a `<!-- matrix-end -->` shown inside a code example cannot close a live region. Kept verbatim when unterminated, axis-less, or on failure. """ indent = match.group("indent") tokens = (match.group("args") or "").split() if not tokens: return [lines[index]], index + 1 axis = tokens[0] options = _parse_marker_options(tokens[1:]) close = None probe = index + 1 while probe < len(lines): span = spans.get(probe) if span is not None: if span.close is None: break probe = span.close + 1 continue close_match = _MARKER_CLOSE_RE.match(lines[probe]) if close_match and close_match.group("indent") == indent: close = probe break probe += 1 if close is None: return [lines[index]], index + 1 table = _regenerate(axis, options, base_dir) if not table: return lines[index : close + 1], close + 1 out = [lines[index], ""] out.extend(f"{indent}{row}" if row else row for row in table.splitlines()) out.extend(["", f"{indent}{_MARKER_CLOSE}"]) return out, close + 1 def _rewrite_matrix_blocks(text: str, base_dir: Path) -> str: """Return `text` with every `matrix` block's table regenerated. Handles both forms: the ``{matrix} <axis>`` directive fence (rendered by Sphinx) and the `<!-- matrix <axis> -->` comment region (which renders as a real table on GitHub too). Each keeps its axis and options; only the embedded table is replaced. The walk is fence-aware: a ``{matrix}`` example nested inside a longer code fence is a documented illustration, copied verbatim and never refreshed. A block whose generation fails (non-repository path, missing git, unreadable PyPI, no data) is left byte-for-byte untouched. """ lines = text.splitlines() spans = fence_spans(lines) out: list[str] = [] index = 0 total = len(lines) while index < total: span = spans.get(index) if span is not None: if span.close is None: # Unterminated fence: leave the tail untouched. out.extend(lines[index:]) break fence_match = _FENCE_OPEN_RE.match(lines[index]) if fence_match: out.extend( _refresh_fence_block( fence_match, lines, index, span.close, base_dir ) ) else: out.extend(lines[index : span.close + 1]) index = span.close + 1 continue marker_match = _MARKER_OPEN_RE.match(lines[index]) if marker_match: chunk, index = _refresh_marker_region( marker_match, lines, index, spans, base_dir ) out.extend(chunk) continue out.append(lines[index]) index += 1 result = "\n".join(out) if text.endswith("\n"): result += "\n" return result
[docs] def update_matrix_blocks(paths: Iterable[Path], *, check: bool = False) -> list[Path]: """Refresh every ``{matrix}`` block in the given Markdown sources. See {func}`click_extra.blocks.update_blocks` for the walk, write, and `check`-mode contract. Every block of the refresh reads the git history through one memo, see `_memoized_history`: a project's tags are listed once, and each tag's release files read once, however many pages and axes render them. :return: the files whose ``{matrix}`` blocks were (or, under `check`, would be) updated. """ with _memoized_history(): return update_blocks( paths, lambda text, path: _rewrite_matrix_blocks(text, base_dir=path.parent), check=check, )