Source code for tests.test_metadata

# 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] 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", 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_metadata_config_fields_are_sorted_config_fields(): """Each allowed name is a `Config` field, so a renamed field fails here.""" assert list(METADATA_CONFIG_FIELDS) == sorted(METADATA_CONFIG_FIELDS) assert set(METADATA_CONFIG_FIELDS) <= {field.name for field in fields(Config)}
[docs] @pytest.mark.repo_maintenance @pytest.mark.parametrize("field_name", METADATA_CONFIG_FIELDS) def test_metadata_config_fields_are_read_by_a_workflow(field_name): """A config field joins the metadata output only for a workflow to read. The subcommands read `[tool.repomatic]` themselves, so an allowed field no workflow mentions is surface nobody consumes. """ workflows = sorted(WORKFLOWS_DIR.glob("*.y*ml")) assert workflows assert any( field_name in workflow.read_text(encoding="UTF-8") for workflow in workflows ), f"No workflow reads the {field_name} metadata key."
[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_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, a list of strings or paths is joined into one # string, because GitHub Actions stringifies a JSON array as "Array" when # an expression interpolates it. Transform expected values to match: file # lists become space-separated quoted strings, and plain string lists # space-separated unquoted strings. A list of mappings stays a JSON array, # which a workflow hands to `strategy.matrix.include` as it is. github_json_expected = dict(expected) for key, value in github_json_expected.items(): if 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) 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", ) 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] 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.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, 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, 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.write_output`. """ result = CliRunner().invoke( repomatic, ["show-metadata", "test_matrix", "--output-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) == CELL_STATE_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_marks_an_empty_intersection(): """A cell no job occupies renders the placeholder.""" assert format_matrix_cell(None) == EMPTY_CELL
[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( "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
[docs] def test_unknown_metadata_keys_reads_every_step(): """Each step calling the subcommand is listed, with the keys it gets wrong.""" workflows = { Path("ci.yaml"): { "jobs": { "metadata": { "steps": [ {"run": "uvx 'repomatic==8.0.0' show-metadata package_name"}, {"run": "repomatic show-metadata harvest_yield is_bot"}, {"run": "repomatic metadata"}, {"run": "echo show-metadata"}, {"uses": "actions/checkout"}, ] } } } } assert unknown_metadata_keys(workflows, "repomatic") == [ MetadataRequest(Path("ci.yaml"), "metadata", ("package_name",), (), False), MetadataRequest( Path("ci.yaml"), "metadata", ("harvest_yield", "is_bot"), ("harvest_yield",), False, ), MetadataRequest(Path("ci.yaml"), "metadata", (), (), True), ]
[docs] def test_metadata_reads_the_shared_pyproject_parse(tmp_path, monkeypatch): """The singleton reads the same cached document every other reader gets.""" metadata = metadata_from_pyproject( tmp_path, monkeypatch, '[project]\nname = "fig"\n' ) assert metadata.pyproject_toml is read_pyproject_toml(tmp_path)
[docs] def test_metadata_config_follows_the_published_config(tmp_path, monkeypatch): """The command's published config is the singleton's config too.""" (tmp_path / "pyproject.toml").write_text( '[tool.repomatic]\nminimum-release-age = "2 weeks"\n', encoding="UTF-8" ) monkeypatch.chdir(tmp_path) published = Config(minimum_release_age="3 days") publish_config(published) assert Metadata().config is published
[docs] def test_metadata_config_warns_once_about_an_unknown_key(tmp_path, monkeypatch, caplog): """The singleton and a library helper report a stale key once between them.""" (tmp_path / "pyproject.toml").write_text( "[tool.repomatic]\nmango-option = true\n", encoding="UTF-8" ) monkeypatch.chdir(tmp_path) assert Metadata().config == load_repomatic_config() assert caplog.text.count("Unknown configuration option(s)") == 1
[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( ("matrix_config", "expected"), ( pytest.param("", LATEST_STABLE, id="default-axis"), pytest.param( f'remove = {{ python-version = ["{LATEST_STABLE}"] }}\n', TEST_PYTHON_FULL[0], id="removed", ), pytest.param( f'replace = {{ python-version = {{ "{LATEST_STABLE}" = "3.99" }} }}\n', "3.99", id="replaced", ), pytest.param( f"remove = {{ python-version = {json.dumps(PLAIN_RELEASES)} }}\n", None, id="no-plain-release", ), ), ) def test_latest_stable_python_reads_the_configured_axis( tmp_path, monkeypatch, matrix_config, expected ): """The key follows the project's axis, and skips flavors and dev versions. The free-threaded flavor and the in-development version stay on the full matrix in every case here, so an axis left with only those has no answer. """ content = ( '[project]\nname = "fruit-salad"\nversion = "1.0.0"\n\n' f"[tool.repomatic.test-matrix]\n{matrix_config}" ) metadata = metadata_from_pyproject(tmp_path, monkeypatch, content) assert metadata.latest_stable_python == expected
[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