DEVX-5: feat: add DEFAULT_INFRASTRUCTURE and configurable task prefix
This commit is contained in:
@@ -21,6 +21,7 @@ from click.testing import CliRunner
|
||||
|
||||
import devx.ci.classify_changes as classify_changes_mod
|
||||
from devx.ci.classify_changes import (
|
||||
DEFAULT_INFRASTRUCTURE,
|
||||
ChangeClassifier,
|
||||
ClassificationResult,
|
||||
ClassifierConfig,
|
||||
@@ -108,11 +109,11 @@ class TestMatchesGlob:
|
||||
|
||||
|
||||
class TestClassifierConfig:
|
||||
def test_from_pyproject_loads_config(self, tmp_path: Path) -> None:
|
||||
def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
"[tool.devx.classify]\n"
|
||||
'infrastructure = [".gitea/**", "tests/**"]\n'
|
||||
'infrastructure = ["scripts/**"]\n'
|
||||
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
|
||||
'user_facing_overrides = ["docs/important.py"]\n'
|
||||
"\n"
|
||||
@@ -120,39 +121,62 @@ class TestClassifierConfig:
|
||||
'ansible = ["ansible/**"]\n'
|
||||
)
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert config.infrastructure == [".gitea/**", "tests/**"]
|
||||
# Project-specific path is merged with defaults
|
||||
assert "scripts/**" in config.infrastructure
|
||||
assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
|
||||
assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
|
||||
assert config.use_defaults is True
|
||||
assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
|
||||
assert config.user_facing_overrides == ["docs/important.py"]
|
||||
assert config.tags == {"ansible": ["ansible/**"]}
|
||||
|
||||
def test_from_pyproject_missing_file(self) -> None:
|
||||
def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert config.infrastructure == [".gitea/**"]
|
||||
assert "tests/**" not in config.infrastructure # no defaults
|
||||
assert config.use_defaults is False
|
||||
|
||||
def test_from_pyproject_missing_file_returns_defaults(self) -> None:
|
||||
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
|
||||
assert config.infrastructure == []
|
||||
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||
assert config.infrastructure_overrides == []
|
||||
assert config.user_facing_overrides == []
|
||||
assert config.tags == {}
|
||||
assert config.use_defaults is True
|
||||
|
||||
def test_from_pyproject_missing_section(self, tmp_path: Path) -> None:
|
||||
def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[project]\nname = "test"\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert config.infrastructure == []
|
||||
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||
|
||||
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**"]\n')
|
||||
pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert config.infrastructure == [".gitea/**"]
|
||||
assert "scripts/**" in config.infrastructure
|
||||
assert ".gitea/**" in config.infrastructure # merged with defaults
|
||||
assert config.infrastructure_overrides == []
|
||||
assert config.user_facing_overrides == []
|
||||
assert config.tags == {}
|
||||
|
||||
def test_defaults_are_empty(self) -> None:
|
||||
def test_defaults_are_empty_for_bare_constructor(self) -> None:
|
||||
"""ClassifierConfig() without from_pyproject has empty lists."""
|
||||
config = ClassifierConfig()
|
||||
assert config.infrastructure == []
|
||||
assert config.infrastructure_overrides == []
|
||||
assert config.user_facing_overrides == []
|
||||
assert config.tags == {}
|
||||
assert config.use_defaults is True
|
||||
|
||||
def test_default_infrastructure_is_non_empty(self) -> None:
|
||||
"""The framework ships with a curated default infrastructure list."""
|
||||
assert len(DEFAULT_INFRASTRUCTURE) > 0
|
||||
assert ".gitea/**" in DEFAULT_INFRASTRUCTURE
|
||||
assert "tests/**" in DEFAULT_INFRASTRUCTURE
|
||||
assert "docs/**" in DEFAULT_INFRASTRUCTURE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -438,6 +462,26 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_default_mode_displays_tags(
|
||||
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
|
||||
) -> None:
|
||||
"""Default mode shows tag files when tags are configured."""
|
||||
mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Ansible files" in result.output
|
||||
assert "ansible/tasks/main.yml" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
@@ -480,19 +524,33 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
@@ -516,15 +574,38 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible"])
|
||||
assert result.exit_code == 0
|
||||
assert "Ansible changes detected" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "nonexistent"])
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown check category" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
@@ -536,7 +617,17 @@ class TestMain:
|
||||
|
||||
|
||||
class TestGithubOutput:
|
||||
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _make_classifier_with_ansible(self) -> ChangeClassifier:
|
||||
return ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**", "AGENTS.md"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
@@ -549,7 +640,9 @@ class TestGithubOutput:
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
@@ -560,7 +653,9 @@ class TestGithubOutput:
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
@@ -578,7 +673,9 @@ class TestGithubOutput:
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
@@ -590,3 +687,25 @@ class TestGithubOutput:
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_no_tags_outputs_all_tags_true(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When no tags exist, only user-facing-changed is written."""
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={},
|
||||
)
|
||||
)
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
# No tag outputs since no tags are configured
|
||||
assert "ansible-changed" not in content
|
||||
|
||||
Reference in New Issue
Block a user