#!/usr/bin/env -S uv run --script # /// script # dependencies = ["pytest"] # /// import json import logging from pathlib import Path import subprocess import sys from unittest.mock import patch import pytest import docker_refresh as script @pytest.mark.parametrize("image, expected", [ ("nginx", False), ("nginx:latest", False), ("app:stable", False), ("app:main", False), ("app:nightly", False), ("app:alpine", False), ("localhost:5000/team/app", False), ("localhost:5000/team/app:latest", False), ("nginx:1.27.3", True), ("postgres:16-alpine", True), ("app:v2.0.1", True), ("app:2026-10-03", True), ("app@sha256:abc", True), ("app:latest@sha256:abc", True), ]) def test_image_pinning(image: str, expected: bool) -> None: assert script.is_pinned(image=image) == expected @pytest.mark.parametrize("selected, expected", [ (set(), ["other", "web"]), ({"web"}, ["web"]), ({"web", "other"}, ["other", "web"]), ({"other", "missing"}, ["other"]), ({"missing"}, []), ]) def test_discovery_filters_and_groups_replicas(selected: set[str], expected: list[str]) -> None: containers = [] for image, running, oneoff, project in [ ("app:latest", True, "False", "web"), ("app:latest", True, "False", "web"), ("db:16", True, "False", "web"), ("stopped:latest", False, "False", "web"), ("oneoff:latest", True, "True", "web"), ("app:latest", True, "False", "other"), ]: containers.append({ "State": {"Running": running}, "Config": {"Image": image, "Labels": { "com.docker.compose.project": project, "com.docker.compose.service": image.split(":")[0], "com.docker.compose.oneoff": oneoff, "com.docker.compose.project.working_dir": "/srv/web", "com.docker.compose.project.config_files": "compose.yml,override.yml", "com.docker.compose.project.environment_file": "/srv/web/custom.env", }}, }) projects = script.discover_projects(containers=containers, selected_projects=selected) assert [project.name for project in projects] == expected for project in projects: assert project.services == {"app"} assert project.files == (Path("/srv/web/compose.yml"), Path("/srv/web/override.yml")) assert "--env-file" in project.command() @pytest.mark.parametrize("names", [[], ["web"], ["web", "api"]]) def test_positional_project_arguments(names: list[str]) -> None: with patch("sys.argv", ["docker_refresh.py", "--dry-run", *names]): arguments = script.parse_args() assert arguments.projects == names assert arguments.dry_run def test_selected_pinned_project_reports_skip_reason(caplog: pytest.LogCaptureFixture) -> None: container = { "State": {"Running": True}, "Config": { "Image": "ghcr.io/abdusco/uno:v1.0.0", "Labels": { "com.docker.compose.project": "uno", "com.docker.compose.service": "uno", }, }, } with patch("sys.argv", ["docker_refresh.py", "uno"]): with patch.object(script, "inspect_containers", return_value=[container]): with caplog.at_level(logging.INFO): assert script.main() == 0 assert caplog.record_tuples == [ ("root", logging.WARNING, "Skipping uno/uno: deployed image ghcr.io/abdusco/uno:v1.0.0 is pinned"), ("root", logging.INFO, "No eligible services to refresh in selected projects: uno"), ] @pytest.mark.parametrize("dry_run", [True, False]) def test_refresh_commands_and_current_config_filtering(tmp_path: Path, dry_run: bool) -> None: compose = tmp_path / "compose.yml" compose.touch() project = script.Project(name="web", directory=tmp_path, files=(compose,), environment_files=(), services={"app", "db"}) response = subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps({ "services": { "app": {"image": "app:latest"}, "db": {"image": "db:16"}, }, })) with patch.object(script.subprocess, "run", return_value=response) as run: script.refresh_project(project=project, dry_run=dry_run) assert run.call_count == (1 if dry_run else 3) if not dry_run: assert run.call_args_list[1].kwargs["args"] == [*project.command(), "pull", "--policy", "always", "app"] assert run.call_args_list[2].kwargs["args"] == [*project.command(), "up", "--detach", "--no-deps", "--no-build", "--pull", "never", "app"] @pytest.mark.parametrize("build", [".", {"context": "."}]) def test_project_with_any_built_service_is_skipped(tmp_path: Path, build: str | dict[str, str]) -> None: compose = tmp_path / "compose.yml" compose.touch() project = script.Project(name="web", directory=tmp_path, files=(compose,), environment_files=(), services={"app"}) response = subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps({ "services": { "app": {"image": "app:latest"}, "local": {"build": build}, }, })) with patch.object(script.subprocess, "run", return_value=response) as run: script.refresh_project(project=project, dry_run=False) assert run.call_count == 1 assert run.call_args.kwargs["args"] == [*project.command(), "config", "--format", "json"] def test_failed_pull_does_not_redeploy(tmp_path: Path) -> None: compose = tmp_path / "compose.yml" compose.touch() project = script.Project(name="web", directory=tmp_path, files=(compose,), environment_files=(), services={"app"}) response = subprocess.CompletedProcess(args=[], returncode=0, stdout='{"services":{"app":{"image":"app:latest"}}}') with patch.object(script.subprocess, "run", side_effect=[ response, subprocess.CalledProcessError(returncode=1, cmd="pull"), ]) as run: with pytest.raises(subprocess.CalledProcessError): script.refresh_project(project=project, dry_run=False) assert run.call_count == 2 def test_missing_files_fail_before_docker_commands(tmp_path: Path) -> None: project = script.Project(name="web", directory=tmp_path, files=(tmp_path / "missing.yml",), environment_files=(), services={"app"}) with patch.object(script.subprocess, "run") as run: with pytest.raises(ValueError): script.refresh_project(project=project, dry_run=False) run.assert_not_called() if __name__ == "__main__": sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))