Source code for tests.test_config

# 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 the `[tool.repomatic]` schema and its loader."""

from __future__ import annotations

from dataclasses import MISSING, fields as dc_fields

import pytest

from repomatic.cli.repo import (
    config_reference_rows,
    format_config_default,
    format_config_type,
)
from repomatic.config import (
    SITE_DEPLOY_TARGETS,
    Config,
    LintRepoConfig,
    MetricsConfig,
    config_key_paths,
    config_reference,
    load_repomatic_config,
    publish_config,
)


[docs] def test_default_locations_target_claude_code(): """Skills, subagents and settings land where Claude Code reads them.""" config = Config() assert config.skills_location == "./.claude/skills/" assert config.subagents_location == "./.claude/agents/" assert config.settings_location == "./.claude/settings.json"
[docs] @pytest.mark.parametrize( ("key", "value", "expected"), ( # A key of a nested table. pytest.param("metrics.sync", {"metrics": {"sync": True}}, True, id="nested"), # A flat field whose dotted path is only metadata. pytest.param("debug.sync", {"debug": {"sync": True}}, True, id="flat-dotted"), pytest.param( "notification.unsubscribe", {"notification": {"unsubscribe": True}}, True, id="flat-dotted-other-table", ), # A flat field named after itself. pytest.param("setup-guide", {"setup-guide": False}, False, id="flat"), pytest.param( "awesome-template.sync", {"awesome-template": {"sync": False}}, False, id="flat-dotted-off", ), ), ) def test_is_enabled_reads_the_key_the_user_writes(key, value, expected): """Every shape of `[tool.repomatic]` key reaches the field that stores it.""" config = load_repomatic_config({"tool": {"repomatic": value}}) assert config.is_enabled(key) is expected assert Config().is_enabled(key) is not expected
[docs] @pytest.mark.parametrize("key", ("metrics.skip", "flavor.agent", "mango.sync", "")) def test_is_enabled_rejects_a_key_with_no_field(key): """A gate wired to a missing key raises instead of reading as switched off.""" with pytest.raises(ValueError, match="has no"): Config().is_enabled(key)
[docs] def test_config_key_paths_cover_every_reference_row(): """The gate map and the reference table agree on the set of keys.""" assert set(config_key_paths()) == {key for key, *_ in config_reference()}
[docs] @pytest.mark.parametrize( "table", ( pytest.param({"flavor": {"agent": "claude_code"}}, id="flavor"), pytest.param({"metrics": {"skip": {"papaya": "No forge."}}}, id="metrics-skip"), ), ) def test_removed_keys_warn_as_unknown(table, caplog): """A key `8.0.0` removed is an unknown key, reported rather than fatal.""" config = load_repomatic_config({"tool": {"repomatic": table}}) assert isinstance(config, Config) assert "Unknown configuration option(s)" in caplog.text assert not hasattr(config, "flavor") assert not hasattr(MetricsConfig(), "skip")
TWO_WEEKS_COOLDOWN = """\ [tool.repomatic] minimum-release-age = "2 weeks" """ """A `pyproject.toml` whose cooldown differs from the default one."""
[docs] def test_published_config_wins_over_the_working_directory(tmp_path, monkeypatch): """A published config answers every no-argument load, whatever the file says. The CLI resolves its configuration through click-extra (an upward search, the user folder, `--config`), and a library helper reading `./pyproject.toml` alone would disagree with it. """ (tmp_path / "pyproject.toml").write_text(TWO_WEEKS_COOLDOWN, encoding="UTF-8") monkeypatch.chdir(tmp_path) assert load_repomatic_config().minimum_release_age == "2 weeks" published = Config(minimum_release_age="3 days") publish_config(published) assert load_repomatic_config() is published assert load_repomatic_config(project_root=tmp_path) is published publish_config(None) assert load_repomatic_config().minimum_release_age == "2 weeks"
[docs] def test_published_config_leaves_another_project_alone(tmp_path, monkeypatch): """A project other than the working directory still reads its own file.""" other = tmp_path / "orchard" other.mkdir() (other / "pyproject.toml").write_text(TWO_WEEKS_COOLDOWN, encoding="UTF-8") monkeypatch.chdir(tmp_path) publish_config(Config(minimum_release_age="3 days")) assert load_repomatic_config(project_root=other).minimum_release_age == "2 weeks"
[docs] def test_config_holds_plain_data(tmp_path, monkeypatch): """A config read from the cached `tomlrt` document holds no `tomlrt` object. The document stays shared and keeps its formatting trivia, so the config built from it gets plain copies. """ content = """\ [tool.repomatic] exclude = ["skills"] [tool.repomatic.labels.file-rules] "🥭 mango" = ["orchard/**"] """ (tmp_path / "pyproject.toml").write_text(content, encoding="UTF-8") monkeypatch.chdir(tmp_path) config = load_repomatic_config() assert type(config.exclude) is list assert type(config.labels.file_rules) is dict assert type(config.labels.file_rules["🥭 mango"]) is list
[docs] def test_load_repomatic_config_defaults(tmp_path, monkeypatch): """Test that load_repomatic_config returns a Config instance with defaults.""" monkeypatch.chdir(tmp_path) config = load_repomatic_config() assert isinstance(config, Config) assert config.dependency_graph.output == "./docs/assets/dependencies.mmd" assert config.dependency_graph.all_groups is True assert config.dependency_graph.all_extras is True assert config.dependency_graph.no_groups == [] assert config.dependency_graph.no_extras == [] assert config.dependency_graph.level is None assert config.nuitka_enabled is True assert config.nuitka_entry_points == [] assert config.labels.extra_files == [] assert config.pypi_package_history == [] assert config.setup_guide is True assert config.workflow.sync is True assert config.exclude == [] assert config.include == []
[docs] def test_load_repomatic_config_custom_values(tmp_path, monkeypatch): """Test that load_repomatic_config reads custom values from pyproject.toml.""" pyproject_content = """\ [project] name = "test-project" version = "1.0.0" [tool.repomatic] dependency-graph.output = "./custom/deps.mmd" nuitka.enabled = false """ (tmp_path / "pyproject.toml").write_text(pyproject_content, encoding="UTF-8") monkeypatch.chdir(tmp_path) config = load_repomatic_config() assert config.dependency_graph.output == "./custom/deps.mmd" assert config.nuitka_enabled is False
[docs] def test_load_repomatic_config_with_preloaded_data(): """Test that load_repomatic_config accepts pre-parsed pyproject data.""" data = { "tool": { "repomatic": { "dependency-graph": {"output": "./custom/deps.mmd"}, }, }, } config = load_repomatic_config(data) assert config.dependency_graph.output == "./custom/deps.mmd" # Other defaults are still present. assert config.gitignore.location == "./.gitignore"
[docs] @pytest.mark.parametrize("target", sorted(SITE_DEPLOY_TARGETS)) def test_site_deploy_accepts_every_implemented_target(target): """Each target names a deploy job in `docs.yaml`, so each must load.""" assert ( load_repomatic_config({ "tool": {"repomatic": {"site": {"deploy": target}}} }).site_deploy == target )
[docs] def test_site_deploy_rejects_a_target_with_no_job_behind_it(): """An unimplemented target would publish nowhere, from a green workflow. The `docs.yaml` jobs gate on equality with a known value, so a typo or a host nobody wired up simply matches neither, and the run reports success having deployed nothing at all. """ with pytest.raises(ValueError, match=r"Unsupported site\.deploy 'netlify'"): load_repomatic_config({"tool": {"repomatic": {"site": {"deploy": "netlify"}}}})
[docs] def test_site_cloudflare_settings_load_from_their_table(): """The drift-check declarations ride the same `site.*` table.""" config = load_repomatic_config({ "tool": { "repomatic": { "site": { "deploy": "cloudflare-pages", "cloudflare-project": "papaya-site", "cloudflare-compatibility-date": "2026-06-16", "cloudflare-placement": "smart", } } } }) assert config.site_deploy == "cloudflare-pages" assert config.site_cloudflare_project == "papaya-site" assert config.site_cloudflare_compatibility_date == "2026-06-16" assert config.site_cloudflare_placement == "smart"
[docs] def test_site_cloudflare_placement_rejects_unknown_modes(): """A bad placement mode must die here, not in a Cloudflare PATCH response. The value is written verbatim to the live project by `cloudflare-pages --apply`, so the API rejection would surface far from the `pyproject.toml` line that caused it. """ with pytest.raises( ValueError, match=r"Unsupported site\.cloudflare-placement 'clever'" ): load_repomatic_config({ "tool": {"repomatic": {"site": {"cloudflare-placement": "clever"}}} })
[docs] def test_site_cloudflare_r2_keys_load_from_their_table(): config = load_repomatic_config({ "tool": { "repomatic": { "site": { "cloudflare-r2-bucket": "papaya-files", "cloudflare-r2-domain": "files.example.com", } } } }) assert config.site_cloudflare_r2_bucket == "papaya-files" assert config.site_cloudflare_r2_domain == "files.example.com"
[docs] @pytest.mark.parametrize( "site", ( pytest.param({"cloudflare-r2-bucket": "papaya-files"}, id="bucket-alone"), pytest.param({"cloudflare-r2-domain": "files.example.com"}, id="domain-alone"), ), ) def test_site_cloudflare_r2_keys_come_as_a_pair(site): """A bucket and its host are declared together, or the loader refuses. A bucket with nowhere to redirect, or a host with nothing to upload to, would only fail at the first oversized file, deep in a deploy. """ with pytest.raises(ValueError, match=r"go together"): load_repomatic_config({"tool": {"repomatic": {"site": site}}})
[docs] def test_site_cloudflare_r2_domain_must_be_a_bare_hostname(): """The offload writes `https://{domain}/{key}`: a scheme would double.""" with pytest.raises(ValueError, match=r"must be a bare hostname"): load_repomatic_config({ "tool": { "repomatic": { "site": { "cloudflare-r2-bucket": "papaya-files", "cloudflare-r2-domain": "https://files.example.com", } } } })
[docs] def test_lint_repo_declined_keeps_its_keys_verbatim(): """A declined entry is named by a tool's own keys, so none may be rewritten. Key normalization would turn `extend-select` into `extend_select`, which names no key of `[tool.ruff]`: the entry would then decline nothing. """ config = load_repomatic_config({ "tool": { "repomatic": { "lint-repo": { "declined": { "ruff": {"lint": {"extend-select": {"PLW1514": "Orchard."}}}, "pytest": {"addopts": {"--numprocesses=logical": "Harvest."}}, "mypy": {"warn_unreachable": "Season."}, } } } } }) assert config.lint_repo.declined_entries() == { ("ruff", "lint", "extend-select", "PLW1514"): "Orchard.", ("pytest", "addopts", "--numprocesses=logical"): "Harvest.", ("mypy", "warn_unreachable"): "Season.", }
[docs] @pytest.mark.parametrize( ("declined", "expected"), ( pytest.param({}, {}, id="nothing-declined"), pytest.param({"pytest": "Orchard."}, {("pytest",): "Orchard."}, id="tool"), pytest.param({"pytest": {}}, {("pytest",): {}}, id="empty-tool-table"), pytest.param( {"pytest": {"addopts": {}}}, {("pytest", "addopts"): {}}, id="empty-key-table", ), pytest.param( {"pytest": {"testpaths": ["tests"]}}, {("pytest", "testpaths"): ["tests"]}, id="list", ), ), ) def test_lint_repo_declined_entries_keep_what_is_not_a_reason(declined, expected): """A leaf holding no reason stays in the answer, for `lint-repo` to report.""" assert LintRepoConfig(declined=declined).declined_entries() == expected
[docs] def test_load_repomatic_config_warns_unknown_keys(tmp_path, monkeypatch, caplog): """Unknown keys in [tool.repomatic] produce a warning, not an error.""" pyproject_content = """\ [project] name = "test-project" version = "1.0.0" [tool.repomatic] nonexistent-option = true """ (tmp_path / "pyproject.toml").write_text(pyproject_content, encoding="UTF-8") monkeypatch.chdir(tmp_path) config = load_repomatic_config() assert isinstance(config, Config) # The warning comes from click-extra's schema layer (warn_unknown), which # names keys in their normalized snake_case form. assert "Unknown configuration option(s): nonexistent_option" in caplog.text
[docs] def test_load_repomatic_config_warns_unknown_keys_once(tmp_path, monkeypatch, caplog): """Re-loading the same project reports its unknown keys only once. Every helper needing a setting re-loads the config, so without the per-project dedup a single stale key floods each invocation's output. """ pyproject_content = """\ [project] name = "test-project" version = "1.0.0" [tool.repomatic] nonexistent-option = true """ (tmp_path / "pyproject.toml").write_text(pyproject_content, encoding="UTF-8") monkeypatch.chdir(tmp_path) load_repomatic_config() load_repomatic_config() assert caplog.text.count("Unknown configuration option(s)") == 1
[docs] def test_config_reference(): """Config reference table covers all Config fields with descriptions.""" rows = config_reference() # Count expected rows: one per flat Config field, plus sub-fields for # nested dataclass fields (which are expanded, not listed as a single row). expected_rows = 0 for f in dc_fields(Config): default = f.default_factory() if f.default_factory is not MISSING else f.default if hasattr(default, "__dataclass_fields__"): expected_rows += len(dc_fields(type(default))) # type: ignore[arg-type] else: expected_rows += 1 assert len(rows) == expected_rows # Every row has a non-empty description. for option, ftype, default, desc in rows: assert desc, f"Empty description for {option}"
[docs] @pytest.mark.parametrize( ("annotation", "expected"), [ ("str", "str"), ("str | None", "str"), ("list[str]", "list[str]"), ("list[dict[str, str]]", "list\\[dict[str, str]\\]"), ("dict[str, list[str]] | None", "dict\\[str, list[str]\\]"), ], ) def test_show_config_type_cell(annotation, expected): """A nested generic escapes its outer brackets, and `| None` is dropped.""" assert format_config_type(annotation) == expected
[docs] @pytest.mark.parametrize( ("value", "expected"), [ (None, "*(none)*"), (True, "`true`"), (3, "`3`"), ("1 week", '`"1 week"`'), ("apple\nkiwi", "*(see example)*"), ([], "`[]`"), (["apple"], "`['apple']`"), ], ) def test_show_config_default_cell(value, expected): """Each default type renders in the cell form the reference table uses.""" assert format_config_default(value) == expected
[docs] def test_show_config_rows_cover_every_key(): """The rendered table quotes every key the gate map knows, once.""" options = [row[0] for row in config_reference_rows()] assert all(option.startswith("`") and option.endswith("`") for option in options) assert sorted(option.strip("`") for option in options) == sorted(config_key_paths())