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:
emil
2026-08-09 03:07:28 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent d2aa4c6298
commit bbb14c71e9
85 changed files with 4828 additions and 551 deletions
@@ -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
+8
View File
@@ -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()
+55
View File
@@ -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
+100
View File
@@ -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)
+27
View File
@@ -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:
+6 -126
View File
@@ -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()
-204
View File
@@ -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)
+1
View File
@@ -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 "."
+103
View File
@@ -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
+291
View File
@@ -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
+171
View File
@@ -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
+161
View File
@@ -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([], {}) == []
+101
View File
@@ -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"