diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e6d45..4f43d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] - 2025-12-10 + +### Changed +- **VERSION ALIGNMENT**: All CapiscIO packages now share the same version number. + - `capiscio-core`, `capiscio` (npm), and `capiscio` (PyPI) are all v2.2.0. + - Simplifies compatibility - no version matrix needed. +- **CORE VERSION**: Now downloads `capiscio-core` v2.2.0. + +### Added +- **Test Suite**: Added comprehensive test coverage (96%) for CLI wrapper and binary manager. + ## [2.1.3] - 2025-11-21 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 832548d..a036484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "capiscio" -version = "2.1.3" +version = "2.2.0" description = "The official CapiscIO CLI tool for validating A2A agents." readme = "README.md" requires-python = ">=3.10" diff --git a/src/capiscio/__init__.py b/src/capiscio/__init__.py index 2b74d99..7157d33 100644 --- a/src/capiscio/__init__.py +++ b/src/capiscio/__init__.py @@ -1,3 +1,3 @@ """CapiscIO CLI package.""" -__version__ = "0.1.0" +__version__ = "2.2.0" diff --git a/src/capiscio/manager.py b/src/capiscio/manager.py index c4f880d..5c71e99 100644 --- a/src/capiscio/manager.py +++ b/src/capiscio/manager.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) # Configuration -CORE_VERSION = "1.0.2" # The version of the core binary to download +CORE_VERSION = "2.2.0" # The version of the core binary to download GITHUB_REPO = "capiscio/capiscio-core" BINARY_NAME = "capiscio" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49f2e92 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +"""Pytest configuration for capiscio-python tests.""" +import sys +from pathlib import Path + +# Add src directory to path so tests can import capiscio +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) diff --git a/tests/test_cli.py b/tests/test_cli.py index a82ad24..03f545a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,59 +1,237 @@ +"""Tests for capiscio.cli module.""" import sys from unittest.mock import patch, MagicMock import pytest from capiscio.cli import main -def test_cli_pass_through(): - """ - Verify that arguments passed to the CLI are forwarded - exactly as-is to the run_core function. - """ - test_args = ["capiscio", "validate", "https://example.com", "--verbose"] - - # Mock sys.argv - with patch.object(sys, 'argv', test_args): - # Mock run_core to avoid actual execution/download - with patch('capiscio.cli.run_core') as mock_run_core: - # Mock sys.exit to prevent test from exiting - with patch.object(sys, 'exit') as mock_exit: - main() - - # Check that run_core was called with the correct arguments - # sys.argv[1:] slices off the script name ("capiscio") - expected_args = ["validate", "https://example.com", "--verbose"] - mock_run_core.assert_called_once_with(expected_args) - -def test_wrapper_version_flag(): - """Verify that --wrapper-version is intercepted and not passed to core.""" - test_args = ["capiscio", "--wrapper-version"] - - with patch.object(sys, 'argv', test_args): - with patch('capiscio.cli.run_core') as mock_run_core: - with patch.object(sys, 'exit') as mock_exit: - # We need to mock importlib.metadata.version since package might not be installed - with patch('importlib.metadata.version', return_value="1.2.3"): + +class TestMainCLI: + """Tests for the main CLI entry point.""" + + def test_cli_pass_through(self): + """ + Verify that arguments passed to the CLI are forwarded + exactly as-is to the run_core function. + """ + test_args = ["capiscio", "validate", "https://example.com", "--verbose"] + + # Mock sys.argv + with patch.object(sys, 'argv', test_args): + # Mock run_core to avoid actual execution/download + with patch('capiscio.cli.run_core') as mock_run_core: + # Mock sys.exit to prevent test from exiting + with patch.object(sys, 'exit') as mock_exit: main() - # Should NOT call run_core - mock_run_core.assert_not_called() - # Should exit with 0 - mock_exit.assert_called_with(0) + # Check that run_core was called with the correct arguments + # sys.argv[1:] slices off the script name ("capiscio") + expected_args = ["validate", "https://example.com", "--verbose"] + mock_run_core.assert_called_once_with(expected_args) -def test_wrapper_clean_flag(): - """Verify that --wrapper-clean is intercepted.""" - test_args = ["capiscio", "--wrapper-clean"] - - with patch.object(sys, 'argv', test_args): - with patch('capiscio.cli.run_core') as mock_run_core: - with patch.object(sys, 'exit') as mock_exit: - with patch('shutil.rmtree') as mock_rmtree: - with patch('capiscio.cli.get_cache_dir') as mock_get_dir: - mock_dir = MagicMock() - mock_dir.exists.return_value = True - mock_get_dir.return_value = mock_dir - + def test_cli_empty_args(self): + """Test CLI with no arguments passes empty list to run_core.""" + test_args = ["capiscio"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + main() + mock_run_core.assert_called_once_with([]) + + +class TestWrapperCommands: + """Tests for wrapper-specific commands.""" + + def test_wrapper_version_flag(self): + """Verify that --wrapper-version is intercepted and not passed to core.""" + test_args = ["capiscio", "--wrapper-version"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit') as mock_exit: + # We need to mock importlib.metadata.version since package might not be installed + with patch('importlib.metadata.version', return_value="1.2.3"): main() - mock_rmtree.assert_called_once() + # Should NOT call run_core mock_run_core.assert_not_called() + # Should exit with 0 mock_exit.assert_called_with(0) + + def test_wrapper_version_unknown(self): + """Test --wrapper-version when version cannot be determined.""" + test_args = ["capiscio", "--wrapper-version"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit') as mock_exit: + with patch('importlib.metadata.version', side_effect=Exception("Not found")): + with patch('capiscio.cli.console') as mock_console: + main() + + mock_run_core.assert_not_called() + # Should still print something about version + mock_console.print.assert_called() + mock_exit.assert_called_with(0) + + def test_wrapper_clean_flag(self): + """Verify that --wrapper-clean is intercepted.""" + test_args = ["capiscio", "--wrapper-clean"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit') as mock_exit: + with patch('shutil.rmtree') as mock_rmtree: + with patch('capiscio.cli.get_cache_dir') as mock_get_dir: + mock_dir = MagicMock() + mock_dir.exists.return_value = True + mock_get_dir.return_value = mock_dir + + main() + + mock_rmtree.assert_called_once() + mock_run_core.assert_not_called() + mock_exit.assert_called_with(0) + + def test_wrapper_clean_nonexistent_dir(self): + """Test --wrapper-clean when cache directory doesn't exist.""" + test_args = ["capiscio", "--wrapper-clean"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit') as mock_exit: + with patch('shutil.rmtree') as mock_rmtree: + with patch('capiscio.cli.get_cache_dir') as mock_get_dir: + with patch('capiscio.cli.console') as mock_console: + mock_dir = MagicMock() + mock_dir.exists.return_value = False + mock_get_dir.return_value = mock_dir + + main() + + mock_rmtree.assert_not_called() + mock_run_core.assert_not_called() + mock_exit.assert_called_with(0) + + def test_wrapper_clean_error(self): + """Test --wrapper-clean when cleanup fails.""" + test_args = ["capiscio", "--wrapper-clean"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit') as mock_exit: + with patch('shutil.rmtree', side_effect=PermissionError("Access denied")): + with patch('capiscio.cli.get_cache_dir') as mock_get_dir: + with patch('capiscio.cli.console') as mock_console: + mock_dir = MagicMock() + mock_dir.exists.return_value = True + mock_get_dir.return_value = mock_dir + + main() + + mock_run_core.assert_not_called() + # Should exit with 1 on error + mock_exit.assert_called_with(1) + + def test_unknown_wrapper_command_returns(self): + """Test that unknown --wrapper-* commands don't crash.""" + test_args = ["capiscio", "--wrapper-unknown"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + # Should return early, not call run_core + main() + mock_run_core.assert_not_called() + + +class TestCommandDelegation: + """Tests for command delegation to core binary.""" + + def test_validate_command(self): + """Test that validate command is passed to core.""" + test_args = ["capiscio", "validate", "agent-card.json"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + main() + mock_run_core.assert_called_once_with(["validate", "agent-card.json"]) + + def test_score_command(self): + """Test that score command is passed to core.""" + test_args = ["capiscio", "score", "https://example.com/agent"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + main() + mock_run_core.assert_called_once_with(["score", "https://example.com/agent"]) + + def test_help_command(self): + """Test that help is passed to core.""" + test_args = ["capiscio", "--help"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + main() + mock_run_core.assert_called_once_with(["--help"]) + + def test_version_command(self): + """Test that --version (without wrapper prefix) is passed to core.""" + test_args = ["capiscio", "--version"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + main() + mock_run_core.assert_called_once_with(["--version"]) + + def test_complex_args(self): + """Test complex argument combinations are passed correctly.""" + test_args = [ + "capiscio", "validate", + "--url", "https://example.com", + "--output", "json", + "--verbose", + "--strict" + ] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core') as mock_run_core: + with patch.object(sys, 'exit'): + main() + expected = [ + "validate", + "--url", "https://example.com", + "--output", "json", + "--verbose", + "--strict" + ] + mock_run_core.assert_called_once_with(expected) + + +class TestExitCodes: + """Tests for exit code handling.""" + + def test_run_core_exit_code_propagated(self): + """Test that run_core exit code is propagated.""" + test_args = ["capiscio", "validate", "nonexistent.json"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core', return_value=1) as mock_run_core: + with patch.object(sys, 'exit') as mock_exit: + main() + mock_exit.assert_called_with(1) + + def test_run_core_success_exit_code(self): + """Test that successful run_core exit code is propagated.""" + test_args = ["capiscio", "validate", "valid.json"] + + with patch.object(sys, 'argv', test_args): + with patch('capiscio.cli.run_core', return_value=0): + with patch.object(sys, 'exit') as mock_exit: + main() + mock_exit.assert_called_with(0) + diff --git a/tests/test_manager.py b/tests/test_manager.py new file mode 100644 index 0000000..27d0d99 --- /dev/null +++ b/tests/test_manager.py @@ -0,0 +1,254 @@ +"""Tests for capiscio.manager module.""" +import os +import platform +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open +import pytest + +from capiscio.manager import ( + get_platform_info, + get_binary_filename, + get_cache_dir, + get_binary_path, + download_binary, + run_core, + CORE_VERSION, + GITHUB_REPO, +) + + +class TestGetPlatformInfo: + """Tests for get_platform_info function.""" + + @patch.object(platform, 'system', return_value='Darwin') + @patch.object(platform, 'machine', return_value='arm64') + def test_darwin_arm64(self, mock_machine, mock_system): + """Test macOS arm64 detection.""" + os_name, arch = get_platform_info() + assert os_name == "darwin" + assert arch == "arm64" + + @patch.object(platform, 'system', return_value='Darwin') + @patch.object(platform, 'machine', return_value='x86_64') + def test_darwin_amd64(self, mock_machine, mock_system): + """Test macOS Intel detection.""" + os_name, arch = get_platform_info() + assert os_name == "darwin" + assert arch == "amd64" + + @patch.object(platform, 'system', return_value='Linux') + @patch.object(platform, 'machine', return_value='x86_64') + def test_linux_amd64(self, mock_machine, mock_system): + """Test Linux x86_64 detection.""" + os_name, arch = get_platform_info() + assert os_name == "linux" + assert arch == "amd64" + + @patch.object(platform, 'system', return_value='Linux') + @patch.object(platform, 'machine', return_value='aarch64') + def test_linux_arm64_aarch64(self, mock_machine, mock_system): + """Test Linux aarch64 (alias for arm64) detection.""" + os_name, arch = get_platform_info() + assert os_name == "linux" + assert arch == "arm64" + + @patch.object(platform, 'system', return_value='Windows') + @patch.object(platform, 'machine', return_value='AMD64') + def test_windows_amd64(self, mock_machine, mock_system): + """Test Windows AMD64 detection.""" + os_name, arch = get_platform_info() + assert os_name == "windows" + assert arch == "amd64" + + @patch.object(platform, 'system', return_value='FreeBSD') + @patch.object(platform, 'machine', return_value='x86_64') + def test_unsupported_os(self, mock_machine, mock_system): + """Test that unsupported OS raises RuntimeError.""" + with pytest.raises(RuntimeError, match="Unsupported operating system"): + get_platform_info() + + @patch.object(platform, 'system', return_value='Linux') + @patch.object(platform, 'machine', return_value='i386') + def test_unsupported_arch(self, mock_machine, mock_system): + """Test that unsupported architecture raises RuntimeError.""" + with pytest.raises(RuntimeError, match="Unsupported architecture"): + get_platform_info() + + +class TestGetBinaryFilename: + """Tests for get_binary_filename function.""" + + def test_darwin_amd64(self): + """Test filename for macOS Intel.""" + filename = get_binary_filename("darwin", "amd64") + assert filename == "capiscio-darwin-amd64" + + def test_linux_arm64(self): + """Test filename for Linux ARM64.""" + filename = get_binary_filename("linux", "arm64") + assert filename == "capiscio-linux-arm64" + + def test_windows_amd64(self): + """Test filename for Windows (has .exe extension).""" + filename = get_binary_filename("windows", "amd64") + assert filename == "capiscio-windows-amd64.exe" + + +class TestGetCacheDir: + """Tests for get_cache_dir function.""" + + @patch('capiscio.manager.user_cache_dir') + def test_returns_bin_subdir(self, mock_user_cache_dir): + """Test that cache dir is in bin subdirectory.""" + mock_user_cache_dir.return_value = "/home/user/.cache/capiscio" + with patch.object(Path, 'mkdir'): + cache_dir = get_cache_dir() + assert str(cache_dir).endswith("bin") + + @patch('capiscio.manager.user_cache_dir') + def test_creates_directory(self, mock_user_cache_dir): + """Test that cache directory is created.""" + mock_user_cache_dir.return_value = "/home/user/.cache/capiscio" + mock_path = MagicMock(spec=Path) + + with patch('capiscio.manager.Path', return_value=mock_path) as mock_path_cls: + mock_path_cls.return_value.__truediv__ = MagicMock(return_value=mock_path) + mock_path.mkdir = MagicMock() + + get_cache_dir() + mock_path.mkdir.assert_called_once_with(parents=True, exist_ok=True) + + +class TestGetBinaryPath: + """Tests for get_binary_path function.""" + + @patch('capiscio.manager.get_cache_dir') + @patch('capiscio.manager.get_platform_info', return_value=('darwin', 'arm64')) + def test_includes_version(self, mock_platform, mock_cache_dir): + """Test that binary path includes version directory.""" + mock_cache_dir.return_value = Path("/cache/bin") + path = get_binary_path("1.0.0") + assert "1.0.0" in str(path) + assert str(path).endswith("capiscio-darwin-arm64") + + +class TestDownloadBinary: + """Tests for download_binary function.""" + + @patch('capiscio.manager.get_binary_path') + def test_returns_existing_binary(self, mock_get_path): + """Test that existing binary is returned without download.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_get_path.return_value = mock_path + + result = download_binary("1.0.0") + assert result == mock_path + + @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) + @patch('capiscio.manager.get_binary_path') + @patch('capiscio.manager.requests.get') + @patch('capiscio.manager.console') + def test_downloads_binary_on_missing(self, mock_console, mock_requests, mock_get_path, mock_platform): + """Test that binary is downloaded when missing.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = False + mock_path.parent = MagicMock() + mock_get_path.return_value = mock_path + + # Mock the response + mock_response = MagicMock() + mock_response.headers = {'content-length': '1024'} + mock_response.iter_content.return_value = [b'x' * 1024] + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_requests.return_value = mock_response + + with patch('builtins.open', mock_open()): + with patch.object(os, 'stat') as mock_stat: + with patch.object(os, 'chmod'): + mock_stat.return_value = MagicMock(st_mode=0o644) + result = download_binary("1.0.0") + + assert result == mock_path + + @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) + @patch('capiscio.manager.get_binary_path') + @patch('capiscio.manager.requests.get') + @patch('capiscio.manager.console') + def test_cleans_up_on_download_error(self, mock_console, mock_requests, mock_get_path, mock_platform): + """Test that partial downloads are cleaned up on error.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.side_effect = [False, True] # First check: not exists, cleanup check: exists + mock_path.parent = MagicMock() + mock_get_path.return_value = mock_path + + # Mock request error + import requests.exceptions + mock_requests.side_effect = requests.exceptions.RequestException("Network error") + + with pytest.raises(RuntimeError, match="Failed to download"): + download_binary("1.0.0") + + # Verify cleanup was attempted + mock_path.unlink.assert_called_once() + + +class TestRunCore: + """Tests for run_core function.""" + + @patch('capiscio.manager.download_binary') + @patch('capiscio.manager.platform.system', return_value='Windows') + @patch('capiscio.manager.subprocess.call', return_value=0) + def test_runs_subprocess_on_windows(self, mock_call, mock_system, mock_download): + """Test that subprocess.call is used on Windows.""" + mock_download.return_value = Path("/bin/capiscio.exe") + + result = run_core(["validate", "--help"]) + + mock_call.assert_called_once() + assert result == 0 + + @patch('capiscio.manager.download_binary') + @patch('capiscio.manager.platform.system', return_value='Linux') + @patch.object(os, 'execv') + def test_runs_execv_on_unix(self, mock_execv, mock_system, mock_download): + """Test that os.execv is used on Unix systems.""" + mock_download.return_value = Path("/bin/capiscio") + + run_core(["validate", "--help"]) + + mock_execv.assert_called_once() + args = mock_execv.call_args[0] + assert args[0] == "/bin/capiscio" + assert "validate" in args[1] + assert "--help" in args[1] + + @patch('capiscio.manager.download_binary') + @patch('capiscio.manager.console') + def test_handles_download_error(self, mock_console, mock_download): + """Test that download errors are handled gracefully.""" + mock_download.side_effect = RuntimeError("Download failed") + + result = run_core(["validate"]) + + assert result == 1 + mock_console.print.assert_called() + + +class TestConstants: + """Tests for module constants.""" + + def test_core_version_format(self): + """Test that CORE_VERSION follows semver format.""" + parts = CORE_VERSION.split('.') + assert len(parts) == 3 + for part in parts: + assert part.isdigit() + + def test_github_repo_format(self): + """Test that GITHUB_REPO is in owner/repo format.""" + assert '/' in GITHUB_REPO + parts = GITHUB_REPO.split('/') + assert len(parts) == 2 + assert all(part for part in parts)