diff --git a/capiscio_sdk/_rpc/process.py b/capiscio_sdk/_rpc/process.py index dd7ff7a..9877a88 100644 --- a/capiscio_sdk/_rpc/process.py +++ b/capiscio_sdk/_rpc/process.py @@ -150,6 +150,20 @@ def _get_cached_binary_path() -> Path: filename = f"capiscio-{os_name}-{arch_name}{ext}" return CACHE_DIR / CORE_VERSION / filename + def ensure_cached(self) -> Path: + """Ensure the capiscio-core binary is downloaded and cached. + + Returns the path to the cached binary. If the binary is already + present in the versioned cache directory, the download is skipped. + + This is the public API for pre-caching the binary without starting + the gRPC process (used by the ``capiscio-warmup`` CLI). + """ + cached = self._get_cached_binary_path() + if cached.exists(): + return cached + return self._download_binary() + def _download_binary(self) -> Path: """Download the capiscio-core binary for the current platform. diff --git a/capiscio_sdk/warmup.py b/capiscio_sdk/warmup.py new file mode 100644 index 0000000..3cd1631 --- /dev/null +++ b/capiscio_sdk/warmup.py @@ -0,0 +1,62 @@ +"""Pre-download and cache the capiscio-core binary. + +Usage: + python -m capiscio_sdk.warmup + +This is intended for Docker builds, CI pipelines, and serverless deployments +where you want to cache the binary at build time rather than downloading it +on first request. + +See: https://github.com/capiscio/capiscio-sdk-python/issues/41 +""" + +from __future__ import annotations + +import subprocess +import sys + +from capiscio_sdk._rpc.process import CORE_VERSION, ProcessManager + + +def main() -> int: + """Download and verify the capiscio-core binary, then exit.""" + manager = ProcessManager() + + print(f"capiscio-core v{CORE_VERSION}") + + # Ensure binary is in the versioned cache (~/.capiscio/bin//) + binary = manager.ensure_cached() + print(f" Binary path: {binary}") + + # Verify the binary is executable + try: + result = subprocess.run( + [str(binary), "--version"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + version_str = result.stdout.strip() or result.stderr.strip() + print(f" Binary OK \u2713 ({version_str})" if version_str else " Binary OK \u2713") + else: + print(f" Warning: binary exited with code {result.returncode}", file=sys.stderr) + return 1 + except FileNotFoundError: + print(f" Error: binary not found at {binary}", file=sys.stderr) + return 1 + except subprocess.TimeoutExpired: + # --version hanging likely means the binary exists but has different CLI + print(" Binary OK \u2713 (version check timed out)") + except PermissionError as exc: + print(f" Error: cannot execute binary at {binary}: {exc}", file=sys.stderr) + return 1 + except Exception as exc: + print(f" Error: unexpected failure: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index a941c74..a0df8df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ dev = [ "base58>=2.1.0", # Used in tests for DID verification ] +[project.scripts] +capiscio-warmup = "capiscio_sdk.warmup:main" + [project.urls] Homepage = "https://capisc.io" Documentation = "https://docs.capisc.io/sdk-python" diff --git a/tests/unit/test_warmup.py b/tests/unit/test_warmup.py new file mode 100644 index 0000000..1ac12dd --- /dev/null +++ b/tests/unit/test_warmup.py @@ -0,0 +1,106 @@ +"""Tests for the warmup CLI module.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from capiscio_sdk.warmup import main + + +class TestWarmup: + def test_already_cached(self, tmp_path): + """When binary is already cached, returns 0.""" + fake_binary = tmp_path / "capiscio" + fake_binary.write_text("#!/bin/sh\necho v2.4.0") + fake_binary.chmod(0o755) + + with patch("capiscio_sdk.warmup.ProcessManager") as MockPM: + instance = MockPM.return_value + instance.ensure_cached.return_value = fake_binary + with patch("capiscio_sdk.warmup.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout="v2.4.0", stderr="") + mock_sub.TimeoutExpired = TimeoutError + result = main() + + assert result == 0 + instance.ensure_cached.assert_called_once() + + def test_download_needed(self, tmp_path): + """When binary is not cached, downloads it and returns 0.""" + fake_binary = tmp_path / "capiscio" + fake_binary.write_text("#!/bin/sh\necho v2.4.0") + fake_binary.chmod(0o755) + + with patch("capiscio_sdk.warmup.ProcessManager") as MockPM: + instance = MockPM.return_value + instance.ensure_cached.return_value = fake_binary + with patch("capiscio_sdk.warmup.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout="v2.4.0", stderr="") + mock_sub.TimeoutExpired = TimeoutError + result = main() + + assert result == 0 + instance.ensure_cached.assert_called_once() + + def test_binary_verification_failure(self, tmp_path): + """When binary --version returns non-zero, returns 1.""" + fake_binary = tmp_path / "capiscio" + fake_binary.write_text("") + fake_binary.chmod(0o755) + + with patch("capiscio_sdk.warmup.ProcessManager") as MockPM: + instance = MockPM.return_value + instance.ensure_cached.return_value = fake_binary + with patch("capiscio_sdk.warmup.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + mock_sub.TimeoutExpired = TimeoutError + result = main() + + assert result == 1 + + def test_binary_not_found_error(self, tmp_path): + """When binary doesn't exist at the returned path, returns 1.""" + fake_binary = tmp_path / "nonexistent" + + with patch("capiscio_sdk.warmup.ProcessManager") as MockPM: + instance = MockPM.return_value + instance.ensure_cached.return_value = fake_binary + with patch("capiscio_sdk.warmup.subprocess") as mock_sub: + mock_sub.run.side_effect = FileNotFoundError("not found") + mock_sub.TimeoutExpired = TimeoutError + result = main() + + assert result == 1 + + def test_version_timeout_still_ok(self, tmp_path): + """When --version times out, still returns 0 (binary exists).""" + import subprocess as real_subprocess + + fake_binary = tmp_path / "capiscio" + fake_binary.write_text("") + fake_binary.chmod(0o755) + + with patch("capiscio_sdk.warmup.ProcessManager") as MockPM: + instance = MockPM.return_value + instance.ensure_cached.return_value = fake_binary + with patch("capiscio_sdk.warmup.subprocess") as mock_sub: + mock_sub.run.side_effect = real_subprocess.TimeoutExpired("cmd", 10) + mock_sub.TimeoutExpired = real_subprocess.TimeoutExpired + result = main() + + assert result == 0 + + def test_permission_error(self, tmp_path): + """When binary can't be executed due to permissions, returns 1.""" + fake_binary = tmp_path / "capiscio" + fake_binary.write_text("") + + with patch("capiscio_sdk.warmup.ProcessManager") as MockPM: + instance = MockPM.return_value + instance.ensure_cached.return_value = fake_binary + with patch("capiscio_sdk.warmup.subprocess") as mock_sub: + mock_sub.run.side_effect = PermissionError("Permission denied") + mock_sub.TimeoutExpired = TimeoutError + result = main() + + assert result == 1