Source code for tests.test_metadata

# 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] def test_metadata_keys_reference_documents_every_key(): """Every emitted key needs a non-empty description in the reference table.""" rows = dict(metadata_keys_reference()) assert set(rows) == all_metadata_keys() undocumented = sorted(key for key, text in rows.items() if not text.strip()) assert not undocumented, ( f"Metadata keys with no description: {undocumented}. Add one to " "_METADATA_KEY_DESCRIPTIONS, or an attribute docstring to the Config " "field." )
[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_metadata_github_json_format(): raw = Metadata().dump(Dialect.github_json) assert isinstance(raw, str) # Output must be a single line starting with "metadata=". lines = raw.strip().splitlines() assert len(lines) == 1 assert lines[0].startswith("metadata=") json_str = lines[0][len("metadata=") :] metadata = json.loads(json_str) # In github_json format, list/tuple values are pre-formatted via # format_github_value() because GitHub Actions stringifies JSON arrays # as "Array" when interpolated in ${{ }} expressions. Transform expected # values to match: file lists become space-separated quoted strings, # plain string lists become space-separated unquoted strings, and # dict lists become JSON strings. github_json_expected = dict(expected) for key, value in github_json_expected.items(): if isinstance(value, OptionalList): # Convert OptionalList to OptionalString for github_json format. github_json_expected[key] = OptionalString(value.item_pattern) elif isinstance(value, list): if not value: github_json_expected[key] = "" elif all(isinstance(i, str) for i in value): # StringList items are unquoted; file list items are double-quoted. if key.endswith("_files"): github_json_expected[key] = " ".join(f'"{v}"' for v in value) else: github_json_expected[key] = " ".join(value) elif all(isinstance(i, dict) for i in value): github_json_expected[key] = json.dumps(value) iter_checks(metadata, github_json_expected, raw)
[docs] def test_metadata_github_json_format_key_filtering(): raw = Metadata().dump( Dialect.github_json, keys=("is_python_project", "current_version"), ) json_str = raw.strip().removeprefix("metadata=") metadata = json.loads(json_str) assert set(metadata.keys()) == {"is_python_project", "current_version"}
[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.repo_maintenance def test_metadata_json_format(): metadata = Metadata().dump(Dialect.json) assert isinstance(metadata, str) iter_checks(json.loads(metadata), expected, metadata)
[docs] @pytest.mark.repo_maintenance def test_metadata_github_format(): raw_metadata = Metadata().dump() assert isinstance(raw_metadata, str) # Prepare metadata for checks metadata = {} # Accumulation states. acc_key = None acc_delimiter = None acc_lines = [] for line in raw_metadata.splitlines(): # We are at the end of the accumulation for a key. if line == acc_delimiter: assert acc_delimiter assert acc_key assert acc_lines metadata[acc_key] = "\n".join(acc_lines) # Reset accumulation states. acc_key = None acc_delimiter = None acc_lines = [] continue # We are accumulating lines for a key. if acc_key: acc_lines.append(line) continue # We should not have any accumulation state at this point. assert acc_key is None assert acc_delimiter is None assert acc_lines == [] # We are starting a new accumulation for a key. if "<<" in line: # Check the delimiter syntax. assert line.count("<<") == 1 acc_key, acc_delimiter = line.split("<<", 1) assert re.fullmatch(r"GHA_DELIMITER_[0-9]+", acc_delimiter) continue # We are at a simple key-value pair. if "=" in line: key, value = line.split("=", 1) # Convert list-like and dict-like JSON string into Python objects. if value.startswith(("[", "{")): value = json.loads(value) metadata[key] = value continue raise ValueError( f"Unexpected line format in metadata: {line!r}. " "Expecting a key-value pair or a delimited block." ) # Adapt expected values to match GitHub Actions format. github_format_expected = {} for key, value in expected.items(): new_value = value if value is None: new_value = "" elif isinstance(value, bool): new_value = str(value).lower() elif isinstance(value, int): new_value = str(value) elif isinstance(value, OptionalList): # Convert OptionalList to OptionalString for GitHub format. new_value = OptionalString(value.item_pattern) elif isinstance(value, AnyBool): # Convert AnyBool to AnyBoolString for GitHub format. new_value = AnyBoolString() elif isinstance(value, OptionalMatrix): # Convert OptionalMatrix to OptionalMatrixOrEmptyString for GitHub format. new_value = OptionalMatrixOrEmptyString() elif isinstance(value, OptionalVersionString): # Convert OptionalVersionString to OptionalVersionOrEmptyString for GitHub # format. new_value = OptionalVersionOrEmptyString(value.version_pattern) elif isinstance(value, AnyReleaseNotes): # Convert AnyReleaseNotes to AnyReleaseNotesOrEmptyString for GitHub format. new_value = AnyReleaseNotesOrEmptyString( value.dev_pattern, value.release_pattern ) elif isinstance(value, StringList): # Plain string lists: space-separated without double-quotes. new_value = " ".join(value) elif isinstance(value, list) and all(isinstance(i, str) for i in value): # File path lists (Path objects in actual metadata): double-quoted. new_value = " ".join(f'"{i}"' for i in value) github_format_expected[key] = new_value iter_checks(metadata, github_format_expected, raw_metadata)
[docs] def test_metadata_command_renders_under_captured_runner(): """`repomatic show-metadata` renders to a captured stdout that has no descriptor. The docs `{click:run}` directive live-renders this command through click-extra's in-memory runner, which is Click's default `capture="sys"` mode: its stdout has no `fileno()`. `prep_path` must degrade to that stream rather than reopening its descriptor, and a bare stdout run must stay silent (no spurious overwrite warning) so the rendered block is pure JSON. See `repomatic.cli.main.prep_path`. """ result = CliRunner().invoke( repomatic, ["show-metadata", "test_matrix", "--format", "json"] ) assert result.exit_code == 0, result.output # A `prep_path` crash (exit 1) or a leaked warning line both break this parse. data = json.loads(result.output) assert "test_matrix" in data
[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_format_matrix_cell_labels_every_state(): """Each state of a cell shared by several jobs gets its own glyph.""" tally = Counter(dict.fromkeys(TEST_MATRIX_STATE_DISPLAY, 1)) assert format_matrix_cell("", tally) == PIVOT_CELL_SEPARATOR.join( TEST_MATRIX_STATE_DISPLAY.values() )
[docs] @pytest.mark.parametrize(("state", "label"), tuple(TEST_MATRIX_STATE_DISPLAY.items())) def test_format_matrix_cell_counts_jobs_past_the_first(state, label): """A state carrying several jobs says how many; a lone job stays bare.""" assert format_matrix_cell("", Counter({state: 1})) == label assert format_matrix_cell("", Counter({state: 3})) == f"{label} {JOB_COUNT_MARK}3" # A count is data, not decoration: --no-emoji keeps it. assert ( format_matrix_cell("", Counter({state: 3}), emoji=False) == f"{state} {JOB_COUNT_MARK}3" )
[docs] def test_format_matrix_cell_keeps_the_empty_intersection_placeholder(): """A cell no job occupies renders whatever `pivot` put there.""" assert format_matrix_cell("—", None) == "—"
[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
[docs] @pytest.mark.parametrize( "value", [ "no_colon", "a:b:c", ":missing_module", "missing_callable:", "", ], ) def test_script_entries_rejects_malformed_value(tmp_path, monkeypatch, value): """Values not of the form `module:object` raise a descriptive error.""" pyproject_content = f"""\ [project] name = "test-project" version = "1.0.0" [project.scripts] cli = "{value}" """ metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content) with pytest.raises(ValueError, match=r"\[project\.scripts\] value"): _ = metadata.script_entries