diff --git a/Makefile b/Makefile index faaade560..cb0fc2e97 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev +.PHONY: help install dev-install format lint type-check security test check-all clean pre-commit setup-dev dev help: @echo "Available commands:" @@ -11,7 +11,8 @@ help: @echo " lint - Lint code with ruff" @echo " type-check - Run type checking with mypy and pyright" @echo " security - Run security checks with bandit" - @echo " check-all - Run all code quality checks" + @echo " test - Run test suite" + @echo " check-all - Run all code quality checks + tests" @echo "" @echo "Development:" @echo " pre-commit - Run pre-commit hooks on all files" @@ -50,7 +51,12 @@ security: uv run bandit -r strix/ -c pyproject.toml @echo "โœ… Security checks complete!" -check-all: format lint type-check security +test: + @echo "๐Ÿงช Running tests with pytest..." + uv run pytest tests/ -v + @echo "โœ… All tests passed!" + +check-all: format lint type-check security test @echo "โœ… All code quality checks passed!" pre-commit: diff --git a/pyproject.toml b/pyproject.toml index 6999aafb3..feba06a4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,8 @@ dev = [ "bandit>=1.8.3", "pre-commit>=4.2.0", "pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'", + "pytest>=9.0.3", + "pytest-mock>=3.15.1", ] [build-system] @@ -100,6 +102,7 @@ module = [ "docker.*", "caido_sdk_client.*", "pydantic_settings.*", + "pygments.*", ] ignore_missing_imports = true disable_error_code = ["import-untyped"] @@ -197,7 +200,7 @@ ignore = [ # Custom Docker subclass duplicates parent body; some imports are for annotations. # Backend factories import their backend's deps lazily so deployments # that pick a different backend don't need every backend's libs installed. -"strix/runtime/backends.py" = ["PLC0415"] +"strix/runtime/backends.py" = ["PLC0415", "BLE001", "SIM105", "S607"] "strix/runtime/docker_client.py" = [ "TC002", # Manifest, Container imported for annotations "TC003", # uuid imported for annotation @@ -228,6 +231,13 @@ ignore = [ "strix/interface/tui/app.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"] "strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"] "strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"] +# Lazy litellm imports avoid startup penalty and circular imports. +"strix/config/models.py" = ["PLC0415"] +"strix/report/usage.py" = ["PLC0415"] +"strix/telemetry/logging.py" = ["PLC0415"] +# Test files: unused fixture args are intentional (side-effect fixtures like +# clean_env), and stubs have unused params for interface compatibility. +"tests/**/*.py" = ["ARG002", "S108", "PLC0415", "ARG001", "TC003"] [tool.ruff.lint.isort] force-single-line = false @@ -278,6 +288,20 @@ reportDuplicateImport = true # Black Configuration (Code Formatter) # ============================================================================ +# ============================================================================ +# Pytest Configuration +# ============================================================================ + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +# ============================================================================ +# Black Configuration (Code Formatter) +# ============================================================================ + [tool.black] line-length = 100 target-version = ['py312'] diff --git a/strix/config/settings.py b/strix/config/settings.py index 1458e1ff8..55db9222d 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -47,6 +47,7 @@ class RuntimeSettings(BaseSettings): alias="STRIX_IMAGE", ) backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") + socket_path: str | None = Field(default=None, alias="STRIX_RUNTIME_SOCKET") class TelemetrySettings(BaseSettings): diff --git a/strix/interface/main.py b/strix/interface/main.py index 76d36cf5e..98780d939 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -5,6 +5,7 @@ import argparse import asyncio +import logging import shutil import sys from datetime import UTC, datetime @@ -44,16 +45,11 @@ ) from strix.report.state import get_global_report_state from strix.report.writer import read_run_record, write_run_record +from strix.runtime.backends import get_host_gateway from strix.telemetry import posthog, scarf from strix.telemetry.logging import configure_dependency_logging -HOST_GATEWAY_HOSTNAME = "host.docker.internal" - - -import logging # noqa: E402 - - logger = logging.getLogger(__name__) @@ -183,6 +179,33 @@ def validate_environment() -> None: def check_docker_installed() -> None: + settings = load_settings() + backend = settings.runtime.backend + + if backend == "podman": + if shutil.which("podman") is None: + logger.error("Podman CLI not found in PATH") + console = Console() + error_text = Text() + error_text.append("PODMAN NOT INSTALLED", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("The 'podman' CLI was not found in your PATH.\n", style="white") + error_text.append( + "Please install Podman and ensure the 'podman' command is available.\n\n", + style="white", + ) + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style="red", + padding=(1, 2), + ) + console.print("\n", panel, "\n") + sys.exit(1) + logger.debug("Podman CLI present") + return + if shutil.which("docker") is None: logger.error("Docker CLI not found in PATH") console = Console() @@ -456,7 +479,8 @@ def parse_arguments() -> argparse.Namespace: parser.error(f"Invalid target '{target}'") assign_workspace_subdirs(args.targets_info) - rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME) + host_gateway = get_host_gateway(load_settings().runtime.backend) + rewrite_localhost_targets(args.targets_info, host_gateway) return args diff --git a/strix/interface/utils.py b/strix/interface/utils.py index ff53a6cd8..f8e3136c6 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -14,7 +14,6 @@ from urllib.parse import urlparse from urllib.request import Request, urlopen -import docker from docker.errors import DockerException, ImageNotFound from rich.console import Console from rich.panel import Panel @@ -1329,18 +1328,42 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) def check_docker_connection() -> Any: + from strix.config import load_settings + from strix.runtime.backends import create_docker_client + + settings = load_settings() + backend = settings.runtime.backend + try: - return docker.from_env() - except DockerException: + return create_docker_client(backend) + except DockerException as exc: console = Console() error_text = Text() - error_text.append("DOCKER NOT AVAILABLE", style="bold red") - error_text.append("\n\n", style="white") - error_text.append("Cannot connect to Docker daemon.\n", style="white") - error_text.append( - "Please ensure Docker Desktop is installed and running, and try running strix again.\n", - style="white", - ) + + if backend == "podman": + error_text.append("PODMAN NOT AVAILABLE", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("Cannot connect to Podman daemon.\n", style="white") + error_text.append( + "Please ensure Podman is installed and running, and try running strix again.\n\n", + style="white", + ) + error_text.append(f"Reason: {exc}\n\n", style="dim") + error_text.append( + "Tip: set STRIX_RUNTIME_SOCKET to your Podman socket path " + "if auto-detection fails.\n", + style="dim", + ) + else: + error_text.append("DOCKER NOT AVAILABLE", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("Cannot connect to Docker daemon.\n", style="white") + error_text.append( + "Please ensure Docker Desktop is installed and running, " + "and try running strix again.\n\n", + style="white", + ) + error_text.append(f"Reason: {exc}\n", style="dim") panel = Panel( error_text, diff --git a/strix/report/writer.py b/strix/report/writer.py index 8118fe9f6..a7c2146df 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -27,7 +27,7 @@ def read_run_record(run_dir: Path) -> dict[str, Any]: except (OSError, json.JSONDecodeError) as exc: raise RuntimeError(f"run.json at {path} is unreadable: {exc}") from exc if not isinstance(data, dict): - raise RuntimeError(f"run.json at {path} is not an object") + raise TypeError(f"run.json at {path} is not an object") return data diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index 9f241a3ae..b8806e38b 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -3,9 +3,14 @@ from __future__ import annotations import logging +import os +import sys from collections.abc import Awaitable, Callable +from pathlib import Path from typing import TYPE_CHECKING, Any +from strix.config import load_settings + if TYPE_CHECKING: from agents.sandbox.manifest import Manifest @@ -17,40 +22,192 @@ SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]] -async def _docker_backend( +def get_host_gateway(backend_name: str) -> str: + """Return the host-gateway hostname for *backend_name*. + + Docker uses ``host.docker.internal``; Podman uses + ``host.containers.internal`` (resolved automatically by Podman's + built-in DNS, no ``--add-host`` needed). + """ + if backend_name == "podman": + return "host.containers.internal" + return "host.docker.internal" + + +def create_docker_client(backend_name: str) -> Any: + """Create a ``docker.DockerClient`` pointed at the right daemon. + + Resolution order (each step falls through on failure): + 1. ``STRIX_RUNTIME_SOCKET`` env var / config (explicit) + 2. ``DOCKER_HOST`` env var (standard docker-py mechanism) + 3. Per-backend auto-detection (e.g. Podman socket probing) + 4. ``docker.from_env()`` default + """ + import docker + + settings = load_settings() + socket_path = settings.runtime.socket_path + + if socket_path: + try: + logger.debug("Trying STRIX_RUNTIME_SOCKET: %s", socket_path) + return docker.DockerClient(base_url=socket_path) + except Exception as exc: + logger.debug("STRIX_RUNTIME_SOCKET failed: %s", exc) + + if os.environ.get("DOCKER_HOST"): + try: + return docker.from_env() + except Exception as exc: + logger.debug("DOCKER_HOST connection failed: %s", exc) + + if backend_name == "podman": + for candidate in _podman_socket_candidates(): + path = candidate.replace("unix://", "") + if Path(path).exists(): + try: + logger.debug("Trying podman socket: %s", candidate) + return docker.DockerClient(base_url=candidate) + except Exception as exc: + logger.debug("Podman socket %s failed: %s", candidate, exc) + + return docker.from_env() + + +def _podman_socket_candidates() -> list[str]: + """Return Podman socket URI candidates ordered by likelihood. + + Covers Linux rootless, Linux rootful, and macOS ``podman machine`` + (both applehv and libkrun). + """ + candidates: list[str] = [] + + # -- macOS podman machine (applehv / libkrun) -- + if sys.platform == "darwin": + candidates.extend(_macos_podman_machine_sockets()) + + # -- Linux rootless -- + xdg_runtime = os.environ.get("XDG_RUNTIME_DIR") + if xdg_runtime: + candidates.append(f"unix://{xdg_runtime}/podman/podman.sock") + else: + try: + candidates.append(f"unix:///run/user/{os.getuid()}/podman/podman.sock") + except (AttributeError, OSError): + pass + + # -- Linux rootful -- + candidates.append("unix:///run/podman/podman.sock") + + # -- macOS podman machine temp-dir fallback -- + tmpdir = os.environ.get("TMPDIR") + if tmpdir: + candidates.append(f"unix://{tmpdir.rstrip('/')}/podman/podman-machine-default-api.sock") + + return candidates + + +def _macos_podman_machine_sockets() -> list[str]: + """Query ``podman machine inspect`` for the exact socket path (macOS).""" + import subprocess + + try: + proc = subprocess.run( + ["podman", "machine", "inspect"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return [] + + if proc.returncode != 0: + return [] + + try: + import json + + machines = json.loads(proc.stdout) + except json.JSONDecodeError: + return [] + + sockets: list[str] = [] + for m in machines: + conn = m.get("ConnectionInfo", {}) + sock = conn.get("PodmanSocket", {}) + path = sock.get("Path") + if path: + sockets.append(f"unix://{path}") + return sockets + + +# -- backend factories -------------------------------------------------- + + +async def _create_sandbox( *, image: str, manifest: Manifest, exposed_ports: tuple[int, ...], + docker_client: Any, + host_gateway_hostname: str, ) -> tuple[Any, Any]: - """Bring up a session backed by the local Docker daemon. - - Uses :class:`StrixDockerSandboxClient` to inject NET_ADMIN / - NET_RAW caps + ``host.docker.internal`` host-gateway. Imports - ``docker`` lazily so deployments that target a non-Docker - backend don't need the docker-py library installed. - - ``session.start()`` is what materializes the manifest entries - (LocalDir copies, mount setup, etc.) into the running container โ€” - the SDK's ``client.create()`` only builds the inner session object - without applying the manifest. ``async with session:`` would call it - too, but Strix manages session lifetime explicitly via - ``client.delete()`` so we trigger ``start()`` ourselves. - """ - import docker from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions from strix.runtime.docker_client import StrixDockerSandboxClient - client = StrixDockerSandboxClient(docker.from_env()) + client = StrixDockerSandboxClient(docker_client, host_gateway_hostname=host_gateway_hostname) options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) session = await client.create(options=options, manifest=manifest) await session.start() return client, session +async def _docker_backend( + *, + image: str, + manifest: Manifest, + exposed_ports: tuple[int, ...], +) -> tuple[Any, Any]: + """Bring up a session backed by the local Docker daemon.""" + docker_client = create_docker_client("docker") + return await _create_sandbox( + image=image, + manifest=manifest, + exposed_ports=exposed_ports, + docker_client=docker_client, + host_gateway_hostname=get_host_gateway("docker"), + ) + + +async def _podman_backend( + *, + image: str, + manifest: Manifest, + exposed_ports: tuple[int, ...], +) -> tuple[Any, Any]: + """Bring up a session backed by a local Podman daemon. + + Uses the Docker-compatible API socket โ€” the same ``docker-py`` + library drives it, just pointed at the Podman socket. + """ + docker_client = create_docker_client("podman") + return await _create_sandbox( + image=image, + manifest=manifest, + exposed_ports=exposed_ports, + docker_client=docker_client, + host_gateway_hostname=get_host_gateway("podman"), + ) + + +# -- registry ----------------------------------------------------------- + + _BACKENDS: dict[str, SandboxBackend] = { "docker": _docker_backend, + "podman": _podman_backend, } diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index fb6f68086..6709cc9a5 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -42,6 +42,15 @@ class StrixDockerSandboxClient(DockerSandboxClient): + def __init__( + self, + docker_client: Any, + *, + host_gateway_hostname: str = "host.docker.internal", + ) -> None: + super().__init__(docker_client) + self._host_gateway_hostname = host_gateway_hostname + async def _create_container( self, image: str, @@ -105,8 +114,13 @@ async def _create_container( if cap not in cap_add: cap_add.append(cap) - extra_hosts = create_kwargs.setdefault("extra_hosts", {}) - extra_hosts["host.docker.internal"] = "host-gateway" + # Docker requires an explicit host-gateway mapping for + # host.docker.internal. Podman resolves host.containers.internal + # via its built-in DNS and the compat API's host-gateway support + # only arrived in v4.7, so skip extra_hosts for Podman. + if self._host_gateway_hostname == "host.docker.internal": + extra_hosts = create_kwargs.setdefault("extra_hosts", {}) + extra_hosts[self._host_gateway_hostname] = "host-gateway" logger.debug( "Creating sandbox container: image=%s caps=%s exposed_ports=%s", diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 6f3e27338..5aa106953 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -10,7 +10,7 @@ from agents.sandbox.manifest import Environment, Manifest from strix.config import load_settings -from strix.runtime.backends import get_backend +from strix.runtime.backends import get_backend, get_host_gateway from strix.runtime.caido_bootstrap import bootstrap_caido @@ -48,6 +48,9 @@ async def create_or_reuse( continue entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve()) + backend_name = load_settings().runtime.backend + host_gateway = get_host_gateway(backend_name) + # Caido runs as an in-container sidecar; HTTP(S) traffic from any # process started via ``session.exec`` (the SDK's Shell tool, etc.) # picks up these env vars automatically. ``NO_PROXY`` keeps the @@ -59,7 +62,7 @@ async def create_or_reuse( environment=Environment( value={ "PYTHONUNBUFFERED": "1", - "HOST_GATEWAY": "host.docker.internal", + "HOST_GATEWAY": host_gateway, "http_proxy": container_caido_url, "https_proxy": container_caido_url, "ALL_PROXY": container_caido_url, @@ -68,7 +71,6 @@ async def create_or_reuse( ), ) - backend_name = load_settings().runtime.backend backend = get_backend(backend_name) logger.info( diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..5a71f1f43 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import os +from collections.abc import Generator + +import pytest + + +@pytest.fixture +def clean_env() -> Generator[None, None, None]: + """Remove Strix/Docker env vars that influence backend selection.""" + saved = { + k: v + for k, v in os.environ.items() + if k in ("STRIX_RUNTIME_SOCKET", "DOCKER_HOST", "XDG_RUNTIME_DIR", "TMPDIR") + } + for k in saved: + del os.environ[k] + try: + yield + finally: + for k, v in saved.items(): + if v is not None: + os.environ[k] = v diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 000000000..c8b8f04ae --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +import os +import subprocess +from collections.abc import Generator +from unittest import mock + +import pytest + +from strix.runtime.backends import ( + _macos_podman_machine_sockets, + _podman_socket_candidates, + get_backend, + get_host_gateway, + register_backend, + supported_backends, +) + + +@pytest.fixture +def no_machine_inspect() -> Generator[None, None, None]: + """Prevent real ``podman machine inspect`` calls during tests.""" + with mock.patch("strix.runtime.backends._macos_podman_machine_sockets", return_value=[]): + yield + + +# -- get_host_gateway ------------------------------------------------------ + + +def test_host_gateway_docker() -> None: + assert get_host_gateway("docker") == "host.docker.internal" + + +def test_host_gateway_podman() -> None: + assert get_host_gateway("podman") == "host.containers.internal" + + +def test_host_gateway_defaults_to_docker_for_unknown() -> None: + assert get_host_gateway("unknown-backend") == "host.docker.internal" + + +# -- get_backend ----------------------------------------------------------- + + +def test_get_backend_docker_returns_callable() -> None: + backend = get_backend("docker") + assert callable(backend) + + +def test_get_backend_podman_returns_callable() -> None: + backend = get_backend("podman") + assert callable(backend) + + +def test_get_backend_unknown_raises_valueerror() -> None: + with pytest.raises(ValueError, match="Unknown STRIX_RUNTIME_BACKEND"): + get_backend("nonexistent") + + +def test_get_backend_error_includes_supported_list() -> None: + with pytest.raises(ValueError, match=r"\(supported: .*docker.*podman"): + get_backend("nonexistent") + + +# -- register_backend ------------------------------------------------------ + + +async def _stub_backend(**kwargs: object) -> tuple[str, str]: + return ("stub_client", "stub_session") + + +def test_register_backend_adds_new_entry() -> None: + register_backend("custom", _stub_backend) + try: + backend = get_backend("custom") + assert backend is _stub_backend + assert "custom" in supported_backends() + finally: + # Clean up so other tests aren't affected + from strix.runtime.backends import _BACKENDS + + _BACKENDS.pop("custom", None) + + +def test_register_backend_overwrites_existing() -> None: + original = get_backend("docker") + register_backend("docker", _stub_backend) + try: + assert get_backend("docker") is _stub_backend + finally: + register_backend("docker", original) + + +def test_register_backend_overwrite_preserves_count() -> None: + count_before = len(supported_backends()) + original = get_backend("docker") + register_backend("docker", _stub_backend) + try: + assert len(supported_backends()) == count_before + finally: + register_backend("docker", original) + + +# -- supported_backends ---------------------------------------------------- + + +def test_supported_backends_returns_sorted_list() -> None: + backends = supported_backends() + assert backends == sorted(backends) + + +def test_supported_backends_includes_docker_and_podman() -> None: + backends = supported_backends() + assert "docker" in backends + assert "podman" in backends + + +# -- _podman_socket_candidates -------------------------------------------- + + +@pytest.mark.usefixtures("clean_env", "no_machine_inspect") +class TestPodmanSocketCandidates: + def test_always_includes_rootful_socket(self) -> None: + candidates = _podman_socket_candidates() + assert "unix:///run/podman/podman.sock" in candidates + + def test_includes_xdg_runtime_when_set(self) -> None: + os.environ["XDG_RUNTIME_DIR"] = "/run/user/1000" + candidates = _podman_socket_candidates() + assert "unix:///run/user/1000/podman/podman.sock" in candidates + + def test_falls_back_to_uid_path_when_no_xdg(self) -> None: + candidates = _podman_socket_candidates() + uid = os.getuid() + assert f"unix:///run/user/{uid}/podman/podman.sock" in candidates + + def test_includes_tmpdir_when_set_with_trailing_slash(self) -> None: + os.environ["TMPDIR"] = "/tmp/" + candidates = _podman_socket_candidates() + assert "unix:///tmp/podman/podman-machine-default-api.sock" in candidates + + def test_includes_tmpdir_when_set_without_trailing_slash(self) -> None: + os.environ["TMPDIR"] = "/tmp" + candidates = _podman_socket_candidates() + assert "unix:///tmp/podman/podman-machine-default-api.sock" in candidates + + def test_no_tmpdir_entry_when_not_set(self) -> None: + candidates = _podman_socket_candidates() + tmpdir_candidates = [c for c in candidates if "podman-machine-default-api" in c] + assert len(tmpdir_candidates) == 0 + + +# -- _macos_podman_machine_sockets ---------------------------------------- + + +class TestMacOSPodmanMachineSockets: + def test_returns_empty_when_podman_not_found(self) -> None: + with mock.patch("subprocess.run", side_effect=FileNotFoundError): + assert _macos_podman_machine_sockets() == [] + + def test_returns_empty_on_timeout(self) -> None: + timeout_error = subprocess.TimeoutExpired(cmd="podman", timeout=5) + with mock.patch("subprocess.run", side_effect=timeout_error): + assert _macos_podman_machine_sockets() == [] + + def test_returns_empty_on_nonzero_returncode(self) -> None: + with mock.patch("subprocess.run", return_value=mock.Mock(returncode=1)): + assert _macos_podman_machine_sockets() == [] + + def test_returns_empty_on_invalid_json(self) -> None: + proc_mock = mock.Mock(returncode=0, stdout="not valid json") + with mock.patch("subprocess.run", return_value=proc_mock): + assert _macos_podman_machine_sockets() == [] + + def test_extracts_podman_socket_from_machine_inspect(self) -> None: + inspect_output = json.dumps( + [ + { + "ConnectionInfo": { + "PodmanSocket": {"Path": "/var/run/podman.sock"}, + }, + }, + ] + ) + proc_mock = mock.Mock(returncode=0, stdout=inspect_output) + with mock.patch("subprocess.run", return_value=proc_mock): + sockets = _macos_podman_machine_sockets() + assert "unix:///var/run/podman.sock" in sockets + + def test_skips_machines_without_socket(self) -> None: + inspect_output = json.dumps( + [ + {"ConnectionInfo": {}}, + { + "ConnectionInfo": { + "PodmanSocket": {"Path": "/tmp/podman.sock"}, + }, + }, + ] + ) + proc_mock = mock.Mock(returncode=0, stdout=inspect_output) + with mock.patch("subprocess.run", return_value=proc_mock): + sockets = _macos_podman_machine_sockets() + assert sockets == ["unix:///tmp/podman.sock"] + + def test_handles_multiple_machines(self) -> None: + inspect_output = json.dumps( + [ + {"ConnectionInfo": {"PodmanSocket": {"Path": "/run/podman1.sock"}}}, + {"ConnectionInfo": {"PodmanSocket": {"Path": "/run/podman2.sock"}}}, + ] + ) + proc_mock = mock.Mock(returncode=0, stdout=inspect_output) + with mock.patch("subprocess.run", return_value=proc_mock): + sockets = _macos_podman_machine_sockets() + assert len(sockets) == 2 + assert "unix:///run/podman1.sock" in sockets + assert "unix:///run/podman2.sock" in sockets diff --git a/uv.lock b/uv.lock index 7c13141d0..4aa59f03a 100644 --- a/uv.lock +++ b/uv.lock @@ -775,6 +775,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1347,6 +1356,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.5.1" @@ -1633,6 +1651,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-discovery" version = "1.2.0" @@ -2056,6 +2102,8 @@ dev = [ { name = "pre-commit" }, { name = "pyinstaller", marker = "python_full_version < '3.15'" }, { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-mock" }, { name = "ruff" }, ] @@ -2079,6 +2127,8 @@ dev = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pyinstaller", marker = "python_full_version >= '3.12' and python_full_version < '3.15'", specifier = ">=6.17.0" }, { name = "pyright", specifier = ">=1.1.401" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "ruff", specifier = ">=0.11.13" }, ]