# 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.
"""Tests for :mod:`click_extra.sphinx.matrix`.
Split in two halves: the dependency-light matrix-generation logic (git tag
walking, spec parsing, floor filtering) and the ``matrix`` Sphinx directive
that surfaces it. Both live under ``tests/sphinx/`` because importing
:mod:`click_extra.sphinx.matrix` pulls in the Sphinx package; the sibling
``conftest.py`` skips the whole tree when Sphinx or MyST-Parser is absent.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from textwrap import dedent
import pytest
from click.testing import CliRunner
from packaging.version import Version
from click_extra.cli import refresh_directives_cmd
from click_extra.sphinx.matrix import (
FORBIDDEN_CELL,
PYTHON_RELEASE_DATES,
SUPPORTED_CELL,
UNDECLARED_CELL,
DependencyMatrixGroup,
PythonMatrixGroup,
ReleasesUnreadable,
_column_candidates,
_dependency_cell,
_dependency_columns,
_envelope,
_extract_requirement,
_latest_locked_version,
_newest_major,
_pypi_releases,
_python_cell,
_range_labels,
_render_block,
_resolve_root,
_spec_floor,
_spec_identity,
_to_specifier_set,
_walk_tags,
dependency_matrix_groups,
dependency_matrix_table,
envelope_list,
parse_python_spec,
python_matrix_groups,
python_matrix_table,
python_versions_released_by,
update_matrix_blocks,
)
pytestmark = pytest.mark.skipif(
shutil.which("git") is None,
reason="matrix generation walks git tags, so the whole module needs a git binary",
)
"""Skip the module when git is missing, instead of failing every test.
The sibling ``conftest.py`` skips the tree when Sphinx or MyST-Parser is absent.
Git is the third thing this module needs, and the only one that is a binary
rather than an import, so that guard cannot cover it.
"""
MATRIX_MODULE = "click_extra.sphinx.matrix"
"""Dotted path the PyPI lookup is patched through.
Importing the module object instead would put a `matrix` name in scope, and
ruff would then read every `{matrix}` fence in the fixtures as an f-string.
"""
LABEL_DATE_RE = re.compile(r" \(\d{4}-\d{2}-\d{2}\)")
"""The first-release date a row label carries after its lower bound."""
WIDGET_CORNER = "`proj` ↴ \\\\ `widget` →"
"""Top-left cell of a ``proj`` table tracking ``widget``, backslash escaped."""
[docs]
@pytest.fixture(autouse=True)
def offline_pypi(monkeypatch: pytest.MonkeyPatch) -> None:
"""Anchor every table on ``uv.lock``, with no call to PyPI.
The fixtures track a made-up ``widget`` dependency, and a real project of
that name on PyPI would move their columns. The tests of the PyPI anchor
stub the lookup again with the releases they need.
"""
stub_releases(monkeypatch)
[docs]
@pytest.fixture
def unreadable_pypi(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make every PyPI lookup fail, over the read ``offline_pypi`` stubs.
The lookup answers ``required`` the way the real one does: a required read
raises, and any other read returns no release.
"""
def unreachable(dep_name, *, required=False):
if required:
raise ReleasesUnreadable(dep_name)
return {}
monkeypatch.setattr(f"{MATRIX_MODULE}._pypi_releases", unreachable)
[docs]
def git_repo(path: Path) -> Callable[..., None]:
"""Initialize a bare-bones git repository at ``path``.
Returns a ``run(*args)`` helper executing commands in that working tree,
so each fixture only spells out the commits and tags it cares about.
"""
path.mkdir(parents=True, exist_ok=True)
def run(*args: str) -> None:
subprocess.run(args, cwd=path, check=True, capture_output=True)
run("git", "init", "--initial-branch=main", "--quiet")
run("git", "config", "user.email", "test@example.com")
run("git", "config", "user.name", "Test")
run("git", "config", "commit.gpgsign", "false")
return run
[docs]
def dated_tag(repo: Path, tag: str, date: str) -> None:
"""Commit the working tree of ``repo`` on ``date`` and tag it ``tag``.
The dates decide when a range was superseded, which the undeclared cells
of both axes read.
"""
stamp = f"{date}T12:00:00+00:00"
env = {**os.environ, "GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
for args in (
("git", "add", "--all"),
("git", "commit", "--allow-empty", "-m", tag, "--quiet"),
("git", "tag", tag),
):
subprocess.run(args, cwd=repo, check=True, capture_output=True, env=env)
[docs]
def declare_python(repo: Path, spec: str, *classifiers: str) -> None:
"""Write a ``pyproject.toml`` declaring ``requires-python`` and classifiers."""
lines = ["[project]", f'requires-python = "{spec}"']
if classifiers:
lines.append("classifiers = [")
lines.extend(f' "Programming Language :: Python :: {c}",' for c in classifiers)
lines.append("]")
(repo / "pyproject.toml").write_text("\n".join(lines) + "\n", encoding="UTF-8")
[docs]
def named_repo(path: Path, pyproject: str | None) -> Path:
"""Tag one release in a git repository at ``path``, then leave ``pyproject``
as its working-tree ``pyproject.toml``, or no such file for ``None``.
The tagged file declares the Python support a matrix renders. The
working-tree file is the one the default package label is read from.
"""
run = git_repo(path)
target = path / "pyproject.toml"
target.write_text(
'[project]\nrequires-python = ">=3.11"\n'
'classifiers = ["Programming Language :: Python :: 3.12"]\n',
encoding="UTF-8",
)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", "v1.0.0", "--quiet")
run("git", "tag", "v1.0.0")
if pyproject is None:
target.unlink()
else:
target.write_text(pyproject, encoding="UTF-8")
return path
[docs]
def table_cells(line: str) -> list[str]:
"""Split one line of a rendered GFM matrix into its stripped cells."""
return [cell.strip() for cell in line.strip().strip("|").split("|")]
[docs]
def tagged_table_rows(table: str) -> dict[str, list[str]]:
"""Parse a rendered GFM matrix into ``{row label: [cells…]}``.
Lets a test assert a whole row at once (spec, then the full ``✅`` / ``❌``
vector) instead of probing the table for loose substrings. The key drops
the first-release date of the label, since the fixtures tag their commits
on the day the test runs.
"""
rows = {}
# Skip the header and its alignment separator.
for line in table.splitlines()[2:]:
cells = table_cells(line)
rows[LABEL_DATE_RE.sub("", cells[0])] = cells[1:]
return rows
[docs]
@pytest.mark.parametrize(
("spec", "expected"),
[
# PEP 440.
(">=3.10", ("3.10", "", set())),
(">= 3.10", ("3.10", "", set())),
(">=3.10,<3.14", ("3.10", "3.14", set())),
(">=3.10, <3.14", ("3.10", "3.14", set())),
(
">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*",
("2.7", "", {"3.0", "3.1", "3.2"}),
),
# setup.py's older non-wildcard syntax.
(">= 2.7, != 3.0, != 3.1, != 3.2", ("2.7", "", {"3.0", "3.1", "3.2"})),
# Poetry caret expands to major-bump ceiling.
("^3.7", ("3.7", "4.0", set())),
("^3.10", ("3.10", "4.0", set())),
# Poetry tilde expands to minor-bump ceiling.
("~3.7", ("3.7", "3.8", set())),
# Empty / whitespace.
("", ("", "", set())),
(" ", ("", "", set())),
],
)
def test_parse_python_spec(spec: str, expected: tuple[str, str, set[str]]) -> None:
assert parse_python_spec(spec) == expected
[docs]
def test_python_versions_released_by_default_table() -> None:
# Python 3.10 released 2021-10-04, 3.11 released 2022-10-24.
assert "3.10" in python_versions_released_by("2022-01-01")
assert "3.11" not in python_versions_released_by("2022-01-01")
assert "3.11" in python_versions_released_by("2023-01-01")
[docs]
def test_python_versions_released_by_sorted_ascending() -> None:
result = python_versions_released_by("2020-01-01")
assert result == sorted(result, key=lambda v: tuple(int(p) for p in v.split(".")))
[docs]
def test_python_versions_released_by_custom_table() -> None:
custom = {"3.99": "2099-01-01", "3.5": "2010-01-01"}
assert python_versions_released_by("2020-01-01", release_dates=custom) == ["3.5"]
assert python_versions_released_by("2099-06-01", release_dates=custom) == [
"3.5",
"3.99",
]
[docs]
def test_python_release_dates_shape() -> None:
"""Every entry must be ``X.Y`` → ISO date string."""
for version, iso_date in PYTHON_RELEASE_DATES.items():
assert version.count(".") == 1
major, minor = version.split(".")
assert major.isdigit()
assert minor.isdigit()
assert len(iso_date) == 10
assert iso_date[4] == "-" and iso_date[7] == "-"
[docs]
@pytest.fixture
def synthetic_repo(tmp_path: Path) -> Path:
"""Build a tiny git repo with two tagged commits declaring different
Python support sets, so ``python_matrix_groups`` sees an evolution.
"""
repo = tmp_path / "repo"
run = git_repo(repo)
# Tag v1.0.0: Poetry-style declaration, no classifiers.
(repo / "pyproject.toml").write_text(
'[tool.poetry.dependencies]\npython = "^3.10"\n',
encoding="UTF-8",
)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", "v1.0.0", "--quiet")
run("git", "tag", "v1.0.0")
# Tag v2.0.0: PEP 621 + classifiers.
(repo / "pyproject.toml").write_text(
'[project]\nrequires-python = ">=3.11"\n'
"classifiers = [\n"
' "Programming Language :: Python :: 3.11",\n'
' "Programming Language :: Python :: 3.12",\n'
"]\n",
encoding="UTF-8",
)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", "v2.0.0", "--quiet")
run("git", "tag", "v2.0.0")
return repo
[docs]
def test_python_matrix_groups_synthetic(synthetic_repo: Path) -> None:
groups = python_matrix_groups(synthetic_repo)
assert len(groups) == 2
# v1.0.0: Poetry ``^3.10`` capped at next-group-start = v2.0.0's date.
# v2.0.0 commits are all on the same day, so the cap barely stretches
# past v1.0.0; the exact set depends on when Python versions had shipped
# relative to that day. The floor is what we can assert deterministically.
assert groups[0].first_tag == "v1.0.0"
assert "3.10" in groups[0].python_versions
# v2.0.0: classifiers drive the set.
assert groups[1].first_tag == "v2.0.0"
assert groups[1].python_versions == ("3.11", "3.12")
[docs]
def test_python_matrix_groups_returns_named_tuple(synthetic_repo: Path) -> None:
groups = python_matrix_groups(synthetic_repo)
assert all(isinstance(g, PythonMatrixGroup) for g in groups)
assert groups[0].first_tag == groups[0][0]
assert groups[0].python_versions == groups[0][3]
[docs]
def test_python_matrix_groups_reads_a_tag_that_is_no_utf8(synthetic_repo: Path) -> None:
"""A tag name that does not decode as UTF-8 leaves the walk going.
The tag goes in `packed-refs`: a loose ref is a file, and APFS refuses a
file name that is no UTF-8.
"""
sha = subprocess.run(
("git", "rev-parse", "HEAD"),
cwd=synthetic_repo,
capture_output=True,
check=True,
).stdout.strip()
packed = synthetic_repo / ".git" / "packed-refs"
assert not packed.exists()
# The `è` of "crème" in Latin-1, a byte UTF-8 cannot decode alone.
packed.write_bytes(sha + b" refs/tags/cr\xe8me\n")
groups = python_matrix_groups(synthetic_repo)
assert [group.first_tag for group in groups] == ["v1.0.0", "v2.0.0"]
[docs]
def test_python_matrix_groups_version_floor(synthetic_repo: Path) -> None:
"""``version_floor`` drops release tags below the bare version."""
# Floor at 2.0.0 keeps only v2.0.0, so the v1.0.0 group disappears.
groups = python_matrix_groups(synthetic_repo, version_floor="2.0.0")
assert len(groups) == 1
assert groups[0].first_tag == "v2.0.0"
assert groups[0].python_versions == ("3.11", "3.12")
# A floor above every tag yields no group at all.
assert python_matrix_groups(synthetic_repo, version_floor="99.0.0") == []
[docs]
def test_python_matrix_groups_ceiling_honored(tmp_path: Path) -> None:
"""A declared ``<X.Y`` ceiling excludes those versions from ``✅``."""
repo = tmp_path / "ceiling"
run = git_repo(repo)
(repo / "pyproject.toml").write_text(
'[project]\nrequires-python = ">=3.10,<3.13"\n',
encoding="UTF-8",
)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", "v1.0.0", "--quiet")
run("git", "tag", "v1.0.0")
groups = python_matrix_groups(repo)
assert len(groups) == 1
assert "3.10" in groups[0].python_versions
assert "3.11" in groups[0].python_versions
assert "3.12" in groups[0].python_versions
# 3.13 is above the explicit ceiling.
assert "3.13" not in groups[0].python_versions
[docs]
def test_python_matrix_groups_keeps_declared_spec(synthetic_repo: Path) -> None:
"""Each group carries its raw ``requires-python`` next to the set it
attests, since the two drive different cells."""
groups = python_matrix_groups(synthetic_repo)
assert [g.spec for g in groups] == ["^3.10", ">=3.11"]
[docs]
def test_python_matrix_groups_respelled_floor_continues_range(tmp_path: Path) -> None:
"""Re-spelling a floor-only ``requires-python`` does not end its range.
The range lasts until ``2.0.0``, after Python 3.11 and 3.12 came out, so its
capped support includes both. Split at the re-spelling, its older half would
end in June 2022, before 3.11, and read 3.10 alone.
"""
repo = tmp_path / "respelled"
git_repo(repo)
for tag, date, spec in (
("v1.0.0", "2022-01-10", ">= 3.10"),
("v1.1.0", "2022-06-10", ">=3.10"),
("v2.0.0", "2023-11-01", ">=3.12"),
):
declare_python(repo, spec)
dated_tag(repo, tag, date)
first, *_ = python_matrix_groups(repo)
assert (first.first_tag, first.last_tag) == ("v1.0.0", "v1.1.0")
assert first.python_versions == ("3.10", "3.11", "3.12")
[docs]
@pytest.mark.parametrize(
("bounds", "expected"),
[
# Groups meeting at a minor boundary keep their wildcards, and the
# date always follows the lower bound.
(
[
("v3.1.0", "v3.1.5", "2026-05-01"),
("v3.0.0", "v3.0.9", "2026-01-15"),
("v1.2.0", "v2.4.1", "2025-03-10"),
],
[
"`3.1.x` (2026-05-01)",
"`3.0.x` (2026-01-15)",
"`1.2.x` (2025-03-10) → `2.4.x`",
],
),
# A full major and a single release carry no upper bound.
(
[("v9.0.0", "v9.3.1", "2026-08-28"), ("v8.1.0", "v8.1.0", "2026-06-22")],
["`9.x` (2026-08-28)", "`8.1.0` (2026-06-22)"],
),
# A minor split across three groups: each bound on a split shows its
# exact version, so no two labels name the same `4.6.x`.
(
[
("v4.6.4", "v4.8.3", "2023-08-23"),
("v4.6.2", "v4.6.3", "2023-07-15"),
("v4.2.0", "v4.6.1", "2023-05-23"),
],
[
"`4.6.4` (2023-08-23) → `4.x`",
"`4.6.2` (2023-07-15) → `4.6.3`",
"`4.2.x` (2023-05-23) → `4.6.1`",
],
),
# A split start also blocks the full-major collapse: `6.x` would claim
# the `6.0.1` release the older group holds.
(
[("v6.0.2", "v6.4.0", "2025-10-08"), ("v5.0.0", "v6.0.1", "2025-05-13")],
["`6.0.2` (2025-10-08) → `6.x`", "`5.0.x` (2025-05-13) → `6.0.1`"],
),
# A bare major reads as that major's start, like the `9` a `>=9` floor
# yields for a dependency column when the release list is empty.
(
[("9", "9.3.5", ""), ("8.8.1", "8.9.5", "")],
["`9.x`", "`8.8.x` → `8.9.x`"],
),
],
)
def test_range_labels(bounds: list[tuple[str, str, str]], expected: list[str]) -> None:
assert _range_labels(bounds) == expected
[docs]
@pytest.mark.parametrize(
("version", "expected"),
[
# Attested by a classifier.
("3.11", SUPPORTED_CELL),
("3.12", SUPPORTED_CELL),
# Below the declared floor: an installer refuses outright.
("3.10", FORBIDDEN_CELL),
("3.9", FORBIDDEN_CELL),
# Above every classifier, but nothing rules it out.
("3.13", UNDECLARED_CELL),
("3.14", UNDECLARED_CELL),
],
)
def test_python_cell(version: str, expected: str) -> None:
group = PythonMatrixGroup(
"v1.0.0", "v1.0.0", "2024-01-01", ("3.11", "3.12"), ">=3.11"
)
assert _python_cell(version, group) == expected
[docs]
@pytest.mark.parametrize(
("spec", "version", "expected"),
[
# A ceiling is exclusive, so the ceiling itself is forbidden…
(">=3.9,<3.12", "3.12", FORBIDDEN_CELL),
(">=3.9,<3.12", "3.13", FORBIDDEN_CELL),
# …and an exclusion clause punches an isolated hole.
(">=3.9,!=3.11", "3.11", FORBIDDEN_CELL),
(">=3.9,!=3.11", "3.13", UNDECLARED_CELL),
# A range declaring no spec at all can forbid nothing.
("", "2.7", UNDECLARED_CELL),
],
)
def test_python_cell_spec_shapes(spec: str, version: str, expected: str) -> None:
group = PythonMatrixGroup("v1.0.0", "v1.0.0", "2024-01-01", ("3.9", "3.10"), spec)
assert _python_cell(version, group) == expected
[docs]
def test_python_matrix_table_three_states(tmp_path: Path) -> None:
"""A release claiming less than its floor allows renders all three cells.
The classifiers stop at 3.11 while ``requires-python`` only rules out
everything under 3.10, so 3.12 is neither supported nor forbidden.
"""
repo = tmp_path / "tristate"
run = git_repo(repo)
(repo / "pyproject.toml").write_text(
'[project]\nrequires-python = ">=3.10"\n'
"classifiers = [\n"
' "Programming Language :: Python :: 3.10",\n'
' "Programming Language :: Python :: 3.11",\n'
"]\n",
encoding="UTF-8",
)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", "v1.0.0", "--quiet")
run("git", "tag", "v1.0.0")
# A later release widens the classifier list, so 3.12 and 3.9 become
# columns the older row has to classify.
(repo / "pyproject.toml").write_text(
'[project]\nrequires-python = ">=3.9"\n'
"classifiers = [\n"
+ "".join(
f' "Programming Language :: Python :: 3.{n}",\n' for n in (9, 10, 11, 12)
)
+ "]\n",
encoding="UTF-8",
)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", "v2.0.0", "--quiet")
run("git", "tag", "v2.0.0")
rows = tagged_table_rows(python_matrix_table(repo, "proj"))
# Columns run 3.12, 3.11, 3.10, 3.9 (newest-first).
assert "".join(rows["`2.0.0`"]) == "✅✅✅✅"
assert "".join(rows["`1.0.0`"]) == f"{UNDECLARED_CELL}✅✅{FORBIDDEN_CELL}"
[docs]
def test_python_matrix_table_synthetic(synthetic_repo: Path) -> None:
table = python_matrix_table(synthetic_repo, "my-project")
# Header row must carry the label in backticks and the version columns.
# The top-left cell names both axes, its backslash escaped for Markdown.
assert table.startswith("| `my-project` ↴ \\\\ Python → |")
# Labels carry no release date unless asked for.
assert not LABEL_DATE_RE.search(table)
assert "`3.11`" in table
assert "`3.12`" in table
# Columns run newest-first so the current support sits in the upper-left.
assert table.index("`3.12`") < table.index("`3.11`")
# The table body contains ✅ / ❌ glyphs.
assert "✅" in table
assert "❌" in table
[docs]
@pytest.mark.parametrize("show_date", [True, False])
def test_python_matrix_table_show_date(synthetic_repo: Path, show_date: bool) -> None:
"""`show_date` follows each row label with its first release date."""
table = python_matrix_table(synthetic_repo, "my-project", show_date=show_date)
dated = re.search(r"^\| `2\.0\.0` \(\d{4}-\d{2}-\d{2}\) ", table, re.MULTILINE)
assert bool(dated) is show_date
[docs]
@pytest.mark.parametrize("column_order", ["newest-first", "oldest-first"])
def test_python_matrix_table_column_order(
synthetic_repo: Path, column_order: str
) -> None:
table = python_matrix_table(synthetic_repo, "my-project", column_order=column_order)
newest_first = table.index("`3.12`") < table.index("`3.11`")
assert newest_first == (column_order == "newest-first")
[docs]
@pytest.mark.parametrize("row_order", ["newest-first", "oldest-first"])
def test_python_matrix_table_row_order(synthetic_repo: Path, row_order: str) -> None:
# Each tag forms its own single-release group, labeled by its bare version.
table = python_matrix_table(synthetic_repo, "my-project", row_order=row_order)
newest_on_top = table.index("`2.0.0`") < table.index("`1.0.0`")
assert newest_on_top == (row_order == "newest-first")
[docs]
def test_python_matrix_table_invalid_order(synthetic_repo: Path) -> None:
with pytest.raises(ValueError, match="column-order"):
python_matrix_table(synthetic_repo, "my-project", column_order="sideways")
with pytest.raises(ValueError, match="row-order"):
python_matrix_table(synthetic_repo, "my-project", row_order="sideways")
[docs]
def test_python_matrix_table_python_floor(synthetic_repo: Path) -> None:
"""``python_floor`` trims the low Python columns from the header."""
table = python_matrix_table(synthetic_repo, "my-project", python_floor="3.12")
assert "`3.12`" in table
# Columns below the floor are gone.
assert "`3.10`" not in table
assert "`3.11`" not in table
[docs]
def test_python_matrix_table_empty(tmp_path: Path) -> None:
"""An empty repo produces the empty string, matching the ``if not
groups: return ""`` early exit."""
repo = tmp_path / "empty"
repo.mkdir()
subprocess.run(
["git", "init", "--initial-branch=main", "--quiet"], cwd=repo, check=True
)
assert python_matrix_table(repo, "proj") == ""
[docs]
@pytest.fixture
def split_floor_repo(tmp_path: Path) -> Path:
"""A repo raising its Python floor in the ``1.0.1`` patch release.
The newest release supports 3.11 and later, and ``1.0.0`` only 3.9, so the
support envelope starts at ``1.0.1``, inside the ``1.0`` minor series.
"""
repo = tmp_path / "split-floor"
git_repo(repo)
for tag, spec, classifiers in (
("v1.0.0", ">=3.9", ("3.9",)),
("v1.0.1", ">=3.11", ("3.11",)),
("v1.1.0", ">=3.11", ("3.11",)),
("v2.0.0", ">=3.11", ("3.11", "3.12")),
):
declare_python(repo, spec, *classifiers)
dated_tag(repo, tag, "2024-01-10")
return repo
[docs]
def test_python_matrix_table_envelope(split_floor_repo: Path) -> None:
"""The ranges below the oldest one supporting the floor or a later Python go.
The oldest columns, which no remaining range supports, go with them.
"""
table = python_matrix_table(split_floor_repo, "apricot")
assert table_header(table)[1:] == ["`3.12`", "`3.11`"]
# The dropped `1.0.0` still splits the `1.0` minor series, so the oldest
# label shows the exact release the envelope starts at.
assert tagged_table_rows(table) == {
"`2.0.0`": ["✅", "✅"],
"`1.0.1` → `1.1.x`": [UNDECLARED_CELL, "✅"],
}
[docs]
def test_python_matrix_table_full_history(split_floor_repo: Path) -> None:
"""The full history keeps every range and every column."""
table = python_matrix_table(split_floor_repo, "apricot", full_history=True)
assert table_header(table)[1:] == ["`3.12`", "`3.11`", "`3.9`"]
assert list(tagged_table_rows(table)) == [
"`2.0.0`",
"`1.0.1` → `1.1.x`",
"`1.0.0`",
]
[docs]
@pytest.mark.parametrize("full_history", [True, False])
def test_matrix_directive_full_history_option(
sphinx_app_myst, split_floor_repo: Path, full_history: bool
) -> None:
"""``:full-history:`` keeps the releases the envelope leaves out."""
option = ":full-history:\n" if full_history else ""
html = sphinx_app_myst.build_document(
f"```{{matrix}} python\n:path: {split_floor_repo}\n{option}```\n"
)
assert html is not None
assert (">1.0.0<" in html) is full_history
[docs]
def test_update_matrix_blocks_full_history_marker(
split_floor_repo: Path, tmp_path: Path
) -> None:
"""The ``full-history`` marker flag keeps the releases the envelope drops."""
doc = tmp_path / "page.md"
for flag, full_history in (("", False), (" full-history", True)):
doc.write_text(
f"<!-- matrix python path={split_floor_repo}{flag} -->\n"
"<!-- matrix-end -->\n",
encoding="UTF-8",
)
assert update_matrix_blocks([doc]) == [doc]
text = doc.read_text(encoding="UTF-8")
assert ("| `1.0.0` " in text) is full_history
assert update_matrix_blocks([doc], check=True) == []
[docs]
def test_matrix_python_directive_renders_table(sphinx_app_myst, synthetic_repo) -> None:
"""``{matrix} python`` renders the generated table as a real ``<table>``."""
content = dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# The GitHub-flavored table is parsed by the host MyST parser into HTML.
assert "<table" in html
assert "my-project" in html
assert "3.11" in html
assert "3.12" in html
[docs]
def test_matrix_python_directive_respects_floors(sphinx_app_myst, synthetic_repo):
"""The hyphenated directive options map to the floor parameters."""
content = dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
:python-floor: 3.12
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<table" in html
# 3.12 column survives the floor; 3.10 / 3.11 header cells are dropped.
assert ">3.12<" in html
assert ">3.10<" not in html
assert ">3.11<" not in html
[docs]
def test_matrix_python_directive_order_options(sphinx_app_myst, synthetic_repo):
"""The `:column-order:` / `:row-order:` options flip both axes."""
content = dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
:column-order: oldest-first
:row-order: oldest-first
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<table" in html
assert html.index(">3.11<") < html.index(">3.12<")
assert html.index("1.0.0") < html.index("2.0.0")
[docs]
def test_matrix_python_directive_renders_embedded_table(sphinx_app_myst) -> None:
"""An embedded table renders as a real ``<table>`` without any git access.
This is the self-updating steady state: the offline updater keeps the
table in the source, and the build renders that copy verbatim.
"""
content = dedent("""
```{matrix} python
:package: demo
| `demo` | `3.11` | `3.12` |
| :------ | :----: | :----: |
| `1.0.x` | ✅ | ❌ |
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<table" in html
assert "demo" in html
assert "✅" in html and "❌" in html
[docs]
def test_update_matrix_blocks_populates_empty_block(synthetic_repo, tmp_path) -> None:
"""The updater fills an empty block, keeping options and surrounding text."""
doc = tmp_path / "page.md"
doc.write_text(
dedent(f"""
# Doc
Intro paragraph.
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
```
Outro paragraph.
"""),
encoding="UTF-8",
)
assert update_matrix_blocks([doc]) == [doc]
text = doc.read_text(encoding="UTF-8")
assert "| `my-project`" in text
assert "✅" in text
# Options preserved verbatim.
assert ":package: my-project" in text
assert f":path: {synthetic_repo}" in text
# Prose around the block is untouched.
assert "Intro paragraph." in text
assert "Outro paragraph." in text
[docs]
def test_update_matrix_blocks_idempotent(synthetic_repo, tmp_path) -> None:
"""A second run over freshly written blocks reports no change."""
doc = tmp_path / "page.md"
doc.write_text(
dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
```
"""),
encoding="UTF-8",
)
assert update_matrix_blocks([doc]) == [doc]
assert update_matrix_blocks([doc]) == []
[docs]
def test_update_matrix_blocks_check_mode(synthetic_repo, tmp_path) -> None:
"""``check=True`` flags a stale block without writing to disk."""
doc = tmp_path / "page.md"
original = dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
```
""")
doc.write_text(original, encoding="UTF-8")
assert update_matrix_blocks([doc], check=True) == [doc]
assert doc.read_text(encoding="UTF-8") == original
[docs]
def test_update_matrix_blocks_leaves_bad_path_untouched(tmp_path) -> None:
"""A block whose git generation fails is left byte-for-byte unchanged."""
doc = tmp_path / "page.md"
original = dedent("""
```{matrix} python
:package: nope
:path: /nonexistent/not-a-repo
```
""")
doc.write_text(original, encoding="UTF-8")
assert update_matrix_blocks([doc]) == []
assert doc.read_text(encoding="UTF-8") == original
[docs]
def test_update_matrix_blocks_keeps_other_line_separators(tmp_path) -> None:
"""Prose holding a line separator other than a newline is copied verbatim.
A Unicode line separator splits a line for `str.splitlines`, not for a
Markdown reader: a refresh writing it back as a newline would rewrite the
page, and report it stale on every check.
"""
doc = tmp_path / "page.md"
original = dedent("""
Apples\u2028and pears.
```{matrix} python
:path: /nonexistent/not-a-repo
```
""")
doc.write_text(original, encoding="UTF-8")
assert update_matrix_blocks([doc], check=True) == []
assert update_matrix_blocks([doc]) == []
assert doc.read_text(encoding="UTF-8") == original
[docs]
def test_update_matrix_blocks_leaves_an_unreadable_pypi_untouched(
synthetic_dep_repo, tmp_path, unreadable_pypi
) -> None:
"""An unreachable PyPI is a generation failure, not a stale block."""
doc = tmp_path / "page.md"
original = dedent(f"""
```{{matrix}} widget
:package: proj
:path: {synthetic_dep_repo}
```
""")
doc.write_text(original, encoding="UTF-8")
assert update_matrix_blocks([doc], check=True) == []
assert update_matrix_blocks([doc]) == []
assert doc.read_text(encoding="UTF-8") == original
# The live directive keeps the fallback the docstrings promise.
assert dependency_matrix_table(synthetic_dep_repo, "proj", "widget")
[docs]
def test_update_matrix_blocks_skips_examples_nested_in_code_block(
synthetic_repo, tmp_path
) -> None:
"""Matrix examples shown inside a longer code fence are never refreshed.
Both forms are documented illustrations here, not live blocks, even though
their generation would succeed: the fence-aware walk copies them verbatim.
"""
doc = tmp_path / "page.md"
documented = dedent(f"""
````{{code-block}} markdown
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
```
````
````{{code-block}} markdown
<!-- matrix python path={synthetic_repo} -->
<!-- matrix-end -->
````
""")
doc.write_text(documented, encoding="UTF-8")
assert update_matrix_blocks([doc]) == []
assert doc.read_text(encoding="UTF-8") == documented
[docs]
def test_refresh_directives_cli(synthetic_repo, tmp_path) -> None:
"""`click-extra refresh-directives` refreshes in place; --check gates CI."""
doc = tmp_path / "page.md"
doc.write_text(
dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
```
"""),
encoding="UTF-8",
)
runner = CliRunner()
# A stale block exits non-zero under --check, without writing.
result = runner.invoke(refresh_directives_cmd, ["--check", str(doc)])
assert result.exit_code == 1
assert "| `my-project`" not in doc.read_text(encoding="UTF-8")
# Write mode refreshes the block and names the file.
result = runner.invoke(refresh_directives_cmd, [str(doc)])
assert result.exit_code == 0
assert "refreshed" in result.output
assert "| `my-project`" in doc.read_text(encoding="UTF-8")
# A freshly refreshed block is clean.
result = runner.invoke(refresh_directives_cmd, ["--check", str(doc)])
assert result.exit_code == 0
[docs]
def test_refresh_directives_cli_without_sphinx(tmp_path, monkeypatch) -> None:
"""The command fails gracefully (not a traceback) when sphinx is absent."""
doc = tmp_path / "page.md"
doc.write_text("```{matrix} python\n:package: x\n```\n", encoding="UTF-8")
# Simulate the optional sphinx extra being uninstalled: a ``None`` entry in
# ``sys.modules`` makes the lazy ``import`` raise ImportError.
monkeypatch.setitem(sys.modules, "click_extra.sphinx.matrix", None)
result = CliRunner().invoke(refresh_directives_cmd, [str(doc)])
assert result.exit_code != 0
assert "sphinx" in result.output.lower()
[docs]
@pytest.mark.parametrize(
("spec", "member", "nonmember"),
[
# Inclusive and exclusive floors.
(">=8.3.1", "8.4.0", "8.3.0"),
(">= 8.3.0", "8.3.0", "8.2.9"),
(">8.1.4", "8.1.5", "8.1.4"),
# Ceilings on their own.
("<=8.2", "8.2", "8.2.1"),
("<8.2", "8.1.9", "8.2"),
# Pins: exact, wildcard, and PEP 440 arbitrary equality.
("==8.1.4", "8.1.4", "8.1.5"),
("==8.1.*", "8.1.9", "8.2.0"),
("===8.1.4", "8.1.4", "8.1.5"),
# Compatible-release caps at the next minor…
("~= 8.1.4", "8.1.9", "8.2.0"),
# …but at the next major when given a single minor.
("~=8.1", "8.9.0", "9.0"),
# Bounded range, with and without the whitespace Poetry-era
# declarations sprinkle around the comma.
(">=8.0,<8.2", "8.1.0", "8.2.0"),
(">= 8.0 , < 8.2", "8.1.0", "8.2.0"),
# An exclusion clause punches a hole in an open floor.
(">=8.0,!=8.1.*", "8.2.0", "8.1.5"),
# Poetry caret caps at the next major, whatever its precision.
("^8", "8.9", "9.0"),
("^8.1", "8.9.0", "9.0.0"),
("^8.1.1", "8.9.0", "8.1.0"),
# Poetry tilde caps at the next minor, whatever its precision.
("~8.1", "8.1.9", "8.2.0"),
("~8.1.4", "8.1.9", "8.2.0"),
# A PEP 440 suffix on a Poetry range stays on its floor and plays no
# part in its ceiling.
("^2.0.0.post1", "2.9.0", "2.0.0"),
("~1.2.3rc1", "1.2.3rc1", "1.3.0"),
# A pre-release floor only matches under prereleases=True, which is
# how every cell is computed.
(">=8.0.0rc1", "8.0.0rc2", "7.9"),
# An epoch outranks every epoch-less version.
(">=1!2.0", "1!2.1", "2.0"),
],
)
def test_to_specifier_set(spec: str, member: str, nonmember: str) -> None:
spec_set = _to_specifier_set(spec)
assert spec_set is not None
assert spec_set.contains(member, prereleases=True)
assert not spec_set.contains(nonmember, prereleases=True)
[docs]
@pytest.mark.parametrize(
"spec",
[
"garbage",
"8.1.4", # A bare version is not a specifier.
">=8.0||<7.0", # npm-style union, not PEP 440.
"^8.1.1.1", # Caret with more precision than the regex allows.
],
)
def test_to_specifier_set_unparsable(spec: str) -> None:
"""An unparsable specifier yields ``None``, which renders as an all-``❌``
row rather than raising out of the Sphinx build."""
assert _to_specifier_set(spec) is None
[docs]
def test_to_specifier_set_empty_matches_everything() -> None:
"""An empty specifier is the universal set, not ``None``.
Unreachable through the matrix itself, since a tag with no declared
requirement is skipped before this point.
"""
spec_set = _to_specifier_set("")
assert spec_set is not None
assert spec_set.contains("1.0", prereleases=True)
[docs]
@pytest.mark.parametrize(
("spec", "other", "expected"),
[
# Two spellings of one range match.
("^2.0", ">=2.0,<3.0.0", True),
(">= 3.0.7", ">=3.0.7", True),
# The caret range reaches 3.1, the compatible-release one stops at 3.0.
("^3.0.1", "~=3.0.5", False),
# An unparsable specifier matches its own text only, spaces aside.
("~> 1.2", "~>1.2", True),
("~> 1.2", "~> 1.3", False),
("~> 1.2", ">=1.2", False),
(">=1.2", "~> 1.2", False),
],
)
def test_spec_identity(spec: str, other: str, expected: bool) -> None:
"""Two specifiers share an identity when they accept the same versions."""
assert (_spec_identity(spec) == _spec_identity(other)) is expected
[docs]
@pytest.mark.parametrize(
("spec", "expected"),
[
(">=8.3.1", "8.3.1"),
(">8.1.4", "8.1.4"),
(">=8.0,!=8.1.*", "8.0"),
("==8.1.4", "8.1.4"),
("===8.1.4", "8.1.4"),
(">=8.0,<8.2", "8.0"),
("~=8.1.4", "8.1.4"),
("^8.1.1", "8.1.1"),
("^2.0.0.post1", "2.0.0.post1"),
("~8.1", "8.1"),
("~8", "8"),
("==8.1.*", "8.1"),
("8.1.*", "8.1"),
# No lower bound at all: a lone ceiling and a bare wildcard name none.
("<8.2", None),
("*", None),
],
)
def test_spec_floor(spec: str, expected: str | None) -> None:
floor = _spec_floor(spec)
assert (str(floor) if floor is not None else None) == expected
[docs]
@pytest.mark.parametrize(
("spec", "version", "released", "expected"),
[
# The specifier rules a release out, whenever it came out.
(">=2.0", "1.0.0", "2020-01-01", FORBIDDEN_CELL),
("<2.0", "3.0.0", "2026-01-01", FORBIDDEN_CELL),
# An unparsable specifier rules out everything.
("not a spec", "1.0.0", "2020-01-01", FORBIDDEN_CELL),
# Out while the range was current, its last day included.
(">=1.0", "1.0.0", "2020-01-01", SUPPORTED_CELL),
(">=1.0", "2.0.0", "2024-06-10", SUPPORTED_CELL),
# Out after the range was superseded: nobody tried it.
(">=1.0", "3.0.0", "2024-06-11", UNDECLARED_CELL),
# The offline fallback carries no date to compare.
(">=1.0", "3.0.0", "", SUPPORTED_CELL),
],
)
def test_dependency_cell(spec: str, version: str, released: str, expected: str) -> None:
cell = _dependency_cell(
_to_specifier_set(spec), Version(version), released, "2024-06-10"
)
assert cell == expected
[docs]
@pytest.mark.parametrize(
("version", "released", "expected"),
[
# A major out after the last release of the range: no release met it.
("2.0.0", "2024-03-01", UNDECLARED_CELL),
("2.0.0", "", UNDECLARED_CELL),
# A later release of a major the range met.
("1.5.0", "2024-03-01", SUPPORTED_CELL),
],
)
def test_dependency_cell_caps_later_majors(
version: str, released: str, expected: str
) -> None:
"""A superseded range never attests a major newer than its cap."""
cell = _dependency_cell(
_to_specifier_set(">=1.0"), Version(version), released, "2024-06-10", 1
)
assert cell == expected
[docs]
def test_newest_major() -> None:
"""The newest major out by a day, and none without a dated release."""
releases = {
Version("1.0.0"): "2023-12-01",
Version("2.0.0"): "2024-05-01",
Version("3.0.0"): "",
}
assert _newest_major(releases, "2024-01-10") == 1
assert _newest_major(releases, "2024-05-01") == 2
assert _newest_major({Version("1.0.0"): ""}, "2024-01-10") is None
[docs]
@pytest.mark.parametrize(
("rows", "shown", "expected"),
[
# Consecutive columns every shown range treats alike share a bin.
(["✅✅❌❌", "❌✅✅✅"], [0, 1], [(0, 0), (1, 1), (2, 3)]),
# A range outside the envelope tells no column apart.
(["✅✅❌❌", "❌✅✅✅"], [1], [(0, 0), (1, 3)]),
# An undeclared cell splits a bin like any other state.
(["✅✅––"], [0], [(0, 1), (2, 3)]),
([], [], []),
],
)
def test_dependency_columns(
rows: list[str], shown: list[int], expected: list[tuple[int, int]]
) -> None:
assert _dependency_columns([list(row) for row in rows], shown) == expected
[docs]
@pytest.mark.parametrize(
("rows", "full_history", "expected"),
[
# Every range supports the floor: nothing goes.
(["✅✅❌", "❌✅✅"], False, (0, 0)),
# A range below every range supporting the floor or a later version
# goes, and the columns only it supported go with it.
(["✅❌❌", "❌✅✅"], False, (1, 1)),
# A range between the newest one and the oldest kept one stays,
# whatever it holds.
(["✅✅❌", "❌❌❌", "❌✅✅"], False, (0, 0)),
# A range that dropped the floor for a while, but kept a later version,
# stays.
(["❌❌✅", "❌✅✅"], False, (0, 1)),
# An old column no kept range supports goes, undeclared cells included.
(["–✅", "❌✅"], False, (0, 1)),
# A newest range supporting nothing sets no floor: every row stays.
(["✅❌", "❌❌"], False, (0, 0)),
(["❌❌", "❌–"], False, (0, 0)),
# The full history keeps every row, and drops the oldest columns no
# range supports.
(["✅❌❌", "❌✅✅"], True, (0, 0)),
(["❌✅❌", "❌✅✅"], True, (0, 1)),
],
)
def test_envelope(
rows: list[str], full_history: bool, expected: tuple[int, int]
) -> None:
"""Rows come oldest first, and columns run from the oldest version."""
assert _envelope([list(row) for row in rows], full_history=full_history) == (
expected
)
[docs]
@pytest.mark.parametrize(
("specs", "releases", "locked", "expected"),
[
# The locked version joins the releases PyPI lists.
([">=2.1"], ["2.1.0", "3.0.0"], "2.4.0", ["2.1.0", "2.4.0", "3.0.0"]),
# Without them, the floors of the ranges stand in, lone ceilings aside.
([">=2.1", "~=3.0.5", "<4"], [], "", ["2.1", "3.0.5"]),
# An unreadable locked version is skipped.
([">=2.1"], ["2.1.0"], "not a version", ["2.1.0"]),
# So is a development version, like one locked from a git branch.
([">=2.1"], ["2.1.0"], "2.5.0.dev0", ["2.1.0"]),
],
)
def test_column_candidates(
specs: list[str], releases: list[str], locked: str, expected: list[str]
) -> None:
candidates = _column_candidates(specs, tuple(map(Version, releases)), locked)
assert sorted(map(str, candidates)) == expected
[docs]
@pytest.mark.parametrize(
("dep_name", "expected"),
[
# The lockfile spells a package by its normalized name, whatever the
# spelling the matrix block uses.
("widget-lib", "2.4.0"),
("Widget_Lib", "2.4.0"),
("WIDGET.LIB", "2.4.0"),
# A name the lockfile does not hold, even as a prefix, locks nothing.
("widget", ""),
],
)
def test_latest_locked_version(tmp_path: Path, dep_name: str, expected: str) -> None:
(tmp_path / "uv.lock").write_text(
'[[package]]\nname = "widget-lib"\nversion = "2.4.0"\n', encoding="UTF-8"
)
assert _latest_locked_version(tmp_path, dep_name) == expected
[docs]
def test_latest_locked_version_without_lockfile(tmp_path: Path) -> None:
"""A project with no `uv.lock` locks nothing, rather than failing."""
assert _latest_locked_version(tmp_path, "widget") == ""
[docs]
def stub_releases(
monkeypatch: pytest.MonkeyPatch,
*versions: str,
dates: dict[str, str] | None = None,
) -> None:
"""Make PyPI list ``versions`` as the stable releases of every dependency.
A release is undated unless ``dates`` gives its ISO publication date, so a
stub without dates never produces an undeclared cell.
"""
published = {
Version(version): (dates or {}).get(version, "") for version in versions
}
monkeypatch.setattr(
f"{MATRIX_MODULE}._pypi_releases",
# Takes `required` like the real one: a stub answering every read has
# nothing to refuse, but a mismatched signature would fail the call.
lambda dep_name, *, required=False: published,
)
[docs]
@pytest.fixture
def synthetic_dep_repo(tmp_path: Path) -> Path:
"""A git repo whose ``widget`` dependency floor evolves across two tags,
exercising both minor-grouped and patch-split column derivation.
"""
repo = tmp_path / "deprepo"
run = git_repo(repo)
for tag, spec in (("v1.0.0", ">=1.0"), ("v2.0.0", ">=2.1.3")):
declare_widget(repo, spec)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", tag, "--quiet")
run("git", "tag", tag)
return repo
[docs]
def test_dependency_matrix_groups(synthetic_dep_repo: Path) -> None:
groups = dependency_matrix_groups(synthetic_dep_repo, "widget")
assert all(isinstance(g, DependencyMatrixGroup) for g in groups)
assert [g.spec for g in groups] == [">=1.0", ">=2.1.3"]
[docs]
def test_dependency_matrix_table_columns_and_cells(
synthetic_dep_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
stub_releases(monkeypatch, "1.0.0", "1.4.0", "2.1.0", "2.1.3", "2.2.0")
table = dependency_matrix_table(
synthetic_dep_repo, "proj", "widget", show_spec=True
)
# The >=2.1.3 floor splits the releases in two runs, labeled like rows and
# newest-first, like the Python axis. Each header reads from its newest
# bound, as the whole header row does.
assert table_header(table) == [
WIDGET_CORNER,
"Spec",
"`2.x` ← `2.1.3`",
"`2.1.0` ← `1.0.x`",
]
# The Spec column carries each range's raw specifier.
assert tagged_table_rows(table) == {
"`2.0.0`": ["`>=2.1.3`", "✅", "❌"],
"`1.0.0`": ["`>=1.0`", "✅", "✅"],
}
[docs]
@pytest.fixture
def exotic_spec_repo(tmp_path: Path) -> Path:
"""A repo whose ``widget`` requirement takes a different exotic shape at
every tag, so one render exercises the whole specifier vocabulary.
The caret tag also migrates the declaration from Poetry to PEP 621 and
back, the way a real project's history crosses packaging backends.
"""
repo = tmp_path / "exotic"
run = git_repo(repo)
specs = (
("v1.0.0", ">=1.0,<2.0"), # Bounded range.
("v2.0.0", "~=2.1.4"), # Compatible release.
("v3.0.0", "^3.1"), # Poetry caret.
("v4.0.0", ">=4.0,!=4.1.*"), # Open floor with an exclusion hole.
("v5.0.0", ">=5.2.1"), # Open patch-level floor: splits its minor.
("v6.0.0", ">= 6.0, < 7.0"), # Whitespace around the clauses.
)
for tag, spec in specs:
declare_widget(repo, spec)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", tag, "--quiet")
run("git", "tag", tag)
return repo
[docs]
def test_dependency_matrix_table_exotic_specs(
exotic_spec_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Each specifier shape resolves to the right ``✅`` / ``❌`` vector.
The exclusion hole of ``>=4.0,!=4.1.*`` isolates 4.1.0, and the 2.1.0
release no range accepts keeps its column between two that some do. The
full history keeps the ranges the support envelope would leave out.
"""
stub_releases(
monkeypatch,
*("1.0.0", "1.5.0", "2.1.0", "2.1.4", "2.1.9", "3.1.0", "3.5.0"),
*("4.0.0", "4.1.0", "4.2.0", "5.2.0", "5.2.1", "6.0.0", "7.0.0"),
)
table = dependency_matrix_table(
exotic_spec_repo, "proj", "widget", show_spec=True, full_history=True
)
assert len(table_header(table)) == 12
rows = tagged_table_rows(table)
# Each row keeps its raw specifier (whitespace squeezed out) next to the
# cells it produced, newest column first: 7.0.0, 6.0.0, 5.2.1, 4.2.0 to
# 5.2.0, 4.1.0, 4.0.0, 3.1.x to 3.5.0, 2.1.4 to 2.1.9, 2.1.0, 1.0.0 to 1.5.0.
assert {label: (cells[0], "".join(cells[1:])) for label, cells in rows.items()} == {
"`6.0.0`": ("`>=6.0,<7.0`", "❌✅❌❌❌❌❌❌❌❌"),
"`5.0.0`": ("`>=5.2.1`", "✅✅✅❌❌❌❌❌❌❌"),
"`4.0.0`": ("`>=4.0,!=4.1.*`", "✅✅✅✅❌✅❌❌❌❌"),
"`3.0.0`": ("`^3.1`", "❌❌❌❌❌❌✅❌❌❌"),
"`2.0.0`": ("`~=2.1.4`", "❌❌❌❌❌❌❌✅❌❌"),
"`1.0.0`": ("`>=1.0,<2.0`", "❌❌❌❌❌❌❌❌❌✅"),
}
[docs]
def test_dependency_matrix_table_merges_equivalent_specs(tmp_path: Path) -> None:
"""Consecutive tags differing only in spelling form a single range.
Poetry's ``^2.0`` and PEP 621's ``>=2.0,<3.0.0`` describe the same set, so
they group into one range instead of two identical rows. This is what a
packaging-backend migration looks like in the history.
"""
repo = tmp_path / "equivalent"
run = git_repo(repo)
for tag, spec in (("v1.0.0", "^2.0"), ("v1.1.0", ">=2.0,<3.0.0")):
declare_widget(repo, spec)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", tag, "--quiet")
run("git", "tag", tag)
groups = dependency_matrix_groups(repo, "widget")
assert [(g.first_tag, g.last_tag, g.spec) for g in groups] == [
("v1.0.0", "v1.1.0", "^2.0"),
]
# One row: the label spans both tags (collapsing to the whole major
# series) and the Spec column keeps the oldest spelling.
rows = tagged_table_rows(
dependency_matrix_table(repo, "proj", "widget", show_spec=True)
)
assert list(rows) == ["`1.x`"]
assert rows["`1.x`"][0] == "`^2.0`"
[docs]
@pytest.fixture
def narrowing_floor_repo(tmp_path: Path) -> Path:
"""A repo whose ``widget`` floor narrows across three tags, locked at 4.0.0.
The caret range accepts 3.1 and the compatible-release range does not.
"""
repo = tmp_path / "narrowing"
run = git_repo(repo)
(repo / "uv.lock").write_text(
'name = "widget"\nversion = "4.0.0"\n',
encoding="UTF-8",
)
for tag, spec in (
("v1.0.0", "^3.0.1"),
("v2.0.0", "~=3.0.5"),
("v3.0.0", ">=3.0.7"),
):
declare_widget(repo, spec)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", tag, "--quiet")
run("git", "tag", tag)
return repo
[docs]
@pytest.mark.parametrize(
("show_spec", "expected"),
[
# Each range keeps its own row, so no Spec cell speaks for releases that
# declared another range.
(
True,
{
"`2.0.0`": ["`>=3.0,<5`", "✅"],
"`1.0.0`": ["`>=3.0,<4`", "✅"],
},
),
# Without a Spec column, the two rows reading the same merge.
(False, {"`1.0.x` → `2.x`": ["✅"]}),
],
)
def test_dependency_matrix_table_merge_keeps_distinct_specs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
show_spec: bool,
expected: dict[str, list[str]],
) -> None:
"""Ranges whose cells coincide merge, unless their specs differ.
Both ranges accept every release so far, and differ only above the newest
one. A merged row shows the Spec cell of its oldest range, so merging them
would claim ``<4`` for releases that declared ``<5``.
"""
repo = tmp_path / "ceiling-bump"
run = git_repo(repo)
for tag, spec in (("v1.0.0", ">=3.0,<4"), ("v2.0.0", ">=3.0,<5")):
declare_widget(repo, spec)
run("git", "add", "pyproject.toml")
run("git", "commit", "-m", tag, "--quiet")
run("git", "tag", tag)
stub_releases(monkeypatch, "3.0.0", "3.1.0")
table = dependency_matrix_table(repo, "proj", "widget", show_spec=show_spec)
assert tagged_table_rows(table) == expected
[docs]
def test_dependency_matrix_table_bins_split_ranges(
narrowing_floor_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The releases between two floors tell the ranges apart on their cells."""
stub_releases(monkeypatch, "3.0.0", "3.0.4", "3.0.6", "3.0.7", "3.1.0", "4.0.0")
table = dependency_matrix_table(narrowing_floor_repo, "proj", "widget")
assert table_header(table) == [
WIDGET_CORNER,
"`4.0.0`",
"`3.1.0`",
"`3.0.7`",
"`3.0.6`",
"`3.0.4`",
]
assert tagged_table_rows(table) == {
"`3.0.0`": ["✅", "✅", "✅", "❌", "❌"],
"`2.0.0`": ["❌", "❌", "✅", "✅", "❌"],
"`1.0.0`": ["❌", "✅", "✅", "✅", "✅"],
}
[docs]
@pytest.mark.parametrize(
("spec", "columns", "cells"),
[
# An exact pin isolates the one release it accepts.
("==2.1.4", ["`2.x` ← `2.1.5`", "`2.1.4`"], "❌✅"),
("===2.1.4", ["`2.x` ← `2.1.5`", "`2.1.4`"], "❌✅"),
# A wildcard pin accepts its whole series.
("==2.1.*", ["`2.4.0`", "`2.1.x`"], "❌✅"),
],
)
def test_dependency_matrix_table_pinned_spec(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
spec: str,
columns: list[str],
cells: str,
) -> None:
repo = pinned_widget_repo(tmp_path, f"pinned{abs(hash(spec))}", spec)
stub_releases(monkeypatch, "2.1.0", "2.1.4", "2.1.5", "2.4.0")
table = dependency_matrix_table(repo, "proj", "widget")
assert table_header(table) == [WIDGET_CORNER, *columns]
assert "".join(tagged_table_rows(table)["`1.0.0`"]) == cells
[docs]
def test_dependency_matrix_table_lone_ceiling(tmp_path: Path) -> None:
"""A lone ceiling anchors no column of its own.
Unlike a pin, it has no lower bound to place, so the only column left is
the locked version, which the ceiling genuinely forbids. The all-❌ row is
the honest rendering here, not a missing column.
"""
repo = pinned_widget_repo(tmp_path, "ceiling-only", "<2.1")
table = dependency_matrix_table(repo, "proj", "widget")
assert "".join(tagged_table_rows(table)["`1.0.0`"]) == "❌"
[docs]
def test_dependency_matrix_table_suffixed_poetry_floor(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Poetry caret on a post-release translates like any other caret.
Its floor keeps the suffix, so the release it follows falls out of range,
and its ceiling ignores it, so the later 2.x release stays in.
"""
repo = pinned_widget_repo(tmp_path, "post-release", "^2.0.0.post1")
stub_releases(monkeypatch, "2.0.0", "2.0.0.post1", "2.4.0", "3.0.0")
table = dependency_matrix_table(repo, "proj", "widget")
assert table_header(table) == [WIDGET_CORNER, "`3.0.0`", "`2.4.x` ← `2.0.0.post1`"]
assert "".join(tagged_table_rows(table)["`1.0.0`"]) == "❌✅"
[docs]
def test_dependency_matrix_table_shows_a_release_ahead_of_the_lock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A release newer than the locked one gets a column of its own.
The table shows it before the lockfile adopts it, here as a major no range
accepts yet. The locked version counts even when PyPI does not list it.
"""
repo = pinned_widget_repo(tmp_path, "ahead-of-lock", ">=2.1,<3")
stub_releases(monkeypatch, "2.1.0", "3.0.0")
table = dependency_matrix_table(repo, "proj", "widget")
assert table_header(table) == [WIDGET_CORNER, "`3.0.0`", "`2.4.x` ← `2.1.x`"]
assert "".join(tagged_table_rows(table)["`1.0.0`"]) == "❌✅"
WIDGET_DATES = {
"1.0.0": "2023-12-01",
"2.0.0": "2024-05-01",
# Out on the day `v2.0.0` superseded `v1.0.0`.
"2.1.0": "2024-06-10",
"3.0.0": "2025-01-01",
}
"""Publication dates of the ``widget`` releases the dated repository sees."""
[docs]
@pytest.fixture
def dated_dep_repo(tmp_path: Path) -> Path:
"""A repo raising its ``widget`` floor at each of three dated tags."""
repo = tmp_path / "dated"
git_repo(repo)
for tag, date, spec in (
("v1.0.0", "2024-01-10", ">=1.0"),
("v2.0.0", "2024-06-10", ">=2.0"),
("v3.0.0", "2025-02-01", ">=3.0"),
):
declare_widget(repo, spec)
dated_tag(repo, tag, date)
return repo
[docs]
def test_dependency_matrix_table_undeclared_after_superseded(
dated_dep_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A release out after a range was superseded reads ``–`` for that range,
though its specifier accepts it. So does a release of a major out after the
last release of the range: ``1.0.0`` never met ``widget`` 2.x, and ``2.0.0``
never met ``widget`` 3.0.0."""
stub_releases(monkeypatch, *WIDGET_DATES, dates=WIDGET_DATES)
table = dependency_matrix_table(dated_dep_repo, "proj", "widget", full_history=True)
assert table_header(table)[1:] == ["`3.0.0`", "`2.x`", "`1.0.0`"]
assert tagged_table_rows(table) == {
"`3.0.0`": ["✅", "❌", "❌"],
"`2.0.0`": [UNDECLARED_CELL, "✅", "❌"],
"`1.0.0`": [UNDECLARED_CELL, UNDECLARED_CELL, "✅"],
}
[docs]
def test_dependency_matrix_table_unmet_major_after_last_release(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A major out after the last release of a superseded range reads ``–``.
``widget`` 1.5.0 and 2.0.0 both come out after ``1.1.0``, the last release
of the ``1.x`` range, and before ``2.0.0`` supersedes it. Its open specifier
accepts both, and the range met the 1.x major, so 1.5.0 reads ``✅``. No
release of the range ever met 2.0.0.
"""
repo = tmp_path / "unmet-major"
git_repo(repo)
for tag, date, spec in (
("v1.0.0", "2024-01-10", ">=1.0"),
("v1.1.0", "2024-03-10", ">=1.0"),
("v2.0.0", "2024-09-10", ">=1.5"),
):
declare_widget(repo, spec)
dated_tag(repo, tag, date)
dates = {"1.0.0": "2023-12-01", "1.5.0": "2024-05-01", "2.0.0": "2024-08-01"}
stub_releases(monkeypatch, *dates, dates=dates)
table = dependency_matrix_table(repo, "proj", "widget")
assert table_header(table)[1:] == ["`2.0.0`", "`1.5.0`", "`1.0.0`"]
assert tagged_table_rows(table) == {
"`2.0.0`": ["✅", "✅", "❌"],
"`1.x`": [UNDECLARED_CELL, "✅", "✅"],
}
[docs]
def test_dependency_matrix_table_envelope(
dated_dep_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The envelope stops at the oldest range supporting the floor or later.
A range supports a dated release it accepts only when the release came out
while the range was current, from a major out by its last release. Neither
older range ever met ``widget`` 3.0.0, the floor of the newest range, so both
go, and the columns only they accepted too.
"""
stub_releases(monkeypatch, *WIDGET_DATES, dates=WIDGET_DATES)
table = dependency_matrix_table(dated_dep_repo, "proj", "widget")
assert table_header(table)[1:] == ["`3.0.0`"]
assert tagged_table_rows(table) == {"`3.0.0`": ["✅"]}
ENVELOPE_WIDGET_DATES = {
"1.0.0": "2021-12-01",
"2.0.0": "2023-05-01",
"3.0.0": "2024-12-01",
}
"""Publication dates of the ``widget`` releases the envelope repository sees."""
[docs]
@pytest.fixture
def envelope_repo(tmp_path: Path) -> Path:
"""A repo declaring its Python support and a ``widget`` floor at each tag.
The Python envelope leaves out ``1.0.0``, which supports no Python from 3.11
on. The ``widget`` one keeps every release.
"""
repo = tmp_path / "envelope"
git_repo(repo)
for tag, date, spec, classifiers, widget in (
("v1.0.0", "2022-01-10", ">=3.9", ("3.9", "3.10"), ">=1.0"),
("v2.0.0", "2023-06-10", ">=3.10", ("3.10", "3.11"), ">=2.0"),
("v3.0.0", "2025-01-10", ">=3.11", ("3.11", "3.12", "3.13"), ">=2.0"),
):
declared = "".join(
f' "Programming Language :: Python :: {c}",\n' for c in classifiers
)
(repo / "pyproject.toml").write_text(
f'[project]\nrequires-python = "{spec}"\n'
f'dependencies = ["widget{widget}"]\n'
f"classifiers = [\n{declared}]\n",
encoding="UTF-8",
)
dated_tag(repo, tag, date)
return repo
[docs]
def test_envelope_list_across_axes(
envelope_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The release bound is the strictest one, and each version bound is read
again over the releases left in scope."""
stub_releases(monkeypatch, *ENVELOPE_WIDGET_DATES, dates=ENVELOPE_WIDGET_DATES)
assert envelope_list(envelope_repo, "apricot", ["python", "widget"]) == (
"- `apricot` releases before `2.0.0` (2023-06-10)\n"
"- Python versions before `3.10` (2021-10-04)\n"
# The `widget` matrix alone keeps `1.0.0`, which accepts widget 1.0.0,
# but `1.0.0` is out of scope for Python.
"- `widget` versions before `2.0.0` (2023-05-01)"
)
[docs]
def test_envelope_list_without_cut(synthetic_dep_repo: Path) -> None:
"""With no envelope leaving out a release, the list has no release bound. An
axis the history never declares is skipped, and a version of unknown date
gets no date."""
assert envelope_list(synthetic_dep_repo, "proj", ["python", "widget"]) == (
"- `widget` versions before `1.0`"
)
[docs]
def test_update_matrix_blocks_envelope_region(
envelope_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The refresh fills an envelope region between its markers, and settles."""
stub_releases(monkeypatch, *ENVELOPE_WIDGET_DATES, dates=ENVELOPE_WIDGET_DATES)
doc = tmp_path / "page.md"
marker = f"<!-- envelope python widget path={envelope_repo} package=apricot -->"
doc.write_text(f"{marker}\n<!-- envelope-end -->\n", encoding="UTF-8")
assert update_matrix_blocks([doc]) == [doc]
assert doc.read_text(encoding="UTF-8") == (
f"{marker}\n"
"\n"
"- `apricot` releases before `2.0.0` (2023-06-10)\n"
"- Python versions before `3.10` (2021-10-04)\n"
"- `widget` versions before `2.0.0` (2023-05-01)\n"
"\n"
"<!-- envelope-end -->\n"
)
assert update_matrix_blocks([doc], check=True) == []
[docs]
@pytest.mark.parametrize("args", ["python widget", "package=apricot"])
def test_update_matrix_blocks_leaves_an_envelope_region_untouched(
envelope_repo: Path, tmp_path: Path, unreadable_pypi, args: str
) -> None:
"""An unreadable PyPI, or a region naming no axis, rewrites nothing."""
doc = tmp_path / "page.md"
original = f"<!-- envelope {args} path={envelope_repo} -->\n<!-- envelope-end -->\n"
doc.write_text(original, encoding="UTF-8")
assert update_matrix_blocks([doc]) == []
assert doc.read_text(encoding="UTF-8") == original
[docs]
def test_dependency_matrix_groups_respelled_spec_continues_range(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A re-spelled specifier continues its range.
``widget`` 1.5.0 came out after ``1.1.0`` re-spelled the specifier, while
that range was still current, so it reads ``✅`` for the whole range. Split
at the re-spelling, ``1.0.0`` would look superseded before 1.5.0 came out.
The whole history keeps the range in the table, though it never met the 2.x
major that the newest range floors.
"""
repo = tmp_path / "respelled"
git_repo(repo)
for tag, date, spec in (
("v1.0.0", "2024-01-10", " >= 1.0"),
("v1.1.0", "2024-03-10", ">=1.0"),
("v2.0.0", "2024-09-10", ">=2.0"),
):
declare_widget(repo, spec)
dated_tag(repo, tag, date)
groups = dependency_matrix_groups(repo, "widget")
assert [(g.first_tag, g.last_tag) for g in groups] == [
("v1.0.0", "v1.1.0"),
("v2.0.0", "v2.0.0"),
]
dates = {"1.0.0": "2023-12-01", "1.5.0": "2024-05-01", "2.0.0": "2024-08-01"}
stub_releases(monkeypatch, *dates, dates=dates)
table = dependency_matrix_table(repo, "proj", "widget", full_history=True)
assert tagged_table_rows(table) == {
"`2.0.0`": ["✅", "❌"],
"`1.x`": [UNDECLARED_CELL, "✅"],
}
[docs]
@pytest.mark.parametrize(
("column_order", "expected"),
[
("newest-first", ["`2.x` ← `2.1.3`", "`2.1.0` ← `1.0.x`"]),
("oldest-first", ["`1.0.x` → `2.1.0`", "`2.1.3` → `2.x`"]),
],
)
def test_dependency_matrix_table_headers_read_in_column_order(
synthetic_dep_repo: Path,
monkeypatch: pytest.MonkeyPatch,
column_order: str,
expected: list[str],
) -> None:
"""A header reads from the bound its neighbors continue: the newest one
while the columns run newest first, the oldest one otherwise."""
stub_releases(monkeypatch, "1.0.0", "1.4.0", "2.1.0", "2.1.3", "2.2.0")
table = dependency_matrix_table(
synthetic_dep_repo, "proj", "widget", column_order=column_order
)
assert table_header(table)[1:] == expected
[docs]
@pytest.fixture
def pypi(httpserver, monkeypatch: pytest.MonkeyPatch):
"""Serve PyPI's JSON API from the local test server."""
monkeypatch.setattr(
f"{MATRIX_MODULE}.PYPI_JSON_URL", httpserver.url_for("/pypi/{name}/json")
)
return httpserver
[docs]
def test_pypi_releases(pypi) -> None:
"""The lookup asks for the normalized name and sorts the releases."""
pypi.expect_request("/pypi/widget/json").respond_with_json({
"releases": {
"3.0.0": [{"yanked": False}],
"1.0.0": [{"yanked": False}],
# One file left unyanked keeps the release installable.
"2.0.0": [{"yanked": True}, {"yanked": False}],
}
})
releases = _pypi_releases("Widget")
assert list(releases) == list(map(Version, ("1.0.0", "2.0.0", "3.0.0")))
# The files carry no upload time, so no release gets a date.
assert set(releases.values()) == {""}
[docs]
def test_pypi_releases_dates(pypi) -> None:
"""A release is dated by the first upload among its files not yanked."""
pypi.expect_request("/pypi/widget/json").respond_with_json({
"releases": {
"1.0.0": [
{"yanked": False, "upload_time_iso_8601": "2024-03-02T08:00:00Z"},
{"yanked": False, "upload_time_iso_8601": "2024-03-01T23:59:00Z"},
],
"2.0.0": [
{"yanked": True, "upload_time_iso_8601": "2024-05-01T00:00:00Z"},
{"yanked": False, "upload_time_iso_8601": "2024-06-01T00:00:00Z"},
],
# A file without an upload time leaves its release undated.
"3.0.0": [{"yanked": False}],
}
})
assert _pypi_releases("widget") == {
Version("1.0.0"): "2024-03-01",
Version("2.0.0"): "2024-06-01",
Version("3.0.0"): "",
}
[docs]
@pytest.mark.parametrize(
("tag", "files"),
[
("3.0.0rc1", [{"yanked": False}]),
("3.0.0.dev0", [{"yanked": False}]),
("3.0.0", [{"yanked": True}]),
("3.0.0", []),
("not a version", [{"yanked": False}]),
],
)
def test_pypi_releases_discards(pypi, tag: str, files: list) -> None:
"""Pre-releases, yanked or empty releases and invalid versions are dropped."""
pypi.expect_request("/pypi/widget/json").respond_with_json({
"releases": {"1.0.0": [{"yanked": False}], tag: files}
})
assert _pypi_releases("widget") == {Version("1.0.0"): ""}
[docs]
@pytest.mark.parametrize(
("body", "status"),
[
("Not Found", 404),
("<html>Maintenance</html>", 200),
("{}", 200),
('{"releases": []}', 200),
],
)
def test_pypi_releases_unreadable(pypi, body: str, status: int) -> None:
"""An error or a malformed answer costs the columns, not the table.
A caller that cannot use the fallback columns hears about the failure.
"""
pypi.expect_request("/pypi/widget/json").respond_with_data(body, status=status)
assert _pypi_releases("widget") == {}
with pytest.raises(ReleasesUnreadable, match="widget"):
_pypi_releases("widget", required=True)
# Every example from Poetry's dependency-specification reference, mapping the
# declared range to the `(inclusive floor, exclusive ceiling)` it documents.
# https://python-poetry.org/docs/dependency-specification/
POETRY_RANGES: dict[str, tuple[str, str]] = {
# Caret: bumps the leftmost non-zero component.
"^0": ("0.0.0", "1.0.0"),
"^0.0": ("0.0.0", "0.1.0"),
"^0.0.3": ("0.0.3", "0.0.4"),
"^0.2.3": ("0.2.3", "0.3.0"),
"^1": ("1.0.0", "2.0.0"),
"^1.2": ("1.2.0", "2.0.0"),
"^1.2.3": ("1.2.3", "2.0.0"),
# Tilde: bumps the minor, unless only a major was given.
"~1": ("1.0.0", "2.0.0"),
"~1.2": ("1.2.0", "1.3.0"),
"~1.2.3": ("1.2.3", "1.3.0"),
# Wildcard: pins the series it names.
"1.*": ("1.0.0", "2.0.0"),
"1.2.*": ("1.2.0", "1.3.0"),
}
[docs]
def test_to_specifier_set_poetry_bare_wildcard() -> None:
"""Poetry's bare ``*`` allows any version, so it has no bounds to check."""
spec_set = _to_specifier_set("*")
assert spec_set is not None
assert spec_set.contains("0.0.1", prereleases=True)
assert spec_set.contains("99.0", prereleases=True)
[docs]
@pytest.mark.parametrize("show_date", [True, False])
def test_dependency_matrix_table_show_date(
synthetic_dep_repo: Path, show_date: bool
) -> None:
"""`show_date` dates the rows without changing how they merge."""
table = dependency_matrix_table(
synthetic_dep_repo, "proj", "widget", show_date=show_date
)
assert bool(LABEL_DATE_RE.search(table)) is show_date
undated = dependency_matrix_table(synthetic_dep_repo, "proj", "widget")
assert tagged_table_rows(table) == tagged_table_rows(undated)
[docs]
@pytest.mark.parametrize("column_order", ["newest-first", "oldest-first"])
def test_dependency_matrix_table_column_order(
synthetic_dep_repo: Path, column_order: str
) -> None:
table = dependency_matrix_table(
synthetic_dep_repo, "proj", "widget", column_order=column_order
)
newest_first = table.index("`2.1.3`") < table.index("`1.0`")
assert newest_first == (column_order == "newest-first")
[docs]
@pytest.mark.parametrize("row_order", ["newest-first", "oldest-first"])
def test_dependency_matrix_table_row_order(
synthetic_dep_repo: Path, row_order: str
) -> None:
table = dependency_matrix_table(
synthetic_dep_repo, "proj", "widget", row_order=row_order
)
newest_on_top = table.index("`2.0.0`") < table.index("`1.0.0`")
assert newest_on_top == (row_order == "newest-first")
[docs]
def test_dependency_matrix_table_empty(tmp_path: Path) -> None:
repo = tmp_path / "empty"
repo.mkdir()
subprocess.run(
["git", "init", "--initial-branch=main", "--quiet"], cwd=repo, check=True
)
assert dependency_matrix_table(repo, "proj", "widget") == ""
[docs]
def test_matrix_dependency_directive_renders(sphinx_app_myst, synthetic_dep_repo):
"""``{matrix} <dep>`` renders the dependency matrix as a real ``<table>``."""
content = dedent(f"""
```{{matrix}} widget
:package: proj
:path: {synthetic_dep_repo}
:show-spec:
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<table" in html
assert "Spec" in html
assert "2.1.3" in html
[docs]
def test_update_matrix_blocks_reads_git_twice_per_refresh(
synthetic_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One refresh lists the tags and reads their files once, whatever the blocks.
Two pages carrying three blocks, with floors of their own, share the same
history: the tag listing is one `git for-each-ref` and every tag's files one
`git cat-file --batch`.
"""
calls: list[list[str]] = []
real_run = subprocess.run
def counting_run(args, *more, **kwargs):
calls.append(list(args[:2]))
return real_run(args, *more, **kwargs)
monkeypatch.setattr(subprocess, "run", counting_run)
first = tmp_path / "first.md"
first.write_text(
f"<!-- matrix python path={synthetic_repo} -->\n<!-- matrix-end -->\n\n"
f"<!-- matrix python path={synthetic_repo} version-floor=2.0 -->\n"
"<!-- matrix-end -->\n",
encoding="UTF-8",
)
second = tmp_path / "second.md"
second.write_text(
f"```{{matrix}} python\n:path: {synthetic_repo}\n```\n", encoding="UTF-8"
)
assert update_matrix_blocks([first, second]) == [first, second]
assert calls == [["git", "for-each-ref"], ["git", "cat-file"]]
# The memo ends with the refresh: a later table reads the history again.
assert "`2.0.0`" in python_matrix_table(synthetic_repo, "my-project")
assert calls[2:] == [["git", "for-each-ref"], ["git", "cat-file"]]
[docs]
def test_update_matrix_blocks_marker_order_options(synthetic_repo, tmp_path) -> None:
"""`column-order` / `row-order` marker options flip the refreshed table."""
doc = tmp_path / "page.md"
doc.write_text(
f"<!-- matrix python package=my-project path={synthetic_repo} "
"column-order=oldest-first row-order=oldest-first -->\n"
"<!-- matrix-end -->\n",
encoding="UTF-8",
)
assert update_matrix_blocks([doc]) == [doc]
text = doc.read_text(encoding="UTF-8")
assert text.index("`3.11`") < text.index("`3.12`")
assert text.index("`1.0.0`") < text.index("`2.0.0`")
[docs]
@pytest.mark.parametrize("flag", [" show-date", ""])
def test_update_matrix_blocks_marker_show_date(
synthetic_repo, tmp_path, flag: str
) -> None:
"""The `show-date` marker flag dates the refreshed rows."""
doc = tmp_path / "page.md"
doc.write_text(
f"<!-- matrix python package=my-project path={synthetic_repo}{flag} -->\n"
"<!-- matrix-end -->\n",
encoding="UTF-8",
)
assert update_matrix_blocks([doc]) == [doc]
dated = LABEL_DATE_RE.search(doc.read_text(encoding="UTF-8"))
assert bool(dated) is bool(flag)
[docs]
def test_matrix_directive_show_date_option(sphinx_app_myst, synthetic_repo) -> None:
"""The `:show-date:` directive option dates the rendered rows."""
content = dedent(f"""
```{{matrix}} python
:package: my-project
:path: {synthetic_repo}
:show-date:
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert re.search(r"\(\d{4}-\d{2}-\d{2}\)", html)
[docs]
def test_resolve_root_relative_base_dir(synthetic_repo, monkeypatch) -> None:
"""A relative ``base_dir`` still resolves to the real repo folder, so the
default package label is never the empty ``Path(".").name``."""
monkeypatch.chdir(synthetic_repo)
root = _resolve_root(None, Path("."))
assert root == synthetic_repo.resolve()
assert root.name == synthetic_repo.name != ""
[docs]
@pytest.mark.parametrize(
("pyproject", "options", "expected"),
[
# The PEP 621 name.
('[project]\nname = "apricot"\n', {}, "apricot"),
# The Poetry name.
('[tool.poetry]\nname = "apricot"\n', {}, "apricot"),
# The PEP 621 table is read before the Poetry one.
(
'[project]\nname = "apricot"\n\n[tool.poetry]\nname = "banana"\n',
{},
"apricot",
),
# The option is read before any declared name.
('[project]\nname = "apricot"\n', {"package": "cherry"}, "cherry"),
# No name to read: the folder names the package.
('[project]\nrequires-python = ">=3.11"\n', {}, "orchard"),
('[project]\nname = ""\n', {}, "orchard"),
('project = "apricot"\n', {}, "orchard"),
("[project\n", {}, "orchard"),
(None, {}, "orchard"),
],
)
def test_render_block_default_package_label(
tmp_path: Path, pyproject: str | None, options: dict[str, str], expected: str
) -> None:
"""Without a ``package`` option, the label is the name the project declares
in its ``pyproject.toml``. The folder name is the fallback."""
repo = named_repo(tmp_path / "orchard", pyproject)
table = _render_block("python", options, repo)
assert table_header(table)[0].startswith(f"`{expected}` ")
[docs]
def test_update_matrix_blocks_label_ignores_checkout_name(tmp_path: Path) -> None:
"""A copy of a project under another folder name agrees with the table
refreshed in the first checkout, so its check reports nothing stale."""
repo = named_repo(tmp_path / "orchard", '[project]\nname = "apricot"\n')
doc = repo / "docs" / "install.md"
doc.parent.mkdir()
doc.write_text("<!-- matrix python -->\n<!-- matrix-end -->\n", encoding="UTF-8")
assert update_matrix_blocks([doc]) == [doc]
assert "| `apricot` " in doc.read_text(encoding="UTF-8")
copy = shutil.copytree(repo, tmp_path / "grove")
assert update_matrix_blocks([copy / "docs" / "install.md"], check=True) == []
[docs]
def test_render_block_raises_without_git(tmp_path, monkeypatch) -> None:
"""With git absent from PATH, generation raises an error the callers catch."""
monkeypatch.setenv("PATH", "")
with pytest.raises((OSError, subprocess.SubprocessError)):
_render_block("python", {"path": str(tmp_path)}, tmp_path)
[docs]
def test_update_matrix_blocks_preserves_table_without_git(tmp_path, monkeypatch):
"""Refreshing is non-destructive when git is unavailable: an embedded table
stays put rather than being wiped by a failed regeneration."""
doc = tmp_path / "page.md"
doc.write_text(
"```{matrix} python\n"
":package: p\n\n"
"| `p` | `3.14` |\n"
"| :------ | :----: |\n"
"| `1.0.0` | ✅ |\n"
"```\n",
encoding="UTF-8",
)
monkeypatch.setenv("PATH", "")
assert update_matrix_blocks([doc]) == []
assert "| `p`" in doc.read_text(encoding="UTF-8")
assert "✅" in doc.read_text(encoding="UTF-8")
[docs]
def test_matrix_directive_renders_embedded_without_git(sphinx_app_myst, monkeypatch):
"""A populated block renders at build time with no git on PATH: the
shallow-clone / no-git CI case the embedded copy is designed for."""
monkeypatch.setenv("PATH", "")
content = dedent("""
```{matrix} python
:package: demo
| `demo` | `3.14` |
| :------ | :----: |
| `1.0.x` | ✅ |
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<table" in html
assert "demo" in html