# 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 `repomatic.metadata.core`: the CI metadata singleton and its dump."""
from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
import pytest
from click_extra import Context
from click_extra.testing import CliRunner
from extra_platforms import is_windows
from repomatic.cli.main import (
JOB_COUNT_MARK,
TEST_MATRIX_STATE_DISPLAY,
UNIVERSAL_AXIS_KEYS,
MatrixAxis,
flat_matrix_table,
format_matrix_cell,
matrix_axis_keys,
repomatic,
)
from repomatic.config import (
Config,
)
from repomatic.github.actions import NULL_SHA
from repomatic.github.matrix import (
OS_AXIS,
PIVOT_CELL_SEPARATOR,
PYTHON_VERSION_AXIS,
)
from repomatic.matrix_axes import (
PRERELEASE_LABEL_SUFFIX,
SINGLE_RUNNER_PYTHON_VERSIONS,
TEST_PYTHON_FULL,
TEST_RUNNERS_FULL,
TEST_RUNNERS_PR,
UNSTABLE_PYTHON_VERSIONS,
python_version_sort_key,
)
from repomatic.metadata.core import (
_METADATA_KEY_DESCRIPTIONS,
Dialect,
Metadata,
_metadata_config_fields,
all_metadata_keys,
metadata_keys_reference,
)
from repomatic.release.binary import NUITKA_BUILD_TARGETS
from tests.conftest import IN_GIT_CHECKOUT, metadata_from_pyproject
[docs]
def regex(pattern: str) -> re.Pattern:
"""Compile a regex pattern with DOTALL flag."""
return re.compile(pattern, re.DOTALL)
@pytest.fixture(autouse=True)
def _scrub_workflow_event(monkeypatch):
"""Detach every test from the CI run's own workflow event.
In CI the suite inherits the real run's ``GITHUB_EVENT_NAME`` and event
payload, which event-derived properties (``event_type``, ``commit_range``,
``nuitka_matrix``'s canary gating) read, so expectations would differ
between CI and a local run. Deleting the event markers pins every test to
the deterministic local behavior; tests exercising CI context set the
variables back explicitly via ``monkeypatch.setenv``.
"""
for envvar in (
"GITHUB_BASE_REF",
"GITHUB_EVENT_NAME",
"GITHUB_EVENT_PATH",
"GITHUB_HEAD_REF",
):
monkeypatch.delenv(envvar, raising=False)
[docs]
class OptionalList:
"""Matcher for values that can be None or a list matching a pattern.
Used for fields like ``new_commits`` that are ``None`` outside GitHub Actions
but contain commit SHAs when running in CI with event data.
"""
def __init__(self, item_pattern: re.Pattern) -> None:
self.item_pattern = item_pattern
[docs]
class OptionalString:
"""Matcher for GitHub format values that can be empty or space-separated items.
GitHub Actions format converts None to empty string and lists to space-separated
quoted strings. This class matches either case.
"""
def __init__(self, item_pattern: re.Pattern) -> None:
self.item_pattern = item_pattern
[docs]
class AnyBool:
"""Matcher that accepts either True or False.
Used for fields that depend on repository state and can vary between test runs.
"""
[docs]
class AnyBoolString:
"""Matcher that accepts either 'true' or 'false' string.
Used for GitHub format booleans that depend on repository state.
"""
[docs]
class AnyLengthList:
"""Matcher for lists where each item matches a pattern, regardless of length.
Used for matrix fields like ``commit`` where the number of commits can vary
depending on how many are pushed together.
"""
def __init__(self, item_pattern: re.Pattern) -> None:
self.item_pattern = item_pattern
# A marker the expectation walker reads through `isinstance(x, list)`, never a
# list this code mutates, so FURB189's pitfalls do not apply. `UserList` would
# instead make every such check miss it.
[docs]
class StringList(list): # noqa: FURB189
"""A list of plain strings serialized without double-quoting in GitHub Actions format.
Used for metadata fields like ``cli_scripts`` that contain plain string values
(not file paths). File path lists use double-quoted space-separated format because
the actual metadata holds ``Path`` objects; plain string lists do not.
"""
[docs]
class PartialIncludeList:
"""Matcher for include lists where required items must be present.
The nuitka_matrix ``include`` list contains per-commit entries that multiply
when multiple commits are pushed together. This matcher validates that all
required items (build targets, entry point info, state) are present, while
allowing additional commit-specific items.
"""
def __init__(self, required_items: list[dict]) -> None:
self.required_items = required_items
[docs]
class OptionalMatrix:
"""Matcher for values that can be None or a matrix dict with commit data.
Used for fields like ``new_commits_matrix`` that are ``None`` outside GitHub Actions
but contain a matrix dict when running in CI with event data.
"""
[docs]
class OptionalMatrixOrEmptyString:
"""Matcher for GitHub format values that can be empty string or a matrix dict.
GitHub Actions format converts None to empty string. In CI, the value is a dict.
"""
[docs]
class OptionalVersionString:
"""Matcher for values that can be None or a version string.
Used for ``released_version`` which is ``None`` during development but contains
a version string like ``"5.5.0"`` on release branches.
"""
def __init__(self, version_pattern: re.Pattern) -> None:
self.version_pattern = version_pattern
[docs]
class OptionalVersionOrEmptyString:
"""Matcher for GitHub format values that can be empty string or a version string.
GitHub Actions format converts None to empty string. On release branches,
the value is a version string.
"""
def __init__(self, version_pattern: re.Pattern) -> None:
self.version_pattern = version_pattern
[docs]
class AnyReleaseNotes:
"""Matcher for release notes that vary based on development vs release state.
During development, release notes contain a warning that the version is not
released yet. On release branches, the notes contain actual release content.
None is valid when the changelog section is empty (e.g. right after a release).
"""
def __init__(self, dev_pattern: re.Pattern, release_pattern: re.Pattern) -> None:
self.dev_pattern = dev_pattern
self.release_pattern = release_pattern
[docs]
class AnyReleaseNotesOrEmptyString:
"""Matcher for GitHub format release notes.
Can be empty string (when no version), None (empty changelog section),
or match either development or release notes patterns.
"""
def __init__(self, dev_pattern: re.Pattern, release_pattern: re.Pattern) -> None:
self.dev_pattern = dev_pattern
self.release_pattern = release_pattern
def _matches_pattern(item: Any, pattern: dict) -> bool:
"""Check if an item matches a pattern dict.
Returns True if item is a dict with all keys from pattern, and each value
matches (either exact match or regex match for Pattern objects).
"""
if not isinstance(item, dict):
return False
if not set(pattern.keys()).issubset(set(item.keys())):
return False
for key, expected_value in pattern.items():
actual_value = item[key]
if isinstance(expected_value, re.Pattern):
if not isinstance(actual_value, str):
return False
if re.fullmatch(expected_value, actual_value) is None:
return False
elif actual_value != expected_value:
return False
return True
def _windows_path_sort_key(path: str) -> tuple[str, ...]:
"""Match `WindowsPath.__lt__`: parts compared case-folded, not the joined string.
A plain case-folded string sort puts `.claude-plugin` before `.claude`,
since `-` sorts before `\\`. `Path` ordering compares parts instead, so a
directory sorts before a sibling file whose name merely starts with it.
"""
return tuple(part.casefold() for part in PureWindowsPath(path).parts)
[docs]
def iter_checks(metadata: Any, expected: Any, context: Any) -> None:
"""Recursively iterate over expected content and check it matches in metadata."""
if isinstance(expected, re.Pattern):
assert isinstance(metadata, str)
assert re.fullmatch(expected, metadata) is not None, (
f"{metadata!r} does not match {expected.pattern!r} in {context!r}"
)
elif isinstance(expected, OptionalList):
# Allow None or a list of items matching the pattern.
if metadata is None:
return
assert isinstance(metadata, list), (
f"{metadata!r} should be None or a list in {context!r}"
)
for item in metadata:
assert isinstance(item, str), f"{item!r} should be a string in {context!r}"
assert re.fullmatch(expected.item_pattern, item) is not None, (
f"{item!r} does not match {expected.item_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, OptionalString):
# Allow None, empty string, or space-separated items matching the pattern.
# None occurs in github_json format (JSON null); empty string in github format.
if metadata is None:
return
assert isinstance(metadata, str), (
f"{metadata!r} should be a string in {context!r}"
)
if metadata == "":
return
# Parse space-separated items: "sha1" "sha2" -> ["sha1", "sha2"].
for item in metadata.split():
assert re.fullmatch(expected.item_pattern, item) is not None, (
f"{item!r} does not match {expected.item_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, AnyBool):
# Accept either True or False.
assert isinstance(metadata, bool), (
f"{metadata!r} should be a boolean in {context!r}"
)
elif isinstance(expected, AnyBoolString):
# Accept either 'true' or 'false' string.
assert metadata in ("true", "false"), (
f"{metadata!r} should be 'true' or 'false' in {context!r}"
)
elif isinstance(expected, AnyLengthList):
# Accept a list of any length where each item matches the pattern.
assert isinstance(metadata, list), (
f"{metadata!r} should be a list in {context!r}"
)
assert len(metadata) >= 1, f"list should have at least one item in {context!r}"
for item in metadata:
assert isinstance(item, str), f"{item!r} should be a string in {context!r}"
assert re.fullmatch(expected.item_pattern, item) is not None, (
f"{item!r} does not match {expected.item_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, PartialIncludeList):
# Accept a list where all required items are present, allowing extras.
assert isinstance(metadata, list), (
f"{metadata!r} should be a list in {context!r}"
)
assert len(metadata) >= len(expected.required_items), (
f"list should have at least {len(expected.required_items)} items in "
f"{context!r}"
)
# Check each required item has at least one match in metadata.
for required in expected.required_items:
found = False
for item in metadata:
if _matches_pattern(item, required):
found = True
break
assert found, (
f"required item {required!r} not found in metadata list in {context!r}"
)
elif isinstance(expected, OptionalMatrix):
# Allow None or a matrix dict with commit data.
if metadata is None:
return
assert isinstance(metadata, dict), (
f"{metadata!r} should be None or a dict in {context!r}"
)
# Validate basic matrix structure if present.
if "commit" in metadata:
assert isinstance(metadata["commit"], list)
elif isinstance(expected, OptionalMatrixOrEmptyString):
# Allow empty string or a matrix dict.
if metadata == "":
return
assert isinstance(metadata, dict), (
f"{metadata!r} should be '' or a dict in {context!r}"
)
# Validate basic matrix structure if present.
if "commit" in metadata:
assert isinstance(metadata["commit"], list)
elif isinstance(expected, OptionalVersionString):
# Allow None or a version string matching the pattern.
if metadata is None:
return
assert isinstance(metadata, str), (
f"{metadata!r} should be None or a string in {context!r}"
)
assert re.fullmatch(expected.version_pattern, metadata) is not None, (
f"{metadata!r} does not match {expected.version_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, OptionalVersionOrEmptyString):
# Allow empty string or a version string matching the pattern.
if metadata == "":
return
assert isinstance(metadata, str), (
f"{metadata!r} should be '' or a string in {context!r}"
)
assert re.fullmatch(expected.version_pattern, metadata) is not None, (
f"{metadata!r} does not match {expected.version_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, AnyReleaseNotes):
# Allow None (empty changelog section) or release notes matching either
# development or release pattern.
if metadata is None:
return
assert isinstance(metadata, str), (
f"{metadata!r} should be a string in {context!r}"
)
dev_match = re.fullmatch(expected.dev_pattern, metadata) is not None
release_match = re.fullmatch(expected.release_pattern, metadata) is not None
assert dev_match or release_match, (
f"{metadata!r} does not match dev pattern {expected.dev_pattern.pattern!r} "
f"or release pattern {expected.release_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, AnyReleaseNotesOrEmptyString):
# Allow empty string, None (empty changelog section), or release notes
# matching either pattern.
if metadata is None or metadata == "":
return
assert isinstance(metadata, str), (
f"{metadata!r} should be '' or a string in {context!r}"
)
dev_match = re.fullmatch(expected.dev_pattern, metadata) is not None
release_match = re.fullmatch(expected.release_pattern, metadata) is not None
assert dev_match or release_match, (
f"{metadata!r} does not match dev pattern {expected.dev_pattern.pattern!r} "
f"or release pattern {expected.release_pattern.pattern!r} in {context!r}"
)
elif isinstance(expected, dict):
assert isinstance(metadata, dict)
assert set(metadata) == set(expected)
for key, value in expected.items():
# By convention, keys ending with "_files" are path strings so they need to
# be adjusted for Windows.
if key.endswith("_files") and is_windows():
# Path are stored as a list in JSON format.
# Re-sort with case-insensitive key to match Windows Path ordering.
if isinstance(value, list):
value = sorted(
(v.replace("/", "\\") for v in value),
key=_windows_path_sort_key,
)
# Path are space-separated quoted strings in GitHub format.
# Re-sort to match Windows case-insensitive Path ordering.
elif value:
paths = [p.replace("/", "\\") for p in value.split('" "')]
# Strip outer quotes from first/last, sort, re-quote.
paths[0] = paths[0].lstrip('"')
paths[-1] = paths[-1].rstrip('"')
paths.sort(key=_windows_path_sort_key)
value = " ".join(f'"{p}"' for p in paths)
else:
value = value.replace("/", "\\")
iter_checks(metadata[key], value, metadata)
elif isinstance(expected, list):
assert isinstance(metadata, list), (
f"{metadata!r} should be a list in {context!r}"
)
if len(metadata) != len(expected):
# The file inventories are the long lists here, and a bare length
# comparison reduces a tree that drifted from the git index to a
# pair of integers, leaving the reader to hunt the paths down in
# another test's output. Name the difference instead: the
# `_ALL_TRACKED` docstring makes enumerating drift this test's job.
missing = [item for item in expected if item not in metadata]
unexpected = [item for item in metadata if item not in expected]
raise AssertionError(
f"Got {len(metadata)} items, expected {len(expected)}. "
f"Missing: {missing!r}. Unexpected: {unexpected!r}."
)
for produced, wanted in zip(metadata, expected, strict=True):
iter_checks(produced, wanted, metadata)
else:
assert metadata == expected, (
f"{metadata!r} does not match {expected!r} in {context!r}"
)
assert type(metadata) is type(expected)
_ALL_TRACKED = (
tuple(
subprocess.run(
["git", "ls-files"],
capture_output=True,
text=True,
encoding="UTF-8",
check=True,
).stdout.splitlines()
)
if IN_GIT_CHECKOUT
else ()
)
"""Every path in the git index, snapshotted once at collection time.
The independent oracle behind the file-inventory expectations below:
`Metadata.glob_files` walks the disk under gitignore filtering, while this
reads the index, so their agreement proves the two views of the tree match. A
stray untracked file (or a tracked file missing on disk) still fails the
comparison, deliberately: the metadata a workflow consumes must describe the
committed tree, and enumerating drift by name is the test's job.
Empty outside a git checkout, where the `repo_maintenance` tests comparing
against it are skipped.
"""
def _tracked_inventory(
*extensions: str,
subdir: str = "",
names: tuple[str, ...] = (),
exclude: tuple[str, ...] = (),
) -> list[str]:
"""Filter {data}`_ALL_TRACKED` the way one `glob_files` pattern would.
*extensions* mirror a brace set (`**/*.{py,pyi}`), *names* match exact
basenames (`**/pyproject.toml`), *subdir* prefixes the walk
(`.github/workflows/**`), and *exclude* mirrors a `!**/{name}` negation.
Symlinked twins collapse onto their target's spelling exactly like
`glob_files`, which resolves every path and drops duplicates: the
`repomatic/data/` symlinks to workflows, agents and skills never surface
under their link names.
"""
repo_root = Path.cwd()
seen = set()
for line in _ALL_TRACKED:
if subdir and not line.startswith(subdir):
continue
basename = line.rsplit("/", 1)[-1]
if basename in exclude:
continue
suffix = basename.rsplit(".", 1)[-1] if "." in basename else ""
if not (suffix in extensions or basename in names):
continue
resolved = (repo_root / line).resolve()
try:
seen.add(resolved.relative_to(repo_root).as_posix())
except ValueError:
seen.add(line)
# Ordered like `glob_files`, which sorts `Path` objects: that compares parts
# rather than whole strings, so a directory sorts before a sibling file whose
# name merely starts with it (`.claude/` before `.claude-plugin/`, where a
# plain string sort puts `-` ahead of `/`).
return sorted(seen, key=lambda path: PurePosixPath(path).parts)
MARKDOWN_EXTENSIONS = (
"markdown",
"mdown",
"mkdn",
"mdwn",
"mkd",
"md",
"mdtxt",
"mdtext",
"mdx",
)
"""The `markdown_files` brace set, mirroring `Metadata.markdown_files`."""
MARKDOWN_INVENTORY = _tracked_inventory(*MARKDOWN_EXTENSIONS)
"""Every Markdown file tracked in the repository, in `glob_files` order.
Backs both `doc_files` and `markdown_files`. The two metadata keys glob
different extension sets: `doc_files` also takes `.rst` and `.tex`. They
coincide only because the repository currently ships neither, so the day one
lands the two expectations below have to diverge.
"""
expected: dict[str, Any] = {
"is_bot": AnyBool(),
# skip_binary_build depends on the event type and changed files. In CI push events
# where only non-binary-affecting files changed, it is True.
"skip_binary_build": AnyBool(),
# *_changed booleans depend on the current event's commit range.
"yaml_changed": AnyBool(),
"zsh_changed": AnyBool(),
"workflows_changed": AnyBool(),
# new_commits is None when running outside GitHub Actions (no event data).
# In CI, it contains commit SHAs extracted from the push event payload.
"new_commits": OptionalList(regex(r"[a-f0-9]{40}")),
# release_commits is None when there are no release commits in the event.
# It contains SHAs only when a "[changelog] Release vX.Y.Z" commit is present.
"release_commits": OptionalList(regex(r"[a-f0-9]{40}")),
"mailmap_exists": True,
"gitignore_exists": True,
"python_files": _tracked_inventory("py", "pyi", "pyw", "pyx", "ipynb"),
"json_files": _tracked_inventory(
"json",
"jsonc",
names=(".code-workspace",),
exclude=("package-lock.json",),
),
"yaml_files": _tracked_inventory("yaml", "yml"),
"pyproject_files": _tracked_inventory(names=("pyproject.toml",)),
"workflow_files": _tracked_inventory("yaml", "yml", subdir=".github/workflows/"),
"doc_files": MARKDOWN_INVENTORY,
"markdown_files": MARKDOWN_INVENTORY,
"image_files": _tracked_inventory("jpeg", "jpg", "png", "webp", "avif"),
# Empty since the per-skill packaging script retired: this repository
# carries no shell script of its own any more.
"shfmt_files": [],
# Empty: a `.sh` file joins zsh_files only when its shebang names zsh, and
# there is no `.sh` file left here either way.
"zsh_files": [],
"is_python_project": True,
"is_python_package": True,
"binaries_sync": True,
"manpages_script": "repomatic.cli.main:repomatic",
"manpages_asset_name": "",
# Plain strings, not Path objects, so GitHub format joins them unquoted.
"release_assets": StringList(["repomatic-claude-plugin.zip"]),
"nuitka_extras": [],
"package_name": "repomatic",
"cli_scripts": StringList(["repomatic"]),
"project_description": "🏭 Automate repository maintenance, releases, and CI/CD workflows",
"mypy_params": StringList(["--python-version", "3.10"]),
"current_version": regex(r"[0-9\.]+(\.dev[0-9]+)?"),
# released_version is None during development, but contains a version string
# on release branches (e.g., "5.5.0" when a "[changelog] Release v5.5.0"
# commit exists).
"released_version": OptionalVersionString(regex(r"[0-9]+\.[0-9]+\.[0-9]+")),
"is_sphinx": True,
"active_autodoc": True,
"uses_myst": True,
# Release notes are verbatim changelog content.
# Development: starts with the unreleased warning admonition.
# Release: contains actual changelog entries (bullets, admonitions).
"release_notes": AnyReleaseNotes(
dev_pattern=regex(
r"> \[\!WARNING\]\n"
r"> This version is \*\*not released yet\*\* and is under active development\.\n\n"
r".+"
),
release_pattern=regex(
r"(?:(?!> \[\!WARNING\]).+|" # With changelog entries.
# With admonition (e.g. NOTE or CAUTION).
r"> \[\!(NOTE|CAUTION)\]\n>.+)"
),
),
# Same as release_notes, but always includes a pre-computed availability
# admonition with PyPI and GitHub links.
"release_notes_with_admonition": AnyReleaseNotes(
dev_pattern=regex(
r"> \[\!WARNING\]\n"
r"> This version is \*\*not released yet\*\* and is under active development\.\n\n"
r"> \[\!NOTE\]\n"
r"> .+is available on.+\n\n"
r".+"
),
release_pattern=regex(
r"> \[\!NOTE\]\n"
r"> .+is available on.+"
),
),
# new_commits_matrix is None when running outside GitHub Actions.
# In CI, it contains a matrix dict with commit data.
"new_commits_matrix": OptionalMatrix(),
# release_commits_matrix is None when there are no release commits.
# It contains a matrix dict only when a "[changelog] Release vX.Y.Z"
# commit is present.
"release_commits_matrix": OptionalMatrix(),
"build_targets": [
{
"target": "linux-arm64",
"os": "ubuntu-26.04-arm",
"platform_id": "linux",
"arch": "aarch64",
"extension": "bin",
"floor": "2.28",
"container": (
"quay.io/pypa/manylinux_2_28_aarch64@sha256:"
"e7035406e58d96b7407246af1f6514a3cbd753a0025b42b9adfbeadd3b29ba80"
),
},
{
"target": "linux-x64",
"os": "ubuntu-26.04",
"platform_id": "linux",
"arch": "x86_64",
"extension": "bin",
"floor": "2.28",
"container": (
"quay.io/pypa/manylinux_2_28_x86_64@sha256:"
"fdb9a9c223b215604dc7b6f7e8fff4b39bfea5fbaa7777a2e5544a60dfa437f8"
),
},
{
"target": "macos-arm64",
"os": "macos-26",
"platform_id": "macos",
"arch": "aarch64",
"extension": "bin",
"floor": "11.0",
},
{
"target": "macos-x64",
"os": "macos-26-intel",
"platform_id": "macos",
"arch": "x86_64",
"extension": "bin",
"floor": "10.15",
},
{
"target": "windows-arm64",
"os": "windows-11-arm",
"platform_id": "windows",
"arch": "aarch64",
"extension": "exe",
"floor": "11",
},
{
"target": "windows-x64",
"os": "windows-2025",
"platform_id": "windows",
"arch": "x86_64",
"extension": "exe",
"floor": "10",
},
],
"nuitka_matrix": {
"os": [
"ubuntu-26.04-arm",
"ubuntu-26.04",
"macos-26",
"macos-26-intel",
"windows-11-arm",
"windows-2025",
],
"entry_point": ["repomatic"],
"commit": AnyLengthList(regex(r"[a-z0-9]+")),
# The include list contains per-commit entries that multiply with more commits.
# We validate that required fixed items are present (build targets, entry point,
# state) plus at least one commit info and one bin_name entry.
"include": PartialIncludeList([
# Build targets (fixed, one per platform).
{
"target": "linux-arm64",
"os": "ubuntu-26.04-arm",
"platform_id": "linux",
"arch": "aarch64",
"extension": "bin",
"floor": "2.28",
"container": (
"quay.io/pypa/manylinux_2_28_aarch64@sha256:"
"e7035406e58d96b7407246af1f6514a3cbd753a0025b42b9adfbeadd3b29ba80"
),
},
{
"target": "linux-x64",
"os": "ubuntu-26.04",
"platform_id": "linux",
"arch": "x86_64",
"extension": "bin",
"floor": "2.28",
"container": (
"quay.io/pypa/manylinux_2_28_x86_64@sha256:"
"fdb9a9c223b215604dc7b6f7e8fff4b39bfea5fbaa7777a2e5544a60dfa437f8"
),
},
{
"target": "macos-arm64",
"os": "macos-26",
"platform_id": "macos",
"arch": "aarch64",
"extension": "bin",
"floor": "11.0",
},
{
"target": "macos-x64",
"os": "macos-26-intel",
"platform_id": "macos",
"arch": "x86_64",
"extension": "bin",
"floor": "10.15",
},
{
"target": "windows-arm64",
"os": "windows-11-arm",
"platform_id": "windows",
"arch": "aarch64",
"extension": "exe",
"floor": "11",
},
{
"target": "windows-x64",
"os": "windows-2025",
"platform_id": "windows",
"arch": "x86_64",
"extension": "exe",
"floor": "10",
},
# Entry point info (fixed, one per entry point). The value of
# Nuitka's --main-entry-point rides here, per entry point;
# [tool.nuitka] is resolved at build time by `repomatic run nuitka`.
{
"entry_point": "repomatic",
"cli_id": "repomatic",
"module_id": "repomatic.__main__",
"callable_id": "main",
"main_entry_point": "repomatic=repomatic.__main__:main",
},
# State (fixed).
{"state": "stable"},
# At least one commit info entry (varies by commit count).
{
"commit": regex(r"[a-z0-9]+"),
"short_sha": regex(r"[a-z0-9]+"),
"current_version": regex(r"[0-9\.]+(\.dev[0-9]+)?"),
},
# At least one bin_name entry per OS (varies by commit count).
{
"os": "ubuntu-26.04-arm",
"entry_point": "repomatic",
"commit": regex(r"[a-z0-9]+"),
"bin_name": regex(r"repomatic-[\d.]+(\.dev\d+)?-linux-arm64\.bin"),
},
{
"os": "ubuntu-26.04",
"entry_point": "repomatic",
"commit": regex(r"[a-z0-9]+"),
"bin_name": regex(r"repomatic-[\d.]+(\.dev\d+)?-linux-x64\.bin"),
},
{
"os": "macos-26",
"entry_point": "repomatic",
"commit": regex(r"[a-z0-9]+"),
"bin_name": regex(r"repomatic-[\d.]+(\.dev\d+)?-macos-arm64\.bin"),
},
{
"os": "macos-26-intel",
"entry_point": "repomatic",
"commit": regex(r"[a-z0-9]+"),
"bin_name": regex(r"repomatic-[\d.]+(\.dev\d+)?-macos-x64\.bin"),
},
{
"os": "windows-11-arm",
"entry_point": "repomatic",
"commit": regex(r"[a-z0-9]+"),
"bin_name": regex(r"repomatic-[\d.]+(\.dev\d+)?-windows-arm64\.exe"),
},
{
"os": "windows-2025",
"entry_point": "repomatic",
"commit": regex(r"[a-z0-9]+"),
"bin_name": regex(r"repomatic-[\d.]+(\.dev\d+)?-windows-x64\.exe"),
},
]),
},
# Every runner the full matrix can land on, keyed by its `runs-on:` label.
# Sorted by label, which is why this does not follow the `os` axis order
# below.
"runner_arch": {
"macos-26": "aarch64",
"macos-26-intel": "x86_64",
"ubuntu-26.04": "x86_64",
"ubuntu-26.04-arm": "aarch64",
"windows-11-arm": "aarch64",
"windows-2025": "x86_64",
},
# A flat job list instead of axes: the `full-include` row of this
# repository's `[tool.repomatic] test-matrix` makes the full matrix name
# every job. That row comes last: it is the lowest-versions cell.
"test_matrix": {
"include": [
{"state": "stable", "os": "ubuntu-26.04-arm", "python-version": "3.10"},
{"state": "stable", "os": "ubuntu-26.04-arm", "python-version": "3.14"},
{
"state": "unstable",
"os": "ubuntu-26.04-arm",
"python-version": "3.15",
"python-label": "3.15-dev",
},
{"state": "stable", "os": "ubuntu-26.04", "python-version": "3.10"},
{"state": "stable", "os": "ubuntu-26.04", "python-version": "3.14"},
{
"state": "unstable",
"os": "ubuntu-26.04",
"python-version": "3.15",
"python-label": "3.15-dev",
},
{"state": "stable", "os": "macos-26", "python-version": "3.10"},
{"state": "stable", "os": "macos-26", "python-version": "3.14"},
{
"state": "unstable",
"os": "macos-26",
"python-version": "3.15",
"python-label": "3.15-dev",
},
{"state": "stable", "os": "macos-26-intel", "python-version": "3.10"},
{"state": "stable", "os": "macos-26-intel", "python-version": "3.14"},
{
"state": "unstable",
"os": "macos-26-intel",
"python-version": "3.15",
"python-label": "3.15-dev",
},
{"state": "stable", "os": "windows-11-arm", "python-version": "3.14"},
{
"state": "unstable",
"os": "windows-11-arm",
"python-version": "3.15",
"python-label": "3.15-dev",
},
{"state": "stable", "os": "windows-2025", "python-version": "3.10"},
{"state": "stable", "os": "windows-2025", "python-version": "3.14"},
{
"state": "unstable",
"os": "windows-2025",
"python-version": "3.15",
"python-label": "3.15-dev",
},
{"state": "stable", "os": "ubuntu-26.04-arm", "python-version": "3.14t"},
{
"state": "stable",
"os": "ubuntu-26.04-arm",
"python-version": "3.10",
"resolution": "lowest-direct",
},
],
},
"test_matrix_pr": {
"os": [
"ubuntu-26.04-arm",
"macos-26",
"windows-2025",
],
"python-version": [
"3.10",
"3.14",
],
"include": [
{"state": "stable"},
],
},
# Bump allowed values depend on comparing current version vs latest git tag.
# These can be True or False depending on the current development cycle state.
"minor_bump_allowed": AnyBool(),
"major_bump_allowed": AnyBool(),
# `minimum-release-age` default "1 week", rendered as whole days for npm.
"npm_min_release_age_days": 7,
# This repository publishes `.html` pages, the `sphinx.builder` default.
"sphinx_builder": "html",
# This repository dogfoods its own Cloudflare Pages lane on repomatic.net,
# so it declares that target rather than the `github-pages` default, into a
# project named after the repository (the empty override).
"site_cloudflare_project": "",
"site_deploy": "cloudflare-pages",
}
[docs]
def test_dump_factories_match_key_descriptions():
"""The three views of the key inventory must name the same keys.
`_METADATA_KEY_DESCRIPTIONS` (what `--list-keys` and the docs table show),
`Metadata.dump_factories` (what the command emits) and
`all_metadata_keys` (what the command accepts as an argument) are three
hand-maintained lists sitting far apart in the module. A description
without a factory makes `repomatic show-metadata <key>` accept the key and then
emit nothing; a factory without a description drops the key from
`--list-keys` and the rendered docs.
"""
factories = set(Metadata().dump_factories())
config_keys = set(_metadata_config_fields())
assert set(_METADATA_KEY_DESCRIPTIONS) == factories - config_keys
assert all_metadata_keys() == factories
[docs]
@pytest.mark.parametrize("field_name", sorted(_metadata_config_fields()))
def test_config_defaults_render_as_github_values(field_name):
"""Every config field exposed as metadata must survive GitHub encoding.
A nested config dataclass has no GitHub encoding, so adding one without
listing it in `SUBCOMMAND_CONFIG_FIELDS` makes `repomatic show-metadata` raise
`NotImplementedError` on every run. Checking the dataclass defaults rather
than this repository's own values keeps the guard on the declared surface.
"""
value = getattr(Config(), field_name)
assert isinstance(Metadata.format_github_value(value), str)
[docs]
@pytest.mark.repo_maintenance
def test_file_inventories_are_not_vacuous():
"""The git-derived inventories really enumerate the tree.
Guards the `_tracked_inventory` derivations against a filter typo
silently emptying a list: each inventory must contain a signature file
that will exist for the life of the project, and the symlink collapse
must keep targets, never link names.
"""
assert "repomatic/cli/main.py" in expected["python_files"]
assert "tests/test_metadata.py" in expected["python_files"]
assert ".github/workflows/tests.yaml" in expected["workflow_files"]
assert ".github/workflows/autofix.yaml" in expected["yaml_files"]
assert expected["pyproject_files"] == ["pyproject.toml"]
assert ".claude-plugin/marketplace.json" in expected["json_files"]
assert "changelog.md" in MARKDOWN_INVENTORY
assert "readme.md" in MARKDOWN_INVENTORY
assert "docs/assets/icon.png" in expected["image_files"]
# Symlink twins collapse onto their targets, like `glob_files` resolves.
assert "repomatic/data/autofix.yaml" not in expected["yaml_files"]
assert "repomatic/data/agent-grunt-qa.md" not in MARKDOWN_INVENTORY
[docs]
def test_gitignore_is_decoded_as_utf8_whatever_the_locale(tmp_path):
"""A non-ASCII `.gitignore` pattern matches under a locale that is not UTF-8.
py-walk decodes the file with the locale encoding, so the inventory decodes
it itself. The probe runs in an interpreter of its own, because the locale
is fixed when an interpreter starts. It is a file and not a `-c` argument:
Python reads a source file as UTF-8 under any locale, and its command line
with the locale encoding.
"""
tmp_path.joinpath(".gitignore").write_text("café/\n", encoding="UTF-8")
tmp_path.joinpath("probe.py").write_text(
"from repomatic.file_inventory import FileInventory\n"
"print(FileInventory().gitignore_match('café/menu.txt'))\n",
encoding="UTF-8",
)
# The probe must stay out of the coverage measure. An old pytest-cov starts
# coverage in every child interpreter, and this one runs in a directory with
# no `pyproject.toml` to turn branch coverage on: its statement data cannot
# be combined with the branch data of the suite.
env = {
name: value
for name, value in os.environ.items()
if not name.startswith(("COV_CORE_", "COVERAGE_"))
}
env.update(LC_ALL="C", PYTHONCOERCECLOCALE="0", PYTHONUTF8="0")
result = subprocess.run(
[sys.executable, "probe.py"],
capture_output=True,
check=False,
cwd=tmp_path,
encoding="UTF-8",
env=env,
text=True,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "True"
[docs]
@pytest.mark.parametrize(
("versions", "expected"),
[
(["3.15", "3.10"], ["3.10", "3.15"]),
(["3.14t", "3.14"], ["3.14", "3.14t"]),
(["3.15", "3.14t", "3.10", "3.14"], ["3.10", "3.14", "3.14t", "3.15"]),
(["3.10.2", "3.10"], ["3.10", "3.10.2"]),
],
)
def test_python_version_sort_key_orders_by_release(versions, expected):
"""Versions sort numerically, a flavor right after its base version."""
assert sorted(versions, key=python_version_sort_key) == expected
[docs]
def test_show_test_matrix_renders_full_grid():
"""`--grid` renders the full matrix as a labelled grid."""
result = CliRunner().invoke(repomatic, ["show-test-matrix", "--grid"])
assert result.exit_code == 0, result.output
assert "Python" in result.output
assert "ubuntu-26.04-arm" in result.output
# The matrix always carries stable jobs, decorated with an emoji by default.
assert "✅ stable" in result.output
[docs]
@pytest.mark.parametrize("view", ([], ["--grid"]))
def test_show_test_matrix_rows_sorted_by_release(view):
"""Both views order Python rows by release, flavors after their base."""
result = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix", "full", *view]
)
assert result.exit_code == 0, result.output
versions = [row["Python"] for row in json.loads(result.output)]
assert versions == sorted(versions, key=python_version_sort_key)
[docs]
@pytest.mark.parametrize(
("matrix_name", "canonical"),
[("full", TEST_RUNNERS_FULL), ("pr", TEST_RUNNERS_PR)],
)
def test_show_test_matrix_columns_follow_canonical_order(matrix_name, canonical):
"""Grid OS columns follow the runner order of the axis constants."""
result = CliRunner().invoke(
repomatic,
["--table-format", "json", "show-test-matrix", matrix_name, "--grid"],
)
assert result.exit_code == 0, result.output
columns = [key for key in json.loads(result.output)[0] if key != "Python"]
assert set(columns) <= set(canonical), "the grid heads columns with runners"
# Canonical runners keep their declared order; any runner the config added
# trails them in first-seen order.
expected = [runner for runner in canonical if runner in columns]
expected += [col for col in columns if col not in canonical]
assert columns == expected
[docs]
def test_show_test_matrix_pr_is_reduced():
"""The `pr` argument selects a grid with fewer rows and columns than `full`."""
full = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix", "full", "--grid"]
)
pr = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix", "pr", "--grid"]
)
assert full.exit_code == 0, full.output
assert pr.exit_code == 0, pr.output
full_rows = json.loads(full.output)
pr_rows = json.loads(pr.output)
# Fewer Python rows, and fewer columns per row (one key per OS, plus Python).
assert len(pr_rows) < len(full_rows)
assert len(pr_rows[0]) < len(full_rows[0])
[docs]
def test_show_test_matrix_no_emoji_uses_plain_words():
"""`--no-emoji` renders bare state words, deriving the expectation from config."""
plain = CliRunner().invoke(
repomatic,
["--table-format", "json", "show-test-matrix", "--no-emoji", "--grid"],
)
fancy = CliRunner().invoke(repomatic, ["show-test-matrix", "--grid"])
assert plain.exit_code == 0, plain.output
assert fancy.exit_code == 0, fancy.output
for state, label in TEST_MATRIX_STATE_DISPLAY.items():
assert label.removesuffix(f" {state}") not in plain.output
states = {
value.split(f" {JOB_COUNT_MARK}")[0]
for row in json.loads(plain.output)
for key, value in row.items()
if key != "Python"
}
# Each plain state present gains its glyph prefix in the default rendering.
for state in states & set(TEST_MATRIX_STATE_DISPLAY):
assert TEST_MATRIX_STATE_DISPLAY[state] in fancy.output
[docs]
def test_show_test_matrix_pivots_on_the_chosen_axes():
"""--row-axis and --col-axis transpose the grid onto the named job keys."""
default = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix", "--grid"]
)
transposed = CliRunner().invoke(
repomatic,
[
"--table-format",
"json",
"show-test-matrix",
"--grid",
"--row-axis",
OS_AXIS,
"--col-axis",
PYTHON_VERSION_AXIS,
],
)
assert default.exit_code == 0, default.output
assert transposed.exit_code == 0, transposed.output
default_rows = json.loads(default.output)
transposed_rows = json.loads(transposed.output)
# Transposing swaps the two axes: each one's values head the other side.
assert next(iter(transposed_rows[0])) == "OS"
assert {row["Python"] for row in default_rows} == set(transposed_rows[0]) - {"OS"}
assert {row["OS"] for row in transposed_rows} == set(default_rows[0]) - {"Python"}
[docs]
def test_flat_matrix_table_columns_every_key_with_the_state_last():
"""Every key any job carries becomes a column, the state landing last."""
jobs = (
{"fruit": "papaya", "state": "stable"},
{"fruit": "cherry", "city": "paris", "state": "unstable"},
)
headers, rows = flat_matrix_table(jobs, emoji=False)
assert headers == ("fruit", "city", "State")
# The job carrying no city renders empty there rather than shifting its row.
assert rows == (("papaya", "", "stable"), ("cherry", "paris", "unstable"))
[docs]
def test_flat_matrix_table_leads_with_the_keys_it_is_given():
"""Leading keys column first, in order. One no job carries is skipped."""
jobs = ({"fruit": "papaya", "city": "paris", "state": "stable"},)
headers, _ = flat_matrix_table(jobs, ("city", "weather"), emoji=False)
assert headers == ("city", "fruit", "State")
[docs]
def test_show_test_matrix_lists_every_job_by_default():
"""With no --grid, the command renders one row per solved job."""
result = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix"]
)
assert result.exit_code == 0, result.output
rows = json.loads(result.output)
assert len(rows) == len(tuple(Metadata().test_matrix.solve()))
# The two axes the listing sorts on lead; the outcome trails.
assert tuple(rows[0])[:2] == ("Python", "OS")
assert tuple(rows[0])[-1] == "State"
[docs]
def test_show_test_matrix_grid_collapses_the_listing():
"""--grid trades the one-row-per-job listing for the two-axis pivot.
The listing is lossless and the grid is not, so the grid never has more
rows than there are values on its row axis.
"""
listing = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix"]
)
grid = CliRunner().invoke(
repomatic, ["--table-format", "json", "show-test-matrix", "--grid"]
)
assert listing.exit_code == 0, listing.output
assert grid.exit_code == 0, grid.output
listing_rows = json.loads(listing.output)
grid_rows = json.loads(grid.output)
assert len(grid_rows) < len(listing_rows)
# A grid heads its columns with the column axis' values, not its key.
assert set(grid_rows[0]) - {"Python"} <= set(TEST_RUNNERS_FULL)
assert {row["Python"] for row in grid_rows} == {
row["Python"] for row in listing_rows
}
[docs]
def test_show_test_matrix_rejects_an_unknown_axis():
"""An axis no job carries is refused, naming the keys the matrix has."""
result = CliRunner().invoke(repomatic, ["show-test-matrix", "--col-axis", "papaya"])
assert result.exit_code != 0
assert "papaya" in result.output
assert PYTHON_VERSION_AXIS in result.output
[docs]
@pytest.mark.parametrize("axis_option", ("--row-axis", "--col-axis"))
def test_show_test_matrix_help_lists_the_axis_keys(axis_option):
"""Both axis options render the keys they accept as a Choice metavar."""
result = CliRunner().invoke(repomatic, ["show-test-matrix", "--help"])
assert result.exit_code == 0, result.output
metavar = "[" + "|".join(matrix_axis_keys(Metadata().test_matrix)) + "]"
assert f"{axis_option} {metavar}" in result.output
[docs]
def test_show_test_matrix_help_and_validation_read_the_same_keys():
"""The listing offered and the set enforced come from one source.
A hand-kept help string is what drifts: it would keep naming an axis the
matrix dropped, or miss the `[tool.repomatic.test-matrix]` variation this
project just added.
"""
assert MatrixAxis().choices == matrix_axis_keys(Metadata().test_matrix)
[docs]
def test_matrix_axis_falls_back_to_the_universal_keys(monkeypatch):
"""An unreadable matrix leaves the keys every matrix carries, never none.
Empty choices would refuse this option's own default, so a matrix that
failed to compute would take the whole command down with it.
"""
assert set(UNIVERSAL_AXIS_KEYS) <= set(MatrixAxis().choices)
monkeypatch.setattr(MatrixAxis, "resolve", lambda self: (), raising=True)
assert MatrixAxis().choices == UNIVERSAL_AXIS_KEYS
[docs]
def test_matrix_axis_completes_the_keys_it_lists():
"""Shell completion offers the same keys, filtered on what is typed."""
command = repomatic.commands["show-test-matrix"]
param = next(p for p in command.params if "--row-axis" in p.opts)
assert isinstance(param.type, MatrixAxis)
with Context(command) as ctx:
completions = param.type.shell_complete(ctx, param, "python-")
assert [item.value for item in completions] == [
key for key in MatrixAxis().choices if key.startswith("python-")
]
assert completions, "the full matrix always carries a python-version axis"
[docs]
def test_show_test_matrix_rejects_unknown_name():
"""An unknown matrix name is rejected by the Choice argument."""
result = CliRunner().invoke(repomatic, ["show-test-matrix", "bogus"])
assert result.exit_code != 0
[docs]
def test_dev_targets_default():
"""Without config, the canary subset is the fastest Linux arm builder."""
assert Metadata().dev_targets == {"linux-arm64"}
[docs]
def test_dev_targets_discards_unknown(tmp_path, monkeypatch, caplog):
"""Unknown dev target names are warned about and dropped."""
metadata = metadata_from_pyproject(
tmp_path,
monkeypatch,
"""
[project]
name = "papaya"
version = "1.0.0"
[tool.repomatic]
nuitka.dev-targets = ["windows-x64", "himalaya"]
""",
)
with caplog.at_level(logging.WARNING):
assert metadata.dev_targets == {"windows-x64"}
assert "himalaya" in caplog.text
[docs]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_canary_on_push(monkeypatch):
"""An ordinary CI push compiles only the dev-targets canary subset."""
monkeypatch.setattr("repomatic.metadata.env.is_github_ci", lambda: True)
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
matrix = Metadata().nuitka_matrix
assert matrix is not None
assert matrix["os"] == ("ubuntu-26.04-arm",)
# Only the canary target's include data is present.
include_targets = {i["target"] for i in matrix.include if "target" in i}
assert include_targets == {"linux-arm64"}
[docs]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_full_fleet_on_schedule(monkeypatch):
"""The weekly scheduled run rebuilds every target."""
monkeypatch.setattr("repomatic.metadata.env.is_github_ci", lambda: True)
monkeypatch.setenv("GITHUB_EVENT_NAME", "schedule")
matrix = Metadata().nuitka_matrix
assert matrix is not None
assert set(matrix["os"]) == {
target.runner for target in NUITKA_BUILD_TARGETS.values()
}
[docs]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_full_fleet_on_release_push(monkeypatch):
"""A push carrying a release commit rebuilds every target."""
monkeypatch.setattr("repomatic.metadata.env.is_github_ci", lambda: True)
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
release_matrix = Metadata().current_commit_matrix
monkeypatch.setattr(Metadata, "release_commits_matrix", release_matrix)
matrix = Metadata().nuitka_matrix
assert matrix is not None
assert set(matrix["os"]) == {
target.runner for target in NUITKA_BUILD_TARGETS.values()
}
[docs]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_full_fleet_locally():
"""Outside CI (no event), the matrix keeps the full roster."""
matrix = Metadata().nuitka_matrix
assert matrix is not None
assert set(matrix["os"]) == {
target.runner for target in NUITKA_BUILD_TARGETS.values()
}
[docs]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_needs_no_module_file(tmp_path, monkeypatch):
"""An entry point reaches the matrix whatever the layout of its project.
Nuitka imports the module by name, so the matrix never looks for a file. No
`papaya/__main__.py` exists below the working directory, which is what a
src-layout project leaves at the path its module name spells.
"""
pyproject_content = """\
[project]
name = "papaya"
version = "1.0.0"
[project.scripts]
papaya = "papaya.__main__:main"
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
matrix = metadata.nuitka_matrix
assert matrix is not None
entry_points = [entry for entry in matrix.include if "main_entry_point" in entry]
assert entry_points == [
{
"entry_point": "papaya",
"cli_id": "papaya",
"module_id": "papaya.__main__",
"callable_id": "main",
"main_entry_point": "papaya=papaya.__main__:main",
}
]
[docs]
def test_nuitka_matrix_skips_push_without_dev_targets(monkeypatch):
"""An empty dev-targets list disables binary builds on ordinary pushes."""
monkeypatch.setattr("repomatic.metadata.env.is_github_ci", lambda: True)
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
monkeypatch.setattr(Metadata, "dev_targets", set())
assert Metadata().nuitka_matrix is None
[docs]
def test_null_sha_constant():
"""Test that NULL_SHA is a valid 40-character string of zeros.
This constant is used to detect when GitHub sends a null SHA as the "before"
commit when a tag is created (since there is no previous commit).
"""
assert isinstance(NULL_SHA, str)
assert len(NULL_SHA) == 40
assert NULL_SHA == "0" * 40
# Verify it's truthy (important for the fix: we can't just check `if not sha`).
assert bool(NULL_SHA) is True
[docs]
def test_new_commits_degrades_when_git_rejects_checkout(monkeypatch, caplog):
"""A git failure degrades new_commits to None with git's stderr surfaced,
instead of crashing the whole `metadata` command.
Reproduces the compiled-binary self-test running inside a manylinux container
over a checkout git refuses as "dubious ownership".
"""
monkeypatch.setattr(Metadata, "commit_range", ("a" * 40, "b" * 40))
def reject(self, commit_id, **kwargs):
raise subprocess.CalledProcessError(
128,
["git", "rev-list", "--count", "HEAD"],
stderr="fatal: detected dubious ownership in repository",
)
monkeypatch.setattr(Metadata, "git_deepen", reject)
with caplog.at_level(logging.WARNING):
assert Metadata().new_commits is None
assert "dubious ownership" in caplog.text
[docs]
def test_changed_files_surfaces_git_stderr(monkeypatch, caplog):
"""A `git diff` failure degrades changed_files to None and logs git's stderr."""
monkeypatch.setattr(Metadata, "commit_range", ("a" * 40, "b" * 40))
def reject(start, end):
raise subprocess.CalledProcessError(
128,
["git", "diff", "--name-only"],
stderr="fatal: detected dubious ownership in repository",
)
monkeypatch.setattr("repomatic.metadata.git.diff_names", reject)
with caplog.at_level(logging.WARNING):
assert Metadata().changed_files is None
assert "dubious ownership" in caplog.text
[docs]
def test_is_bot_false_by_default(monkeypatch):
"""Test that is_bot is False when not in a bot context."""
# Clear CI env vars that could make is_bot return True when tests run on a
# commit pushed by a bot actor.
monkeypatch.delenv("GITHUB_ACTOR", raising=False)
monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
monkeypatch.delenv("GITHUB_HEAD_REF", raising=False)
metadata = Metadata()
# Outside of bot context, is_bot is False.
assert isinstance(metadata.is_bot, bool)
assert metadata.is_bot is False
[docs]
@pytest.mark.parametrize(
"prop, envvar, value",
[
("event_name", "GITHUB_EVENT_NAME", "push"),
("job_name", "GITHUB_JOB", "sync-labels"),
("ref_name", "GITHUB_REF_NAME", "main"),
("repo_owner", "GITHUB_REPOSITORY_OWNER", "kdeldycke"),
("repo_slug", "GITHUB_REPOSITORY", "kdeldycke/repomatic"),
("run_attempt", "GITHUB_RUN_ATTEMPT", "1"),
("run_id", "GITHUB_RUN_ID", "123456789"),
("run_number", "GITHUB_RUN_NUMBER", "42"),
("server_url", "GITHUB_SERVER_URL", "https://github.com"),
("sha", "GITHUB_SHA", "abc123def456"),
("triggering_actor", "GITHUB_TRIGGERING_ACTOR", "kdeldycke"),
(
"workflow_ref",
"GITHUB_WORKFLOW_REF",
"kdeldycke/repomatic/.github/workflows/autofix.yaml@refs/heads/main",
),
],
)
def test_ci_context_properties(monkeypatch, prop, envvar, value):
"""Test CI context properties read from environment variables."""
monkeypatch.setenv(envvar, value)
metadata = Metadata()
assert getattr(metadata, prop) == value
[docs]
def test_ci_context_defaults(monkeypatch):
"""Test CI context properties return None when env vars are unset."""
for envvar in (
"GITHUB_EVENT_NAME",
"GITHUB_JOB",
"GITHUB_REF_NAME",
"GITHUB_RUN_ATTEMPT",
"GITHUB_RUN_ID",
"GITHUB_RUN_NUMBER",
"GITHUB_SHA",
"GITHUB_TRIGGERING_ACTOR",
"GITHUB_WORKFLOW_REF",
):
monkeypatch.delenv(envvar, raising=False)
metadata = Metadata()
assert metadata.event_name is None
assert metadata.job_name is None
assert metadata.ref_name is None
assert metadata.run_attempt is None
assert metadata.run_id is None
assert metadata.run_number is None
assert metadata.sha is None
assert metadata.triggering_actor is None
assert metadata.workflow_ref is None
[docs]
@pytest.mark.parametrize(
"prop, envvar",
[
("event_name", "GITHUB_EVENT_NAME"),
("job_name", "GITHUB_JOB"),
("ref_name", "GITHUB_REF_NAME"),
("run_attempt", "GITHUB_RUN_ATTEMPT"),
("run_id", "GITHUB_RUN_ID"),
("run_number", "GITHUB_RUN_NUMBER"),
("sha", "GITHUB_SHA"),
("triggering_actor", "GITHUB_TRIGGERING_ACTOR"),
("workflow_ref", "GITHUB_WORKFLOW_REF"),
],
)
def test_ci_context_empty_is_none(monkeypatch, prop, envvar):
"""Test that empty env var values are normalized to None."""
monkeypatch.setenv(envvar, "")
metadata = Metadata()
assert getattr(metadata, prop) is None
[docs]
def test_repo_name_derived_from_slug(monkeypatch):
"""Test that repo_name is derived from repo_slug."""
monkeypatch.setenv("GITHUB_REPOSITORY", "kdeldycke/repomatic")
metadata = Metadata()
assert metadata.repo_name == "repomatic"
[docs]
@pytest.mark.parametrize(
("gh_slug", "remote_slug", "expected"),
[
pytest.param("owner/papaya", "owner/ignored", "owner/papaya", id="gh-cli"),
pytest.param(None, "owner/mango", "owner/mango", id="git-remote"),
pytest.param(None, None, None, id="neither"),
],
)
def test_repo_slug_fallback_chain(monkeypatch, gh_slug, remote_slug, expected):
"""Without `GITHUB_REPOSITORY`, the slug falls back to gh, then the remote.
Both fallbacks are stubbed rather than probed: on a runner with no `gh`
auth the real chain yields `None`, which is what made the original pair of
tests assert nothing at all on CI.
"""
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
def fake_gh(args, **kwargs):
if gh_slug is None:
msg = "gh is not authenticated"
raise RuntimeError(msg)
return f"{gh_slug}\n"
monkeypatch.setattr("repomatic.metadata.env.run_gh_command", fake_gh)
monkeypatch.setattr(
"repomatic.metadata.env.get_repo_slug_from_remote", lambda: remote_slug
)
assert Metadata().repo_slug == expected
[docs]
def test_repo_name_fallback_to_gh_cli(monkeypatch):
"""`repo_name` is the trailing segment of a gh-resolved slug."""
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
monkeypatch.setattr(
"repomatic.metadata.env.run_gh_command", lambda args, **kwargs: "owner/papaya\n"
)
assert Metadata().repo_name == "papaya"
[docs]
@pytest.mark.parametrize(
("repo", "expected"),
[
("kdeldycke/awesome-billing", True),
("kdeldycke/awesome-falsehood", True),
("user/my-awesome-thing", False),
("user/regular-repo", False),
],
)
def test_is_awesome(monkeypatch, repo, expected):
"""Test that is_awesome detects awesome-* repository names."""
monkeypatch.setenv("GITHUB_REPOSITORY", repo)
assert Metadata().is_awesome is expected
[docs]
def test_is_awesome_none_slug(monkeypatch):
"""Test that is_awesome returns False when repo_slug is None."""
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
monkeypatch.setattr(Metadata, "repo_slug", None)
assert Metadata().is_awesome is False
[docs]
def test_repo_owner_fallback_to_slug(monkeypatch):
"""Test that repo_owner falls back to owner from repo_slug."""
monkeypatch.delenv("GITHUB_REPOSITORY_OWNER", raising=False)
monkeypatch.setenv("GITHUB_REPOSITORY", "kdeldycke/repomatic")
metadata = Metadata()
assert metadata.repo_owner == "kdeldycke"
[docs]
def test_repo_url_composed(monkeypatch):
"""Test that repo_url is composed from server_url and repo_slug."""
monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com")
monkeypatch.setenv("GITHUB_REPOSITORY", "kdeldycke/repomatic")
metadata = Metadata()
assert metadata.repo_url == "https://github.com/kdeldycke/repomatic"
[docs]
def test_repo_url_fallback(monkeypatch):
"""`repo_url` composes the default server with a gh-resolved slug."""
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
monkeypatch.setattr(
"repomatic.metadata.env.run_gh_command", lambda args, **kwargs: "owner/papaya\n"
)
assert Metadata().repo_url == "https://github.com/owner/papaya"
[docs]
def test_repo_url_none_without_a_slug(monkeypatch):
"""No slug from any source leaves the URL undefined rather than malformed."""
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
monkeypatch.setattr(Metadata, "repo_slug", None)
assert Metadata().repo_url is None
[docs]
def test_server_url_default(monkeypatch):
"""Test that server_url defaults to https://github.com."""
monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
metadata = Metadata()
assert metadata.server_url == "https://github.com"
[docs]
def test_repomatic_config_defaults(tmp_path, monkeypatch):
"""Test that [tool.repomatic] config properties return sensible defaults."""
metadata = metadata_from_pyproject(
tmp_path,
monkeypatch,
'[project]\nname = "test-project"\nversion = "1.0.0"\n',
)
assert metadata.config.gitignore.location == "./.gitignore"
assert metadata.config.gitignore.extra_categories == []
assert metadata.config.gitignore.extra_content == (
"# Claude Code local files.\n"
".claude/scheduled_tasks.lock\n.claude/settings.local.json\n"
"**/.claude/.cc-writes/\n\n"
"# Cloudflare wrangler local state.\n.wrangler/\n\n"
"# Sphinx linkcheck output.\ndocs/_linkcheck/"
)
assert metadata.config.dependency_graph.output == "./docs/assets/dependencies.mmd"
assert metadata.config.dependency_graph.all_groups is True
assert metadata.config.dependency_graph.all_extras is True
assert metadata.config.dependency_graph.no_groups == []
assert metadata.config.dependency_graph.no_extras == []
assert metadata.config.dependency_graph.level is None
assert metadata.config.dependency_graph.update is True
assert metadata.config.labels.content_rules == {}
assert metadata.config.labels.extra == []
assert metadata.config.labels.extra_files == []
assert metadata.config.labels.file_rules == {}
assert metadata.config.pypi_package_history == []
assert metadata.config.notification_unsubscribe is False
assert metadata.config.awesome_template_sync is True
assert metadata.config.binaries_sync is True
assert metadata.config.bumpversion_sync is True
assert metadata.config.dev_release_sync is True
assert metadata.config.gitignore.sync is True
assert metadata.config.labels.sync is True
assert metadata.config.mailmap_sync is True
assert metadata.config.setup_guide is True
assert metadata.config.uv_lock_sync is True
assert metadata.config.workflow.source_paths is None
assert metadata.config.workflow.sync is True
assert metadata.config.exclude == []
assert metadata.config.include == []
assert metadata.config.test_matrix.exclude == []
assert metadata.config.test_matrix.include == []
assert metadata.config.test_matrix.remove == {}
assert metadata.config.test_matrix.replace == {}
assert metadata.config.test_matrix.variations == {}
assert metadata.config.test_matrix.full_include == []
[docs]
def test_full_include_flattens_matrix(tmp_path, monkeypatch):
"""`full-include` rows emit as standalone jobs in a flat matrix.
A full-include cell sharing its ``os``/``python-version`` with a
shipped-config combination must add a new job, not overwrite that
combination, and the matrix must serialize as a flat ``{"include": [...]}``
list so GitHub runs the rows verbatim.
"""
metadata_from_pyproject(
tmp_path,
monkeypatch,
"""\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic]
test-matrix.include = [{ "click-version" = "released" }]
test-matrix.full-include = [
{ "os" = "ubuntu-26.04-arm", "python-version" = "3.10", "click-version" = "8.3.1" },
]
""",
)
emitted = Metadata().test_matrix.matrix()
# Flat form: base axes live only inside the include rows, no cross-product.
assert "os" not in emitted
assert "exclude" not in emitted
rows = emitted["include"]
shipped = {
"os": "ubuntu-26.04-arm",
"python-version": "3.10",
"click-version": "released",
"state": "stable",
}
pinned = {
"os": "ubuntu-26.04-arm",
"python-version": "3.10",
"click-version": "8.3.1",
"state": "stable",
}
# Both coexist: the full-include row added a job instead of overwriting the
# shipped-config job at the same os/python.
assert shipped in rows
assert pinned in rows
[docs]
def test_full_include_backfills_probe_defaults(tmp_path, monkeypatch):
"""Free-threaded probe jobs inherit the single-key default includes.
The 3.14t probe is a standalone include pinned to one runner, appended by
``solve()`` and never augmented by GitHub's include algorithm. The flat
full-include matrix backfills the matrix defaults onto it, so it never
emits an empty ``click-version``/``cloup-version`` that GitHub would expand
to ``""`` and a downstream run step would mishandle.
"""
metadata_from_pyproject(
tmp_path,
monkeypatch,
"""\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic]
test-matrix.include = [
{ "click-version" = "released" },
{ "cloup-version" = "released" },
]
test-matrix.full-include = [
{ "os" = "ubuntu-26.04-arm", "python-version" = "3.14", "click-version" = "main" },
]
""",
)
# `.include` is the flat job list, typed as tuple[dict[str, str], ...]; the
# equivalent matrix()["include"] widens to a union that defeats indexing.
rows = Metadata().test_matrix.include
probe = next(r for r in rows if r["python-version"] == "3.14t")
assert probe == {
"os": "ubuntu-26.04-arm",
"python-version": "3.14t",
"click-version": "released",
"cloup-version": "released",
"state": "stable",
}
# Every emitted job carries the version defaults: none leaks an empty key
# that GitHub would expand to "".
assert all(r.get("click-version") and r.get("cloup-version") for r in rows)
[docs]
def test_repomatic_config_custom_values(tmp_path, monkeypatch):
"""Test that [tool.repomatic] config properties read from pyproject.toml."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic]
gitignore.location = "./custom/.gitignore"
gitignore.extra-categories = ["terraform", "go"]
gitignore.extra-content = '''
*.tmp
# Claude Code
.claude/
'''
dependency-graph.update = false
dependency-graph.output = "./custom/deps.mmd"
dependency-graph.all-groups = false
dependency-graph.all-extras = true
dependency-graph.no-groups = ["typing"]
dependency-graph.no-extras = ["xml"]
dependency-graph.level = 2
nuitka.dev-targets = ["macos-arm64", "windows-x64"]
nuitka.unstable-targets = ["linux-arm64", "windows-x64"]
labels.extra-files = ["https://example.com/labels.toml"]
pypi-package-history = ["old-name", "older-name"]
notification.unsubscribe = true
awesome-template.sync = false
binaries.sync = false
bumpversion.sync = false
dev-release.sync = false
gitignore.sync = false
labels.sync = false
mailmap.sync = false
setup-guide = false
uv-lock.sync = false
workflow.source-paths = ["extra_platforms"]
workflow.sync = false
exclude = ["skills", "workflows/debug.yaml", "workflows/autolock.yaml"]
include = ["labels"]
[[tool.repomatic.labels.extra]]
name = "📦 manager: apk"
color = "bfdadc"
description = "apk"
[[tool.repomatic.labels.extra]]
name = "📦 manager: brew"
color = "#bfdadc"
description = "homebrew"
[tool.repomatic.labels.file-rules]
"📦 manager: apk" = ["managers/apk*", "tests/*apk*"]
"📚 docs" = ["docs/**"]
[tool.repomatic.labels.content-rules]
"🔌 bar-plugin" = ["xbar", "swiftbar"]
[tool.repomatic.test-matrix]
exclude = [
{os = "windows-11-arm"},
{os = "macos-26-intel", python-version = "3.10"},
]
include = [
{state = "unstable", python-version = "3.99"},
]
[tool.repomatic.test-matrix.variations]
os = ["custom-runner"]
click-version = ["released", "stable", "main"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
assert metadata.config.gitignore.location == "./custom/.gitignore"
assert metadata.config.gitignore.extra_categories == [
"terraform",
"go",
]
assert (
metadata.config.gitignore.extra_content == "*.tmp\n\n# Claude Code\n.claude/\n"
)
assert metadata.config.dependency_graph.output == "./custom/deps.mmd"
assert metadata.config.dependency_graph.all_groups is False
assert metadata.config.dependency_graph.all_extras is True
assert metadata.config.dependency_graph.no_groups == ["typing"]
assert metadata.config.dependency_graph.no_extras == ["xml"]
assert metadata.config.dependency_graph.level == 2
assert metadata.config.dependency_graph.update is False
assert metadata.dev_targets == {"macos-arm64", "windows-x64"}
assert metadata.unstable_targets == {"linux-arm64", "windows-x64"}
assert metadata.config.labels.extra == [
{
"name": "📦 manager: apk",
"color": "bfdadc",
"description": "apk",
},
{
"name": "📦 manager: brew",
"color": "#bfdadc",
"description": "homebrew",
},
]
assert metadata.config.labels.extra_files == [
"https://example.com/labels.toml",
]
# Label keys carry emojis, spaces and colons: they must reach the config
# verbatim, shielded from the loader's key normalization.
assert metadata.config.labels.file_rules == {
"📦 manager: apk": ["managers/apk*", "tests/*apk*"],
"📚 docs": ["docs/**"],
}
assert metadata.config.labels.content_rules == {
"🔌 bar-plugin": ["xbar", "swiftbar"],
}
assert metadata.config.pypi_package_history == ["old-name", "older-name"]
assert metadata.config.notification_unsubscribe is True
assert metadata.config.awesome_template_sync is False
assert metadata.config.binaries_sync is False
assert metadata.config.bumpversion_sync is False
assert metadata.config.dev_release_sync is False
assert metadata.config.gitignore.sync is False
assert metadata.config.labels.sync is False
assert metadata.config.mailmap_sync is False
assert metadata.config.setup_guide is False
assert metadata.config.uv_lock_sync is False
assert metadata.config.workflow.source_paths == ["extra_platforms"]
assert metadata.config.workflow.sync is False
assert metadata.config.exclude == [
"skills",
"workflows/debug.yaml",
"workflows/autolock.yaml",
]
assert metadata.config.include == ["labels"]
assert metadata.config.test_matrix.exclude == [
{"os": "windows-11-arm"},
{"os": "macos-26-intel", "python-version": "3.10"},
]
assert metadata.config.test_matrix.include == [
{"state": "unstable", "python-version": "3.99"},
]
assert metadata.config.test_matrix.variations == {
"os": ["custom-runner"],
"click-version": ["released", "stable", "main"],
}
[docs]
def test_test_matrix_config_exclude(tmp_path, monkeypatch):
"""Test that test-matrix.exclude removes combos from both matrices."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix]
exclude = [
{os = "windows-11-arm"},
]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Full matrix: config exclude is present alongside the upstream default.
full = metadata.test_matrix.matrix()
assert {"os": "windows-11-arm"} in full["exclude"]
assert {"os": "windows-11-arm", "python-version": "3.10"} in full["exclude"]
# PR matrix: windows-11-arm is not in the PR runner list, so the exclude
# is pruned as a no-op.
pr = metadata.test_matrix_pr.matrix()
assert "exclude" not in pr or {"os": "windows-11-arm"} not in pr.get("exclude", ())
[docs]
def test_test_matrix_config_variations(tmp_path, monkeypatch):
"""Test that test-matrix.variations adds to full matrix only."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix.variations]
os = ["custom-runner"]
click-version = ["released", "stable"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Full matrix: custom-runner added to OS, click-version is a new axis.
full = metadata.test_matrix.matrix()
assert "custom-runner" in full["os"]
assert "click-version" in full
assert full["click-version"] == ("released", "stable")
# PR matrix: variations are NOT applied.
pr = metadata.test_matrix_pr.matrix()
assert "custom-runner" not in pr["os"]
assert "click-version" not in pr
[docs]
def test_test_matrix_config_include(tmp_path, monkeypatch):
"""Test that test-matrix.include adds directives to both matrices."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix]
include = [
{state = "unstable", python-version = "3.99"},
]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Full matrix: custom include added.
full_includes = metadata.test_matrix.matrix()["include"]
assert {"state": "unstable", "python-version": "3.99"} in full_includes
# PR matrix: same custom include added.
pr_includes = metadata.test_matrix_pr.matrix()["include"]
assert {"state": "unstable", "python-version": "3.99"} in pr_includes
[docs]
def test_test_matrix_config_replace(tmp_path, monkeypatch):
"""Test that test-matrix.replace swaps axis values in both matrices."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix.replace]
os = { "ubuntu-26.04-arm" = "ubuntu-24.04" }
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Full matrix: ubuntu-26.04-arm replaced with ubuntu-24.04.
full = metadata.test_matrix.matrix()
assert "ubuntu-24.04" in full["os"]
assert "ubuntu-26.04-arm" not in full["os"]
# PR matrix: same replacement applied (ubuntu-26.04-arm is in both sets).
pr = metadata.test_matrix_pr.matrix()
assert "ubuntu-24.04" in pr["os"]
assert "ubuntu-26.04-arm" not in pr["os"]
[docs]
def test_test_matrix_config_remove(tmp_path, monkeypatch):
"""Test that test-matrix.remove drops axis values from both matrices."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix.remove]
os = ["windows-11-arm"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Full matrix: windows-11-arm removed from the axis entirely.
full = metadata.test_matrix.matrix()
assert "windows-11-arm" not in full["os"]
# PR matrix: windows-11-arm was never in the PR runner list, so no change.
pr = metadata.test_matrix_pr.matrix()
assert "windows-11-arm" not in pr["os"]
# Verify no resurrection: unstable python include cannot bring it back.
full_jobs = list(metadata.test_matrix.solve())
assert all(j["os"] != "windows-11-arm" for j in full_jobs)
[docs]
def test_test_matrix_config_unstable(tmp_path, monkeypatch):
"""Test test-matrix.unstable flags full-matrix combos and skips the PR matrix."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix]
variations.click-version = ["released", "colorama"]
unstable = [
{click-version = "colorama"},
]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Full matrix: every colorama combo is flagged unstable, on every OS/Python.
full_jobs = list(metadata.test_matrix.solve())
colorama_jobs = [j for j in full_jobs if j.get("click-version") == "colorama"]
assert colorama_jobs
assert all(j["state"] == "unstable" for j in colorama_jobs)
# At a stable Python the marking is what flips state: colorama is unstable
# while released stays stable (released only goes unstable at a development
# Python, via the separate built-in rule).
colorama_stable_py = [j for j in colorama_jobs if j["python-version"] == "3.14"]
released_stable_py = [
j
for j in full_jobs
if j.get("click-version") == "released" and j["python-version"] == "3.14"
]
assert colorama_stable_py and released_stable_py
assert all(j["state"] == "unstable" for j in colorama_stable_py)
assert all(j["state"] == "stable" for j in released_stable_py)
# PR matrix: the directive is full-only, so it is absent and no PR job is
# hijacked into the colorama click-version or flipped to unstable.
pr = metadata.test_matrix_pr.matrix()
assert {"state": "unstable", "click-version": "colorama"} not in pr.get(
"include", ()
)
pr_jobs = list(metadata.test_matrix_pr.solve())
assert all(j["state"] == "stable" for j in pr_jobs)
assert all(j.get("click-version") != "colorama" for j in pr_jobs)
[docs]
def test_single_runner_python_versions_are_stable_and_pinned(tmp_path, monkeypatch):
"""Each released flavor runs once, on its pinned runner, stable; never in PR.
Locks the SINGLE_RUNNER_PYTHON_VERSIONS contract for every entry: a
free-threaded build is a single-runner smoke test, not the full
cross-platform spread, and not an unstable probe.
"""
metadata = metadata_from_pyproject(
tmp_path,
monkeypatch,
'[project]\nname = "test-project"\nversion = "1.0.0"\n',
)
full_jobs = list(metadata.test_matrix.solve())
pr_jobs = list(metadata.test_matrix_pr.solve())
assert SINGLE_RUNNER_PYTHON_VERSIONS
for version, runner in SINGLE_RUNNER_PYTHON_VERSIONS.items():
# A flavor is neither part of the full cross-platform spread nor an
# unstable probe: it is released and expected to pass.
assert version not in TEST_PYTHON_FULL
assert version not in UNSTABLE_PYTHON_VERSIONS
# Exactly one full-matrix job, on the pinned runner, marked stable.
assert [j for j in full_jobs if j["python-version"] == version] == [
{"os": runner, "python-version": version, "state": "stable"}
]
# The reduced PR matrix never carries a flavor.
assert all(j["python-version"] != version for j in pr_jobs)
[docs]
def test_unstable_python_versions_carry_a_prerelease_label(tmp_path, monkeypatch):
"""Each in-development Python gets a display label; the axis keeps the version.
The label is what the CI job name shows, so an unstable cell reads
`py3.15-dev` and says why it may fail rather than looking like any released
one. Adding a version to UNSTABLE_PYTHON_VERSIONS and forgetting its label
is the failure this catches.
Label and axis value stay separate on purpose: `uv venv --python`, which
`tests.yaml` feeds from `python-version`, does not parse the `-dev`
spelling, and downstream `[tool.repomatic.test-matrix]` directives key on
the bare version.
"""
metadata = metadata_from_pyproject(
tmp_path,
monkeypatch,
'[project]\nname = "test-project"\nversion = "1.0.0"\n',
)
full_jobs = list(metadata.test_matrix.solve())
pr_jobs = list(metadata.test_matrix_pr.solve())
assert UNSTABLE_PYTHON_VERSIONS
for version in UNSTABLE_PYTHON_VERSIONS:
# The axis carries the bare version, never the labelled spelling.
assert version in TEST_PYTHON_FULL
assert f"{version}{PRERELEASE_LABEL_SUFFIX}" not in TEST_PYTHON_FULL
probes = [j for j in full_jobs if j["python-version"] == version]
assert probes
for job in probes:
assert job["state"] == "unstable"
assert job["python-label"] == f"{version}{PRERELEASE_LABEL_SUFFIX}"
# The reduced PR matrix never probes an unreleased Python.
assert all(j["python-version"] != version for j in pr_jobs)
# Labelled and unstable are the same set of cells: a released Python must
# stay unlabelled so the job name falls back to its own version.
assert all(
("python-label" in job) == (job["python-version"] in UNSTABLE_PYTHON_VERSIONS)
for job in full_jobs
)
[docs]
def test_stale_test_matrix_excludes(tmp_path, monkeypatch):
"""Test detection of test-matrix.exclude entries that match no live axis."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic.test-matrix]
exclude = [
{os = "ubuntu-26.04"},
{os = "macos-15-intel"},
{python-version = "9.99"},
]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# ubuntu-26.04 is a live axis value, so its exclude is honored. The renamed
# macos-15-intel and the bogus Python version match no axis and are flagged,
# each paired with the values no axis carries.
# `ubuntu-slim` would fail here too: a known runner, but no longer a cell.
stale = metadata.stale_test_matrix_excludes
assert all(entry != {"os": "ubuntu-26.04"} for entry, _bad in stale)
assert ({"os": "macos-15-intel"}, {"os": "macos-15-intel"}) in stale
assert ({"python-version": "9.99"}, {"python-version": "9.99"}) in stale
[docs]
def test_unstable_targets_default(tmp_path, monkeypatch):
"""Test that unstable_targets defaults to an empty set."""
metadata = metadata_from_pyproject(
tmp_path,
monkeypatch,
'[project]\nname = "test-project"\nversion = "1.0.0"\n',
)
assert metadata.unstable_targets == set()
[docs]
def test_unstable_targets_ignores_unknown(tmp_path, monkeypatch):
"""Test that unrecognized unstable targets are discarded with a warning."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[tool.repomatic]
nuitka.unstable-targets = ["linux-arm64", "unknown-target"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# Only known targets are kept.
assert metadata.unstable_targets == {"linux-arm64"}
[docs]
def test_nuitka_entry_points_default_deduplicates(tmp_path, monkeypatch):
"""Default: alias entry points sharing a callable are deduplicated."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
short = "my_pkg.__main__:main"
long-name = "my_pkg.__main__:main"
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
assert metadata.config.nuitka_entry_points == []
# Both point to the same callable, so only the first is kept.
assert metadata.nuitka_entry_points == ["short"]
[docs]
def test_nuitka_entry_points_default_distinct(tmp_path, monkeypatch):
"""Default: entry points with different callables are all kept."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
cli-a = "my_pkg.cli:main_a"
cli-b = "my_pkg.cli:main_b"
cli-a-alias = "my_pkg.cli:main_a"
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# cli-a and cli-b have different callables, both kept.
# cli-a-alias duplicates cli-a's callable, dropped.
assert metadata.nuitka_entry_points == ["cli-a", "cli-b"]
[docs]
def test_nuitka_entry_points_custom(tmp_path, monkeypatch):
"""Custom entry-points config selects specific entry points."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
short = "my_pkg.__main__:main"
long-name = "my_pkg.__main__:main"
[tool.repomatic]
nuitka.entry-points = ["long-name"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
assert metadata.nuitka_entry_points == ["long-name"]
[docs]
def test_nuitka_entry_points_all(tmp_path, monkeypatch):
"""Listing all entry points explicitly builds all of them."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
short = "my_pkg.__main__:main"
long-name = "my_pkg.__main__:main"
[tool.repomatic]
nuitka.entry-points = ["short", "long-name"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
assert metadata.nuitka_entry_points == ["short", "long-name"]
[docs]
def test_nuitka_entry_points_ignores_unknown(tmp_path, monkeypatch):
"""Unrecognized entry points are discarded; falls back to first if all invalid."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
short = "my_pkg.__main__:main"
[tool.repomatic]
nuitka.entry-points = ["nonexistent"]
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
# All configured entries are invalid, so falls back to first.
assert metadata.nuitka_entry_points == ["short"]
[docs]
def test_script_entries_basic(tmp_path, monkeypatch):
"""Well-formed `[project.scripts]` entries parse into (name, module, callable)."""
pyproject_content = """\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
mdedup = "mail_deduplicate.cli:mdedup"
mpm = "meta_package_manager.__main__:main"
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
assert metadata.script_entries == [
("mdedup", "mail_deduplicate.cli", "mdedup"),
("mpm", "meta_package_manager.__main__", "main"),
]
[docs]
@pytest.mark.parametrize(
"name",
[
"../escape",
"nested/script",
".",
"..",
"with space",
"exclaim!",
],
)
def test_script_entries_rejects_unsafe_name(tmp_path, monkeypatch, name):
"""Script names that PyPI / uv-build would refuse are rejected up front."""
pyproject_content = f"""\
[project]
name = "test-project"
version = "1.0.0"
[project.scripts]
"{name}" = "my_pkg.cli:main"
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
with pytest.raises(ValueError, match=r"\[project\.scripts\] name"):
_ = metadata.script_entries