diff --git a/hatch_validator/core/pkg_accessor_factory.py b/hatch_validator/core/pkg_accessor_factory.py index 331ba5f..bcc21d8 100644 --- a/hatch_validator/core/pkg_accessor_factory.py +++ b/hatch_validator/core/pkg_accessor_factory.py @@ -76,6 +76,12 @@ def _ensure_accessors_loaded(cls) -> None: except ImportError as e: logger.warning(f"Could not load v1.2.2 accessor: {e}") + try: + from hatch_validator.package.v2_0_0.accessor import HatchPkgAccessor as V200HatchPkgAccessor + cls.register_accessor("2.0.0", V200HatchPkgAccessor) + except ImportError as e: + logger.warning(f"Could not load v2.0.0 accessor: {e}") + @classmethod def create_accessor_chain(cls, target_version: Optional[str] = None) -> HatchPkgAccessor: """Create appropriate accessor chain based on target version. diff --git a/hatch_validator/core/validator_factory.py b/hatch_validator/core/validator_factory.py index d714a84..55e9dca 100644 --- a/hatch_validator/core/validator_factory.py +++ b/hatch_validator/core/validator_factory.py @@ -78,6 +78,12 @@ def _ensure_validators_loaded(cls) -> None: cls.register_validator("1.2.2", V122Validator) except ImportError as e: logger.warning(f"Could not load v1.2.2 validator: {e}") + + try: + from hatch_validator.package.v2_0_0.validator import Validator as V200Validator + cls.register_validator("2.0.0", V200Validator) + except ImportError as e: + logger.warning(f"Could not load v2.0.0 validator: {e}") @classmethod def create_validator_chain(cls, target_version: Optional[str] = None) -> Validator: diff --git a/hatch_validator/package/package_service.py b/hatch_validator/package/package_service.py index 46188ee..5b2ad16 100644 --- a/hatch_validator/package/package_service.py +++ b/hatch_validator/package/package_service.py @@ -42,10 +42,10 @@ def load_metadata(self, metadata: Dict[str, Any]) -> None: ValueError: If no accessor can handle the package metadata. """ self._metadata = metadata - schema_version = metadata.get("package_schema_version") + schema_version = metadata.get("hatch_schema_version") or metadata.get("package_schema_version") if not schema_version: - raise ValueError("Missing 'package_schema_version' in metadata.") + raise ValueError("Missing schema version in metadata. Expected 'hatch_schema_version' or 'package_schema_version'.") self._accessor = HatchPkgAccessorFactory.create_accessor_chain(schema_version) if not self._accessor: diff --git a/hatch_validator/package/v2_0_0/__init__.py b/hatch_validator/package/v2_0_0/__init__.py new file mode 100644 index 0000000..e7a0890 --- /dev/null +++ b/hatch_validator/package/v2_0_0/__init__.py @@ -0,0 +1,6 @@ +"""Schema validation package for v2.0.0. + +This package contains the validator and strategies for schema version 2.0.0, +which integrates the package metadata format with the Official MCP Registry. +""" + diff --git a/hatch_validator/package/v2_0_0/accessor.py b/hatch_validator/package/v2_0_0/accessor.py new file mode 100644 index 0000000..ca6dd5f --- /dev/null +++ b/hatch_validator/package/v2_0_0/accessor.py @@ -0,0 +1,92 @@ +"""Package metadata accessor for schema version 2.0.0. + +This module provides the metadata accessor for schema version 2.0.0. +Schema version 2.0.0 introduces the new hatch_schema_version field, +uses an authors array instead of a singular author object, and renames +`tools[].description` to `tools[].desc`. +""" + +import logging +from typing import Any, Dict + +from hatch_validator.core.pkg_accessor_base import HatchPkgAccessor as HatchPkgAccessorBase + +logger = logging.getLogger("hatch.package.v2_0_0.accessor") + +class HatchPkgAccessor(HatchPkgAccessorBase): + """Metadata accessor for Hatch package schema version 2.0.0.""" + + def can_handle(self, schema_version: str) -> bool: + """Check if this accessor can handle schema version 2.0.0. + + Args: + schema_version (str): Schema version to check + + Returns: + bool: True if schema_version is '2.0.0' + """ + return schema_version == "2.0.0" + + def get_package_schema_version(self, metadata: Dict[str, Any]) -> Any: + """Get the package schema version value from metadata. + + Args: + metadata (Dict[str, Any]): Package metadata + + Returns: + Any: Schema version value from either hatch_schema_version or package_schema_version + """ + return metadata.get("hatch_schema_version") or metadata.get("package_schema_version") + + def get_author(self, metadata: Dict[str, Any]) -> Any: + """Get authors from metadata. + + Schema v2.0.0 stores author information in an array under the `authors` key. + + Args: + metadata (Dict[str, Any]): Package metadata + + Returns: + Any: Author information, preferably the authors list or legacy author object + """ + authors = metadata.get("authors") + if isinstance(authors, list): + return authors + if authors is not None: + return [authors] + return metadata.get("author") + + def get_tools(self, metadata: Dict[str, Any]) -> Any: + """Get tools from metadata. + + Tools entries in schema v2.0.0 use `desc` instead of `description`. + + Args: + metadata (Dict[str, Any]): Package metadata + + Returns: + Any: Tools list from metadata + """ + return metadata.get("tools", []) + + def get_provenance(self, metadata: Dict[str, Any]) -> Any: + """Get provenance metadata for v2.0.0. + + Args: + metadata (Dict[str, Any]): Package metadata + + Returns: + Any: Provenance metadata object or None if not present + """ + return metadata.get("provenance") + + def get_citations(self, metadata: Dict[str, Any]) -> Any: + """Get citations metadata for v2.0.0. + + Args: + metadata (Dict[str, Any]): Package metadata + + Returns: + Any: Citations list or an empty list if not present + """ + return metadata.get("citations", []) diff --git a/hatch_validator/package/v2_0_0/dependency_validation.py b/hatch_validator/package/v2_0_0/dependency_validation.py new file mode 100644 index 0000000..2ce2545 --- /dev/null +++ b/hatch_validator/package/v2_0_0/dependency_validation.py @@ -0,0 +1,101 @@ +"""Dependency validation strategy for schema version v2.0.0. + +This module implements dependency validation for schema version 2.0.0. +Only Docker-specific validation is owned here; Hatch, Python, and System +dependency validation is delegated to v1.2.2 via the chain. +""" + +import logging +from typing import Dict, List, Tuple + +from hatch_validator.core.validation_strategy import DependencyValidationStrategy +from hatch_validator.core.validation_context import ValidationContext +from hatch_validator.package.package_service import PackageService + +logger = logging.getLogger("hatch.dependency_validation_v2_0_0") +logger.setLevel(logging.DEBUG) + + +class DependencyValidation(DependencyValidationStrategy): + """Strategy for validating Docker dependencies according to v2.0.0 schema.""" + + def validate_dependencies(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate Docker dependencies according to v2.0.0 schema. + + Hatch, Python, and System dependency validation is handled by the + previous validator in the chain. This strategy validates only the + Docker subset, which requires a digest in v2.0.0. + + Args: + metadata (Dict): Package metadata containing dependency information + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Tuple containing: + - bool: Whether Docker dependency validation was successful + - List[str]: List of Docker dependency validation errors + """ + try: + package_service = context.get_data("package_service", None) + if package_service is None: + package_service = PackageService(metadata) + + dependencies = package_service.get_dependencies() + docker_dependencies = dependencies.get('docker', []) + + errors = [] + is_valid = True + + if docker_dependencies: + docker_valid, docker_errors = self._validate_docker_dependencies(docker_dependencies, context) + if not docker_valid: + errors.extend(docker_errors) + is_valid = False + + except Exception as e: + logger.error(f"Error during Docker dependency validation: {e}") + return False, [f"Error during Docker dependency validation: {e}"] + + logger.debug(f"Docker dependency validation result: {is_valid}, errors: {errors}") + return is_valid, errors + + def _validate_docker_dependencies(self, docker_dependencies: List[Dict], + context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate Docker image dependencies.""" + errors = [] + is_valid = True + + for dep in docker_dependencies: + dep_valid, dep_errors = self._validate_single_docker_dependency(dep, context) + if not dep_valid: + errors.extend(dep_errors) + is_valid = False + + return is_valid, errors + + def _validate_single_docker_dependency(self, dep: Dict, + context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate a single Docker dependency. + + Structural checks (digest presence, digest pattern, version_constraint rejection) + are enforced by the JSON schema and are not repeated here. + """ + errors = [] + is_valid = True + + dep_name = dep.get('name') + if not dep_name: + errors.append("Docker dependency missing name") + return False, errors + + tag = dep.get('tag') + if tag is not None and not isinstance(tag, str): + errors.append(f"Invalid Docker tag for '{dep_name}'. Must be a string") + is_valid = False + + registry = dep.get('registry') + if registry is not None and not isinstance(registry, str): + errors.append(f"Invalid registry value for Docker dependency '{dep_name}'. Must be a string") + is_valid = False + + return is_valid, errors diff --git a/hatch_validator/package/v2_0_0/schema_validation.py b/hatch_validator/package/v2_0_0/schema_validation.py new file mode 100644 index 0000000..8c54a1a --- /dev/null +++ b/hatch_validator/package/v2_0_0/schema_validation.py @@ -0,0 +1,59 @@ +"""Schema validation strategy for v2.0.0. + +This module provides the schema validation strategy for schema version 2.0.0. +""" + +import logging +from typing import Dict, List, Tuple + +from hatch_validator.core.validation_strategy import SchemaValidationStrategy +from hatch_validator.core.validation_context import ValidationContext +from hatch_validator.schemas.schemas_retriever import get_package_schema + +# Configure logging +logger = logging.getLogger("hatch.schema.v2_0_0.schema_validation") + + +class SchemaValidation(SchemaValidationStrategy): + """Strategy for validating metadata against v2.0.0 schema.""" + + def validate_schema(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate metadata against v2.0.0 schema. + + Args: + metadata (Dict): Package metadata to validate against schema + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Tuple containing: + - bool: Whether schema validation was successful + - List[str]: List of schema validation errors + """ + try: + jsonschema = __import__("jsonschema") + except ImportError: + error_msg = "jsonschema is required for schema validation but is not installed" + logger.error(error_msg) + return False, [error_msg] + + try: + schema = get_package_schema(version="2.0.0", force_update=context.force_schema_update) + if not schema: + error_msg = "Failed to load package schema version 2.0.0" + logger.error(error_msg) + return False, [error_msg] + + jsonschema.validate(instance=metadata, schema=schema) + logger.debug("Package metadata successfully validated against v2.0.0 schema") + return True, [] + + except jsonschema.ValidationError as e: + error_msg = f"Schema validation failed: {e.message}" + if e.absolute_path: + error_msg += f" at path: {'.'.join(str(p) for p in e.absolute_path)}" + logger.error(error_msg) + return False, [error_msg] + except Exception as e: + error_msg = f"Unexpected error during schema validation: {str(e)}" + logger.error(error_msg) + return False, [error_msg] diff --git a/hatch_validator/package/v2_0_0/validator.py b/hatch_validator/package/v2_0_0/validator.py new file mode 100644 index 0000000..e5d9bce --- /dev/null +++ b/hatch_validator/package/v2_0_0/validator.py @@ -0,0 +1,178 @@ +"""Schema validation strategies and validator for v2.0.0. + +This module provides concrete implementations of the validation strategies +and validator for schema version 2.0.0, following the Chain of Responsibility +and Strategy patterns. +""" + +import logging +from typing import Dict, List, Tuple + +from hatch_validator.core.validator_base import Validator as ValidatorBase +from hatch_validator.core.validation_context import ValidationContext + +from .dependency_validation import DependencyValidation +from .schema_validation import SchemaValidation + + +# Configure logging +logger = logging.getLogger("hatch.schema.v2_0_0.validator") +logger.setLevel(logging.INFO) + + +class Validator(ValidatorBase): + """Validator for packages using schema version 2.0.0. + + Schema version 2.0.0 renames some fields (package_schema_version → hatch_schema_version, + author → authors, tools[].description → tools[].desc), sets Docker dependencies to require + tag, digest now instead of version_constraint, and makes version_constraint optional for + all dependency types. This validator handles the new dependency structure and delegates + unchanged validation logic to the previous validator in the chain. + """ + + def __init__(self, next_validator=None): + """Initialize the v2.0.0 validator with strategies. + + Args: + next_validator (Validator, optional): Next validator in chain. Defaults to None. + """ + super().__init__(next_validator) + self.schema_strategy = SchemaValidation() + self.dependency_strategy = DependencyValidation() + + def can_handle(self, schema_version: str) -> bool: + """Determine if this validator can handle the given schema version. + + Args: + schema_version (str): Schema version to check + + Returns: + bool: True if this validator can handle the schema version + """ + return schema_version == "2.0.0" + + def validate(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validation entry point for packages following schema v2.0.0. + + Args: + metadata (Dict): Package metadata to validate + context (ValidationContext): Validation context with resources and state + + Returns: + Tuple[bool, List[str]]: Tuple containing: + - bool: Whether validation was successful + - List[str]: List of validation errors + """ + # Support new field name for v2.0.0 with fallback for legacy packages. + schema_version = metadata.get("hatch_schema_version") or metadata.get("package_schema_version", "") + + # Check if we can handle this version + if not self.can_handle(schema_version): + if self.next_validator: + return self.next_validator.validate(metadata, context) + return False, [f"Unsupported schema version: {schema_version}"] + + logger.info(f"Validating package metadata using v2.0.0 validator") + + all_errors = [] + is_valid = True + + # 1. Validate against JSON schema + schema_valid, schema_errors = self.validate_schema(metadata, context) + if not schema_valid: + all_errors.extend(schema_errors) + is_valid = False + # If schema validation fails, don't continue with other validations + return is_valid, all_errors + + # 2. Validate Hatch/Python/System dependencies — unchanged from v1.2.2, delegated via chain + deps_valid, deps_errors = self.validate_dependencies(metadata, context) + if not deps_valid: + all_errors.extend(deps_errors) + is_valid = False + + # 3. Validate Docker dependencies — new concern owned by v2.0.0 + docker_valid, docker_errors = self.validate_docker_dependencies(metadata, context) + if not docker_valid: + all_errors.extend(docker_errors) + is_valid = False + + # 4. Validate entry point and tools if package directory is available + if context.package_dir: + entry_valid, entry_errors = self.validate_entry_point(metadata, context) + if not entry_valid: + all_errors.extend(entry_errors) + is_valid = False + + if entry_valid: + tools_valid, tools_errors = self.validate_tools(metadata, context) + if not tools_valid: + all_errors.extend(tools_errors) + is_valid = False + + return is_valid, all_errors + + def validate_schema(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate metadata against schema for v2.0.0. + + Args: + metadata (Dict): Package metadata to validate + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + logger.debug("Validating package metadata against v2.0.0 schema") + return self.schema_strategy.validate_schema(metadata, context) + + def validate_docker_dependencies(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate Docker dependencies for v2.0.0. + + Docker dependencies are new in v2.0.0 (digest-based, no version_constraint). + This is a new concern owned entirely by v2.0.0. + + Args: + metadata (Dict): Package metadata to validate + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + logger.debug("Validating Docker dependencies for v2.0.0") + return self.dependency_strategy.validate_dependencies(metadata, context) + + def validate_tools(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate tools for v2.0.0. + + Tools validation (declared tool names must match @mcp.tool()-decorated functions) + is unchanged from v1.2.1, so delegate to the next validator in the chain. + + Args: + metadata (Dict): Package metadata to validate + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + logger.debug("Delegating tools validation to v1.2.1 via chain") + if self.next_validator: + return self.next_validator.validate_tools(metadata, context) + return False, ["No validator available for tools validation"] + + def validate_entry_point(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate entry point for v2.0.0. + + Entry point validation (dual mcp_server + hatch_mcp_server file checks) + is unchanged from v1.2.1, so delegate to the next validator in the chain. + + Args: + metadata (Dict): Package metadata to validate + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + logger.debug("Delegating entry point validation to v1.2.1 via chain") + if self.next_validator: + return self.next_validator.validate_entry_point(metadata, context) + return False, ["No validator available for entry point validation"] diff --git a/hatch_validator/package_validator.py b/hatch_validator/package_validator.py index 69bc038..70716ef 100644 --- a/hatch_validator/package_validator.py +++ b/hatch_validator/package_validator.py @@ -77,7 +77,9 @@ def validate_pkg_metadata(self, metadata: Dict) -> Tuple[bool, List[str]]: except ValueError as e: if "Unsupported schema version" in str(e): - return False, [f"Unsupported schema version: {metadata.get('package_schema_version', 'unknown')}"] + # Provide meaningful error with either field name + version_value = metadata.get('hatch_schema_version') or metadata.get('package_schema_version', 'unknown') + return False, [f"Unsupported schema version: {version_value}"] raise except Exception as e: return False, [f"Validation error: {str(e)}"] @@ -207,13 +209,22 @@ def validate_package(self, package_dir: Path, pending_update: Optional[Tuple[str def _determine_schema_version(self, metadata: Dict) -> str: """Determine the schema version to use for validation. + Maintains backward compatibility by checking for both the new field name + (hatch_schema_version) used in v2.0.0+ and the legacy field name + (package_schema_version) used in v1.x packages. + Args: metadata (Dict): Package metadata Returns: str: Schema version to use """ - # First, check if metadata specifies a schema version + # First, check for new field name (v2.0.0+) + schema_version = metadata.get("hatch_schema_version") + if schema_version: + return schema_version + + # Fall back to legacy field name for backward compatibility (v1.x) schema_version = metadata.get("package_schema_version") if schema_version: return schema_version diff --git a/tests/test_package_service.py b/tests/test_package_service.py index b4e8354..8090a0e 100644 --- a/tests/test_package_service.py +++ b/tests/test_package_service.py @@ -94,6 +94,45 @@ "citations": {"origin": "", "mcp": ""} } +DUMMY_METADATA_V200 = { + "hatch_schema_version": "2.0.0", + "name": "dummy_pkg_v200", + "version": "0.4.0", + "description": "A dummy package for v2.0.0 schema.", + "tags": ["test", "dummy"], + "authors": [{"name": "Frank", "email": "frank@example.com"}], + "license": {"name": "MIT"}, + "documentation": "https://example.com/docs4", + "provenance": { + "source": "internal" + }, + "dependencies": { + "hatch": [ + {"name": "base_pkg_4", "version_constraint": ">=0.0.0"} + ], + "python": [ + {"name": "pydantic", "version_constraint": ">=1.0.0", "package_manager": "pip"} + ], + "system": [ + {"name": "libssl", "version_constraint": ">=1.1.1", "package_manager": "apt"} + ], + "docker": [ + { + "name": "ubuntu", + "tag": "20.04", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "registry": "dockerhub" + } + ] + }, + "entry_point": { + "mcp_server": "mcp_server.py", + "hatch_mcp_server": "hatch_mcp_server.py" + }, + "tools": [{"name": "tool4", "desc": "A v2 tool"}], + "citations": [{"format": "formatted", "value": "A sample citation", "note": "Test citation"}] +} + class TestPackageService(unittest.TestCase): """Tests for the PackageService and concrete package accessors.""" @@ -160,6 +199,28 @@ def test_v121_fields(self): tools = service.get_tools() self.assertEqual(tools[0]["name"], "tool3") + def test_v200_fields(self): + """Test all top-level fields for v2.0.0 dummy package.""" + service = PackageService(DUMMY_METADATA_V200) + self.assertTrue(service.is_loaded()) + self.assertEqual(service.get_field("name"), "dummy_pkg_v200") + self.assertEqual(service.get_field("version"), "0.4.0") + self.assertEqual(service.get_field("author")[0]["name"], "Frank") + self.assertEqual(service.get_field("provenance")["source"], "internal") + self.assertEqual(service.get_field("tools")[0]["name"], "tool4") + self.assertEqual(service.get_field("tools")[0]["desc"], "A v2 tool") + self.assertEqual(service.get_field("citations")[0]["format"], "formatted") + deps = service.get_dependencies() + self.assertIn("hatch", deps) + self.assertIn("python", deps) + self.assertIn("system", deps) + self.assertIn("docker", deps) + self.assertEqual(deps["hatch"][0]["name"], "base_pkg_4") + self.assertEqual(deps["python"][0]["name"], "pydantic") + self.assertEqual(deps["system"][0]["name"], "libssl") + self.assertEqual(deps["docker"][0]["tag"], "20.04") + self.assertEqual(deps["docker"][0]["digest"], "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + def test_version_routing(self): """Test that PackageService routes to correct accessor based on schema version.""" # Test v1.1.0 routing diff --git a/tests/test_package_validator_for_v2_0_0.py b/tests/test_package_validator_for_v2_0_0.py new file mode 100644 index 0000000..e110ff4 --- /dev/null +++ b/tests/test_package_validator_for_v2_0_0.py @@ -0,0 +1,56 @@ +"""Unit tests for v2.0.0 package validator specific features. + +Tests behavioral differences introduced by v2.0.0: +- authors is an array (v1.x had a single author object) +- Validator and accessor routing for schema version "2.0.0" +""" + +import unittest + +from hatch_validator.core.validator_factory import ValidatorFactory +from hatch_validator.core.pkg_accessor_factory import HatchPkgAccessorFactory +from hatch_validator.package.package_service import PackageService + + +class TestPackageValidatorV200(unittest.TestCase): + """Unit tests for v2.0.0 specific validation features.""" + + def setUp(self): + """Set up test fixtures.""" + self.validator = ValidatorFactory.create_validator_chain("2.0.0") + self.accessor = HatchPkgAccessorFactory.create_accessor_chain("2.0.0") + + def test_authors_array_access(self): + """Test that authors field returns an array in v2.0.0.""" + metadata = { + "hatch_schema_version": "2.0.0", + "name": "test-package", + "authors": [ + {"name": "Author One", "email": "one@example.com"}, + {"name": "Author Two", "email": "two@example.com"} + ] + } + + service = PackageService() + service.load_metadata(metadata) + authors = service.get_field("author") + self.assertIsInstance(authors, list) + self.assertEqual(len(authors), 2) + self.assertEqual(authors[0]["name"], "Author One") + self.assertEqual(authors[1]["name"], "Author Two") + + def test_v2_0_0_validator_can_handle(self): + """Test that v2.0.0 validator correctly identifies supported versions.""" + self.assertTrue(self.validator.can_handle("2.0.0")) + self.assertFalse(self.validator.can_handle("1.2.2")) + self.assertFalse(self.validator.can_handle("3.0.0")) + + def test_v2_0_0_accessor_can_handle(self): + """Test that v2.0.0 accessor correctly identifies supported versions.""" + self.assertTrue(self.accessor.can_handle("2.0.0")) + self.assertFalse(self.accessor.can_handle("1.2.2")) + self.assertFalse(self.accessor.can_handle("3.0.0")) + + +if __name__ == "__main__": + unittest.main()