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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/capiscio/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""CapiscIO CLI package."""

__version__ = "0.1.0"
__version__ = "2.2.0"
2 changes: 1 addition & 1 deletion src/capiscio/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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))
272 changes: 225 additions & 47 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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)

Loading