Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions capiscio_sdk/_rpc/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
62 changes: 62 additions & 0 deletions capiscio_sdk/warmup.py
Original file line number Diff line number Diff line change
@@ -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/<version>/)
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:
Comment on lines +39 to +42
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success output uses a Unicode checkmark ("✓"). On some Windows consoles or ASCII-only stdout encodings (common in minimal Docker/CI environments), this can raise UnicodeEncodeError and cause the warmup command to fail even though the binary is OK. Prefer ASCII-only output (e.g., "OK") or gate the symbol on sys.stdout.encoding capability.

Copilot uses AI. Check for mistakes.
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)")
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subprocess.run(...) failures other than FileNotFoundError/TimeoutExpired (e.g., PermissionError when the downloaded file isn’t executable, or a RuntimeError from unsupported platform detection earlier) will currently bubble up as an uncaught exception/traceback. Since this is a CLI, it should consistently emit a clear error message and return a non-zero status for these cases.

Suggested change
print(" Binary OK \u2713 (version check timed out)")
print(" Binary OK \u2713 (version check timed out)")
except PermissionError as exc:
print(f" Error: failed to execute binary at {binary}: {exc}", file=sys.stderr)
return 1
except Exception as exc:
# Catch-all to avoid uncaught tracebacks in CLI usage
print(f" Error: unexpected failure while running binary: {exc}", file=sys.stderr)
return 1

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — added PermissionError handler and a catch-all Exception handler. Both emit a clean error message to stderr and return non-zero. Added test_permission_error test case.

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())
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
106 changes: 106 additions & 0 deletions tests/unit/test_warmup.py
Original file line number Diff line number Diff line change
@@ -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()

Comment on lines +28 to +44
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_download_needed is currently a duplicate of test_already_cached (both mock ensure_cached to return a path and assert only that main() returns 0). Since warmup now delegates cache-vs-download behavior to ProcessManager.ensure_cached(), this test doesn’t actually validate the “download needed” path; either remove the duplicate or adjust tests to exercise ensure_cached() behavior by mocking _get_cached_binary_path().exists() and _download_binary().

Suggested change
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()

Copilot uses AI. Check for mistakes.
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
Loading