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. The specifier,
  read with `packaging`, drives `✅` and `❌`, and a release it accepts but that
  came out after the range was superseded renders as `–`, like a future Python.
  So does a release of a major that came out after the last release of a
  superseded range: no release of that range ever met it.

Both axes then keep only the support envelope of the project (see
{func}`_envelope`): the ranges from the newest one down to the oldest one that
supports the floor of the newest range or a later version, and the versions from
the oldest one these ranges support. The `<!-- envelope -->` region lists the
obsolete bounds of the envelopes of several axes: see {func}`envelope_bounds`.

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 tomllib
from dataclasses import dataclass, field
from datetime import UTC, datetime
from http.client import HTTPException
from itertools import groupby
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 ..markdown.blocks import (
    FenceSpan,
    fence_spans,
    marker_res,
    region_markers,
    rewrite_fences,
    split_options,
    update_blocks,
    walk_fences,
)
from ..table import TableFormat, corner_header, render_table
from ..version_facts import find_git_root, run_git
from ._base import ContentOffsetMixin, parse_into_section

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

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

    K = TypeVar("K")
    T = TypeVar("T")


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",
    # 3.15.0 final per https://peps.python.org/pep-0790/ and its python.org page.
    "3.15": "2026-10-09",
}
"""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 flavours of *absence of evidence*: a version its declaration lets
through but that came out after the range was superseded or belongs to a major
that came out after its last release, and, on the `python` axis, a Python 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. Spelling the first one
{data}`SUPPORTED_CELL` would over-claim the other way: an open-ended specifier
like `click>=8.0` accepts every future release, whether it works or not.
"""

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."""

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."""

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 `==`.
"""

_RELEASE_FILES: tuple[str, ...] = ("pyproject.toml", "setup.py")
"""The files of a release tag the two axes read their declarations from."""

_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."""

_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 `markdown.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]

# `<!-- envelope <axis>... [key=value ...] -->` … `<!-- envelope-end -->` region
# markers, in the same grammar. Its bare args name the axes it covers. repomatic's
# `update-docs` refreshes only the pages holding a prefix of its
# `DIRECTIVE_BLOCK_MARKERS`: a new marker here needs its prefix there too.
_ENVELOPE_OPEN_RE, _ENVELOPE_CLOSE_RE = marker_res("envelope")
_ENVELOPE_CLOSE = region_markers("envelope")[1]


[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_parts(version: str) -> list[str]: """The dot-separated components of a version or of a release tag. The leading `v` of the tag namespace is dropped, so a bare version (`3.10`) and a release tag (`v3.10.2`) read the same. """ return version.removeprefix("v").split(".") def _version_sort_key(version: str) -> tuple[int, ...]: """Numeric sort key for `X.Y` version strings and release tags.""" return tuple(map(int, _version_parts(version))) def _runs(items: Iterable[T], key: Callable[[T], K]) -> Iterator[tuple[K, T, T]]: """Yield `(key, first, last)` for each run of consecutive `items` sharing a key. A matrix is built by grouping what agrees: consecutive tags declaring the same support, consecutive ranges rendering the same cells, consecutive releases every range treats alike. Keys are compared with `==` against the first item of their run, and need not be hashable. """ for value, run in groupby(items, key): members = list(run) yield value, members[0], members[-1]
[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
@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()
[docs] @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. A missing git binary or a path outside a repository raises an {class}`OSError` or a {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. """ memo = _memo() history = memo.tags.get((project_root, tags_sort)) if history is None: # No output means no tag. listing = ( run_git( "for-each-ref", f"--sort={tags_sort}", "--format=%(refname:strip=2)%09%(authordate:short)" "%09%(*authordate:short)", "refs/tags", cwd=project_root, check=True, ) or "" ) 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]
[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 declaring the same classifiers and the # same spec, compared parsed. A re-spelled spec (`>= 3.10` rewritten # `>=3.10`) continues its range: splitting it there would end the older # half early, and a floor-only declaration would then be capped at that # premature end in Pass 3. The group keeps the spelling of its first tag. raw_groups = [ (first[0], last[0], first[1], first[2], first[3]) for _, first, last in _runs( tag_data, key=lambda tag: (tag[2], parse_python_spec(tag[3])) ) ] # Pass 3: resolve effective versions per group. Classifiers win when # present; otherwise apply the parsed floor / ceiling / excluded, capped at # the Pythons released before the next group starts. today_iso = datetime.now(tz=UTC).date().isoformat() end_dates = [group[2] for group in raw_groups[1:]] + [today_iso] resolved: list[PythonMatrixGroup] = [] for (first_tag, last_tag, first_date, cls, spec), end_date in zip( raw_groups, end_dates, strict=True ): if cls: versions: tuple[str, ...] = cls else: floor, ceiling, excluded = parse_python_spec(spec) if not floor: continue floor_key = _version_sort_key(floor) ceiling_key = _version_sort_key(ceiling) if ceiling else None versions = tuple( v for v in python_versions_released_by(end_date, release_dates) if _version_sort_key(v) >= floor_key and v not in excluded and (ceiling_key is None or _version_sort_key(v) < ceiling_key) ) if versions: resolved.append( PythonMatrixGroup(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. return [ first._replace(last_tag=last.last_tag) for _, first, last in _runs( resolved, key=lambda group: (group.python_versions, parse_python_spec(group.spec)), ) ]
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 = _version_parts(first_tag) last = _version_parts(last_tag) 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(_version_parts(next_first_tag)[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 = "", descending: bool = False, ) -> 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`. `descending` puts the upper bound first, for a header read from the newest version to the oldest one: `9.4.x` ← `9.3.4`. The arrow still points at the newer bound, and the versions of a row of such headers decrease from left to right. """ first = first_tag.removeprefix("v") last = last_tag.removeprefix("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" if descending: return f"`{high}` ← `{low}`{date}" 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 _version_parts(tag)[:2] == _version_parts(other_tag)[:2] def _range_labels( bounds: Sequence[tuple[str, str, str]], *, older: tuple[str, str, str] | None = None, descending: bool = False, ) -> 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`). `older` is the group just below the list, when the support envelope cuts the list short: the oldest label still shows the exact version a split minor starts at, `6.0.1` → `6.1.x` rather than `6.0.x` → `6.1.x`, which would claim the `6.0.0` release left out. `descending` flips every label. """ neighbors = [*bounds, older] if older else list(bounds) labels = [] for index, (first_tag, last_tag, first_date) in enumerate(bounds): newer_first = bounds[index - 1][0] if index else None older_last = neighbors[index + 1][1] if index + 1 < len(neighbors) 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, descending=descending, ), ) 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 _supported_columns(rows: Iterable[Sequence[str]]) -> Iterator[int]: """Yield the column of each {data}`SUPPORTED_CELL` of `rows`, row after row.""" for row in rows: for index, cell in enumerate(row): if cell == SUPPORTED_CELL: yield index def _envelope( cells: Sequence[Sequence[str]], *, full_history: bool = False ) -> tuple[int, int]: """Return the first row and the first column of the support envelope. `cells` holds one row per release range, oldest first, and one column per version, ascending. The envelope is the part of the history still in scope: 1. Its floor is the oldest version the newest range marks {data}`SUPPORTED_CELL`. 2. Its rows run from the newest range down to the oldest one supporting the floor or a later version. The ranges in between stay whatever they hold, so a release that dropped a version for a while keeps its row. 3. Its columns start at the oldest version a kept range supports. A later column stays even when no kept range supports it, so a gap in support stays visible. A newest range supporting nothing has no floor, and every row stays, as under `full_history`. The oldest columns no row supports go whenever some row supports a column: they only say that the history starts later. :return: `(first_row, first_column)` indices into `cells`. """ first_row = 0 floor = next(_supported_columns(cells[-1:]), None) if floor is not None and not full_history: first_row = next( index for index, row in enumerate(cells) if SUPPORTED_CELL in row[floor:] ) return first_row, min(_supported_columns(cells[first_row:]), default=0) 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] = (), older: PythonMatrixGroup | DependencyMatrixGroup | None = None, ) -> 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`. `older` is the range just below the oldest row, when the support envelope leaves it out (see {func}`_range_labels`). """ ordered = list(reversed(groups)) labels = _range_labels( [(first, last, date) for first, last, date, _ in ordered], older=(older.first_tag, older.last_tag, older.first_date) if older else None, ) 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 class _Grid(NamedTuple): """The cells of a matrix, before its support envelope trims them.""" groups: Sequence[PythonMatrixGroup | DependencyMatrixGroup] """The release ranges, oldest first: one row each.""" versions: list[str] """The version of each column, ascending.""" dates: list[str] """The ISO release date of each column version, empty when unknown.""" cells: list[list[str]] """One cell per range and version.""" def _python_grid( project_root: Path, *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, python_floor: str = "", version_floor: str = "", release_dates: dict[str, str] | None = None, ) -> _Grid | None: """Build the Python matrix of `project_root`, or `None` when it is empty. Its columns are the Python versions some range supports, at or above `python_floor`, each dated by `release_dates`. The other parameters go to {func}`python_matrix_groups`. """ groups = python_matrix_groups( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, release_dates=release_dates, ) versions = sorted( {v for g in groups for v in g.python_versions}, key=_version_sort_key ) if python_floor: python_floor_key = _version_sort_key(python_floor) versions = [v for v in versions if _version_sort_key(v) >= python_floor_key] if not versions: return None dates = PYTHON_RELEASE_DATES if release_dates is None else release_dates return _Grid( groups, versions, [dates.get(version, "") for version in versions], [[_python_cell(version, group) for version in versions] for group in groups], )
[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, full_history: 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. The table keeps the support envelope of the project (see {func}`_envelope`). Consecutive ranges whose cells then coincide merge into one row. :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) sets no floor. The support envelope reads its floor from the columns left (see {func}`_envelope`): a `python_floor` above the oldest Python of the newest range raises that floor, which drops the ranges below the oldest one supporting it or a later Python. Under `full_history`, `python_floor` drops columns only. :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 full_history: keep every release range, not just the support envelope. :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) grid = _python_grid( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort, python_floor=python_floor, version_floor=version_floor, release_dates=release_dates, ) if grid is None: return "" groups, versions, _, cells = grid first_row, first_column = _envelope(cells, full_history=full_history) columns = list(range(first_column, len(versions))) if column_order == NEWEST_FIRST: columns.reverse() def rendered(row: int) -> list[str]: return [cells[row][column] for column in columns] rows = [ ( groups[oldest].first_tag, groups[newest].last_tag, groups[oldest].first_date if show_date else "", rendered(oldest), ) for _, oldest, newest in _runs(range(first_row, len(groups)), key=rendered) ] return _render_matrix( label, "Python", rows, [f"`{versions[column]}`" for column in columns], row_order=row_order, older=groups[first_row - 1] if first_row else None, )
[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.""" last_date: str """ISO `YYYY-MM-DD` date of the last 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 "" 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 _spec_identity(spec: str) -> tuple[str, SpecifierSet | str]: """What two specifiers accepting the same versions share, whatever their spelling. A specifier is 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. The tag opening the tuple keeps a parsed set from being compared with a text, which {class}`~packaging.specifiers.SpecifierSet` would try to parse. """ spec_set = _to_specifier_set(spec) if spec_set is None: return ("text", spec.replace(" ", "")) return ("set", spec_set) 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
[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) -> dict[Version, str]: """Return every stable release of `dep_name` on PyPI, with its date. 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. Its date is the UTC day the first of its files that are not yanked was uploaded, empty when PyPI gives none. 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. :return: each release, oldest first, mapped to its ISO `YYYY-MM-DD` date. """ 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: dict[Version, str] = {} for tag, files in releases.items(): version = _safe_version(str(tag)) installable = [file for file in files if not file.get("yanked")] if version is None or version.is_prerelease or not installable: continue uploads = ( str(file.get("upload_time_iso_8601") or "")[:10] for file in installable ) stable[version] = min(filter(None, uploads), default="") 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 dict(sorted(stable.items())) 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. The lockfile spells every package by its normalized name, so `dep_name` is looked up that way: `Widget_Lib` finds the `widget-lib` entry. """ try: text = (project_root / "uv.lock").read_text(encoding="UTF-8") except (OSError, ValueError): return "" name = re.escape(canonicalize_name(dep_name)) m = re.search(rf'name = "{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. A locked pre-release or development version is left out, like PyPI's: it is no release an installer gets from the index. A project tracking the branch of a dependency locks such a version, which no PyPI date places in time. Every range whose specifier accepts it would read as supporting it, and so stay in the support envelope however old the range is. """ 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 and not locked_version.is_prerelease: candidates.add(locked_version) return candidates def _newest_major(releases: Mapping[Version, str], cutoff: str) -> int | None: """Return the newest major of the `releases` out on `cutoff` or before. `None` when no release carries a date, like the offline fallback columns: no major cap applies then. """ return max( ( version.major for version, date in releases.items() if date and date <= cutoff ), default=None, ) def _dependency_cell( spec_set: SpecifierSet | None, version: Version, released: str, end_date: str, major_cap: int | None = None, ) -> str: """Classify one release of a dependency against one release range. `released` is the ISO date of the release, empty when unknown, and `end_date` the day the range was superseded. A release the specifier rules out is forbidden, and an unparsable specifier rules out everything. A release it accepts is supported when it came out while the range was current, its last day included, and undeclared when it came out later (see {data}`UNDECLARED_CELL`). A release of unknown date reads as supported: the offline fallback columns carry no date to compare. `major_cap` is the newest major of the dependency out by the last release of a superseded range, `None` for the newest range. A release the specifier accepts from a later major is undeclared too: no release of the range ever met it, and a new major is free to break. """ if spec_set is None or not spec_set.contains(version, prereleases=True): return FORBIDDEN_CELL if released and released > end_date: return UNDECLARED_CELL if major_cap is not None and version.major > major_cap: return UNDECLARED_CELL return SUPPORTED_CELL def _dependency_columns( cells: Sequence[Sequence[str]], rows: Iterable[int] ) -> list[tuple[int, int]]: """Bin the columns of `cells` into runs of versions that `rows` treat alike. `cells` holds one row per range and one column per candidate version, ascending. Consecutive columns whose cells agree in every one of `rows` share a bin, so a shown range holds the same cell for every version of a bin, and each cell is exact. Only the shown ranges count: two releases that only a range outside the envelope tells apart share a bin. :return: `(first, last)` column indices of each bin, ascending. """ shown = list(rows) def column_cells(column: int) -> list[str]: return [cells[row][column] for row in shown] return [ (first, last) for _, first, last in _runs(range(len(cells[0]) if cells else 0), column_cells) ]
[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. Specifiers are compared parsed (see {func}`_spec_identity`), so a re-spelled specifier continues its range: `cloup >= 3.0.7` rewritten as `cloup>=3.0.7` by a formatter is no new range. Splitting there would end the older half early, and every release of the dependency published after that would read as undeclared for it. A group keeps the spelling of its first tag. :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. """ declared = [ (tag, iso_date, spec) for tag, iso_date, pyproject, setup_py in _walk_tags( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, ) if (spec := _extract_requirement(pyproject, setup_py, dep_name)) ] return [ DependencyMatrixGroup(first[0], last[0], first[1], last[1], first[2]) for _, first, last in _runs(declared, key=lambda tag: _spec_identity(tag[2])) ]
def _dependency_grid( project_root: Path, dep_name: str, *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, version_floor: str = "", require_releases: bool = False, ) -> _Grid | None: """Build the `dep_name` matrix of `project_root`, or `None` when it is empty. One column per release (see {func}`_column_candidates`), dated from PyPI, and one cell per range and release (see {func}`_dependency_cell`). `require_releases` goes to {func}`_pypi_releases`, the other parameters to {func}`dependency_matrix_groups`. """ groups = dependency_matrix_groups( project_root, dep_name, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, ) if not groups: return None releases = _pypi_releases(dep_name, required=require_releases) candidates = sorted( _column_candidates( [group.spec for group in groups], releases, _latest_locked_version(project_root, dep_name), ) ) if not candidates: return None # A range is current until the next one starts, and the newest one today. today = datetime.now(tz=UTC).date().isoformat() end_dates = [group.first_date for group in groups[1:]] + [today] # A superseded range attests no major out after its own last release. major_caps = [_newest_major(releases, group.last_date) for group in groups[:-1]] major_caps.append(None) spec_sets = [_to_specifier_set(group.spec) for group in groups] return _Grid( groups, [str(version) for version in candidates], [releases.get(version, "") for version in candidates], [ [ _dependency_cell( spec_set, version, releases.get(version, ""), end, major_cap ) for version in candidates ] for spec_set, end, major_cap in zip( spec_sets, end_dates, major_caps, strict=True ) ], )
[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, full_history: bool = False, require_releases: bool = False, ) -> str: """Render the `dep_name` compatibility matrix as a markdown table. Each cell classifies a release of `dep_name` against a range (see {func}`_dependency_cell`). Each column covers a run of releases that every shown range treats alike (see {func}`_column_candidates` and {func}`_dependency_columns`), labeled like the rows. Headers read from their newest bound, `9.4.x` ← `9.3.4`, while the columns run newest first. The table keeps the support envelope of the project (see {func}`_envelope`), and consecutive ranges whose cells coincide merge 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}`_spec_identity`). :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 full_history: keep every release range, not just the support envelope. :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) grid = _dependency_grid( project_root, dep_name, tag_pattern=tag_pattern, tags_sort=tags_sort, version_floor=version_floor, require_releases=require_releases, ) if grid is None: return "" groups, versions, _, cells = grid first_row, first_column = _envelope(cells, full_history=full_history) shown = range(first_row, len(groups)) # Label the columns the way the rows are labeled, newest first. bins = list(reversed(_dependency_columns(cells, shown))) column_labels = _range_labels( [(versions[first], versions[last], "") for first, last in bins], descending=column_order == NEWEST_FIRST, ) # Drop the oldest columns, outside the envelope. Labeled before the drop, # the oldest column left still names the exact release it starts at. columns = [ (column_label, first) for column_label, (first, _) in zip(column_labels, bins, strict=True) if first >= first_column ] if column_order == OLDEST_FIRST: columns.reverse() # Re-merge consecutive ranges whose cells coincide: they treat every shown # release alike. 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. def merge_key(row: int) -> tuple[list[str], tuple[str, SpecifierSet | str] | None]: row_cells = [cells[row][column] for _, column in columns] return row_cells, _spec_identity(groups[row].spec) if show_spec else None rows = [] for (row_cells, _), oldest, newest in _runs(shown, key=merge_key): spec_cells = [f"`{groups[oldest].spec.replace(' ', '')}`"] if show_spec else [] rows.append(( groups[oldest].first_tag, groups[newest].last_tag, groups[oldest].first_date if show_date else "", [*spec_cells, *row_cells], )) return _render_matrix( label, f"`{dep_name}`", rows, [column_label for column_label, _ in columns], row_order=row_order, leading_headers=["Spec"] if show_spec else [], older=groups[first_row - 1] if first_row else None, )
[docs] class EnvelopeBound(NamedTuple): """An obsolete bound of the support envelopes of several axes. The release bound is the oldest release every envelope keeps. A version bound is the oldest version that a range in scope supports, which can be newer than the oldest column of the matrix of its axis (see {func}`envelope_bounds`). """ subject: str """What the bound limits: the package releases, or the versions of an axis.""" version: str """The oldest release in scope, or the oldest version a range in scope supports.""" date: str """The ISO release date of that release or version, empty when unknown."""
[docs] def envelope_bounds( project_root: Path, package: str, axes: Iterable[str], *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, require_releases: bool = False, ) -> list[EnvelopeBound]: """Return the obsolete bounds of the support envelopes of `axes`. Each axis keeps its own envelope, as its matrix does (see {func}`_envelope`). A release is in scope when every envelope that leaves out releases keeps it, so the release bound is the newest of their first releases. Each version bound is then the oldest version a range in scope supports, read over those ranges alone: it can be newer than the oldest version of the axis' matrix, whose older rows support older versions. A release is dated by its tag, a Python by its final release (see {data}`PYTHON_RELEASE_DATES`), and a dependency release by PyPI. :param package: the documented package, named by the release bound. :param axes: `python`, or the names of tracked distributions. An axis the history never declares is skipped. :param require_releases: passed to {func}`_pypi_releases`. :return: the release bound first, when an envelope leaves out releases, then one version bound per axis, in the order of `axes`. """ envelopes = [] for axis in axes: if axis == "python": grid = _python_grid( project_root, tag_pattern=tag_pattern, tags_sort=tags_sort ) subject = "Python versions" else: grid = _dependency_grid( project_root, axis, tag_pattern=tag_pattern, tags_sort=tags_sort, require_releases=require_releases, ) subject = f"`{axis}` versions" if grid is not None: envelopes.append((subject, grid, *_envelope(grid.cells))) release = max( (grid.groups[first_row] for _, grid, first_row, _ in envelopes if first_row), key=lambda group: _version_sort_key(group.first_tag), default=None, ) bounds = [] if release is not None: bounds.append( EnvelopeBound( f"`{package}` releases", release.first_tag.removeprefix("v"), release.first_date, ) ) for subject, grid, first_row, first_column in envelopes: in_scope = [ row for row in range(first_row, len(grid.groups)) if release is None or _version_sort_key(grid.groups[row].last_tag) >= _version_sort_key(release.first_tag) ] column = min( _supported_columns(grid.cells[row] for row in in_scope), default=first_column, ) bounds.append(EnvelopeBound(subject, grid.versions[column], grid.dates[column])) return bounds
[docs] def envelope_list( project_root: Path, package: str, axes: Iterable[str], *, tag_pattern: str = DEFAULT_TAG_PATTERN, tags_sort: str = DEFAULT_TAGS_SORT, require_releases: bool = False, ) -> str: """Render the obsolete bounds of the envelopes of `axes` as a Markdown list. One item per bound of {func}`envelope_bounds`, which takes every parameter. An item names what the bound limits and the oldest version in scope, then the release date of that version when known. :return: the list, or the empty string when no axis has any data. """ items = [] for bound in envelope_bounds( project_root, package, axes, tag_pattern=tag_pattern, tags_sort=tags_sort, require_releases=require_releases, ): date = f" ({bound.date})" if bound.date else "" items.append(f"- {bound.subject} before `{bound.version}`{date}") return "\n".join(items)
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(".")`. Outside a repository the walk falls # back on `base_dir` itself: the git reads that follow fail there, and the # callers report it. start = base_dir.resolve() git_root = find_git_root(start) or start 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") except (OSError, ValueError): return "" data = _pyproject_data(text) 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 _resolve_project(options: Mapping[str, str], base_dir: Path) -> tuple[Path, str]: """Resolve the working tree and the package label a block's options name. The tree comes from the `path` option (see {func}`_resolve_root`). The label is the `package` option, else the name the project declares, else the name of its folder. Every block and region resolves its project here, so a region and the matrices beside it name the same one. """ root = _resolve_root(options.get("path"), base_dir) return root, options.get("package") or _project_name(root) or root.name 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, package = _resolve_project(options, base_dir) 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, full_history="full-history" 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, full_history="full-history" in options, 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:` sets the header column label. Defaults to the name declared in the project's `pyproject.toml`, then to the name of its folder. - `:path:` names the git working tree to walk, absolute or relative to the documented project's git root. Defaults to that git root. - `:version-floor:` drops the release rows below this package version. - `:tag-pattern:` is the regex selecting release tags. Defaults to {data}`DEFAULT_TAG_PATTERN`. - `:column-order:` orders the version columns from left to right: `newest-first` (default) or `oldest-first`. - `:row-order:` orders the release rows from top to bottom: `newest-first` (default) or `oldest-first`. - `:python-floor:` drops the Python columns below `X.Y` (python axis). - `:show-spec:` adds a raw-specifier `Spec` column (dependency axis). - `:show-date:` follows each row label with its first release date. - `:full-history:` keeps every release range, not just the support envelope (see {func}`_envelope`). 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, "full-history": 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)
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 _REFRESH_ERRORS = (OSError, ReleasesUnreadable, subprocess.SubprocessError) """The failures that leave a block or a region of the source as it is. An unreadable PyPI is one of them, where live rendering keeps the fallback columns instead: the refresh writes the new 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. """ def _regenerate(axis: str, options: Mapping[str, str], base_dir: Path) -> str: """Render a block, returning `""` on any of the {data}`_REFRESH_ERRORS`.""" try: return _render_block(axis, options, base_dir, require_releases=True) except _REFRESH_ERRORS: 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, close_re: re.Pattern[str], close_marker: str, render: Callable[[list[str], Path], str], ) -> tuple[list[str], int]: """Regenerate one marker region starting at `index`. `render` receives the whitespace-separated arguments of the start marker and `base_dir`, and returns the new body, or `""` to keep the region as it is. The start marker is kept verbatim; the body between the markers is replaced, blank-line padded so `mdformat` does not ping-pong on the surrounding HTML comments. The search for the closing marker, `close_re`, skips fence spans, so a closing marker shown inside a code example cannot close a live region. Kept verbatim when unterminated, argument-less, or on failure. """ indent = match.group("indent") tokens = (match.group("args") or "").split() if not tokens: return [lines[index]], index + 1 close = None for probe, span in walk_fences(lines, spans, index + 1): if span is not None: continue close_match = close_re.match(lines[probe]) if close_match and close_match.group("indent") == indent: close = probe break if close is None: return [lines[index]], index + 1 body = render(tokens, base_dir) if not body: return lines[index : close + 1], close + 1 out = [lines[index], ""] out.extend(f"{indent}{row}" if row else row for row in body.splitlines()) out.extend(["", f"{indent}{close_marker}"]) return out, close + 1 def _refresh_matrix(tokens: list[str], base_dir: Path) -> str: """Render a `<!-- matrix <axis> [opts] -->` region from its arguments.""" return _regenerate(tokens[0], _parse_marker_options(tokens[1:]), base_dir) def _refresh_envelope(tokens: list[str], base_dir: Path) -> str: """Render a `<!-- envelope <axis>... [key=value ...] -->` region. Its bare arguments name the axes, in order. Its `key=value` pairs are the `package`, `path` and `tag-pattern` options of a matrix block, read the same way (see {func}`_resolve_project`). Returns `""` on any of the {data}`_REFRESH_ERRORS`, like {func}`_regenerate`. """ axes = [token for token in tokens if "=" not in token] if not axes: return "" options = _parse_marker_options(token for token in tokens if "=" in token) try: root, package = _resolve_project(options, base_dir) return envelope_list( root, package, axes, tag_pattern=options.get("tag-pattern") or DEFAULT_TAG_PATTERN, require_releases=True, ) except _REFRESH_ERRORS: return ""
[docs] 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. Every `<!-- envelope <axis>... -->` region gets its list of obsolete bounds regenerated too (see {func}`envelope_list`). 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.split("\n") spans = fence_spans(lines) def refresh(index: int, span: FenceSpan | None) -> tuple[list[str], int] | None: if span is not None: # An unterminated fence keeps the tail untouched. fence_match = _FENCE_OPEN_RE.match(lines[index]) if span.close is None or not fence_match: return None block = _refresh_fence_block( fence_match, lines, index, span.close, base_dir ) return block, span.end for open_re, close_re, close_marker, render in ( (_MARKER_OPEN_RE, _MARKER_CLOSE_RE, _MARKER_CLOSE, _refresh_matrix), (_ENVELOPE_OPEN_RE, _ENVELOPE_CLOSE_RE, _ENVELOPE_CLOSE, _refresh_envelope), ): marker_match = open_re.match(lines[index]) if marker_match: return _refresh_marker_region( marker_match, lines, index, spans, base_dir, close_re, close_marker, render, ) return None return "\n".join(rewrite_fences(lines, refresh, spans))
[docs] def update_matrix_blocks(paths: Iterable[Path], *, check: bool = False) -> list[Path]: """Refresh every ``{matrix}`` block and envelope region of Markdown sources. See {func}`click_extra.markdown.blocks.update_blocks` for the walk, write, and `check`-mode contract. Every block of the refresh reads the git history through one memo, see {func}`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, )