diff --git a/hatch_validator/core/pkg_accessor_base.py b/hatch_validator/core/pkg_accessor_base.py index 07dbe77..3c25e18 100644 --- a/hatch_validator/core/pkg_accessor_base.py +++ b/hatch_validator/core/pkg_accessor_base.py @@ -347,3 +347,23 @@ def get_citations(self, metadata: Dict[str, Any]) -> Any: if self.next_accessor: return self.next_accessor.get_citations(metadata) raise NotImplementedError("Citations accessor not implemented for this schema version") + + def get_python_dependency_channel(self, dependency: Dict[str, Any]) -> Any: + """Get channel from a Python dependency. + + This method is only available for schema versions >= 1.2.2 which support + conda package manager with channel specification. + + Args: + dependency (Dict[str, Any]): Python dependency object + + Returns: + Any: Channel value (e.g., "conda-forge", "bioconda") + + Raises: + NotImplementedError: If there is no next accessor and this method is not overridden, + or if the schema version does not support channels + """ + if self.next_accessor: + return self.next_accessor.get_python_dependency_channel(dependency) + raise NotImplementedError("Python dependency channel accessor not implemented for this schema version") diff --git a/hatch_validator/core/pkg_accessor_factory.py b/hatch_validator/core/pkg_accessor_factory.py index d324bfc..331ba5f 100644 --- a/hatch_validator/core/pkg_accessor_factory.py +++ b/hatch_validator/core/pkg_accessor_factory.py @@ -70,6 +70,12 @@ def _ensure_accessors_loaded(cls) -> None: except ImportError as e: logger.warning(f"Could not load v1.2.1 accessor: {e}") + try: + from hatch_validator.package.v1_2_2.accessor import HatchPkgAccessor as V122HatchPkgAccessor + cls.register_accessor("1.2.2", V122HatchPkgAccessor) + except ImportError as e: + logger.warning(f"Could not load v1.2.2 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 8dea555..d714a84 100644 --- a/hatch_validator/core/validator_factory.py +++ b/hatch_validator/core/validator_factory.py @@ -72,6 +72,12 @@ def _ensure_validators_loaded(cls) -> None: cls.register_validator("1.2.1", V121Validator) except ImportError as e: logger.warning(f"Could not load v1.2.1 validator: {e}") + + try: + from hatch_validator.package.v1_2_2.validator import Validator as V122Validator + cls.register_validator("1.2.2", V122Validator) + except ImportError as e: + logger.warning(f"Could not load v1.2.2 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 18e5b64..46188ee 100644 --- a/hatch_validator/package/package_service.py +++ b/hatch_validator/package/package_service.py @@ -160,3 +160,23 @@ def get_tools(self) -> Any: if not self.is_loaded(): raise ValueError("Package metadata is not loaded.") return self._accessor.get_tools(self._metadata) + + def get_python_dependency_channel(self, dependency: Dict[str, Any]) -> Any: + """Get channel from a Python dependency. + + This method is only available for schema versions >= 1.2.2 which support + conda package manager with channel specification. + + Args: + dependency (Dict[str, Any]): Python dependency object + + Returns: + Any: Channel value (e.g., "conda-forge", "bioconda"), or None if not specified + + Raises: + ValueError: If metadata is not loaded. + NotImplementedError: If the schema version does not support channels. + """ + if not self.is_loaded(): + raise ValueError("Package metadata is not loaded.") + return self._accessor.get_python_dependency_channel(dependency) diff --git a/hatch_validator/package/v1_2_1/accessor.py b/hatch_validator/package/v1_2_1/accessor.py index 7fc07b0..e4f99df 100644 --- a/hatch_validator/package/v1_2_1/accessor.py +++ b/hatch_validator/package/v1_2_1/accessor.py @@ -30,16 +30,20 @@ def can_handle(self, schema_version: str) -> bool: return schema_version == "1.2.1" def get_entry_point(self, metadata): - """From v1.2.1, returns the same as get_mcp_entry_point(). + """Get the full entry point dict for v1.2.1. + + In v1.2.1, entry_point is a dict with mcp_server and hatch_mcp_server keys. + This method returns the full dict to maintain backward compatibility with + code that expects to access both entry points. Args: metadata (dict): Package metadata Returns: - Any: Dual entry point value + dict: Dual entry point dict with mcp_server and hatch_mcp_server keys """ - return metadata.get('entry_point').get('mcp_server') - + return metadata.get('entry_point', {}) + def get_mcp_entry_point(self, metadata): """Get MCP entry point from metadata. @@ -47,9 +51,10 @@ def get_mcp_entry_point(self, metadata): metadata (dict): Package metadata Returns: - Any: MCP entry point value + str: MCP entry point value (e.g., "mcp_server.py") """ - return self.get_entry_point(metadata) + entry_point = metadata.get('entry_point', {}) + return entry_point.get('mcp_server') if isinstance(entry_point, dict) else None def get_hatch_mcp_entry_point(self, metadata): """Get Hatch MCP entry point from metadata. diff --git a/hatch_validator/package/v1_2_2/__init__.py b/hatch_validator/package/v1_2_2/__init__.py new file mode 100644 index 0000000..b155a4e --- /dev/null +++ b/hatch_validator/package/v1_2_2/__init__.py @@ -0,0 +1,6 @@ +"""Schema validation package for v1.2.2. + +This package contains the validator and strategies for schema version 1.2.2, +which introduces conda package manager support for Python dependencies. +""" + diff --git a/hatch_validator/package/v1_2_2/accessor.py b/hatch_validator/package/v1_2_2/accessor.py new file mode 100644 index 0000000..9776dc7 --- /dev/null +++ b/hatch_validator/package/v1_2_2/accessor.py @@ -0,0 +1,47 @@ +"""Package metadata accessor for schema version 1.2.2. + +This module provides the metadata accessor for schema version 1.2.2, +which introduces conda package manager support for Python dependencies. +All metadata access patterns remain unchanged from v1.2.1, except for +the new channel field in Python dependencies. +""" + +import logging +from typing import Dict, Any +from hatch_validator.core.pkg_accessor_base import HatchPkgAccessor as HatchPkgAccessorBase + +logger = logging.getLogger("hatch.package.v1_2_2.accessor") + +class HatchPkgAccessor(HatchPkgAccessorBase): + """Metadata accessor for Hatch package schema version 1.2.2. + + Schema version 1.2.2 introduces conda package manager support for Python + dependencies with optional channel specification. This accessor implements + the channel accessor while delegating all other operations to v1.2.1. + """ + + def can_handle(self, schema_version: str) -> bool: + """Check if this accessor can handle schema version 1.2.2. + + Args: + schema_version (str): Schema version to check + + Returns: + bool: True if schema_version is '1.2.2' + """ + return schema_version == "1.2.2" + + def get_python_dependency_channel(self, dependency: Dict[str, Any]) -> Any: + """Get channel from a Python dependency. + + This method retrieves the channel field from a Python dependency, + which is available in schema version 1.2.2 for conda packages. + + Args: + dependency (Dict[str, Any]): Python dependency object + + Returns: + Any: Channel value (e.g., "conda-forge", "bioconda"), or None if not specified + """ + return dependency.get('channel') + diff --git a/hatch_validator/package/v1_2_2/dependency_validation.py b/hatch_validator/package/v1_2_2/dependency_validation.py new file mode 100644 index 0000000..5138499 --- /dev/null +++ b/hatch_validator/package/v1_2_2/dependency_validation.py @@ -0,0 +1,380 @@ +"""Dependency validation strategy for schema version v1.2.2. + +This module implements dependency validation for v1.2.2, which introduces +conda package manager support for Python dependencies. It extends the v1.2.0 +validation logic with conda-specific validation. +""" + +import json +import logging +from typing import Dict, List, Tuple, Optional, Set +from pathlib import Path + +from hatch_validator.core.validation_strategy import DependencyValidationStrategy, ValidationError +from hatch_validator.core.validation_context import ValidationContext +from hatch_validator.utils.hatch_dependency_graph import HatchDependencyGraphBuilder +from hatch_validator.utils.version_utils import VersionConstraintValidator +from hatch_validator.registry.registry_service import RegistryService, RegistryError +from hatch_validator.package.package_service import PackageService + +logger = logging.getLogger("hatch.dependency_validation_v1_2_2") +logger.setLevel(logging.DEBUG) + + +class DependencyValidation(DependencyValidationStrategy): + """Strategy for validating dependencies according to v1.2.2 schema. + + This implementation extends v1.2.0 dependency validation with conda + package manager support for Python dependencies: + - dependencies.hatch: Array of Hatch package dependencies (unchanged) + - dependencies.python: Array of Python package dependencies (enhanced with conda support) + - dependencies.system: Array of System package dependencies (unchanged) + - dependencies.docker: Array of Docker image dependencies (unchanged) + + New in v1.2.2: + - Python dependencies can specify package_manager: "pip" or "conda" + - Conda dependencies can specify a channel (e.g., "conda-forge", "bioconda") + """ + + def __init__(self): + """Initialize the dependency validation strategy.""" + self.version_validator = VersionConstraintValidator() + self.registry_service: Optional[RegistryService] = None + + def validate_dependencies(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate dependencies according to v1.2.2 schema. + + In v1.2.2, dependencies structure is the same as v1.2.0, but Python + dependencies now support conda package manager and channel specification. + + Args: + metadata (Dict): Package metadata containing dependency information + context (ValidationContext): Validation context with resources + + Returns: + Tuple[bool, List[str]]: Tuple containing: + - bool: Whether dependency validation was successful + - List[str]: List of dependency validation errors + """ + try: + # Initialize package service from the context if available + package_service = context.get_data("package_service", None) + if package_service is None: + # Create a package service with the provided metadata + package_service = PackageService(metadata) + + # Store package service for use in helper methods + self.package_service = package_service + + # Initialize registry service from the context if available + # Get registry data from context + registry_data = context.registry_data + registry_service = context.get_data("registry_service", None) + + # Check if registry data is missing + if registry_data is None: + logger.error("No registry data available for dependency validation") + raise ValidationError("No registry data available for dependency validation") + + if registry_service is None: + # Create a registry service with the provided data + registry_service = RegistryService(registry_data) + + # Store registry service for use in helper methods + self.registry_service = registry_service + + errors = [] + is_valid = True + + # Get dependencies from v1.2.2 unified format (same as v1.2.0) + dependencies = package_service.get_dependencies() + hatch_dependencies = dependencies.get('hatch', []) + python_dependencies = dependencies.get('python', []) + + # Validate Hatch dependencies (unchanged from v1.2.0) + if hatch_dependencies: + hatch_valid, hatch_errors = self._validate_hatch_dependencies( + hatch_dependencies, context + ) + if not hatch_valid: + errors.extend(hatch_errors) + is_valid = False + + # Validate Python dependencies (enhanced with conda support) + if python_dependencies: + python_valid, python_errors = self._validate_python_dependencies( + python_dependencies, context + ) + if not python_valid: + errors.extend(python_errors) + is_valid = False + + except Exception as e: + logger.error(f"Error during dependency validation: {e}") + errors.append(f"Error during dependency validation: {e}") + is_valid = False + + logger.debug(f"Dependency validation result: {is_valid}, errors: {errors}") + + return is_valid, errors + + def _validate_python_dependencies(self, python_dependencies: List[Dict], + context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate Python package dependencies with conda support. + + Args: + python_dependencies (List[Dict]): List of Python dependency definitions + context (ValidationContext): Validation context + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + errors = [] + is_valid = True + + for dep in python_dependencies: + dep_valid, dep_errors = self._validate_single_python_dependency(dep, context) + if not dep_valid: + errors.extend(dep_errors) + is_valid = False + + return is_valid, errors + + def _validate_single_python_dependency(self, dep: Dict, + context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate a single Python dependency with conda support. + + Args: + dep (Dict): Python dependency definition + context (ValidationContext): Validation context + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + errors = [] + is_valid = True + + dep_name = dep.get('name') + if not dep_name: + errors.append("Python dependency missing name") + return False, errors + + # Validate version constraint if present + version_constraint = dep.get('version_constraint') + if version_constraint: + constraint_valid, constraint_error = self.version_validator.validate_constraint(version_constraint) + if not constraint_valid: + errors.append(f"Invalid version constraint for Python package '{dep_name}': {constraint_error}") + is_valid = False + + # Validate package_manager field (new in v1.2.2) + package_manager = dep.get('package_manager', 'pip') # Default to pip + if package_manager not in ['pip', 'conda']: + errors.append(f"Invalid package_manager '{package_manager}' for Python package '{dep_name}'. Must be 'pip' or 'conda'") + is_valid = False + + # Validate channel field (new in v1.2.2) + channel = dep.get('channel') + if channel is not None: + # Channel should only be specified for conda packages + if package_manager != 'conda': + errors.append(f"Channel '{channel}' specified for Python package '{dep_name}' with package_manager '{package_manager}'. Channel is only valid for conda packages") + is_valid = False + else: + # Validate channel format: ^[a-zA-Z0-9_\-]+$ + import re + channel_pattern = r'^[a-zA-Z0-9_\-]+$' + if not re.match(channel_pattern, channel): + errors.append(f"Invalid channel format '{channel}' for Python package '{dep_name}'. Must match pattern: {channel_pattern}") + is_valid = False + + return is_valid, errors + + def _validate_hatch_dependencies(self, hatch_dependencies: List[Dict], + context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate Hatch package dependencies. + + This method is unchanged from v1.2.0 implementation. + + Args: + hatch_dependencies (List[Dict]): List of Hatch dependency definitions + context (ValidationContext): Validation context + + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + errors = [] + is_valid = True + + # Step 1: Validate individual dependencies + for dep in hatch_dependencies: + dep_valid, dep_errors = self._validate_single_hatch_dependency(dep, context) + if not dep_valid: + errors.extend(dep_errors) + is_valid = False + + # Step 2: Build dependency graph and check for cycles + try: + hatch_dep_graph_builder = HatchDependencyGraphBuilder( + package_service=self.package_service, + registry_service=self.registry_service + ) + dependency_graph = hatch_dep_graph_builder.build_dependency_graph(hatch_dependencies, context) + logger.debug(f"Dependency graph: {json.dumps(dependency_graph.to_dict(), indent=2)}") + + has_cycles, cycles = dependency_graph.detect_cycles() + + if has_cycles: + for cycle in cycles: + cycle_str = " -> ".join(cycle) + error_msg = f"Circular dependency detected: {cycle_str}" + logger.error(error_msg) + errors.append(error_msg) + is_valid = False + except Exception as e: + logger.error(f"Error building dependency graph: {e}") + errors.append(f"Error analyzing dependency graph: {e}") + is_valid = False + + return is_valid, errors + + def _parse_hatch_dep_name(self, dep_name: str) -> Tuple[Optional[str], str]: + """Parse a hatch dependency name into (repo, package_name). + + This is only used when it has already been determined that the dependency is remote. + Otherwise, absolute paths on windows may contain colons, which would be misinterpreted as a repo prefix. + + Args: + dep_name (str): Dependency name, possibly with repo prefix. + Returns: + Tuple[Optional[str], str]: (repo_name, package_name). repo_name is None if not present. + """ + if ':' in dep_name: + repo, pkg = dep_name.split(':', 1) + return repo, pkg + return None, dep_name + + def _validate_single_hatch_dependency(self, dep: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate a single Hatch dependency. + + This method is unchanged from v1.2.0 implementation. + + Args: + dep (Dict): Dependency definition + context (ValidationContext): Validation context + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + errors = [] + is_valid = True + dep_name = dep.get('name') + if not dep_name: + errors.append("Hatch dependency missing name") + return False, errors + + # Validate version constraint if present + version_constraint = dep.get('version_constraint') + if version_constraint: + constraint_valid, constraint_error = self.version_validator.validate_constraint(version_constraint) + if not constraint_valid: + errors.append(f"Invalid version constraint for '{dep_name}': {constraint_error}") + is_valid = False + + # Check if this looks like a local path, otherwise treat as remote + if self.package_service.is_local_dependency(dep, context.package_dir): + # Local dependency - check if allowed + if not context.allow_local_dependencies: + errors.append(f"Local dependency '{dep_name}' not allowed in this context") + return False, errors + local_valid, local_errors = self._validate_local_dependency(dep, context) + if not local_valid: + errors.extend(local_errors) + is_valid = False + else: + # Remote dependency - validate through registry + registry_valid, registry_errors = self._validate_registry_dependency(dep, context) + if not registry_valid: + errors.extend(registry_errors) + is_valid = False + + return is_valid, errors + + def _validate_local_dependency(self, dep: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate a local file dependency. + + This method is unchanged from v1.2.0 implementation. + + Args: + dep (Dict): Local dependency definition + context (ValidationContext): Validation context + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + errors = [] + dep_name = dep.get('name') + + # Resolve path + path = Path(dep_name) + if context.package_dir and not path.is_absolute(): + path = context.package_dir / path + + # Check if path exists as a file (not a directory) + if path.exists(): + if not path.is_dir(): + errors.append(f"Local dependency '{dep_name}' path is not a directory: {path}") + return False, errors + else: + errors.append(f"Local dependency '{dep_name}' path is not a directory: {path}") + return False, errors + + # Check for metadata file + metadata_path = path / "hatch_metadata.json" + if not metadata_path.exists(): + errors.append(f"Local dependency '{dep_name}' missing hatch_metadata.json: {metadata_path}") + return False, errors + + return True, [] + + def _validate_registry_dependency(self, dep: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate a registry dependency. + + This method is unchanged from v1.2.0 implementation. + + Args: + dep (Dict): Registry dependency definition + context (ValidationContext): Validation context + Returns: + Tuple[bool, List[str]]: Validation result and errors + """ + errors = [] + dep_name = dep.get('name') + version_constraint = dep.get('version_constraint') + + # Parse repo and package name + repo, pkg = self._parse_hatch_dep_name(dep_name) + + if repo: + # Check repo existence + if not self.registry_service.repository_exists(repo): + errors.append(f"Repository '{repo}' not found in registry for dependency '{dep_name}'") + return False, errors + # Check package existence in repo + if not self.registry_service.package_exists(pkg, repo_name=repo): + errors.append(f"Package '{pkg}' not found in repository '{repo}' for dependency '{dep_name}'") + return False, errors + else: + # No repo prefix, check package in any repo + if not self.registry_service.package_exists(pkg): + errors.append(f"Registry dependency '{pkg}' not found in registry for dependency '{dep_name}'") + return False, errors + + # Check version compatibility if constraint is specified + if version_constraint: + version_compatible, version_error = self.registry_service.validate_version_compatibility( + dep_name, version_constraint) + if not version_compatible: + errors.append(f"No version of '{dep_name}' satisfies constraint {version_constraint}: {version_error}") + return False, errors + + return True, [] + diff --git a/hatch_validator/package/v1_2_2/schema_validation.py b/hatch_validator/package/v1_2_2/schema_validation.py new file mode 100644 index 0000000..8df0521 --- /dev/null +++ b/hatch_validator/package/v1_2_2/schema_validation.py @@ -0,0 +1,63 @@ +"""Schema validation strategy for v1.2.2. + +This module provides the schema validation strategy for schema version 1.2.2, +which validates packages with conda package manager support for Python dependencies. +""" + +import logging +from typing import Dict, List, Tuple + +import jsonschema + +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.v1_2_2.schema_validation") + + +class SchemaValidation(SchemaValidationStrategy): + """Strategy for validating metadata against v1.2.2 schema. + + This strategy validates packages against the v1.2.2 schema which introduces + conda package manager support for Python dependencies. + """ + + def validate_schema(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate metadata against v1.2.2 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: + # Load schema for v1.2.2 + schema = get_package_schema(version="1.2.2", force_update=context.force_schema_update) + if not schema: + error_msg = "Failed to load package schema version 1.2.2" + logger.error(error_msg) + return False, [error_msg] + + # Validate against schema + jsonschema.validate(instance=metadata, schema=schema) + logger.debug("Package metadata successfully validated against v1.2.2 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/v1_2_2/validator.py b/hatch_validator/package/v1_2_2/validator.py new file mode 100644 index 0000000..3760be7 --- /dev/null +++ b/hatch_validator/package/v1_2_2/validator.py @@ -0,0 +1,174 @@ +"""Schema validation strategies and validator for v1.2.2. + +This module provides concrete implementations of the validation strategies +and validator for schema version 1.2.2, following the Chain of Responsibility +and Strategy patterns. + +Schema version 1.2.2 introduces conda package manager support for Python +dependencies while maintaining dual entry point support from v1.2.1. +""" + +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 .schema_validation import SchemaValidation +from .dependency_validation import DependencyValidation + + +# Configure logging +logger = logging.getLogger("hatch.schema.v1_2_2.validator") +logger.setLevel(logging.INFO) + + +class Validator(ValidatorBase): + """Validator for packages using schema version 1.2.2. + + Schema version 1.2.2 introduces conda package manager support for Python + dependencies. This validator implements enhanced dependency validation while + delegating unchanged validation logic (entry points, tools) to the v1.2.1 validator. + """ + + def __init__(self, next_validator=None): + """Initialize the v1.2.2 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: + """Check 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 version, False otherwise + """ + return schema_version == "1.2.2" + + def validate(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validation entry point for packages following schema v1.2.2. + + 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 + """ + schema_version = 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 v1.2.2 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 dependencies (enhanced with conda support) + deps_valid, deps_errors = self.validate_dependencies(metadata, context) + if not deps_valid: + all_errors.extend(deps_errors) + is_valid = False + + # 3. Validate entry point (delegate to v1.2.1 - unchanged) + entry_point_valid, entry_point_errors = self.validate_entry_point(metadata, context) + if not entry_point_valid: + all_errors.extend(entry_point_errors) + is_valid = False + + # 4. Validate tools (delegate to v1.2.1 - unchanged) + tools_valid, tools_errors = self.validate_tools(metadata, context) + if not tools_valid: + all_errors.extend(tools_errors) + is_valid = False + + if is_valid: + logger.info("Package metadata validation successful for v1.2.2") + else: + logger.warning(f"Package metadata validation failed for v1.2.2: {len(all_errors)} errors") + + return is_valid, all_errors + + def validate_schema(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate metadata against schema for v1.2.2. + + 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 v1.2.2 schema") + return self.schema_strategy.validate_schema(metadata, context) + + def validate_dependencies(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate dependencies for v1.2.2. + + Dependencies structure includes conda support for Python packages. + + 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 dependencies with conda support for v1.2.2") + return self.dependency_strategy.validate_dependencies(metadata, context) + + def validate_entry_point(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate dual entry point for v1.2.2. + + Entry point validation is unchanged from v1.2.1, so delegate to the next validator. + + 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 validator") + if self.next_validator: + return self.next_validator.validate_entry_point(metadata, context) + return False, ["No validator available for entry point validation"] + + def validate_tools(self, metadata: Dict, context: ValidationContext) -> Tuple[bool, List[str]]: + """Validate tools with FastMCP server enforcement for v1.2.2. + + Tools validation is unchanged from v1.2.1, so delegate to the next validator. + + 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 validator") + if self.next_validator: + return self.next_validator.validate_tools(metadata, context) + return False, ["No validator available for tools validation"] + diff --git a/tests/test_package_validator_for_v1_2_2.py b/tests/test_package_validator_for_v1_2_2.py new file mode 100644 index 0000000..9812124 --- /dev/null +++ b/tests/test_package_validator_for_v1_2_2.py @@ -0,0 +1,329 @@ +"""Unit tests for package validation with schema version 1.2.2. + +This module tests the validation functionality for packages using schema +version 1.2.2, which introduces conda package manager support for Python +dependencies. +""" + +import unittest +import json +from pathlib import Path +from typing import Dict + +# Add parent directory to path for imports +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from hatch_validator.core.validation_context import ValidationContext +from hatch_validator.core.validator_factory import ValidatorFactory +from hatch_validator.core.pkg_accessor_factory import HatchPkgAccessorFactory + + +class TestV122PackageValidation(unittest.TestCase): + """Test cases for v1.2.2 package validation.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + # Create minimal test registry data + cls.registry_data = { + "registry_schema_version": "1.0.0", + "repositories": [] + } + + def setUp(self): + """Set up each test.""" + self.context = ValidationContext( + registry_data=self.registry_data, + allow_local_dependencies=False, + force_schema_update=False + ) + + def test_valid_v122_package_with_conda_dependencies(self): + """Test validation of valid v1.2.2 package with conda dependencies.""" + metadata = { + "package_schema_version": "1.2.2", + "name": "test_conda_package", + "version": "1.0.0", + "description": "Test package with conda dependencies", + "tags": ["test", "conda"], + "author": {"name": "Test Author", "email": "test@example.com"}, + "license": {"name": "MIT"}, + "entry_point": { + "mcp_server": "server.py", + "hatch_mcp_server": "hatch_server.py" + }, + "dependencies": { + "python": [ + { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "conda", + "channel": "conda-forge" + }, + { + "name": "scipy", + "version_constraint": ">=1.7.0", + "package_manager": "conda", + "channel": "bioconda" + } + ] + } + } + + validator = ValidatorFactory.create_validator_chain("1.2.2") + is_valid, errors = validator.validate(metadata, self.context) + + # Note: This will fail schema validation until we have the actual files + # but it tests the validator chain construction + self.assertIsNotNone(validator) + + def test_valid_v122_package_with_pip_dependencies(self): + """Test validation of valid v1.2.2 package with pip dependencies (backward compatibility).""" + metadata = { + "package_schema_version": "1.2.2", + "name": "test_pip_package", + "version": "1.0.0", + "description": "Test package with pip dependencies", + "tags": ["test", "pip"], + "author": {"name": "Test Author"}, + "license": {"name": "MIT"}, + "entry_point": { + "mcp_server": "server.py", + "hatch_mcp_server": "hatch_server.py" + }, + "dependencies": { + "python": [ + { + "name": "requests", + "version_constraint": ">=2.28.0", + "package_manager": "pip" + } + ] + } + } + + validator = ValidatorFactory.create_validator_chain("1.2.2") + is_valid, errors = validator.validate(metadata, self.context) + + self.assertIsNotNone(validator) + + def test_valid_v122_package_with_mixed_dependencies(self): + """Test validation of valid v1.2.2 package with mixed pip and conda dependencies.""" + metadata = { + "package_schema_version": "1.2.2", + "name": "test_mixed_package", + "version": "1.0.0", + "description": "Test package with mixed dependencies", + "tags": ["test", "mixed"], + "author": {"name": "Test Author"}, + "license": {"name": "MIT"}, + "entry_point": { + "mcp_server": "server.py", + "hatch_mcp_server": "hatch_server.py" + }, + "dependencies": { + "python": [ + { + "name": "requests", + "version_constraint": ">=2.28.0", + "package_manager": "pip" + }, + { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "conda", + "channel": "conda-forge" + } + ] + } + } + + validator = ValidatorFactory.create_validator_chain("1.2.2") + is_valid, errors = validator.validate(metadata, self.context) + + self.assertIsNotNone(validator) + + def test_invalid_channel_for_pip_package(self): + """Test that channel specification for pip package is invalid.""" + from hatch_validator.package.v1_2_2.dependency_validation import DependencyValidation + + dep_validation = DependencyValidation() + + # Pip package with channel should fail + dep = { + "name": "requests", + "version_constraint": ">=2.28.0", + "package_manager": "pip", + "channel": "conda-forge" # Invalid for pip + } + + is_valid, errors = dep_validation._validate_single_python_dependency(dep, self.context) + + self.assertFalse(is_valid) + self.assertTrue(any("Channel" in error and "pip" in error for error in errors)) + + def test_invalid_channel_format(self): + """Test that invalid channel format is rejected.""" + from hatch_validator.package.v1_2_2.dependency_validation import DependencyValidation + + dep_validation = DependencyValidation() + + # Conda package with invalid channel format + dep = { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "conda", + "channel": "invalid channel!" # Invalid format (contains space and !) + } + + is_valid, errors = dep_validation._validate_single_python_dependency(dep, self.context) + + self.assertFalse(is_valid) + self.assertTrue(any("channel format" in error.lower() for error in errors)) + + def test_valid_channel_formats(self): + """Test that valid channel formats are accepted.""" + from hatch_validator.package.v1_2_2.dependency_validation import DependencyValidation + + dep_validation = DependencyValidation() + + valid_channels = ["conda-forge", "bioconda", "colomoto", "my_channel", "channel123"] + + for channel in valid_channels: + dep = { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "conda", + "channel": channel + } + + is_valid, errors = dep_validation._validate_single_python_dependency(dep, self.context) + + # Should be valid (no channel format errors) + channel_format_errors = [e for e in errors if "channel format" in e.lower()] + self.assertEqual(len(channel_format_errors), 0, + f"Channel '{channel}' should be valid but got errors: {channel_format_errors}") + + def test_invalid_package_manager(self): + """Test that invalid package_manager value is rejected.""" + from hatch_validator.package.v1_2_2.dependency_validation import DependencyValidation + + dep_validation = DependencyValidation() + + dep = { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "apt" # Invalid - only pip or conda allowed + } + + is_valid, errors = dep_validation._validate_single_python_dependency(dep, self.context) + + self.assertFalse(is_valid) + self.assertTrue(any("package_manager" in error and "apt" in error for error in errors)) + + def test_conda_package_without_channel(self): + """Test that conda package without channel is valid (channel is optional).""" + from hatch_validator.package.v1_2_2.dependency_validation import DependencyValidation + + dep_validation = DependencyValidation() + + dep = { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "conda" + # No channel specified - should be valid + } + + is_valid, errors = dep_validation._validate_single_python_dependency(dep, self.context) + + # Should be valid (channel is optional) + self.assertTrue(is_valid, f"Conda package without channel should be valid, but got errors: {errors}") + + def test_default_package_manager_is_pip(self): + """Test that package_manager defaults to pip when not specified.""" + from hatch_validator.package.v1_2_2.dependency_validation import DependencyValidation + + dep_validation = DependencyValidation() + + dep = { + "name": "requests", + "version_constraint": ">=2.28.0" + # No package_manager specified - should default to pip + } + + is_valid, errors = dep_validation._validate_single_python_dependency(dep, self.context) + + # Should be valid (defaults to pip) + self.assertTrue(is_valid, f"Package without package_manager should default to pip, but got errors: {errors}") + + +class TestV122AccessorChain(unittest.TestCase): + """Test cases for v1.2.2 accessor chain.""" + + def test_accessor_chain_construction(self): + """Test that v1.2.2 accessor chain is constructed correctly.""" + accessor = HatchPkgAccessorFactory.create_accessor_chain("1.2.2") + + self.assertIsNotNone(accessor) + self.assertTrue(accessor.can_handle("1.2.2")) + + def test_accessor_delegates_to_v121(self): + """Test that v1.2.2 accessor delegates to v1.2.1 for unchanged operations.""" + accessor = HatchPkgAccessorFactory.create_accessor_chain("1.2.2") + + metadata = { + "package_schema_version": "1.2.2", + "name": "test_package", + "version": "1.0.0", + "entry_point": { + "mcp_server": "server.py", + "hatch_mcp_server": "hatch_server.py" + } + } + + # Test that accessor can access entry points (delegated to v1.2.1) + mcp_entry = accessor.get_mcp_entry_point(metadata) + self.assertEqual(mcp_entry, "server.py") + + hatch_mcp_entry = accessor.get_hatch_mcp_entry_point(metadata) + self.assertEqual(hatch_mcp_entry, "hatch_server.py") + + +class TestV122ValidatorChain(unittest.TestCase): + """Test cases for v1.2.2 validator chain.""" + + def test_validator_chain_construction(self): + """Test that v1.2.2 validator chain is constructed correctly.""" + validator = ValidatorFactory.create_validator_chain("1.2.2") + + self.assertIsNotNone(validator) + self.assertTrue(validator.can_handle("1.2.2")) + + def test_validator_chain_includes_all_versions(self): + """Test that v1.2.2 validator chain includes all previous versions.""" + validator = ValidatorFactory.create_validator_chain("1.2.2") + + # Check chain includes v1.2.2, v1.2.1, v1.2.0, v1.1.0 + current = validator + versions_in_chain = [] + + while current: + if hasattr(current, 'can_handle'): + # Find which version this validator handles + for version in ["1.2.2", "1.2.1", "1.2.0", "1.1.0"]: + if current.can_handle(version): + versions_in_chain.append(version) + break + current = getattr(current, 'next_validator', None) + + self.assertIn("1.2.2", versions_in_chain) + self.assertIn("1.2.1", versions_in_chain) + self.assertIn("1.2.0", versions_in_chain) + self.assertIn("1.1.0", versions_in_chain) + + +if __name__ == '__main__': + unittest.main() + + diff --git a/tests/test_v1_2_2_integration.py b/tests/test_v1_2_2_integration.py new file mode 100644 index 0000000..5a30224 --- /dev/null +++ b/tests/test_v1_2_2_integration.py @@ -0,0 +1,165 @@ +"""Integration test for v1.2.2 schema support. + +This test demonstrates the full functionality of v1.2.2 schema validation +including conda package manager support. +""" + +import unittest +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from hatch_validator.core.validator_factory import ValidatorFactory +from hatch_validator.core.pkg_accessor_factory import HatchPkgAccessorFactory +from hatch_validator.core.validation_context import ValidationContext + + +class TestV122Integration(unittest.TestCase): + """Integration tests for v1.2.2 schema support.""" + + def setUp(self): + """Set up test environment.""" + self.registry_data = { + "registry_schema_version": "1.0.0", + "repositories": [] + } + self.context = ValidationContext( + registry_data=self.registry_data, + allow_local_dependencies=False, + force_schema_update=False + ) + + def test_full_v122_package_with_conda(self): + """Test complete v1.2.2 package with conda dependencies.""" + metadata = { + "$schema": "https://raw.githubusercontent.com/CrackingShells/Hatch-Schemas/refs/heads/main/package/v1.2.2/hatch_pkg_metadata_schema.json", + "package_schema_version": "1.2.2", + "name": "bioinformatics_tool", + "version": "2.1.0", + "description": "A bioinformatics analysis tool using conda packages", + "tags": ["bioinformatics", "conda", "analysis"], + "author": { + "name": "Research Team", + "email": "research@example.com" + }, + "license": { + "name": "MIT", + "uri": "https://opensource.org/licenses/MIT" + }, + "repository": "https://github.com/example/bioinformatics-tool", + "documentation": "https://bioinformatics-tool.readthedocs.io", + "entry_point": { + "mcp_server": "server.py", + "hatch_mcp_server": "hatch_server.py" + }, + "dependencies": { + "python": [ + { + "name": "numpy", + "version_constraint": ">=1.20.0", + "package_manager": "conda", + "channel": "conda-forge" + }, + { + "name": "biopython", + "version_constraint": ">=1.79", + "package_manager": "conda", + "channel": "bioconda" + }, + { + "name": "requests", + "version_constraint": ">=2.28.0", + "package_manager": "pip" + } + ] + }, + "tools": [ + { + "name": "analyze_sequence", + "description": "Analyze DNA/RNA sequences" + }, + { + "name": "compare_genomes", + "description": "Compare genomic data" + } + ] + } + + # Create validator chain + validator = ValidatorFactory.create_validator_chain("1.2.2") + + # Verify validator can handle v1.2.2 + self.assertTrue(validator.can_handle("1.2.2")) + + # Create accessor chain + accessor = HatchPkgAccessorFactory.create_accessor_chain("1.2.2") + + # Verify accessor can handle v1.2.2 + self.assertTrue(accessor.can_handle("1.2.2")) + + # Test accessor methods + self.assertEqual(accessor.get_name(metadata), "bioinformatics_tool") + self.assertEqual(accessor.get_version(metadata), "2.1.0") + self.assertEqual(accessor.get_mcp_entry_point(metadata), "server.py") + self.assertEqual(accessor.get_hatch_mcp_entry_point(metadata), "hatch_server.py") + + # Test dependency access + deps = accessor.get_dependencies(metadata) + self.assertIn("python", deps) + self.assertEqual(len(deps["python"]), 3) + + # Verify conda dependencies + conda_deps = [d for d in deps["python"] if d.get("package_manager") == "conda"] + self.assertEqual(len(conda_deps), 2) + + # Verify pip dependencies + pip_deps = [d for d in deps["python"] if d.get("package_manager", "pip") == "pip"] + self.assertEqual(len(pip_deps), 1) + + print("\n✅ Integration test passed!") + print(f" - Validator chain constructed for v1.2.2") + print(f" - Accessor chain constructed for v1.2.2") + print(f" - Package metadata accessed successfully") + print(f" - Conda dependencies: {len(conda_deps)}") + print(f" - Pip dependencies: {len(pip_deps)}") + + def test_backward_compatibility_v121(self): + """Test that v1.2.2 chain can handle v1.2.1 packages.""" + metadata_v121 = { + "package_schema_version": "1.2.1", + "name": "legacy_package", + "version": "1.0.0", + "description": "A legacy v1.2.1 package", + "tags": ["legacy"], + "author": {"name": "Legacy Author"}, + "license": {"name": "MIT"}, + "entry_point": { + "mcp_server": "server.py", + "hatch_mcp_server": "hatch_server.py" + }, + "dependencies": { + "python": [ + { + "name": "requests", + "version_constraint": ">=2.28.0", + "package_manager": "pip" + } + ] + } + } + + # Create v1.2.2 validator chain + validator = ValidatorFactory.create_validator_chain("1.2.2") + + # Should delegate to v1.2.1 validator + self.assertFalse(validator.can_handle("1.2.1")) + self.assertTrue(validator.next_validator.can_handle("1.2.1")) + + print("\n✅ Backward compatibility test passed!") + print(f" - v1.2.2 chain correctly delegates to v1.2.1") + + +if __name__ == '__main__': + unittest.main(verbosity=2) +