Public Access
feat: sync missing features from v0.49.x line to master
The v0.49.x tag line diverged from origin/master, leaving many features only accessible via tags but not on the master branch. New modules: - ci/cancel_superseded_runs.py — cancel superseded CI runs - ci/check_workflow_artifact_deps.py — validate artifact deps - ci/check_workflow_tofu_init.py — validate tofu init steps - tools/check_alert_rules.py — validate Prometheus alert rules - tools/check_ansible_set_fact_to_json.py — lint set_fact usage - tools/check_docker_init.py — validate Docker init scripts - utils/jinja.py — Jinja2 template utilities - utils/ui.py — UI/console utilities Modified modules: - distribute_molecule.py: add --include-roles/--exclude-roles - utils/api.py: add container.credentials for private registry auth - install_tools.py: retry ansible-galaxy on transient timeouts - setup_image.py: skip dep resolution with --no-deps - cli.py: register new commands - i18n.py: add new translation keys Also removes accidentally committed .vale/styles/Google/ files. Test results: 2195 passed, 100% coverage. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
d2aa4c6298
commit
bbb14c71e9
@@ -0,0 +1,172 @@
|
||||
"""Unit tests for devx.ci.cancel_superseded_runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import devx.ci.cancel_superseded_runs as mod
|
||||
from devx.ci.cancel_superseded_runs import _api_request, cancel_run, list_running_runs, main
|
||||
|
||||
_HTTP_NO_CONTENT = mod._HTTP_NO_CONTENT
|
||||
_PAGE_SIZE = mod._PAGE_SIZE
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_http_no_content_is_204(self) -> None:
|
||||
assert _HTTP_NO_CONTENT == 204
|
||||
|
||||
def test_page_size_is_50(self) -> None:
|
||||
assert _PAGE_SIZE == 50
|
||||
|
||||
|
||||
class TestApiRequest:
|
||||
def test_returns_empty_for_204(self) -> None:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = _HTTP_NO_CONTENT
|
||||
mock_resp.read.return_value = b""
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=None)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
result = _api_request("POST", "/repos/test/actions/runs/1/cancel", "tok", "https://x")
|
||||
assert result == {}
|
||||
|
||||
def test_returns_json_for_200(self) -> None:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.read.return_value = json.dumps({"id": 1}).encode()
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=None)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
result = _api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
||||
assert result == {"id": 1}
|
||||
|
||||
def test_http_error_raises(self) -> None:
|
||||
err = urllib.error.HTTPError("x", 500, "err", {}, None)
|
||||
err.read = MagicMock(return_value=b"error body")
|
||||
with patch("urllib.request.urlopen", side_effect=err):
|
||||
with pytest.raises(urllib.error.HTTPError):
|
||||
_api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
||||
|
||||
def test_url_error_raises(self) -> None:
|
||||
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("fail")):
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
_api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
||||
|
||||
|
||||
class TestListRunningRuns:
|
||||
def test_paginates_until_empty(self) -> None:
|
||||
page1 = {"workflow_runs": [{"id": 1}, {"id": 2}], "total_count": 2}
|
||||
page2 = {"workflow_runs": [], "total_count": 2}
|
||||
responses = iter([page1, page2])
|
||||
with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)):
|
||||
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||
assert len(runs) == 2
|
||||
|
||||
def test_empty_first_page(self) -> None:
|
||||
with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}):
|
||||
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||
assert runs == []
|
||||
|
||||
def test_stops_at_page_size(self) -> None:
|
||||
full_page = {"workflow_runs": [{"id": i} for i in range(_PAGE_SIZE)], "total_count": _PAGE_SIZE + 1}
|
||||
half_page = {"workflow_runs": [{"id": 99}], "total_count": _PAGE_SIZE + 1}
|
||||
responses = iter([full_page, half_page])
|
||||
with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)):
|
||||
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||
assert len(runs) == _PAGE_SIZE + 1
|
||||
|
||||
def test_uses_in_progress_status(self) -> None:
|
||||
with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}) as mock_req:
|
||||
list_running_runs("owner/repo", "tok", "https://x")
|
||||
path = mock_req.call_args.args[1]
|
||||
assert "status=in_progress" in path
|
||||
assert "status=running" not in path
|
||||
|
||||
def test_accepts_bare_list(self) -> None:
|
||||
with patch.object(mod, "_api_request", return_value=[{"id": 1}, {"id": 2}]):
|
||||
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||
assert len(runs) == 2
|
||||
|
||||
|
||||
class TestCancelRun:
|
||||
def test_success_returns_true(self) -> None:
|
||||
with patch.object(mod, "_api_request", return_value={}):
|
||||
assert cancel_run("owner/repo", 123, "tok", "https://x") is True
|
||||
|
||||
def test_http_error_returns_false(self) -> None:
|
||||
with patch.object(mod, "_api_request", side_effect=urllib.error.HTTPError("x", 500, "err", {}, None)):
|
||||
assert cancel_run("owner/repo", 123, "tok", "https://x") is False
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_no_token_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "1", "--head-branch", "feat"])
|
||||
assert main() == 0
|
||||
|
||||
def test_no_superseded_runs(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||
with patch.object(mod, "list_running_runs", return_value=[]):
|
||||
assert main() == 0
|
||||
|
||||
def test_cancels_superseded(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
runs = [
|
||||
{"id": 5, "head_branch": "feat"},
|
||||
{"id": 8, "head_branch": "feat"},
|
||||
{"id": 12, "head_branch": "other"},
|
||||
]
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||
with patch.object(mod, "list_running_runs", return_value=runs):
|
||||
with patch.object(mod, "cancel_run", return_value=True) as mock_cancel:
|
||||
assert main() == 0
|
||||
cancelled_ids = [call.args[1] for call in mock_cancel.call_args_list]
|
||||
assert cancelled_ids == [5, 8]
|
||||
|
||||
def test_dry_run_does_not_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
runs = [{"id": 5, "head_branch": "feat"}]
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat", "--dry-run"],
|
||||
)
|
||||
with patch.object(mod, "list_running_runs", return_value=runs):
|
||||
with patch.object(mod, "cancel_run", return_value=True) as mock_cancel:
|
||||
assert main() == 0
|
||||
assert mock_cancel.call_count == 0
|
||||
|
||||
def test_cancel_failure_continues(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
runs = [{"id": 5, "head_branch": "feat"}, {"id": 8, "head_branch": "feat"}]
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||
with patch.object(mod, "list_running_runs", return_value=runs):
|
||||
with patch.object(mod, "cancel_run", side_effect=[False, True]):
|
||||
assert main() == 0
|
||||
|
||||
def test_404_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||
err = urllib.error.HTTPError("x", 404, "Not Found", {}, None)
|
||||
with patch.object(mod, "list_running_runs", side_effect=err):
|
||||
assert main() == 0
|
||||
|
||||
def test_400_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||
err = urllib.error.HTTPError("x", 400, "Bad Request", {}, None)
|
||||
with patch.object(mod, "list_running_runs", side_effect=err):
|
||||
assert main() == 0
|
||||
|
||||
def test_500_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||
err = urllib.error.HTTPError("x", 500, "Server Error", {}, None)
|
||||
with patch.object(mod, "list_running_runs", side_effect=err):
|
||||
with pytest.raises(urllib.error.HTTPError):
|
||||
main()
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Unit tests for devx.ci.check_workflow_artifact_deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.check_workflow_artifact_deps import (
|
||||
_check_workflow,
|
||||
_extract_artifact_info,
|
||||
_is_artifact_action,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestIsArtifactAction:
|
||||
def test_upload_action_gitea(self):
|
||||
assert _is_artifact_action("christopherhx/gitea-upload-artifact@v4", ("upload-artifact",))
|
||||
|
||||
def test_upload_action_github(self):
|
||||
assert _is_artifact_action("actions/upload-artifact@v4", ("upload-artifact",))
|
||||
|
||||
def test_download_action(self):
|
||||
assert _is_artifact_action("christopherhx/gitea-download-artifact@v4", ("download-artifact",))
|
||||
|
||||
def test_non_artifact_action(self):
|
||||
assert not _is_artifact_action("actions/checkout@v4", ("upload-artifact",))
|
||||
|
||||
def test_empty_string(self):
|
||||
assert not _is_artifact_action("", ("upload-artifact",))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _is_artifact_action("Actions/Upload-Artifact@v4", ("upload-artifact",))
|
||||
|
||||
|
||||
class TestExtractArtifactInfo:
|
||||
def test_uploads_and_downloads(self):
|
||||
import yaml
|
||||
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- name: Upload config
|
||||
uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: config-${{ github.run_id }}
|
||||
consumer:
|
||||
needs: [producer]
|
||||
steps:
|
||||
- name: Download config
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: config-${{ github.run_id }}
|
||||
""").strip()
|
||||
wf = yaml.safe_load(workflow_yaml)
|
||||
uploads, downloads = _extract_artifact_info(wf)
|
||||
assert uploads == {"config-${{ github.run_id }}": ["producer"]}
|
||||
assert downloads == [("consumer", "config-${{ github.run_id }}", "Download config")]
|
||||
|
||||
def test_no_artifacts(self):
|
||||
import yaml
|
||||
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
""").strip()
|
||||
wf = yaml.safe_load(workflow_yaml)
|
||||
uploads, downloads = _extract_artifact_info(wf)
|
||||
assert uploads == {}
|
||||
assert downloads == []
|
||||
|
||||
def test_multiple_uploaders_same_artifact(self):
|
||||
import yaml
|
||||
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer-a:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: shared
|
||||
producer-b:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: shared
|
||||
""").strip()
|
||||
wf = yaml.safe_load(workflow_yaml)
|
||||
uploads, downloads = _extract_artifact_info(wf)
|
||||
assert uploads == {"shared": ["producer-a", "producer-b"]}
|
||||
|
||||
def test_step_without_name(self):
|
||||
import yaml
|
||||
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
needs: [producer]
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
wf = yaml.safe_load(workflow_yaml)
|
||||
uploads, downloads = _extract_artifact_info(wf)
|
||||
assert downloads == [("consumer", "data", "")]
|
||||
|
||||
def test_upload_without_name_skipped(self):
|
||||
import yaml
|
||||
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
path: ./dist
|
||||
""").strip()
|
||||
wf = yaml.safe_load(workflow_yaml)
|
||||
uploads, downloads = _extract_artifact_info(wf)
|
||||
assert uploads == {}
|
||||
|
||||
|
||||
class TestCheckWorkflow:
|
||||
def test_valid_dependency(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- name: Upload config
|
||||
uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
consumer:
|
||||
needs: [producer]
|
||||
steps:
|
||||
- name: Download config
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
def test_missing_dependency(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- name: Upload config
|
||||
uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
consumer:
|
||||
needs: [other-job]
|
||||
steps:
|
||||
- name: Download config
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
errors = _check_workflow(f)
|
||||
assert len(errors) == 1
|
||||
assert "consumer" in errors[0]
|
||||
assert "producer" in errors[0]
|
||||
|
||||
def test_no_needs_at_all(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
errors = _check_workflow(f)
|
||||
assert len(errors) == 1
|
||||
assert "consumer" in errors[0]
|
||||
|
||||
def test_artifact_not_uploaded_in_workflow(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
consumer:
|
||||
steps:
|
||||
- name: Download external
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: external-artifact
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
def test_multiple_uploaders_one_in_needs(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer-a:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: shared
|
||||
producer-b:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: shared
|
||||
consumer:
|
||||
needs: [producer-a, other]
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: shared
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
def test_string_needs(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
needs: producer
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
def test_needs_null(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
needs: null
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
errors = _check_workflow(f)
|
||||
assert len(errors) == 1
|
||||
assert "consumer" in errors[0]
|
||||
|
||||
def test_invalid_yaml(self, tmp_path: Path):
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text("jobs: [invalid yaml: {")
|
||||
errors = _check_workflow(f)
|
||||
assert len(errors) == 1
|
||||
assert "cannot parse YAML" in errors[0]
|
||||
|
||||
def test_not_a_dict(self, tmp_path: Path):
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text("just a string")
|
||||
errors = _check_workflow(f)
|
||||
assert len(errors) == 1
|
||||
assert "not a valid workflow" in errors[0]
|
||||
|
||||
def test_no_jobs(self, tmp_path: Path):
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text("name: empty\non: push\n")
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
def test_continue_on_error_guard(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- name: Upload config
|
||||
uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
consumer:
|
||||
needs: [other-job]
|
||||
steps:
|
||||
- name: Download config
|
||||
continue-on-error: true
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
def test_continue_on_error_false_still_errors(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- name: Upload config
|
||||
uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
consumer:
|
||||
needs: [other-job]
|
||||
steps:
|
||||
- name: Download config
|
||||
continue-on-error: false
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: config
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
errors = _check_workflow(f)
|
||||
assert len(errors) == 1
|
||||
assert "consumer" in errors[0]
|
||||
|
||||
def test_job_with_no_steps(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
empty:
|
||||
runs-on: docker
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
assert _check_workflow(f) == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_passes_when_valid(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
needs: [producer]
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflows-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_fails_when_missing_dep(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflows-dir", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
assert "consumer" in result.output
|
||||
|
||||
def test_specific_workflow_file(self, tmp_path: Path):
|
||||
workflow_yaml = textwrap.dedent("""
|
||||
jobs:
|
||||
producer:
|
||||
steps:
|
||||
- uses: christopherhx/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
consumer:
|
||||
needs: [producer]
|
||||
steps:
|
||||
- uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: data
|
||||
""").strip()
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text(workflow_yaml)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflow", str(f)])
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Unit tests for devx.ci.check_workflow_tofu_init."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.ci.check_workflow_tofu_init as mod
|
||||
from devx.ci.check_workflow_tofu_init import _check_workflow, main
|
||||
|
||||
|
||||
def _write_workflow(tmp_path: Path, content: str) -> Path:
|
||||
filepath = tmp_path / "test.yml"
|
||||
filepath.write_text(textwrap.dedent(content), encoding="utf-8")
|
||||
return filepath
|
||||
|
||||
|
||||
class TestCheckWorkflow:
|
||||
def test_passes_when_tofu_init_present(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/create_production_deployment.py --phase tofu-init
|
||||
- run: python3 scripts/preflight_deploy.py --env production
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
def test_fails_when_tofu_init_missing(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/preflight_deploy.py --env production
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "preflight" in errors[0]
|
||||
assert "tofu-init" in errors[0]
|
||||
|
||||
def test_passes_when_direct_tofu_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu init
|
||||
- run: tofu output -json
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
def test_fails_when_direct_tofu_output_without_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
check:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu output -json
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "check" in errors[0]
|
||||
|
||||
def test_passes_when_no_tofu_usage(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: make lint
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
def test_passes_with_staging_deployment_tofu_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/create_staging_deployment.py --phase tofu-init
|
||||
- run: python3 scripts/create_staging_deployment.py --phase deploy
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
def test_fails_with_tofu_plan_without_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu plan
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "plan" in errors[0]
|
||||
|
||||
def test_fails_with_tofu_apply_without_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu apply -auto-approve
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "apply" in errors[0]
|
||||
|
||||
def test_multiple_jobs_one_missing(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
good:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/create_production_deployment.py --phase tofu-init
|
||||
- run: python3 scripts/preflight_deploy.py --env production
|
||||
bad:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/preflight_deploy.py --env production
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "bad" in errors[0]
|
||||
|
||||
def test_no_steps_passes(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
empty:
|
||||
runs-on: docker
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
def test_destroy_orphans_does_not_require_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/destroy_orphans.py
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None:
|
||||
filepath = tmp_path / "bad.yml"
|
||||
filepath.write_text("jobs: [invalid yaml: {", encoding="utf-8")
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "cannot parse YAML" in errors[0]
|
||||
|
||||
def test_tofu_show_requires_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
show:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu show -json
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||
assert len(errors) == 1
|
||||
assert "show" in errors[0]
|
||||
|
||||
def test_custom_state_scripts(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
custom:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/my_custom_script.py
|
||||
""",
|
||||
)
|
||||
errors = _check_workflow(filepath, {"my_custom_script.py"})
|
||||
assert len(errors) == 1
|
||||
assert "custom" in errors[0]
|
||||
|
||||
def test_step_with_no_run_skipped(self, tmp_path: Path) -> None:
|
||||
"""A step with no 'run' key should be skipped (line 80 continue)."""
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- run: tofu init
|
||||
- run: tofu output
|
||||
""",
|
||||
)
|
||||
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_passes_with_specific_workflow(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu init
|
||||
- run: tofu output
|
||||
""",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflow", str(filepath)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_fails_with_missing_tofu_init(self, tmp_path: Path) -> None:
|
||||
filepath = _write_workflow(
|
||||
tmp_path,
|
||||
"""
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: python3 scripts/preflight_deploy.py --env production
|
||||
""",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflow", str(filepath)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
assert "preflight" in result.output
|
||||
|
||||
def test_checks_all_workflows_by_default(self, tmp_path: Path) -> None:
|
||||
workflows_dir = tmp_path / "workflows"
|
||||
workflows_dir.mkdir()
|
||||
(workflows_dir / "good.yml").write_text(
|
||||
textwrap.dedent("""
|
||||
name: Good
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu init
|
||||
- run: tofu output
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workflows_dir / "bad.yml").write_text(
|
||||
textwrap.dedent("""
|
||||
name: Bad
|
||||
on: push
|
||||
jobs:
|
||||
check:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu output
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflows-dir", str(workflows_dir)])
|
||||
assert result.exit_code == 1
|
||||
assert "bad.yml" in result.output
|
||||
assert "check" in result.output
|
||||
|
||||
def test_all_workflows_pass(self, tmp_path: Path) -> None:
|
||||
workflows_dir = tmp_path / "workflows"
|
||||
workflows_dir.mkdir()
|
||||
(workflows_dir / "ok.yml").write_text(
|
||||
textwrap.dedent("""
|
||||
name: OK
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: tofu init
|
||||
- run: tofu plan
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--workflows-dir", str(workflows_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
@@ -40,6 +40,7 @@ class TestCliGroups:
|
||||
result = runner.invoke(cli, ["molecule", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "distribute" in result.output
|
||||
assert "guard" in result.output
|
||||
assert "all" in result.output
|
||||
|
||||
|
||||
@@ -230,6 +231,13 @@ class TestMoleculeCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_molecule_guard(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["molecule", "guard"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -311,6 +311,61 @@ class TestDiscoverMultiRole:
|
||||
with pytest.raises(click.ClickException):
|
||||
discover_multi_role_scenarios()
|
||||
|
||||
def test_include_roles_filters_to_subset(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
for scenario in ["default"]:
|
||||
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles, include_roles=["docker_base", "crowdsec"])
|
||||
assert ("docker_base", "default") in result
|
||||
assert ("crowdsec", "default") in result
|
||||
assert ("app_container", "default") not in result
|
||||
assert len(result) == 2
|
||||
|
||||
def test_include_roles_case_insensitive(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "other" / "molecule" / "default").mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles, include_roles=["docker_base"])
|
||||
assert ("Docker_Base", "default") in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_exclude_roles_skips_subset(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
for scenario in ["default"]:
|
||||
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base", "crowdsec"])
|
||||
assert ("docker_base", "default") not in result
|
||||
assert ("crowdsec", "default") not in result
|
||||
assert ("app_container", "default") in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_exclude_roles_case_insensitive(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "other" / "molecule" / "default").mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base"])
|
||||
assert ("Docker_Base", "default") not in result
|
||||
assert ("other", "default") in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_include_and_exclude_combined(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
for scenario in ["default"]:
|
||||
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(
|
||||
roles, include_roles=["docker_base", "crowdsec", "app_container"], exclude_roles=["crowdsec"]
|
||||
)
|
||||
assert ("docker_base", "default") in result
|
||||
assert ("crowdsec", "default") not in result
|
||||
assert ("app_container", "default") in result
|
||||
assert len(result) == 2
|
||||
|
||||
def test_default_roles_root_constant(self) -> None:
|
||||
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Unit tests for devx.i18n."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import devx.i18n as i18n_mod
|
||||
from devx.i18n import _, configure_i18n
|
||||
|
||||
|
||||
class TestTranslate:
|
||||
def test_returns_english_by_default(self) -> None:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.delenv("DEVX_LANG", raising=False)
|
||||
assert _("Running tests") == "Running tests"
|
||||
|
||||
def test_returns_key_when_missing(self) -> None:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.delenv("DEVX_LANG", raising=False)
|
||||
assert _("nonexistent.key.xyz") == "nonexistent.key.xyz"
|
||||
|
||||
def test_formats_kwargs(self) -> None:
|
||||
# Find a key with format placeholders
|
||||
for key, translations in i18n_mod.TRANSLATIONS.items():
|
||||
en = translations.get("en", "")
|
||||
if "{" in en:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.delenv("DEVX_LANG", raising=False)
|
||||
result = _(key, **dict.fromkeys(_extract_format_keys(en), "x"))
|
||||
assert "{" not in result
|
||||
return
|
||||
pytest.skip("No key with format placeholders found")
|
||||
|
||||
def test_invalid_lang_falls_back_to_english(self) -> None:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("DEVX_LANG", "fr")
|
||||
assert _("Running tests") == "Running tests"
|
||||
|
||||
def test_bulgarian_translation(self) -> None:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("DEVX_LANG", "bg")
|
||||
# Find a key that has a Bulgarian translation
|
||||
for key, translations in i18n_mod.TRANSLATIONS.items():
|
||||
if "bg" in translations:
|
||||
result = _(key)
|
||||
assert result == translations["bg"]
|
||||
return
|
||||
pytest.skip("No Bulgarian translation found")
|
||||
|
||||
|
||||
class TestConfigureI18n:
|
||||
def test_custom_lang_env_var(self) -> None:
|
||||
configure_i18n(lang_env_var="GRM_LANG")
|
||||
try:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("GRM_LANG", "bg")
|
||||
mp.delenv("DEVX_LANG", raising=False)
|
||||
# Find a key with Bulgarian translation
|
||||
for key, translations in i18n_mod.TRANSLATIONS.items():
|
||||
if "bg" in translations:
|
||||
assert _(key) == translations["bg"]
|
||||
return
|
||||
pytest.skip("No Bulgarian translation found")
|
||||
finally:
|
||||
configure_i18n() # Reset to defaults
|
||||
|
||||
def test_custom_translations_path_env_var(self, tmp_path) -> None:
|
||||
custom_translations = {"custom.key": {"en": "Custom Value", "bg": "Персонализирано"}}
|
||||
custom_file = tmp_path / "custom.json"
|
||||
custom_file.write_text(__import__("json").dumps(custom_translations))
|
||||
|
||||
configure_i18n(translations_path_env_var="GRM_TRANSLATIONS_PATH")
|
||||
try:
|
||||
# Use i18n_mod.TRANSLATIONS (not a stale import) — other tests
|
||||
# may call importlib.reload(devx.i18n), replacing the dict object.
|
||||
translations = i18n_mod.TRANSLATIONS
|
||||
original = dict(translations)
|
||||
translations.update(custom_translations)
|
||||
try:
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("GRM_TRANSLATIONS_PATH", str(custom_file))
|
||||
assert _("custom.key") == "Custom Value"
|
||||
finally:
|
||||
translations.clear()
|
||||
translations.update(original)
|
||||
finally:
|
||||
configure_i18n() # Reset to defaults
|
||||
|
||||
def test_reset_to_defaults(self) -> None:
|
||||
configure_i18n(lang_env_var="GRM_LANG")
|
||||
configure_i18n() # Reset
|
||||
assert i18n_mod._lang_env_var == "DEVX_LANG"
|
||||
assert i18n_mod._translations_path_env_var == "DEVX_TRANSLATIONS_PATH"
|
||||
|
||||
|
||||
def _extract_format_keys(template: str) -> list[str]:
|
||||
"""Extract {key} format placeholders from a template string."""
|
||||
import re
|
||||
|
||||
return re.findall(r"\{(\w+)\}", template)
|
||||
@@ -238,6 +238,33 @@ class TestInstallTea:
|
||||
assert install_tools.install_tea() is True
|
||||
assert (tmp_path / "tea").exists()
|
||||
|
||||
def test_install_fallback_to_second_url(self, tmp_path: Path) -> None:
|
||||
"""First URL fails (403), second URL succeeds."""
|
||||
call_count = [0]
|
||||
|
||||
def _download_side_effect(url: str, dest: Path) -> None:
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise OSError("HTTP Error 403: Forbidden")
|
||||
Path(dest).write_bytes(b"binary")
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_download_side_effect):
|
||||
assert install_tools.install_tea() is True
|
||||
assert (tmp_path / "tea").exists()
|
||||
assert call_count[0] == 2
|
||||
|
||||
def test_install_all_urls_fail(self, tmp_path: Path) -> None:
|
||||
"""All URLs fail — should raise ClickException."""
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=OSError("403 Forbidden")):
|
||||
with pytest.raises(ClickException, match="Failed to download tea"):
|
||||
install_tools.install_tea()
|
||||
|
||||
|
||||
class TestInstallHadolint:
|
||||
def test_already_installed(self) -> None:
|
||||
|
||||
@@ -7,7 +7,6 @@ import subprocess # nosec B404
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.integration_guard import cli
|
||||
@@ -112,8 +111,9 @@ class TestCli:
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
@@ -158,8 +158,9 @@ class TestCli:
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
@@ -202,8 +203,9 @@ class TestCli:
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
@@ -287,125 +289,3 @@ def test_main_module_block() -> None:
|
||||
namespace = dict(ig.__dict__)
|
||||
exec(compile(source, ig.__file__, "exec"), namespace)
|
||||
assert callable(namespace["cli"])
|
||||
|
||||
|
||||
class TestGetRunningJobs:
|
||||
def test_returns_jobs(self) -> None:
|
||||
with patch("devx.ci.integration_guard.requests.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"jobs": [
|
||||
{"name": "integration-tests (0)", "conclusion": "success"},
|
||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
from devx.ci.integration_guard import get_running_jobs
|
||||
|
||||
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||
assert len(jobs) == 2
|
||||
mock_get.assert_called_once()
|
||||
|
||||
def test_raises_on_request_error(self) -> None:
|
||||
import requests
|
||||
|
||||
with patch("devx.ci.integration_guard.requests.get") as mock_get:
|
||||
mock_get.side_effect = requests.RequestException("boom")
|
||||
with pytest.raises(requests.RequestException):
|
||||
from devx.ci.integration_guard import get_running_jobs
|
||||
|
||||
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||
|
||||
|
||||
class TestAnyOtherRunnerFailed:
|
||||
def test_detects_other_failure(self) -> None:
|
||||
from devx.ci.integration_guard import any_other_runner_failed
|
||||
|
||||
jobs = [
|
||||
{"name": "integration-tests (0)", "conclusion": "success"},
|
||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||
{"name": "integration-tests (2)", "conclusion": "running"},
|
||||
]
|
||||
assert any_other_runner_failed(jobs, "integration-tests", 0) is True
|
||||
|
||||
def test_ignores_current_runner(self) -> None:
|
||||
from devx.ci.integration_guard import any_other_runner_failed
|
||||
|
||||
jobs = [
|
||||
{"name": "integration-tests (0)", "conclusion": "failure"},
|
||||
{"name": "integration-tests (1)", "conclusion": "success"},
|
||||
]
|
||||
assert any_other_runner_failed(jobs, "integration-tests", 0) is False
|
||||
|
||||
def test_ignores_non_matching_jobs(self) -> None:
|
||||
from devx.ci.integration_guard import any_other_runner_failed
|
||||
|
||||
jobs = [
|
||||
{"name": "quality", "conclusion": "failure"},
|
||||
{"name": "integration-tests (1)", "conclusion": "success"},
|
||||
]
|
||||
assert any_other_runner_failed(jobs, "integration-tests", 0) is False
|
||||
|
||||
|
||||
class TestPollForOtherFailures:
|
||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
||||
from devx.ci.integration_guard import poll_for_other_failures
|
||||
|
||||
stop_event = MagicMock()
|
||||
failed_event = MagicMock()
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
if stop_event.wait.call_count < 1:
|
||||
return [
|
||||
{"name": "integration-tests (0)", "conclusion": "success"},
|
||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
return []
|
||||
|
||||
with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs:
|
||||
mock_get_jobs.side_effect = side_effect
|
||||
stop_event.is_set.side_effect = [False, False]
|
||||
stop_event.wait.return_value = True
|
||||
|
||||
poll_for_other_failures(
|
||||
"https://gitea.example",
|
||||
"owner",
|
||||
"repo",
|
||||
"token",
|
||||
123,
|
||||
"integration-tests",
|
||||
0,
|
||||
stop_event,
|
||||
failed_event,
|
||||
)
|
||||
|
||||
failed_event.set.assert_called_once()
|
||||
|
||||
def test_poll_warns_on_api_error(self) -> None:
|
||||
import requests
|
||||
|
||||
from devx.ci.integration_guard import poll_for_other_failures
|
||||
|
||||
stop_event = MagicMock()
|
||||
failed_event = MagicMock()
|
||||
|
||||
with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs:
|
||||
mock_get_jobs.side_effect = requests.RequestException("boom")
|
||||
stop_event.is_set.side_effect = [False, True]
|
||||
stop_event.wait.return_value = True
|
||||
|
||||
poll_for_other_failures(
|
||||
"https://gitea.example",
|
||||
"owner",
|
||||
"repo",
|
||||
"token",
|
||||
123,
|
||||
"integration-tests",
|
||||
0,
|
||||
stop_event,
|
||||
failed_event,
|
||||
)
|
||||
|
||||
failed_event.set.assert_not_called()
|
||||
|
||||
@@ -14,7 +14,6 @@ from devx.tools.setup import (
|
||||
_install_pre_commit_hooks,
|
||||
_install_python_deps,
|
||||
_run,
|
||||
_try_gitea_mirror_install,
|
||||
_verify,
|
||||
main,
|
||||
)
|
||||
@@ -107,7 +106,6 @@ class TestInstallAnsibleCollections:
|
||||
with patch("devx.tools.setup.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.__str__ = lambda _: str(req)
|
||||
mock_path.return_value.read_text = lambda: req.read_text()
|
||||
_install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@@ -136,7 +134,6 @@ class TestInstallAnsibleCollections:
|
||||
with patch("devx.tools.setup.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.__str__ = lambda _: str(req)
|
||||
mock_path.return_value.read_text = lambda: req.read_text()
|
||||
_install_ansible_collections(".venv/bin")
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
@@ -156,211 +153,10 @@ class TestInstallAnsibleCollections:
|
||||
with patch("devx.tools.setup.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.__str__ = lambda _: str(req)
|
||||
mock_path.return_value.read_text = lambda: req.read_text()
|
||||
with pytest.raises(_subprocess.CalledProcessError):
|
||||
_install_ansible_collections(".venv/bin")
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
@patch("devx.tools.setup._try_gitea_mirror_install", return_value=True)
|
||||
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
|
||||
@patch("devx.tools.setup._run")
|
||||
def test_mirror_install_skips_galaxy_fallback(
|
||||
self, mock_run: MagicMock, mock_which: MagicMock, mock_mirror: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""When mirror install succeeds, galaxy fallback is not called."""
|
||||
req = tmp_path / "ansible" / "requirements.yml"
|
||||
req.parent.mkdir(parents=True)
|
||||
req.write_text("collections: []")
|
||||
with patch("devx.tools.setup.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.__str__ = lambda _: str(req)
|
||||
mock_path.return_value.read_text = lambda: req.read_text()
|
||||
_install_ansible_collections(".venv/bin")
|
||||
# _run should not be called because mirror install returns True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestTryGiteaMirrorInstall:
|
||||
"""Tests for _try_gitea_mirror_install — Gitea mirror with auth + fallback."""
|
||||
|
||||
_GITEA_URL = "https://git.example.com/api/packages/org/generic/ansible-collections/1.0.0/ansible-posix-1.0.0.tar.gz"
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_no_url_entries_returns_false(self, tmp_path: Path) -> None:
|
||||
"""Requirements without type: url entries should return False."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text("collections:\n - name: ansible.posix\n version: '1.0.0'\n")
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is False
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_no_token_returns_false(self, tmp_path: Path) -> None:
|
||||
"""No Gitea token set → return False to fall back to galaxy."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
"collections:\n"
|
||||
" - name: ansible.posix\n"
|
||||
" version: '1.0.0'\n"
|
||||
" type: url\n"
|
||||
f" source: '{self._GITEA_URL}'\n"
|
||||
)
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is False
|
||||
|
||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
||||
def test_yaml_parse_error_returns_false(self, tmp_path: Path) -> None:
|
||||
"""Malformed YAML → return False."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text("not: valid: yaml: [[")
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is False
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("urllib.request.urlopen")
|
||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
||||
def test_successful_mirror_install(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
"""Valid URL entries + token → downloads with auth and installs offline."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
f"collections:\n"
|
||||
f" - name: ansible.posix\n"
|
||||
f" version: '1.0.0'\n"
|
||||
f" type: url\n"
|
||||
f" source: '{self._GITEA_URL}'\n"
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"fake-tarball"
|
||||
mock_resp.__enter__ = lambda _: mock_resp
|
||||
mock_resp.__exit__ = lambda *a: None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is True
|
||||
|
||||
# Verify auth header was added
|
||||
call_args = mock_urlopen.call_args[0][0]
|
||||
assert call_args.get_header("Authorization") == "token tok123"
|
||||
|
||||
# Verify offline install was called
|
||||
install_cmd = mock_run.call_args[0][0]
|
||||
assert "collection" in install_cmd
|
||||
assert "install" in install_cmd
|
||||
assert "--offline" in install_cmd
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
||||
def test_download_failure_returns_false(self, mock_urlopen: MagicMock, tmp_path: Path) -> None:
|
||||
"""Download failure → return False to fall back to galaxy."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
f"collections:\n"
|
||||
f" - name: ansible.posix\n"
|
||||
f" version: '1.0.0'\n"
|
||||
f" type: url\n"
|
||||
f" source: '{self._GITEA_URL}'\n"
|
||||
)
|
||||
mock_urlopen.side_effect = Exception("401 Unauthorized")
|
||||
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is False
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("urllib.request.urlopen")
|
||||
@patch.dict(os.environ, {"CI_GITEA_API_TOKEN": "tok456"}, clear=True)
|
||||
def test_prefers_api_token_over_legacy(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
"""CI_GITEA_API_TOKEN takes priority over CI_GITEA_TOKEN."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
f"collections:\n"
|
||||
f" - name: ansible.posix\n"
|
||||
f" version: '1.0.0'\n"
|
||||
f" type: url\n"
|
||||
f" source: '{self._GITEA_URL}'\n"
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"fake-tarball"
|
||||
mock_resp.__enter__ = lambda _: mock_resp
|
||||
mock_resp.__exit__ = lambda *a: None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is True
|
||||
|
||||
call_args = mock_urlopen.call_args[0][0]
|
||||
assert call_args.get_header("Authorization") == "token tok456"
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("urllib.request.urlopen")
|
||||
@patch.dict(os.environ, {"DEVELOPER_GITEA_API_TOKEN": "tok789"}, clear=True)
|
||||
def test_developer_token_fallback(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
"""DEVELOPER_GITEA_API_TOKEN is used when CI tokens are absent."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
f"collections:\n"
|
||||
f" - name: ansible.posix\n"
|
||||
f" version: '1.0.0'\n"
|
||||
f" type: url\n"
|
||||
f" source: '{self._GITEA_URL}'\n"
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"fake-tarball"
|
||||
mock_resp.__enter__ = lambda _: mock_resp
|
||||
mock_resp.__exit__ = lambda *a: None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is True
|
||||
|
||||
call_args = mock_urlopen.call_args[0][0]
|
||||
assert call_args.get_header("Authorization") == "token tok789"
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("urllib.request.urlopen")
|
||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
||||
def test_non_gitea_url_passed_through(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
"""URL entries not pointing to /api/packages/ are kept as-is (no download)."""
|
||||
external_url = "https://galaxy.ansible.com/download/ansible-posix-1.0.0.tar.gz"
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
f"collections:\n"
|
||||
f" - name: ansible.posix\n"
|
||||
f" version: '1.0.0'\n"
|
||||
f" type: url\n"
|
||||
f" source: '{external_url}'\n"
|
||||
)
|
||||
# Should not call urlopen since the URL is not a Gitea package URL
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is True
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("urllib.request.urlopen")
|
||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
||||
def test_mixed_entries_gitea_and_non_gitea(
|
||||
self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Mix of Gitea URL entries and regular galaxy entries."""
|
||||
req = tmp_path / "requirements.yml"
|
||||
req.write_text(
|
||||
f"collections:\n"
|
||||
f" - name: ansible.posix\n"
|
||||
f" version: '1.0.0'\n"
|
||||
f" type: url\n"
|
||||
f" source: '{self._GITEA_URL}'\n"
|
||||
f" - name: community.general\n"
|
||||
f" version: '13.0.0'\n"
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"fake-tarball"
|
||||
mock_resp.__enter__ = lambda _: mock_resp
|
||||
mock_resp.__exit__ = lambda *a: None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
||||
assert result is True
|
||||
# Only the Gitea URL entry should trigger a download
|
||||
mock_urlopen.assert_called_once()
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
@patch("devx.tools.setup.shutil.which", return_value=None)
|
||||
|
||||
@@ -52,6 +52,7 @@ class TestInstallInImage:
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--no-cache-dir" in cmd
|
||||
assert "--no-deps" in cmd
|
||||
assert "-e" in cmd
|
||||
assert "." in cmd
|
||||
# No extras → spec is "."
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Unit tests for devx.tools.check_alert_rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_alert_rules import main
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_skip_when_promtool_not_found(self, tmp_path: Path):
|
||||
"""Should exit 0 and print skip message when promtool is not on PATH."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "promtool not found" in result.output
|
||||
|
||||
def test_validates_rules_successfully(self, tmp_path: Path):
|
||||
"""Should exit 0 when promtool reports SUCCESS."""
|
||||
(tmp_path / "alert-rules.yml.j2").write_text("groups: []")
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "Checking /tmp/test.yml\n SUCCESS: 60 rules found\n"
|
||||
mock_result.stderr = ""
|
||||
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_fails_on_promtool_error(self, tmp_path: Path):
|
||||
"""Should exit non-zero when promtool reports an error."""
|
||||
(tmp_path / "alert-rules.yml.j2").write_text("groups: []")
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "Error: invalid template function 'default'\n"
|
||||
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_uses_correct_template_path(self, tmp_path: Path):
|
||||
"""Should render the specified template from the given path."""
|
||||
(tmp_path / "alert-rules.yml.j2").write_text("groups: []")
|
||||
captured_args = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
captured_args.append(args)
|
||||
mock = MagicMock()
|
||||
mock.returncode = 0
|
||||
mock.stdout = "SUCCESS"
|
||||
mock.stderr = ""
|
||||
return mock
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||
with patch("subprocess.run", side_effect=fake_run):
|
||||
runner = CliRunner()
|
||||
runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||
assert captured_args[0][0] == "promtool"
|
||||
assert captured_args[0][1] == "check"
|
||||
assert captured_args[0][2] == "rules"
|
||||
|
||||
def test_custom_template_name(self, tmp_path: Path):
|
||||
"""Should render a custom template name."""
|
||||
(tmp_path / "custom-rules.yml.j2").write_text("groups: []")
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "SUCCESS"
|
||||
mock_result.stderr = ""
|
||||
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main, ["--template-path", str(tmp_path), "--template-name", "custom-rules.yml.j2"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_template_vars_passed(self, tmp_path: Path):
|
||||
"""Should pass template variables to the render call."""
|
||||
(tmp_path / "alert-rules.yml.j2").write_text("grafana: {{ grafana_base_url }}\ngroups: []")
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "SUCCESS"
|
||||
mock_result.stderr = ""
|
||||
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--template-path",
|
||||
str(tmp_path),
|
||||
"--var",
|
||||
"grafana_base_url=https://grafana.test.example.com",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Unit tests for devx.tools.check_ansible_set_fact_to_json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_ansible_set_fact_to_json import (
|
||||
_check_file,
|
||||
_check_task,
|
||||
_check_task_list,
|
||||
_find_task_files,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestFindTaskFiles:
|
||||
def test_single_file(self, tmp_path: Path):
|
||||
f = tmp_path / "tasks.yml"
|
||||
f.write_text("tasks: []")
|
||||
assert _find_task_files(f) == [f]
|
||||
|
||||
def test_directory_recursive(self, tmp_path: Path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
f1 = tmp_path / "a.yml"
|
||||
f2 = tmp_path / "sub" / "b.yml"
|
||||
f1.write_text("tasks: []")
|
||||
f2.write_text("tasks: []")
|
||||
result = _find_task_files(tmp_path)
|
||||
assert f1 in result
|
||||
assert f2 in result
|
||||
|
||||
def test_nonexistent_path(self, tmp_path: Path):
|
||||
assert _find_task_files(tmp_path / "nonexistent") == []
|
||||
|
||||
def test_non_yaml_file_skipped(self, tmp_path: Path):
|
||||
f = tmp_path / "readme.txt"
|
||||
f.write_text("not yaml")
|
||||
assert _find_task_files(f) == []
|
||||
|
||||
|
||||
class TestCheckTask:
|
||||
def test_set_fact_with_to_json_flagged(self, tmp_path: Path):
|
||||
task = {"name": "Set targets", "set_fact": {"customer_hosts": "{{ targets | to_json }}"}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "customer_hosts" in errors[0]
|
||||
assert "to_json" in errors[0]
|
||||
|
||||
def test_set_fact_without_to_json_ok(self, tmp_path: Path):
|
||||
task = {"name": "Set targets", "set_fact": {"customer_hosts": "{{ targets }}"}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
def test_ansible_builtin_set_fact(self, tmp_path: Path):
|
||||
task = {"name": "Set targets", "ansible.builtin.set_fact": {"my_list": "{{ items | to_nice_json }}"}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "to_nice_json" in errors[0]
|
||||
|
||||
def test_non_set_fact_task_ignored(self, tmp_path: Path):
|
||||
task = {"name": "Render config", "copy": {"content": "{{ data | to_json }}"}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
def test_cacheable_key_ignored(self, tmp_path: Path):
|
||||
task = {"name": "Set fact", "set_fact": {"my_var": "{{ value }}", "cacheable": True}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
def test_unnamed_task(self, tmp_path: Path):
|
||||
task = {"set_fact": {"my_var": "{{ value | to_json }}"}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "(unnamed)" in errors[0]
|
||||
|
||||
def test_set_fact_not_dict_ignored(self, tmp_path: Path):
|
||||
task = {"set_fact": "not a dict"}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
def test_to_json_no_spaces(self, tmp_path: Path):
|
||||
task = {"set_fact": {"my_var": "{{ items|to_json }}"}}
|
||||
errors: list[str] = []
|
||||
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert len(errors) == 1
|
||||
|
||||
|
||||
class TestCheckTaskList:
|
||||
def test_block_tasks_checked(self, tmp_path: Path):
|
||||
tasks = [{"name": "Block", "block": [{"name": "Set in block", "set_fact": {"x": "{{ y | to_json }}"}}]}]
|
||||
errors: list[str] = []
|
||||
_check_task_list(tasks, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "x" in errors[0]
|
||||
|
||||
def test_non_dict_task_ignored(self, tmp_path: Path):
|
||||
tasks = ["just a string", 42, None]
|
||||
errors: list[str] = []
|
||||
_check_task_list(tasks, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestCheckFile:
|
||||
def test_playbook_with_set_fact_to_json(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Deploy
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set targets
|
||||
ansible.builtin.set_fact:
|
||||
customer_hosts: "{{ targets | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "customer_hosts" in errors[0]
|
||||
|
||||
def test_playbook_without_set_fact(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Deploy
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Debug
|
||||
ansible.builtin.debug:
|
||||
msg: "hello"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
assert _check_file(f, tmp_path) == []
|
||||
|
||||
def test_role_tasks_file(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Set config
|
||||
set_fact:
|
||||
my_data: "{{ data | to_json }}"
|
||||
- name: Copy config
|
||||
copy:
|
||||
content: "{{ config | to_json }}"
|
||||
dest: /etc/config.json
|
||||
""").strip()
|
||||
f = tmp_path / "main.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "my_data" in errors[0]
|
||||
|
||||
def test_pre_tasks_and_post_tasks(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
pre_tasks:
|
||||
- name: Pre set
|
||||
set_fact:
|
||||
pre_var: "{{ x | to_json }}"
|
||||
post_tasks:
|
||||
- name: Post set
|
||||
set_fact:
|
||||
post_var: "{{ y | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 2
|
||||
|
||||
def test_handlers_checked(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
handlers:
|
||||
- name: Restart service
|
||||
set_fact:
|
||||
restart_data: "{{ data | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
|
||||
def test_invalid_yaml(self, tmp_path: Path):
|
||||
f = tmp_path / "bad.yml"
|
||||
f.write_text("tasks: [invalid: {")
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "cannot parse YAML" in errors[0]
|
||||
|
||||
def test_non_dict_doc_skipped(self, tmp_path: Path):
|
||||
f = tmp_path / "list.yml"
|
||||
f.write_text("- just\n- a\n- list\n")
|
||||
assert _check_file(f, tmp_path) == []
|
||||
|
||||
def test_multi_doc_yaml(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
---
|
||||
- name: Play 1
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set in play 1
|
||||
set_fact:
|
||||
var1: "{{ x | to_json }}"
|
||||
---
|
||||
- name: Play 2
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set in play 2
|
||||
set_fact:
|
||||
var2: "{{ y }}"
|
||||
""").strip()
|
||||
f = tmp_path / "multi.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "var1" in errors[0]
|
||||
|
||||
def test_dict_doc_role_tasks_file(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
tasks:
|
||||
- name: Set var
|
||||
set_fact:
|
||||
my_var: "{{ value | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "main.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "my_var" in errors[0]
|
||||
|
||||
def test_play_with_roles_key(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
roles:
|
||||
- role: my_role
|
||||
tasks:
|
||||
- name: Set in role
|
||||
set_fact:
|
||||
role_var: "{{ x | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "role_var" in errors[0]
|
||||
|
||||
def test_bare_task_in_list_with_block(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Outer task
|
||||
set_fact:
|
||||
outer: "{{ x | to_json }}"
|
||||
- name: Block
|
||||
block:
|
||||
- name: Inner task
|
||||
set_fact:
|
||||
inner: "{{ y | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "tasks.yml"
|
||||
f.write_text(content)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 2
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_passes_when_clean(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set var
|
||||
set_fact:
|
||||
my_var: "{{ value }}"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_fails_when_to_json_found(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set var
|
||||
set_fact:
|
||||
my_var: "{{ value | to_json }}"
|
||||
""").strip()
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
assert "my_var" in result.output
|
||||
|
||||
def test_directory_scan(self, tmp_path: Path):
|
||||
(tmp_path / "good.yml").write_text(
|
||||
textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set
|
||||
set_fact:
|
||||
x: "{{ y }}"
|
||||
""").strip()
|
||||
)
|
||||
(tmp_path / "bad.yml").write_text(
|
||||
textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set
|
||||
set_fact:
|
||||
x: "{{ y | to_json }}"
|
||||
""").strip()
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "bad.yml" in result.output
|
||||
|
||||
def test_custom_ansible_dirs(self, tmp_path: Path):
|
||||
(tmp_path / "playbook.yml").write_text(
|
||||
textwrap.dedent("""
|
||||
- name: Play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Set
|
||||
set_fact:
|
||||
x: "{{ y | to_json }}"
|
||||
""").strip()
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "playbook.yml" in result.output
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Unit tests for devx.tools.check_docker_init."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_docker_init import _check_template, _find_compose_templates, _parse_services, main
|
||||
|
||||
|
||||
class TestFindComposeTemplates:
|
||||
def test_finds_docker_compose_templates(self, tmp_path: Path):
|
||||
(tmp_path / "docker-compose.observability.yml.j2").write_text("services:")
|
||||
(tmp_path / "docker-compose.service.yml.j2").write_text("services:")
|
||||
result = _find_compose_templates(tmp_path)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_finds_exporters_compose(self, tmp_path: Path):
|
||||
(tmp_path / "exporters-compose.yml.j2").write_text("services:")
|
||||
result = _find_compose_templates(tmp_path)
|
||||
assert len(result) == 1
|
||||
assert "exporters-compose" in str(result[0])
|
||||
|
||||
def test_finds_compose_yaml_templates(self, tmp_path: Path):
|
||||
(tmp_path / "compose.yaml.j2").write_text("services:")
|
||||
result = _find_compose_templates(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_single_file(self, tmp_path: Path):
|
||||
f = tmp_path / "docker-compose.test.yml.j2"
|
||||
f.write_text("services:")
|
||||
result = _find_compose_templates(f)
|
||||
assert result == [f]
|
||||
|
||||
def test_nonexistent_path(self, tmp_path: Path):
|
||||
assert _find_compose_templates(tmp_path / "nonexistent") == []
|
||||
|
||||
def test_deduplicates(self, tmp_path: Path):
|
||||
(tmp_path / "docker-compose.yml.j2").write_text("services:")
|
||||
result = _find_compose_templates(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_recursive(self, tmp_path: Path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "docker-compose.yml.j2").write_text("services:")
|
||||
result = _find_compose_templates(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestParseServices:
|
||||
def test_basic_services(self):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "localhost"]
|
||||
db:
|
||||
image: postgres
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
services = _parse_services(content)
|
||||
assert "web" in services
|
||||
assert "db" in services
|
||||
assert any("image: nginx" in line for line in services["web"])
|
||||
|
||||
def test_jinja2_service_names(self):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
{{ app_name }}:
|
||||
image: {{ app_image }}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl"]
|
||||
{{ app_name }}-db:
|
||||
image: postgres
|
||||
networks:
|
||||
traefik:
|
||||
""").strip()
|
||||
services = _parse_services(content)
|
||||
assert "{{ app_name }}" in services
|
||||
assert "{{ app_name }}-db" in services
|
||||
|
||||
def test_no_services_section(self):
|
||||
content = "version: '3'\nvolumes:\n data:"
|
||||
assert _parse_services(content) == {}
|
||||
|
||||
def test_service_at_end_of_file(self):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
""").strip()
|
||||
services = _parse_services(content)
|
||||
assert "web" in services
|
||||
|
||||
def test_volumes_ends_services(self):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
volumes:
|
||||
data:
|
||||
""").strip()
|
||||
services = _parse_services(content)
|
||||
assert "web" in services
|
||||
assert "data" not in services
|
||||
|
||||
|
||||
class TestCheckTemplate:
|
||||
def test_service_with_healthcheck_and_init_ok(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
init: true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "localhost"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
assert _check_template(f, tmp_path) == []
|
||||
|
||||
def test_service_with_healthcheck_no_init_flagged(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "localhost"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
errors = _check_template(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "web" in errors[0]
|
||||
assert "init: true" in errors[0]
|
||||
|
||||
def test_service_without_healthcheck_ok(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
assert _check_template(f, tmp_path) == []
|
||||
|
||||
def test_multiple_services_some_missing(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
good:
|
||||
image: nginx
|
||||
init: true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl"]
|
||||
bad:
|
||||
image: redis
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
errors = _check_template(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "bad" in errors[0]
|
||||
assert "good" not in errors[0]
|
||||
|
||||
def test_no_services_section(self, tmp_path: Path):
|
||||
content = "version: '3'\nvolumes:\n data:"
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
assert _check_template(f, tmp_path) == []
|
||||
|
||||
def test_jinja2_conditional_service(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
{% if backup_enabled %}
|
||||
backup:
|
||||
image: backup
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep backup"]
|
||||
{% endif %}
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
errors = _check_template(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "backup" in errors[0]
|
||||
|
||||
def test_relative_path_in_error(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
healthcheck:
|
||||
test: ["CMD"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
errors = _check_template(f, tmp_path)
|
||||
assert len(errors) == 1
|
||||
assert "docker-compose.yml.j2" in errors[0]
|
||||
assert str(tmp_path) not in errors[0]
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_passes_when_all_ok(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
init: true
|
||||
healthcheck:
|
||||
test: ["CMD"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_fails_when_missing_init(self, tmp_path: Path):
|
||||
content = textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
healthcheck:
|
||||
test: ["CMD"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
f = tmp_path / "docker-compose.yml.j2"
|
||||
f.write_text(content)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
assert "web" in result.output
|
||||
|
||||
def test_default_dir(self, tmp_path: Path):
|
||||
(tmp_path / "docker-compose.good.yml.j2").write_text(
|
||||
textwrap.dedent("""
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
init: true
|
||||
healthcheck:
|
||||
test: ["CMD"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
)
|
||||
(tmp_path / "docker-compose.bad.yml.j2").write_text(
|
||||
textwrap.dedent("""
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
healthcheck:
|
||||
test: ["CMD"]
|
||||
networks:
|
||||
default:
|
||||
""").strip()
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--templates-dir", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "db" in result.output
|
||||
|
||||
def test_no_templates_found(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--templates-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for devx.utils.api.APIClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.utils.api import APIClient
|
||||
|
||||
|
||||
class TestAPIClient:
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_get(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {"Authorization": "Bearer token"})
|
||||
result = client.get("/users")
|
||||
assert result is mock_resp
|
||||
mock_req.assert_called_once_with(
|
||||
"GET",
|
||||
"https://api.example.com/users",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_post(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {})
|
||||
client.post("/users", json={"name": "alice"})
|
||||
mock_req.assert_called_once_with(
|
||||
"POST",
|
||||
"https://api.example.com/users",
|
||||
headers={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
json={"name": "alice"},
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_put(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {})
|
||||
client.put("/users/1", json={"name": "bob"})
|
||||
mock_req.assert_called_once_with(
|
||||
"PUT",
|
||||
"https://api.example.com/users/1",
|
||||
headers={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
json={"name": "bob"},
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_delete(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {})
|
||||
client.delete("/users/1")
|
||||
mock_req.assert_called_once_with(
|
||||
"DELETE",
|
||||
"https://api.example.com/users/1",
|
||||
headers={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_patch(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {})
|
||||
client.patch("/users/1", json={"name": "carol"})
|
||||
mock_req.assert_called_once_with(
|
||||
"PATCH",
|
||||
"https://api.example.com/users/1",
|
||||
headers={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
json={"name": "carol"},
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_auth_tuple(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {}, auth=("admin", "pass"))
|
||||
client.get("/data")
|
||||
mock_req.assert_called_once_with(
|
||||
"GET",
|
||||
"https://api.example.com/data",
|
||||
headers={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
auth=("admin", "pass"),
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_custom_timeout_and_verify(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {}, timeout=60, verify=False)
|
||||
client.get("/data")
|
||||
mock_req.assert_called_once_with(
|
||||
"GET",
|
||||
"https://api.example.com/data",
|
||||
headers={},
|
||||
timeout=60,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_raises_on_error(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {})
|
||||
with pytest.raises(requests.HTTPError):
|
||||
client.get("/fail")
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_strips_trailing_slash(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com/", {})
|
||||
client.get("/users")
|
||||
mock_req.assert_called_once_with(
|
||||
"GET",
|
||||
"https://api.example.com/users",
|
||||
headers={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_kwargs_override_defaults(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {}, timeout=30)
|
||||
client.get("/slow", timeout=120)
|
||||
mock_req.assert_called_once_with(
|
||||
"GET",
|
||||
"https://api.example.com/slow",
|
||||
headers={},
|
||||
timeout=120,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
@patch("devx.utils.api.requests.request")
|
||||
def test_no_auth_when_not_set(self, mock_req):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_req.return_value = mock_resp
|
||||
client = APIClient("https://api.example.com", {})
|
||||
client.get("/data")
|
||||
call_kwargs = mock_req.call_args.kwargs
|
||||
assert "auth" not in call_kwargs
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Unit tests for devx.utils.jinja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import jinja2
|
||||
|
||||
from devx.utils.jinja import (
|
||||
make_env,
|
||||
make_value_env,
|
||||
regex_escape,
|
||||
regex_replace,
|
||||
regex_search,
|
||||
render_manifest_values,
|
||||
render_template,
|
||||
render_value,
|
||||
to_bool,
|
||||
to_json,
|
||||
)
|
||||
|
||||
|
||||
class TestFilters:
|
||||
def test_to_json(self):
|
||||
assert to_json({"a": 1}) == '{"a": 1}'
|
||||
|
||||
def test_to_json_list(self):
|
||||
assert to_json([1, 2]) == "[1, 2]"
|
||||
|
||||
def test_to_bool_true(self):
|
||||
assert to_bool(True) is True
|
||||
|
||||
def test_to_bool_false(self):
|
||||
assert to_bool(False) is False
|
||||
|
||||
def test_to_bool_string_true(self):
|
||||
assert to_bool("yes") is True
|
||||
|
||||
def test_to_bool_string_false(self):
|
||||
assert to_bool("false") is False
|
||||
|
||||
def test_to_bool_empty_string(self):
|
||||
assert to_bool("") is False
|
||||
|
||||
def test_to_bool_none(self):
|
||||
assert to_bool(None) is False
|
||||
|
||||
def test_to_bool_int(self):
|
||||
assert to_bool(1) is True
|
||||
assert to_bool(0) is False
|
||||
|
||||
def test_regex_replace(self):
|
||||
assert regex_replace("hello world", "world", "there") == "hello there"
|
||||
|
||||
def test_regex_replace_with_pattern(self):
|
||||
assert regex_replace("abc123", r"\d+", "X") == "abcX"
|
||||
|
||||
def test_regex_escape(self):
|
||||
assert regex_escape("a.b*c") == "a\\.b\\*c"
|
||||
|
||||
def test_regex_search_found(self):
|
||||
assert regex_search("hello world", r"world") == "world"
|
||||
|
||||
def test_regex_search_not_found(self):
|
||||
assert regex_search("hello", r"world") is None
|
||||
|
||||
def test_regex_search_group(self):
|
||||
assert regex_search("abc123", r"\d+") == "123"
|
||||
|
||||
|
||||
class TestMakeEnv:
|
||||
def test_returns_environment(self, tmp_path):
|
||||
(tmp_path / "test.j2").write_text("hello {{ name }}")
|
||||
env = make_env(str(tmp_path))
|
||||
assert isinstance(env, jinja2.Environment)
|
||||
|
||||
def test_has_filters(self, tmp_path):
|
||||
env = make_env(str(tmp_path))
|
||||
assert "to_json" in env.filters
|
||||
assert "bool" in env.filters
|
||||
assert "regex_replace" in env.filters
|
||||
assert "regex_escape" in env.filters
|
||||
assert "regex_search" in env.filters
|
||||
|
||||
def test_cached(self, tmp_path):
|
||||
env1 = make_env(str(tmp_path))
|
||||
env2 = make_env(str(tmp_path))
|
||||
assert env1 is env2
|
||||
|
||||
def test_auto_reload_disabled(self, tmp_path):
|
||||
env = make_env(str(tmp_path))
|
||||
assert env.auto_reload is False
|
||||
|
||||
def test_strict_undefined(self, tmp_path):
|
||||
env = make_env(str(tmp_path))
|
||||
assert env.undefined is jinja2.StrictUndefined
|
||||
|
||||
|
||||
class TestMakeValueEnv:
|
||||
def test_returns_environment(self):
|
||||
env = make_value_env()
|
||||
assert isinstance(env, jinja2.Environment)
|
||||
|
||||
def test_chainable_undefined(self):
|
||||
env = make_value_env()
|
||||
assert env.undefined is jinja2.ChainableUndefined
|
||||
|
||||
def test_cached(self):
|
||||
assert make_value_env() is make_value_env()
|
||||
|
||||
def test_has_filters(self):
|
||||
env = make_value_env()
|
||||
assert "to_json" in env.filters
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
def test_renders_named_template(self, tmp_path):
|
||||
(tmp_path / "test.j2").write_text("hello {{ name }}")
|
||||
env = make_env(str(tmp_path))
|
||||
assert render_template(env, "test.j2", name="world") == "hello world"
|
||||
|
||||
def test_renders_with_filters(self, tmp_path):
|
||||
(tmp_path / "test.j2").write_text("{{ data | to_json }}")
|
||||
env = make_env(str(tmp_path))
|
||||
assert render_template(env, "test.j2", data={"a": 1}) == '{"a": 1}'
|
||||
|
||||
|
||||
class TestRenderValue:
|
||||
def test_renders_string_with_expressions(self):
|
||||
assert render_value("hello {{ name }}", {"name": "world"}) == "hello world"
|
||||
|
||||
def test_passes_through_non_string(self):
|
||||
assert render_value(42, {}) == 42
|
||||
|
||||
def test_passes_through_string_without_expressions(self):
|
||||
assert render_value("plain text", {}) == "plain text"
|
||||
|
||||
def test_passes_through_none(self):
|
||||
assert render_value(None, {}) is None
|
||||
|
||||
|
||||
class TestRenderManifestValues:
|
||||
def test_renders_dict_values(self):
|
||||
result = render_manifest_values({"key": "{{ value }}"}, {"value": "rendered"})
|
||||
assert result == {"key": "rendered"}
|
||||
|
||||
def test_renders_list_values(self):
|
||||
result = render_manifest_values(["{{ a }}", "{{ b }}"], {"a": "1", "b": "2"})
|
||||
assert result == ["1", "2"]
|
||||
|
||||
def test_renders_nested(self):
|
||||
result = render_manifest_values({"outer": {"inner": "{{ x }}"}}, {"x": "yes"})
|
||||
assert result == {"outer": {"inner": "yes"}}
|
||||
|
||||
def test_passes_through_non_string(self):
|
||||
result = render_manifest_values({"n": 42, "b": True, "l": [1, 2]}, {})
|
||||
assert result == {"n": 42, "b": True, "l": [1, 2]}
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert render_manifest_values({}, {}) == {}
|
||||
|
||||
def test_empty_list(self):
|
||||
assert render_manifest_values([], {}) == []
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Unit tests for devx.utils.ui."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import devx.utils.ui as ui_mod
|
||||
from devx.utils.ui import _console_level, configure_ui, say
|
||||
|
||||
|
||||
class TestConsoleLevel:
|
||||
def test_default_is_info(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _console_level() == logging.INFO
|
||||
|
||||
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DEVX_LOG_LEVEL", "DEBUG")
|
||||
assert _console_level() == logging.DEBUG
|
||||
|
||||
def test_invalid_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DEVX_LOG_LEVEL", "VERBOSE")
|
||||
assert _console_level() == logging.INFO
|
||||
|
||||
def test_custom_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
configure_ui(log_level_env_var="GRM_LOG_LEVEL")
|
||||
try:
|
||||
monkeypatch.setenv("GRM_LOG_LEVEL", "DEBUG")
|
||||
monkeypatch.delenv("DEVX_LOG_LEVEL", raising=False)
|
||||
assert _console_level() == logging.DEBUG
|
||||
finally:
|
||||
configure_ui()
|
||||
|
||||
|
||||
class TestSay:
|
||||
def test_echoes_to_console(self) -> None:
|
||||
with patch("devx.utils.ui.click.echo") as mock_echo:
|
||||
say("hello")
|
||||
mock_echo.assert_called_once_with("hello", err=False)
|
||||
|
||||
def test_logs_at_info_level(self) -> None:
|
||||
with (
|
||||
patch("devx.utils.ui.click.echo"),
|
||||
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock_get_logger.return_value
|
||||
say("hello")
|
||||
mock_logger.log.assert_called_once_with(logging.INFO, "hello")
|
||||
|
||||
def test_passes_level_and_err(self) -> None:
|
||||
with (
|
||||
patch("devx.utils.ui.click.echo") as mock_echo,
|
||||
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock_get_logger.return_value
|
||||
say("error msg", level=logging.ERROR, err=True)
|
||||
mock_echo.assert_called_once_with("error msg", err=True)
|
||||
mock_logger.log.assert_called_once_with(logging.ERROR, "error msg")
|
||||
|
||||
def test_suppresses_console_below_level(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DEVX_LOG_LEVEL", "WARNING")
|
||||
with (
|
||||
patch("devx.utils.ui.click.echo") as mock_echo,
|
||||
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock_get_logger.return_value
|
||||
say("debug msg", level=logging.DEBUG)
|
||||
mock_echo.assert_not_called()
|
||||
mock_logger.log.assert_called_once_with(logging.DEBUG, "debug msg")
|
||||
|
||||
def test_color_applied(self) -> None:
|
||||
with (
|
||||
patch("devx.utils.ui.click.echo") as mock_echo,
|
||||
patch("devx.utils.ui.click.style") as mock_style,
|
||||
):
|
||||
mock_style.return_value = "styled-output"
|
||||
say("success", color="green")
|
||||
mock_style.assert_called_once_with("success", fg="green")
|
||||
mock_echo.assert_called_once_with("styled-output", err=False)
|
||||
|
||||
def test_custom_logger_name(self) -> None:
|
||||
configure_ui(logger_name="grm")
|
||||
try:
|
||||
with (
|
||||
patch("devx.utils.ui.click.echo"),
|
||||
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
say("hello")
|
||||
mock_get_logger.assert_called_with("grm")
|
||||
finally:
|
||||
configure_ui()
|
||||
|
||||
|
||||
class TestConfigureUi:
|
||||
def test_reset_to_defaults(self) -> None:
|
||||
configure_ui(log_level_env_var="GRM_LOG_LEVEL", logger_name="grm")
|
||||
configure_ui()
|
||||
assert ui_mod._LOG_LEVEL_ENV_VAR == "DEVX_LOG_LEVEL"
|
||||
assert ui_mod._LOGGER_NAME == "devx"
|
||||
Reference in New Issue
Block a user