# Copyright Kevin Deldycke <[email protected]> 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 dataclasses import fields
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 extra_platforms.pytest import skip_windows
from repomatic.cli.ci import (
CELL_STATE_SEPARATOR,
EMPTY_CELL,
JOB_COUNT_MARK,
TEST_MATRIX_STATE_DISPLAY,
UNIVERSAL_AXIS_KEYS,
MatrixAxis,
flat_matrix_table,
format_matrix_cell,
show_test_matrix,
)
from repomatic.cli.main import repomatic
from repomatic.config import (
Config,
load_repomatic_config,
publish_config,
)
from repomatic.file_inventory import FileInventory
from repomatic.git_ops import NULL_SHA
from repomatic.github.event import WorkflowEvent
from repomatic.github.outputs import MAX_STEP_OUTPUT_BYTES
from repomatic.matrix.axes import (
PRERELEASE_LABEL_SUFFIX,
SINGLE_RUNNER_PYTHON_VERSIONS,
TEST_PYTHON_FULL,
TEST_PYTHON_PR,
TEST_RUNNERS_FULL,
TEST_RUNNERS_PR,
UNSTABLE_PYTHON_VERSIONS,
MatrixName,
python_version_sort_key,
)
from repomatic.matrix.model import (
OS_AXIS,
PYTHON_VERSION_AXIS,
Matrix,
)
from repomatic.metadata.core import (
_METADATA_KEY_DESCRIPTIONS,
FILE_GROUP_KEYS,
METADATA_CONFIG_FIELDS,
Metadata,
MetadataRequest,
all_metadata_keys,
metadata_keys_reference,
unknown_metadata_keys,
)
from repomatic.metadata.matrix import NUITKA_CACHE_KEY
from repomatic.metadata.output import Dialect, format_github_value
from repomatic.pyproject import read_pyproject_toml
from repomatic.release.binary import NUITKA_BUILD_TARGETS, binary_name
from repomatic.tooling.registry import TOOL_REGISTRY
from tests.conftest import (
IN_GIT_CHECKOUT,
WORKFLOWS_DIR,
metadata_from_pyproject,
)
[docs]
def regex(pattern: str) -> re.Pattern:
"""Compile a regex pattern with DOTALL flag."""
return re.compile(pattern, re.DOTALL)
# Every test of this module runs detached from the CI run's own workflow event.
pytestmark = pytest.mark.usefixtures("scrub_workflow_event")
[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 unquoted 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
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, 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} "
f"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} "
f"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} "
f"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 `FileInventory.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(),
"is_awesome": False,
# *_changed booleans depend on the current event's commit range.
"yaml_changed": AnyBool(),
"shfmt_changed": AnyBool(),
"zsh_changed": AnyBool(),
"workflows_changed": AnyBool(),
"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,
# A fresh unreleased section is empty right after a release, so this tracks
# repository state like `is_bot` and `yaml_changed` do.
"changelog_has_entries": AnyBool(),
"binaries_sync": True,
"manpages_script": "repomatic.cli.main:repomatic",
# The `<package-name>-manpages` default, `manpages.asset-name` being empty.
"manpages_asset_name": "repomatic-manpages",
# Plain strings, not Path objects, so GitHub format joins them unquoted.
"release_assets": StringList(["repomatic-claude-plugin.zip"]),
"package_name": "repomatic",
"cli_scripts": StringList(["repomatic"]),
"main_cli_script": "repomatic",
"import_name": "repomatic",
"project_description": (
"🏭 Automate repository maintenance, releases, and CI/CD workflows"
),
"mypy_params": StringList(["--python-version", "3.11"]),
"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,
"builds_sphinx": True,
"active_autodoc": True,
"uses_myst": True,
# 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(),
# No release commit outside a release push.
"is_release_push": False,
# The event's own commit when it carries no release commit: `GITHUB_SHA`
# in CI, absent locally. A SHA rather than a version, which the optional
# matcher takes all the same.
"release_commit": OptionalVersionString(regex(r"[0-9a-f]{40}")),
"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",
"nuitka_cache": "true",
"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",
"nuitka_cache": "true",
"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",
"nuitka_cache": "false",
},
{
"target": "macos-x64",
"os": "macos-26-intel",
"platform_id": "macos",
"arch": "x86_64",
"extension": "bin",
"floor": "10.15",
"nuitka_cache": "false",
},
{
"target": "windows-arm64",
"os": "windows-11-arm",
"platform_id": "windows",
"arch": "aarch64",
"extension": "exe",
"floor": "11",
"nuitka_cache": "true",
},
{
"target": "windows-x64",
"os": "windows-2025",
"platform_id": "windows",
"arch": "x86_64",
"extension": "exe",
"floor": "10",
"nuitka_cache": "true",
},
# 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.11"},
{"state": "stable", "os": "ubuntu-26.04-arm", "python-version": "3.15"},
{
"state": "unstable",
"os": "ubuntu-26.04-arm",
"python-version": "3.16",
"python-label": "3.16-dev",
},
{"state": "stable", "os": "ubuntu-26.04", "python-version": "3.11"},
{"state": "stable", "os": "ubuntu-26.04", "python-version": "3.15"},
{
"state": "unstable",
"os": "ubuntu-26.04",
"python-version": "3.16",
"python-label": "3.16-dev",
},
{"state": "stable", "os": "macos-26", "python-version": "3.11"},
{"state": "stable", "os": "macos-26", "python-version": "3.15"},
{
"state": "unstable",
"os": "macos-26",
"python-version": "3.16",
"python-label": "3.16-dev",
},
{"state": "stable", "os": "macos-26-intel", "python-version": "3.11"},
{"state": "stable", "os": "macos-26-intel", "python-version": "3.15"},
{
"state": "unstable",
"os": "macos-26-intel",
"python-version": "3.16",
"python-label": "3.16-dev",
},
{"state": "stable", "os": "windows-11-arm", "python-version": "3.11"},
{"state": "stable", "os": "windows-11-arm", "python-version": "3.15"},
{
"state": "unstable",
"os": "windows-11-arm",
"python-version": "3.16",
"python-label": "3.16-dev",
},
{"state": "stable", "os": "windows-2025", "python-version": "3.11"},
{"state": "stable", "os": "windows-2025", "python-version": "3.15"},
{
"state": "unstable",
"os": "windows-2025",
"python-version": "3.16",
"python-label": "3.16-dev",
},
{"state": "stable", "os": "ubuntu-26.04-arm", "python-version": "3.15t"},
{
"state": "stable",
"os": "ubuntu-26.04-arm",
"python-version": "3.11",
"resolution": "lowest-direct",
},
{
"state": "stable",
"os": "ubuntu-26.04-arm",
"python-version": "3.15",
"source": "sdist",
},
],
},
"test_matrix_pr": {
"os": [
"ubuntu-26.04-arm",
"macos-26",
"windows-2025",
],
"python-version": [
"3.11",
"3.15",
],
"include": [
{"state": "stable"},
],
},
# The newest plain release of the full matrix's `python-version` axis.
"latest_stable_python": "3.15",
# `minimum-release-age` default "1 week", rendered as whole days for npm.
"npm_min_release_age_days": 7,
# Every registry pin, by tool name: the cache keys of the workflows read it.
"tool_versions": {
name: spec.version for name, spec in sorted(TOOL_REGISTRY.items())
},
# 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: `site.cloudflare-project` is empty.
"cloudflare_project": "repomatic",
# This repository publishes `.html` pages, the `sphinx.builder` default.
"sphinx_builder": "html",
# This repository sets `sphinx.fail-on-warning`, off by default.
"sphinx_fail_on_warning": True,
"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", 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 listing one in
`METADATA_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(format_github_value(value), str)
[docs]
def test_file_group_keys_are_inventory_attributes():
"""Every file-group key names an output key and an inventory attribute."""
assert FILE_GROUP_KEYS <= set(_METADATA_KEY_DESCRIPTIONS)
for key in FILE_GROUP_KEYS:
assert hasattr(FileInventory, key), key
[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",
)
env = dict(os.environ, 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]
def test_exclude_file_rules_apply_below_gitignore(tmp_path, monkeypatch):
"""The exclude file filters the inventory, and `.gitignore` overrides it.
Git reads `info/exclude` beside `.gitignore`, and gives `.gitignore` the
last word: a file that the exclude rules drop comes back when `.gitignore`
negates it.
"""
exclude = tmp_path / ".git" / "info" / "exclude"
exclude.parent.mkdir(parents=True)
exclude.write_text("*.txt\n", encoding="UTF-8")
(tmp_path / ".gitignore").write_text("!kiwi.txt\n", encoding="UTF-8")
for name in ("apple.txt", "kiwi.txt", "mango.md"):
(tmp_path / name).write_text("", encoding="UTF-8")
monkeypatch.chdir(tmp_path)
assert FileInventory().glob_files("*") == [
Path(".gitignore"),
Path("kiwi.txt"),
Path("mango.md"),
]
[docs]
def test_linked_worktree_reads_the_exclude_file_of_its_main_checkout(
tmp_path, monkeypatch
):
"""A linked worktree follows its `.git` file to the shared exclude file.
Git writes the `.git` file and the `commondir` file of a linked worktree in
this layout, and keeps one exclude file for every worktree.
"""
common_dir = tmp_path / "orchard" / ".git"
(common_dir / "info").mkdir(parents=True)
(common_dir / "info" / "exclude").write_text("*.txt\n", encoding="UTF-8")
worktree_dir = common_dir / "worktrees" / "grove"
worktree_dir.mkdir(parents=True)
(worktree_dir / "commondir").write_text("../..\n", encoding="UTF-8")
grove = tmp_path / "grove"
grove.mkdir()
(grove / ".git").write_text(f"gitdir: {worktree_dir}\n", encoding="UTF-8")
for name in ("apple.txt", "mango.md"):
(grove / name).write_text("", encoding="UTF-8")
monkeypatch.chdir(grove)
assert FileInventory().glob_files("*.md", "*.txt") == [Path("mango.md")]
[docs]
@pytest.mark.parametrize(
("dot_git", "exclude", "kept"),
(
pytest.param(
"gitdir: ../.git/modules/fig\n",
Path("..", ".git", "modules", "fig", "info", "exclude"),
[Path("mango.md")],
id="submodule",
),
pytest.param(
"papaya\n",
None,
[Path("apple.txt"), Path("mango.md")],
id="no-gitdir-line",
),
),
)
def test_dot_git_file_layouts(tmp_path, monkeypatch, dot_git, exclude, kept):
"""A `.git` file finds the exclude file only through its `gitdir:` line.
Git writes the `.git` file of a submodule in this layout: its git directory
sits in the superproject and holds no `commondir` file, so the exclude
file is the one of that directory.
"""
modules = tmp_path / "orchard" / ".git" / "modules" / "fig"
(modules / "info").mkdir(parents=True)
(modules / "info" / "exclude").write_text("*.txt\n", encoding="UTF-8")
fig = tmp_path / "orchard" / "fig"
fig.mkdir()
(fig / ".git").write_text(dot_git, encoding="UTF-8")
for name in ("apple.txt", "mango.md"):
(fig / name).write_text("", encoding="UTF-8")
monkeypatch.chdir(fig)
inventory = FileInventory()
assert inventory.exclude_path == exclude
assert inventory.glob_files("*.md", "*.txt") == kept
[docs]
@skip_windows(reason="Windows restricts symbolic links to privileged accounts.")
def test_glob_files_resolves_links(tmp_path, monkeypatch):
"""A dangling link is dropped, and a link out of the directory stays absolute."""
kiwi = tmp_path / "kiwi.md"
kiwi.write_text("", encoding="UTF-8")
orchard = tmp_path / "orchard"
orchard.mkdir()
(orchard / "pear.md").write_text("", encoding="UTF-8")
(orchard / "dangling.md").symlink_to(orchard / "absent.md")
(orchard / "outside.md").symlink_to(kiwi)
monkeypatch.chdir(orchard)
assert FileInventory().glob_files("*.md") == [kiwi.resolve(), Path("pear.md")]
[docs]
def test_shell_groups_split_sh_files_on_their_shebang(tmp_path, monkeypatch):
"""A `.sh` file joins the group its shebang names, read once per inventory."""
(tmp_path / "orchard.sh").write_text(
"#!/usr/bin/env zsh\necho papaya\n", encoding="UTF-8"
)
grove = tmp_path / "grove.sh"
grove.write_text("#!/usr/bin/env bash\necho kiwi\n", encoding="UTF-8")
(tmp_path / "tasks.zsh").write_text("echo mango\n", encoding="UTF-8")
monkeypatch.chdir(tmp_path)
inventory = FileInventory()
assert inventory.zsh_files == [Path("orchard.sh"), Path("tasks.zsh")]
# The verdict read for `zsh_files` holds, so a new shebang changes nothing.
grove.write_text("#!/usr/bin/env zsh\necho kiwi\n", encoding="UTF-8")
assert inventory.shfmt_files == [Path("grove.sh")]
# A file that cannot be read has no dialect.
assert inventory.shebang_names_zsh(Path("absent.sh")) is None
[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
FRUIT_AXIS_PYPROJECT = """\
[project]
name = "orchard"
version = "1.0.0"
[tool.repomatic.test-matrix.variations]
fruit = ["papaya", "kiwi", "mango"]
"""
"""A full-matrix axis with no canonical order, its values declared unsorted."""
[docs]
def test_show_test_matrix_refuses_a_full_only_axis_on_pr(tmp_path, monkeypatch):
"""An axis the `pr` matrix drops is refused there, though the full one has it."""
metadata_from_pyproject(tmp_path, monkeypatch, FRUIT_AXIS_PYPROJECT)
monkeypatch.chdir(tmp_path)
result = CliRunner().invoke(
repomatic, ["show-test-matrix", "pr", "--col-axis", "fruit"]
)
assert result.exit_code == 2, result.output
assert "'fruit' is not a key of the pr matrix" in result.output
[docs]
@pytest.mark.parametrize("view", ([], ["--grid"]), ids=("listing", "grid"))
@pytest.mark.parametrize("axis_option", ("--row-axis", "--col-axis"))
def test_show_test_matrix_keeps_job_order_on_an_axis_without_one(
tmp_path, monkeypatch, view, axis_option
):
"""An axis with no canonical order lists its values in job order, unsorted."""
metadata_from_pyproject(tmp_path, monkeypatch, FRUIT_AXIS_PYPROJECT)
monkeypatch.chdir(tmp_path)
result = CliRunner().invoke(
repomatic,
["--table-format", "json", "show-test-matrix", *view, axis_option, "fruit"],
)
assert result.exit_code == 0, result.output
rows = json.loads(result.output)
if view and axis_option == "--col-axis":
fruits = list(rows[0])[1:]
else:
# An include job the variation does not reach lists no fruit.
fruits = list(dict.fromkeys(row["fruit"] for row in rows if row["fruit"]))
assert fruits == ["papaya", "kiwi", "mango"]
[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(Metadata().test_matrix.job_keys()) + "]"
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 == Metadata().test_matrix.job_keys()
[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 = 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]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_names_binaries_without_a_declared_version(tmp_path, monkeypatch):
"""A commit declaring no version still yields a matrix, of versionless names.
A project with no bump-my-version configuration leaves `current_version`
out of each commit entry, so every binary takes its stable alias name.
"""
pyproject_content = """\
[project]
name = "papaya"
version = "1.0.0"
[project.scripts]
papaya = "papaya.__main__:main"
"""
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
monkeypatch.setattr("repomatic.metadata.git.version_at", lambda ref: None)
matrix = metadata.nuitka_matrix
assert matrix is not None
assert {entry["bin_name"] for entry in matrix.include if "bin_name" in entry} == {
binary_name("papaya", target) for target in NUITKA_BUILD_TARGETS
}
[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]
@pytest.mark.repo_maintenance
def test_nuitka_matrix_is_empty_when_the_build_is_skipped(monkeypatch):
"""An event that cannot change a binary leaves nothing to compile."""
monkeypatch.setattr(Metadata, "skip_binary_build", True)
assert Metadata().nuitka_matrix is None
[docs]
@pytest.mark.repo_maintenance
@pytest.mark.parametrize("release", (False, True), ids=("canary", "release"))
def test_nuitka_matrix_gates_the_compile_cache(monkeypatch, release):
"""Each target tells its compile job whether to keep a cache.
Release commits build cold, and a target compiling without ccache keeps
no cache at all. The flag is a string, as every matrix value is.
"""
if release:
monkeypatch.setattr(
Metadata, "release_commits_matrix", Metadata().current_commit_matrix
)
matrix = Metadata().nuitka_matrix
assert matrix is not None
flags = {
entry["target"]: entry[NUITKA_CACHE_KEY]
for entry in matrix.include
if "target" in entry
}
assert flags == {
target_id: "true" if target.compile_cache and not release else "false"
for target_id, target in NUITKA_BUILD_TARGETS.items()
}
assert flags["macos-arm64"] == "false"
[docs]
@pytest.mark.parametrize(
("configured", "package", "expected"),
(
pytest.param("", "papaya", "papaya-manpages", id="default"),
pytest.param("papaya-docs", "papaya", "papaya-docs", id="configured"),
pytest.param("", None, None, id="no-package"),
),
)
def test_manpages_asset_name(tmp_path, monkeypatch, configured, package, expected):
"""The tarball stem defaults to the package name, unless configured."""
lines = []
if package:
lines += ["[project]", f'name = "{package}"', 'version = "1.0.0"']
if configured:
lines += ["[tool.repomatic]", f'manpages.asset-name = "{configured}"']
metadata = metadata_from_pyproject(tmp_path, monkeypatch, "\n".join(lines) + "\n")
monkeypatch.chdir(tmp_path)
assert metadata.manpages_asset_name == expected
[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_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/ignored", "owner/mango", "owner/mango", id="git-remote"),
pytest.param("owner/papaya", None, "owner/papaya", id="gh-cli"),
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 the remote, then gh.
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.get_repo_slug_from_remote", lambda: None
)
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.get_repo_slug_from_remote", lambda: None
)
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"
"# Agent skill scripts, which the virtualenv `[Ss]cripts` rule ignores.\n"
"!**/skills/*/scripts/\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.11", "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.11",
"click-version": "released",
"state": "stable",
}
pinned = {
"os": "ubuntu-26.04-arm",
"python-version": "3.11",
"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.15t 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.15", "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.15t")
assert probe == {
"os": "ubuntu-26.04-arm",
"python-version": "3.15t",
"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: the config exclude is present.
full = metadata.test_matrix.matrix()
assert {"os": "windows-11-arm"} 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.15"]
released_stable_py = [
j
for j in full_jobs
if j.get("click-version") == "released" and j["python-version"] == "3.15"
]
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.16-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"}
ALIASED_SCRIPTS = 'short = "my_pkg.__main__:main"\nlong-name = "my_pkg.__main__:main"\n'
"""Two entry points sharing one callable."""
[docs]
@pytest.mark.parametrize(
("scripts", "config", "expected"),
(
# Both point to the same callable, so only the first is kept.
pytest.param(ALIASED_SCRIPTS, "", ["short"], id="default-deduplicates"),
# cli-a-alias duplicates the callable of cli-a, so it is dropped.
pytest.param(
'cli-a = "my_pkg.cli:main_a"\n'
'cli-b = "my_pkg.cli:main_b"\n'
'cli-a-alias = "my_pkg.cli:main_a"\n',
"",
["cli-a", "cli-b"],
id="default-distinct",
),
pytest.param(
ALIASED_SCRIPTS,
'nuitka.entry-points = ["long-name"]\n',
["long-name"],
id="custom",
),
pytest.param(
ALIASED_SCRIPTS,
'nuitka.entry-points = ["short", "long-name"]\n',
["short", "long-name"],
id="all",
),
# Every configured entry is unknown, so the first script is the fallback.
pytest.param(
'short = "my_pkg.__main__:main"\n',
'nuitka.entry-points = ["nonexistent"]\n',
["short"],
id="unknown-falls-back",
),
),
)
def test_nuitka_entry_points(tmp_path, monkeypatch, scripts, config, expected):
"""`nuitka.entry-points` picks the scripts; the default drops aliases."""
pyproject_content = (
'[project]\nname = "test-project"\nversion = "1.0.0"\n\n'
f"[project.scripts]\n{scripts}\n[tool.repomatic]\n{config}"
)
metadata = metadata_from_pyproject(tmp_path, monkeypatch, pyproject_content)
if not config:
assert metadata.config.nuitka_entry_points == []
assert metadata.nuitka_entry_points == expected
[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(
("conf", "expected"),
[
pytest.param(
'extensions = ["myst_parser", "sphinx.ext.autodoc"]\n',
{"myst_parser", "sphinx.ext.autodoc"},
id="plain",
),
pytest.param(
'extensions: tuple[str, ...] = ("myst_parser",)\n',
{"myst_parser"},
id="annotated-tuple",
),
pytest.param(
'extensions = ["myst_parser"]\nextensions += ["sphinx.ext.autodoc"]\n',
{"myst_parser", "sphinx.ext.autodoc"},
id="augmented",
),
pytest.param(
'extensions = ["sphinx.ext.autodoc"]\nextensions = ["myst_parser"]\n',
{"myst_parser"},
id="reassigned",
),
pytest.param(
'project, author = "papaya", "mango"\n'
"html_context = {}\n"
'html_context["fruits"] = ["kiwi"]\n'
'extensions = ["myst_parser"]\n',
{"myst_parser"},
id="tuple-and-subscript-targets",
),
pytest.param("extensions = [\n", set(), id="syntax-error"),
],
)
def test_sphinx_extensions_reads_every_assignment_shape(
tmp_path, monkeypatch, conf, expected
):
"""Every assignment shape of `conf.py` reads, and none crashes.
A tuple or subscript target carries no name, and reading one as if it did
crashed `show-metadata` and `update-docs` on the project assigning it.
"""
conf_path = tmp_path / "conf.py"
conf_path.write_text(conf, encoding="UTF-8")
monkeypatch.setattr(Metadata, "sphinx_conf_path", conf_path)
metadata = Metadata()
assert metadata.sphinx_extensions == expected
assert metadata.uses_myst is ("myst_parser" in expected)
assert metadata.active_autodoc is ("sphinx.ext.autodoc" in expected)
[docs]
def test_sphinx_extensions_without_sphinx(tmp_path, monkeypatch):
"""A project with no Sphinx configuration lists no extension."""
monkeypatch.setattr(Metadata, "sphinx_conf_path", tmp_path / "conf.py")
assert Metadata().sphinx_extensions == frozenset()
[docs]
def test_sphinx_conf_strings_reads_the_named_variable(tmp_path, monkeypatch):
"""Each variable reads on its own, and a value that is no list reads empty."""
conf_path = tmp_path / "conf.py"
conf_path.write_text(
'extensions = ["myst_parser"]\n'
'suppress_warnings = ["myst.header"]\n'
'html_theme = "furo"\n',
encoding="UTF-8",
)
monkeypatch.setattr(Metadata, "sphinx_conf_path", conf_path)
metadata = Metadata()
assert metadata.sphinx_conf_strings("suppress_warnings") == {"myst.header"}
assert metadata.sphinx_conf_strings("extensions") == {"myst_parser"}
assert metadata.sphinx_conf_strings("html_theme") == frozenset()
assert metadata.sphinx_conf_strings("templates_path") == frozenset()
[docs]
def test_github_dialect_trims_an_oversized_heredoc(caplog):
"""A heredoc value past the step-output ceiling is trimmed, with a warning.
A consuming step receives the value in its environment, and a value past
the ceiling kills that step with `E2BIG` before its command starts.
"""
line = "papaya mango kiwi\n"
oversized = line * (MAX_STEP_OUTPUT_BYTES // len(line) + 100)
content = Dialect.github.serialize({"harvest_log": oversized, "is_bot": False})
assert len(content.encode("UTF-8")) < MAX_STEP_OUTPUT_BYTES + 200
assert content.startswith("harvest_log<<GHA_DELIMITER_")
assert content.endswith("is_bot=false\n")
assert "harvest_log" in caplog.text
[docs]
@pytest.mark.parametrize("base_ref", ["main", ""])
def test_event_type_needs_the_event_name(monkeypatch, caplog, base_ref):
"""Without `GITHUB_EVENT_NAME` the event is unknown, whatever else is set."""
monkeypatch.setattr("repomatic.metadata.env.is_github_ci", lambda: True)
monkeypatch.setenv("GITHUB_BASE_REF", base_ref)
assert Metadata().event_type is None
assert "GITHUB_EVENT_NAME" in caplog.text
[docs]
@pytest.mark.parametrize(
("event_name", "expected"),
[
("schedule", WorkflowEvent.schedule),
("pull_request", WorkflowEvent.pull_request),
("orchard_harvest", None),
],
)
def test_event_type_reads_the_event_name(monkeypatch, event_name, expected):
"""The event name maps onto its member, and an unknown one onto nothing."""
monkeypatch.setattr("repomatic.metadata.env.is_github_ci", lambda: True)
monkeypatch.setenv("GITHUB_EVENT_NAME", event_name)
assert Metadata().event_type is expected
[docs]
@pytest.mark.parametrize(
("actor", "sender", "expected"),
[
pytest.param("dependabot[bot]", {}, True, id="dependabot-login"),
pytest.param("renovate[bot]", {"type": "Bot"}, True, id="app-sender"),
pytest.param("papaya", {"type": "User"}, False, id="user"),
pytest.param("orchard", {"type": "Organization"}, False, id="organization"),
],
)
def test_is_bot_reads_the_sender_then_the_login(monkeypatch, actor, sender, expected):
"""A GitHub App sender is a bot, and so is the Dependabot login alone."""
monkeypatch.setenv("GITHUB_ACTOR", actor)
monkeypatch.setattr(Metadata, "github_event", {"sender": sender})
assert Metadata().is_bot is expected
[docs]
@pytest.mark.parametrize(
("name", "runners", "python_versions", "attribute"),
[
(MatrixName.FULL, TEST_RUNNERS_FULL, TEST_PYTHON_FULL, "test_matrix"),
(MatrixName.PR, TEST_RUNNERS_PR, TEST_PYTHON_PR, "test_matrix_pr"),
],
)
def test_matrix_name_designates_its_matrix(name, runners, python_versions, attribute):
"""Each matrix name seeds its axes and designates its metadata key."""
assert name.runners == runners
assert name.python_versions == python_versions
metadata = Metadata()
assert metadata.test_matrix_named(name) is getattr(metadata, attribute)
[docs]
def test_current_version_reads_the_most_recent_commit(monkeypatch):
"""A release push reports the post-release version of its most recent commit.
The commit matrix lists the new commits oldest first, so the release commit
comes before the post-release bump that follows it.
"""
release, bump = "a" * 40, "b" * 40
matrix = Matrix()
matrix.add_variation("commit", [release, bump])
matrix.add_includes(
{"commit": release, "short_sha": release[:7], "current_version": "5.0.0"},
{"commit": bump, "short_sha": bump[:7], "current_version": "5.0.1.dev0"},
)
monkeypatch.setattr(Metadata, "new_commits_matrix", matrix)
assert Metadata().current_version == "5.0.1.dev0"
[docs]
@pytest.mark.parametrize(
("scripts", "main_script", "import_name"),
(
pytest.param(
"[project.scripts]\n"
'mdedup = "mail_deduplicate.cli:mdedup"\n'
'mailbox = "mailbox_tools.cli:main"\n',
"mdedup",
"mail_deduplicate",
id="first-script",
),
pytest.param("", None, "fruit_salad", id="no-script"),
),
)
def test_main_cli_script_and_its_import_name(
tmp_path, monkeypatch, scripts, main_script, import_name
):
"""The first script names the CLI, and its module names the import package.
The module wins over the package name, which can differ from it. A project
with no script falls back to the package name, hyphens turned into
underscores.
"""
content = f'[project]\nname = "fruit-salad"\nversion = "1.0.0"\n{scripts}'
metadata = metadata_from_pyproject(tmp_path, monkeypatch, content)
assert metadata.main_cli_script == main_script
assert metadata.import_name == import_name
[docs]
@pytest.mark.parametrize(
("python_project", "sphinx", "expected"),
(
(True, True, True),
(True, False, False),
(False, True, False),
),
)
def test_builds_sphinx_needs_a_python_project_and_a_sphinx_config(
monkeypatch, python_project, sphinx, expected
):
"""The Sphinx build installs the project, so it needs both to run."""
monkeypatch.setattr(Metadata, "is_python_project", python_project)
monkeypatch.setattr(Metadata, "is_sphinx", sphinx)
assert Metadata().builds_sphinx is expected
LATEST_STABLE = TEST_PYTHON_PR[-1]
"""The newest released Python of the default axes, which the PR matrix runs too."""
PLAIN_RELEASES = [v for v in TEST_PYTHON_FULL if v not in UNSTABLE_PYTHON_VERSIONS]
"""The plain releases of the default `python-version` axis."""
[docs]
@pytest.mark.parametrize(
("declared", "expected"),
(
pytest.param("kiwi-site", "kiwi-site", id="declared"),
pytest.param("", "papaya", id="repository-name"),
),
)
def test_cloudflare_project_falls_back_to_the_repository_name(
monkeypatch, declared, expected
):
"""`site.cloudflare-project` names the project, else the repository does."""
monkeypatch.setenv("GITHUB_REPOSITORY", "orchard/papaya")
publish_config(Config(site_cloudflare_project=declared))
assert Metadata().cloudflare_project == expected
[docs]
def test_github_json_keeps_a_list_of_mappings_as_an_array():
"""A list of mappings reaches the workflow as a JSON array, not a string.
A workflow hands it to `strategy.matrix.include` as it is. A list of words
still joins into one string, and an empty one into the empty string an
`if:` gate reads as false.
"""
targets = [{"os": "ubuntu-26.04", "arch": "x86_64"}]
raw = Dialect.github_json.serialize({
"build_targets": targets,
"cli_scripts": ["mdedup", "mailbox"],
"doc_files": [Path("readme.md")],
"zsh_files": [],
})
payload = json.loads(raw.removeprefix("metadata="))
assert payload == {
"build_targets": targets,
"cli_scripts": "mdedup mailbox",
"doc_files": '"readme.md"',
"zsh_files": "",
}
[docs]
def test_changelog_has_entries_reads_the_current_section(tmp_path, monkeypatch):
"""The key is true only when the section of the current version holds a bullet."""
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "papaya"\nversion = "1.2.3"\n\n'
'[tool.bumpversion]\ncurrent_version = "1.2.3"\n',
encoding="UTF-8",
)
assert Metadata().changelog_has_entries is False
changelog = tmp_path / "changelog.md"
heading = (
"# Changelog\n\n"
"## [`1.2.3` (unreleased)](https://github.com/user/repo/compare/v1.2.2...main)\n"
)
changelog.write_text(heading, encoding="UTF-8")
# The singleton caches every key, so each reading starts from a fresh one.
Metadata.reset()
assert Metadata().changelog_has_entries is False
changelog.write_text(heading + "\n- Add a feature.\n", encoding="UTF-8")
Metadata.reset()
assert Metadata().changelog_has_entries is True