From 2174968201efc944b9c907138c9ff81045e785a0 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 23 Jun 2025 00:05:28 +0900 Subject: [PATCH 01/48] [WIP] Updated implementation plan --- __temp__/plan_for_ext_deps_install_support.md | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 __temp__/plan_for_ext_deps_install_support.md diff --git a/__temp__/plan_for_ext_deps_install_support.md b/__temp__/plan_for_ext_deps_install_support.md new file mode 100644 index 0000000..c17f481 --- /dev/null +++ b/__temp__/plan_for_ext_deps_install_support.md @@ -0,0 +1,420 @@ + +# Phase 1: Extend Validator and Integrate with Hatch + +**Goal:** +Ensure the validator provides a simple, install-ready list of Hatch package dependencies (including `resolved_version`), and refactor Hatch to use this output. Address and fix the API breakage caused by recent validator changes. + +--- + +### Action 1.1: Extend the validator to output install-ready Hatch dependencies + +- **Preconditions:** + - Existing validator logic for dependency validation and graph traversal. + - Only Hatch dependencies are relevant for this step. + +- **Details:** + - Add or extend a method in `HatchPackageValidator` (or a related utility) to return a list of Hatch package dependencies for a given package, in install order (topologically sorted, acyclic). + - Each dependency object should include: + - `name` + - `version_constraint` + - `resolved_version` (mandatory, to facilitate downstream installation) + - Use or adapt logic from `version_utils.py` and `package_validator.py` to avoid duplicating dependency parsing or graph traversal. + - Do **not** include external dependency types in this output; those will be handled by their respective managers. + +- **Context**: + - Files: + - package_validator.py + - dependency_graph.py + - version_utils.py + - validator.py + - Symbols: + - `HatchPackageValidator` + - `DependencyGraph` + - Any new method like `get_hatch_dependencies_in_install_order` + +- **Postconditions:** + - Validator can output a simple, install-ready list of Hatch package dependencies (with `resolved_version`) for a given package. + +- **Validation:** + - **Development tests:** Integration tests using dummy packages to verify correct dependency order, content, and resolved versions. + - **Verification method:** Compare output to expected install order for known test cases. + +--- + +### Action 1.2: Refactor Hatch to delegate all dependency resolution to the validator and fix API breakage + +- **Preconditions:** + - Validator provides a method for retrieving install-ready Hatch dependencies (with `resolved_version`). + - Hatch currently has broken integration due to missing/changed attributes in the validator. + +- **Details:** + - Refactor Hatch to use the new validator method for all Hatch dependency resolution. + - Remove any direct access to `dependency_resolver` or other internals that no longer exist in `HatchPackageValidator`. + - Update all relevant code paths in Hatch (especially in `environment_manager.py`) to use the new API. + - Ensure that the new integration is robust to future validator changes by relying only on documented, stable APIs. + - Add or update error handling to provide clear messages if the validator cannot resolve dependencies. + +- **Context**: + - Files: + - environment_manager.py + - package_loader.py + - cli_hatch.py + - package_validator.py + - Symbols: + - `HatchEnvironmentManager` + - `HatchPackageValidator` + - Any new/updated method for dependency resolution + +- **Postconditions:** + - Hatch no longer relies on removed or internal attributes of the validator. + - All dependency resolution for Hatch packages is delegated to the validator via a stable, public API. + - The integration is robust and future-proof. + +- **Validation:** + - **Development tests:** Reuse or enhance `test_env_manip.py` to cover package installation, environment creation, and dependency resolution. + - **Verification method:** Run Hatch end-to-end and confirm no AttributeError or similar integration failures. + +--- + +### Phase 1 Completion Criteria + +- Validator provides a simple, install-ready list of Hatch dependencies (with `resolved_version`). +- Hatch uses only the validator for Hatch dependency resolution, with no broken or deprecated API usage. +- All integration and regression tests pass for both validator and Hatch. + +--- + +# Phase 2: Installer Interface, Concrete Installers, and Registry + +**Goal:** +Design a robust, extensible installer interface and registry, and implement installers for all supported types, each in its own file. + +--- + +### Action 2.1: Carefully design the `DependencyInstaller` abstract base class + +- **Preconditions:** + - Install-ready dependency objects are defined. + +- **Details:** + - Create `base_installer.py` with `DependencyInstaller` ABC. + - Define the interface: + - `install(dependency, env_context, progress_callback=None)` + - (Optional) `uninstall(dependency, env_context)` + - Document all parameters and expected behaviors. + +- **Context**: + - Files: + - `hatch/installers/base_installer.py` + - Symbols: + - `DependencyInstaller` (ABC) + +- **Postconditions:** + - Interface is stable and well-documented. + +- **Validation:** + - **Development tests:** Static type checks, interface tests. + - **Verification method:** Peer review of interface design. + +--- + +### Action 2.2: Implement and test concrete installers, each in its own file + +- **Preconditions:** + - Interface is defined. + +- **Details:** + - Create one file per installer: + - `hatch_installer.py` (uses `HatchPackageLoader` for file ops) + - `python_installer.py` (pip logic) + - `system_installer.py` (system package manager logic) + - `docker_installer.py` (Docker logic) + - Each installer implements the interface and handles its dependency type. + - Use dummy packages from validator tests for realistic scenarios. + +- **Context**: + - Files: + - `hatch/installers/hatch_installer.py` + - `hatch/installers/python_installer.py` + - `hatch/installers/system_installer.py` + - `hatch/installers/docker_installer.py` + - package_loader.py + - Symbols: + - `HatchInstaller` + - `PythonInstaller` + - `SystemInstaller` + - `DockerInstaller` + - `HatchPackageLoader` + +- **Postconditions:** + - Each installer can handle its dependency type. + +- **Validation:** + - **Development tests:** Use dummy packages for install simulation. + - **Verification method:** Check logs/output for correct installer invocation. + +--- + +### Action 2.3: Implement the installer registry in its own file and test with dummy packages + +- **Preconditions:** + - Installers are implemented. + +- **Details:** + - Create `registry.py` for the installer registry. + - Register each installer with the registry. + - Test registry lookup and installation using dummy packages, letting the registry orchestrate the process. + +- **Context**: + - Files: + - `hatch/installers/registry.py` + - Symbols: + - `InstallerRegistry` + +- **Postconditions:** + - Registry correctly delegates to installers. + +- **Validation:** + - **Development tests:** Integration tests using dummy packages. + - **Verification method:** Confirm correct installer is used for each dependency. + +--- + +### Phase 2 Completion Criteria + +- Stable, extensible installer interface in `base_installer.py`. +- All supported types have working installers, each in its own file. +- Registry delegates correctly, implemented in `registry.py`. +- All dummy package scenarios pass. + + +# Implementation Plan: Phase 3 – Orchestration, Environment Refactor, Parallelization, and Progress Reporting + +## Overview +**Objective:** +Modularize and modernize the installation orchestration, refactor environment management, enable safe parallelization, and implement robust progress reporting using the observer pattern. + +**Key constraints:** +- Maintain clear separation of concerns between environment management and installation orchestration. +- Ensure thread/process safety for parallel installs. +- Provide real-time, extensible progress reporting for UI/CLI. + +--- + +## Phase 3.1: Refactor Environment Management and Delegate Installation + +**Goal:** +Move all installation orchestration logic out of `environment_manager.py` into a dedicated orchestrator class. + +### Actions + +1. **Action 3.1.1:** Identify and extract all installation-related logic from `environment_manager.py`. + - **Preconditions:** Installer registry and concrete installers are implemented. + - **Details:** + - Move all code that resolves dependencies, selects installers, and performs installation to a new orchestrator class (e.g., `DependencyInstallerOrchestrator`). + - Keep only environment lifecycle and state management in `environment_manager.py`. + - **Context**: + - Files: + - `hatch/environment_manager.py` + - `hatch/package_loader.py` + - `hatch/installers/` (new directory for installers) + - `hatch/installers/registry.py` (installer registry) + - Symbols: + - `HatchEnvironmentManager` + - `add_package_to_environment` + - `HatchPackageLoader` + - `DependencyInstallerOrchestrator` (to be created) + - **Postconditions:** `environment_manager.py` delegates all installation to the orchestrator. + - **Validation:** + - **Development tests:** Integration tests for environment creation, deletion, and package installation. + - **Verification method:** Code review for separation of concerns. + +2. **Action 3.1.2:** Update all environment-related APIs to use the orchestrator for installation. + - **Preconditions:** Orchestrator class is implemented. + - **Details:** + - Refactor methods like `add_package_to_environment` to call the orchestrator. + - Ensure backward compatibility for public APIs. + - **Context**: + - Files: + - `hatch/environment_manager.py` + - `hatch/installers/dependency_installation_orchestrator.py` (or similar) + - Symbols: + - `HatchEnvironmentManager.add_package_to_environment` + - `DependencyInstallerOrchestrator.install_dependencies` + - **Postconditions:** All install flows go through the orchestrator. + - **Validation:** + - **Development tests:** Regression and integration tests for all environment operations. + +### Phase Completion Criteria +- `environment_manager.py` contains only environment lifecycle/state logic. +- All installation is delegated to the orchestrator. +- All tests for environment and install flows pass. + +--- + +## Phase 3.2: Implement Orchestration Logic with Parallelization + +**Goal:** +Enable the orchestrator to install non-overlapping dependency types in parallel, with robust error handling and rollback. + +### Actions + +1. **Action 3.2.1:** Analyze dependency types for safe parallelization. + - **Preconditions:** Installers are implemented and tested. + - **Details:** + - Identify which dependency types (e.g., hatch, python, docker, system) can be installed in parallel without conflicts. + - Document any constraints or exceptions. + - **Context**: + - Files: + - `hatch/installers/base_installer.py` + - `hatch/installers/hatch_installer.py` + - `hatch/installers/python_installer.py` + - `hatch/installers/system_installer.py` + - `hatch/installers/docker_installer.py` + - Symbols: + - `DependencyInstaller` + - `HatchInstaller` + - `PythonInstaller` + - `SystemInstaller` + - `DockerInstaller` + - **Postconditions:** Parallelization plan is documented. + - **Validation:** + - **Verification method:** Peer review of parallelization plan. + +2. **Action 3.2.2:** Implement parallel installation in the orchestrator. + - **Preconditions:** Parallelization plan is defined. + - **Details:** + - Use threads, async tasks, or process pools to install independent dependencies in parallel. + - Ensure thread/process safety and proper error propagation. + - Provide a configuration option to enable/disable parallelization. + - **Context**: + - Files: + - `hatch/installers/dependency_installation_orchestrator.py` + - Symbols: + - `DependencyInstallerOrchestrator.install_dependencies` + - **Postconditions:** Orchestrator can install dependencies in parallel where safe. + - **Validation:** + - **Development tests:** Simulate parallel installs with dummy packages. + - **Verification method:** Check for race conditions, correct install order, and error handling. + +3. **Action 3.2.3:** Implement robust error handling and rollback. + - **Preconditions:** Parallel installation logic is in place. + - **Details:** + - Use the Command pattern to encapsulate install/uninstall actions. + - On failure, roll back previously installed dependencies in reverse order. + - **Context**: + - Files: + - `hatch/installers/dependency_installation_orchestrator.py` + - Symbols: + - `DependencyInstallerOrchestrator.rollback` + - **Postconditions:** Partial installs are cleaned up on error. + - **Validation:** + - **Development tests:** Simulate failures and verify rollback. + - **Verification method:** Check environment state after simulated errors. + +### Phase Completion Criteria +- Orchestrator supports safe parallel installation. +- Rollback logic is robust and tested. +- All install scenarios (success, partial failure, rollback) are covered by tests. + +--- + +## Phase 3.3: Implement Observer-Based Progress Reporting + +**Goal:** +Provide real-time, extensible progress reporting using the observer (publish-subscribe) pattern. + +### Actions + +1. **Action 3.3.1:** Define progress event and subscriber interfaces. + - **Preconditions:** Orchestrator class is implemented. + - **Details:** + - Create a `ProgressEvent` data class (fields: dependency, status, percent, message, etc.). + - Define a `ProgressSubscriber` interface with an `update(event)` method. + - **Context**: + - Files: + - `hatch/installers/progress_events.py` (or similar) + - Symbols: + - `ProgressEvent` + - `ProgressSubscriber` + - **Postconditions:** Progress event and subscriber interfaces are available. + - **Validation:** + - **Development tests:** Unit tests for event and subscriber classes. + +2. **Action 3.3.2:** Integrate observer pattern into the orchestrator. + - **Preconditions:** Interfaces are defined. + - **Details:** + - Orchestrator maintains a list of subscribers. + - At each install step (start, progress, complete, error), orchestrator publishes a `ProgressEvent` to all subscribers. + - **Context**: + - Files: + - `hatch/installers/dependency_installation_orchestrator.py` + - `hatch/installers/progress_events.py` + - Symbols: + - `DependencyInstallerOrchestrator.subscribe` + - `DependencyInstallerOrchestrator.notify` + - **Postconditions:** Orchestrator notifies subscribers of progress in real time. + - **Validation:** + - **Development tests:** Simulate installs and verify progress events are sent. + - **Verification method:** Mock subscribers receive correct updates. + +3. **Action 3.3.3:** Implement a CLI/GUI subscriber for user feedback. + - **Preconditions:** Observer pattern is integrated. + - **Details:** + - Implement a subscriber that displays progress (percentage, current dependency, status) in the CLI or GUI. + - Ensure the subscriber can be easily replaced or extended for different UIs. + - **Context**: + - Files: + - `hatch/cli/progress_subscriber.py` (or similar) + - Symbols: + - `CLIProgressSubscriber` (example) + - **Postconditions:** Users receive real-time feedback during installation. + - **Validation:** + - **Development tests:** Manual and automated tests for progress display. + +### Phase Completion Criteria +- Observer pattern is fully integrated. +- Real-time progress updates are available to UI/CLI. +- Progress reporting is extensible and robust. + +--- + +## Phase 3.4: Final Integration and Regression Testing + +**Goal:** +Ensure all new and refactored components work together seamlessly and maintain backward compatibility. + +### Actions + +1. **Action 3.4.1:** Integrate all components and update documentation. + - **Preconditions:** All previous actions are complete. + - **Details:** + - Ensure all APIs, orchestrator, installers, and progress reporting are integrated. + - Update developer and user documentation to reflect new architecture. + - **Context**: + - Files: + - All files modified or created in previous actions + - `README.md`, developer docs + - Symbols: + - All public APIs and classes + - **Postconditions:** Documentation is up to date. + - **Validation:** + - **Verification method:** Peer review of documentation. + +2. **Action 3.4.2:** Run full regression and integration test suite. + - **Preconditions:** All code is integrated. + - **Details:** + - Run all existing and new tests (unit, integration, regression). + - Address any failures or regressions. + - **Context**: + - Files: + - `tests/` (all relevant test files) + - Symbols: + - All test cases and test runners + - **Postconditions:** All tests pass. + - **Validation:** + - **Development tests:** Full test suite. + +### Phase Completion Criteria +- All components are integrated and documented. +- All tests pass, ensuring stability and backward compatibility. \ No newline at end of file From 1454bffeb1718ffb58da2406c88eff15bd37eead Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 23 Jun 2025 23:38:12 +0900 Subject: [PATCH 02/48] [Refactoring] Pkg installation **Major**: - Rewrote completely the logic of `add_package_to_environment` to leverage the new API in `hatch_validator` - This allowed to completely remove duplicated code for dependency resolution. - In addition, we are making use of the package and registry services to leverage automatic handling of the different schema versions. - Also updated the tests to have a better mock registry as it was necessary with all the checks in place to control the registry's structure. --- hatch/environment_manager.py | 324 ++++++++++------------------------- tests/test_env_manip.py | 75 ++++++-- 2 files changed, 155 insertions(+), 244 deletions(-) diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 4744600..e799dee 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -9,10 +9,14 @@ from pathlib import Path from typing import Dict, List, Optional, Any, Tuple -from hatch_validator import HatchPackageValidator +from hatch_validator.package.package_service import PackageService +from hatch_validator.registry.registry_service import RegistryService, RegistryError +from hatch_validator.utils.hatch_dependency_graph import HatchDependencyGraphBuilder +from hatch_validator.utils.dependency_graph import DependencyGraph +from hatch_validator.utils.version_utils import VersionConstraintValidator, VersionConstraintError +from hatch_validator.core.validation_context import ValidationContext from .registry_retriever import RegistryRetriever -from .package_loader import HatchPackageLoader, PackageLoaderError -from .registry_explorer import find_package, find_package_version, get_package_release_url +from .package_loader import HatchPackageLoader class HatchEnvironmentError(Exception): @@ -67,16 +71,16 @@ def __init__(self, self._current_env_name = self._load_current_env_name() # Initialize dependencies - self.package_loader = HatchPackageLoader(cache_dir=cache_dir) - - # Get dependency resolver from imported module + self.package_loader = HatchPackageLoader(cache_dir=cache_dir) # Get dependency resolver from imported module self.retriever = RegistryRetriever(cache_ttl=cache_ttl, local_cache_dir=cache_dir, simulation_mode=simulation_mode, local_registry_cache_path=local_registry_cache_path) self.registry_data = self.retriever.get_registry() - self.package_validator = HatchPackageValidator(registry_data=self.registry_data) - self.dependency_resolver = self.package_validator.dependency_resolver + + # Initialize services for dependency management + self.registry_service = RegistryService(self.registry_data) + self.dependency_graph_builder = None # Will be initialized when needed def _initialize_environments_file(self): """Create the initial environments file with default environment.""" @@ -280,10 +284,11 @@ def add_package_to_environment(self, package_path_or_name: str, to an environment, including dependency resolution and installation. It performs the following steps: 1. Determines if the package is local or remote - 2. Gets package metadata and dependencies - 3. Checks for circular dependencies - 4. Installs missing dependencies - 5. Installs the main package + 2. Gets package metadata + 3. Builds a dependency graph for the package and returns ordered dependencies + 4. Compare against existing packages in the environment and retrieve missing dependencies + 5. Installs the package and its dependencies + Args: package_path_or_name (str): Path to local package or name of remote package. @@ -298,230 +303,86 @@ def add_package_to_environment(self, package_path_or_name: str, bool: True if successful, False otherwise. """ - # Refresh registry if requested or if force_download is specified - if refresh_registry or force_download: - self.refresh_registry(force_refresh=True) - - env_name = env_name or self._current_env_name - if not self.environment_exists(env_name): - self.logger.error(f"Environment {env_name} does not exist") - return False - # Check if package is local or remote - package_path = Path(package_path_or_name) - is_local_package = package_path.exists() and package_path.is_dir() - - local_deps, remote_deps = [], [] - if is_local_package: - # Get the hatch_dependencies from the local pkg's metadata - with open(package_path / "hatch_metadata.json", 'r') as f: - hatch_metadata = json.load(f) - package_name = hatch_metadata.get("name", Path(package_path).name) - package_version = hatch_metadata.get("version", "0.0.0") - hatch_dependencies = hatch_metadata.get("hatch_dependencies", []) - local_deps, remote_deps = self._get_deps_from_all_local_packages(hatch_dependencies) - - else: - # For remote packages, there can only be remote dependencies - package_name = package_path_or_name - if not version_constraint: - # Find package in registry data - package_registry_data = find_package(self.registry_data, package_name) - if not package_registry_data: - self.logger.error(f"Package {package_name} not found in registry") - return False - # Find the information about the package version - package_version_data = find_package_version(package_registry_data, version_constraint) - if not package_version_data: - self.logger.error(f"Package {package_name} with version constraint {version_constraint} not found in registry") - return False - else: - package_version = package_version_data.get("version") + # 1. Determine if the package is local or remote + root_pkg_type = "remote" + root_pkg_location = "" + path = Path(package_path_or_name) + if path.exists() and path.is_dir(): + root_pkg_type = "local" + root_pkg_location = str(path.resolve()) + # 2. Get package metadata for local package + metadata_path = path / "hatch_metadata.json" + with open(metadata_path, 'r') as f: + package_metadata = json.load(f) - remote_deps = self.package_validator.dependency_resolver.get_full_package_dependencies( - package_name, package_version).get("dependencies", []) - - # and the package we're trying to install - current_dependencies = [] - - # Get currently installed packages - installed_packages = {} - for pkg in self._environments[env_name].get("packages", []): - installed_packages[pkg["name"]] = pkg["version"] - - # For each installed package, get its dependencies to build the dependency graph - if pkg["type"] == "local": - # For local packages, use metadata file to get dependencies - pkg_install_path = self.get_environment_path(env_name) / pkg["name"] - if (pkg_install_path / "hatch_metadata.json").exists(): - with open(pkg_install_path / "hatch_metadata.json", 'r') as f: - pkg_metadata = json.load(f) - pkg_deps = pkg_metadata.get("hatch_dependencies", []) - - # Transform the dependencies to correct format - processed_deps = [] - for dep in pkg_deps: - if dep.get("name"): # Make sure the dependency has a name - processed_deps.append(dep.get("name")) - - current_dependencies.append({"name": pkg["name"], "dependencies": processed_deps}) - else: - # For remote packages, use the registry data - pkg_deps = self.package_validator.dependency_resolver.get_full_package_dependencies( - pkg["name"], pkg["version"]).get("dependencies", []) - - # Transform the dependencies to correct format - processed_deps = [] - for dep in pkg_deps: - if dep.get("name"): # Make sure the dependency has a name - processed_deps.append(dep.get("name")) - - current_dependencies.append({"name": pkg["name"], "dependencies": processed_deps}) - - # Add the new package to check if it would create a circular dependency - if is_local_package: - hatch_deps = hatch_metadata.get("hatch_dependencies", []) - processed_deps = [] - for dep in hatch_deps: - if dep.get("name"): # Make sure the dependency has a name - processed_deps.append(dep.get("name")) - - current_dependencies.append({ - "name": package_name, - "dependencies": processed_deps - }) else: - pkg_deps = self.package_validator.dependency_resolver.get_full_package_dependencies( - package_name, package_version).get("dependencies", []) - - processed_deps = [] - for dep in pkg_deps: - if dep.get("name"): # Make sure the dependency has a name - processed_deps.append(dep.get("name")) - - current_dependencies.append({ - "name": package_name, - "dependencies": processed_deps - }) - - self.logger.debug(f"Checking for circular dependencies in: {current_dependencies}") - - # Check for circular dependencies - has_cycles, cycles = self.package_validator.dependency_resolver.detect_dependency_cycles( - current_dependencies) - - if has_cycles: - self.logger.error(f"Circular dependency detected: {cycles}") - return False - - # Find missing dependencies - local_missing_deps = self._filter_for_missing_dependencies(local_deps, env_name) - remote_missing_deps = self._filter_for_missing_dependencies(remote_deps, env_name) - - # Install missing dependencies - ## Delegate to package loader - for dep in local_missing_deps: - try: - self.package_loader.install_local_package( - dep["path"], - self.get_environment_path(env_name), - dep["name"] - ) - with open(dep["path"] / "hatch_metadata.json", 'r') as f: - hatch_metadata = json.load(f) - self._add_package_to_env_data(env_name, dep["name"], hatch_metadata.get("version"), "local", "local") - except PackageLoaderError as e: - self.logger.error(f"Failed to install local package {dep['name']}: {e}") + # Assume it's a remote package + if not self.registry_service.package_exists(package_path_or_name): + self.logger.error(f"Package {package_path_or_name} does not exist in registry") return False - for dep in remote_missing_deps: + + # 2. Get package metadata for remote package try: - # First, download the package to cache - package_registry_data = find_package(self.registry_data, dep['name']) - self.logger.debug(f"Package registry data: {json.dumps(package_registry_data, indent=2)}") - package_url, package_version = get_package_release_url(package_registry_data, dep["version_constraint"]) - self.package_loader.install_remote_package(package_url, - dep["name"], - package_version, - self.get_environment_path(env_name), - force_download) - self._add_package_to_env_data(env_name, dep["name"], package_version, "remote", "registry") - except PackageLoaderError as e: - self.logger.error(f"Failed to install remote package {dep['name']}: {e}") + compatible_version = self.registry_service.find_compatible_version(package_path_or_name, version_constraint) + except VersionConstraintError as e: + self.logger.error(f"Version constraint error: {e}") return False - - # Install the main package and add it to environment data + + root_pkg_location = self.registry_service.get_package_uri(package_path_or_name, compatible_version) + path = self.package_loader.download_package(root_pkg_location, + package_path_or_name, + compatible_version, + force_download=force_download) + metadata_path = path / "hatch_metadata.json" + with open(metadata_path, 'r') as f: + package_metadata = json.load(f) + + # 3. Build dependency graph for the package + self.package_service = PackageService(package_metadata) + self.dependency_graph_builder = HatchDependencyGraphBuilder(self.package_service, self.registry_service) + context = ValidationContext(package_dir= path, + registry_data= self.registry_data, + allow_local_dependencies= True) + try: - if is_local_package: - # Install the local package - self.package_loader.install_local_package( - package_path, - self.get_environment_path(env_name), - Path(package_path).name - ) - # Read metadata to get name and version - with open(package_path / "hatch_metadata.json", 'r') as f: - package_metadata = json.load(f) - package_name = package_metadata.get("name", Path(package_path).name) - package_version = package_metadata.get("version", "0.0.0") - self._add_package_to_env_data(env_name, package_name, package_version, "local", "local") - else: # Remote package - package_registry_data = find_package(self.registry_data, package_path_or_name) - if not package_registry_data: - self.logger.error(f"Package {package_path_or_name} not found in registry") - return False - - package_url, package_version = get_package_release_url(package_registry_data, version_constraint) - if not package_url: - self.logger.error(f"Could not find release URL for package {package_path_or_name} with version constraint {version_constraint}") - return False - self.package_loader.install_remote_package( - package_url, - package_path_or_name, - package_version, - self.get_environment_path(env_name), - force_download - ) - self._add_package_to_env_data(env_name, package_path_or_name, package_version, "remote", "registry") - except PackageLoaderError as e: - self.logger.error(f"Failed to install package {package_path_or_name}: {e}") + dependencies = self.dependency_graph_builder.get_install_ready_dependencies(context) + except Exception as e: + self.logger.error(f"Error building dependency graph: {e}") return False - return True + # 4. Compare against existing packages in the environment and retrieve missing dependencies + env_name = env_name or self._current_env_name + missing_dependencies = self._filter_for_missing_dependencies(dependencies, env_name) - def _get_deps_from_all_local_packages(self, hatch_dependencies: List[Dict]) -> Tuple[List[Dict], List[str]]: - """Retrieves the local and remote dependencies from the hatch_dependencies list of a package. - - This method uses a breadth-first search approach to gather all dependencies, - separating them into local and remote categories. - - Args: - hatch_dependencies: List of dependencies from the package's metadata. - - Returns: - Tuple[List[Dict], List[str]]: A tuple containing: - - List of local dependencies with path information - - List of remote dependencies with version constraints - """ - deps_queue = hatch_dependencies.copy() - local_deps = [] - remote_deps_queue = [] - - while deps_queue: - dep = deps_queue.pop(0) - if dep.get("type").get("type") == "local": - local_deps += [{"name": dep.get("name"), "path": dep.get("type").get("path")}] - deps_queue += local_deps[-1].get("hatch_dependencies", []) + # 5. Install the package and its dependencies + self.package_loader.install_local_package(path, self.get_environment_path(env_name) / env_name, self.package_service.get_field("name")) + self._add_package_to_env_data(env_name, + self.package_service.get_field("name"), + self.package_service.get_field("version"), + root_pkg_type, + root_pkg_location) + for dep in missing_dependencies: + location = missing_dependencies[dep].get("uri") + if location[:7] == "file://": + # Local dependency + dep_path = Path(location[7:]) + # Install local package + self.package_loader.install_local_package(dep_path, self.get_environment_path(env_name), dep["name"]) + self._add_package_to_env_data(env_name, + dep["name"], + dep["resolved_version"], + "local", + location[7:]) else: - remote_deps_queue.append(dep) - - remote_deps = [] - while remote_deps_queue: - dep = remote_deps_queue.pop(0) - remote_deps += [{"name": dep.get("name"), "version_constraint": dep.get("version_constraint")}] - new_deps = self.package_validator.dependency_resolver.get_full_package_dependencies( - dep.get("name"), dep.get("version_constraint")) - remote_deps_queue += new_deps.get("dependencies", []) + # Remote dependency + self.package_loader.install_remote_package(location, dep["name"], dep["resolved_version"], self.get_environment_path(env_name)) + self._add_package_to_env_data(env_name, + dep["name"], + dep["resolved_version"], + "remote", + location) - return local_deps, remote_deps + return True def _filter_for_missing_dependencies(self, dependencies: List[Dict], env_name: str) -> List[Dict]: """Determine which dependencies are not installed in the environment.""" @@ -543,9 +404,11 @@ def _filter_for_missing_dependencies(self, dependencies: List[Dict], env_name: s # Check version constraints constraint = dep.get("version_constraint") - if constraint and not self.package_validator.dependency_resolver.is_version_compatible( - installed_packages[dep_name], constraint): - missing_deps.append(dep) + if constraint: + is_compatible, _ = VersionConstraintValidator.is_version_compatible( + installed_packages[dep_name], constraint) + if not is_compatible: + missing_deps.append(dep) return missing_deps @@ -731,9 +594,8 @@ def refresh_registry(self, force_refresh: bool = True) -> None: self.logger.info("Refreshing registry data...") try: self.registry_data = self.retriever.get_registry(force_refresh=force_refresh) - # Update package validator with new registry data - self.package_validator = HatchPackageValidator(registry_data=self.registry_data) - self.dependency_resolver = self.package_validator.dependency_resolver + # Update registry service with new registry data + self.registry_service = RegistryService(self.registry_data) self.logger.info("Registry data refreshed successfully") except Exception as e: self.logger.error(f"Failed to refresh registry data: {e}") diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index 11221ae..131bc48 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -11,8 +11,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from hatch.environment_manager import HatchEnvironmentManager -from hatch.registry_retriever import RegistryRetriever -from hatch_registry import RegistryUpdater # Configure logging logging.basicConfig( @@ -37,7 +35,6 @@ def setUp(self): # Create a sample registry that includes Hatching-Dev packages self._create_sample_registry() - self.test_registry = RegistryUpdater(self.registry_path) # Override environment paths to use our test directory env_dir = Path(self.temp_dir) / "envs" @@ -59,16 +56,16 @@ def setUp(self): self.env_manager.reload_environments() def _create_sample_registry(self): - """Create a sample registry with Hatching-Dev packages""" - # Basic registry structure - test_registry = { + """Create a sample registry with Hatching-Dev packages using real metadata.""" + now = datetime.now().isoformat() + registry = { "registry_schema_version": "1.1.0", - "last_updated": datetime.now().isoformat(), + "last_updated": now, "repositories": [ { "name": "test-repo", - "url": "file:///hatching-dev", - "last_indexed": datetime.now().isoformat(), + "url": f"file://{self.hatch_dev_path}", + "last_indexed": now, "packages": [] } ], @@ -77,13 +74,65 @@ def _create_sample_registry(self): "total_versions": 0 } } - + pkg_names = [ + "arithmetic_pkg", "base_pkg_1", "base_pkg_2", "python_dep_pkg", + "circular_dep_pkg_1", "circular_dep_pkg_2", "complex_dep_pkg", "simple_dep_pkg" + ] + for pkg_name in pkg_names: + pkg_path = self.hatch_dev_path / pkg_name + if pkg_path.exists(): + metadata_path = pkg_path / "hatch_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path, 'r') as f: + metadata = json.load(f) + pkg_entry = { + "name": metadata.get("name", pkg_name), + "description": metadata.get("description", ""), + "tags": metadata.get("tags", []), + "latest_version": metadata.get("version", "1.0.0"), + "versions": [ + { + "version": metadata.get("version", "1.0.0"), + "release_uri": f"file://{pkg_path}", + "author": { + "GitHubID": metadata.get("author", {}).get("name", "test_user"), + "email": metadata.get("author", {}).get("email", "test@example.com") + }, + "added_date": now, + "hatch_dependencies_added": [ + { + "name": dep["name"], + "version_constraint": dep.get("version_constraint", "") + } for dep in metadata.get("hatch_dependencies", []) + ], + "python_dependencies_added": [ + { + "name": dep["name"], + "version_constraint": dep.get("version_constraint", ""), + "package_manager": dep.get("package_manager", "pip") + } for dep in metadata.get("python_dependencies", []) + ], + "hatch_dependencies_removed": [], + "hatch_dependencies_modified": [], + "python_dependencies_removed": [], + "python_dependencies_modified": [], + "compatibility_changes": {} + } + ] + } + registry["repositories"][0]["packages"].append(pkg_entry) + except Exception as e: + logger.error(f"Failed to load metadata for {pkg_name}: {e}") + raise e + # Update stats + registry["stats"]["total_packages"] = len(registry["repositories"][0]["packages"]) + registry["stats"]["total_versions"] = sum(len(pkg["versions"]) for pkg in registry["repositories"][0]["packages"]) registry_dir = Path(self.temp_dir) / "registry" registry_dir.mkdir(parents=True, exist_ok=True) self.registry_path = registry_dir / "hatch_packages_registry.json" - - with open(self.registry_path, "w") as f: - json.dump(test_registry, f, indent=2) + with open(self.registry_path, "w") as f: + json.dump(registry, f, indent=2) logger.info(f"Sample registry created at {self.registry_path}") def tearDown(self): From 205906a1456defd3cc4f2aa244e99bb95609881e Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Tue, 24 Jun 2025 00:17:39 +0900 Subject: [PATCH 03/48] [Update] Version bump v0.4.0 **Major**: - Acknowledge successfully refactoring to use the new `hatch_validator` --- hatch/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hatch/__init__.py b/hatch/__init__.py index f9cd2c1..60c0aaa 100644 --- a/hatch/__init__.py +++ b/hatch/__init__.py @@ -5,7 +5,7 @@ and interacting with the Hatch registry. """ -__version__ = "0.3.1" +__version__ = "0.4.0" from .cli_hatch import main from .environment_manager import HatchEnvironmentManager diff --git a/pyproject.toml b/pyproject.toml index 30404b3..00b0e8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hatch" -version = "0.3.1" +version = "0.4.0" authors = [ { name = "Hatch Team" }, ] From 4b1452493e9c057c7075e88cac04dc89036e18e9 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Tue, 24 Jun 2025 00:33:16 +0900 Subject: [PATCH 04/48] [Add] Deps installer base logic **Major**: - `DependencyInstaller` Abstract base class for dependency installers. - `InstallationContext` (data class), `InstallationStatus` (Enum), `InstallationResult` (data class) - Added `test_installer_base.py` and integrated it with `run_environment_tests.py` --- hatch/installers/__init__.py | 14 ++ hatch/installers/installation_context.py | 98 ++++++++++ hatch/installers/installer_base.py | 195 ++++++++++++++++++++ tests/run_environment_tests.py | 4 + tests/test_installer_base.py | 223 +++++++++++++++++++++++ 5 files changed, 534 insertions(+) create mode 100644 hatch/installers/__init__.py create mode 100644 hatch/installers/installation_context.py create mode 100644 hatch/installers/installer_base.py create mode 100644 tests/test_installer_base.py diff --git a/hatch/installers/__init__.py b/hatch/installers/__init__.py new file mode 100644 index 0000000..79d30fc --- /dev/null +++ b/hatch/installers/__init__.py @@ -0,0 +1,14 @@ +"""Installer framework for Hatch dependency management. + +This package provides a robust, extensible installer interface and concrete +implementations for different dependency types including Hatch packages, +Python packages, system packages, and Docker containers. +""" + +from .installer_base import DependencyInstaller, InstallationError, InstallationContext + +__all__ = [ + "DependencyInstaller", + "InstallationError", + "InstallationContext" +] diff --git a/hatch/installers/installation_context.py b/hatch/installers/installation_context.py new file mode 100644 index 0000000..44dd6a9 --- /dev/null +++ b/hatch/installers/installation_context.py @@ -0,0 +1,98 @@ +""" +Defines context, status, and result data structures for dependency installation. + +This module provides the InstallationContext dataclass for encapsulating +environment and configuration information required during dependency installation, +as well as InstallationStatus and InstallationResult for representing the +outcome and details of installation operations. +""" + +from pathlib import Path +from typing import Dict, Any, Optional, List +from dataclasses import dataclass +from enum import Enum + +@dataclass +class InstallationContext: + """Context information for dependency installation. + + This class encapsulates all the environment and configuration information + needed for installing dependencies, making the installer interface cleaner + and more extensible. + """ + + environment_path: Path + """Path to the target environment where dependencies will be installed.""" + + environment_name: str + """Name of the target environment.""" + + temp_dir: Optional[Path] = None + """Temporary directory for download/build operations.""" + + cache_dir: Optional[Path] = None + """Cache directory for reusable artifacts.""" + + parallel_enabled: bool = True + """Whether parallel installation is enabled.""" + + force_reinstall: bool = False + """Whether to force reinstallation of existing packages.""" + + simulation_mode: bool = False + """Whether to run in simulation mode (no actual installation).""" + + extra_config: Optional[Dict[str, Any]] = None + """Additional installer-specific configuration.""" + + def get_config(self, key: str, default: Any = None) -> Any: + """Get a configuration value from extra_config. + + Args: + key (str): Configuration key to retrieve. + default (Any, optional): Default value if key not found. + + Returns: + Any: Configuration value or default. + """ + if self.extra_config is None: + return default + return self.extra_config.get(key, default) + + +class InstallationStatus(Enum): + """Status of an installation operation.""" + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + ROLLED_BACK = "rolled_back" + +@dataclass +class InstallationResult: + """Result of an installation operation. + + Provides detailed information about the installation outcome, + including status, paths, and any error information. + """ + + dependency_name: str + """Name of the dependency that was installed.""" + + status: InstallationStatus + """Final status of the installation.""" + + installed_path: Optional[Path] = None + """Path where the dependency was installed.""" + + installed_version: Optional[str] = None + """Actual version that was installed.""" + + error_message: Optional[str] = None + """Error message if installation failed.""" + + artifacts: Optional[List[Path]] = None + """List of files/directories created during installation.""" + + metadata: Optional[Dict[str, Any]] = None + """Additional installer-specific metadata.""" \ No newline at end of file diff --git a/hatch/installers/installer_base.py b/hatch/installers/installer_base.py new file mode 100644 index 0000000..f7cd828 --- /dev/null +++ b/hatch/installers/installer_base.py @@ -0,0 +1,195 @@ +"""Abstract base class for dependency installers. + +This module defines the core installer interface that all concrete installers +must implement, ensuring consistent behavior across different dependency types. +""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List + +from .installation_context import InstallationContext, InstallationResult + + +class InstallationError(Exception): + """Exception raised for installation-related errors. + + This exception provides structured error information that can be used + for error reporting and recovery strategies. + """ + + def __init__(self, message: str, dependency_name: Optional[str] = None, + error_code: Optional[str] = None, cause: Optional[Exception] = None): + """Initialize the installation error. + + Args: + message (str): Human-readable error message. + dependency_name (str, optional): Name of the dependency that failed. + error_code (str, optional): Machine-readable error code. + cause (Exception, optional): Underlying exception that caused this error. + """ + super().__init__(message) + self.dependency_name = dependency_name + self.error_code = error_code + self.cause = cause + + +class DependencyInstaller(ABC): + """Abstract base class for dependency installers. + + This class defines the core interface that all concrete installers must implement. + It provides a consistent API for installing and managing dependencies across + different types (Hatch packages, Python packages, system packages, Docker containers). + + The installer design follows these principles: + - Single responsibility: Each installer handles one dependency type + - Extensibility: New dependency types can be added by implementing this interface + - Observability: Progress reporting through callbacks + - Error handling: Structured exceptions and rollback support + - Testability: Clear interface for mocking and testing + """ + + @property + @abstractmethod + def installer_type(self) -> str: + """Get the type identifier for this installer. + + Returns: + str: Unique identifier for the installer type (e.g., "hatch", "python", "docker"). + """ + pass + + @property + @abstractmethod + def supported_schemes(self) -> List[str]: + """Get the URI schemes this installer can handle. + + Returns: + List[str]: List of URI schemes (e.g., ["file", "http", "https"] for local/remote packages). + """ + pass + + @abstractmethod + def can_install(self, dependency: Dict[str, Any]) -> bool: + """Check if this installer can handle the given dependency. + + This method allows the installer registry to determine which installer + should be used for a specific dependency. + + Args: + dependency (Dict[str, Any]): Dependency object with keys like 'type', 'name', 'uri', etc. + + Returns: + bool: True if this installer can handle the dependency, False otherwise. + """ + pass + + @abstractmethod + def install(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Install a dependency. + + This is the core method that performs the actual installation of a dependency + into the specified environment. + + Args: + dependency (Dict[str, Any]): Dependency object containing: + - name (str): Name of the dependency + - version_constraint (str): Version constraint + - resolved_version (str): Specific version to install + - uri (str, optional): Download/source URI + - type (str): Dependency type + - Additional installer-specific fields + context (InstallationContext): Installation context with environment info + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + Parameters: (operation_name, progress_percentage, status_message) + + Returns: + InstallationResult: Result of the installation operation. + + Raises: + InstallationError: If installation fails for any reason. + """ + pass + + def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Uninstall a dependency. + + Default implementation raises NotImplementedError. Concrete installers + can override this method to provide uninstall functionality. + + Args: + dependency (Dict[str, Any]): Dependency object to uninstall. + context (InstallationContext): Installation context with environment info. + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the uninstall operation. + + Raises: + NotImplementedError: If uninstall is not supported by this installer. + InstallationError: If uninstall fails for any reason. + """ + raise NotImplementedError(f"Uninstall not implemented for {self.installer_type} installer") + + def validate_dependency(self, dependency: Dict[str, Any]) -> bool: + """Validate that a dependency object has required fields. + + This method can be overridden by concrete installers to perform + installer-specific validation. + + Args: + dependency (Dict[str, Any]): Dependency object to validate. + + Returns: + bool: True if dependency is valid, False otherwise. + """ + required_fields = ["name", "version_constraint", "resolved_version"] + return all(field in dependency for field in required_fields) + + def get_installation_info(self, dependency: Dict[str, Any], context: InstallationContext) -> Dict[str, Any]: + """Get information about what would be installed without actually installing. + + This method can be used for dry-run scenarios or to provide installation + previews to users. + + Args: + dependency (Dict[str, Any]): Dependency object to analyze. + context (InstallationContext): Installation context. + + Returns: + Dict[str, Any]: Information about the planned installation. + """ + return { + "installer_type": self.installer_type, + "dependency_name": dependency.get("name"), + "resolved_version": dependency.get("resolved_version"), + "target_path": context.environment_path, + "supported": self.can_install(dependency) + } + + def cleanup_failed_installation(self, dependency: Dict[str, Any], context: InstallationContext, + artifacts: Optional[List[Path]] = None) -> None: + """Clean up artifacts from a failed installation. + + This method is called when an installation fails and needs to be rolled back. + Concrete installers can override this to perform specific cleanup operations. + + Args: + dependency (Dict[str, Any]): Dependency that failed to install. + context (InstallationContext): Installation context. + artifacts (List[Path], optional): List of files/directories to clean up. + """ + if artifacts: + for artifact in artifacts: + try: + if artifact.exists(): + if artifact.is_file(): + artifact.unlink() + elif artifact.is_dir(): + import shutil + shutil.rmtree(artifact) + except Exception: + # Log but don't raise - cleanup is best effort + pass diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index c7d9595..7ddf6d3 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -39,6 +39,10 @@ # Run only package loader online mode tests logger.info("Running package loader online mode tests...") test_suite = test_loader.loadTestsFromName("test_online_package_loader.OnlinePackageLoaderTests") + elif len(sys.argv) > 1 and sys.argv[1] == "--installer-only": + # Run only installer interface tests + logger.info("Running installer interface tests only...") + test_suite = test_loader.loadTestsFromName("test_installer_base.BaseInstallerTests") else: # Run all tests logger.info("Running all package environment tests...") diff --git a/tests/test_installer_base.py b/tests/test_installer_base.py new file mode 100644 index 0000000..e29c864 --- /dev/null +++ b/tests/test_installer_base.py @@ -0,0 +1,223 @@ +import sys +import unittest +import logging +import tempfile +import shutil +from pathlib import Path +from unittest.mock import Mock +from typing import Dict, Any, List + +# Add parent directory to path for direct testing +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from hatch.installers.installer_base import ( + DependencyInstaller, + InstallationError +) + +from hatch.installers.installation_context import ( + InstallationContext, + InstallationResult, + InstallationStatus +) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("hatch.installer_interface_tests") + + +class MockInstaller(DependencyInstaller): + """Mock installer for testing the base interface.""" + + @property + def installer_type(self) -> str: + return "mock" + + @property + def supported_schemes(self) -> List[str]: + return ["test", "mock"] + + def can_install(self, dependency: Dict[str, Any]) -> bool: + return dependency.get("type") == "mock" + + def install(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback=None) -> InstallationResult: + return InstallationResult( + dependency_name=dependency["name"], + status=InstallationStatus.COMPLETED, + installed_path=context.environment_path / dependency["name"], + installed_version=dependency["resolved_version"] + ) + + +class BaseInstallerTests(unittest.TestCase): + """Tests for the DependencyInstaller base class interface.""" + + def setUp(self): + """Set up test environment before each test.""" + # Create a temporary directory for test environments + self.temp_dir = tempfile.mkdtemp() + self.env_path = Path(self.temp_dir) / "test_env" + self.env_path.mkdir(parents=True, exist_ok=True) + + # Create a mock installer instance for testing + self.installer = MockInstaller() + + # Create test context + self.context = InstallationContext( + environment_path=self.env_path, + environment_name="test_env" + ) + + logger.info(f"Set up test environment at {self.temp_dir}") + + def tearDown(self): + """Clean up test environment after each test.""" + if hasattr(self, 'temp_dir') and Path(self.temp_dir).exists(): + shutil.rmtree(self.temp_dir, ignore_errors=True) + logger.info(f"Cleaned up test environment at {self.temp_dir}") + + def test_installation_context_creation(self): + """Test that InstallationContext can be created with required fields.""" + context = InstallationContext( + environment_path=Path("/test/env"), + environment_name="test_env" + ) + self.assertEqual(context.environment_path, Path("/test/env"), f"Expected environment_path=/test/env, got {context.environment_path}") + self.assertEqual(context.environment_name, "test_env", f"Expected environment_name='test_env', got {context.environment_name}") + self.assertTrue(context.parallel_enabled, f"Expected parallel_enabled=True, got {context.parallel_enabled}") # Default value + self.assertEqual(context.get_config("nonexistent", "default"), "default", f"Expected default config fallback, got {context.get_config('nonexistent', 'default')}") + logger.info("InstallationContext creation test passed") + + def test_installation_context_with_config(self): + """Test InstallationContext with extra configuration.""" + context = InstallationContext( + environment_path=Path("/test/env"), + environment_name="test_env", + extra_config={"custom_setting": "value"} + ) + self.assertEqual(context.get_config("custom_setting"), "value", f"Expected custom_setting='value', got {context.get_config('custom_setting')}") + self.assertEqual(context.get_config("missing_key", "fallback"), "fallback", f"Expected fallback for missing_key, got {context.get_config('missing_key', 'fallback')}") + logger.info("InstallationContext with config test passed") + + def test_installation_result_creation(self): + """Test that InstallationResult can be created.""" + result = InstallationResult( + dependency_name="test_package", + status=InstallationStatus.COMPLETED, + installed_path=Path("/env/test_package"), + installed_version="1.0.0" + ) + self.assertEqual(result.dependency_name, "test_package", f"Expected dependency_name='test_package', got {result.dependency_name}") + self.assertEqual(result.status, InstallationStatus.COMPLETED, f"Expected status=COMPLETED, got {result.status}") + self.assertEqual(result.installed_path, Path("/env/test_package"), f"Expected installed_path=/env/test_package, got {result.installed_path}") + self.assertEqual(result.installed_version, "1.0.0", f"Expected installed_version='1.0.0', got {result.installed_version}") + logger.info("InstallationResult creation test passed") + + def test_installation_error(self): + """Test InstallationError creation and attributes.""" + error = InstallationError( + message="Installation failed", + dependency_name="test_package", + error_code="DOWNLOAD_FAILED" + ) + self.assertEqual(str(error), "Installation failed", f"Expected error message 'Installation failed', got '{str(error)}'") + self.assertEqual(error.dependency_name, "test_package", f"Expected dependency_name='test_package', got {error.dependency_name}") + self.assertEqual(error.error_code, "DOWNLOAD_FAILED", f"Expected error_code='DOWNLOAD_FAILED', got {error.error_code}") + logger.info("InstallationError test passed") + + def test_mock_installer_interface(self): + """Test that MockInstaller implements the interface correctly.""" + # Test properties + self.assertEqual(self.installer.installer_type, "mock", f"Expected installer_type='mock', got {self.installer.installer_type}") + self.assertEqual(self.installer.supported_schemes, ["test", "mock"], f"Expected supported_schemes=['test', 'mock'], got {self.installer.supported_schemes}") + # Test can_install + mock_dep = {"type": "mock", "name": "test"} + non_mock_dep = {"type": "other", "name": "test"} + self.assertTrue(self.installer.can_install(mock_dep), f"Expected can_install to be True for {mock_dep}") + self.assertFalse(self.installer.can_install(non_mock_dep), f"Expected can_install to be False for {non_mock_dep}") + logger.info("MockInstaller interface test passed") + + def test_mock_installer_install(self): + """Test the install method of MockInstaller.""" + dependency = { + "name": "test_package", + "type": "mock", + "version_constraint": ">=1.0.0", + "resolved_version": "1.2.0" + } + result = self.installer.install(dependency, self.context) + self.assertEqual(result.dependency_name, "test_package", f"Expected dependency_name='test_package', got {result.dependency_name}") + self.assertEqual(result.status, InstallationStatus.COMPLETED, f"Expected status=COMPLETED, got {result.status}") + self.assertEqual(result.installed_path, self.env_path / "test_package", f"Expected installed_path={self.env_path / 'test_package'}, got {result.installed_path}") + self.assertEqual(result.installed_version, "1.2.0", f"Expected installed_version='1.2.0', got {result.installed_version}") + logger.info("MockInstaller install test passed") + + def test_mock_installer_validation(self): + """Test dependency validation.""" + valid_dep = { + "name": "test", + "version_constraint": ">=1.0.0", + "resolved_version": "1.0.0" + } + invalid_dep = { + "name": "test" + # Missing required fields + } + self.assertTrue(self.installer.validate_dependency(valid_dep), f"Expected valid dependency to pass validation: {valid_dep}") + self.assertFalse(self.installer.validate_dependency(invalid_dep), f"Expected invalid dependency to fail validation: {invalid_dep}") + logger.info("MockInstaller validation test passed") + + def test_mock_installer_get_installation_info(self): + """Test getting installation information.""" + dependency = { + "name": "test_package", + "type": "mock", + "resolved_version": "1.0.0" + } + info = self.installer.get_installation_info(dependency, self.context) + self.assertEqual(info["installer_type"], "mock", f"Expected installer_type='mock', got {info['installer_type']}") + self.assertEqual(info["dependency_name"], "test_package", f"Expected dependency_name='test_package', got {info['dependency_name']}") + self.assertEqual(info["resolved_version"], "1.0.0", f"Expected resolved_version='1.0.0', got {info['resolved_version']}") + self.assertEqual(info["target_path"], self.env_path, f"Expected target_path={self.env_path}, got {info['target_path']}") + self.assertTrue(info["supported"], f"Expected supported=True, got {info['supported']}") + logger.info("MockInstaller get_installation_info test passed") + + def test_mock_installer_uninstall_not_implemented(self): + """Test that uninstall raises NotImplementedError by default.""" + dependency = {"name": "test", "type": "mock"} + with self.assertRaises(NotImplementedError, msg="Expected NotImplementedError for uninstall on MockInstaller"): + self.installer.uninstall(dependency, self.context) + logger.info("MockInstaller uninstall NotImplementedError test passed") + + def test_installation_status_enum(self): + """Test InstallationStatus enum values.""" + self.assertEqual(InstallationStatus.PENDING.value, "pending", f"Expected PENDING='pending', got {InstallationStatus.PENDING.value}") + self.assertEqual(InstallationStatus.IN_PROGRESS.value, "in_progress", f"Expected IN_PROGRESS='in_progress', got {InstallationStatus.IN_PROGRESS.value}") + self.assertEqual(InstallationStatus.COMPLETED.value, "completed", f"Expected COMPLETED='completed', got {InstallationStatus.COMPLETED.value}") + self.assertEqual(InstallationStatus.FAILED.value, "failed", f"Expected FAILED='failed', got {InstallationStatus.FAILED.value}") + self.assertEqual(InstallationStatus.ROLLED_BACK.value, "rolled_back", f"Expected ROLLED_BACK='rolled_back', got {InstallationStatus.ROLLED_BACK.value}") + logger.info("InstallationStatus enum test passed") + + def test_progress_callback_support(self): + """Test that installer accepts progress callback.""" + dependency = { + "name": "test_package", + "type": "mock", + "resolved_version": "1.0.0" + } + callback_called = [] + def progress_callback(progress: float, message: str = ""): + callback_called.append((progress, message)) + # Install with callback - should not raise error + result = self.installer.install(dependency, self.context, progress_callback) + self.assertEqual(result.status, InstallationStatus.COMPLETED, f"Expected status=COMPLETED, got {result.status}") + logger.info("Progress callback support test passed") + + +if __name__ == "__main__": + # Run the tests + unittest.main(verbosity=2) From 8f80f8dc74d986e9b652abb1303c784917620a8c Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Tue, 24 Jun 2025 11:59:58 +0900 Subject: [PATCH 05/48] [Add] Hatch Installer **Major**: - The concrete implementation to install hatch depencies of a hatch package - Added tests to check installation assuming the validation checks were performed before hand (i.e. truly instal-ready) --- hatch/installers/hatch_installer.py | 177 ++++++++++++++++++++++++++++ tests/run_environment_tests.py | 4 + tests/test_hatch_installer.py | 157 ++++++++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 hatch/installers/hatch_installer.py create mode 100644 tests/test_hatch_installer.py diff --git a/hatch/installers/hatch_installer.py b/hatch/installers/hatch_installer.py new file mode 100644 index 0000000..cfe87ae --- /dev/null +++ b/hatch/installers/hatch_installer.py @@ -0,0 +1,177 @@ +"""Installer for Hatch package dependencies. + +Implements installation logic for Hatch packages using the HatchPackageLoader and +integrates pre-install validation using HatchPackageValidator and PackageService. +""" + +import logging +import shutil +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List + +from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError +from hatch.package_loader import HatchPackageLoader, PackageLoaderError +from hatch_validator.package_validator import HatchPackageValidator +from hatch_validator.package.package_service import PackageService + +class HatchInstaller(DependencyInstaller): + """Installer for Hatch package dependencies. + + Handles installation, validation, and uninstallation of Hatch packages using + the HatchPackageLoader and validator APIs. + """ + + def __init__(self, registry_data: Optional[Dict[str, Any]] = None): + """Initialize the HatchInstaller. + + Args: + registry_data (Dict[str, Any], optional): Registry data for validation. Defaults to None. + """ + self.logger = logging.getLogger("hatch.installers.hatch_installer") + self.package_loader = HatchPackageLoader() + self.validator = HatchPackageValidator(registry_data=registry_data) + + @property + def installer_type(self) -> str: + """Get the type identifier for this installer. + + Returns: + str: Unique identifier for the installer type ("hatch"). + """ + return "hatch" + + @property + def supported_schemes(self) -> List[str]: + """Get the URI schemes this installer can handle. + + Returns: + List[str]: List of URI schemes (e.g., ["file", "http", "https"]). + """ + return ["file", "http", "https"] + + def can_install(self, dependency: Dict[str, Any]) -> bool: + """Check if this installer can handle the given dependency. + + Args: + dependency (Dict[str, Any]): Dependency object. + + Returns: + bool: True if this installer can handle the dependency, False otherwise. + """ + return dependency.get("type") == self.installer_type + + def validate_dependency(self, dependency: Dict[str, Any]) -> bool: + """Validate that a dependency object has required fields and is a valid Hatch package. + + Args: + dependency (Dict[str, Any]): Dependency object to validate. + + Returns: + bool: True if dependency is valid, False otherwise. + """ + required_fields = ["name", "version_constraint", "resolved_version", "uri"] + if not all(field in dependency for field in required_fields): + return False + # Optionally, perform further validation using the validator if a path is provided + return True + + def install(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Install a Hatch package dependency. + + Args: + dependency (Dict[str, Any]): Dependency object containing name, version, uri, etc. + context (InstallationContext): Installation context with environment info. + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the installation operation. + + Raises: + InstallationError: If installation fails for any reason. + """ + name = dependency["name"] + version = dependency["resolved_version"] + uri = dependency["uri"] + target_dir = Path(context.environment_path) + try: + if progress_callback: + progress_callback("validate", 0.0, f"Validating {name}") + # Optionally, validate package metadata if local path is available + # Download/install the package + if uri and uri.startswith("file://"): + pkg_path = Path(uri[7:]) + result_path = self.package_loader.install_local_package(pkg_path, target_dir, name) + elif uri: + result_path = self.package_loader.install_remote_package(uri, name, version, target_dir) + else: + raise InstallationError(f"No URI provided for dependency {name}", dependency_name=name) + if progress_callback: + progress_callback("install", 1.0, f"Installed {name} to {result_path}") + return InstallationResult( + dependency_name=name, + status="COMPLETED", + installed_path=result_path, + installed_version=version, + error_message=None, + artifacts=result_path, + metadata={"name": name, "version": version} + ) + except (PackageLoaderError, Exception) as e: + self.logger.error(f"Failed to install {name}: {e}") + raise InstallationError(f"Failed to install {name}: {e}", dependency_name=name, cause=e) + + def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Uninstall a Hatch package dependency. + + Args: + dependency (Dict[str, Any]): Dependency object to uninstall. + context (InstallationContext): Installation context with environment info. + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the uninstall operation. + + Raises: + InstallationError: If uninstall fails for any reason. + """ + name = dependency["name"] + target_dir = Path(context.environment_path) / name + try: + if target_dir.exists(): + shutil.rmtree(target_dir) + if progress_callback: + progress_callback("uninstall", 1.0, f"Uninstalled {name}") + return InstallationResult( + dependency_name=name, + status="COMPLETED", + installed_path=target_dir, + installed_version=dependency.get("resolved_version"), + error_message=None, + artifacts=None, + metadata={"name": name} + ) + except Exception as e: + self.logger.error(f"Failed to uninstall {name}: {e}") + raise InstallationError(f"Failed to uninstall {name}: {e}", dependency_name=name, cause=e) + + def cleanup_failed_installation(self, dependency: Dict[str, Any], context: InstallationContext, + artifacts: Optional[List[Path]] = None) -> None: + """Clean up artifacts from a failed installation. + + Args: + dependency (Dict[str, Any]): Dependency that failed to install. + context (InstallationContext): Installation context. + artifacts (List[Path], optional): List of files/directories to clean up. + """ + if artifacts: + for artifact in artifacts: + try: + if artifact.exists(): + if artifact.is_file(): + artifact.unlink() + elif artifact.is_dir(): + shutil.rmtree(artifact) + except Exception: + pass diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index 7ddf6d3..a1779ad 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -43,6 +43,10 @@ # Run only installer interface tests logger.info("Running installer interface tests only...") test_suite = test_loader.loadTestsFromName("test_installer_base.BaseInstallerTests") + elif len(sys.argv) > 1 and sys.argv[1] == "--hatch-installer-only": + # Run only HatchInstaller tests + logger.info("Running HatchInstaller tests only...") + test_suite = test_loader.loadTestsFromName("test_hatch_installer.TestHatchInstaller") else: # Run all tests logger.info("Running all package environment tests...") diff --git a/tests/test_hatch_installer.py b/tests/test_hatch_installer.py new file mode 100644 index 0000000..e5279a6 --- /dev/null +++ b/tests/test_hatch_installer.py @@ -0,0 +1,157 @@ +import unittest +import tempfile +import shutil +import logging +from pathlib import Path +from datetime import datetime + +from hatch.installers.hatch_installer import HatchInstaller +from hatch.package_loader import HatchPackageLoader +from hatch_validator.package_validator import HatchPackageValidator +from hatch_validator.package.package_service import PackageService + +class TestHatchInstaller(unittest.TestCase): + """Tests for the HatchInstaller using dummy packages from Hatching-Dev.""" + + @classmethod + def setUpClass(cls): + # Path to Hatching-Dev dummy packages + cls.hatch_dev_path = Path(__file__).parent.parent.parent / "Hatching-Dev" + assert cls.hatch_dev_path.exists(), f"Hatching-Dev directory not found at {cls.hatch_dev_path}" + + # Build a mock registry from Hatching-Dev packages (pattern from test_package_validator.py) + cls.registry_data = cls._build_test_registry(cls.hatch_dev_path) + cls.validator = HatchPackageValidator(registry_data=cls.registry_data) + cls.package_loader = HatchPackageLoader() + cls.installer = HatchInstaller() + + @staticmethod + def _build_test_registry(hatch_dev_path): + registry = { + "registry_schema_version": "1.1.0", + "last_updated": datetime.now().isoformat(), + "repositories": [ + { + "name": "Hatch-Dev", + "url": "file://" + str(hatch_dev_path), + "packages": [], + "last_indexed": datetime.now().isoformat() + } + ] + } + pkg_names = [ + "arithmetic_pkg", "base_pkg_1", "base_pkg_2", "python_dep_pkg", + "circular_dep_pkg_1", "circular_dep_pkg_2", "complex_dep_pkg", + "simple_dep_pkg", "missing_dep_pkg", "version_dep_pkg" + ] + for pkg_name in pkg_names: + pkg_path = hatch_dev_path / pkg_name + if pkg_path.exists(): + metadata_path = pkg_path / "hatch_metadata.json" + if metadata_path.exists(): + with open(metadata_path, 'r') as f: + import json + metadata = json.load(f) + pkg_entry = { + "name": metadata.get("name", pkg_name), + "description": metadata.get("description", ""), + "category": "development", + "tags": metadata.get("tags", []), + "latest_version": metadata.get("version", "1.0.0"), + "versions": [ + { + "version": metadata.get("version", "1.0.0"), + "release_uri": f"file://{pkg_path}", + "author": { + "GitHubID": metadata.get("author", {}).get("name", "test_user"), + "email": metadata.get("author", {}).get("email", "test@example.com") + }, + "added_date": datetime.now().isoformat(), + "hatch_dependencies_added": [ + { + "name": dep["name"], + "version_constraint": dep.get("version_constraint", "") + } + for dep in metadata.get("hatch_dependencies", []) + ], + "python_dependencies_added": [ + { + "name": dep["name"], + "version_constraint": dep.get("version_constraint", ""), + "package_manager": dep.get("package_manager", "pip") + } + for dep in metadata.get("python_dependencies", []) + ], + } + ] + } + registry["repositories"][0]["packages"].append(pkg_entry) + return registry + + def setUp(self): + # Create a temporary directory for installs + self.temp_dir = tempfile.mkdtemp() + self.target_dir = Path(self.temp_dir) / "target" + self.target_dir.mkdir(parents=True, exist_ok=True) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_installer_can_install_and_uninstall(self): + """Test the full install and uninstall cycle for a dummy Hatch package using the installer.""" + pkg_name = "arithmetic_pkg" + pkg_path = self.hatch_dev_path / pkg_name + metadata_path = pkg_path / "hatch_metadata.json" + with open(metadata_path, 'r') as f: + import json + metadata = json.load(f) + dependency = { + "name": pkg_name, + "version_constraint": metadata.get("version", "1.0.0"), + "resolved_version": metadata.get("version", "1.0.0"), + "type": "hatch", + "uri": f"file://{pkg_path}" + } + # Prepare a minimal InstallationContext + class DummyContext: + environment_path = str(self.target_dir) + context = DummyContext() + # Install + result = self.installer.install(dependency, context) + self.assertEqual(result.status, "COMPLETED") + installed_path = Path(result.installed_path) + self.assertTrue(installed_path.exists()) + # Uninstall + uninstall_result = self.installer.uninstall(dependency, context) + self.assertEqual(uninstall_result.status, "COMPLETED") + self.assertFalse(installed_path.exists()) + + def test_installer_rejects_invalid_dependency(self): + """Test that the installer rejects dependencies missing required fields.""" + invalid_dep = {"name": "foo"} # Missing required fields + self.assertFalse(self.installer.validate_dependency(invalid_dep)) + + def test_installation_error_on_missing_uri(self): + """Test that the installer raises InstallationError if no URI is provided.""" + pkg_name = "arithmetic_pkg" + dependency = { + "name": pkg_name, + "version_constraint": "1.0.0", + "resolved_version": "1.0.0", + "type": "hatch" + } + class DummyContext: + environment_path = str(self.target_dir) + context = DummyContext() + with self.assertRaises(Exception): + self.installer.install(dependency, context) + + def test_can_install_method(self): + """Test the can_install method for correct dependency type recognition.""" + dep = {"type": "hatch"} + self.assertTrue(self.installer.can_install(dep)) + dep2 = {"type": "python"} + self.assertFalse(self.installer.can_install(dep2)) + +if __name__ == "__main__": + unittest.main() From cec1023e8211236dad9aa361680f48a4c1983db7 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Tue, 24 Jun 2025 17:01:34 +0900 Subject: [PATCH 06/48] [Add] Python Installer **Major**: - The concrete implementation to install python depencies of a hatch package relying on "pip" - Added tests to check installation assuming the validation checks were performed before hand (i.e. truly instal-ready) - Tests are covering mocking of `_run_pip_subprocess` to make sure the system to capture some error types is working - Then integration tests are building fake python dependencies and having them being installed in a `venv` created in teh `setup` function --- hatch/installers/python_installer.py | 360 +++++++++++++++++++++++++++ tests/run_environment_tests.py | 16 ++ tests/test_python_installer.py | 301 ++++++++++++++++++++++ 3 files changed, 677 insertions(+) create mode 100644 hatch/installers/python_installer.py create mode 100644 tests/test_python_installer.py diff --git a/hatch/installers/python_installer.py b/hatch/installers/python_installer.py new file mode 100644 index 0000000..6c49d03 --- /dev/null +++ b/hatch/installers/python_installer.py @@ -0,0 +1,360 @@ +"""Installer for Python package dependencies using pip. + +This module implements installation logic for Python packages using pip via subprocess, +with support for configurable Python environments and comprehensive error handling. +""" + +import sys +import subprocess +import logging +import os +import re +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List +import os +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List + +from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError +from .installation_context import InstallationStatus + + +class PythonInstaller(DependencyInstaller): + """Installer for Python package dependencies using pip. + + Handles installation of Python packages using pip via subprocess, with support + for configurable Python environments through InstallationContext.extra_config. + """ + + def __init__(self): + """Initialize the PythonInstaller.""" + self.logger = logging.getLogger("hatch.installers.python_installer") + self.logger.setLevel(logging.INFO) + + @property + def installer_type(self) -> str: + """Get the type identifier for this installer. + + Returns: + str: Unique identifier for the installer type ("python"). + """ + return "python" + + @property + def supported_schemes(self) -> List[str]: + """Get the URI schemes this installer can handle. + + This installer supports: + - "pypi" for PyPI packages + - "git+https" for Git repositories over HTTPS + - "git+ssh" for Git repositories over SSH + - "file" for local file paths + + Returns: + List[str]: List of URI schemes (["pypi", "git+https", "git+ssh", "file"]). + """ + return ["pypi", "git+https", "git+ssh", "file"] + + def can_install(self, dependency: Dict[str, Any]) -> bool: + """Check if this installer can handle the given dependency. + + Args: + dependency (Dict[str, Any]): Dependency object. + + Returns: + bool: True if this installer can handle the dependency, False otherwise. + """ + return dependency.get("type") == self.installer_type + + def validate_dependency(self, dependency: Dict[str, Any]) -> bool: + """Validate that a dependency object has required fields for Python packages. + + Args: + dependency (Dict[str, Any]): Dependency object to validate. + + Returns: + bool: True if dependency is valid, False otherwise. + """ + required_fields = ["name", "version_constraint"] + if not all(field in dependency for field in required_fields): + return False + + # Check for valid package manager if specified + package_manager = dependency.get("package_manager", "pip") + if package_manager not in ["pip"]: + return False + + return True + + def _run_pip_subprocess(self, cmd: List[str]) -> tuple[int, str, str]: + """Run a pip subprocess and capture stdout and stderr. + + Args: + cmd (List[str]): The pip command to execute as a list. + + Returns: + Tuple[int, str, str]: (returncode, stdout, stderr) + + Raises: + subprocess.TimeoutExpired: If the process times out. + Exception: For unexpected errors. + """ + + env = os.environ.copy() + env['PYTHONUNBUFFERED'] = '1' + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + universal_newlines=True, + bufsize=1 # Line-buffered output + ) + + _stdout, _stderr = "", "" + for line in process.stdout: + if line: + self.logger.info(f"pip stdout: {line.strip()}") + _stdout += line + + for line in process.stderr: + if line: + self.logger.info(f"pip stderr: {line.strip()}") + _stderr += line + + process.wait() # Ensure cleanup + return process.returncode, _stdout, _stderr + + except subprocess.TimeoutExpired: + process.kill() + process.wait() # Ensure cleanup + raise InstallationError("Pip subprocess timed out", error_code="TIMEOUT", cause=None) + + except Exception as e: + raise InstallationError( + f"Unexpected error running pip command: {e}", + error_code="PIP_SUBPROCESS_ERROR", + cause=e + ) + + def install(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Install a Python package dependency using pip. + + This method uses subprocess to call pip with the appropriate Python executable, + which can be configured via context.extra_config["python_executable"]. + + Args: + dependency (Dict[str, Any]): Dependency object containing name, version, etc. + context (InstallationContext): Installation context with environment info. + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the installation operation. + + Raises: + InstallationError: If installation fails for any reason. + """ + name = dependency["name"] + version_constraint = dependency["version_constraint"] + + if progress_callback: + progress_callback("validate", 0.0, f"Validating Python package {name}") + + # Get Python executable from context or use system default + python_exec = context.get_config("python_executable", sys.executable) + + # Build package specification with version constraint + # Let pip resolve the actual version based on the constraint + if version_constraint and version_constraint != "*": + package_spec = f"{name}{version_constraint}" + else: + package_spec = name + + # Handle extras if specified + extras = dependency.get("extras") + if extras: + if isinstance(extras, list): + extras_str = ",".join(extras) + else: + extras_str = str(extras) + if version_constraint and version_constraint != "*": + package_spec = f"{name}[{extras_str}]{version_constraint}" + else: + package_spec = f"{name}[{extras_str}]" + + # Build pip command + cmd = [str(python_exec), "-m", "pip", "install", package_spec] + + # Add additional pip options + cmd.extend(["--no-cache-dir"]) # Avoid cache issues in different environments + + if context.simulation_mode: + # In simulation mode, just return success without actually installing + self.logger.info(f"Simulation mode: would install {package_spec}") + return InstallationResult( + dependency_name=name, + status=InstallationStatus.COMPLETED, + installed_version=version_constraint, + metadata={"simulation": True, "command": cmd} + ) + + try: + if progress_callback: + progress_callback("install", 0.3, f"Installing {package_spec}") + + returncode, stdout, stderr = self._run_pip_subprocess(cmd) + self.logger.debug(f"pip command: {' '.join(cmd)}\nreturncode: {returncode}\nstdout: {stdout}\nstderr: {stderr}") + + if returncode == 0: + + if progress_callback: + progress_callback("install", 1.0, f"Successfully installed {name}") + + return InstallationResult( + dependency_name=name, + status=InstallationStatus.COMPLETED, + error_message=stderr, + metadata={ + "pip_output": stdout, + "command": cmd, + "version_constraint": version_constraint + } + ) + + else: + error_msg = f"Failed to install {name}: {stderr}" + self.logger.error(error_msg) + raise InstallationError( + error_msg, + dependency_name=name, + error_code="PIP_FAILED", + cause=None + ) + except subprocess.TimeoutExpired: + error_msg = f"Installation of {name} timed out after 5 minutes" + self.logger.error(error_msg) + raise InstallationError(error_msg, dependency_name=name, error_code="TIMEOUT") + + except Exception as e: + error_msg = f"Unexpected error installing {name}: {e}" + self.logger.error(error_msg) + raise InstallationError(error_msg, dependency_name=name, cause=e) + + def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Uninstall a Python package dependency using pip. + + Args: + dependency (Dict[str, Any]): Dependency object to uninstall. + context (InstallationContext): Installation context with environment info. + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the uninstall operation. + + Raises: + InstallationError: If uninstall fails for any reason. + """ + name = dependency["name"] + + if progress_callback: + progress_callback("uninstall", 0.0, f"Uninstalling Python package {name}") + + # Get Python executable from context + python_exec = context.get_config("python_executable", sys.executable) + + # Build pip uninstall command + cmd = [str(python_exec), "-m", "pip", "uninstall", "-y", name] + + if context.simulation_mode: + self.logger.info(f"Simulation mode: would uninstall {name}") + return InstallationResult( + dependency_name=name, + status=InstallationStatus.COMPLETED, + metadata={"simulation": True, "command": cmd} + ) + + try: + if progress_callback: + progress_callback("uninstall", 0.5, f"Removing {name}") + + returncode, stdout, stderr = self._run_pip_subprocess(cmd) + + if returncode == 0: + + if progress_callback: + progress_callback("uninstall", 1.0, f"Successfully uninstalled {name}") + self.logger.info(f"Successfully uninstalled Python package {name}") + + return InstallationResult( + dependency_name=name, + status=InstallationStatus.COMPLETED, + error_message=stderr, + metadata={ + "pip_output": stdout, + "command": cmd + } + ) + else: + + # pip uninstall can fail if package is not installed, which might be OK + if "not installed" in stderr.lower(): + self.logger.warning(f"Package {name} was not installed") + return InstallationResult( + dependency_name=name, + status=InstallationStatus.COMPLETED, + error_message=stderr, + metadata={"warning": "Package was not installed"} + ) + + error_msg = f"Failed to uninstall {name}: {stderr}" + self.logger.error(error_msg) + + raise InstallationError( + error_msg, + dependency_name=name, + error_code="PIP_UNINSTALL_FAILED", + cause=None + ) + except subprocess.TimeoutExpired: + error_msg = f"Uninstallation of {name} timed out after 1 minute" + self.logger.error(error_msg) + raise InstallationError(error_msg, dependency_name=name, error_code="TIMEOUT") + except Exception as e: + error_msg = f"Unexpected error uninstalling {name}: {e}" + self.logger.error(error_msg) + raise InstallationError(error_msg, dependency_name=name, cause=e) + + def get_installation_info(self, dependency: Dict[str, Any], context: InstallationContext) -> Dict[str, Any]: + """Get information about what would be installed without actually installing. + + Args: + dependency (Dict[str, Any]): Dependency object to analyze. + context (InstallationContext): Installation context. + + Returns: + Dict[str, Any]: Information about the planned installation. + """ + python_exec = context.get_config("python_executable", sys.executable) + version_constraint = dependency.get("version_constraint", "*") + + # Build package spec for display + if version_constraint and version_constraint != "*": + package_spec = f"{dependency['name']}{version_constraint}" + else: + package_spec = dependency['name'] + + info = super().get_installation_info(dependency, context) + info.update({ + "python_executable": str(python_exec), + "package_manager": dependency.get("package_manager", "pip"), + "package_spec": package_spec, + "version_constraint": version_constraint, + "extras": dependency.get("extras") + }) + + return info diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index a1779ad..260bd3a 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -47,6 +47,22 @@ # Run only HatchInstaller tests logger.info("Running HatchInstaller tests only...") test_suite = test_loader.loadTestsFromName("test_hatch_installer.TestHatchInstaller") + elif len(sys.argv) > 1 and sys.argv[1] == "--python-installer-only": + # Run only PythonInstaller tests + logger.info("Running PythonInstaller tests only...") + test_mocking = test_loader.loadTestsFromName("test_python_installer.TestPythonInstaller") + test_integration = test_loader.loadTestsFromName("test_python_installer.TestPythonInstallerIntegration") + test_suite = unittest.TestSuite([test_mocking, test_integration]) + elif len(sys.argv) > 1 and sys.argv[1] == "--all-installers": + # Run all installer tests + logger.info("Running all installer tests...") + hatch_tests = test_loader.loadTestsFromName("test_hatch_installer.TestHatchInstaller") + python_tests_mocking = test_loader.loadTestsFromName("test_python_installer.TestPythonInstaller") + python_tests_integration = test_loader.loadTestsFromName("test_python_installer.TestPythonInstallerIntegration") + + # Add future installer tests here as needed + + test_suite = unittest.TestSuite([hatch_tests, python_tests_mocking, python_tests_integration]) else: # Run all tests logger.info("Running all package environment tests...") diff --git a/tests/test_python_installer.py b/tests/test_python_installer.py new file mode 100644 index 0000000..fe76e8d --- /dev/null +++ b/tests/test_python_installer.py @@ -0,0 +1,301 @@ +import subprocess +import unittest +import tempfile +import shutil +import sys +from pathlib import Path +from unittest import mock + +from hatch.installers.python_installer import PythonInstaller +from hatch.installers.installation_context import InstallationContext, InstallationStatus +from hatch.installers.installer_base import InstallationError + +class DummyContext(InstallationContext): + def __init__(self, env_path=None, env_name=None, simulation_mode=False, extra_config=None): + self.simulation_mode = simulation_mode + self.extra_config = extra_config or {} + self.environment_path = env_path + self.environment_name = env_name + + def get_config(self, key, default=None): + return self.extra_config.get(key, default) + +class TestPythonInstaller(unittest.TestCase): + """Tests for the PythonInstaller class covering validation, installation, and error handling.""" + + def setUp(self): + """Set up a temporary directory and PythonInstaller instance for each test.""" + + self.temp_dir = tempfile.mkdtemp() + self.env_path = Path(self.temp_dir) / "test_env" + + # make the directory + self.env_path.mkdir(parents=True, exist_ok=True) + + # assert the virtual environment was created successfully + self.assertTrue(self.env_path.exists() and self.env_path.is_dir()) + + self.installer = PythonInstaller() + self.dummy_context = DummyContext(self.env_path, env_name="test_env", extra_config={ + "target_dir": str(self.env_path) + }) + + def tearDown(self): + """Clean up the temporary directory after each test.""" + shutil.rmtree(self.temp_dir) + + def test_validate_dependency_valid(self): + """Test validate_dependency returns True for valid dependency dict.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0"} + self.assertTrue(self.installer.validate_dependency(dep)) + + def test_validate_dependency_invalid_missing_fields(self): + """Test validate_dependency returns False if required fields are missing.""" + dep = {"name": "requests"} + self.assertFalse(self.installer.validate_dependency(dep)) + + def test_validate_dependency_invalid_package_manager(self): + """Test validate_dependency returns False for unsupported package manager.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0", "package_manager": "unknown"} + self.assertFalse(self.installer.validate_dependency(dep)) + + def test_can_install_python_type(self): + """Test can_install returns True for type 'python'.""" + dep = {"type": self.installer.installer_type} + self.assertTrue(self.installer.can_install(dep)) + + def test_can_install_wrong_type(self): + """Test can_install returns False for non-python type.""" + dep = {"type": "hatch"} + self.assertFalse(self.installer.can_install(dep)) + + @mock.patch("hatch.installers.python_installer.subprocess.Popen", side_effect=Exception("fail")) + def test_run_pip_subprocess_exception(self, mock_popen): + """Test _run_pip_subprocess raises InstallationError on exception.""" + cmd = [sys.executable, "-m", "pip", "--version"] + with self.assertRaises(InstallationError): + self.installer._run_pip_subprocess(cmd) + + def test_install_simulation_mode(self): + """Test install returns COMPLETED immediately in simulation mode.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0"} + context = DummyContext(simulation_mode=True) + result = self.installer.install(dep, context) + self.assertEqual(result.status, InstallationStatus.COMPLETED) + + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(0, "", "")) + def test_install_success(self, mock_run): + """Test install returns COMPLETED on successful pip install.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0"} + context = DummyContext() + result = self.installer.install(dep, context) + self.assertEqual(result.status, InstallationStatus.COMPLETED) + + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(1, "", "error")) + def test_install_failure(self, mock_run): + """Test install raises InstallationError on pip failure.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0"} # The content don't matter here given the mock + context = DummyContext() + with self.assertRaises(InstallationError): + self.installer.install(dep, context) + + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(0, "", "")) + def test_uninstall_success(self, mock_run): + """Test uninstall returns COMPLETED on successful pip uninstall.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0"} + context = DummyContext() + result = self.installer.uninstall(dep, context) + self.assertEqual(result.status, InstallationStatus.COMPLETED) + + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(1, "", "error")) + def test_uninstall_failure(self, mock_run): + """Test uninstall raises InstallationError on pip uninstall failure.""" + dep = {"name": "requests", "version_constraint": ">=2.0.0"} + context = DummyContext() + with self.assertRaises(InstallationError): + self.installer.uninstall(dep, context) + +class TestPythonInstallerIntegration(unittest.TestCase): + + """Integration tests for PythonInstaller that perform actual package installations.""" + + def setUp(self): + """Set up a temporary directory and PythonInstaller instance for each test.""" + + self.temp_dir = tempfile.mkdtemp() + self.env_path = Path(self.temp_dir) / "test_env" + + # Use pip to create a virtual environment + subprocess.check_call([sys.executable, "-m", "venv", str(self.env_path)]) + + # assert the virtual environment was created successfully + self.assertTrue(self.env_path.exists() and self.env_path.is_dir()) + + # Get the Python executable in the virtual environment + if sys.platform == "win32": + self.python_executable = self.env_path / "Scripts" / "python.exe" + else: + self.python_executable = self.env_path / "bin" / "python" + + self.installer = PythonInstaller() + self.dummy_context = DummyContext(self.env_path, env_name="test_env", extra_config={ + "python_executable": self.python_executable, + "target_dir": str(self.env_path) + }) + + def tearDown(self): + """Clean up the temporary directory after each test.""" + shutil.rmtree(self.temp_dir) + + def test_install_actual_package_success(self): + """Test actual installation of a real Python package without mocking. + + Uses a lightweight package that's commonly available and installs quickly. + This validates the entire installation pipeline including subprocess handling. + """ + # Use a lightweight, commonly available package for testing + dep = { + "name": "wheel", + "version_constraint": "*", + "type": "python" + } + + # Create a virtual environment context to avoid polluting system packages + context = DummyContext( + env_path=self.env_path, + env_name="test_env", + extra_config={ + "python_executable": self.python_executable, + "target_dir": str(self.env_path) + } + ) + result = self.installer.install(dep, context) + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertIn("wheel", result.dependency_name) + + def test_install_package_with_version_constraint(self): + """Test installation with specific version constraint. + + Validates that version constraints are properly passed to pip + and that the installation succeeds with real package resolution. + """ + dep = { + "name": "setuptools", + "version_constraint": ">=40.0.0", + "type": "python" + } + + context = DummyContext( + env_path=self.env_path, + env_name="test_env", + extra_config={ + "python_executable": self.python_executable + }) + + result = self.installer.install(dep, context) + self.assertEqual(result.status, InstallationStatus.COMPLETED) + # Verify the dependency was processed correctly + self.assertIsNotNone(result.metadata) + + def test_install_package_with_extras(self): + """Test installation of a package with extras specification. + + Tests the extras handling functionality with a real package installation. + """ + dep = { + "name": "requests", + "version_constraint": "*", + "type": "python", + "extras": ["security"] # pip[security] if available + } + + context = DummyContext( + env_path=self.env_path, + env_name="test_env", + extra_config={ + "python_executable": self.python_executable + }) + + result = self.installer.install(dep, context) + self.assertEqual(result.status, InstallationStatus.COMPLETED) + + def test_uninstall_actual_package(self): + """Test actual uninstallation of a Python package. + + First installs a package, then uninstalls it to test the complete cycle. + This validates both installation and uninstallation without mocking. + """ + dep = { + "name": "wheel", + "version_constraint": "*", + "type": "python" + } + + context = DummyContext( + env_path=self.env_path, + env_name="test_env", + extra_config={ + "python_executable": self.python_executable + }) + + # First install the package + install_result = self.installer.install(dep, context) + self.assertEqual(install_result.status, InstallationStatus.COMPLETED) + + # Then uninstall it + uninstall_result = self.installer.uninstall(dep, context) + self.assertEqual(uninstall_result.status, InstallationStatus.COMPLETED) + + def test_install_nonexistent_package_failure(self): + """Test that installation fails appropriately for non-existent packages. + + This validates error handling when pip encounters a package that doesn't exist, + without using mocks to simulate the failure. + """ + dep = { + "name": "this-package-definitely-does-not-exist-12345", + "version_constraint": "*", + "type": "python" + } + + context = DummyContext( + env_path=self.env_path, + env_name="test_env", + extra_config={ + "python_executable": self.python_executable + }) + + with self.assertRaises(InstallationError) as cm: + self.installer.install(dep, context) + + # Verify the error contains useful information + error_msg = str(cm.exception) + self.assertIn("this-package-definitely-does-not-exist-12345", error_msg) + + def test_get_installation_info_for_installed_package(self): + """Test retrieval of installation info for an actually installed package. + + This tests the get_installation_info method with a real package + that should be available in most Python environments. + """ + dep = { + "name": "pip", # pip should be available in most environments + "version_constraint": "*", + "type": "python" + } + + context = DummyContext( + env_path=self.env_path, + env_name="test_env", + extra_config={ + "python_executable": self.python_executable + }) + + info = self.installer.get_installation_info(dep, context) + self.assertIsInstance(info, dict) + # Basic checks for expected info structure + if info: # Only check if info was returned (some implementations might return empty dict) + self.assertIn("dependency_name", info) + +if __name__ == "__main__": + unittest.main() From 581379f26615eff53355a3c966af531787d41788 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 25 Jun 2025 00:31:12 +0900 Subject: [PATCH 07/48] [Add] System Installer **Major**: - The concrete implementation to install system depencies of a hatch package relying on "apt" - I don't really know how long this one will survive given the limitations of "apt" only running with sudo. If we support them, it's open doors to having MCP servers running only in specific environments. - Added tests to check installation assuming the validation checks were performed before hand (i.e. truly instal-ready) - Tests are mainly using Mockup given the constrainsts of needing `sudo`(as mentioned above). - That being said, the current tests do trigger once a sudo password prompt to the user in basic environment with the NOPASSWORD is not setup. - I think it will be a pain to integrate within the docker container...let's see! --- hatch/installers/system_installer.py | 614 +++++++++++++++++++++++++++ tests/run_environment_tests.py | 10 +- tests/test_system_installer.py | 583 +++++++++++++++++++++++++ 3 files changed, 1206 insertions(+), 1 deletion(-) create mode 100644 hatch/installers/system_installer.py create mode 100644 tests/test_system_installer.py diff --git a/hatch/installers/system_installer.py b/hatch/installers/system_installer.py new file mode 100644 index 0000000..225c46b --- /dev/null +++ b/hatch/installers/system_installer.py @@ -0,0 +1,614 @@ +"""Installer for system package dependencies using apt. + +This module implements installation logic for system packages using apt via subprocess, +with support for Ubuntu/Debian platforms, version constraints, and comprehensive error handling. +""" + +import platform +import subprocess +import logging +import re +import shutil +import os +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List +from packaging.specifiers import SpecifierSet + +from .installer_base import DependencyInstaller, InstallationError +from .installation_context import InstallationContext, InstallationResult, InstallationStatus + + +class SystemInstaller(DependencyInstaller): + """Installer for system package dependencies using apt. + + Handles installation of system packages using apt package manager via subprocess. + Supports Ubuntu/Debian platforms with platform detection and version constraint handling. + User consent is managed at the orchestrator level - this installer assumes permission + has been granted. + """ + + def __init__(self): + """Initialize the SystemInstaller.""" + self.logger = logging.getLogger("hatch.installers.system_installer") + self.logger.setLevel(logging.DEBUG) + + @property + def installer_type(self) -> str: + """Get the type identifier for this installer. + + Returns: + str: Unique identifier for the installer type ("system"). + """ + return "system" + + @property + def supported_schemes(self) -> List[str]: + """Get the URI schemes this installer can handle. + + Returns: + List[str]: List of URI schemes (["apt"] for apt package manager). + """ + return ["apt"] + + def can_install(self, dependency: Dict[str, Any]) -> bool: + """Check if this installer can handle the given dependency. + + Args: + dependency (Dict[str, Any]): Dependency object. + + Returns: + bool: True if this installer can handle the dependency, False otherwise. + """ + if dependency.get("type") != self.installer_type: + return False + + # Check platform compatibility + if not self._is_platform_supported(): + return False + + # Check if apt is available + return self._is_apt_available() + + def validate_dependency(self, dependency: Dict[str, Any]) -> bool: + """Validate that a dependency object has required fields for system packages. + + Args: + dependency (Dict[str, Any]): Dependency object to validate. + + Returns: + bool: True if dependency is valid, False otherwise. + """ + # Required fields per schema + required_fields = ["name", "version_constraint"] + if not all(field in dependency for field in required_fields): + self.logger.error(f"Missing required fields. Expected: {required_fields}, got: {list(dependency.keys())}") + return False + + # Validate package manager + package_manager = dependency.get("package_manager", "apt") + if package_manager != "apt": + self.logger.error(f"Unsupported package manager: {package_manager}. Only 'apt' is supported.") + return False + + # Validate version constraint format + version_constraint = dependency.get("version_constraint", "") + if not self._validate_version_constraint(version_constraint): + self.logger.error(f"Invalid version constraint format: {version_constraint}") + return False + + return True + + def install(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Install a system dependency using apt. + + Args: + dependency (Dict[str, Any]): Dependency object containing: + - name (str): Name of the system package + - version_constraint (str): Version constraint + - package_manager (str): Must be "apt" + context (InstallationContext): Installation context with environment info + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the installation operation. + + Raises: + InstallationError: If installation fails for any reason. + """ + if not self.validate_dependency(dependency): + raise InstallationError( + f"Invalid dependency: {dependency}", + dependency_name=dependency.get("name"), + error_code="INVALID_DEPENDENCY" + ) + + package_name = dependency["name"] + version_constraint = dependency["version_constraint"] + + if progress_callback: + progress_callback(f"Installing {package_name}", 0.0, "Starting installation") + + self.logger.info(f"Installing system package: {package_name} with constraint: {version_constraint}") + + try: + # Handle dry-run/simulation mode + if context.simulation_mode: + return self._simulate_installation(dependency, context, progress_callback) + + # Build and execute apt command + cmd = self._build_apt_command(dependency, context) + + if progress_callback: + progress_callback(f"Installing {package_name}", 25.0, "Executing apt command") + + returncode, stdout, stderr = self._run_apt_subprocess(cmd) + self.logger.debug(f"apt command: {cmd}\nreturn code: {returncode}\nstdout: {stdout}\nstderr: {stderr}") + + if returncode != 0: + raise InstallationError( + f"Installation failed with error: {stderr.strip()}", + dependency_name=package_name, + error_code="APT_INSTALL_FAILED", + cause=None + ) + + if progress_callback: + progress_callback(f"Installing {package_name}", 75.0, "Verifying installation") + + # Verify installation + installed_version = self._verify_installation(package_name) + + if progress_callback: + progress_callback(f"Installing {package_name}", 100.0, "Installation complete") + + return InstallationResult( + dependency_name=package_name, + status=InstallationStatus.COMPLETED, + installed_version=installed_version, + metadata={ + "package_manager": "apt", + "command_executed": " ".join(cmd), + "platform": platform.platform(), + "automated": context.get_config("automated", False), + "output": stdout.strip(), + } + ) + + except InstallationError as e: + self.logger.error(f"Installation error for {package_name}: {str(e)}") + raise e + + except Exception as e: + self.logger.error(f"Unexpected error installing {package_name}: {str(e)}") + raise InstallationError( + f"Unexpected error installing {package_name}: {str(e)}", + dependency_name=package_name, + error_code="UNEXPECTED_ERROR", + cause=e + ) + + def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Uninstall a system dependency using apt. + + Args: + dependency (Dict[str, Any]): Dependency object to uninstall. + context (InstallationContext): Installation context. + progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback. + + Returns: + InstallationResult: Result of the uninstall operation. + + Raises: + InstallationError: If uninstall fails for any reason. + """ + package_name = dependency["name"] + + if progress_callback: + progress_callback(f"Uninstalling {package_name}", 0.0, "Starting uninstall") + + self.logger.info(f"Uninstalling system package: {package_name}") + + try: + # Handle dry-run/simulation mode + if context.simulation_mode: + return self._simulate_uninstall(dependency, context, progress_callback) + + # Build apt remove command + cmd = ["apt", "remove", package_name] + + # Add automation flag if configured + if context.get_config("automated", False): + cmd.append("-y") + + if progress_callback: + progress_callback(f"Uninstalling {package_name}", 50.0, "Executing apt remove") + + # Execute command + returncode, stdout, stderr = self._run_apt_subprocess(cmd) + + if returncode != 0: + raise InstallationError( + f"Uninstallation failed with error: {stderr.strip()}", + dependency_name=package_name, + error_code="APT_UNINSTALL_FAILED", + cause=None + ) + + if progress_callback: + progress_callback(f"Uninstalling {package_name}", 100.0, "Uninstall complete") + + return InstallationResult( + dependency_name=package_name, + status=InstallationStatus.COMPLETED, + metadata={ + "operation": "uninstall", + "package_manager": "apt", + "command_executed": " ".join(cmd), + "automated": context.get_config("automated", False), + } + ) + except InstallationError as e: + self.logger.error(f"Uninstallation error for {package_name}: {str(e)}") + raise e + + except Exception as e: + self.logger.error(f"Unexpected error uninstalling {package_name}: {str(e)}") + raise InstallationError( + f"Unexpected error uninstalling {package_name}: {str(e)}", + dependency_name=package_name, + error_code="UNEXPECTED_ERROR", + cause=e + ) + + def _is_platform_supported(self) -> bool: + """Check if the current platform supports apt package manager. + + Returns: + bool: True if platform is Ubuntu/Debian-based, False otherwise. + """ + try: + # Check if we're on a Debian-based system + if Path("/etc/debian_version").exists(): + return True + + # Check platform string + system = platform.system().lower() + if system == "linux": + # Additional check for Ubuntu + try: + with open("/etc/os-release", "r") as f: + content = f.read().lower() + return "ubuntu" in content or "debian" in content + + except FileNotFoundError: + pass + + return False + + except Exception: + return False + + def _is_apt_available(self) -> bool: + """Check if apt command is available on the system. + + Returns: + bool: True if apt is available, False otherwise. + """ + return shutil.which("apt") is not None + + def _validate_version_constraint(self, version_constraint: str) -> bool: + """Validate version constraint format. + + Args: + version_constraint (str): Version constraint to validate. + + Returns: + bool: True if format is valid, False otherwise. + """ + try: + if not version_constraint.strip(): + return True + + SpecifierSet(version_constraint) + + return True + + except Exception: + self.logger.error(f"Invalid version constraint format: {version_constraint}") + return False + + def _build_apt_command(self, dependency: Dict[str, Any], context: InstallationContext, sudo: bool = True) -> List[str]: + """Build the apt install command for the dependency. + + Args: + dependency (Dict[str, Any]): Dependency object. + context (InstallationContext): Installation context. + sudo (bool): Whether to use sudo for the command. + + Returns: + List[str]: Apt command as list of arguments. + """ + package_name = dependency["name"] + version_constraint = dependency["version_constraint"] + + # Start with base command + if sudo: + command = ["sudo", "apt", "install"] + else: + command = ["apt", "install"] + + # Add automation flag if configured + if context.get_config("automated", False): + command.append("-y") + + # Handle version constraints + # apt doesn't support complex version constraints directly, + # but we can specify exact versions for == constraints + if version_constraint.startswith("=="): + # Extract version from constraint like "== 1.2.3" + version = version_constraint.replace("==", "").strip() + package_spec = f"{package_name}={version}" + else: + # For other constraints (>=, <=, !=), install latest and let apt handle it + package_spec = package_name + self.logger.warning(f"Version constraint {version_constraint} simplified to latest version for {package_name}") + + command.append(package_spec) + return command + + def _run_apt_subprocess(self, cmd: List[str]) -> tuple[int, str, str]: + """Run an apt subprocess and capture stdout and stderr. + + Args: + cmd (List[str]): The apt command to execute as a list. + + Returns: + Tuple[int, str, str]: (returncode, stdout, stderr) + + Raises: + subprocess.TimeoutExpired: If the process times out. + Exception: For unexpected errors. + """ + env = os.environ.copy() + try: + if cmd[0] == "sudo": + # Ensure sudo is available in the environment + if shutil.which("sudo") is None: + raise InstallationError("sudo command not found", error_code="SUDO_NOT_FOUND", cause=None) + + process = subprocess.Popen( + cmd, + #stdout=subprocess.PIPE, + #stderr=subprocess.PIPE, + text=True, + #env=env, + universal_newlines=True + #bufsize=1 # Line-buffered output + ) + # _stdout, _stderr = "", "" + + # for line in process.stdout: + # if line: + # self.logger.info(f"apt stdout: {line.strip()}") + # _stdout += line + + # for line in process.stderr: + # if line: + # self.logger.info(f"apt stderr: {line.strip()}") + # _stderr += line + + # process.wait() # Ensure cleanup + + process.communicate() # Set a timeout for the command + process.wait() # Ensure cleanup + return process.returncode, "", "" + else: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + universal_newlines=True, + bufsize=1 # Line-buffered output + ) + _stdout, _stderr = "", "" + + for line in process.stdout: + if line: + self.logger.info(f"apt stdout: {line.strip()}") + _stdout += line + + for line in process.stderr: + if line: + self.logger.info(f"apt stderr: {line.strip()}") + _stderr += line + + process.wait() # Ensure cleanup + return process.returncode, _stdout, _stderr + + except subprocess.TimeoutExpired: + process.kill() + process.wait() # Ensure cleanup + raise InstallationError("Apt subprocess timed out", error_code="TIMEOUT", cause=None) + + except Exception as e: + raise InstallationError( + f"Unexpected error running apt command: {e}", + error_code="APT_SUBPROCESS_ERROR", + cause=e + ) + + def _verify_installation(self, package_name: str) -> Optional[str]: + """Verify that a package was installed and get its version. + + Args: + package_name (str): Name of package to verify. + + Returns: + Optional[str]: Installed version if found, None otherwise. + """ + try: + cmd = ["apt-cache", "policy", package_name] + returncode, stdout, stderr = self._run_apt_subprocess(cmd) + if returncode == 0: + for line in stdout.splitlines(): + if "***" in line: + version = line.split()[1] + if version and version != "(none)": + return version + return None + except Exception: + return None + + def _parse_apt_error(self, error: InstallationError) -> str: + """Parse apt error output to provide actionable error messages. + + Args: + error (InstallationError): The installation error. + + Returns: + str: Human-readable error message with suggestions. + """ + error_output = error.message + + # Common apt error patterns and suggestions + if "permission denied" in error_output.lower(): + return "Permission denied. Try running with sudo or check user permissions." + elif "could not get lock" in error_output.lower(): + return "Another package manager is running. Wait for it to finish and try again." + elif "unable to locate package" in error_output.lower(): + return "Package not found. Check package name and update package lists with 'apt update'." + elif "network" in error_output.lower() or "connection" in error_output.lower(): + return "Network connectivity issue. Check internet connection and repository availability." + elif "space" in error_output.lower(): + return "Insufficient disk space. Free up space and try again." + else: + return f"Apt command failed: {error_output}" + + def _simulate_installation(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Simulate installation without making actual changes. + + Args: + dependency (Dict[str, Any]): Dependency object. + context (InstallationContext): Installation context. + progress_callback (Callable[[str, float, str], None], optional): Progress callback. + + Returns: + InstallationResult: Simulated result. + """ + package_name = dependency["name"] + + if progress_callback: + progress_callback(f"Simulating {package_name}", 0.5, "Running dry-run") + + try: + # Use apt's dry-run functionality + cmd = self._build_apt_command(dependency, context) + cmd.append("--simulate") + returncode, stdout, stderr = self._run_apt_subprocess(cmd) + + if returncode != 0: + raise InstallationError( + f"Simulation failed with error: {stderr}", + dependency_name=package_name, + error_code="APT_SIMULATION_FAILED", + cause=None + ) + + if progress_callback: + progress_callback(f"Simulating {package_name}", 1.0, "Simulation complete") + + return InstallationResult( + dependency_name=package_name, + status=InstallationStatus.COMPLETED, + metadata={ + "simulation": True, + "dry_run_output": stdout, + "command_simulated": " ".join(cmd), + "automated": context.get_config("automated", False) + } + ) + + except InstallationError as e: + self.logger.error(f"Error during installation simulation for {package_name}: {e.message}") + raise e + + except Exception as e: + return InstallationResult( + dependency_name=package_name, + status=InstallationStatus.FAILED, + error_message=f"Simulation failed: {e}", + metadata={ + "simulation": True, + "simulation_error": e, + "command_simulated": " ".join(cmd), + "automated": context.get_config("automated", False) + } + ) + + def _simulate_uninstall(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Simulate uninstall without making actual changes. + + Args: + dependency (Dict[str, Any]): Dependency object. + context (InstallationContext): Installation context. + progress_callback (Callable[[str, float, str], None], optional): Progress callback. + + Returns: + InstallationResult: Simulated result. + """ + package_name = dependency["name"] + + if progress_callback: + progress_callback(f"Simulating uninstall {package_name}", 0.5, "Running dry-run") + + try: + # Use apt's dry-run functionality for remove + cmd = ["apt", "remove", dependency["name"], "--simulate"] + returncode, stdout, stderr = self._run_apt_subprocess(cmd) + + if returncode != 0: + raise InstallationError( + f"Uninstall simulation failed with error: {stderr.strip()}", + dependency_name=package_name, + error_code="APT_UNINSTALL_SIMULATION_FAILED", + cause=None + ) + + if progress_callback: + progress_callback(f"Simulating uninstall {package_name}", 1.0, "Simulation complete") + + return InstallationResult( + dependency_name=package_name, + status=InstallationStatus.COMPLETED, + metadata={ + "operation": "uninstall", + "simulation": True, + "dry_run_output": stdout, + "command_simulated": " ".join(cmd), + "automated": context.get_config("automated", False) + } + ) + + except InstallationError as e: + self.logger.error(f"Uninstall simulation error for {package_name}: {str(e)}") + raise e + + except Exception as e: + return InstallationResult( + dependency_name=package_name, + status=InstallationStatus.FAILED, + error_message=f"Uninstall simulation failed: {str(e)}", + metadata={ + "operation": "uninstall", + "simulation": True, + "simulation_error": str(e), + "command_simulated": " ".join(cmd), + "automated": context.get_config("automated", False) + } + ) diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index 260bd3a..1ff926a 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -53,16 +53,24 @@ test_mocking = test_loader.loadTestsFromName("test_python_installer.TestPythonInstaller") test_integration = test_loader.loadTestsFromName("test_python_installer.TestPythonInstallerIntegration") test_suite = unittest.TestSuite([test_mocking, test_integration]) + elif len(sys.argv) > 1 and sys.argv[1] == "--system-installer-only": + # Run only SystemInstaller tests + logger.info("Running SystemInstaller tests only...") + test_mocking = test_loader.loadTestsFromName("test_system_installer.TestSystemInstaller") + test_integration = test_loader.loadTestsFromName("test_system_installer.TestSystemInstallerIntegration") + test_suite = unittest.TestSuite([test_mocking, test_integration]) elif len(sys.argv) > 1 and sys.argv[1] == "--all-installers": # Run all installer tests logger.info("Running all installer tests...") hatch_tests = test_loader.loadTestsFromName("test_hatch_installer.TestHatchInstaller") python_tests_mocking = test_loader.loadTestsFromName("test_python_installer.TestPythonInstaller") python_tests_integration = test_loader.loadTestsFromName("test_python_installer.TestPythonInstallerIntegration") + system_tests = test_loader.loadTestsFromName("test_system_installer.TestSystemInstaller") + system_tests_integration = test_loader.loadTestsFromName("test_system_installer.TestSystemInstallerIntegration") # Add future installer tests here as needed - test_suite = unittest.TestSuite([hatch_tests, python_tests_mocking, python_tests_integration]) + test_suite = unittest.TestSuite([hatch_tests, python_tests_mocking, python_tests_integration, system_tests, system_tests_integration]) else: # Run all tests logger.info("Running all package environment tests...") diff --git a/tests/test_system_installer.py b/tests/test_system_installer.py new file mode 100644 index 0000000..8418c58 --- /dev/null +++ b/tests/test_system_installer.py @@ -0,0 +1,583 @@ +"""Tests for SystemInstaller. + +This module contains comprehensive tests for the SystemInstaller class, +including unit tests with mocked system calls and integration tests with +dummy packages. +""" + +import unittest +import platform +import subprocess +from pathlib import Path +from unittest.mock import patch, MagicMock +from typing import Dict, Any + +from hatch.installers.system_installer import SystemInstaller +from hatch.installers.installer_base import InstallationError +from hatch.installers.installation_context import InstallationContext, InstallationResult, InstallationStatus + + +class DummyContext(InstallationContext): + def __init__(self, env_path=None, env_name=None, simulation_mode=False, extra_config=None): + self.simulation_mode = simulation_mode + self.extra_config = extra_config or {} + self.environment_path = env_path + self.environment_name = env_name + + def get_config(self, key, default=None): + return self.extra_config.get(key, default) + + +class TestSystemInstaller(unittest.TestCase): + """Test suite for SystemInstaller using unittest.""" + + def setUp(self): + self.installer = SystemInstaller() + self.mock_context = DummyContext( + env_path=Path("/test/env"), + env_name="test_env", + simulation_mode=False, + extra_config={} + ) + + def test_installer_type(self): + self.assertEqual(self.installer.installer_type, "system") + + def test_supported_schemes(self): + self.assertEqual(self.installer.supported_schemes, ["apt"]) + + def test_can_install_valid_dependency(self): + dependency = { + "type": "system", + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + with patch.object(self.installer, '_is_platform_supported', return_value=True), \ + patch.object(self.installer, '_is_apt_available', return_value=True): + self.assertTrue(self.installer.can_install(dependency)) + + def test_can_install_wrong_type(self): + dependency = { + "type": "python", + "name": "requests", + "version_constraint": ">=2.0.0" + } + + self.assertFalse(self.installer.can_install(dependency)) + + def test_can_install_unsupported_platform(self): + dependency = { + "type": "system", + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + with patch.object(self.installer, '_is_platform_supported', return_value=False): + self.assertFalse(self.installer.can_install(dependency)) + + def test_can_install_apt_not_available(self): + dependency = { + "type": "system", + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + with patch.object(self.installer, '_is_platform_supported', return_value=True), \ + patch.object(self.installer, '_is_apt_available', return_value=False): + self.assertFalse(self.installer.can_install(dependency)) + + def test_validate_dependency_valid(self): + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + self.assertTrue(self.installer.validate_dependency(dependency)) + + def test_validate_dependency_missing_name(self): + dependency = { + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + self.assertFalse(self.installer.validate_dependency(dependency)) + + def test_validate_dependency_missing_version_constraint(self): + dependency = { + "name": "curl", + "package_manager": "apt" + } + + self.assertFalse(self.installer.validate_dependency(dependency)) + + def test_validate_dependency_invalid_package_manager(self): + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "yum" + } + + self.assertFalse(self.installer.validate_dependency(dependency)) + + def test_validate_dependency_invalid_version_constraint(self): + dependency = { + "name": "curl", + "version_constraint": "invalid_version", + "package_manager": "apt" + } + + self.assertFalse(self.installer.validate_dependency(dependency)) + + @patch('platform.system') + @patch('pathlib.Path.exists') + def test_is_platform_supported_debian(self, mock_exists, mock_system): + """Test platform support detection for Debian.""" + mock_system.return_value = "Linux" + mock_exists.return_value = True + + self.assertTrue(self.installer._is_platform_supported()) + mock_exists.assert_called_with() + + @patch('platform.system') + @patch('pathlib.Path.exists') + @patch('builtins.open') + def test_is_platform_supported_ubuntu(self, mock_open, mock_exists, mock_system): + """Test platform support detection for Ubuntu.""" + mock_system.return_value = "Linux" + mock_exists.return_value = False + + # Mock os-release file content + mock_file = MagicMock() + mock_file.read.return_value = "NAME=\"Ubuntu\"\nVERSION=\"20.04\"" + mock_open.return_value.__enter__.return_value = mock_file + + self.assertTrue(self.installer._is_platform_supported()) + + @patch('platform.system') + @patch('pathlib.Path.exists') + def test_is_platform_supported_unsupported(self, mock_exists, mock_system): + """Test platform support detection for unsupported systems.""" + mock_system.return_value = "Windows" + mock_exists.return_value = False + + self.assertFalse(self.installer._is_platform_supported()) + + @patch('shutil.which') + def test_is_apt_available_true(self, mock_which): + """Test apt availability detection when apt is available.""" + mock_which.return_value = "/usr/bin/apt" + + self.assertTrue(self.installer._is_apt_available()) + mock_which.assert_called_once_with("apt") + + @patch('shutil.which') + def test_is_apt_available_false(self, mock_which): + """Test apt availability detection when apt is not available.""" + mock_which.return_value = None + + self.assertFalse(self.installer._is_apt_available()) + + def test_build_apt_command_basic(self): + """Test building basic apt install command.""" + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + command = self.installer._build_apt_command(dependency, self.mock_context) + self.assertEqual(command, ["sudo", "apt", "install", "curl"]) + + def test_build_apt_command_exact_version(self): + """Test building apt command with exact version constraint.""" + dependency = { + "name": "curl", + "version_constraint": "==7.68.0", + "package_manager": "apt" + } + + command = self.installer._build_apt_command(dependency, self.mock_context) + self.assertEqual(command, ["sudo", "apt", "install", "curl=7.68.0"]) + + def test_build_apt_command_automated(self): + """Test building apt command in automated mode.""" + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + self.mock_context.extra_config = {"automated": True} + command = self.installer._build_apt_command(dependency, self.mock_context) + self.assertEqual(command, ["sudo", "apt", "install", "-y", "curl"]) + + @patch('subprocess.run') + def test_verify_installation_success(self, mock_run): + """Test successful installation verification.""" + mock_run.return_value = subprocess.CompletedProcess( + args=["dpkg-query", "-W", "-f='${Version}'", "curl"], + returncode=0, + stdout="'7.68.0-1ubuntu2.7'", + stderr="" + ) + + version = self.installer._verify_installation("curl") + self.assertTrue(isinstance(version, str) and len(version) > 0, f"Expected a non-empty version string, got: {version}") + + @patch('subprocess.run') + def test_verify_installation_failure(self, mock_run): + """Test installation verification when package not found.""" + mock_run.side_effect = subprocess.CalledProcessError(1, ["dpkg-query"]) + + version = self.installer._verify_installation("nonexistent") + self.assertIsNone(version) + + def test_parse_apt_error_permission_denied(self): + """Test parsing permission denied error.""" + error = subprocess.CalledProcessError( + 1, ["apt", "install", "curl"], + stderr="E: Could not open lock file - permission denied" + ) + wrapped_error = InstallationError( + str(error.stderr), + dependency_name="curl", + error_code="APT_INSTALL_FAILED", + cause=error + ) + message = self.installer._parse_apt_error(wrapped_error) + self.assertIn("permission denied", message.lower()) + self.assertIn("sudo", message.lower()) + + def test_parse_apt_error_package_not_found(self): + """Test parsing package not found error.""" + error = subprocess.CalledProcessError( + 100, ["apt", "install", "nonexistent"], + stderr="E: Unable to locate package nonexistent" + ) + wrapped_error = InstallationError( + str(error.stderr), + dependency_name="nonexistent", + error_code="APT_INSTALL_FAILED", + cause=error + ) + message = self.installer._parse_apt_error(wrapped_error) + self.assertIn("package not found", message.lower()) + self.assertIn("apt update", message.lower()) + + def test_parse_apt_error_generic(self): + """Test parsing generic apt error.""" + error = subprocess.CalledProcessError( + 1, ["apt", "install", "curl"], + stderr="Some unknown error occurred" + ) + wrapped_error = InstallationError( + str(error.stderr), + dependency_name="curl", + error_code="APT_INSTALL_FAILED", + cause=error + ) + message = self.installer._parse_apt_error(wrapped_error) + self.assertIn("apt command failed", message.lower()) + self.assertIn("unknown error", message.lower()) + + @patch.object(SystemInstaller, 'validate_dependency') + @patch.object(SystemInstaller, '_build_apt_command') + @patch.object(SystemInstaller, '_run_apt_subprocess') + @patch.object(SystemInstaller, '_verify_installation') + def test_install_success(self, mock_verify, mock_execute, mock_build, mock_validate): + """Test successful installation.""" + # Setup mocks + mock_validate.return_value = True + mock_build.return_value = ["apt", "install", "curl"] + mock_execute.return_value = (0, "", "") + mock_verify.return_value = "7.68.0" + + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + # Test with progress callback + progress_calls = [] + def progress_callback(operation, progress, message): + progress_calls.append((operation, progress, message)) + + result = self.installer.install(dependency, self.mock_context, progress_callback) + + # Verify result + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertEqual(result.installed_version, "7.68.0") + self.assertEqual(result.metadata["package_manager"], "apt") + + # Verify progress was reported + self.assertEqual(len(progress_calls), 4) + self.assertEqual(progress_calls[0][1], 0.0) # Start + self.assertEqual(progress_calls[-1][1], 100.0) # Complete + + @patch.object(SystemInstaller, 'validate_dependency') + def test_install_invalid_dependency(self, mock_validate): + """Test installation with invalid dependency.""" + mock_validate.return_value = False + + dependency = { + "name": "curl", + "version_constraint": "invalid" + } + + with self.assertRaises(InstallationError) as exc_info: + self.installer.install(dependency, self.mock_context) + + self.assertEqual(exc_info.exception.error_code, "INVALID_DEPENDENCY") + self.assertIn("Invalid dependency", str(exc_info.exception)) + + @patch.object(SystemInstaller, 'validate_dependency') + @patch.object(SystemInstaller, '_build_apt_command') + @patch.object(SystemInstaller, '_run_apt_subprocess') + def test_install_apt_failure(self, mock_execute, mock_build, mock_validate): + """Test installation failure due to apt command error.""" + mock_validate.return_value = True + mock_build.return_value = ["apt", "install", "curl"] + mock_execute.return_value = (1, "", "Permission denied") + + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + with self.assertRaises(InstallationError) as exc_info: + self.installer.install(dependency, self.mock_context) + + self.assertEqual(exc_info.exception.error_code, "APT_INSTALL_FAILED") + self.assertEqual(exc_info.exception.dependency_name, "curl") + + @patch.object(SystemInstaller, 'validate_dependency') + @patch.object(SystemInstaller, '_simulate_installation') + def test_install_simulation_mode(self, mock_simulate, mock_validate): + """Test installation in simulation mode.""" + mock_validate.return_value = True + mock_simulate.return_value = InstallationResult( + dependency_name="curl", + status=InstallationStatus.COMPLETED, + metadata={"simulation": True} + ) + + self.mock_context.simulation_mode = True + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + result = self.installer.install(dependency, self.mock_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["simulation"]) + mock_simulate.assert_called_once() + + @patch('subprocess.run') + def test_simulate_installation_success(self, mock_run): + """Test successful installation simulation.""" + mock_run.return_value = subprocess.CompletedProcess( + args=["apt", "install", "--dry-run", "curl"], + returncode=0, + stdout="Inst curl (7.68.0-1ubuntu2.7 Ubuntu:20.04/focal [amd64])", + stderr="" + ) + + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + result = self.installer._simulate_installation(dependency, self.mock_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["simulation"]) + self.assertIn("dry_run_output", result.metadata) + + @patch.object(SystemInstaller, '_run_apt_subprocess') + def test_simulate_installation_failure(self, mock_run): + """Test installation simulation failure.""" + mock_run.return_value = (1, "", "E: Unable to locate package nonexistent") + mock_run.side_effect = InstallationError( + "Simulation failed", + dependency_name="nonexistent", + error_code="APT_SIMULATION_FAILED" + ) + + dependency = { + "name": "nonexistent", + "version_constraint": ">=1.0.0", + "package_manager": "apt" + } + + with self.assertRaises(InstallationError) as exc_info: + self.installer._simulate_installation(dependency, self.mock_context) + + self.assertEqual(exc_info.exception.dependency_name, "nonexistent") + self.assertEqual(exc_info.exception.error_code, "APT_SIMULATION_FAILED") + + @patch.object(SystemInstaller, '_run_apt_subprocess', return_value=(0, "", "")) + def test_uninstall_success(self, mock_execute): + """Test successful uninstall.""" + + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + result = self.installer.uninstall(dependency, self.mock_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertEqual(result.metadata["operation"], "uninstall") + + @patch.object(SystemInstaller, '_run_apt_subprocess', return_value=(0, "", "")) + def test_uninstall_automated(self, mock_execute): + """Test uninstall in automated mode.""" + + self.mock_context.extra_config = {"automated": True} + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + result = self.installer.uninstall(dependency, self.mock_context) + + self.assertEqual(result.status, InstallationStatus.COMPLETED) + # Verify -y flag is in the command (final command is in the metadata) + self.assertIn("-y", result.metadata.get("command_executed", [])) + + @patch.object(SystemInstaller, '_simulate_uninstall') + def test_uninstall_simulation_mode(self, mock_simulate): + """Test uninstall in simulation mode.""" + mock_simulate.return_value = InstallationResult( + dependency_name="curl", + status=InstallationStatus.COMPLETED, + metadata={"operation": "uninstall", "simulation": True} + ) + + self.mock_context.simulation_mode = True + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + result = self.installer.uninstall(dependency, self.mock_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["simulation"]) + mock_simulate.assert_called_once() + + +class TestSystemInstallerIntegration(unittest.TestCase): + """Integration tests for SystemInstaller using actual system dependencies.""" + + def setUp(self): + """Set up integration test fixtures.""" + self.installer = SystemInstaller() + self.test_context = InstallationContext( + environment_path=Path("/tmp/test_env"), + environment_name="integration_test", + simulation_mode=True, # Always use simulation for integration tests + extra_config={"automated": True} + ) + + + + def test_validate_real_system_dependency(self): + """Test validation with real system dependency from dummy package.""" + # This mimics the dependency from system_dep_pkg + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + self.assertTrue(self.installer.validate_dependency(dependency)) + + @patch.object(SystemInstaller, '_is_platform_supported') + @patch.object(SystemInstaller, '_is_apt_available') + def test_can_install_real_dependency(self, mock_apt_available, mock_platform_supported): + """Test can_install with real system dependency.""" + mock_platform_supported.return_value = True + mock_apt_available.return_value = True + + dependency = { + "type": "system", + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + self.assertTrue(self.installer.can_install(dependency)) + + def test_simulate_curl_installation(self): + """Test simulating installation of curl package.""" + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + # Mock subprocess for simulation + with patch('subprocess.run') as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["apt", "install", "--dry-run", "curl"], + returncode=0, + stdout="Inst curl (7.68.0-1ubuntu2.7 Ubuntu:20.04/focal [amd64])", + stderr="" + ) + + result = self.installer._simulate_installation(dependency, self.test_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["simulation"]) + + def test_get_installation_info(self): + """Test getting installation info for system dependency.""" + dependency = { + "type": "system", + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + with patch.object(self.installer, 'can_install', return_value=True): + info = self.installer.get_installation_info(dependency, self.test_context) + + self.assertEqual(info["installer_type"], "system") + self.assertEqual(info["dependency_name"], "curl") + self.assertTrue(info["supported"]) + + def test_install_real_dependency(self): + """Test installing a real system dependency.""" + dependency = { + "name": "sl", # Use a rarer package than 'curl' + "version_constraint": ">=5.02", + "package_manager": "apt" + } + + # real installation + result = self.installer.install(dependency, self.test_context) + + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["automated"]) + + \ No newline at end of file From 116192cb3582df6f3160ef70f2414f620cffa236 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sat, 28 Jun 2025 20:57:42 +0900 Subject: [PATCH 08/48] [Add] Docker Installer **Major**: - The concrete implementation to install docker depencies of a hatch package relying on `docker-py` - Added the dependency to `docker-py v7.1.0`, currently the latest version of the SDK - Added tests to check installation assuming the validation checks were performed before hand (i.e. truly instal-ready) - Tests are doing mockup of the individual functions - and testing that some small images can be pulled. --- hatch/installers/docker_installer.py | 541 +++++++++++++++++++++++++++ pyproject.toml | 1 + tests/run_environment_tests.py | 20 +- tests/test_docker_installer.py | 495 ++++++++++++++++++++++++ 4 files changed, 1054 insertions(+), 3 deletions(-) create mode 100644 hatch/installers/docker_installer.py create mode 100644 tests/test_docker_installer.py diff --git a/hatch/installers/docker_installer.py b/hatch/installers/docker_installer.py new file mode 100644 index 0000000..b50fe6b --- /dev/null +++ b/hatch/installers/docker_installer.py @@ -0,0 +1,541 @@ +"""Installer for Docker image dependencies. + +This module implements installation logic for Docker images using docker-py library, +with support for version constraints, registry management, and comprehensive error handling. +""" +import logging +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List +from packaging.specifiers import SpecifierSet, InvalidSpecifier +from packaging.version import Version, InvalidVersion + +from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError +from .installation_context import InstallationStatus + +logger = logging.getLogger(__name__) + +# Handle docker-py import with graceful fallback +try: + import docker + from docker.errors import DockerException, ImageNotFound, APIError + DOCKER_AVAILABLE = True +except ImportError: + docker = None + DockerException = Exception + ImageNotFound = Exception + APIError = Exception + DOCKER_AVAILABLE = False + logger.warning("docker-py library not available. Docker installer will be disabled.") + + +class DockerInstaller(DependencyInstaller): + """Installer for Docker image dependencies. + + Handles installation and removal of Docker images using the docker-py library. + Supports version constraint mapping to Docker tags and progress reporting during + image pull operations. + """ + + def __init__(self): + """Initialize the DockerInstaller. + + Raises: + InstallationError: If docker-py library is not available. + """ + if not DOCKER_AVAILABLE: + logger.error("Docker installer requires docker-py library") + self._docker_client = None + + @property + def installer_type(self) -> str: + """Get the installer type identifier. + + Returns: + str: The installer type "docker". + """ + return "docker" + + @property + def supported_schemes(self) -> List[str]: + """Get the list of supported registry schemes. + + Returns: + List[str]: List of supported schemes, currently only ["dockerhub"]. + """ + return ["dockerhub"] + + def can_install(self, dependency: Dict[str, Any]) -> bool: + """Check if this installer can handle the given dependency. + + Args: + dependency (Dict[str, Any]): The dependency specification. + + Returns: + bool: True if the dependency can be installed, False otherwise. + """ + if dependency.get("type") != "docker": + return False + + return self._is_docker_available() + + def validate_dependency(self, dependency: Dict[str, Any]) -> bool: + """Validate a Docker dependency specification. + + Args: + dependency (Dict[str, Any]): The dependency specification to validate. + + Returns: + bool: True if the dependency is valid, False otherwise. + """ + required_fields = ["name", "version_constraint"] + + # Check required fields + if not all(field in dependency for field in required_fields): + logger.error(f"Docker dependency missing required fields. Required: {required_fields}") + return False + + # Validate type + if dependency.get("type") != "docker": + logger.error(f"Invalid dependency type: {dependency.get('type')}, expected 'docker'") + return False + + # Validate registry if specified + registry = dependency.get("registry", "unknown") + if registry not in self.supported_schemes: + logger.error(f"Unsupported registry: {registry}, supported: {self.supported_schemes}") + return False + + # Validate version constraint format + version_constraint = dependency.get("version_constraint", "") + if not self._validate_version_constraint(version_constraint): + logger.error(f"Invalid version constraint format: {version_constraint}") + return False + + return True + + def install(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Install a Docker image dependency. + + Args: + dependency (Dict[str, Any]): The dependency specification. + context (InstallationContext): Installation context and configuration. + progress_callback (Optional[Callable[[str, float, str], None]]): Progress reporting callback. + + Returns: + InstallationResult: Result of the installation operation. + + Raises: + InstallationError: If installation fails. + """ + if not self.validate_dependency(dependency): + raise InstallationError( + f"Invalid Docker dependency specification: {dependency}", + dependency_name=dependency.get("name", "unknown"), + error_code="DOCKER_DEPENDENCY_INVALID", + cause=ValueError("Dependency validation failed") + ) + + image_name = dependency["name"] + version_constraint = dependency["version_constraint"] + registry = dependency.get("registry", "dockerhub") + + if progress_callback: + progress_callback(f"Starting Docker image pull: {image_name}", 0.0, "starting") + + # Handle simulation mode + if context.simulation_mode: + logger.info(f"[SIMULATION] Would pull Docker image: {image_name}:{version_constraint}") + if progress_callback: + progress_callback(f"Simulated pull: {image_name}", 100.0, "completed") + return InstallationResult( + dependency_name=image_name, + status=InstallationStatus.COMPLETED, + installed_version=version_constraint, + artifacts=[], + metadata={ + "message": f"Simulated installation of Docker image: {image_name}:{version_constraint}", + } + ) + + try: + # Resolve version constraint to Docker tag + docker_tag = self._resolve_docker_tag(version_constraint) + full_image_name = f"{image_name}:{docker_tag}" + + # Pull the Docker image + self._pull_docker_image(full_image_name, progress_callback) + + if progress_callback: + progress_callback(f"Completed pull: {image_name}", 100.0, "completed") + + return InstallationResult( + dependency_name=image_name, + status=InstallationStatus.COMPLETED, + installed_version=docker_tag, + artifacts=[full_image_name], + metadata={ + "message": f"Successfully installed Docker image: {full_image_name}", + } + ) + + except Exception as e: + error_msg = f"Failed to install Docker image {image_name}: {str(e)}" + logger.error(error_msg) + if progress_callback: + progress_callback(f"Failed: {image_name}", 0.0, "error") + raise InstallationError(error_msg, + dependency_name=image_name, + error_code="DOCKER_INSTALL_ERROR", + cause=e) + + def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult: + """Uninstall a Docker image dependency. + + Args: + dependency (Dict[str, Any]): The dependency specification. + context (InstallationContext): Installation context and configuration. + progress_callback (Optional[Callable[[str, float, str], None]]): Progress reporting callback. + + Returns: + InstallationResult: Result of the uninstallation operation. + + Raises: + InstallationError: If uninstallation fails. + """ + if not self.validate_dependency(dependency): + raise InstallationError(f"Invalid Docker dependency specification: {dependency}") + + image_name = dependency["name"] + version_constraint = dependency["version_constraint"] + + if progress_callback: + progress_callback(f"Starting Docker image removal: {image_name}", 0.0, "starting") + + # Handle simulation mode + if context.simulation_mode: + logger.info(f"[SIMULATION] Would remove Docker image: {image_name}:{version_constraint}") + if progress_callback: + progress_callback(f"Simulated removal: {image_name}", 100.0, "completed") + return InstallationResult( + dependency_name=image_name, + status=InstallationStatus.COMPLETED, + installed_version=version_constraint, + artifacts=[], + metadata={ + "message": f"Simulated removal of Docker image: {image_name}:{version_constraint}", + } + ) + + try: + # Resolve version constraint to Docker tag + docker_tag = self._resolve_docker_tag(version_constraint) + full_image_name = f"{image_name}:{docker_tag}" + + # Remove the Docker image + self._remove_docker_image(full_image_name, context, progress_callback) + + if progress_callback: + progress_callback(f"Completed removal: {image_name}", 100.0, "completed") + + return InstallationResult( + dependency_name=image_name, + status=InstallationStatus.COMPLETED, + installed_version=docker_tag, + artifacts=[], + metadata={ + "message": f"Successfully removed Docker image: {full_image_name}", + } + ) + + except Exception as e: + error_msg = f"Failed to remove Docker image {image_name}: {str(e)}" + logger.error(error_msg) + if progress_callback: + progress_callback(f"Failed removal: {image_name}", 0.0, "error") + raise InstallationError(error_msg, + dependency_name=image_name, + error_code="DOCKER_UNINSTALL_ERROR", + cause=e) + + def cleanup_failed_installation(self, dependency: Dict[str, Any], context: InstallationContext, + artifacts: Optional[List[Path]] = None) -> None: + """Clean up artifacts from a failed installation. + + Args: + dependency (Dict[str, Any]): The dependency that failed to install. + context (InstallationContext): Installation context. + artifacts (Optional[List[Path]]): List of artifacts to clean up. + """ + if not artifacts: + return + + logger.info(f"Cleaning up failed Docker installation for {dependency.get('name', 'unknown')}") + + for artifact in artifacts: + if isinstance(artifact, str): # Docker image name + try: + self._remove_docker_image(artifact, context, None, force=True) + logger.info(f"Cleaned up Docker image: {artifact}") + except Exception as e: + logger.warning(f"Failed to clean up Docker image {artifact}: {e}") + + def _is_docker_available(self) -> bool: + """Check if Docker daemon is available. + + Returns: + bool: True if Docker daemon is available, False otherwise. + """ + if not DOCKER_AVAILABLE: + logger.warning("Docker library not available, cannot check Docker daemon status. Install docker-py to enable Docker support.") + return False + + try: + client = self._get_docker_client() + client.ping() + return True + except Exception as e: + logger.debug(f"Docker daemon not available: {e}") + return False + + def _get_docker_client(self): + """Get or create Docker client. + + Returns: + docker.DockerClient: Docker client instance. + + Raises: + InstallationError: If Docker client cannot be created. + """ + if not DOCKER_AVAILABLE: + raise InstallationError( + "Docker library not available", + error_code="DOCKER_LIBRARY_NOT_AVAILABLE", + cause=ImportError("docker-py library is required for Docker support") + ) + + if self._docker_client is None: + try: + self._docker_client = docker.from_env() + except DockerException as e: + raise InstallationError( + "Docker daemon not available", + error_code="DOCKER_DAEMON_NOT_AVAILABLE", + cause=e + ) + + return self._docker_client + + def _validate_version_constraint(self, version_constraint: str) -> bool: + """Validate version constraint format. + + Args: + version_constraint (str): Version constraint to validate. + + Returns: + bool: True if valid, False otherwise. + """ + if not version_constraint or not isinstance(version_constraint, str): + return False + + # Accept "latest" as a valid constraint + if version_constraint.strip() == "latest": + return True + + constraint = version_constraint.strip() + + # Accept bare version numbers (e.g. 1.25.0) as valid + try: + Version(constraint) + return True + except Exception: + pass + + # Accept valid PEP 440 specifiers (e.g. >=1.25.0, ==1.25.0) + try: + SpecifierSet(constraint) + return True + except Exception: + logger.error(f"Invalid version constraint format: {version_constraint}") + return False + + def _resolve_docker_tag(self, version_constraint: str) -> str: + """Resolve version constraint to Docker tag. + + Args: + version_constraint (str): Version constraint specification. + + Returns: + str: Docker tag to use. + """ + constraint = version_constraint.strip() + # Handle simple cases + if constraint == "latest": + return "latest" + + # Accept bare version numbers as tags + try: + Version(constraint) + return constraint + except Exception: + pass + + # Try to parse as a version specifier + try: + spec = SpecifierSet(constraint) + except InvalidSpecifier: + logger.warning(f"Invalid version constraint '{constraint}', defaulting to 'latest'") + return "latest" + + return next(iter(spec)).version # always returns the first matching spec's version + + def _pull_docker_image(self, image_name: str, progress_callback: Optional[Callable[[str, float, str], None]]): + """Pull Docker image with progress reporting. + + Args: + image_name (str): Full image name with tag. + progress_callback (Optional[Callable[[str, float, str], None]]): Progress callback. + + Raises: + InstallationError: If pull fails. + """ + try: + client = self._get_docker_client() + + if progress_callback: + progress_callback(f"Pulling {image_name}", 50.0, "pulling") + + # Pull the image + client.images.pull(image_name) + + logger.info(f"Successfully pulled Docker image: {image_name}") + + except ImageNotFound as e: + raise InstallationError( + f"Docker image not found: {image_name}", + error_code="DOCKER_IMAGE_NOT_FOUND", + cause=e + ) + except APIError as e: + raise InstallationError( + f"Docker API error while pulling {image_name}: {e}", + error_code="DOCKER_API_ERROR", + cause=e + ) + except DockerException as e: + raise InstallationError( + f"Docker error while pulling {image_name}: {e}", + error_code="DOCKER_ERROR", + cause=e + ) + + def _remove_docker_image(self, image_name: str, context: InstallationContext, + progress_callback: Optional[Callable[[str, float, str], None]], + force: bool = False): + """Remove Docker image. + + Args: + image_name (str): Full image name with tag. + context (InstallationContext): Installation context. + progress_callback (Optional[Callable[[str, float, str], None]]): Progress callback. + force (bool): Whether to force removal even if image is in use. + + Raises: + InstallationError: If removal fails. + """ + try: + client = self._get_docker_client() + + if progress_callback: + progress_callback(f"Removing {image_name}", 50.0, "removing") + + # Check if image is in use (unless forcing) + if not force and self._is_image_in_use(image_name): + raise InstallationError( + f"Cannot remove Docker image {image_name} as it is in use by running containers", + error_code="DOCKER_IMAGE_IN_USE" + ) + + # Remove the image + client.images.remove(image_name, force=force) + + logger.info(f"Successfully removed Docker image: {image_name}") + + except ImageNotFound: + logger.warning(f"Docker image not found during removal: {image_name}. Nothing to remove.") + except APIError as e: + raise InstallationError( + f"Docker API error while removing {image_name}: {e}", + error_code="DOCKER_API_ERROR", + cause=e + ) + except DockerException as e: + raise InstallationError( + f"Docker error while removing {image_name}: {e}", + error_code="DOCKER_ERROR", + cause=e + ) + + def _is_image_in_use(self, image_name: str) -> bool: + """Check if Docker image is in use by running containers. + + Args: + image_name (str): Image name to check. + + Returns: + bool: True if image is in use, False otherwise. + """ + try: + client = self._get_docker_client() + containers = client.containers.list(all=True) + + for container in containers: + if container.image.tags and any(tag == image_name for tag in container.image.tags): + return True + + return False + + except Exception as e: + logger.warning(f"Could not check if image {image_name} is in use: {e}\n Assuming NOT in use.") + return False # Assume not in use if we can't check + + def get_installation_info(self, dependency: Dict[str, Any], context: InstallationContext) -> Dict[str, Any]: + """Get information about Docker image installation. + + Args: + dependency (Dict[str, Any]): The dependency specification. + context (InstallationContext): Installation context. + + Returns: + Dict[str, Any]: Installation information including availability and status. + """ + image_name = dependency.get("name", "unknown") + version_constraint = dependency.get("version_constraint", "latest") + + info = { + "installer_type": self.installer_type, + "dependency_name": image_name, + "version_constraint": version_constraint, + "docker_available": self._is_docker_available(), + "can_install": self.can_install(dependency) + } + + if self._is_docker_available(): + try: + docker_tag = self._resolve_docker_tag(version_constraint) + full_image_name = f"{image_name}:{docker_tag}" + + client = self._get_docker_client() + try: + image = client.images.get(full_image_name) + info["installed"] = True + info["image_id"] = image.id + info["image_tags"] = image.tags + except ImageNotFound: + info["installed"] = False + + except Exception as e: + info["error"] = str(e) + + return info \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 00b0e8a..1b4744a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "requests>=2.25.0", "packaging>=20.0", "hatch_validator @ git+https://github.com/CrackingShells/Hatch-Validator.git@v0.3.2" + "docker>=7.1.0", ] [project.scripts] diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index 1ff926a..f663323 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -59,6 +59,12 @@ test_mocking = test_loader.loadTestsFromName("test_system_installer.TestSystemInstaller") test_integration = test_loader.loadTestsFromName("test_system_installer.TestSystemInstallerIntegration") test_suite = unittest.TestSuite([test_mocking, test_integration]) + elif len(sys.argv) > 1 and sys.argv[1] == "--docker-installer-only": + # Run only DockerInstaller tests + logger.info("Running DockerInstaller tests only...") + test_mocking = test_loader.loadTestsFromName("test_docker_installer.TestDockerInstaller") + test_integration = test_loader.loadTestsFromName("test_docker_installer.TestDockerInstallerIntegration") + test_suite = unittest.TestSuite([test_mocking, test_integration]) elif len(sys.argv) > 1 and sys.argv[1] == "--all-installers": # Run all installer tests logger.info("Running all installer tests...") @@ -67,10 +73,18 @@ python_tests_integration = test_loader.loadTestsFromName("test_python_installer.TestPythonInstallerIntegration") system_tests = test_loader.loadTestsFromName("test_system_installer.TestSystemInstaller") system_tests_integration = test_loader.loadTestsFromName("test_system_installer.TestSystemInstallerIntegration") + docker_tests = test_loader.loadTestsFromName("test_docker_installer.TestDockerInstaller") + docker_tests_integration = test_loader.loadTestsFromName("test_docker_installer.TestDockerInstallerIntegration") - # Add future installer tests here as needed - - test_suite = unittest.TestSuite([hatch_tests, python_tests_mocking, python_tests_integration, system_tests, system_tests_integration]) + test_suite = unittest.TestSuite([ + hatch_tests, + python_tests_mocking, + python_tests_integration, + system_tests, + system_tests_integration, + docker_tests, + docker_tests_integration + ]) else: # Run all tests logger.info("Running all package environment tests...") diff --git a/tests/test_docker_installer.py b/tests/test_docker_installer.py new file mode 100644 index 0000000..35f8ee5 --- /dev/null +++ b/tests/test_docker_installer.py @@ -0,0 +1,495 @@ +"""Tests for DockerInstaller. + +This module contains comprehensive tests for the DockerInstaller class, +including unit tests with mocked Docker client and integration tests with +real Docker images. +""" +import unittest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch, MagicMock, Mock +from typing import Dict, Any + +from hatch.installers.docker_installer import DockerInstaller, DOCKER_AVAILABLE +from hatch.installers.installer_base import InstallationError +from hatch.installers.installation_context import InstallationContext, InstallationResult, InstallationStatus + + +class DummyContext(InstallationContext): + """Test implementation of InstallationContext.""" + + def __init__(self, env_path=None, env_name=None, simulation_mode=False, extra_config=None): + """Initialize dummy context. + + Args: + env_path (Optional[Path]): Environment path. + env_name (Optional[str]): Environment name. + simulation_mode (bool): Whether to run in simulation mode. + extra_config (Optional[Dict]): Extra configuration. + """ + self.env_path = env_path or Path("dummy_env") + self.env_name = env_name or "dummy" + self.simulation_mode = simulation_mode + self.extra_config = extra_config or {} + + def get_config(self, key, default=None): + """Get configuration value. + + Args: + key (str): Configuration key. + default: Default value if key not found. + + Returns: + Configuration value or default. + """ + return self.extra_config.get(key, default) + + +class TestDockerInstaller(unittest.TestCase): + """Test suite for DockerInstaller using unittest.""" + + def setUp(self): + """Set up test fixtures.""" + self.installer = DockerInstaller() + self.temp_dir = tempfile.mkdtemp() + self.context = DummyContext( + env_path=Path(self.temp_dir), + simulation_mode=False + ) + + def tearDown(self): + """Clean up test fixtures.""" + shutil.rmtree(self.temp_dir) + + def test_installer_type(self): + """Test installer type property.""" + self.assertEqual( + self.installer.installer_type, "docker", + f"Installer type mismatch: expected 'docker', got '{self.installer.installer_type}'" + ) + + def test_supported_schemes(self): + """Test supported schemes property.""" + self.assertEqual( + self.installer.supported_schemes, ["dockerhub"], + f"Supported schemes mismatch: expected ['dockerhub'], got {self.installer.supported_schemes}" + ) + + def test_can_install_valid_dependency(self): + """Test can_install with valid Docker dependency.""" + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "docker", + "registry": "dockerhub" + } + with patch.object(self.installer, '_is_docker_available', return_value=True): + self.assertTrue( + self.installer.can_install(dependency), + f"can_install should return True for valid dependency: {dependency}" + ) + + def test_can_install_wrong_type(self): + """Test can_install with wrong dependency type.""" + dependency = { + "name": "requests", + "version_constraint": ">=2.0.0", + "type": "python" + } + self.assertFalse( + self.installer.can_install(dependency), + f"can_install should return False for non-docker dependency: {dependency}" + ) + + @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + def test_can_install_docker_unavailable(self): + """Test can_install when Docker daemon is unavailable.""" + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "docker" + } + with patch.object(self.installer, '_is_docker_available', return_value=False): + self.assertFalse( + self.installer.can_install(dependency), + f"can_install should return False when Docker is unavailable for dependency: {dependency}" + ) + + def test_validate_dependency_valid(self): + """Test validate_dependency with valid dependency.""" + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "docker", + "registry": "dockerhub" + } + self.assertTrue( + self.installer.validate_dependency(dependency), + f"validate_dependency should return True for valid dependency: {dependency}" + ) + + def test_validate_dependency_missing_name(self): + """Test validate_dependency with missing name field.""" + dependency = { + "version_constraint": ">=1.25.0", + "type": "docker" + } + self.assertFalse( + self.installer.validate_dependency(dependency), + f"validate_dependency should return False when 'name' is missing: {dependency}" + ) + + def test_validate_dependency_missing_version_constraint(self): + """Test validate_dependency with missing version_constraint field.""" + dependency = { + "name": "nginx", + "type": "docker" + } + self.assertFalse( + self.installer.validate_dependency(dependency), + f"validate_dependency should return False when 'version_constraint' is missing: {dependency}" + ) + + def test_validate_dependency_invalid_type(self): + """Test validate_dependency with invalid type.""" + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "python" + } + self.assertFalse( + self.installer.validate_dependency(dependency), + f"validate_dependency should return False for invalid type: {dependency}" + ) + + def test_validate_dependency_invalid_registry(self): + """Test validate_dependency with unsupported registry.""" + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "docker", + "registry": "gcr.io" + } + self.assertFalse( + self.installer.validate_dependency(dependency), + f"validate_dependency should return False for unsupported registry: {dependency}" + ) + + def test_validate_dependency_invalid_version_constraint(self): + """Test validate_dependency with invalid version constraint.""" + dependency = { + "name": "nginx", + "version_constraint": "invalid_version", + "type": "docker" + } + self.assertFalse( + self.installer.validate_dependency(dependency), + f"validate_dependency should return False for invalid version_constraint: {dependency}" + ) + + def test_version_constraint_validation(self): + """Test various version constraint formats.""" + valid_constraints = [ + "1.25.0", + ">=1.25.0", # Theoretically valid, but Docker works with tags and not version constraints. This is just to ensure the method can handle it. + "==1.25.0", # Theoretically valid, but Docker works with tags and not version constraints. This is just to ensure the method can handle it. + "<=2.0.0", # Theoretically valid, but Docker works with tags and not version constraints. This is just to ensure the method can handle it. + #"!=1.24.0", # Docker works with tags and not version constraint, so this one is really irrelevant + "latest", + "1.25", + "1" + ] + for constraint in valid_constraints: + with self.subTest(constraint=constraint): + self.assertTrue( + self.installer._validate_version_constraint(constraint), + f"_validate_version_constraint should return True for valid constraint: '{constraint}'" + ) + + def test_resolve_docker_tag(self): + """Test Docker tag resolution from version constraints.""" + test_cases = [ + ("latest", "latest"), + ("1.25.0", "1.25.0"), + ("==1.25.0", "1.25.0"), # Theoretically valid, but Docker works with tags and not version constraints. This is just to ensure the method can handle it. + (">=1.25.0", "1.25.0"), # Theoretically valid, but Docker works with tags and not version constraints. This is just to ensure the method can handle it. + ("<=1.25.0", "1.25.0"), # Theoretically valid, but Docker works with tags and not version constraints. This is just to ensure the method can handle it. + #("!=1.24.0", "latest"), # Docker works with tags and not version constraint, so this one is really irrelevant + ] + for constraint, expected in test_cases: + with self.subTest(constraint=constraint): + result = self.installer._resolve_docker_tag(constraint) + self.assertEqual( + result, expected, + f"_resolve_docker_tag('{constraint}') returned '{result}', expected '{expected}'" + ) + + def test_install_simulation_mode(self): + """Test installation in simulation mode.""" + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "docker", + "registry": "dockerhub" + } + simulation_context = DummyContext(simulation_mode=True) + progress_calls = [] + def progress_callback(message, percent, status): + progress_calls.append((message, percent, status)) + result = self.installer.install(dependency, simulation_context, progress_callback) + self.assertEqual( + result.status, InstallationStatus.COMPLETED, + f"Simulation install should return COMPLETED, got {result.status} with message: {result.metadata["message"]}" + ) + self.assertIn( + "Simulated installation", result.metadata["message"], + f"Simulation install message should mention 'Simulated installation', got: {result.metadata["message"]}" + ) + self.assertEqual( + len(progress_calls), 2, + f"Simulation install should call progress_callback twice (start and completion), got {len(progress_calls)} calls: {progress_calls}" + ) + + @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @patch('hatch.installers.docker_installer.docker') + def test_install_success(self, mock_docker): + """Test successful Docker image installation.""" + mock_client = Mock() + mock_docker.from_env.return_value = mock_client + mock_client.ping.return_value = True + mock_client.images.pull.return_value = Mock() + dependency = { + "name": "nginx", + "version_constraint": "1.25.0", + "type": "docker", + "registry": "dockerhub" + } + progress_calls = [] + def progress_callback(message, percent, status): + progress_calls.append((message, percent, status)) + result = self.installer.install(dependency, self.context, progress_callback) + self.assertEqual( + result.status, InstallationStatus.COMPLETED, + f"Install should return COMPLETED, got {result.status} with message: {result.metadata["message"]}" + ) + mock_client.images.pull.assert_called_once_with("nginx:1.25.0") + self.assertGreater( + len(progress_calls), 0, + f"Install should call progress_callback at least once, got {len(progress_calls)} calls: {progress_calls}" + ) + + @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @patch('hatch.installers.docker_installer.docker') + def test_install_failure(self, mock_docker): + """Test Docker installation failure.""" + mock_client = Mock() + mock_docker.from_env.return_value = mock_client + mock_client.ping.return_value = True + mock_client.images.pull.side_effect = Exception("Network error") + dependency = { + "name": "nginx", + "version_constraint": "1.25.0", + "type": "docker" + } + with self.assertRaises(InstallationError, msg=f"Install should raise InstallationError on failure for dependency: {dependency}"): + self.installer.install(dependency, self.context) + + def test_install_invalid_dependency(self): + """Test installation with invalid dependency.""" + dependency = { + "name": "nginx", + # Missing version_constraint + "type": "docker" + } + with self.assertRaises(InstallationError, msg=f"Install should raise InstallationError for invalid dependency: {dependency}"): + self.installer.install(dependency, self.context) + + @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @patch('hatch.installers.docker_installer.docker') + def test_uninstall_success(self, mock_docker): + """Test successful Docker image uninstallation.""" + mock_client = Mock() + mock_docker.from_env.return_value = mock_client + mock_client.ping.return_value = True + mock_client.containers.list.return_value = [] + mock_client.images.remove.return_value = None + dependency = { + "name": "nginx", + "version_constraint": "1.25.0", + "type": "docker", + "registry": "dockerhub" + } + result = self.installer.uninstall(dependency, self.context) + self.assertEqual( + result.status, InstallationStatus.COMPLETED, + f"Uninstall should return COMPLETED, got {result.status} with message: {result.metadata["message"]}" + ) + mock_client.images.remove.assert_called_once_with("nginx:1.25.0", force=False) + + def test_uninstall_simulation_mode(self): + """Test uninstallation in simulation mode.""" + dependency = { + "name": "nginx", + "version_constraint": "1.25.0", + "type": "docker", + "registry": "dockerhub" + } + simulation_context = DummyContext(simulation_mode=True) + result = self.installer.uninstall(dependency, simulation_context) + self.assertEqual( + result.status, InstallationStatus.COMPLETED, + f"Simulation uninstall should return COMPLETED, got {result.status} with message: {result.metadata["message"]}" + ) + self.assertIn( + "Simulated removal", result.metadata["message"], + f"Simulation uninstall message should mention 'Simulated removal', got: {result.metadata["message"]}" + ) + + def test_get_installation_info_docker_unavailable(self): + """Test get_installation_info when Docker is unavailable.""" + dependency = { + "name": "nginx", + "version_constraint": "1.25.0", + "type": "docker" + } + with patch.object(self.installer, '_is_docker_available', return_value=False): + info = self.installer.get_installation_info(dependency, self.context) + self.assertEqual( + info["installer_type"], "docker", + f"get_installation_info: installer_type should be 'docker', got {info['installer_type']}" + ) + self.assertEqual( + info["dependency_name"], "nginx", + f"get_installation_info: dependency_name should be 'nginx', got {info['dependency_name']}" + ) + self.assertFalse( + info["docker_available"], + f"get_installation_info: docker_available should be False, got {info['docker_available']}" + ) + self.assertFalse( + info["can_install"], + f"get_installation_info: can_install should be False, got {info['can_install']}" + ) + + @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @patch('hatch.installers.docker_installer.docker') + def test_get_installation_info_image_installed(self, mock_docker): + """Test get_installation_info for installed image.""" + mock_client = Mock() + mock_docker.from_env.return_value = mock_client + mock_client.ping.return_value = True + mock_image = Mock() + mock_image.id = "sha256:abc123" + mock_image.tags = ["nginx:1.25.0"] + mock_client.images.get.return_value = mock_image + dependency = { + "name": "nginx", + "version_constraint": "1.25.0", + "type": "docker" + } + + with patch.object(self.installer, '_is_docker_available', return_value=True): + info = self.installer.get_installation_info(dependency, self.context) + + self.assertTrue(info["docker_available"]) + self.assertTrue(info["installed"]) + self.assertEqual(info["image_id"], "sha256:abc123") + + +class TestDockerInstallerIntegration(unittest.TestCase): + """Integration tests for DockerInstaller using real Docker operations.""" + + def setUp(self): + """Set up integration test fixtures.""" + if not DOCKER_AVAILABLE: + self.skipTest("Docker library not available") + + self.installer = DockerInstaller() + self.temp_dir = tempfile.mkdtemp() + self.context = DummyContext(env_path=Path(self.temp_dir)) + + # Check if Docker daemon is actually available + if not self.installer._is_docker_available(): + self.skipTest("Docker daemon not available") + + def tearDown(self): + """Clean up integration test fixtures.""" + if hasattr(self, 'temp_dir'): + shutil.rmtree(self.temp_dir) + + def test_docker_daemon_availability(self): + """Test Docker daemon availability detection.""" + self.assertTrue(self.installer._is_docker_available()) + + def test_install_and_uninstall_small_image(self): + """Test installing and uninstalling a small Docker image. + + This test uses the alpine image which is very small (~5MB) to minimize + download time and resource usage in CI environments. + """ + dependency = { + "name": "alpine", + "version_constraint": "latest", + "type": "docker", + "registry": "dockerhub" + } + + progress_events = [] + + def progress_callback(message, percent, status): + progress_events.append((message, percent, status)) + + try: + # Test installation + install_result = self.installer.install(dependency, self.context, progress_callback) + self.assertEqual(install_result.status, InstallationStatus.COMPLETED) + self.assertGreater(len(progress_events), 0) + + # Verify image is installed + info = self.installer.get_installation_info(dependency, self.context) + self.assertTrue(info.get("installed", False)) + + # Test uninstallation + progress_events.clear() + uninstall_result = self.installer.uninstall(dependency, self.context, progress_callback) + self.assertEqual(uninstall_result.status, InstallationStatus.COMPLETED) + + except InstallationError as e: + if e.error_code == "DOCKER_DAEMON_NOT_AVAILABLE": + self.skipTest(f"Integration test failed due to Docker/network issues: {e}") + else: + raise e + + def test_docker_dep_pkg_integration(self): + """Test integration with docker_dep_pkg dummy package. + + This test validates the installer works with the real dependency format + from the Hatching-Dev docker_dep_pkg. + """ + # Dependency based on docker_dep_pkg/hatch_metadata.json + dependency = { + "name": "nginx", + "version_constraint": ">=1.25.0", + "type": "docker", + "registry": "dockerhub" + } + + try: + # Test validation + self.assertTrue(self.installer.validate_dependency(dependency)) + + # Test can_install + self.assertTrue(self.installer.can_install(dependency)) + + # Test installation info + info = self.installer.get_installation_info(dependency, self.context) + self.assertEqual(info["installer_type"], "docker") + self.assertEqual(info["dependency_name"], "nginx") + + except Exception as e: + self.skipTest(f"Docker dep pkg integration test failed: {e}") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 605ac17884decbbf81436fec6bc3ee19a36fe1db Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sat, 28 Jun 2025 22:03:42 +0900 Subject: [PATCH 09/48] [Add] Dependency installer registry **Major**: - Added the singleton `InstallerRegistry` to have access to all dependency installers added previously - Updated every installer to register themselves to the registry at the end of the code. - Meaning installers are registered as soon as they are imported once in the codebase. - Added tests to check registration --- hatch/installers/__init__.py | 5 +- hatch/installers/docker_installer.py | 6 +- hatch/installers/hatch_installer.py | 7 +- hatch/installers/installer_base.py | 4 +- hatch/installers/python_installer.py | 4 + hatch/installers/registry.py | 179 +++++++++++++++++++++++++++ hatch/installers/system_installer.py | 4 + tests/run_environment_tests.py | 4 + tests/test_registry.py | 52 ++++++++ 9 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 hatch/installers/registry.py create mode 100644 tests/test_registry.py diff --git a/hatch/installers/__init__.py b/hatch/installers/__init__.py index 79d30fc..44365d4 100644 --- a/hatch/installers/__init__.py +++ b/hatch/installers/__init__.py @@ -6,9 +6,12 @@ """ from .installer_base import DependencyInstaller, InstallationError, InstallationContext +from .registry import InstallerRegistry, installer_registry __all__ = [ "DependencyInstaller", "InstallationError", - "InstallationContext" + "InstallationContext", + "InstallerRegistry", + "installer_registry" ] diff --git a/hatch/installers/docker_installer.py b/hatch/installers/docker_installer.py index b50fe6b..8a4afd4 100644 --- a/hatch/installers/docker_installer.py +++ b/hatch/installers/docker_installer.py @@ -538,4 +538,8 @@ def get_installation_info(self, dependency: Dict[str, Any], context: Installatio except Exception as e: info["error"] = str(e) - return info \ No newline at end of file + return info + +# Register this installer with the global registry +from .registry import installer_registry +installer_registry.register_installer("docker", DockerInstaller) \ No newline at end of file diff --git a/hatch/installers/hatch_installer.py b/hatch/installers/hatch_installer.py index cfe87ae..4705a3f 100644 --- a/hatch/installers/hatch_installer.py +++ b/hatch/installers/hatch_installer.py @@ -96,8 +96,7 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, target_dir = Path(context.environment_path) try: if progress_callback: - progress_callback("validate", 0.0, f"Validating {name}") - # Optionally, validate package metadata if local path is available + progress_callback("install", 0.0, f"Installing {name}-{version} from {uri}") # Download/install the package if uri and uri.startswith("file://"): pkg_path = Path(uri[7:]) @@ -175,3 +174,7 @@ def cleanup_failed_installation(self, dependency: Dict[str, Any], context: Insta shutil.rmtree(artifact) except Exception: pass + +# Register this installer with the global registry +from .registry import installer_registry +installer_registry.register_installer("hatch", HatchInstaller) diff --git a/hatch/installers/installer_base.py b/hatch/installers/installer_base.py index f7cd828..9792b0d 100644 --- a/hatch/installers/installer_base.py +++ b/hatch/installers/installer_base.py @@ -28,7 +28,7 @@ def __init__(self, message: str, dependency_name: Optional[str] = None, error_code (str, optional): Machine-readable error code. cause (Exception, optional): Underlying exception that caused this error. """ - super().__init__(message) + self.message = message self.dependency_name = dependency_name self.error_code = error_code self.cause = cause @@ -165,7 +165,7 @@ def get_installation_info(self, dependency: Dict[str, Any], context: Installatio "installer_type": self.installer_type, "dependency_name": dependency.get("name"), "resolved_version": dependency.get("resolved_version"), - "target_path": context.environment_path, + "target_path": str(context.environment_path), "supported": self.can_install(dependency) } diff --git a/hatch/installers/python_installer.py b/hatch/installers/python_installer.py index 6c49d03..864bd9b 100644 --- a/hatch/installers/python_installer.py +++ b/hatch/installers/python_installer.py @@ -358,3 +358,7 @@ def get_installation_info(self, dependency: Dict[str, Any], context: Installatio }) return info + +# Register this installer with the global registry +from .registry import installer_registry +installer_registry.register_installer("python", PythonInstaller) diff --git a/hatch/installers/registry.py b/hatch/installers/registry.py new file mode 100644 index 0000000..5a271a3 --- /dev/null +++ b/hatch/installers/registry.py @@ -0,0 +1,179 @@ +"""Installer registry for dependency installers. + +This module provides a centralized registry for mapping dependency types to their +corresponding installer implementations, enabling dynamic lookup and delegation +of installation operations. +""" + +import logging +from typing import Dict, Type, List, Optional, Any + +from .installer_base import DependencyInstaller + +logger = logging.getLogger("hatch.installer_registry") + + +class InstallerRegistry: + """Registry for dependency installers by type. + + This class provides a centralized mapping between dependency types and their + corresponding installer implementations. It enables the orchestrator to remain + agnostic to installer details while providing extensible installer management. + + The registry follows these principles: + - Single source of truth for installer-to-type mappings + - Dynamic registration and lookup + - Clear error handling for unsupported types + - Extensibility for future installer types + """ + + def __init__(self): + """Initialize the installer registry.""" + self._installers: Dict[str, Type[DependencyInstaller]] = {} + logger.debug("Initialized installer registry") + + def register_installer(self, dep_type: str, installer_cls: Type[DependencyInstaller]) -> None: + """Register an installer class for a dependency type. + + Args: + dep_type (str): The dependency type identifier (e.g., "hatch", "python", "docker"). + installer_cls (Type[DependencyInstaller]): The installer class to register. + + Raises: + ValueError: If the installer class does not implement DependencyInstaller. + TypeError: If the installer_cls is not a class or is None. + """ + if not isinstance(installer_cls, type): + raise TypeError(f"installer_cls must be a class, got {type(installer_cls)}") + + if not issubclass(installer_cls, DependencyInstaller): + raise ValueError(f"installer_cls must be a subclass of DependencyInstaller, got {installer_cls}") + + if dep_type in self._installers: + logger.warning(f"Overriding existing installer for type '{dep_type}': {self._installers[dep_type]} -> {installer_cls}") + + self._installers[dep_type] = installer_cls + logger.debug(f"Registered installer for type '{dep_type}': {installer_cls.__name__}") + + def get_installer(self, dep_type: str) -> DependencyInstaller: + """Get an installer instance for the given dependency type. + + Args: + dep_type (str): The dependency type to get an installer for. + + Returns: + DependencyInstaller: A new instance of the appropriate installer. + + Raises: + ValueError: If no installer is registered for the given dependency type. + """ + if dep_type not in self._installers: + available_types = list(self._installers.keys()) + raise ValueError( + f"No installer registered for dependency type '{dep_type}'. " + f"Available types: {available_types}" + ) + + installer_cls = self._installers[dep_type] + installer = installer_cls() + logger.debug(f"Created installer instance for type '{dep_type}': {installer_cls.__name__}") + return installer + + def can_install(self, dependency: Dict[str, Any]) -> bool: + """Check if the registry can handle the given dependency. + + This method first checks if an installer is registered for the dependency's + type, then delegates to the installer's can_install method for more + detailed validation. + + Args: + dependency (Dict[str, Any]): Dependency object to check. + + Returns: + bool: True if the dependency can be installed, False otherwise. + """ + dep_type = dependency.get("type") + if not dep_type or dep_type not in self._installers: + return False + + try: + installer = self.get_installer(dep_type) + return installer.can_install(dependency) + except Exception as e: + logger.warning(f"Error checking if dependency can be installed: {e}") + return False + + def get_registered_types(self) -> List[str]: + """Get a list of all registered dependency types. + + Returns: + List[str]: List of registered dependency type identifiers. + """ + return list(self._installers.keys()) + + def is_registered(self, dep_type: str) -> bool: + """Check if an installer is registered for the given type. + + Args: + dep_type (str): The dependency type to check. + + Returns: + bool: True if an installer is registered for the type, False otherwise. + """ + return dep_type in self._installers + + def unregister_installer(self, dep_type: str) -> Optional[Type[DependencyInstaller]]: + """Unregister an installer for the given dependency type. + + This method is primarily intended for testing and advanced use cases. + + Args: + dep_type (str): The dependency type to unregister. + + Returns: + Type[DependencyInstaller]: The unregistered installer class, or None if not found. + """ + installer_cls = self._installers.pop(dep_type, None) + if installer_cls: + logger.debug(f"Unregistered installer for type '{dep_type}': {installer_cls.__name__}") + return installer_cls + + def clear(self) -> None: + """Clear all registered installers. + + This method is primarily intended for testing purposes. + """ + self._installers.clear() + logger.debug("Cleared all registered installers") + + def __len__(self) -> int: + """Get the number of registered installers. + + Returns: + int: Number of registered installers. + """ + return len(self._installers) + + def __contains__(self, dep_type: str) -> bool: + """Check if a dependency type is registered. + + Args: + dep_type (str): The dependency type to check. + + Returns: + bool: True if the type is registered, False otherwise. + """ + return dep_type in self._installers + + def __repr__(self) -> str: + """Get a string representation of the registry. + + Returns: + str: String representation showing registered types. + """ + types = list(self._installers.keys()) + return f"InstallerRegistry(types={types})" + + +# Global singleton instance +installer_registry = InstallerRegistry() diff --git a/hatch/installers/system_installer.py b/hatch/installers/system_installer.py index 225c46b..30e77c3 100644 --- a/hatch/installers/system_installer.py +++ b/hatch/installers/system_installer.py @@ -612,3 +612,7 @@ def _simulate_uninstall(self, dependency: Dict[str, Any], context: InstallationC "automated": context.get_config("automated", False) } ) + +# Register this installer with the global registry +from .registry import installer_registry +installer_registry.register_installer("system", SystemInstaller) diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index f663323..34da9e7 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -85,6 +85,10 @@ docker_tests, docker_tests_integration ]) + elif len(sys.argv) > 1 and sys.argv[1] == "--registry-only": + # Run only installer registry tests + logger.info("Running installer registry tests only...") + test_suite = test_loader.loadTestsFromName("test_registry.TestInstallerRegistry") else: # Run all tests logger.info("Running all package environment tests...") diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..2f7eb5d --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,52 @@ +"""Basic test for installer registry functionality. + +This test verifies that all installers are properly registered and can be +retrieved from the registry. +""" + +import sys +from pathlib import Path +import unittest + +# Add the parent directory to the path so we can import hatch modules +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# It is mandatory to import the installer classes to ensure they are registered +from hatch.installers.hatch_installer import HatchInstaller +from hatch.installers.python_installer import PythonInstaller +from hatch.installers.system_installer import SystemInstaller +from hatch.installers.docker_installer import DockerInstaller +from hatch.installers import installer_registry, DependencyInstaller + + +class TestInstallerRegistry(unittest.TestCase): + """Test suite for the installer registry.""" + + def test_registered_types(self): + """Test that all expected installer types are registered.""" + registered_types = installer_registry.get_registered_types() + expected_types = ["hatch", "python", "system", "docker"] + for expected_type in expected_types: + self.assertIn(expected_type, registered_types, f"{expected_type} installer should be registered") + + def test_get_installer_instance(self): + """Test that the registry returns a valid installer instance for each type.""" + for dep_type in ["hatch", "python", "system", "docker"]: + installer = installer_registry.get_installer(dep_type) + self.assertIsInstance(installer, DependencyInstaller) + self.assertEqual(installer.installer_type, dep_type) + + def test_error_on_unknown_type(self): + """Test that requesting an unknown type raises ValueError.""" + with self.assertRaises(ValueError): + installer_registry.get_installer("unknown_type") + + def test_registry_repr_and_len(self): + """Test __repr__ and __len__ methods for coverage.""" + repr_str = repr(installer_registry) + self.assertIn("InstallerRegistry", repr_str) + self.assertGreaterEqual(len(installer_registry), 4) + + +if __name__ == "__main__": + unittest.main() From 7d1103d53c6cbd3dc500d8c130d4d13d34ac3b97 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 15:05:40 +0900 Subject: [PATCH 10/48] [Add] Dep Installation orchestrator **Major**: - Added the singleton `DependencyInstallerOrchestrator` to coordonate the retrieval of deps of a package, install all deps and the package itself. - Added tests to check the dummy orchestrator can install the dummy packages with all types of dependencies. --- hatch/environment_manager.py | 196 ++---- .../dependency_installation_orchestrator.py | 558 ++++++++++++++++++ tests/test_env_manip.py | 224 ++++++- 3 files changed, 823 insertions(+), 155 deletions(-) create mode 100644 hatch/installers/dependency_installation_orchestrator.py diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index e799dee..fa72bf6 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -9,15 +9,10 @@ from pathlib import Path from typing import Dict, List, Optional, Any, Tuple -from hatch_validator.package.package_service import PackageService from hatch_validator.registry.registry_service import RegistryService, RegistryError -from hatch_validator.utils.hatch_dependency_graph import HatchDependencyGraphBuilder -from hatch_validator.utils.dependency_graph import DependencyGraph -from hatch_validator.utils.version_utils import VersionConstraintValidator, VersionConstraintError -from hatch_validator.core.validation_context import ValidationContext from .registry_retriever import RegistryRetriever from .package_loader import HatchPackageLoader - +from .installers.dependency_installation_orchestrator import DependencyInstallerOrchestrator class HatchEnvironmentError(Exception): """Exception raised for environment-related errors.""" @@ -29,9 +24,9 @@ class HatchEnvironmentManager: This class handles: 1. Creating and managing isolated environments - 2. Adding packages to environments - 3. Resolving and managing dependencies using a DependencyResolver - 4. Installing packages with the HatchPackageLoader + 2. Environment lifecycle and state management + 3. Delegating package installation to the DependencyInstallerOrchestrator + 4. Managing environment metadata and persistence """ def __init__(self, environments_dir: Optional[Path] = None, @@ -71,7 +66,7 @@ def __init__(self, self._current_env_name = self._load_current_env_name() # Initialize dependencies - self.package_loader = HatchPackageLoader(cache_dir=cache_dir) # Get dependency resolver from imported module + self.package_loader = HatchPackageLoader(cache_dir=cache_dir) self.retriever = RegistryRetriever(cache_ttl=cache_ttl, local_cache_dir=cache_dir, simulation_mode=simulation_mode, @@ -80,7 +75,12 @@ def __init__(self, # Initialize services for dependency management self.registry_service = RegistryService(self.registry_data) - self.dependency_graph_builder = None # Will be initialized when needed + + self.dependency_orchestrator = DependencyInstallerOrchestrator( + package_loader=self.package_loader, + registry_service=self.registry_service, + registry_data=self.registry_data + ) def _initialize_environments_file(self): """Create the initial environments file with default environment.""" @@ -277,19 +277,13 @@ def add_package_to_environment(self, package_path_or_name: str, env_name: Optional[str] = None, version_constraint: Optional[str] = None, force_download: bool = False, - refresh_registry: bool = False) -> bool: + refresh_registry: bool = False, + auto_approve: bool = False) -> bool: """Add a package to an environment. - This complex method handles the process of adding either a local or remote package - to an environment, including dependency resolution and installation. It performs - the following steps: - 1. Determines if the package is local or remote - 2. Gets package metadata - 3. Builds a dependency graph for the package and returns ordered dependencies - 4. Compare against existing packages in the environment and retrieve missing dependencies - 5. Installs the package and its dependencies + This method delegates all installation orchestration to the DependencyInstallerOrchestrator + while maintaining responsibility for environment lifecycle and state management. - Args: package_path_or_name (str): Path to local package or name of remote package. env_name (str, optional): Environment to add to. Defaults to current environment. @@ -298,120 +292,59 @@ def add_package_to_environment(self, package_path_or_name: str, bypass the package cache and download directly from the source. Defaults to False. refresh_registry (bool, optional): Force refresh of registry data. When True, fetch the latest registry data before resolving dependencies. Defaults to False. + auto_approve (bool, optional): Skip user consent prompt for automation scenarios. Defaults to False. Returns: bool: True if successful, False otherwise. """ + env_name = env_name or self._current_env_name - # 1. Determine if the package is local or remote - root_pkg_type = "remote" - root_pkg_location = "" - path = Path(package_path_or_name) - if path.exists() and path.is_dir(): - root_pkg_type = "local" - root_pkg_location = str(path.resolve()) - # 2. Get package metadata for local package - metadata_path = path / "hatch_metadata.json" - with open(metadata_path, 'r') as f: - package_metadata = json.load(f) - - else: - # Assume it's a remote package - if not self.registry_service.package_exists(package_path_or_name): - self.logger.error(f"Package {package_path_or_name} does not exist in registry") - return False - - # 2. Get package metadata for remote package - try: - compatible_version = self.registry_service.find_compatible_version(package_path_or_name, version_constraint) - except VersionConstraintError as e: - self.logger.error(f"Version constraint error: {e}") - return False - - root_pkg_location = self.registry_service.get_package_uri(package_path_or_name, compatible_version) - path = self.package_loader.download_package(root_pkg_location, - package_path_or_name, - compatible_version, - force_download=force_download) - metadata_path = path / "hatch_metadata.json" - with open(metadata_path, 'r') as f: - package_metadata = json.load(f) - - # 3. Build dependency graph for the package - self.package_service = PackageService(package_metadata) - self.dependency_graph_builder = HatchDependencyGraphBuilder(self.package_service, self.registry_service) - context = ValidationContext(package_dir= path, - registry_data= self.registry_data, - allow_local_dependencies= True) + if not self.environment_exists(env_name): + self.logger.error(f"Environment {env_name} does not exist") + return False + + # Refresh registry if requested + if refresh_registry: + self.refresh_registry(force_refresh=True) try: - dependencies = self.dependency_graph_builder.get_install_ready_dependencies(context) + # Get currently installed packages for filtering + existing_packages = {} + for pkg in self._environments[env_name].get("packages", []): + existing_packages[pkg["name"]] = pkg["version"] + + # Delegate installation to orchestrator + success, installed_packages = self.dependency_orchestrator.install_dependencies( + package_path_or_name=package_path_or_name, + env_path=self.get_environment_path(env_name), + env_name=env_name, + existing_packages=existing_packages, + version_constraint=version_constraint, + force_download=force_download, + auto_approve=auto_approve + ) + + if success: + # Update environment metadata with installed packages + for pkg_info in installed_packages: + self._add_package_to_env_data( + env_name=env_name, + package_name=pkg_info["name"], + package_version=pkg_info["version"], + package_type=pkg_info["type"], + source=pkg_info["source"] + ) + + self.logger.info(f"Successfully installed {len(installed_packages)} packages to environment {env_name}") + return True + else: + self.logger.info("Package installation was cancelled or failed") + return False + except Exception as e: - self.logger.error(f"Error building dependency graph: {e}") + self.logger.error(f"Failed to add package to environment: {e}") return False - # 4. Compare against existing packages in the environment and retrieve missing dependencies - env_name = env_name or self._current_env_name - missing_dependencies = self._filter_for_missing_dependencies(dependencies, env_name) - - # 5. Install the package and its dependencies - self.package_loader.install_local_package(path, self.get_environment_path(env_name) / env_name, self.package_service.get_field("name")) - self._add_package_to_env_data(env_name, - self.package_service.get_field("name"), - self.package_service.get_field("version"), - root_pkg_type, - root_pkg_location) - for dep in missing_dependencies: - location = missing_dependencies[dep].get("uri") - if location[:7] == "file://": - # Local dependency - dep_path = Path(location[7:]) - # Install local package - self.package_loader.install_local_package(dep_path, self.get_environment_path(env_name), dep["name"]) - self._add_package_to_env_data(env_name, - dep["name"], - dep["resolved_version"], - "local", - location[7:]) - else: - # Remote dependency - self.package_loader.install_remote_package(location, dep["name"], dep["resolved_version"], self.get_environment_path(env_name)) - self._add_package_to_env_data(env_name, - dep["name"], - dep["resolved_version"], - "remote", - location) - - return True - - def _filter_for_missing_dependencies(self, dependencies: List[Dict], env_name: str) -> List[Dict]: - """Determine which dependencies are not installed in the environment.""" - if not self.environment_exists(env_name): - raise HatchEnvironmentError(f"Environment {env_name} does not exist") - - # Get currently installed packages - installed_packages = {} - for pkg in self._environments[env_name].get("packages", []): - installed_packages[pkg["name"]] = pkg["version"] - - # Find missing dependencies - missing_deps = [] - for dep in dependencies: - dep_name = dep.get("name") - if dep_name not in installed_packages: - missing_deps.append(dep) - continue - - # Check version constraints - constraint = dep.get("version_constraint") - if constraint: - is_compatible, _ = VersionConstraintValidator.is_version_compatible( - installed_packages[dep_name], constraint) - if not is_compatible: - missing_deps.append(dep) - - return missing_deps - def _add_package_to_env_data(self, env_name: str, package_name: str, package_version: str, package_type: str, source: str) -> None: @@ -575,13 +508,7 @@ def refresh_registry(self, force_refresh: bool = True) -> None: This method forces a refresh of the registry data to ensure the environment manager has the most recent package information available. After refreshing, it updates the - associated validators and resolvers to use the new registry data. - - The refresh process follows these steps: - 1. Fetch the latest registry data from the configured source - 2. Update the internal registry_data cache - 3. Recreate the package validator with the new data - 4. Update the dependency resolver reference + orchestrator and associated services to use the new registry data. Args: force_refresh (bool, optional): Force refresh the registry even if cache is valid. @@ -596,6 +523,11 @@ def refresh_registry(self, force_refresh: bool = True) -> None: self.registry_data = self.retriever.get_registry(force_refresh=force_refresh) # Update registry service with new registry data self.registry_service = RegistryService(self.registry_data) + + # Update orchestrator with new registry data + self.dependency_orchestrator.registry_service = self.registry_service + self.dependency_orchestrator.registry_data = self.registry_data + self.logger.info("Registry data refreshed successfully") except Exception as e: self.logger.error(f"Failed to refresh registry data: {e}") diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py new file mode 100644 index 0000000..a426788 --- /dev/null +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -0,0 +1,558 @@ +"""Dependency installation orchestrator for coordinating package installation. + +This module provides centralized orchestration for all dependency installation +across different dependency types, with centralized user consent management +and delegation to specific installers. +""" + +import json +import logging +import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any, Tuple + +from hatch_validator.package.package_service import PackageService +from hatch_validator.registry.registry_service import RegistryService +from hatch_validator.utils.hatch_dependency_graph import HatchDependencyGraphBuilder +from hatch_validator.utils.version_utils import VersionConstraintValidator, VersionConstraintError +from hatch_validator.core.validation_context import ValidationContext + +from hatch.package_loader import HatchPackageLoader + + +# Mandatory to insure the installers are registered in the singleton `installer_registry` correctly at import time +from hatch.installers.hatch_installer import HatchInstaller +from hatch.installers.python_installer import PythonInstaller +from hatch.installers.system_installer import SystemInstaller +from hatch.installers.docker_installer import DockerInstaller + +from hatch.installers.registry import installer_registry +from hatch.installers.installer_base import InstallationError +from hatch.installers.installation_context import InstallationContext, InstallationStatus + + +class DependencyInstallationError(Exception): + """Exception raised for dependency installation-related errors.""" + pass + + +class DependencyInstallerOrchestrator: + """Orchestrates dependency installation across all supported dependency types. + + This class coordinates the installation of dependencies by: + 1. Resolving all dependencies for a given package using the validator + 2. Aggregating installation plans across all dependency types + 3. Managing centralized user consent + 4. Delegating to appropriate installers via the registry + 5. Handling installation order and error recovery + + The orchestrator strictly uses PackageService for all metadata access to ensure + compatibility across different package schema versions. + """ + + def __init__(self, + package_loader: HatchPackageLoader, + registry_service: RegistryService, + registry_data: Dict[str, Any]): + """Initialize the dependency installation orchestrator. + + Args: + package_loader (HatchPackageLoader): Package loader for file operations. + registry_service (RegistryService): Service for registry operations. + registry_data (Dict[str, Any]): Registry data for dependency resolution. + """ + self.logger = logging.getLogger("hatch.dependency_orchestrator") + self.package_loader = package_loader + self.registry_service = registry_service + self.registry_data = registry_data + + # These will be set during package resolution + self.package_service: Optional[PackageService] = None + self.dependency_graph_builder: Optional[HatchDependencyGraphBuilder] = None + self._resolved_package_path: Optional[Path] = None + self._resolved_package_type: Optional[str] = None + self._resolved_package_location: Optional[str] = None + + def install_dependencies(self, + package_path_or_name: str, + env_path: Path, + env_name: str, + existing_packages: Dict[str, str], + version_constraint: Optional[str] = None, + force_download: bool = False, + auto_approve: bool = False) -> Tuple[bool, List[Dict[str, Any]]]: + """Install all dependencies for a package with centralized consent management. + + This method orchestrates the complete dependency installation process by + leveraging existing validator components and the installer registry. It handles + all dependency types (hatch, python, system, docker) and provides centralized + user consent management. + + Args: + package_path_or_name (str): Path to local package or name of remote package. + env_path (Path): Path to the environment directory. + env_name (str): Name of the environment. + existing_packages (Dict[str, str]): Currently installed packages {name: version}. + version_constraint (str, optional): Version constraint for remote packages. Defaults to None. + force_download (bool, optional): Force download even if package is cached. Defaults to False. + auto_approve (bool, optional): Skip user consent prompt for automation. Defaults to False. + + Returns: + Tuple[bool, List[Dict[str, Any]]]: Success status and list of installed packages. + + Raises: + DependencyInstallationError: If installation fails at any stage. + """ + try: + # Step 1: Resolve package and load metadata using PackageService + self._resolve_and_load_package(package_path_or_name, version_constraint, force_download) + + # Step 2: Get all dependencies organized by type + dependencies_by_type = self._get_all_dependencies() + + # Step 3: Filter for missing dependencies by type and track satisfied ones + missing_dependencies_by_type, satisfied_dependencies_by_type = self._filter_missing_dependencies_by_type(dependencies_by_type, existing_packages) + + # Step 4: Aggregate installation plan + install_plan = self._aggregate_install_plan(missing_dependencies_by_type, satisfied_dependencies_by_type) + + # Step 5: Print installation summary for user review + self._print_installation_summary(install_plan) + + # Step 6: Request user consent + if not auto_approve: + if not self._request_user_consent(install_plan): + self.logger.info("Installation cancelled by user") + return False, [] + else: + self.logger.warning("Auto-approval enabled, proceeding with installation without user consent") + + # Step 7: Execute installation plan using installer registry + installed_packages = self._execute_install_plan(install_plan, env_path, env_name) + + return True, installed_packages + + except Exception as e: + self.logger.error(f"Dependency installation failed: {e}") + raise DependencyInstallationError(f"Installation failed: {e}") from e + + def _resolve_and_load_package(self, + package_path_or_name: str, + version_constraint: Optional[str] = None, + force_download: bool = False) -> None: + """Resolve package information and load metadata using PackageService. + + Args: + package_path_or_name (str): Path to local package or name of remote package. + version_constraint (str, optional): Version constraint for remote packages. + force_download (bool, optional): Force download even if package is cached. + + Raises: + DependencyInstallationError: If package cannot be resolved or loaded. + """ + path = Path(package_path_or_name) + + if path.exists() and path.is_dir(): + # Local package + metadata_path = path / "hatch_metadata.json" + if not metadata_path.exists(): + raise DependencyInstallationError(f"Local package missing hatch_metadata.json: {path}") + + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + self._resolved_package_path = path + self._resolved_package_type = "local" + self._resolved_package_location = str(path.resolve()) + + else: + # Remote package + if not self.registry_service.package_exists(package_path_or_name): + raise DependencyInstallationError(f"Package {package_path_or_name} does not exist in registry") + + try: + compatible_version = self.registry_service.find_compatible_version( + package_path_or_name, version_constraint) + except VersionConstraintError as e: + raise DependencyInstallationError(f"Version constraint error: {e}") from e + + location = self.registry_service.get_package_uri(package_path_or_name, compatible_version) + downloaded_path = self.package_loader.download_package( + location, package_path_or_name, compatible_version, force_download=force_download) + + metadata_path = downloaded_path / "hatch_metadata.json" + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + self._resolved_package_path = downloaded_path + self._resolved_package_type = "remote" + self._resolved_package_location = location + + # Load metadata using PackageService for schema-aware access + self.package_service = PackageService(metadata) + if not self.package_service.is_loaded(): + raise DependencyInstallationError("Failed to load package metadata") + + def _get_install_ready_hatch_dependencies(self) -> List[Dict[str, Any]]: + """Get install-ready Hatch dependencies using validator components. + + This method only processes Hatch package dependencies, not python, system, or docker. + + Returns: + List[Dict[str, Any]]: List of install-ready Hatch dependencies. + + Raises: + DependencyInstallationError: If dependency resolution fails. + """ + try: + # Use validator components for Hatch dependency resolution + self.dependency_graph_builder = HatchDependencyGraphBuilder( + self.package_service, self.registry_service) + + context = ValidationContext( + package_dir=self._resolved_package_path, + registry_data=self.registry_data, + allow_local_dependencies=True + ) + + # This only returns Hatch dependencies in install order + hatch_dependencies = self.dependency_graph_builder.get_install_ready_dependencies(context) + return hatch_dependencies + + except Exception as e: + raise DependencyInstallationError(f"Error building Hatch dependency graph: {e}") from e + + def _get_all_dependencies(self) -> Dict[str, List[Dict[str, Any]]]: + """Get all dependencies from package metadata organized by type. + + Returns: + Dict[str, List[Dict[str, Any]]]: Dependencies organized by type (hatch, python, system, docker). + + Raises: + DependencyInstallationError: If dependency extraction fails. + """ + try: + # Get all dependencies using PackageService + all_deps = self.package_service.get_dependencies() + + dependencies_by_type = { + "hatch": [], + "python": [], + "system": [], + "docker": [] + } + + # Get Hatch dependencies using validator (properly ordered) + dependencies_by_type["hatch"] = self._get_install_ready_hatch_dependencies() + # Adding the type information to each Hatch dependency + for dep in dependencies_by_type["hatch"]: + dep["type"] = "hatch" + + # Get other dependency types directly from PackageService + for dep_type in ["python", "system", "docker"]: + raw_deps = all_deps.get(dep_type, []) + for dep in raw_deps: + + # Add type information and ensure required fields + dep_with_type = dep.copy() + dep_with_type["type"] = dep_type + if not installer_registry.can_install(dep_type, dep_with_type): + raise DependencyInstallationError( + f"No registered installer can handle dependency with type '{dep_type}': {dep_with_type}" + ) + + dependencies_by_type[dep_type].append(dep_with_type) + + return dependencies_by_type + + except Exception as e: + raise DependencyInstallationError(f"Error extracting dependencies: {e}") from e + + def _filter_missing_dependencies_by_type(self, + dependencies_by_type: Dict[str, List[Dict[str, Any]]], + existing_packages: Dict[str, str]) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, List[Dict[str, Any]]]]: + """Filter dependencies by type to find those not already installed and track satisfied ones. + + For non-Hatch dependencies, we always include them in missing list as the third-party + package manager will handle version checking and installation. + + Args: + dependencies_by_type (Dict[str, List[Dict[str, Any]]]): All dependencies organized by type. + existing_packages (Dict[str, str]): Currently installed packages {name: version}. + + Returns: + Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, List[Dict[str, Any]]]]: + (missing_dependencies_by_type, satisfied_dependencies_by_type) + """ + missing_deps_by_type = {} + satisfied_deps_by_type = {} + + for dep_type, dependencies in dependencies_by_type.items(): + missing_deps = [] + satisfied_deps = [] + + for dep in dependencies: + dep_name = dep.get("name") + + # For non-Hatch dependencies, always consider them as needing installation + # as the third-party package manager will handle version compatibility + if dep_type != "hatch": + missing_deps.append(dep) + continue + + # Hatch dependency processing + if dep_name not in existing_packages: + missing_deps.append(dep) + continue + + # Check version constraints for Hatch dependencies + constraint = dep.get("version_constraint") + installed_version = existing_packages[dep_name] + + if constraint: + is_compatible, compatibility_msg = VersionConstraintValidator.is_version_compatible( + installed_version, constraint) + if not is_compatible: + missing_deps.append(dep) + else: + # Add satisfied dependency with installation info + satisfied_dep = dep.copy() + satisfied_dep["installed_version"] = installed_version + satisfied_dep["compatibility_status"] = compatibility_msg + satisfied_deps.append(satisfied_dep) + else: + # No constraint specified, any installed version satisfies + satisfied_dep = dep.copy() + satisfied_dep["installed_version"] = installed_version + satisfied_dep["compatibility_status"] = "No version constraint specified" + satisfied_deps.append(satisfied_dep) + + missing_deps_by_type[dep_type] = missing_deps + satisfied_deps_by_type[dep_type] = satisfied_deps + + return missing_deps_by_type, satisfied_deps_by_type + + def _aggregate_install_plan(self, + missing_dependencies_by_type: Dict[str, List[Dict[str, Any]]], + satisfied_dependencies_by_type: Dict[str, List[Dict[str, Any]]]) -> Dict[str, Any]: + """Aggregate installation plan across all dependency types. + + Args: + missing_dependencies_by_type (Dict[str, List[Dict[str, Any]]]): Missing dependencies by type. + satisfied_dependencies_by_type (Dict[str, List[Dict[str, Any]]]): Already satisfied dependencies by type. + + Returns: + Dict[str, Any]: Complete installation plan with dependencies grouped by type. + """ + # Use PackageService for all metadata access + plan = { + "main_package": { + "name": self.package_service.get_field("name"), + "version": self.package_service.get_field("version"), + "type": self._resolved_package_type, + "location": self._resolved_package_location + }, + "dependencies_to_install": missing_dependencies_by_type, + "dependencies_satisfied": satisfied_dependencies_by_type, + "total_to_install": 1 + sum(len(deps) for deps in missing_dependencies_by_type.values()), + "total_satisfied": sum(len(deps) for deps in satisfied_dependencies_by_type.values()) + } + + return plan + + def _print_installation_summary(self, install_plan: Dict[str, Any]) -> None: + """Print a summary of the installation plan for user review. + + Args: + install_plan (Dict[str, Any]): Complete installation plan. + """ + print("\n" + "="*60) + print("DEPENDENCY INSTALLATION PLAN") + print("="*60) + + main_pkg = install_plan['main_package'] + print(f"Main Package: {main_pkg['name']} v{main_pkg['version']}") + print(f"Package Type: {main_pkg['type']}") + + # Show satisfied dependencies first + total_satisfied = install_plan.get("total_satisfied", 0) + if total_satisfied > 0: + print(f"\nDependencies already satisfied: {total_satisfied}") + + for dep_type, deps in install_plan.get("dependencies_satisfied", {}).items(): + if deps: + print(f"\n{dep_type.title()} Dependencies (Satisfied):") + for dep in deps: + installed_version = dep.get("installed_version", "unknown") + constraint = dep.get("version_constraint", "any") + compatibility = dep.get("compatibility_status", "") + print(f" ✓ {dep['name']} {constraint} (installed: {installed_version})") + if compatibility and compatibility != "No version constraint specified": + print(f" {compatibility}") + + # Show dependencies to install + total_to_install = sum(len(deps) for deps in install_plan.get("dependencies_to_install", {}).values()) + if total_to_install > 0: + print(f"\nDependencies to install: {total_to_install}") + + for dep_type, deps in install_plan.get("dependencies_to_install", {}).items(): + if deps: + print(f"\n{dep_type.title()} Dependencies (To Install):") + for dep in deps: + constraint = dep.get("version_constraint", "any") + print(f" → {dep['name']} {constraint}") + else: + print("\nNo additional dependencies to install.") + + print(f"\nTotal packages to install: {install_plan.get('total_to_install', 1)}") + if total_satisfied > 0: + print(f"Total dependencies already satisfied: {total_satisfied}") + print("="*60) + + def _request_user_consent(self, install_plan: Dict[str, Any]) -> bool: + """Request user consent for the installation plan. + + Args: + install_plan (Dict[str, Any]): Complete installation plan. + + Returns: + bool: True if user approves, False otherwise. + """ + # Request confirmation + while True: + response = input("\nProceed with installation? [y/N]: ").strip().lower() + if response in ['y', 'yes']: + return True + elif response in ['n', 'no', '']: + return False + else: + print("Please enter 'y' for yes or 'n' for no.") + + def _execute_install_plan(self, + install_plan: Dict[str, Any], + env_path: Path, + env_name: str) -> List[Dict[str, Any]]: + """Execute the installation plan using the installer registry. + + Args: + install_plan (Dict[str, Any]): Installation plan to execute. + env_path (Path): Environment path for installation. + env_name (str): Environment name. + + Returns: + List[Dict[str, Any]]: List of successfully installed packages. + + Raises: + DependencyInstallationError: If installation fails. + """ + installed_packages = [] + + # Create comprehensive installation context + context = InstallationContext( + environment_path=env_path / env_name, + environment_name=env_name, + temp_dir=env_path / env_name / ".tmp", + cache_dir=self.package_loader.cache_dir if hasattr(self.package_loader, 'cache_dir') else None, + parallel_enabled=False, # Future enhancement + force_reinstall=False, # Future enhancement + simulation_mode=False, # Future enhancement + extra_config={ + "package_loader": self.package_loader, + "registry_service": self.registry_service, + "registry_data": self.registry_data, + "main_package_path": self._resolved_package_path, + "main_package_type": self._resolved_package_type + } + ) + + try: + # Install dependencies by type using appropriate installers + for dep_type, dependencies in install_plan["dependencies_to_install"].items(): + if not dependencies: + continue + + if not installer_registry.is_registered(dep_type): + self.logger.warning(f"No installer registered for dependency type: {dep_type}") + continue + + installer = installer_registry.get_installer(dep_type) + + for dep in dependencies: + try: + result = installer.install(dep, context) + if result.status == InstallationStatus.COMPLETED: + installed_packages.append({ + "name": dep["name"], + "version": dep.get("resolved_version", dep.get("version")), + "type": dep_type, + "source": dep.get("uri", "unknown") + }) + self.logger.info(f"Successfully installed {dep_type} dependency: {dep['name']}") + else: + raise DependencyInstallationError(f"Failed to install {dep['name']}: {result.error_message}") + + except InstallationError as e: + self.logger.error(f"Installation error for {dep_type} dependency {dep['name']}: {e.error_code}\n{e.message}") + raise DependencyInstallationError(f"Installation error for {dep['name']}: {e}") from e + + except Exception as e: + self.logger.error(f"Error installing {dep_type} dependency {dep['name']}: {e}") + raise DependencyInstallationError(f"Error installing {dep['name']}: {e}") from e + + # Install main package last + main_pkg_info = self._install_main_package(context) + installed_packages.append(main_pkg_info) + + return installed_packages + + except Exception as e: + self.logger.error(f"Installation execution failed: {e}") + raise DependencyInstallationError(f"Installation execution failed: {e}") from e + + def _install_main_package(self, context: InstallationContext) -> Dict[str, Any]: + """Install the main package using package_loader directly. + + The main package installation bypasses the installer registry and uses + the package_loader directly since it's not a dependency but the primary package. + + Args: + context (InstallationContext): Installation context. + + Returns: + Dict[str, Any]: Installed package information. + + Raises: + DependencyInstallationError: If main package installation fails. + """ + try: + # Get package information using PackageService + package_name = self.package_service.get_field("name") + package_version = self.package_service.get_field("version") + + # Install using package_loader directly + if self._resolved_package_type == "local": + # For local packages, install from resolved path + installed_path = self.package_loader.install_local_package( + source_path=self._resolved_package_path, + target_dir=context.environment_path, + package_name=package_name + ) + else: + # For remote packages, install from downloaded path + installed_path = self.package_loader.install_local_package( + source_path=self._resolved_package_path, # Downloaded path + target_dir=context.environment_path, + package_name=package_name + ) + + self.logger.info(f"Successfully installed main package {package_name} to {installed_path}") + + return { + "name": package_name, + "version": package_version, + "type": self._resolved_package_type, + "source": self._resolved_package_location + } + + except Exception as e: + raise DependencyInstallationError(f"Failed to install main package: {e}") from e diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index 131bc48..023150b 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -195,7 +195,8 @@ def test_add_local_package(self): # Add package to environment result = self.env_manager.add_package_to_environment( str(pkg_path), # Convert to string to handle Path objects - "test_env" + "test_env", + auto_approve=True # Auto-approve for testing ) self.assertTrue(result, "Failed to add local package to environment") @@ -224,7 +225,8 @@ def test_add_package_with_dependencies(self): result = self.env_manager.add_package_to_environment( str(base_pkg_path), - "test_env" + "test_env", + auto_approve=True # Auto-approve for testing ) self.assertTrue(result, "Failed to add base package to environment") @@ -235,7 +237,8 @@ def test_add_package_with_dependencies(self): # Add package to environment result = self.env_manager.add_package_to_environment( str(pkg_path), - "test_env" + "test_env", + auto_approve=True # Auto-approve for testing ) self.assertTrue(result, "Failed to add package with dependencies") @@ -252,46 +255,221 @@ def test_add_package_with_dependencies(self): self.assertIn("base_pkg_1", package_names, "Base package missing from environment") self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") - def test_add_complex_dependencies(self): - """Test adding a package with complex dependencies.""" + def test_add_package_with_some_dependencies_already_present(self): + """Test adding a package where some dependencies are already present and others are not.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment") - # First add all the base packages that are dependencies - for base_pkg in ["base_pkg_1", "base_pkg_2", "python_dep_pkg"]: - pkg_path = self.hatch_dev_path / base_pkg - self.assertTrue(pkg_path.exists(), f"Base package not found: {pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(pkg_path), - "test_env" - ) - self.assertTrue(result, f"Failed to add base package: {base_pkg}") + # First add only one of the dependencies that complex_dep_pkg needs + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Verify base_pkg_1 is in the environment + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 1, "Base package not added correctly") + self.assertEqual(packages[0]["name"], "base_pkg_1", "Wrong package added") - # Now add the complex dependency package + # Now add complex_dep_pkg which depends on base_pkg_1, base_pkg_2 + # base_pkg_1 should be satisfied, base_pkg_2 should need installation complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") result = self.env_manager.add_package_to_environment( str(complex_pkg_path), - "test_env" + "test_env", + auto_approve=True # Auto-approve for testing ) - # This should succeed because all dependencies are satisfied - self.assertTrue(result, "Failed to add package with complex dependencies") + self.assertTrue(result, "Failed to add package with mixed dependency states") - # Verify all packages are in the environment + # Verify all required packages are now in the environment env_data = self.env_manager.get_environments().get("test_env") - self.assertIsNotNone(env_data, "Environment data not found") + packages = env_data.get("packages", []) + + # Should have base_pkg_1 (already present), base_pkg_2, and complex_dep_pkg + expected_packages = ["base_pkg_1", "base_pkg_2", "complex_dep_pkg"] + package_names = [pkg["name"] for pkg in packages] + + for pkg_name in expected_packages: + self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") + + def test_add_package_with_all_dependencies_already_present(self): + """Test adding a package where all dependencies are already present.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment") + + # First add all dependencies that simple_dep_pkg needs + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Verify base package is installed + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 1, "Base package not added correctly") + + # Now add simple_dep_pkg which only depends on base_pkg_1 (which is already present) + simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" + self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(simple_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add package with all dependencies satisfied") + + # Verify both packages are in the environment - no new dependencies should be added + env_data = self.env_manager.get_environments().get("test_env") packages = env_data.get("packages", []) - # Check all expected packages are there - expected_packages = ["base_pkg_1", "base_pkg_2", "python_dep_pkg", "complex_dep_pkg"] + # Should have base_pkg_1 (already present) and simple_dep_pkg (newly added) + expected_packages = ["base_pkg_1", "simple_dep_pkg"] package_names = [pkg["name"] for pkg in packages] + self.assertEqual(len(packages), 2, "Unexpected number of packages in environment") for pkg_name in expected_packages: self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") + + def test_add_package_with_version_constraint_satisfaction(self): + """Test adding a package with version constraints where dependencies are satisfied.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment") + + # Add base_pkg_1 with a specific version + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Look for a package that has version constraints to test against + # For now, we'll simulate this by trying to add another package that depends on base_pkg_1 + simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" + self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(simple_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with version constraint dependencies") + + # Verify packages are correctly installed + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + + self.assertIn("base_pkg_1", package_names, "Base package missing from environment") + self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") + + def test_add_package_with_mixed_dependency_types(self): + """Test adding a package with mixed hatch and python dependencies.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment") + + # Add a package that has both hatch and python dependencies + python_dep_pkg_path = self.hatch_dev_path / "python_dep_pkg" + self.assertTrue(python_dep_pkg_path.exists(), f"Python dependency package not found: {python_dep_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(python_dep_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with mixed dependency types") + + # Verify package was added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + + self.assertIn("python_dep_pkg", package_names, "Package with mixed dependencies missing from environment") + + # Now add a package that depends on the python_dep_pkg (should be satisfied) + # and also depends on other packages (should need installation) + complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" + self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(complex_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with mixed satisfied/unsatisfied dependencies") + + # Verify all expected packages are present + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + + # Should have python_dep_pkg (already present) plus any other dependencies of complex_dep_pkg + self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") + self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") + + def test_add_package_with_system_dependency(self): + """Test adding a package with a system dependency.""" + self.env_manager.create_environment("test_env", "Test environment") + + # Add a package that declares a system dependency (e.g., 'curl') + system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" + self.assertTrue(system_dep_pkg_path.exists(), f"System dependency package not found: {system_dep_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(system_dep_pkg_path), + "test_env", + auto_approve=True + ) + self.assertTrue(result, "Failed to add package with system dependency") + + # Verify package was added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + self.assertIn("system_dep_pkg", package_names, "System dependency package missing from environment") + + def test_add_package_with_docker_dependency(self): + """Test adding a package with a docker dependency.""" + self.env_manager.create_environment("test_env", "Test environment") + + # Add a package that declares a docker dependency (e.g., 'redis:latest') + docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" + self.assertTrue(docker_dep_pkg_path.exists(), f"Docker dependency package not found: {docker_dep_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(docker_dep_pkg_path), + "test_env", + auto_approve=True + ) + self.assertTrue(result, "Failed to add package with docker dependency") + + # Verify package was added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + self.assertIn("docker_dep_pkg", package_names, "Docker dependency package missing from environment") if __name__ == "__main__": unittest.main() From 3e76ff973aef8e5356f2959f65a6ca87efd48b5c Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 16:35:22 +0900 Subject: [PATCH 11/48] [Fix] Environment name argument value **Major**: - Was passing `environment_path=env_path / env_name` in `InstallationContext` of `DependencyInstallerOrchestrator` - But later parts of the code already supports adding `/ env_name` so that was building paths with two `env_name` directories --- hatch/installers/dependency_installation_orchestrator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py index a426788..d84659a 100644 --- a/hatch/installers/dependency_installation_orchestrator.py +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -449,9 +449,9 @@ def _execute_install_plan(self, # Create comprehensive installation context context = InstallationContext( - environment_path=env_path / env_name, + environment_path=env_path, environment_name=env_name, - temp_dir=env_path / env_name / ".tmp", + temp_dir=env_path / ".tmp", cache_dir=self.package_loader.cache_dir if hasattr(self.package_loader, 'cache_dir') else None, parallel_enabled=False, # Future enhancement force_reinstall=False, # Future enhancement From 7809d8caea63000650a86c53245f912890963e52 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 16:38:02 +0900 Subject: [PATCH 12/48] [Fix] Use the enum field rather than associated value **Major**: - Was returning `status="COMPLETED"` in `InstallationResult` of `HatchInstaller` instead of the enum symbol `InstallationStatus.COMPLETED` --- hatch/installers/hatch_installer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/hatch/installers/hatch_installer.py b/hatch/installers/hatch_installer.py index 4705a3f..33c64b6 100644 --- a/hatch/installers/hatch_installer.py +++ b/hatch/installers/hatch_installer.py @@ -9,10 +9,10 @@ from pathlib import Path from typing import Dict, Any, Optional, Callable, List -from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError +from hatch.installers.installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError +from hatch.installers.installation_context import InstallationStatus from hatch.package_loader import HatchPackageLoader, PackageLoaderError from hatch_validator.package_validator import HatchPackageValidator -from hatch_validator.package.package_service import PackageService class HatchInstaller(DependencyInstaller): """Installer for Hatch package dependencies. @@ -105,11 +105,13 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, result_path = self.package_loader.install_remote_package(uri, name, version, target_dir) else: raise InstallationError(f"No URI provided for dependency {name}", dependency_name=name) + if progress_callback: progress_callback("install", 1.0, f"Installed {name} to {result_path}") + return InstallationResult( dependency_name=name, - status="COMPLETED", + status=InstallationStatus.COMPLETED, installed_path=result_path, installed_version=version, error_message=None, @@ -144,7 +146,7 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, progress_callback("uninstall", 1.0, f"Uninstalled {name}") return InstallationResult( dependency_name=name, - status="COMPLETED", + status=InstallationStatus.COMPLETED, installed_path=target_dir, installed_version=dependency.get("resolved_version"), error_message=None, From dd6db1ca4d81a057f54bbaf7606521523eeb29ef Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 16:40:18 +0900 Subject: [PATCH 13/48] [Update] Add validity check before hatch dep install **Major**: - Had forgotten to use `self.validate_dependency(dependency)` at the top of the `install` and `uninstall` functions. --- hatch/installers/hatch_installer.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/hatch/installers/hatch_installer.py b/hatch/installers/hatch_installer.py index 33c64b6..25725b6 100644 --- a/hatch/installers/hatch_installer.py +++ b/hatch/installers/hatch_installer.py @@ -90,6 +90,15 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, Raises: InstallationError: If installation fails for any reason. """ + + self.logger.debug(f"Installing Hatch dependency: {dependency}") + if not self.validate_dependency(dependency): + self.logger.error(f"Invalid dependency format: {dependency}") + raise InstallationError("Invalid dependency object", + dependency_name=dependency.get("name"), + error_code="INVALID_HATCH_DEPENDENCY_FORMAT", + ) + name = dependency["name"] version = dependency["resolved_version"] uri = dependency["uri"] @@ -118,6 +127,7 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, artifacts=result_path, metadata={"name": name, "version": version} ) + except (PackageLoaderError, Exception) as e: self.logger.error(f"Failed to install {name}: {e}") raise InstallationError(f"Failed to install {name}: {e}", dependency_name=name, cause=e) @@ -137,6 +147,12 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, Raises: InstallationError: If uninstall fails for any reason. """ + if not self.validate_dependency(dependency): + raise InstallationError("Invalid dependency object", + dependency_name=dependency.get("name"), + error_code="INVALID_HATCH_DEPENDENCY_FORMAT", + ) + name = dependency["name"] target_dir = Path(context.environment_path) / name try: From 4c0939ab49ac490a0e84dd5b849bba7bcfdfd028 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 16:52:54 +0900 Subject: [PATCH 14/48] [Update] `can_handle(...)` of installer registry **Major**: - Added an argument `dep_type` to facilitate passing this information when `can_handle` is used. --- hatch/installers/registry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hatch/installers/registry.py b/hatch/installers/registry.py index 5a271a3..574b20c 100644 --- a/hatch/installers/registry.py +++ b/hatch/installers/registry.py @@ -79,7 +79,7 @@ def get_installer(self, dep_type: str) -> DependencyInstaller: logger.debug(f"Created installer instance for type '{dep_type}': {installer_cls.__name__}") return installer - def can_install(self, dependency: Dict[str, Any]) -> bool: + def can_install(self, dep_type: str, dependency: Dict[str, Any]) -> bool: """Check if the registry can handle the given dependency. This method first checks if an installer is registered for the dependency's @@ -92,8 +92,8 @@ def can_install(self, dependency: Dict[str, Any]) -> bool: Returns: bool: True if the dependency can be installed, False otherwise. """ - dep_type = dependency.get("type") - if not dep_type or dep_type not in self._installers: + if dep_type not in self._installers: + logger.error(f"No installer registered for dependency type '{dep_type}'") return False try: From 13eeb4418bc7f9f84726e9d10cbecef97f3e31c7 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 17:12:36 +0900 Subject: [PATCH 15/48] [Update] Tests **Major**: - `test_env_manip.py` - Skipping `test_add_package_with_system_dependency` is system is Windows - `test_hatch_installer.py` - Asserts use `InstallationStatus.COMPLETED` instead of "COMPLETED" as this was updated in 7809d8caea63000650a86c53245f912890963e52 - `test_installer_base.py` - Fixed wrong attempt to `str(error)` to using `error.message` - Honestly, this test is not so relevant... - `test_installer_base.py` - Must convert the `Path` with `str()` to be able to compare the values in assert as expected - `test_online_package_loader.py` - Adapted the value of the environment directory to test packages presence as expected - Add the `auto_approve=True` to `add_package_to_environment` to skip user consent - `test_system_installer.py` - skipping `test_verify_installation_success`, `test_simulate_installation_success`, `test_simulate_curl_installation`, `test_install_real_dependency` on Windows **Minor**: - `test_env_manip.py` - Made better use of `HatchEnvironmentManager` initialization arguments rather than changing the member variables manually. - `test_online_package_loader.py` - separated `version` from `version_control` to assert things as expected --- tests/test_env_manip.py | 11 +++-- tests/test_hatch_installer.py | 6 ++- tests/test_installer_base.py | 4 +- tests/test_online_package_loader.py | 70 ++++++++++++++++------------- tests/test_system_installer.py | 6 ++- 5 files changed, 54 insertions(+), 43 deletions(-) diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index 023150b..3b6bf4e 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -41,12 +41,10 @@ def setUp(self): env_dir.mkdir(exist_ok=True) # Create environment manager for testing with isolated test directories - self.env_manager = HatchEnvironmentManager(simulation_mode=True, local_registry_cache_path=self.registry_path) - - # Override environment paths - self.env_manager.environments_dir = env_dir - self.env_manager.environments_file = env_dir / "environments.json" - self.env_manager.current_env_file = env_dir / "current_env" + self.env_manager = HatchEnvironmentManager( + environments_dir=env_dir, + simulation_mode=True, + local_registry_cache_path=self.registry_path) # Initialize environment files with clean state self.env_manager._initialize_environments_file() @@ -429,6 +427,7 @@ def test_add_package_with_mixed_dependency_types(self): self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") def test_add_package_with_system_dependency(self): """Test adding a package with a system dependency.""" self.env_manager.create_environment("test_env", "Test environment") diff --git a/tests/test_hatch_installer.py b/tests/test_hatch_installer.py index e5279a6..6398c24 100644 --- a/tests/test_hatch_installer.py +++ b/tests/test_hatch_installer.py @@ -10,6 +10,8 @@ from hatch_validator.package_validator import HatchPackageValidator from hatch_validator.package.package_service import PackageService +from hatch.installers.installation_context import InstallationStatus + class TestHatchInstaller(unittest.TestCase): """Tests for the HatchInstaller using dummy packages from Hatching-Dev.""" @@ -118,12 +120,12 @@ class DummyContext: context = DummyContext() # Install result = self.installer.install(dependency, context) - self.assertEqual(result.status, "COMPLETED") + self.assertEqual(result.status, InstallationStatus.COMPLETED) installed_path = Path(result.installed_path) self.assertTrue(installed_path.exists()) # Uninstall uninstall_result = self.installer.uninstall(dependency, context) - self.assertEqual(uninstall_result.status, "COMPLETED") + self.assertEqual(uninstall_result.status, InstallationStatus.COMPLETED) self.assertFalse(installed_path.exists()) def test_installer_rejects_invalid_dependency(self): diff --git a/tests/test_installer_base.py b/tests/test_installer_base.py index e29c864..1600998 100644 --- a/tests/test_installer_base.py +++ b/tests/test_installer_base.py @@ -124,7 +124,7 @@ def test_installation_error(self): dependency_name="test_package", error_code="DOWNLOAD_FAILED" ) - self.assertEqual(str(error), "Installation failed", f"Expected error message 'Installation failed', got '{str(error)}'") + self.assertEqual(error.message, "Installation failed", f"Expected error message 'Installation failed', got '{error.message}'") self.assertEqual(error.dependency_name, "test_package", f"Expected dependency_name='test_package', got {error.dependency_name}") self.assertEqual(error.error_code, "DOWNLOAD_FAILED", f"Expected error_code='DOWNLOAD_FAILED', got {error.error_code}") logger.info("InstallationError test passed") @@ -182,7 +182,7 @@ def test_mock_installer_get_installation_info(self): self.assertEqual(info["installer_type"], "mock", f"Expected installer_type='mock', got {info['installer_type']}") self.assertEqual(info["dependency_name"], "test_package", f"Expected dependency_name='test_package', got {info['dependency_name']}") self.assertEqual(info["resolved_version"], "1.0.0", f"Expected resolved_version='1.0.0', got {info['resolved_version']}") - self.assertEqual(info["target_path"], self.env_path, f"Expected target_path={self.env_path}, got {info['target_path']}") + self.assertEqual(info["target_path"], str(self.env_path), f"Expected target_path={self.env_path}, got {info['target_path']}") self.assertTrue(info["supported"], f"Expected supported=True, got {info['supported']}") logger.info("MockInstaller get_installation_info test passed") diff --git a/tests/test_online_package_loader.py b/tests/test_online_package_loader.py index 40f90f9..8956c3d 100644 --- a/tests/test_online_package_loader.py +++ b/tests/test_online_package_loader.py @@ -32,6 +32,8 @@ def setUp(self): self.temp_dir = tempfile.mkdtemp() self.cache_dir = Path(self.temp_dir) / "cache" self.cache_dir.mkdir(parents=True, exist_ok=True) + self.env_dir = Path(self.temp_dir) / "envs" + self.env_dir.mkdir(parents=True, exist_ok=True) # Initialize registry retriever in online mode self.retriever = RegistryRetriever( @@ -47,14 +49,11 @@ def setUp(self): # Initialize environment manager self.env_manager = HatchEnvironmentManager( + environments_dir=self.env_dir, cache_dir=self.cache_dir, simulation_mode=False ) - - # Target directory for installation tests - self.target_dir = Path(self.temp_dir) / "target" - self.target_dir.mkdir(parents=True) - + def tearDown(self): """Clean up test environment after each test.""" # Remove temporary directory @@ -67,7 +66,11 @@ def test_download_package_online(self): version = "==1.0.1" # Add package to environment using the environment manager - result = self.env_manager.add_package_to_environment(package_name, version_constraint=version) + result = self.env_manager.add_package_to_environment( + package_name, + version_constraint=version, + auto_approve=True # Automatically approve installation in tests + ) self.assertTrue(result, f"Failed to add package {package_name}@{version} to environment") # Verify package is in environment @@ -97,15 +100,16 @@ def test_download_package_online(self): # logger.info(f"Successfully downloaded {package_name}@{version}") # except Exception as e: # logger.warning(f"Couldn't download {package_name}@{version}: {e}") + def test_install_and_caching(self): """Test installing and caching a package.""" package_name = "base_pkg_1" - version = "==1.0.1" + version = "1.0.1" + version_constraint = f"=={version}" # Find package in registry package_data = find_package(self.registry_data, package_name) - if not package_data: - self.skipTest(f"Package {package_name} not found in registry") + self.assertIsNotNone(package_data, f"Package {package_name} not found in registry") # Create a specific test environment for this test test_env_name = "test_install_env" @@ -116,24 +120,25 @@ def test_install_and_caching(self): result = self.env_manager.add_package_to_environment( package_name, env_name=test_env_name, - version_constraint=version + version_constraint=version_constraint, + auto_approve=True # Automatically approve installation in tests ) - self.assertTrue(result, f"Failed to add package {package_name}@{version} to environment") + self.assertTrue(result, f"Failed to add package {package_name}@{version_constraint} to environment") # Get environment path env_path = self.env_manager.get_environment_path(test_env_name) installed_path = env_path / package_name # Verify installation - self.assertTrue(installed_path.exists(), "Package not installed to environment directory") - self.assertTrue((installed_path / "hatch_metadata.json").exists(), "Installation missing metadata file") - + self.assertTrue(installed_path.exists(), f"Package not installed to environment directory: {installed_path}") + self.assertTrue((installed_path / "hatch_metadata.json").exists(), f"Installation missing metadata file: {installed_path / 'hatch_metadata.json'}") + # Verify the cache contains the package cache_path = self.cache_dir / "packages" / f"{package_name}-{version}" - self.assertTrue(cache_path.exists(), "Package not cached during installation") - self.assertTrue((cache_path / "hatch_metadata.json").exists(), "Cache missing metadata file") - + self.assertTrue(cache_path.exists(), f"Package not cached during installation: {cache_path}") + self.assertTrue((cache_path / "hatch_metadata.json").exists(), f"Cache missing metadata file: {cache_path / 'hatch_metadata.json'}") + logger.info(f"Successfully installed and cached package: {package_name}@{version}") except Exception as e: self.fail(f"Package installation raised exception: {e}") @@ -141,17 +146,16 @@ def test_install_and_caching(self): def test_cache_reuse(self): """Test that the cache is reused for multiple installs.""" package_name = "base_pkg_1" - version = "==1.0.1" - + version = "1.0.1" + version_constraint = f"=={version}" + # Find package in registry package_data = find_package(self.registry_data, package_name) - if not package_data: - self.skipTest(f"Package {package_name} not found in registry") + self.assertIsNotNone(package_data, f"Package {package_name} not found in registry") # Get package URL - package_url = get_package_release_url(package_data, version) - if not package_url: - self.skipTest(f"No download URL found for {package_name}@{version}") + package_url = get_package_release_url(package_data, version_constraint) + self.assertIsNotNone(package_url, f"No download URL found for {package_name}@{version_constraint}") # Create two test environments first_env = "test_cache_env1" @@ -164,27 +168,29 @@ def test_cache_reuse(self): result_first = self.env_manager.add_package_to_environment( package_name, env_name=first_env, - version_constraint=version + version_constraint=version_constraint, + auto_approve=True # Automatically approve installation in tests ) first_install_time = time.time() - start_time_first - self.assertTrue(result_first, f"Failed to add package {package_name}@{version} to first environment") + logger.info(f"First installation took {first_install_time:.2f} seconds") + self.assertTrue(result_first, f"Failed to add package {package_name}@{version_constraint} to first environment") + first_env_path = self.env_manager.get_environment_path(first_env) + self.assertTrue((first_env_path / package_name).exists(), f"Package not found at the expected path: {first_env_path / package_name}") # Second install - should use cache start_time = time.time() result_second = self.env_manager.add_package_to_environment( package_name, env_name=second_env, - version_constraint=version + version_constraint=version_constraint, + auto_approve=True # Automatically approve installation in tests ) install_time = time.time() - start_time logger.info(f"Second installation took {install_time:.2f} seconds (should be faster if cache used)") - # Both installations should succeed - first_env_path = self.env_manager.get_environment_path(first_env) + second_env_path = self.env_manager.get_environment_path(second_env) - - self.assertTrue((first_env_path / package_name).exists(), "First installation failed") - self.assertTrue((second_env_path / package_name).exists(), "Second installation failed") + self.assertTrue((second_env_path / package_name).exists(), f"Package not found at the expected path: {second_env_path / package_name}") if __name__ == "__main__": diff --git a/tests/test_system_installer.py b/tests/test_system_installer.py index 8418c58..4a2722c 100644 --- a/tests/test_system_installer.py +++ b/tests/test_system_installer.py @@ -6,8 +6,8 @@ """ import unittest -import platform import subprocess +import sys from pathlib import Path from unittest.mock import patch, MagicMock from typing import Dict, Any @@ -216,6 +216,7 @@ def test_build_apt_command_automated(self): command = self.installer._build_apt_command(dependency, self.mock_context) self.assertEqual(command, ["sudo", "apt", "install", "-y", "curl"]) + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") @patch('subprocess.run') def test_verify_installation_success(self, mock_run): """Test successful installation verification.""" @@ -383,6 +384,7 @@ def test_install_simulation_mode(self, mock_simulate, mock_validate): self.assertTrue(result.metadata["simulation"]) mock_simulate.assert_called_once() + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") @patch('subprocess.run') def test_simulate_installation_success(self, mock_run): """Test successful installation simulation.""" @@ -527,6 +529,7 @@ def test_can_install_real_dependency(self, mock_apt_available, mock_platform_sup self.assertTrue(self.installer.can_install(dependency)) + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") def test_simulate_curl_installation(self): """Test simulating installation of curl package.""" dependency = { @@ -566,6 +569,7 @@ def test_get_installation_info(self): self.assertEqual(info["dependency_name"], "curl") self.assertTrue(info["supported"]) + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") def test_install_real_dependency(self): """Test installing a real system dependency.""" dependency = { From dd354a43cf0cfff855cbdbcb225c7234c6c559a7 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 17:45:37 +0900 Subject: [PATCH 16/48] [Update] Use Hatch-Validator@dev **Major**: - Was already doing it locally but pushing the change to test the whole import pipeline. --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1b4744a..8af84d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,9 @@ dependencies = [ "jsonschema>=4.0.0", "requests>=2.25.0", "packaging>=20.0", - "hatch_validator @ git+https://github.com/CrackingShells/Hatch-Validator.git@v0.3.2" "docker>=7.1.0", + + "hatch_validator @ git+https://github.com/CrackingShells/Hatch-Validator.git@dev" ] [project.scripts] From 1c575d06afcd8c603fca5992cb38b21ebd39306b Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 17:54:32 +0900 Subject: [PATCH 17/48] [Fix] Package import & discovery **Major**! - Updating the toml to discover the packages automatically (setuptool) - Aded the imports to all installer such that the installer registry will be populated immediately upon import --- hatch/installers/__init__.py | 12 ++++++++++-- pyproject.toml | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/hatch/installers/__init__.py b/hatch/installers/__init__.py index 44365d4..3accee5 100644 --- a/hatch/installers/__init__.py +++ b/hatch/installers/__init__.py @@ -5,13 +5,21 @@ Python packages, system packages, and Docker containers. """ -from .installer_base import DependencyInstaller, InstallationError, InstallationContext -from .registry import InstallerRegistry, installer_registry +from hatch.installers.installer_base import DependencyInstaller, InstallationError, InstallationContext +from hatch.installers.hatch_installer import HatchInstaller +from hatch.installers.python_installer import PythonInstaller +from hatch.installers.system_installer import SystemInstaller +from hatch.installers.docker_installer import DockerInstaller +from hatch.installers.registry import InstallerRegistry, installer_registry __all__ = [ "DependencyInstaller", "InstallationError", "InstallationContext", + #"HatchInstaller", # Not necessary to expose directly, the registry will handle it + #"PythonInstaller", # Not necessary to expose directly, the registry will handle it + #"SystemInstaller", # Not necessary to expose directly, the registry will handle it + #"DockerInstaller", # Not necessary to expose directly, the registry will handle it "InstallerRegistry", "installer_registry" ] diff --git a/pyproject.toml b/pyproject.toml index 8af84d3..c49c9d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,4 +35,6 @@ hatch = "hatch.cli_hatch:main" [tool.setuptools] package-dir = {"" = "."} -packages = ["hatch"] \ No newline at end of file + +[tool.setuptools.packages.find] +where = ["."] \ No newline at end of file From 9b2e1455290e1265afd2a5d563b65b2a4f4feff9 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Sun, 29 Jun 2025 18:33:59 +0900 Subject: [PATCH 18/48] [Fix] Only add Hatch pkgs in Hatch envs pkg list **Major**: - Filtering the installed package insformation at the step of the `environment_manager.py` at the end of `add_package_to_environment` to only add the hatch packages in the environment information file. - Adapted the value of the field `type` of the package installation result to be able toretrieve expected package type. - Adapted the tests `test_env_manip` --- hatch/environment_manager.py | 17 +++++++++-------- .../dependency_installation_orchestrator.py | 2 +- tests/test_env_manip.py | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index fa72bf6..6835515 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -325,15 +325,16 @@ def add_package_to_environment(self, package_path_or_name: str, ) if success: - # Update environment metadata with installed packages + # Update environment metadata with installed Hatch packages for pkg_info in installed_packages: - self._add_package_to_env_data( - env_name=env_name, - package_name=pkg_info["name"], - package_version=pkg_info["version"], - package_type=pkg_info["type"], - source=pkg_info["source"] - ) + if pkg_info["type"] == "hatch": + self._add_package_to_env_data( + env_name=env_name, + package_name=pkg_info["name"], + package_version=pkg_info["version"], + package_type=pkg_info["type"], + source=pkg_info["source"] + ) self.logger.info(f"Successfully installed {len(installed_packages)} packages to environment {env_name}") return True diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py index d84659a..339becb 100644 --- a/hatch/installers/dependency_installation_orchestrator.py +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -550,7 +550,7 @@ def _install_main_package(self, context: InstallationContext) -> Dict[str, Any]: return { "name": package_name, "version": package_version, - "type": self._resolved_package_type, + "type": "hatch", "source": self._resolved_package_location } diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index 3b6bf4e..e1e0b7a 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -210,7 +210,7 @@ def test_add_local_package(self): self.assertIn("name", pkg_data, "Package data missing name") self.assertIn("version", pkg_data, "Package data missing version") self.assertIn("type", pkg_data, "Package data missing type") - self.assertEqual(pkg_data["type"], "local", "Package type not set to local") + self.assertIn("source", pkg_data, "Package data missing source") def test_add_package_with_dependencies(self): """Test adding a package with dependencies to an environment.""" From 5e70d8c5e9a25b2d308a1702789203a0357dfc7b Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 01:44:22 +0900 Subject: [PATCH 19/48] [Add - Major] PythonEnvironmentManager **Major**: - WARNING New dependency on conda/mamba environment management features - Added methods for creating, removing, and validating Python environments using conda/mamba. - Implemented cross-platform detection of conda/mamba executables with fallback logic. - Added support for specifying Python versions during environment creation. - Integrated shell launching functionality for interactive or command-based execution within environments. **Minor**: - Enhanced environment metadata with detailed diagnostics, including Python version, packages, and platform info. - Implemented methods for retrieving Python executable paths and environment activation variables. - Added diagnostics methods for both manager-level and environment-level troubleshooting. --- hatch/python_environment_manager.py | 664 ++++++++++++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 hatch/python_environment_manager.py diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py new file mode 100644 index 0000000..6dc24f6 --- /dev/null +++ b/hatch/python_environment_manager.py @@ -0,0 +1,664 @@ +"""Python Environment Manager for cross-platform conda/mamba environment management. + +This module provides the core functionality for managing Python environments using +conda/mamba, with support for local installation under Hatch environment directories +and cross-platform compatibility. +""" + +import json +import logging +import platform +import shutil +import subprocess +import sys +import os +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Any + + +class PythonEnvironmentError(Exception): + """Exception raised for Python environment-related errors.""" + pass + + +class PythonEnvironmentManager: + """Manages Python environments using conda/mamba for cross-platform isolation. + + This class handles: + 1. Creating and managing conda/mamba environments locally under Hatch environment directories + 2. Python version management and executable path resolution + 3. Cross-platform conda/mamba detection and validation + 4. Environment lifecycle operations (create, remove, info) + 5. Integration with InstallationContext for Python executable configuration + """ + + def __init__(self, environments_dir: Optional[Path] = None): + """Initialize the Python environment manager. + + Args: + environments_dir (Path, optional): Directory where Hatch environments are stored. + Defaults to ~/.hatch/envs. + """ + self.logger = logging.getLogger("hatch.python_environment_manager") + self.logger.setLevel(logging.INFO) + + # Set up environment directories + self.environments_dir = environments_dir or (Path.home() / ".hatch" / "envs") + + # Detect available conda/mamba + self.conda_executable = None + self.mamba_executable = None + self._detect_conda_mamba() + + self.logger.info(f"Python environment manager initialized with environments_dir: {self.environments_dir}") + if self.mamba_executable: + self.logger.info(f"Using mamba: {self.mamba_executable}") + elif self.conda_executable: + self.logger.info(f"Using conda: {self.conda_executable}") + else: + self.logger.warning("Neither conda nor mamba found - Python environment management will be limited") + + def _detect_conda_mamba(self) -> None: + """Detect available conda/mamba executables on the system. + + Tries to find mamba first (preferred), then conda as fallback. + Sets self.mamba_executable and self.conda_executable based on availability. + """ + # Try to detect mamba first (preferred) + try: + mamba_path = shutil.which("mamba") + if mamba_path: + # Verify mamba works + result = subprocess.run( + [mamba_path, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + self.mamba_executable = mamba_path + self.logger.debug(f"Detected mamba at: {mamba_path}") + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): + self.logger.debug("Mamba not found or not working") + + # Try to detect conda + try: + conda_path = shutil.which("conda") + if conda_path: + # Verify conda works + result = subprocess.run( + [conda_path, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + self.conda_executable = conda_path + self.logger.debug(f"Detected conda at: {conda_path}") + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): + self.logger.debug("Conda not found or not working") + + def _validate_conda_installation(self) -> bool: + """Validate that conda/mamba installation is functional. + + Returns: + bool: True if conda or mamba is available and functional, False otherwise. + """ + if not (self.conda_executable or self.mamba_executable): + return False + + # Use mamba if available, otherwise conda + executable = self.mamba_executable or self.conda_executable + + try: + # Test basic functionality + result = subprocess.run( + [executable, "info", "--json"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0: + # Try to parse the JSON to ensure it's valid + json.loads(result.stdout) + return True + except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + self.logger.error(f"Failed to validate conda/mamba installation: {result.stderr if 'result' in locals() else 'Unknown error'}") + + return False + + def is_available(self) -> bool: + """Check if Python environment management is available. + + Returns: + bool: True if conda/mamba is available and functional, False otherwise. + """ + return self._validate_conda_installation() + + def get_preferred_executable(self) -> Optional[str]: + """Get the preferred conda/mamba executable. + + Returns: + str: Path to mamba (preferred) or conda executable, None if neither available. + """ + return self.mamba_executable or self.conda_executable + + def _get_conda_env_name(self, env_name: str) -> str: + """Get the conda environment name for a Hatch environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + str: Conda environment name following the hatch_ pattern. + """ + return f"hatch_{env_name}" + + def _get_conda_env_prefix(self, env_name: str) -> Path: + """Get the local conda environment prefix path. + + Args: + env_name (str): Hatch environment name. + + Returns: + Path: Local path where the conda environment should be installed. + """ + return self.environments_dir / env_name / "python_env" + + def create_python_environment(self, env_name: str, python_version: Optional[str] = None, + force: bool = False) -> bool: + """Create a Python environment using conda/mamba. + + Creates a conda environment locally under the Hatch environment directory + with the specified Python version. + + Args: + env_name (str): Hatch environment name. + python_version (str, optional): Python version to install (e.g., "3.11", "3.12"). + If None, uses the default Python version from conda. + force (bool, optional): Whether to force recreation if environment exists. + Defaults to False. + + Returns: + bool: True if environment was created successfully, False otherwise. + + Raises: + PythonEnvironmentError: If conda/mamba is not available or creation fails. + """ + if not self.is_available(): + raise PythonEnvironmentError("Neither conda nor mamba is available for Python environment management") + + executable = self.get_preferred_executable() + conda_env_name = self._get_conda_env_name(env_name) + env_prefix = self._get_conda_env_prefix(env_name) + + # Check if environment already exists + if self._conda_env_exists(env_name) and not force: + self.logger.warning(f"Python environment already exists for {env_name}") + return True + + # Remove existing environment if force is True + if force and self._conda_env_exists(env_name): + self.logger.info(f"Removing existing Python environment for {env_name}") + self.remove_python_environment(env_name) + + # Build conda create command + cmd = [executable, "create", "--yes", "--prefix", str(env_prefix)] + + if python_version: + cmd.extend(["python=" + python_version]) + else: + cmd.append("python") + + try: + self.logger.info(f"Creating Python environment for {env_name} at {env_prefix}") + if python_version: + self.logger.info(f"Using Python version: {python_version}") + + result = subprocess.run( + cmd, + timeout=300 # 5 minutes timeout + ) + + if result.returncode == 0: + self.logger.info(f"Successfully created Python environment for {env_name}") + return True + else: + error_msg = f"Failed to create Python environment: {result.stderr}" + self.logger.error(error_msg) + raise PythonEnvironmentError(error_msg) + + except subprocess.TimeoutExpired: + error_msg = f"Timeout creating Python environment for {env_name}" + self.logger.error(error_msg) + raise PythonEnvironmentError(error_msg) + except Exception as e: + error_msg = f"Unexpected error creating Python environment: {e}" + self.logger.error(error_msg) + raise PythonEnvironmentError(error_msg) + + def _conda_env_exists(self, env_name: str) -> bool: + """Check if a conda environment exists for the given Hatch environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + bool: True if the conda environment exists, False otherwise. + """ + env_prefix = self._get_conda_env_prefix(env_name) + python_executable = self._get_python_executable_path(env_name) + + # Check if the environment directory and Python executable exist + return env_prefix.exists() and python_executable.exists() + + def _get_python_executable_path(self, env_name: str) -> Path: + """Get the Python executable path for a given environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + Path: Path to the Python executable in the environment. + """ + env_prefix = self._get_conda_env_prefix(env_name) + + if platform.system() == "Windows": + return env_prefix / "python.exe" + else: + return env_prefix / "bin" / "python" + + def get_python_executable(self, env_name: str) -> Optional[str]: + """Get the Python executable path for an environment if it exists. + + Args: + env_name (str): Hatch environment name. + + Returns: + str: Path to Python executable if environment exists, None otherwise. + """ + if not self._conda_env_exists(env_name): + return None + + python_path = self._get_python_executable_path(env_name) + return str(python_path) if python_path.exists() else None + + def remove_python_environment(self, env_name: str) -> bool: + """Remove a Python environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + bool: True if environment was removed successfully, False otherwise. + + Raises: + PythonEnvironmentError: If conda/mamba is not available or removal fails. + """ + if not self.is_available(): + raise PythonEnvironmentError("Neither conda nor mamba is available for Python environment management") + + if not self._conda_env_exists(env_name): + self.logger.warning(f"Python environment does not exist for {env_name}") + return True + + executable = self.get_preferred_executable() + env_prefix = self._get_conda_env_prefix(env_name) + + try: + self.logger.info(f"Removing Python environment for {env_name}") + + # Use conda/mamba remove with --prefix + # Show output in terminal by not capturing output + result = subprocess.run( + [executable, "env", "remove", "--yes", "--prefix", str(env_prefix)], + timeout=120 # 2 minutes timeout + ) + + if result.returncode == 0: + self.logger.info(f"Successfully removed Python environment for {env_name}") + + # Clean up any remaining directory structure + if env_prefix.exists(): + try: + shutil.rmtree(env_prefix) + except OSError as e: + self.logger.warning(f"Could not fully clean up environment directory: {e}") + + return True + else: + error_msg = f"Failed to remove Python environment: (see terminal output)" + self.logger.error(error_msg) + raise PythonEnvironmentError(error_msg) + + except subprocess.TimeoutExpired: + error_msg = f"Timeout removing Python environment for {env_name}" + self.logger.error(error_msg) + raise PythonEnvironmentError(error_msg) + except Exception as e: + error_msg = f"Unexpected error removing Python environment: {e}" + self.logger.error(error_msg) + raise PythonEnvironmentError(error_msg) + + def get_environment_info(self, env_name: str) -> Optional[Dict[str, Any]]: + """Get information about a Python environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + dict: Environment information including Python version, packages, etc. + None if environment doesn't exist. + """ + if not self._conda_env_exists(env_name): + return None + + executable = self.get_preferred_executable() + env_prefix = self._get_conda_env_prefix(env_name) + python_executable = self._get_python_executable_path(env_name) + + info = { + "environment_name": env_name, + "conda_env_name": self._get_conda_env_name(env_name), + "environment_path": str(env_prefix), + "python_executable": str(python_executable), + "python_version": self.get_python_version(env_name), + "exists": True, + "platform": platform.system() + } + + # Get conda environment info + if self.is_available(): + try: + result = subprocess.run( + [executable, "list", "--prefix", str(env_prefix), "--json"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0: + packages = json.loads(result.stdout) + info["packages"] = packages + info["package_count"] = len(packages) + except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + info["packages"] = [] + info["package_count"] = 0 + + return info + + def list_environments(self) -> List[str]: + """List all Python environments managed by this manager. + + Returns: + list: List of environment names that have Python environments. + """ + environments = [] + + if not self.environments_dir.exists(): + return environments + + for env_dir in self.environments_dir.iterdir(): + if env_dir.is_dir(): + env_name = env_dir.name + if self._conda_env_exists(env_name): + environments.append(env_name) + + return environments + + def get_python_version(self, env_name: str) -> Optional[str]: + """Get the Python version for an environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + str: Python version if environment exists, None otherwise. + """ + python_executable = self.get_python_executable(env_name) + if not python_executable: + return None + + try: + result = subprocess.run( + [python_executable, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + # Parse version from "Python X.Y.Z" format + version_line = result.stdout.strip() + if version_line.startswith("Python "): + return version_line[7:] # Remove "Python " prefix + return version_line + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + return None + + def activate_environment(self, env_name: str) -> Optional[Dict[str, str]]: + """Get environment variables needed to activate a Python environment. + + This method returns the environment variables that should be set + to properly activate the Python environment, but doesn't actually + modify the current process environment. + + Args: + env_name (str): Hatch environment name. + + Returns: + dict: Environment variables to set for activation, None if env doesn't exist. + """ + if not self._conda_env_exists(env_name): + return None + + env_prefix = self._get_conda_env_prefix(env_name) + python_executable = self._get_python_executable_path(env_name) + + env_vars = {} + + # Set CONDA_PREFIX and CONDA_DEFAULT_ENV + env_vars["CONDA_PREFIX"] = str(env_prefix) + env_vars["CONDA_DEFAULT_ENV"] = str(env_prefix) + + # Update PATH to include environment's bin/Scripts directory + if platform.system() == "Windows": + scripts_dir = env_prefix / "Scripts" + library_bin = env_prefix / "Library" / "bin" + bin_paths = [str(env_prefix), str(scripts_dir), str(library_bin)] + else: + bin_dir = env_prefix / "bin" + bin_paths = [str(bin_dir)] + + # Get current PATH and prepend environment paths + current_path = os.environ.get("PATH", "") + new_path = os.pathsep.join(bin_paths + [current_path]) + env_vars["PATH"] = new_path + + # Set PYTHON environment variable + env_vars["PYTHON"] = str(python_executable) + + return env_vars + + def get_manager_info(self) -> Dict[str, Any]: + """Get information about the Python environment manager capabilities. + + Returns: + dict: Manager information including available executables and status. + """ + return { + "conda_executable": self.conda_executable, + "mamba_executable": self.mamba_executable, + "preferred_manager": self.mamba_executable if self.mamba_executable else self.conda_executable, + "is_available": self.is_available(), + "platform": platform.system(), + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + + def get_environment_diagnostics(self, env_name: str) -> Dict[str, Any]: + """Get detailed diagnostics for a specific Python environment. + + Args: + env_name (str): Environment name. + + Returns: + dict: Detailed diagnostics information. + """ + diagnostics = { + "environment_name": env_name, + "conda_env_name": f"hatch-{env_name}", + "exists": False, + "conda_available": self.is_available(), + "manager_executable": self.mamba_executable or self.conda_executable, + "platform": platform.system() + } + + # Check if environment exists + if self.environment_exists(env_name): + diagnostics["exists"] = True + + # Get Python executable + python_exec = self.get_python_executable(env_name) + diagnostics["python_executable"] = python_exec + diagnostics["python_accessible"] = python_exec is not None + + # Get Python version + if python_exec: + python_version = self.get_python_version(env_name) + diagnostics["python_version"] = python_version + diagnostics["python_version_accessible"] = python_version is not None + + # Check if executable actually works + try: + result = subprocess.run( + [python_exec, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + diagnostics["python_executable_works"] = result.returncode == 0 + diagnostics["python_version_output"] = result.stdout.strip() + except Exception as e: + diagnostics["python_executable_works"] = False + diagnostics["python_executable_error"] = str(e) + + # Get environment path + env_path = self.get_environment_path(env_name) + diagnostics["environment_path"] = str(env_path) if env_path else None + diagnostics["environment_path_exists"] = env_path.exists() if env_path else False + + return diagnostics + + def get_manager_diagnostics(self) -> Dict[str, Any]: + """Get general diagnostics for the Python environment manager. + + Returns: + dict: General manager diagnostics. + """ + diagnostics = { + "conda_executable": self.conda_executable, + "mamba_executable": self.mamba_executable, + "conda_available": self.conda_executable is not None, + "mamba_available": self.mamba_executable is not None, + "any_manager_available": self.is_available(), + "preferred_manager": self.mamba_executable if self.mamba_executable else self.conda_executable, + "platform": platform.system(), + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "environments_dir": str(self.environments_dir) + } + + # Test conda/mamba executables + for manager_name, executable in [("conda", self.conda_executable), ("mamba", self.mamba_executable)]: + if executable: + try: + result = subprocess.run( + [executable, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + diagnostics[f"{manager_name}_works"] = result.returncode == 0 + diagnostics[f"{manager_name}_version"] = result.stdout.strip() + except Exception as e: + diagnostics[f"{manager_name}_works"] = False + diagnostics[f"{manager_name}_error"] = str(e) + + return diagnostics + + def launch_shell(self, env_name: str, cmd: Optional[str] = None) -> bool: + """Launch a Python shell or execute a command in the environment. + + Args: + env_name (str): Environment name. + cmd (str, optional): Command to execute. If None, launches interactive shell. + + Returns: + bool: True if successful, False otherwise. + """ + if not self.environment_exists(env_name): + self.logger.error(f"Environment {env_name} does not exist") + return False + + python_exec = self.get_python_executable(env_name) + if not python_exec: + self.logger.error(f"Python executable not found for environment {env_name}") + return False + + try: + if cmd: + # Execute specific command + self.logger.info(f"Executing command in {env_name}: {cmd}") + result = subprocess.run( + [python_exec, "-c", cmd], + cwd=os.getcwd() + ) + return result.returncode == 0 + else: + # Launch interactive shell + self.logger.info(f"Launching Python shell for environment {env_name}") + self.logger.info(f"Python executable: {python_exec}") + + # On Windows, we need to activate the conda environment first + if platform.system() == "Windows": + activate_cmd = f"{self.get_preferred_executable()} activate {self._get_conda_env_prefix(env_name)} && python" + result = subprocess.run( + ["cmd", "/c", activate_cmd], + cwd=os.getcwd() + ) + else: + # On Unix-like systems, we can directly use the Python executable + result = subprocess.run( + [python_exec], + cwd=os.getcwd() + ) + + return result.returncode == 0 + + except Exception as e: + self.logger.error(f"Failed to launch shell for {env_name}: {e}") + return False + + def environment_exists(self, env_name: str) -> bool: + """Check if a Python environment exists. + + Args: + env_name (str): Environment name. + + Returns: + bool: True if environment exists, False otherwise. + """ + return self._conda_env_exists(env_name) + + def get_environment_path(self, env_name: str) -> Optional[Path]: + """Get the file system path for a Python environment. + + Args: + env_name (str): Environment name. + + Returns: + Path: Environment path or None if not found. + """ + if not self.environment_exists(env_name): + return None + + return self._get_conda_env_prefix(env_name) From 6e29de6e7f5be54c0e31dc4ea4348a02f2a6c2ac Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 01:47:51 +0900 Subject: [PATCH 20/48] [Update] `HatchEnvironmentManager` & Python env **Major**: - Integrated PythonEnvironmentManager for managing Python environments within Hatch environments. - Enhanced environment metadata structure with detailed Python environment information, including: - `python_env` object with fields for conda environment name, Python executable, version, and manager. - Updated `create_environment` and `remove_environment` methods to handle Python environments. - Implemented `create_python_environment_only` and `remove_python_environment_only` methods for standalone Python environment management. - Updated `set_current_environment` to configure the Python executable for dependency installation. --- hatch/environment_manager.py | 338 ++++++++++++++++++++++++++++++++++- 1 file changed, 332 insertions(+), 6 deletions(-) diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 6835515..6dc70e6 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -3,6 +3,7 @@ This module provides the core functionality for managing isolated environments for Hatch packages. """ +import sys import json import logging import datetime @@ -13,6 +14,7 @@ from .registry_retriever import RegistryRetriever from .package_loader import HatchPackageLoader from .installers.dependency_installation_orchestrator import DependencyInstallerOrchestrator +from .python_environment_manager import PythonEnvironmentManager, PythonEnvironmentError class HatchEnvironmentError(Exception): """Exception raised for environment-related errors.""" @@ -65,6 +67,9 @@ def __init__(self, self._environments = self._load_environments() self._current_env_name = self._load_current_env_name() + # Initialize Python environment manager + self.python_env_manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + # Initialize dependencies self.package_loader = HatchPackageLoader(cache_dir=cache_dir) self.retriever = RegistryRetriever(cache_ttl=cache_ttl, @@ -81,6 +86,10 @@ def __init__(self, registry_service=self.registry_service, registry_data=self.registry_data ) + + # Configure Python executable for current environment + if self._current_env_name: + self._configure_python_executable(self._current_env_name) def _initialize_environments_file(self): """Create the initial environments file with default environment.""" @@ -89,7 +98,10 @@ def _initialize_environments_file(self): "name": "default", "description": "Default environment", "created_at": datetime.datetime.now().isoformat(), - "packages": [] + "packages": [], + "python_environment": False, # Legacy field + "python_version": None, # Legacy field + "python_env": None # Enhanced metadata structure } } @@ -175,12 +187,48 @@ def set_current_environment(self, env_name: str) -> bool: # Update cache self._current_env_name = env_name + # Configure Python executable for dependency installation + self._configure_python_executable(env_name) + self.logger.info(f"Current environment set to: {env_name}") return True except Exception as e: self.logger.error(f"Failed to set current environment: {e}") return False + def _configure_python_executable(self, env_name: str) -> None: + """Configure the Python executable for the current environment. + + This method sets the Python executable in the dependency orchestrator's + InstallationContext so that python_installer.py uses the correct interpreter. + + Args: + env_name: Name of the environment to configure Python for + """ + # Get Python executable from Python environment manager + python_executable = self.python_env_manager.get_python_executable(env_name) + + if python_executable: + # Configure the dependency orchestrator with the Python executable + self.dependency_orchestrator.set_python_executable(python_executable) + self.logger.info(f"Configured Python executable for {env_name}: {python_executable}") + else: + # Use system Python as fallback + system_python = sys.executable + self.dependency_orchestrator.set_python_executable(system_python) + self.logger.info(f"Using system Python for {env_name}: {system_python}") + + def get_current_python_executable(self) -> Optional[str]: + """Get the Python executable for the current environment. + + Returns: + str: Path to Python executable, None if no current environment or no Python env + """ + if not self._current_env_name: + return None + + return self.python_env_manager.get_python_executable(self._current_env_name) + def list_environments(self) -> List[Dict]: """ List all available environments. @@ -196,13 +244,17 @@ def list_environments(self) -> List[Dict]: return result - def create_environment(self, name: str, description: str = "") -> bool: + def create_environment(self, name: str, description: str = "", + python_version: Optional[str] = None, + create_python_env: bool = True) -> bool: """ Create a new environment. Args: name: Name of the environment description: Description of the environment + python_version: Python version for the environment (e.g., "3.11", "3.12") + create_python_env: Whether to create a Python environment using conda/mamba Returns: bool: True if created successfully, False if environment already exists @@ -217,14 +269,60 @@ def create_environment(self, name: str, description: str = "") -> bool: self.logger.warning(f"Environment already exists: {name}") return False - # Create new environment - self._environments[name] = { + # Create Python environment if requested and conda/mamba is available + python_env_info = None + if create_python_env and self.python_env_manager.is_available(): + try: + python_env_created = self.python_env_manager.create_python_environment( + name, python_version=python_version + ) + if python_env_created: + self.logger.info(f"Created Python environment for {name}") + + # Get detailed Python environment information + python_info = self.python_env_manager.get_environment_info(name) + if python_info: + python_env_info = { + "enabled": True, + "conda_env_name": python_info.get("conda_env_name"), + "python_executable": python_info.get("python_executable"), + "created_at": datetime.datetime.now().isoformat(), + "version": python_info.get("python_version"), + "requested_version": python_version, + "manager": python_info.get("manager", "conda") + } + else: + # Fallback if detailed info is not available + python_env_info = { + "enabled": True, + "conda_env_name": f"hatch-{name}", + "python_executable": None, + "created_at": datetime.datetime.now().isoformat(), + "version": None, + "requested_version": python_version, + "manager": "conda" + } + else: + self.logger.warning(f"Failed to create Python environment for {name}") + except PythonEnvironmentError as e: + self.logger.error(f"Failed to create Python environment: {e}") + # Continue with Hatch environment creation even if Python env creation fails + elif create_python_env: + self.logger.warning("Python environment creation requested but conda/mamba not available") + + # Create new Hatch environment with enhanced metadata + env_data = { "name": name, "description": description, "created_at": datetime.datetime.now().isoformat(), - "packages": [] + "packages": [], + "python_environment": python_env_info is not None, # Legacy field for backward compatibility + "python_version": python_version, # Legacy field for backward compatibility + "python_env": python_env_info # Enhanced metadata structure } + self._environments[name] = env_data + self._save_environments() self.logger.info(f"Created environment: {name}") return True @@ -253,6 +351,15 @@ def remove_environment(self, name: str) -> bool: if name == self._current_env_name: self.set_current_environment("default") + # Remove Python environment if it exists + env_data = self._environments[name] + if env_data.get("python_environment", False): + try: + self.python_env_manager.remove_python_environment(name) + self.logger.info(f"Removed Python environment for {name}") + except PythonEnvironmentError as e: + self.logger.warning(f"Failed to remove Python environment: {e}") + # Remove environment del self._environments[name] @@ -532,4 +639,223 @@ def refresh_registry(self, force_refresh: bool = True) -> None: self.logger.info("Registry data refreshed successfully") except Exception as e: self.logger.error(f"Failed to refresh registry data: {e}") - raise \ No newline at end of file + raise + + def is_python_environment_available(self) -> bool: + """Check if Python environment management is available. + + Returns: + bool: True if conda/mamba is available, False otherwise. + """ + return self.python_env_manager.is_available() + + def get_python_environment_info(self, env_name: str) -> Optional[Dict[str, Any]]: + """Get comprehensive Python environment information for an environment. + + Args: + env_name (str): Environment name. + + Returns: + dict: Comprehensive Python environment info, None if no Python environment exists. + """ + if env_name not in self._environments: + return None + + env_data = self._environments[env_name] + + # Check if Python environment exists + if not env_data.get("python_environment", False): + return None + + # Start with enhanced metadata from Hatch environment + python_env_data = env_data.get("python_env", {}) + + # Get real-time information from Python environment manager + live_info = self.python_env_manager.get_environment_info(env_name) + + # Combine metadata with live information + result = { + # Basic identification + "environment_name": env_name, + "enabled": python_env_data.get("enabled", True), + + # Conda/mamba information + "conda_env_name": python_env_data.get("conda_env_name") or (live_info.get("conda_env_name") if live_info else None), + "manager": python_env_data.get("manager", "conda"), + + # Python executable and version + "python_executable": live_info.get("python_executable") if live_info else python_env_data.get("python_executable"), + "python_version": live_info.get("python_version") if live_info else python_env_data.get("version"), + "requested_version": python_env_data.get("requested_version"), + + # Paths and timestamps + "environment_path": live_info.get("environment_path") if live_info else None, + "created_at": python_env_data.get("created_at"), + + # Package information + "package_count": live_info.get("package_count", 0) if live_info else 0, + + # Status information + "exists": live_info is not None, + "accessible": live_info.get("python_executable") is not None if live_info else False + } + + return result + + def list_python_environments(self) -> List[str]: + """List all environments that have Python environments. + + Returns: + list: List of environment names with Python environments. + """ + return self.python_env_manager.list_environments() + + def create_python_environment_only(self, env_name: str, python_version: Optional[str] = None, + force: bool = False) -> bool: + """Create only a Python environment without creating a Hatch environment. + + Useful for adding Python environments to existing Hatch environments. + + Args: + env_name (str): Environment name. + python_version (str, optional): Python version (e.g., "3.11"). + force (bool, optional): Whether to recreate if exists. + + Returns: + bool: True if successful, False otherwise. + """ + if env_name not in self._environments: + self.logger.error(f"Hatch environment {env_name} must exist first") + return False + + try: + success = self.python_env_manager.create_python_environment( + env_name, python_version=python_version, force=force + ) + + if success: + # Get detailed Python environment information + python_info = self.python_env_manager.get_environment_info(env_name) + if python_info: + python_env_info = { + "enabled": True, + "conda_env_name": python_info.get("conda_env_name"), + "python_executable": python_info.get("python_executable"), + "created_at": datetime.datetime.now().isoformat(), + "version": python_info.get("python_version"), + "requested_version": python_version, + "manager": python_info.get("manager", "conda") + } + else: + # Fallback if detailed info is not available + python_env_info = { + "enabled": True, + "conda_env_name": f"hatch-{env_name}", + "python_executable": None, + "created_at": datetime.datetime.now().isoformat(), + "version": None, + "requested_version": python_version, + "manager": "conda" + } + + # Update environment metadata with enhanced structure + self._environments[env_name]["python_environment"] = True # Legacy field + self._environments[env_name]["python_env"] = python_env_info # Enhanced structure + if python_version: + self._environments[env_name]["python_version"] = python_version # Legacy field + self._save_environments() + + # Reconfigure Python executable if this is the current environment + if env_name == self._current_env_name: + self._configure_python_executable(env_name) + + return success + except PythonEnvironmentError as e: + self.logger.error(f"Failed to create Python environment: {e}") + return False + + def remove_python_environment_only(self, env_name: str) -> bool: + """Remove only the Python environment, keeping the Hatch environment. + + Args: + env_name (str): Environment name. + + Returns: + bool: True if successful, False otherwise. + """ + if env_name not in self._environments: + self.logger.warning(f"Hatch environment {env_name} does not exist") + return False + + try: + success = self.python_env_manager.remove_python_environment(env_name) + + if success: + # Update environment metadata - remove Python environment info + self._environments[env_name]["python_environment"] = False # Legacy field + self._environments[env_name]["python_env"] = None # Enhanced structure + self._environments[env_name].pop("python_version", None) # Legacy field cleanup + self._save_environments() + + # Reconfigure Python executable if this is the current environment + if env_name == self._current_env_name: + self._configure_python_executable(env_name) + + return success + except PythonEnvironmentError as e: + self.logger.error(f"Failed to remove Python environment: {e}") + return False + + def get_python_environment_diagnostics(self, env_name: str) -> Optional[Dict[str, Any]]: + """Get detailed diagnostics for a Python environment. + + Args: + env_name (str): Environment name. + + Returns: + dict: Diagnostics information or None if environment doesn't exist. + """ + if env_name not in self._environments: + return None + + try: + return self.python_env_manager.get_environment_diagnostics(env_name) + except PythonEnvironmentError as e: + self.logger.error(f"Failed to get diagnostics for {env_name}: {e}") + return None + + def get_python_manager_diagnostics(self) -> Dict[str, Any]: + """Get general diagnostics for the Python environment manager. + + Returns: + dict: General diagnostics information. + """ + try: + return self.python_env_manager.get_manager_diagnostics() + except Exception as e: + self.logger.error(f"Failed to get manager diagnostics: {e}") + return {"error": str(e)} + + def launch_python_shell(self, env_name: str, cmd: Optional[str] = None) -> bool: + """Launch a Python shell or execute a command in the environment. + + Args: + env_name (str): Environment name. + cmd (str, optional): Command to execute. If None, launches interactive shell. + + Returns: + bool: True if successful, False otherwise. + """ + if env_name not in self._environments: + self.logger.error(f"Environment {env_name} does not exist") + return False + + if not self._environments[env_name].get("python_environment", False): + self.logger.error(f"No Python environment configured for {env_name}") + return False + + try: + return self.python_env_manager.launch_shell(env_name, cmd) + except PythonEnvironmentError as e: + self.logger.error(f"Failed to launch shell for {env_name}: {e}") + return False \ No newline at end of file From 3d890fda9a148c97a45e86d5452b3af445f5099a Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 01:49:43 +0900 Subject: [PATCH 21/48] [Update] CLI commands for Hatch **Major**: - Covers all the new API for python environment management and information printing. --- hatch/cli_hatch.py | 225 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 1 deletion(-) diff --git a/hatch/cli_hatch.py b/hatch/cli_hatch.py index cea6b8b..37bb8bd 100644 --- a/hatch/cli_hatch.py +++ b/hatch/cli_hatch.py @@ -56,6 +56,9 @@ def main(): env_create_parser = env_subparsers.add_parser("create", help="Create a new environment") env_create_parser.add_argument("name", help="Environment name") env_create_parser.add_argument("--description", "-D", default="", help="Environment description") + env_create_parser.add_argument("--python-version", help="Python version for the environment (e.g., 3.11, 3.12)") + env_create_parser.add_argument("--no-python", action="store_true", + help="Don't create a Python environment using conda/mamba") # Remove environment command env_remove_parser = env_subparsers.add_parser("remove", help="Remove an environment") @@ -71,6 +74,41 @@ def main(): # Show current environment command env_subparsers.add_parser("current", help="Show the current environment") + # Python environment management commands - advanced subcommands + env_python_subparsers = env_subparsers.add_parser("python", help="Manage Python environments").add_subparsers( + dest="python_command", help="Python environment command to execute" + ) + + # Initialize Python environment + python_init_parser = env_python_subparsers.add_parser("init", help="Initialize Python environment") + python_init_parser.add_argument("name", help="Environment name") + python_init_parser.add_argument("--python-version", help="Python version (e.g., 3.11, 3.12)") + python_init_parser.add_argument("--force", action="store_true", help="Force recreation if exists") + + # Show Python environment info + python_info_parser = env_python_subparsers.add_parser("info", help="Show Python environment information") + python_info_parser.add_argument("name", help="Environment name") + python_info_parser.add_argument("--detailed", action="store_true", help="Show detailed diagnostics") + + # Remove Python environment + python_remove_parser = env_python_subparsers.add_parser("remove", help="Remove Python environment") + python_remove_parser.add_argument("name", help="Environment name") + python_remove_parser.add_argument("--force", action="store_true", help="Force removal without confirmation") + + # Launch Python shell + python_shell_parser = env_python_subparsers.add_parser("shell", help="Launch Python shell in environment") + python_shell_parser.add_argument("name", help="Environment name") + python_shell_parser.add_argument("--cmd", help="Command to run in the shell (optional)") + + # Legacy Python environment management (backward compatibility) + env_python_parser = env_subparsers.add_parser("python-legacy", help="Legacy Python environment commands") + env_python_parser.add_argument("action", choices=["add", "remove", "info"], + help="Python environment action") + env_python_parser.add_argument("name", help="Environment name") + env_python_parser.add_argument("--python-version", help="Python version (for add action)") + env_python_parser.add_argument("--force", action="store_true", + help="Force recreation (for add action)") + # Package management commands pkg_subparsers = subparsers.add_parser("package", help="Package management commands").add_subparsers( dest="pkg_command", help="Package command to execute" @@ -132,8 +170,28 @@ def main(): elif args.command == "env": if args.env_command == "create": - if env_manager.create_environment(args.name, args.description): + # Determine whether to create Python environment + create_python_env = not args.no_python + python_version = getattr(args, 'python_version', None) + + if env_manager.create_environment(args.name, args.description, + python_version=python_version, + create_python_env=create_python_env): print(f"Environment created: {args.name}") + + # Show Python environment status + if create_python_env and env_manager.is_python_environment_available(): + python_exec = env_manager.python_env_manager.get_python_executable(args.name) + if python_exec: + python_version_info = env_manager.python_env_manager.get_python_version(args.name) + print(f"Python environment: {python_exec}") + if python_version_info: + print(f"Python version: {python_version_info}") + else: + print("Python environment creation failed") + elif create_python_env: + print("Python environment requested but conda/mamba not available") + return 0 else: print(f"Failed to create environment: {args.name}") @@ -150,10 +208,42 @@ def main(): elif args.env_command == "list": environments = env_manager.list_environments() print("Available environments:") + + # Check if conda/mamba is available for status info + conda_available = env_manager.is_python_environment_available() + for env in environments: current_marker = "* " if env.get("is_current") else " " description = f" - {env.get('description')}" if env.get("description") else "" + + # Show basic environment info print(f"{current_marker}{env.get('name')}{description}") + + # Show Python environment info if available + python_env = env.get("python_environment", False) + if python_env: + python_info = env_manager.get_python_environment_info(env.get('name')) + if python_info: + python_version = python_info.get('python_version', 'Unknown') + conda_env = python_info.get('conda_env_name', 'N/A') + print(f" Python: {python_version} (conda: {conda_env})") + else: + print(f" Python: Configured but unavailable") + elif conda_available: + print(f" Python: Not configured") + else: + print(f" Python: Conda/mamba not available") + + # Show conda/mamba status + if conda_available: + manager_info = env_manager.python_env_manager.get_manager_info() + print(f"\nPython Environment Manager:") + print(f" Conda executable: {manager_info.get('conda_executable', 'Not found')}") + print(f" Mamba executable: {manager_info.get('mamba_executable', 'Not found')}") + print(f" Preferred manager: {manager_info.get('preferred_manager', 'N/A')}") + else: + print(f"\nPython Environment Manager: Conda/mamba not available") + return 0 elif args.env_command == "use": @@ -168,6 +258,139 @@ def main(): current_env = env_manager.get_current_environment() print(f"Current environment: {current_env}") return 0 + + elif args.env_command == "python": + # Advanced Python environment management + if hasattr(args, 'python_command'): + if args.python_command == "init": + python_version = getattr(args, 'python_version', None) + force = getattr(args, 'force', False) + + if env_manager.create_python_environment_only(args.name, python_version, force): + print(f"Python environment initialized for: {args.name}") + + # Show Python environment info + python_info = env_manager.get_python_environment_info(args.name) + if python_info: + print(f" Python executable: {python_info['python_executable']}") + print(f" Python version: {python_info.get('python_version', 'Unknown')}") + print(f" Conda environment: {python_info.get('conda_env_name', 'N/A')}") + + return 0 + else: + print(f"Failed to initialize Python environment for: {args.name}") + return 1 + + elif args.python_command == "info": + detailed = getattr(args, 'detailed', False) + python_info = env_manager.get_python_environment_info(args.name) + + if python_info: + print(f"Python environment info for '{args.name}':") + print(f" Status: {'Active' if python_info.get('enabled', False) else 'Inactive'}") + print(f" Python executable: {python_info['python_executable']}") + print(f" Python version: {python_info.get('python_version', 'Unknown')}") + print(f" Conda environment: {python_info.get('conda_env_name', 'N/A')}") + print(f" Environment path: {python_info['environment_path']}") + print(f" Created: {python_info.get('created_at', 'Unknown')}") + print(f" Package count: {python_info.get('package_count', 0)}") + + if detailed: + print(f"\nDiagnostics:") + diagnostics = env_manager.get_python_environment_diagnostics(args.name) + if diagnostics: + for key, value in diagnostics.items(): + print(f" {key}: {value}") + else: + print(" No diagnostics available") + + return 0 + else: + print(f"No Python environment found for: {args.name}") + + # Show diagnostics for missing environment + if detailed: + print("\nDiagnostics:") + general_diagnostics = env_manager.get_python_manager_diagnostics() + for key, value in general_diagnostics.items(): + print(f" {key}: {value}") + + return 1 + + elif args.python_command == "remove": + force = getattr(args, 'force', False) + + if not force: + # Ask for confirmation + response = input(f"Remove Python environment for '{args.name}'? [y/N]: ") + if response.lower() not in ['y', 'yes']: + print("Operation cancelled") + return 0 + + if env_manager.remove_python_environment_only(args.name): + print(f"Python environment removed from: {args.name}") + return 0 + else: + print(f"Failed to remove Python environment from: {args.name}") + return 1 + + elif args.python_command == "shell": + cmd = getattr(args, 'cmd', None) + + if env_manager.launch_python_shell(args.name, cmd): + return 0 + else: + print(f"Failed to launch Python shell for: {args.name}") + return 1 + else: + print("Unknown Python environment command") + return 1 + else: + print("No Python subcommand specified") + return 1 + + elif args.env_command == "python-legacy": + # Legacy Python environment commands for backward compatibility + if args.action == "add": + python_version = getattr(args, 'python_version', None) + force = getattr(args, 'force', False) + + if env_manager.create_python_environment_only(args.name, python_version, force): + print(f"Python environment added to: {args.name}") + + # Show Python environment info + python_exec = env_manager.python_env_manager.get_python_executable(args.name) + if python_exec: + python_version_info = env_manager.python_env_manager.get_python_version(args.name) + print(f"Python executable: {python_exec}") + if python_version_info: + print(f"Python version: {python_version_info}") + + return 0 + else: + print(f"Failed to add Python environment to: {args.name}") + return 1 + + elif args.action == "remove": + if env_manager.remove_python_environment_only(args.name): + print(f"Python environment removed from: {args.name}") + return 0 + else: + print(f"Failed to remove Python environment from: {args.name}") + return 1 + + elif args.action == "info": + python_info = env_manager.get_python_environment_info(args.name) + if python_info: + print(f"Python environment info for {args.name}:") + print(f" Path: {python_info['environment_path']}") + print(f" Python executable: {python_info['python_executable']}") + print(f" Python version: {python_info.get('python_version', 'Unknown')}") + print(f" Package count: {python_info.get('package_count', 0)}") + return 0 + else: + print(f"No Python environment found for: {args.name}") + return 1 else: parser.print_help() return 1 From fb675a1489f2e52906fa3c230b53f6c5239bda6e Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 01:52:28 +0900 Subject: [PATCH 22/48] [Add] Tests for python environments **Major**: - Mocking, Integration, and CLI tests - The tests for the shell commands are passing, but shell activation via hatch command may fail on windows with mamba, if the user has not initiatest its shell first --- tests/run_environment_tests.py | 17 + tests/test_python_environment_manager.py | 683 +++++++++++++++++++++++ 2 files changed, 700 insertions(+) create mode 100644 tests/test_python_environment_manager.py diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index 34da9e7..72dce13 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -53,6 +53,23 @@ test_mocking = test_loader.loadTestsFromName("test_python_installer.TestPythonInstaller") test_integration = test_loader.loadTestsFromName("test_python_installer.TestPythonInstallerIntegration") test_suite = unittest.TestSuite([test_mocking, test_integration]) + elif len(sys.argv) > 1 and sys.argv[1] == "--python-env-manager-only": + # Run only PythonEnvironmentManager tests (mocked) + logger.info("Running PythonEnvironmentManager mocked tests only...") + test_suite = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManager") + elif len(sys.argv) > 1 and sys.argv[1] == "--python-env-manager-integration": + # Run only PythonEnvironmentManager integration tests (requires conda/mamba) + logger.info("Running PythonEnvironmentManager integration tests only...") + test_integration = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerIntegration") + #test_enhanced = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerEnhancedFeatures") + test_suite = unittest.TestSuite([test_integration])#, test_enhanced]) + elif len(sys.argv) > 1 and sys.argv[1] == "--python-env-manager-all": + # Run all PythonEnvironmentManager tests + logger.info("Running all PythonEnvironmentManager tests...") + test_mocked = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManager") + test_integration = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerIntegration") + test_enhanced = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerEnhancedFeatures") + test_suite = unittest.TestSuite([test_mocked, test_integration, test_enhanced]) elif len(sys.argv) > 1 and sys.argv[1] == "--system-installer-only": # Run only SystemInstaller tests logger.info("Running SystemInstaller tests only...") diff --git a/tests/test_python_environment_manager.py b/tests/test_python_environment_manager.py new file mode 100644 index 0000000..90c66ab --- /dev/null +++ b/tests/test_python_environment_manager.py @@ -0,0 +1,683 @@ +"""Tests for PythonEnvironmentManager. + +This module contains tests for the Python environment management functionality, +including conda/mamba environment creation, configuration, and integration. +""" +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +from hatch.python_environment_manager import PythonEnvironmentManager, PythonEnvironmentError + + +class TestPythonEnvironmentManager(unittest.TestCase): + """Test cases for PythonEnvironmentManager functionality.""" + + def setUp(self): + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.environments_dir = Path(self.temp_dir) / "envs" + self.environments_dir.mkdir(exist_ok=True) + + # Create manager instance for testing + self.manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + + def tearDown(self): + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_init(self): + """Test PythonEnvironmentManager initialization.""" + self.assertEqual(self.manager.environments_dir, self.environments_dir) + self.assertIsNotNone(self.manager.logger) + + @patch('shutil.which') + def test_detect_conda_mamba_with_mamba(self, mock_which): + """Test conda/mamba detection when mamba is available.""" + # Mock mamba available + mock_which.side_effect = lambda cmd: "/usr/bin/mamba" if cmd == "mamba" else "/usr/bin/conda" + + with patch('subprocess.run') as mock_run: + # Mock successful mamba version check + mock_run.return_value = Mock(returncode=0, stdout="mamba 0.24.0") + + manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + + self.assertEqual(manager.mamba_executable, "/usr/bin/mamba") + self.assertEqual(manager.conda_executable, "/usr/bin/conda") + + @patch('shutil.which') + def test_detect_conda_mamba_conda_only(self, mock_which): + """Test conda/mamba detection when only conda is available.""" + # Mock only conda available + mock_which.side_effect = lambda cmd: "/usr/bin/conda" if cmd == "conda" else None + + with patch('subprocess.run') as mock_run: + # Mock successful conda version check + mock_run.return_value = Mock(returncode=0, stdout="conda 4.12.0") + + manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + + self.assertIsNone(manager.mamba_executable) + self.assertEqual(manager.conda_executable, "/usr/bin/conda") + + @patch('shutil.which') + def test_detect_conda_mamba_none_available(self, mock_which): + """Test conda/mamba detection when neither is available.""" + # Mock neither available + mock_which.return_value = None + + manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + + self.assertIsNone(manager.mamba_executable) + self.assertIsNone(manager.conda_executable) + + def test_get_conda_env_name(self): + """Test conda environment name generation.""" + env_name = "test_env" + conda_name = self.manager._get_conda_env_name(env_name) + self.assertEqual(conda_name, "hatch_test_env") + + def test_get_conda_env_prefix(self): + """Test conda environment prefix path generation.""" + env_name = "test_env" + prefix = self.manager._get_conda_env_prefix(env_name) + expected = self.environments_dir / "test_env" / "python_env" + self.assertEqual(prefix, expected) + + def test_get_python_executable_path_windows(self): + """Test Python executable path on Windows.""" + with patch('platform.system', return_value='Windows'): + env_name = "test_env" + python_path = self.manager._get_python_executable_path(env_name) + expected = self.environments_dir / "test_env" / "python_env" / "python.exe" + self.assertEqual(python_path, expected) + + def test_get_python_executable_path_unix(self): + """Test Python executable path on Unix/Linux.""" + with patch('platform.system', return_value='Linux'): + env_name = "test_env" + python_path = self.manager._get_python_executable_path(env_name) + expected = self.environments_dir / "test_env" / "python_env" / "bin" / "python" + self.assertEqual(python_path, expected) + + def test_is_available_no_conda(self): + """Test availability check when conda/mamba is not available.""" + manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + manager.conda_executable = None + manager.mamba_executable = None + + self.assertFalse(manager.is_available()) + + @patch('subprocess.run') + def test_is_available_with_conda(self, mock_run): + """Test availability check when conda is available.""" + self.manager.conda_executable = "/usr/bin/conda" + + # Mock successful conda info + mock_run.return_value = Mock(returncode=0, stdout='{"platform": "linux-64"}') + + self.assertTrue(self.manager.is_available()) + + def test_get_preferred_executable(self): + """Test preferred executable selection.""" + # Test mamba preferred over conda + self.manager.mamba_executable = "/usr/bin/mamba" + self.manager.conda_executable = "/usr/bin/conda" + self.assertEqual(self.manager.get_preferred_executable(), "/usr/bin/mamba") + + # Test conda when mamba not available + self.manager.mamba_executable = None + self.assertEqual(self.manager.get_preferred_executable(), "/usr/bin/conda") + + # Test None when neither available + self.manager.conda_executable = None + self.assertIsNone(self.manager.get_preferred_executable()) + + @patch('shutil.which') + @patch('subprocess.run') + def test_create_python_environment_success(self, mock_run, mock_which): + """Test successful Python environment creation.""" + # Patch mamba detection + mock_which.side_effect = lambda cmd: "/usr/bin/mamba" if cmd == "mamba" else None + + # Patch subprocess.run for both validation and creation + def run_side_effect(cmd, *args, **kwargs): + if "info" in cmd: + # Validation call + return Mock(returncode=0, stdout='{"platform": "win-64"}') + elif "create" in cmd: + # Environment creation call + return Mock(returncode=0, stdout="Environment created") + else: + return Mock(returncode=0, stdout="") + mock_run.side_effect = run_side_effect + + manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + + # Mock environment existence check + with patch.object(manager, '_conda_env_exists', return_value=False): + result = manager.create_python_environment("test_env", python_version="3.11") + self.assertTrue(result) + mock_run.assert_called() + + def test_create_python_environment_no_conda(self): + """Test Python environment creation when conda/mamba is not available.""" + self.manager.conda_executable = None + self.manager.mamba_executable = None + + with self.assertRaises(PythonEnvironmentError): + self.manager.create_python_environment("test_env") + + @patch('shutil.which') + @patch('subprocess.run') + def test_create_python_environment_already_exists(self, mock_run, mock_which): + """Test Python environment creation when environment already exists.""" + # Patch mamba detection + mock_which.side_effect = lambda cmd: "/usr/bin/mamba" if cmd == "mamba" else None + + # Patch subprocess.run for both validation and creation + def run_side_effect(cmd, *args, **kwargs): + if "info" in cmd: + # Validation call + return Mock(returncode=0, stdout='{"platform": "win-64"}') + elif "create" in cmd: + # Environment creation call + return Mock(returncode=0, stdout="Environment created") + else: + return Mock(returncode=0, stdout="") + mock_run.side_effect = run_side_effect + + # Mock environment already exists + with patch.object(self.manager, '_conda_env_exists', return_value=True): + result = self.manager.create_python_environment("test_env") + self.assertTrue(result) + # Ensure 'create' was not called, but 'info' was + create_calls = [call for call in mock_run.call_args_list if "create" in call[0][0]] + self.assertEqual(len(create_calls), 0) + + def test_conda_env_exists(self): + """Test conda environment existence check.""" + env_name = "test_env" + + # Create the environment directory structure + env_prefix = self.manager._get_conda_env_prefix(env_name) + env_prefix.mkdir(parents=True, exist_ok=True) + + # Create Python executable + python_executable = self.manager._get_python_executable_path(env_name) + python_executable.parent.mkdir(parents=True, exist_ok=True) + python_executable.write_text("#!/usr/bin/env python") + + self.assertTrue(self.manager._conda_env_exists(env_name)) + + def test_conda_env_not_exists(self): + """Test conda environment existence check when environment doesn't exist.""" + env_name = "nonexistent_env" + self.assertFalse(self.manager._conda_env_exists(env_name)) + + def test_get_python_executable_exists(self): + """Test getting Python executable when environment exists.""" + env_name = "test_env" + + # Create environment and Python executable + python_executable = self.manager._get_python_executable_path(env_name) + python_executable.parent.mkdir(parents=True, exist_ok=True) + python_executable.write_text("#!/usr/bin/env python") + + with patch.object(self.manager, '_conda_env_exists', return_value=True): + result = self.manager.get_python_executable(env_name) + self.assertEqual(result, str(python_executable)) + + def test_get_python_executable_not_exists(self): + """Test getting Python executable when environment doesn't exist.""" + env_name = "nonexistent_env" + + with patch.object(self.manager, '_conda_env_exists', return_value=False): + result = self.manager.get_python_executable(env_name) + self.assertIsNone(result) + + +class TestPythonEnvironmentManagerIntegration(unittest.TestCase): + """Integration test cases for PythonEnvironmentManager with real conda/mamba operations. + + These tests require conda or mamba to be installed on the system and will create + real conda environments for testing. They are more comprehensive but slower than + the mocked unit tests. + """ + + @classmethod + def setUpClass(cls): + """Set up class-level test environment.""" + cls.temp_dir = tempfile.mkdtemp() + cls.environments_dir = Path(cls.temp_dir) / "envs" + cls.environments_dir.mkdir(exist_ok=True) + + # Create manager instance for integration testing + cls.manager = PythonEnvironmentManager(environments_dir=cls.environments_dir) + + # Skip all tests if conda/mamba is not available + if not cls.manager.is_available(): + raise unittest.SkipTest("Conda/mamba not available for integration tests") + + @classmethod + def tearDownClass(cls): + """Clean up class-level test environment.""" + # Clean up any test environments that might have been created + try: + test_envs = ["test_integration_env", "test_python_311", "test_python_312", "test_diagnostics_env"] + for env_name in test_envs: + if cls.manager.environment_exists(env_name): + cls.manager.remove_python_environment(env_name) + except Exception: + pass # Best effort cleanup + + shutil.rmtree(cls.temp_dir, ignore_errors=True) + + def test_conda_mamba_detection_real(self): + """Test real conda/mamba detection on the system.""" + manager_info = self.manager.get_manager_info() + + # At least one should be available since we skip tests if neither is available + self.assertTrue(manager_info["is_available"]) + self.assertTrue( + manager_info["conda_executable"] is not None or + manager_info["mamba_executable"] is not None + ) + + # Preferred manager should be set + self.assertIsNotNone(manager_info["preferred_manager"]) + + # Platform and Python version should be populated + self.assertIsNotNone(manager_info["platform"]) + self.assertIsNotNone(manager_info["python_version"]) + + def test_manager_diagnostics_real(self): + """Test real manager diagnostics.""" + diagnostics = self.manager.get_manager_diagnostics() + + # Should have basic information + self.assertIn("any_manager_available", diagnostics) + self.assertTrue(diagnostics["any_manager_available"]) + self.assertIn("platform", diagnostics) + self.assertIn("python_version", diagnostics) + self.assertIn("environments_dir", diagnostics) + + # Should test actual executables + if diagnostics["conda_executable"]: + self.assertIn("conda_works", diagnostics) + self.assertIn("conda_version", diagnostics) + + if diagnostics["mamba_executable"]: + self.assertIn("mamba_works", diagnostics) + self.assertIn("mamba_version", diagnostics) + + def test_create_and_remove_python_environment_real(self): + """Test real Python environment creation and removal.""" + env_name = "test_integration_env" + + # Ensure environment doesn't exist initially + if self.manager.environment_exists(env_name): + self.manager.remove_python_environment(env_name) + + # Create environment + result = self.manager.create_python_environment(env_name) + self.assertTrue(result, "Failed to create Python environment") + + # Verify environment exists + self.assertTrue(self.manager.environment_exists(env_name)) + + # Verify Python executable is available + python_exec = self.manager.get_python_executable(env_name) + self.assertIsNotNone(python_exec, "Python executable not found") + self.assertTrue(Path(python_exec).exists(), f"Python executable doesn't exist: {python_exec}") + + # Get environment info + env_info = self.manager.get_environment_info(env_name) + self.assertIsNotNone(env_info) + self.assertEqual(env_info["environment_name"], env_name) + self.assertIsNotNone(env_info["conda_env_name"]) + self.assertIsNotNone(env_info["python_executable"]) + + # Remove environment + result = self.manager.remove_python_environment(env_name) + self.assertTrue(result, "Failed to remove Python environment") + + # Verify environment no longer exists + self.assertFalse(self.manager.environment_exists(env_name)) + + def test_create_python_environment_with_version_real(self): + """Test real Python environment creation with specific version.""" + env_name = "test_python_311" + python_version = "3.11" + + # Ensure environment doesn't exist initially + if self.manager.environment_exists(env_name): + self.manager.remove_python_environment(env_name) + + # Create environment with specific Python version + result = self.manager.create_python_environment(env_name, python_version=python_version) + self.assertTrue(result, f"Failed to create Python {python_version} environment") + + # Verify environment exists + self.assertTrue(self.manager.environment_exists(env_name)) + + # Verify Python version + actual_version = self.manager.get_python_version(env_name) + self.assertIsNotNone(actual_version) + self.assertTrue(actual_version.startswith("3.11"), f"Expected Python 3.11.x, got {actual_version}") + + # Get comprehensive environment info + env_info = self.manager.get_environment_info(env_name) + self.assertIsNotNone(env_info) + self.assertTrue(env_info["python_version"].startswith("3.11"), f"Expected Python 3.11.x, got {env_info['python_version']}") + + # Cleanup + self.manager.remove_python_environment(env_name) + + def test_environment_diagnostics_real(self): + """Test real environment diagnostics.""" + env_name = "test_diagnostics_env" + + # Ensure environment doesn't exist initially + if self.manager.environment_exists(env_name): + self.manager.remove_python_environment(env_name) + + # Test diagnostics for non-existent environment + diagnostics = self.manager.get_environment_diagnostics(env_name) + self.assertFalse(diagnostics["exists"]) + self.assertTrue(diagnostics["conda_available"]) + + # Create environment + self.manager.create_python_environment(env_name) + + # Test diagnostics for existing environment + diagnostics = self.manager.get_environment_diagnostics(env_name) + self.assertTrue(diagnostics["exists"]) + self.assertIsNotNone(diagnostics["python_executable"]) + self.assertTrue(diagnostics["python_accessible"]) + self.assertIsNotNone(diagnostics["python_version"]) + self.assertTrue(diagnostics["python_version_accessible"]) + self.assertTrue(diagnostics["python_executable_works"]) + self.assertIsNotNone(diagnostics["environment_path"]) + self.assertTrue(diagnostics["environment_path_exists"]) + + # Cleanup + self.manager.remove_python_environment(env_name) + + def test_force_recreation_real(self): + """Test force recreation of existing environment.""" + env_name = "test_integration_env" + + # Ensure environment doesn't exist initially + if self.manager.environment_exists(env_name): + self.manager.remove_python_environment(env_name) + + # Create environment + result1 = self.manager.create_python_environment(env_name) + self.assertTrue(result1) + + # Get initial Python executable + python_exec1 = self.manager.get_python_executable(env_name) + self.assertIsNotNone(python_exec1) + + # Try to create again without force (should succeed but not recreate) + result2 = self.manager.create_python_environment(env_name, force=False) + self.assertTrue(result2) + + # Try to create again with force (should recreate) + result3 = self.manager.create_python_environment(env_name, force=True) + self.assertTrue(result3) + + # Verify environment still exists and works + self.assertTrue(self.manager.environment_exists(env_name)) + python_exec3 = self.manager.get_python_executable(env_name) + self.assertIsNotNone(python_exec3) + + # Cleanup + self.manager.remove_python_environment(env_name) + + def test_list_environments_real(self): + """Test listing environments with real conda environments.""" + test_envs = ["test_env_1", "test_env_2"] + + # Clean up any existing test environments + for env_name in test_envs: + if self.manager.environment_exists(env_name): + self.manager.remove_python_environment(env_name) + + # Create test environments + for env_name in test_envs: + result = self.manager.create_python_environment(env_name) + self.assertTrue(result, f"Failed to create {env_name}") + + # List environments + env_list = self.manager.list_environments() + + # Should include our test environments + for env_name in test_envs: + self.assertIn(env_name, env_list, f"{env_name} not found in environment list") + + # Cleanup + for env_name in test_envs: + self.manager.remove_python_environment(env_name) + + @unittest.skipIf( + not (Path("/usr/bin/python3.12").exists() or Path("/usr/bin/python3.9").exists()), + "Multiple Python versions not available for testing" + ) + def test_multiple_python_versions_real(self): + """Test creating environments with multiple Python versions.""" + test_cases = [ + ("test_python_39", "3.9"), + ("test_python_312", "3.12") + ] + + created_envs = [] + + try: + for env_name, python_version in test_cases: + # Skip if this Python version is not available + try: + result = self.manager.create_python_environment(env_name, python_version=python_version) + if result: + created_envs.append(env_name) + + # Verify Python version + actual_version = self.manager.get_python_version(env_name) + self.assertIsNotNone(actual_version) + self.assertTrue( + actual_version.startswith(python_version), + f"Expected Python {python_version}.x, got {actual_version}" + ) + except Exception as e: + # Log but don't fail test if specific Python version is not available + print(f"Skipping Python {python_version} test: {e}") + + finally: + # Cleanup + for env_name in created_envs: + try: + self.manager.remove_python_environment(env_name) + except Exception: + pass # Best effort cleanup + + def test_error_handling_real(self): + """Test error handling with real operations.""" + # Test removing non-existent environment + result = self.manager.remove_python_environment("nonexistent_env") + self.assertTrue(result) # Removing non existent environment returns True because it does nothing + + # Test getting info for non-existent environment + info = self.manager.get_environment_info("nonexistent_env") + self.assertIsNone(info) + + # Test getting Python executable for non-existent environment + python_exec = self.manager.get_python_executable("nonexistent_env") + self.assertIsNone(python_exec) + + # Test diagnostics for non-existent environment + diagnostics = self.manager.get_environment_diagnostics("nonexistent_env") + self.assertFalse(diagnostics["exists"]) + + +class TestPythonEnvironmentManagerEnhancedFeatures(unittest.TestCase): + """Test cases for enhanced features like shell launching and advanced diagnostics.""" + + def setUp(self): + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.environments_dir = Path(self.temp_dir) / "envs" + self.environments_dir.mkdir(exist_ok=True) + + # Create manager instance for testing + self.manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + + def tearDown(self): + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch('subprocess.run') + def test_launch_shell_with_command(self, mock_run): + """Test launching shell with specific command.""" + env_name = "test_shell_env" + cmd = "print('Hello from Python')" + + # Mock environment existence and Python executable + with patch.object(self.manager, 'environment_exists', return_value=True), \ + patch.object(self.manager, 'get_python_executable', return_value="/path/to/python"): + + mock_run.return_value = Mock(returncode=0) + + result = self.manager.launch_shell(env_name, cmd) + self.assertTrue(result) + + # Verify subprocess was called with correct arguments + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + self.assertIn("/path/to/python", call_args) + self.assertIn("-c", call_args) + self.assertIn(cmd, call_args) + + @patch('subprocess.run') + @patch('platform.system') + def test_launch_shell_interactive_windows(self, mock_platform, mock_run): + """Test launching interactive shell on Windows.""" + mock_platform.return_value = "Windows" + env_name = "test_shell_env" + + # Mock environment existence and Python executable + with patch.object(self.manager, 'environment_exists', return_value=True), \ + patch.object(self.manager, 'get_python_executable', return_value="/path/to/python"): + + mock_run.return_value = Mock(returncode=0) + + result = self.manager.launch_shell(env_name) + self.assertTrue(result) + + # Verify subprocess was called for Windows + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + self.assertIn("cmd", call_args) + self.assertIn("/c", call_args) + + @patch('subprocess.run') + @patch('platform.system') + def test_launch_shell_interactive_unix(self, mock_platform, mock_run): + """Test launching interactive shell on Unix.""" + mock_platform.return_value = "Linux" + env_name = "test_shell_env" + + # Mock environment existence and Python executable + with patch.object(self.manager, 'environment_exists', return_value=True), \ + patch.object(self.manager, 'get_python_executable', return_value="/path/to/python"): + + mock_run.return_value = Mock(returncode=0) + + result = self.manager.launch_shell(env_name) + self.assertTrue(result) + + # Verify subprocess was called with Python executable directly + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + self.assertEqual(call_args, ["/path/to/python"]) + + def test_launch_shell_nonexistent_environment(self): + """Test launching shell for non-existent environment.""" + env_name = "nonexistent_env" + + with patch.object(self.manager, 'environment_exists', return_value=False): + result = self.manager.launch_shell(env_name) + self.assertFalse(result) + + def test_launch_shell_no_python_executable(self): + """Test launching shell when Python executable is not found.""" + env_name = "test_shell_env" + + with patch.object(self.manager, 'environment_exists', return_value=True), \ + patch.object(self.manager, 'get_python_executable', return_value=None): + + result = self.manager.launch_shell(env_name) + self.assertFalse(result) + + def test_get_manager_info_structure(self): + """Test manager info structure and content.""" + info = self.manager.get_manager_info() + + # Verify required fields are present + required_fields = [ + "conda_executable", "mamba_executable", "preferred_manager", + "is_available", "platform", "python_version" + ] + + for field in required_fields: + self.assertIn(field, info, f"Missing required field: {field}") + + # Verify data types + self.assertIsInstance(info["is_available"], bool) + self.assertIsInstance(info["platform"], str) + self.assertIsInstance(info["python_version"], str) + + def test_environment_diagnostics_structure(self): + """Test environment diagnostics structure.""" + env_name = "test_diagnostics" + diagnostics = self.manager.get_environment_diagnostics(env_name) + + # Verify required fields are present + required_fields = [ + "environment_name", "conda_env_name", "exists", "conda_available", + "manager_executable", "platform" + ] + + for field in required_fields: + self.assertIn(field, diagnostics, f"Missing required field: {field}") + + # Verify basic structure + self.assertEqual(diagnostics["environment_name"], env_name) + self.assertEqual(diagnostics["conda_env_name"], f"hatch-{env_name}") + self.assertIsInstance(diagnostics["exists"], bool) + self.assertIsInstance(diagnostics["conda_available"], bool) + + def test_manager_diagnostics_structure(self): + """Test manager diagnostics structure.""" + diagnostics = self.manager.get_manager_diagnostics() + + # Verify required fields are present + required_fields = [ + "conda_executable", "mamba_executable", "conda_available", "mamba_available", + "any_manager_available", "preferred_manager", "platform", "python_version", + "environments_dir" + ] + + for field in required_fields: + self.assertIn(field, diagnostics, f"Missing required field: {field}") + + # Verify data types + self.assertIsInstance(diagnostics["conda_available"], bool) + self.assertIsInstance(diagnostics["mamba_available"], bool) + self.assertIsInstance(diagnostics["any_manager_available"], bool) + self.assertIsInstance(diagnostics["platform"], str) + self.assertIsInstance(diagnostics["python_version"], str) + self.assertIsInstance(diagnostics["environments_dir"], str) From d196d0579da3d04eff31441fa678f7fd0ab2a8a8 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 11:57:39 +0900 Subject: [PATCH 23/48] [Update] Docker availability **Major**: - Mostly to facilitate testing - Differentiated the case where the docker library is available (`DOCKER_AVAILABLE`) and the case where the daemon is avaialble (`DOCKER_DAEMON_AVAILABLE`) - We check availability of the daemon only if the library is installed --- hatch/installers/docker_installer.py | 26 ++++++++++++++------------ tests/test_docker_installer.py | 16 ++++++++-------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/hatch/installers/docker_installer.py b/hatch/installers/docker_installer.py index 8a4afd4..5283521 100644 --- a/hatch/installers/docker_installer.py +++ b/hatch/installers/docker_installer.py @@ -15,16 +15,24 @@ logger = logging.getLogger(__name__) # Handle docker-py import with graceful fallback +DOCKER_AVAILABLE = False +DOCKER_DAEMON_AVAILABLE = False try: import docker from docker.errors import DockerException, ImageNotFound, APIError DOCKER_AVAILABLE = True + try: + _docker_client = docker.from_env() + _docker_client.ping() + DOCKER_DAEMON_AVAILABLE = True + except DockerException as e: + DOCKER_DAEMON_AVAILABLE = False + logger.warning(f"docker-py library is available but Docker daemon is not running or not reachable: {e}") except ImportError: docker = None DockerException = Exception ImageNotFound = Exception APIError = Exception - DOCKER_AVAILABLE = False logger.warning("docker-py library not available. Docker installer will be disabled.") @@ -283,21 +291,15 @@ def cleanup_failed_installation(self, dependency: Dict[str, Any], context: Insta def _is_docker_available(self) -> bool: """Check if Docker daemon is available. + + We use the global DOCKER_DAEMON_AVAILABLE flag to determine + if Docker is available. It is set to True if the docker-py + library is available and the Docker daemon is reachable. Returns: bool: True if Docker daemon is available, False otherwise. """ - if not DOCKER_AVAILABLE: - logger.warning("Docker library not available, cannot check Docker daemon status. Install docker-py to enable Docker support.") - return False - - try: - client = self._get_docker_client() - client.ping() - return True - except Exception as e: - logger.debug(f"Docker daemon not available: {e}") - return False + return DOCKER_DAEMON_AVAILABLE def _get_docker_client(self): """Get or create Docker client. diff --git a/tests/test_docker_installer.py b/tests/test_docker_installer.py index 35f8ee5..437d9e7 100644 --- a/tests/test_docker_installer.py +++ b/tests/test_docker_installer.py @@ -11,7 +11,7 @@ from unittest.mock import patch, MagicMock, Mock from typing import Dict, Any -from hatch.installers.docker_installer import DockerInstaller, DOCKER_AVAILABLE +from hatch.installers.docker_installer import DockerInstaller, DOCKER_AVAILABLE, DOCKER_DAEMON_AVAILABLE from hatch.installers.installer_base import InstallationError from hatch.installers.installation_context import InstallationContext, InstallationResult, InstallationStatus @@ -102,7 +102,7 @@ def test_can_install_wrong_type(self): f"can_install should return False for non-docker dependency: {dependency}" ) - @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @unittest.skipUnless(DOCKER_AVAILABLE and DOCKER_DAEMON_AVAILABLE, f"Docker library not available or Docker daemon not available: library={DOCKER_AVAILABLE}, daemon={DOCKER_DAEMON_AVAILABLE}") def test_can_install_docker_unavailable(self): """Test can_install when Docker daemon is unavailable.""" dependency = { @@ -251,7 +251,7 @@ def progress_callback(message, percent, status): f"Simulation install should call progress_callback twice (start and completion), got {len(progress_calls)} calls: {progress_calls}" ) - @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @unittest.skipUnless(DOCKER_AVAILABLE and DOCKER_DAEMON_AVAILABLE, f"Docker library not available or Docker daemon not available: library={DOCKER_AVAILABLE}, daemon={DOCKER_DAEMON_AVAILABLE}") @patch('hatch.installers.docker_installer.docker') def test_install_success(self, mock_docker): """Test successful Docker image installation.""" @@ -279,7 +279,7 @@ def progress_callback(message, percent, status): f"Install should call progress_callback at least once, got {len(progress_calls)} calls: {progress_calls}" ) - @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @unittest.skipUnless(DOCKER_AVAILABLE and DOCKER_DAEMON_AVAILABLE, f"Docker library not available or Docker daemon not available: library={DOCKER_AVAILABLE}, daemon={DOCKER_DAEMON_AVAILABLE}") @patch('hatch.installers.docker_installer.docker') def test_install_failure(self, mock_docker): """Test Docker installation failure.""" @@ -305,7 +305,7 @@ def test_install_invalid_dependency(self): with self.assertRaises(InstallationError, msg=f"Install should raise InstallationError for invalid dependency: {dependency}"): self.installer.install(dependency, self.context) - @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @unittest.skipUnless(DOCKER_AVAILABLE and DOCKER_DAEMON_AVAILABLE, f"Docker library not available or Docker daemon not available: library={DOCKER_AVAILABLE}, daemon={DOCKER_DAEMON_AVAILABLE}") @patch('hatch.installers.docker_installer.docker') def test_uninstall_success(self, mock_docker): """Test successful Docker image uninstallation.""" @@ -372,7 +372,7 @@ def test_get_installation_info_docker_unavailable(self): f"get_installation_info: can_install should be False, got {info['can_install']}" ) - @unittest.skipUnless(DOCKER_AVAILABLE, "Docker library not available") + @unittest.skipUnless(DOCKER_AVAILABLE and DOCKER_DAEMON_AVAILABLE, f"Docker library not available or Docker daemon not available: library={DOCKER_AVAILABLE}, daemon={DOCKER_DAEMON_AVAILABLE}") @patch('hatch.installers.docker_installer.docker') def test_get_installation_info_image_installed(self, mock_docker): """Test get_installation_info for installed image.""" @@ -402,8 +402,8 @@ class TestDockerInstallerIntegration(unittest.TestCase): def setUp(self): """Set up integration test fixtures.""" - if not DOCKER_AVAILABLE: - self.skipTest("Docker library not available") + if not DOCKER_AVAILABLE or not DOCKER_DAEMON_AVAILABLE: + self.skipTest(f"Docker library not available or Docker daemon not available: library={DOCKER_AVAILABLE}, daemon={DOCKER_DAEMON_AVAILABLE}") self.installer = DockerInstaller() self.temp_dir = tempfile.mkdtemp() From 71dcf42c9b6aa44890513dcb347d4124a19465ad Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 12:44:56 +0900 Subject: [PATCH 24/48] [Update] Mamba/Conda discovery **Major**: - Increased the number of places where we are looking for mamba and conda exe to improve robustness of automatic discovery - It still remains deterministic, however. - I searched the options given by mamba, hoping to find a field indicating the path, but I could find anything satisfying. - In the future, we might include a hatch launch setting to let users give a path directly. - Refactored the discovery function to be interchangeable for conda and mamba since it's the same up to a name. **Minor**: - Updated the tests to match the new refactored function. --- hatch/python_environment_manager.py | 188 +++++++++++++++-------- tests/test_python_environment_manager.py | 42 ++--- 2 files changed, 136 insertions(+), 94 deletions(-) diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index 6dc24f6..b302892 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -40,7 +40,7 @@ def __init__(self, environments_dir: Optional[Path] = None): Defaults to ~/.hatch/envs. """ self.logger = logging.getLogger("hatch.python_environment_manager") - self.logger.setLevel(logging.INFO) + self.logger.setLevel(logging.DEBUG) # Set up environment directories self.environments_dir = environments_dir or (Path.home() / ".hatch" / "envs") @@ -58,74 +58,108 @@ def __init__(self, environments_dir: Optional[Path] = None): else: self.logger.warning("Neither conda nor mamba found - Python environment management will be limited") - def _detect_conda_mamba(self) -> None: - """Detect available conda/mamba executables on the system. - - Tries to find mamba first (preferred), then conda as fallback. - Sets self.mamba_executable and self.conda_executable based on availability. + def _detect_manager(self, manager: str) -> Optional[str]: + """Detect the given manager ('mamba' or 'conda') executable on the system. + + This function searches for the specified package manager in common installation paths + and checks if it is executable. + + Args: + manager (str): The name of the package manager to detect ('mamba' or 'conda'). + + Returns: + Optional[str]: The path to the detected executable, or None if not found. """ - # Try to detect mamba first (preferred) - try: - mamba_path = shutil.which("mamba") - if mamba_path: - # Verify mamba works - result = subprocess.run( - [mamba_path, "--version"], - capture_output=True, - text=True, - timeout=10 - ) - if result.returncode == 0: - self.mamba_executable = mamba_path - self.logger.debug(f"Detected mamba at: {mamba_path}") - except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): - self.logger.debug("Mamba not found or not working") - - # Try to detect conda - try: - conda_path = shutil.which("conda") - if conda_path: - # Verify conda works + def find_in_common_paths(names): + paths = [] + if platform.system() == "Windows": + candidates = [ + os.path.expandvars(r"%USERPROFILE%\miniconda3\Scripts"), + os.path.expandvars(r"%USERPROFILE%\Anaconda3\Scripts"), + r"C:\ProgramData\miniconda3\Scripts", + r"C:\ProgramData\Anaconda3\Scripts", + ] + else: + candidates = [ + os.path.expanduser("~/miniconda3/bin"), + os.path.expanduser("~/anaconda3/bin"), + "/opt/conda/bin", + ] + for base in candidates: + for name in names: + exe = os.path.join(base, name) + if os.path.isfile(exe) and os.access(exe, os.X_OK): + paths.append(exe) + return paths + + if platform.system() == "Windows": + exe_name = f"{manager}.exe" + else: + exe_name = manager + + # Try environment variable first + env_var = os.environ.get(f"{manager.upper()}_EXE") + paths = [env_var] if env_var else [] + paths += [shutil.which(exe_name)] + paths += find_in_common_paths([exe_name]) + paths = [p for p in paths if p] + + for path in paths: + self.logger.debug(f"Trying to detect {manager} at: {path}") + try: result = subprocess.run( - [conda_path, "--version"], - capture_output=True, - text=True, + [path, "--version"], + capture_output=True, + text=True, timeout=10 ) if result.returncode == 0: - self.conda_executable = conda_path - self.logger.debug(f"Detected conda at: {conda_path}") - except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): - self.logger.debug("Conda not found or not working") + self.logger.debug(f"Detected {manager} at: {path}") + return path + except Exception as e: + self.logger.warning(f"{manager.capitalize()} not found or not working at {path}: {e}") + return None - def _validate_conda_installation(self) -> bool: - """Validate that conda/mamba installation is functional. - - Returns: - bool: True if conda or mamba is available and functional, False otherwise. + def _detect_conda_mamba(self) -> None: + """Detect available conda/mamba executables on the system. + + Tries to find mamba first (preferred), then conda as fallback. + Sets self.mamba_executable and self.conda_executable based on availability. """ - if not (self.conda_executable or self.mamba_executable): - return False - - # Use mamba if available, otherwise conda - executable = self.mamba_executable or self.conda_executable - - try: - # Test basic functionality - result = subprocess.run( - [executable, "info", "--json"], - capture_output=True, - text=True, - timeout=30 - ) - if result.returncode == 0: - # Try to parse the JSON to ensure it's valid - json.loads(result.stdout) - return True - except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): - self.logger.error(f"Failed to validate conda/mamba installation: {result.stderr if 'result' in locals() else 'Unknown error'}") - - return False + self.mamba_executable = self._detect_manager("mamba") + self.conda_executable = self._detect_manager("conda") + + # def _validate_conda_installation(self) -> bool: + # """Validate that conda/mamba installation is functional. + + # Returns: + # bool: True if conda or mamba is available and functional, False otherwise. + # """ + # if not (self.conda_executable or self.mamba_executable): + # return False + + # # Use mamba if available, otherwise conda + # executable = self.get_preferred_executable() + + # try: + # # Test basic functionality + # result = subprocess.run( + # [executable, "info", "--json"], + # capture_output=True, + # text=True, + # timeout=30 + # ) + # if result.returncode == 0: + # # Try to parse the JSON to ensure it's valid + # json.loads(result.stdout) + # return True + # except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + # self.logger.error(f"Failed to validate conda/mamba installation: {result.stderr if 'result' in locals() else 'Unknown error'}") + # except Exception as e: + # self.logger.error(f"Unexpected error validating conda/mamba installation: {e}") + + # self.logger.error("Conda/mamba installation validation failed") + # return False def is_available(self) -> bool: """Check if Python environment management is available. @@ -133,7 +167,9 @@ def is_available(self) -> bool: Returns: bool: True if conda/mamba is available and functional, False otherwise. """ - return self._validate_conda_installation() + if self.get_preferred_executable(): + return True + return False def get_preferred_executable(self) -> Optional[str]: """Get the preferred conda/mamba executable. @@ -189,7 +225,6 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] raise PythonEnvironmentError("Neither conda nor mamba is available for Python environment management") executable = self.get_preferred_executable() - conda_env_name = self._get_conda_env_name(env_name) env_prefix = self._get_conda_env_prefix(env_name) # Check if environment already exists @@ -224,7 +259,7 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] self.logger.info(f"Successfully created Python environment for {env_name}") return True else: - error_msg = f"Failed to create Python environment: {result.stderr}" + error_msg = f"Failed to create Python environment (see terminal output)" self.logger.error(error_msg) raise PythonEnvironmentError(error_msg) @@ -662,3 +697,26 @@ def get_environment_path(self, env_name: str) -> Optional[Path]: return None return self._get_conda_env_prefix(env_name) + """Check if a Python environment exists. + + Args: + env_name (str): Environment name. + + Returns: + bool: True if environment exists, False otherwise. + """ + return self._conda_env_exists(env_name) + + def get_environment_path(self, env_name: str) -> Optional[Path]: + """Get the file system path for a Python environment. + + Args: + env_name (str): Environment name. + + Returns: + Path: Environment path or None if not found. + """ + if not self.environment_exists(env_name): + return None + + return self._get_conda_env_prefix(env_name) diff --git a/tests/test_python_environment_manager.py b/tests/test_python_environment_manager.py index 90c66ab..3ba79ee 100644 --- a/tests/test_python_environment_manager.py +++ b/tests/test_python_environment_manager.py @@ -33,46 +33,30 @@ def test_init(self): self.assertEqual(self.manager.environments_dir, self.environments_dir) self.assertIsNotNone(self.manager.logger) - @patch('shutil.which') - def test_detect_conda_mamba_with_mamba(self, mock_which): + def test_detect_conda_mamba_with_mamba(self): """Test conda/mamba detection when mamba is available.""" - # Mock mamba available - mock_which.side_effect = lambda cmd: "/usr/bin/mamba" if cmd == "mamba" else "/usr/bin/conda" - - with patch('subprocess.run') as mock_run: - # Mock successful mamba version check - mock_run.return_value = Mock(returncode=0, stdout="mamba 0.24.0") - + with patch.object(PythonEnvironmentManager, "_detect_manager") as mock_detect: + # mamba found, conda found + mock_detect.side_effect = lambda manager: "/usr/bin/mamba" if manager == "mamba" else "/usr/bin/conda" manager = PythonEnvironmentManager(environments_dir=self.environments_dir) - self.assertEqual(manager.mamba_executable, "/usr/bin/mamba") self.assertEqual(manager.conda_executable, "/usr/bin/conda") - @patch('shutil.which') - def test_detect_conda_mamba_conda_only(self, mock_which): + def test_detect_conda_mamba_conda_only(self): """Test conda/mamba detection when only conda is available.""" - # Mock only conda available - mock_which.side_effect = lambda cmd: "/usr/bin/conda" if cmd == "conda" else None - - with patch('subprocess.run') as mock_run: - # Mock successful conda version check - mock_run.return_value = Mock(returncode=0, stdout="conda 4.12.0") - + with patch.object(PythonEnvironmentManager, "_detect_manager") as mock_detect: + # mamba not found, conda found + mock_detect.side_effect = lambda manager: None if manager == "mamba" else "/usr/bin/conda" manager = PythonEnvironmentManager(environments_dir=self.environments_dir) - self.assertIsNone(manager.mamba_executable) self.assertEqual(manager.conda_executable, "/usr/bin/conda") - @patch('shutil.which') - def test_detect_conda_mamba_none_available(self, mock_which): + def test_detect_conda_mamba_none_available(self): """Test conda/mamba detection when neither is available.""" - # Mock neither available - mock_which.return_value = None - - manager = PythonEnvironmentManager(environments_dir=self.environments_dir) - - self.assertIsNone(manager.mamba_executable) - self.assertIsNone(manager.conda_executable) + with patch.object(PythonEnvironmentManager, "_detect_manager", return_value=None): + manager = PythonEnvironmentManager(environments_dir=self.environments_dir) + self.assertIsNone(manager.mamba_executable) + self.assertIsNone(manager.conda_executable) def test_get_conda_env_name(self): """Test conda environment name generation.""" From c49282b710f24aae911e4f12e241d9cc0b18750d Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 13:07:26 +0900 Subject: [PATCH 25/48] [Update] Propagate python exec to Python installer **Major**: - Using the result of python environment manager, we inject the associated python executable to the pip command used to install pacakges **Minor**: - Updated `run_environment_tests.py` to include all the tests again - Updated `test_env_manip` to set `create_python_env=False` when it is not needed for the test in order to speed up by skipping creation of thee environment --- .../dependency_installation_orchestrator.py | 24 +++++++++++++++++++ hatch/installers/installation_context.py | 11 +++++++++ tests/run_environment_tests.py | 4 ++-- tests/test_env_manip.py | 23 +++++++++--------- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py index 339becb..ef18e07 100644 --- a/hatch/installers/dependency_installation_orchestrator.py +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -66,6 +66,9 @@ def __init__(self, self.registry_service = registry_service self.registry_data = registry_data + # Python executable configuration for context + self._python_executable: Optional[str] = None + # These will be set during package resolution self.package_service: Optional[PackageService] = None self.dependency_graph_builder: Optional[HatchDependencyGraphBuilder] = None @@ -73,6 +76,23 @@ def __init__(self, self._resolved_package_type: Optional[str] = None self._resolved_package_location: Optional[str] = None + def set_python_executable(self, python_executable: str) -> None: + """Set the Python executable to use for Python package installations. + + Args: + python_executable (str): Path to the Python executable. + """ + self._python_executable = python_executable + self.logger.debug(f"Python executable set to: {python_executable}") + + def get_python_executable(self) -> Optional[str]: + """Get the configured Python executable. + + Returns: + str: Path to Python executable, None if not configured. + """ + return self._python_executable + def install_dependencies(self, package_path_or_name: str, env_path: Path, @@ -465,6 +485,10 @@ def _execute_install_plan(self, } ) + # Configure Python executable if available + if self._python_executable: + context.set_config("python_executable", self._python_executable) + try: # Install dependencies by type using appropriate installers for dep_type, dependencies in install_plan["dependencies_to_install"].items(): diff --git a/hatch/installers/installation_context.py b/hatch/installers/installation_context.py index 44dd6a9..85cccfb 100644 --- a/hatch/installers/installation_context.py +++ b/hatch/installers/installation_context.py @@ -58,6 +58,17 @@ def get_config(self, key: str, default: Any = None) -> Any: if self.extra_config is None: return default return self.extra_config.get(key, default) + + def set_config(self, key: str, value: Any) -> None: + """Set a configuration value in extra_config. + + Args: + key (str): Configuration key to set. + value (Any): Value to set for the key. + """ + if self.extra_config is None: + self.extra_config = {} + self.extra_config[key] = value class InstallationStatus(Enum): diff --git a/tests/run_environment_tests.py b/tests/run_environment_tests.py index 72dce13..e38e219 100644 --- a/tests/run_environment_tests.py +++ b/tests/run_environment_tests.py @@ -61,8 +61,8 @@ # Run only PythonEnvironmentManager integration tests (requires conda/mamba) logger.info("Running PythonEnvironmentManager integration tests only...") test_integration = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerIntegration") - #test_enhanced = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerEnhancedFeatures") - test_suite = unittest.TestSuite([test_integration])#, test_enhanced]) + test_enhanced = test_loader.loadTestsFromName("test_python_environment_manager.TestPythonEnvironmentManagerEnhancedFeatures") + test_suite = unittest.TestSuite([test_integration, test_enhanced]) elif len(sys.argv) > 1 and sys.argv[1] == "--python-env-manager-all": # Run all PythonEnvironmentManager tests logger.info("Running all PythonEnvironmentManager tests...") diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index e1e0b7a..5f7ae75 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from hatch.environment_manager import HatchEnvironmentManager +from hatch.installers.docker_installer import DOCKER_DAEMON_AVAILABLE # Configure logging logging.basicConfig( @@ -46,10 +47,6 @@ def setUp(self): simulation_mode=True, local_registry_cache_path=self.registry_path) - # Initialize environment files with clean state - self.env_manager._initialize_environments_file() - self.env_manager._initialize_current_env_file() - # Reload environments to ensure clean state self.env_manager.reload_environments() @@ -215,7 +212,7 @@ def test_add_local_package(self): def test_add_package_with_dependencies(self): """Test adding a package with dependencies to an environment.""" # Create an environment - self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) # First add the base package that is a dependency base_pkg_path = self.hatch_dev_path / "base_pkg_1" @@ -256,8 +253,8 @@ def test_add_package_with_dependencies(self): def test_add_package_with_some_dependencies_already_present(self): """Test adding a package where some dependencies are already present and others are not.""" # Create an environment - self.env_manager.create_environment("test_env", "Test environment") - + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # First add only one of the dependencies that complex_dep_pkg needs base_pkg_path = self.hatch_dev_path / "base_pkg_1" self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") @@ -302,8 +299,8 @@ def test_add_package_with_some_dependencies_already_present(self): def test_add_package_with_all_dependencies_already_present(self): """Test adding a package where all dependencies are already present.""" # Create an environment - self.env_manager.create_environment("test_env", "Test environment") - + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # First add all dependencies that simple_dep_pkg needs base_pkg_path = self.hatch_dev_path / "base_pkg_1" self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") @@ -347,7 +344,7 @@ def test_add_package_with_all_dependencies_already_present(self): def test_add_package_with_version_constraint_satisfaction(self): """Test adding a package with version constraints where dependencies are satisfied.""" # Create an environment - self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) # Add base_pkg_1 with a specific version base_pkg_path = self.hatch_dev_path / "base_pkg_1" @@ -430,7 +427,7 @@ def test_add_package_with_mixed_dependency_types(self): @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") def test_add_package_with_system_dependency(self): """Test adding a package with a system dependency.""" - self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) # Add a package that declares a system dependency (e.g., 'curl') system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" @@ -449,9 +446,11 @@ def test_add_package_with_system_dependency(self): package_names = [pkg["name"] for pkg in packages] self.assertIn("system_dep_pkg", package_names, "System dependency package missing from environment") + # Skip if Docker is not available + @unittest.skipUnless(DOCKER_DAEMON_AVAILABLE, "Docker dependency test skipped due to Docker not being available") def test_add_package_with_docker_dependency(self): """Test adding a package with a docker dependency.""" - self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) # Add a package that declares a docker dependency (e.g., 'redis:latest') docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" From 46a22b6471ba233254ee2e745099db6810daec78 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 15:13:53 +0900 Subject: [PATCH 26/48] [Update] Generalized info for python installer **Major**: - The previous implementation was relying on the python's executable path to hope that the correct environment would be picked to install the packages - To make this more robust, we are passing the general information about the python environment by setting the necessary environment variables - FIXED a bug in the tests where we were not actually switching to the correct environment after creating it. - The tests were passing thanks to the system's python - Now the test `test_add_package_with_mixed_dependency_types` will fail if the expected environment is not used **Minor**: - Deleted deprecated commented code --- hatch/environment_manager.py | 10 +++--- .../dependency_installation_orchestrator.py | 33 +++++++++--------- hatch/installers/python_installer.py | 19 +++++++---- hatch/python_environment_manager.py | 34 +------------------ tests/test_env_manip.py | 26 ++++++++++---- 5 files changed, 55 insertions(+), 67 deletions(-) diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 6dc70e6..7e69350 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -210,13 +210,15 @@ def _configure_python_executable(self, env_name: str) -> None: if python_executable: # Configure the dependency orchestrator with the Python executable - self.dependency_orchestrator.set_python_executable(python_executable) - self.logger.info(f"Configured Python executable for {env_name}: {python_executable}") + python_env_vars = self.python_env_manager.get_environment_activation_info(env_name) + self.dependency_orchestrator.set_python_env_vars(python_env_vars) + self.logger.info(f"Configured Python environment variables for {env_name}: {python_env_vars}") else: # Use system Python as fallback system_python = sys.executable - self.dependency_orchestrator.set_python_executable(system_python) - self.logger.info(f"Using system Python for {env_name}: {system_python}") + python_env_vars = {"PYTHON": system_python} + self.dependency_orchestrator.set_python_env_vars(python_env_vars) + self.logger.info(f"Using system Python for {env_name}: {python_env_vars}") def get_current_python_executable(self) -> Optional[str]: """Get the Python executable for the current environment. diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py index ef18e07..4b8030c 100644 --- a/hatch/installers/dependency_installation_orchestrator.py +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -67,7 +67,7 @@ def __init__(self, self.registry_data = registry_data # Python executable configuration for context - self._python_executable: Optional[str] = None + self._python_env_vars = Optional[Dict[str, str]] # Environment variables for Python execution # These will be set during package resolution self.package_service: Optional[PackageService] = None @@ -76,22 +76,21 @@ def __init__(self, self._resolved_package_type: Optional[str] = None self._resolved_package_location: Optional[str] = None - def set_python_executable(self, python_executable: str) -> None: - """Set the Python executable to use for Python package installations. + def set_python_env_vars(self, python_env_vars: Dict[str, str]) -> None: + """Set the environment variables for the Python executable. Args: - python_executable (str): Path to the Python executable. + python_env_vars (Dict[str, str]): Environment variables to set for Python execution. """ - self._python_executable = python_executable - self.logger.debug(f"Python executable set to: {python_executable}") + self._python_env_vars = python_env_vars + + def get_python_env_vars(self) -> Optional[Dict[str, str]]: + """Get the configured environment variables for the Python executable. - def get_python_executable(self) -> Optional[str]: - """Get the configured Python executable. - Returns: - str: Path to Python executable, None if not configured. + Dict[str, str]: Environment variables for Python execution, None if not configured. """ - return self._python_executable + return self._python_env_vars def install_dependencies(self, package_path_or_name: str, @@ -484,12 +483,12 @@ def _execute_install_plan(self, "main_package_type": self._resolved_package_type } ) - - # Configure Python executable if available - if self._python_executable: - context.set_config("python_executable", self._python_executable) - - try: + + # Configure Python environment variables if available + if self._python_env_vars: + context.set_config("python_env_vars", self._python_env_vars) + + try: # Install dependencies by type using appropriate installers for dep_type, dependencies in install_plan["dependencies_to_install"].items(): if not dependencies: diff --git a/hatch/installers/python_installer.py b/hatch/installers/python_installer.py index 864bd9b..23793e2 100644 --- a/hatch/installers/python_installer.py +++ b/hatch/installers/python_installer.py @@ -8,7 +8,7 @@ import subprocess import logging import os -import re +import json from pathlib import Path from typing import Dict, Any, Optional, Callable, List import os @@ -86,11 +86,12 @@ def validate_dependency(self, dependency: Dict[str, Any]) -> bool: return True - def _run_pip_subprocess(self, cmd: List[str]) -> tuple[int, str, str]: + def _run_pip_subprocess(self, cmd: List[str], env_vars: Dict[str, str] = None) -> tuple[int, str, str]: """Run a pip subprocess and capture stdout and stderr. Args: cmd (List[str]): The pip command to execute as a list. + env_vars (Dict[str, str], optional): Additional environment variables to set for the subprocess. Returns: Tuple[int, str, str]: (returncode, stdout, stderr) @@ -102,6 +103,9 @@ def _run_pip_subprocess(self, cmd: List[str]) -> tuple[int, str, str]: env = os.environ.copy() env['PYTHONUNBUFFERED'] = '1' + env.update(env_vars or {}) # Merge in any additional environment variables + + self.logger.debug(f"Running pip command: {' '.join(cmd)} with env: {json.dumps(env, indent=2)}") try: process = subprocess.Popen( @@ -165,7 +169,8 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, progress_callback("validate", 0.0, f"Validating Python package {name}") # Get Python executable from context or use system default - python_exec = context.get_config("python_executable", sys.executable) + python_env_vars = context.get_config("python_env_vars", {}) + python_exec = python_env_vars.get("PYTHON", sys.executable) # Build package specification with version constraint # Let pip resolve the actual version based on the constraint @@ -206,7 +211,7 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, if progress_callback: progress_callback("install", 0.3, f"Installing {package_spec}") - returncode, stdout, stderr = self._run_pip_subprocess(cmd) + returncode, stdout, stderr = self._run_pip_subprocess(cmd, env_vars=python_env_vars) self.logger.debug(f"pip command: {' '.join(cmd)}\nreturncode: {returncode}\nstdout: {stdout}\nstderr: {stderr}") if returncode == 0: @@ -265,7 +270,9 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, progress_callback("uninstall", 0.0, f"Uninstalling Python package {name}") # Get Python executable from context - python_exec = context.get_config("python_executable", sys.executable) + python_env_vars = context.get_config("python_env_vars", {}) + # Use the configured Python executable or fall back to system default + python_exec = python_env_vars.get("PYTHON", sys.executable) # Build pip uninstall command cmd = [str(python_exec), "-m", "pip", "uninstall", "-y", name] @@ -282,7 +289,7 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, if progress_callback: progress_callback("uninstall", 0.5, f"Removing {name}") - returncode, stdout, stderr = self._run_pip_subprocess(cmd) + returncode, stdout, stderr = self._run_pip_subprocess(cmd, env_vars=python_env_vars) if returncode == 0: diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index b302892..aa593c7 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -129,38 +129,6 @@ def _detect_conda_mamba(self) -> None: self.mamba_executable = self._detect_manager("mamba") self.conda_executable = self._detect_manager("conda") - # def _validate_conda_installation(self) -> bool: - # """Validate that conda/mamba installation is functional. - - # Returns: - # bool: True if conda or mamba is available and functional, False otherwise. - # """ - # if not (self.conda_executable or self.mamba_executable): - # return False - - # # Use mamba if available, otherwise conda - # executable = self.get_preferred_executable() - - # try: - # # Test basic functionality - # result = subprocess.run( - # [executable, "info", "--json"], - # capture_output=True, - # text=True, - # timeout=30 - # ) - # if result.returncode == 0: - # # Try to parse the JSON to ensure it's valid - # json.loads(result.stdout) - # return True - # except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): - # self.logger.error(f"Failed to validate conda/mamba installation: {result.stderr if 'result' in locals() else 'Unknown error'}") - # except Exception as e: - # self.logger.error(f"Unexpected error validating conda/mamba installation: {e}") - - # self.logger.error("Conda/mamba installation validation failed") - # return False - def is_available(self) -> bool: """Check if Python environment management is available. @@ -471,7 +439,7 @@ def get_python_version(self, env_name: str) -> Optional[str]: return None - def activate_environment(self, env_name: str) -> Optional[Dict[str, str]]: + def get_environment_activation_info(self, env_name: str) -> Optional[Dict[str, str]]: """Get environment variables needed to activate a Python environment. This method returns the environment variables that should be set diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index 5f7ae75..95100b7 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -182,6 +182,7 @@ def test_add_local_package(self): """Test adding a local package to an environment.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.set_current_environment("test_env") # Use arithmetic_pkg from Hatching-Dev pkg_path = self.hatch_dev_path / "arithmetic_pkg" @@ -213,7 +214,8 @@ def test_add_package_with_dependencies(self): """Test adding a package with dependencies to an environment.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - + self.env_manager.set_current_environment("test_env") + # First add the base package that is a dependency base_pkg_path = self.hatch_dev_path / "base_pkg_1" self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") @@ -254,7 +256,7 @@ def test_add_package_with_some_dependencies_already_present(self): """Test adding a package where some dependencies are already present and others are not.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - + self.env_manager.set_current_environment("test_env") # First add only one of the dependencies that complex_dep_pkg needs base_pkg_path = self.hatch_dev_path / "base_pkg_1" self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") @@ -300,7 +302,7 @@ def test_add_package_with_all_dependencies_already_present(self): """Test adding a package where all dependencies are already present.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - + self.env_manager.set_current_environment("test_env") # First add all dependencies that simple_dep_pkg needs base_pkg_path = self.hatch_dev_path / "base_pkg_1" self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") @@ -345,7 +347,8 @@ def test_add_package_with_version_constraint_satisfaction(self): """Test adding a package with version constraints where dependencies are satisfied.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - + self.env_manager.set_current_environment("test_env") + # Add base_pkg_1 with a specific version base_pkg_path = self.hatch_dev_path / "base_pkg_1" self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") @@ -382,6 +385,7 @@ def test_add_package_with_mixed_dependency_types(self): """Test adding a package with mixed hatch and python dependencies.""" # Create an environment self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.set_current_environment("test_env") # Add a package that has both hatch and python dependencies python_dep_pkg_path = self.hatch_dev_path / "python_dep_pkg" @@ -423,12 +427,20 @@ def test_add_package_with_mixed_dependency_types(self): # Should have python_dep_pkg (already present) plus any other dependencies of complex_dep_pkg self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") - + + # Python dep package has a dep to request. This should be satisfied in the python environment + python_env_info = self.env_manager.python_env_manager.get_environment_info("test_env") + packages = python_env_info.get("packages", []) + self.assertIsNotNone(packages, "Python environment packages not found") + self.assertGreater(len(packages), 0, "No packages found in Python environment") + package_names = [pkg["name"] for pkg in packages] + self.assertIn("requests", package_names, f"Expected 'requests' package not found in Python environment: {packages}") + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") def test_add_package_with_system_dependency(self): """Test adding a package with a system dependency.""" self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - + self.env_manager.set_current_environment("test_env") # Add a package that declares a system dependency (e.g., 'curl') system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" self.assertTrue(system_dep_pkg_path.exists(), f"System dependency package not found: {system_dep_pkg_path}") @@ -451,7 +463,7 @@ def test_add_package_with_system_dependency(self): def test_add_package_with_docker_dependency(self): """Test adding a package with a docker dependency.""" self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - + self.env_manager.set_current_environment("test_env") # Add a package that declares a docker dependency (e.g., 'redis:latest') docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" self.assertTrue(docker_dep_pkg_path.exists(), f"Docker dependency package not found: {docker_dep_pkg_path}") From 3d1069053f0dc850fc02f10193c2476ceb95e426 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 30 Jun 2025 15:50:06 +0900 Subject: [PATCH 27/48] [Update - Minor] Cli python env info display **Major**: - Added the list of packages and versions - Deleted some logs - Changed the level of logs to debug --- hatch/cli_hatch.py | 3 +++ hatch/environment_manager.py | 3 +-- hatch/python_environment_manager.py | 19 ++++++++----------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/hatch/cli_hatch.py b/hatch/cli_hatch.py index 37bb8bd..fd396de 100644 --- a/hatch/cli_hatch.py +++ b/hatch/cli_hatch.py @@ -294,6 +294,9 @@ def main(): print(f" Environment path: {python_info['environment_path']}") print(f" Created: {python_info.get('created_at', 'Unknown')}") print(f" Package count: {python_info.get('package_count', 0)}") + print(f" Packages:") + for pkg in python_info.get('packages', []): + print(f" - {pkg['name']} ({pkg['version']})") if detailed: print(f"\nDiagnostics:") diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 7e69350..38dcb8d 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -212,13 +212,11 @@ def _configure_python_executable(self, env_name: str) -> None: # Configure the dependency orchestrator with the Python executable python_env_vars = self.python_env_manager.get_environment_activation_info(env_name) self.dependency_orchestrator.set_python_env_vars(python_env_vars) - self.logger.info(f"Configured Python environment variables for {env_name}: {python_env_vars}") else: # Use system Python as fallback system_python = sys.executable python_env_vars = {"PYTHON": system_python} self.dependency_orchestrator.set_python_env_vars(python_env_vars) - self.logger.info(f"Using system Python for {env_name}: {python_env_vars}") def get_current_python_executable(self) -> Optional[str]: """Get the Python executable for the current environment. @@ -696,6 +694,7 @@ def get_python_environment_info(self, env_name: str) -> Optional[Dict[str, Any]] # Package information "package_count": live_info.get("package_count", 0) if live_info else 0, + "packages": live_info.get("packages", []) if live_info else [], # Status information "exists": live_info is not None, diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index aa593c7..b85d8d2 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -40,7 +40,7 @@ def __init__(self, environments_dir: Optional[Path] = None): Defaults to ~/.hatch/envs. """ self.logger = logging.getLogger("hatch.python_environment_manager") - self.logger.setLevel(logging.DEBUG) + self.logger.setLevel(logging.INFO) # Set up environment directories self.environments_dir = environments_dir or (Path.home() / ".hatch" / "envs") @@ -50,11 +50,11 @@ def __init__(self, environments_dir: Optional[Path] = None): self.mamba_executable = None self._detect_conda_mamba() - self.logger.info(f"Python environment manager initialized with environments_dir: {self.environments_dir}") + self.logger.debug(f"Python environment manager initialized with environments_dir: {self.environments_dir}") if self.mamba_executable: - self.logger.info(f"Using mamba: {self.mamba_executable}") + self.logger.debug(f"Using mamba: {self.mamba_executable}") elif self.conda_executable: - self.logger.info(f"Using conda: {self.conda_executable}") + self.logger.debug(f"Using conda: {self.conda_executable}") else: self.logger.warning("Neither conda nor mamba found - Python environment management will be limited") @@ -214,17 +214,16 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] cmd.append("python") try: - self.logger.info(f"Creating Python environment for {env_name} at {env_prefix}") + self.logger.debug(f"Creating Python environment for {env_name} at {env_prefix}") if python_version: - self.logger.info(f"Using Python version: {python_version}") - + self.logger.debug(f"Using Python version: {python_version}") + result = subprocess.run( cmd, timeout=300 # 5 minutes timeout ) if result.returncode == 0: - self.logger.info(f"Successfully created Python environment for {env_name}") return True else: error_msg = f"Failed to create Python environment (see terminal output)" @@ -318,9 +317,7 @@ def remove_python_environment(self, env_name: str) -> bool: timeout=120 # 2 minutes timeout ) - if result.returncode == 0: - self.logger.info(f"Successfully removed Python environment for {env_name}") - + if result.returncode == 0: # Clean up any remaining directory structure if env_prefix.exists(): try: From 92c347561f1a141dc42b14900fbc16729153d7a5 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 2 Jul 2025 08:28:13 +0900 Subject: [PATCH 28/48] [Remove] Duplicated code --- hatch/python_environment_manager.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index b85d8d2..bff6b17 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -662,26 +662,3 @@ def get_environment_path(self, env_name: str) -> Optional[Path]: return None return self._get_conda_env_prefix(env_name) - """Check if a Python environment exists. - - Args: - env_name (str): Environment name. - - Returns: - bool: True if environment exists, False otherwise. - """ - return self._conda_env_exists(env_name) - - def get_environment_path(self, env_name: str) -> Optional[Path]: - """Get the file system path for a Python environment. - - Args: - env_name (str): Environment name. - - Returns: - Path: Environment path or None if not found. - """ - if not self.environment_exists(env_name): - return None - - return self._get_conda_env_prefix(env_name) From 04a95318eab22eafb33b6d771633b10e58d0c533 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 2 Jul 2025 08:31:23 +0900 Subject: [PATCH 29/48] [Update - Minor] Docker installer default log lvl **Major**: - Changed the default log level of the docker availability issue from warning to debug. - Changed the logger name to the conventional of the path of the file (indicated manually) --- hatch/installers/docker_installer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hatch/installers/docker_installer.py b/hatch/installers/docker_installer.py index 5283521..5aa81d7 100644 --- a/hatch/installers/docker_installer.py +++ b/hatch/installers/docker_installer.py @@ -12,7 +12,8 @@ from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError from .installation_context import InstallationStatus -logger = logging.getLogger(__name__) +logger = logging.getLogger("hatch.installers.docker_installer") +logger.setLevel(logging.INFO) # Handle docker-py import with graceful fallback DOCKER_AVAILABLE = False @@ -26,14 +27,13 @@ _docker_client.ping() DOCKER_DAEMON_AVAILABLE = True except DockerException as e: - DOCKER_DAEMON_AVAILABLE = False - logger.warning(f"docker-py library is available but Docker daemon is not running or not reachable: {e}") + logger.debug(f"docker-py library is available but Docker daemon is not running or not reachable: {e}") except ImportError: docker = None DockerException = Exception ImageNotFound = Exception APIError = Exception - logger.warning("docker-py library not available. Docker installer will be disabled.") + logger.debug("docker-py library not available. Docker installer will be disabled.") class DockerInstaller(DependencyInstaller): From e8b884f91711a10c9cf23855e8f99b06cf7d072c Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 2 Jul 2025 08:32:32 +0900 Subject: [PATCH 30/48] [Refactor - Minor] Use existing variable **Major**: - Use the value of `DOCKER_DAEMON_AVAILABLE` instead of rewriting the whole test --- hatch/installers/docker_installer.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/hatch/installers/docker_installer.py b/hatch/installers/docker_installer.py index 5aa81d7..700ccb3 100644 --- a/hatch/installers/docker_installer.py +++ b/hatch/installers/docker_installer.py @@ -317,15 +317,12 @@ def _get_docker_client(self): cause=ImportError("docker-py library is required for Docker support") ) - if self._docker_client is None: - try: - self._docker_client = docker.from_env() - except DockerException as e: - raise InstallationError( - "Docker daemon not available", - error_code="DOCKER_DAEMON_NOT_AVAILABLE", - cause=e - ) + if not DOCKER_DAEMON_AVAILABLE: + raise InstallationError( + "Docker daemon not available", + error_code="DOCKER_DAEMON_NOT_AVAILABLE", + cause=e + ) return self._docker_client From 24bbfb51f1a531e950bdf4945676dab597926e23 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 2 Jul 2025 09:28:34 +0900 Subject: [PATCH 31/48] [Update] Make hatch env name optional **Major**: - The CLI commands were expecting the hatch environment name as a positional argument for the python-related commands - We made the name optional and use the current environment by default **Minor**: - Homogenized the format of the import statements --- hatch/cli_hatch.py | 105 ++++++++++------------------------- hatch/environment_manager.py | 74 ++++++++++++++++++------ hatch/registry_explorer.py | 1 - 3 files changed, 86 insertions(+), 94 deletions(-) diff --git a/hatch/cli_hatch.py b/hatch/cli_hatch.py index fd396de..f590860 100644 --- a/hatch/cli_hatch.py +++ b/hatch/cli_hatch.py @@ -12,8 +12,8 @@ import sys from pathlib import Path -from .environment_manager import HatchEnvironmentManager -from .template_generator import create_package_template +from hatch.environment_manager import HatchEnvironmentManager +from hatch.template_generator import create_package_template def main(): """Main entry point for Hatch CLI. @@ -81,34 +81,25 @@ def main(): # Initialize Python environment python_init_parser = env_python_subparsers.add_parser("init", help="Initialize Python environment") - python_init_parser.add_argument("name", help="Environment name") + python_init_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") python_init_parser.add_argument("--python-version", help="Python version (e.g., 3.11, 3.12)") python_init_parser.add_argument("--force", action="store_true", help="Force recreation if exists") # Show Python environment info python_info_parser = env_python_subparsers.add_parser("info", help="Show Python environment information") - python_info_parser.add_argument("name", help="Environment name") + python_info_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") python_info_parser.add_argument("--detailed", action="store_true", help="Show detailed diagnostics") # Remove Python environment python_remove_parser = env_python_subparsers.add_parser("remove", help="Remove Python environment") - python_remove_parser.add_argument("name", help="Environment name") + python_remove_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") python_remove_parser.add_argument("--force", action="store_true", help="Force removal without confirmation") # Launch Python shell python_shell_parser = env_python_subparsers.add_parser("shell", help="Launch Python shell in environment") - python_shell_parser.add_argument("name", help="Environment name") + python_shell_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") python_shell_parser.add_argument("--cmd", help="Command to run in the shell (optional)") - # Legacy Python environment management (backward compatibility) - env_python_parser = env_subparsers.add_parser("python-legacy", help="Legacy Python environment commands") - env_python_parser.add_argument("action", choices=["add", "remove", "info"], - help="Python environment action") - env_python_parser.add_argument("name", help="Environment name") - env_python_parser.add_argument("--python-version", help="Python version (for add action)") - env_python_parser.add_argument("--force", action="store_true", - help="Force recreation (for add action)") - # Package management commands pkg_subparsers = subparsers.add_parser("package", help="Package management commands").add_subparsers( dest="pkg_command", help="Package command to execute" @@ -265,12 +256,12 @@ def main(): if args.python_command == "init": python_version = getattr(args, 'python_version', None) force = getattr(args, 'force', False) - - if env_manager.create_python_environment_only(args.name, python_version, force): - print(f"Python environment initialized for: {args.name}") + + if env_manager.create_python_environment_only(args.hatch_env, python_version, force): + print(f"Python environment initialized for: {args.hatch_env}") # Show Python environment info - python_info = env_manager.get_python_environment_info(args.name) + python_info = env_manager.get_python_environment_info(args.hatch_env) if python_info: print(f" Python executable: {python_info['python_executable']}") print(f" Python version: {python_info.get('python_version', 'Unknown')}") @@ -278,15 +269,18 @@ def main(): return 0 else: - print(f"Failed to initialize Python environment for: {args.name}") + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Failed to initialize Python environment for: {env_name}") return 1 elif args.python_command == "info": detailed = getattr(args, 'detailed', False) - python_info = env_manager.get_python_environment_info(args.name) + + python_info = env_manager.get_python_environment_info(args.hatch_env) if python_info: - print(f"Python environment info for '{args.name}':") + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Python environment info for '{env_name}':") print(f" Status: {'Active' if python_info.get('enabled', False) else 'Inactive'}") print(f" Python executable: {python_info['python_executable']}") print(f" Python version: {python_info.get('python_version', 'Unknown')}") @@ -300,7 +294,7 @@ def main(): if detailed: print(f"\nDiagnostics:") - diagnostics = env_manager.get_python_environment_diagnostics(args.name) + diagnostics = env_manager.get_python_environment_diagnostics(args.hatch_env) if diagnostics: for key, value in diagnostics.items(): print(f" {key}: {value}") @@ -309,7 +303,8 @@ def main(): return 0 else: - print(f"No Python environment found for: {args.name}") + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"No Python environment found for: {env_name}") # Show diagnostics for missing environment if detailed: @@ -325,25 +320,29 @@ def main(): if not force: # Ask for confirmation - response = input(f"Remove Python environment for '{args.name}'? [y/N]: ") + env_name = args.hatch_env or env_manager.get_current_environment() + response = input(f"Remove Python environment for '{env_name}'? [y/N]: ") if response.lower() not in ['y', 'yes']: print("Operation cancelled") return 0 - if env_manager.remove_python_environment_only(args.name): - print(f"Python environment removed from: {args.name}") + if env_manager.remove_python_environment_only(args.hatch_env): + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Python environment removed from: {env_name}") return 0 else: - print(f"Failed to remove Python environment from: {args.name}") + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Failed to remove Python environment from: {env_name}") return 1 elif args.python_command == "shell": cmd = getattr(args, 'cmd', None) - if env_manager.launch_python_shell(args.name, cmd): + if env_manager.launch_python_shell(args.hatch_env, cmd): return 0 else: - print(f"Failed to launch Python shell for: {args.name}") + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Failed to launch Python shell for: {env_name}") return 1 else: print("Unknown Python environment command") @@ -351,52 +350,6 @@ def main(): else: print("No Python subcommand specified") return 1 - - elif args.env_command == "python-legacy": - # Legacy Python environment commands for backward compatibility - if args.action == "add": - python_version = getattr(args, 'python_version', None) - force = getattr(args, 'force', False) - - if env_manager.create_python_environment_only(args.name, python_version, force): - print(f"Python environment added to: {args.name}") - - # Show Python environment info - python_exec = env_manager.python_env_manager.get_python_executable(args.name) - if python_exec: - python_version_info = env_manager.python_env_manager.get_python_version(args.name) - print(f"Python executable: {python_exec}") - if python_version_info: - print(f"Python version: {python_version_info}") - - return 0 - else: - print(f"Failed to add Python environment to: {args.name}") - return 1 - - elif args.action == "remove": - if env_manager.remove_python_environment_only(args.name): - print(f"Python environment removed from: {args.name}") - return 0 - else: - print(f"Failed to remove Python environment from: {args.name}") - return 1 - - elif args.action == "info": - python_info = env_manager.get_python_environment_info(args.name) - if python_info: - print(f"Python environment info for {args.name}:") - print(f" Path: {python_info['environment_path']}") - print(f" Python executable: {python_info['python_executable']}") - print(f" Python version: {python_info.get('python_version', 'Unknown')}") - print(f" Package count: {python_info.get('package_count', 0)}") - return 0 - else: - print(f"No Python environment found for: {args.name}") - return 1 - else: - parser.print_help() - return 1 elif args.command == "package": if args.pkg_command == "add": diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 38dcb8d..2fdda3d 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -11,10 +11,10 @@ from typing import Dict, List, Optional, Any, Tuple from hatch_validator.registry.registry_service import RegistryService, RegistryError -from .registry_retriever import RegistryRetriever -from .package_loader import HatchPackageLoader -from .installers.dependency_installation_orchestrator import DependencyInstallerOrchestrator -from .python_environment_manager import PythonEnvironmentManager, PythonEnvironmentError +from hatch.registry_retriever import RegistryRetriever +from hatch.package_loader import HatchPackageLoader +from hatch.installers.dependency_installation_orchestrator import DependencyInstallerOrchestrator +from hatch.python_environment_manager import PythonEnvironmentManager, PythonEnvironmentError class HatchEnvironmentError(Exception): """Exception raised for environment-related errors.""" @@ -649,15 +649,23 @@ def is_python_environment_available(self) -> bool: """ return self.python_env_manager.is_available() - def get_python_environment_info(self, env_name: str) -> Optional[Dict[str, Any]]: + def get_python_environment_info(self, env_name: Optional[str] = None) -> Optional[Dict[str, Any]]: """Get comprehensive Python environment information for an environment. Args: - env_name (str): Environment name. + env_name (str, optional): Environment name. Defaults to current environment. Returns: dict: Comprehensive Python environment info, None if no Python environment exists. + + Raises: + HatchEnvironmentError: If no environment name provided and no current environment set. """ + if env_name is None: + env_name = self.get_current_environment() + if not env_name: + raise HatchEnvironmentError("No environment name provided and no current environment set") + if env_name not in self._environments: return None @@ -711,20 +719,28 @@ def list_python_environments(self) -> List[str]: """ return self.python_env_manager.list_environments() - def create_python_environment_only(self, env_name: str, python_version: Optional[str] = None, + def create_python_environment_only(self, env_name: Optional[str] = None, python_version: Optional[str] = None, force: bool = False) -> bool: """Create only a Python environment without creating a Hatch environment. Useful for adding Python environments to existing Hatch environments. Args: - env_name (str): Environment name. - python_version (str, optional): Python version (e.g., "3.11"). - force (bool, optional): Whether to recreate if exists. + env_name (str, optional): Environment name. Defaults to current environment. + python_version (str, optional): Python version (e.g., "3.11"). Defaults to None. + force (bool, optional): Whether to recreate if exists. Defaults to False. Returns: bool: True if successful, False otherwise. + + Raises: + HatchEnvironmentError: If no environment name provided and no current environment set. """ + if env_name is None: + env_name = self.get_current_environment() + if not env_name: + raise HatchEnvironmentError("No environment name provided and no current environment set") + if env_name not in self._environments: self.logger.error(f"Hatch environment {env_name} must exist first") return False @@ -775,15 +791,23 @@ def create_python_environment_only(self, env_name: str, python_version: Optional self.logger.error(f"Failed to create Python environment: {e}") return False - def remove_python_environment_only(self, env_name: str) -> bool: + def remove_python_environment_only(self, env_name: Optional[str] = None) -> bool: """Remove only the Python environment, keeping the Hatch environment. Args: - env_name (str): Environment name. + env_name (str, optional): Environment name. Defaults to current environment. Returns: bool: True if successful, False otherwise. + + Raises: + HatchEnvironmentError: If no environment name provided and no current environment set. """ + if env_name is None: + env_name = self.get_current_environment() + if not env_name: + raise HatchEnvironmentError("No environment name provided and no current environment set") + if env_name not in self._environments: self.logger.warning(f"Hatch environment {env_name} does not exist") return False @@ -807,15 +831,23 @@ def remove_python_environment_only(self, env_name: str) -> bool: self.logger.error(f"Failed to remove Python environment: {e}") return False - def get_python_environment_diagnostics(self, env_name: str) -> Optional[Dict[str, Any]]: + def get_python_environment_diagnostics(self, env_name: Optional[str] = None) -> Optional[Dict[str, Any]]: """Get detailed diagnostics for a Python environment. Args: - env_name (str): Environment name. + env_name (str, optional): Environment name. Defaults to current environment. Returns: dict: Diagnostics information or None if environment doesn't exist. + + Raises: + HatchEnvironmentError: If no environment name provided and no current environment set. """ + if env_name is None: + env_name = self.get_current_environment() + if not env_name: + raise HatchEnvironmentError("No environment name provided and no current environment set") + if env_name not in self._environments: return None @@ -837,16 +869,24 @@ def get_python_manager_diagnostics(self) -> Dict[str, Any]: self.logger.error(f"Failed to get manager diagnostics: {e}") return {"error": str(e)} - def launch_python_shell(self, env_name: str, cmd: Optional[str] = None) -> bool: + def launch_python_shell(self, env_name: Optional[str] = None, cmd: Optional[str] = None) -> bool: """Launch a Python shell or execute a command in the environment. Args: - env_name (str): Environment name. - cmd (str, optional): Command to execute. If None, launches interactive shell. + env_name (str, optional): Environment name. Defaults to current environment. + cmd (str, optional): Command to execute. If None, launches interactive shell. Defaults to None. Returns: bool: True if successful, False otherwise. + + Raises: + HatchEnvironmentError: If no environment name provided and no current environment set. """ + if env_name is None: + env_name = self.get_current_environment() + if not env_name: + raise HatchEnvironmentError("No environment name provided and no current environment set") + if env_name not in self._environments: self.logger.error(f"Environment {env_name} does not exist") return False diff --git a/hatch/registry_explorer.py b/hatch/registry_explorer.py index 3131a99..f082458 100644 --- a/hatch/registry_explorer.py +++ b/hatch/registry_explorer.py @@ -3,7 +3,6 @@ This module provides functions to search and extract information from a Hatch registry data structure (see hatch_all_pkg_metadata_schema.json). """ -import re from typing import Any, Dict, List, Optional, Tuple from packaging.version import Version, InvalidVersion from packaging.specifiers import SpecifierSet, InvalidSpecifier From c695b3a7b784abf598cc9e2e641ed5d3d55a7e21 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 2 Jul 2025 12:13:35 +0900 Subject: [PATCH 32/48] [Fix] Registry fetch **Major**: - The checks for the freshness of the registry was performed against a local variable during initialization. - Which means that every single use of hatch via the CLI would trigger download of the registry. - This was fixed/enhanced to save the last update time in a local file --- hatch/registry_retriever.py | 91 +++++++++++++++++++++------ tests/test_registry_retriever.py | 103 ++++++++++++++++++++++++++++--- 2 files changed, 168 insertions(+), 26 deletions(-) diff --git a/hatch/registry_retriever.py b/hatch/registry_retriever.py index c0540c2..74bae79 100644 --- a/hatch/registry_retriever.py +++ b/hatch/registry_retriever.py @@ -18,7 +18,8 @@ class RegistryRetriever: """Manages the retrieval and caching of the Hatch package registry. - Provides caching at file system level and in-memory level. + Provides caching at file system level and in-memory level with persistent + timestamp tracking for cache freshness across CLI invocations. Works in both local simulation and online GitHub environments. Handles registry timing issues with fallback to previous day's registry. """ @@ -39,6 +40,8 @@ def __init__( local_registry_cache_path (Path, optional): Path to local registry file. Defaults to None. """ self.logger = logging.getLogger('hatch.registry_retriever') + self.logger.setLevel(logging.INFO) + self.cache_ttl = cache_ttl self.simulation_mode = simulation_mode self.is_delayed = False # Flag to indicate if using a previous day's registry @@ -70,6 +73,54 @@ def __init__( # In-memory cache self._registry_cache = None self._last_fetch_time = 0 + + # Set up persistent timestamp file path + self._last_fetch_time_path = self.cache_dir / "registry" / ".last_fetch_time" + + # Load persistent timestamp on initialization + self._load_last_fetch_time() + + def _load_last_fetch_time(self) -> None: + """Load the last fetch timestamp from persistent storage. + + Reads the timestamp from the .last_fetch_time file and sets + self._last_fetch_time accordingly. If the file is missing or + corrupt, treats the cache as outdated. + """ + try: + if self._last_fetch_time_path.exists(): + with open(self._last_fetch_time_path, 'r', encoding='utf-8') as f: + timestamp_str = f.read().strip() + # Parse ISO8601 timestamp + timestamp_dt = datetime.datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + self._last_fetch_time = timestamp_dt.timestamp() + self.logger.debug(f"Loaded last fetch time from disk: {timestamp_str}") + else: + self.logger.debug("No persistent timestamp file found, treating cache as outdated") + except Exception as e: + self.logger.warning(f"Failed to read persistent timestamp: {e}, treating cache as outdated") + self._last_fetch_time = 0 + + def _save_last_fetch_time(self) -> None: + """Save the current fetch timestamp to persistent storage. + + Writes the current UTC timestamp to the .last_fetch_time file + in ISO8601 format for persistence across CLI invocations. + """ + try: + # Ensure directory exists + self._last_fetch_time_path.parent.mkdir(parents=True, exist_ok=True) + + # Write current UTC time in ISO8601 format + current_time = datetime.datetime.now(datetime.timezone.utc) + timestamp_str = current_time.isoformat().replace('+00:00', 'Z') + + with open(self._last_fetch_time_path, 'w', encoding='utf-8') as f: + f.write(timestamp_str) + + self.logger.debug(f"Saved last fetch time to disk: {timestamp_str}") + except Exception as e: + self.logger.warning(f"Failed to save persistent timestamp: {e}") def _read_local_cache(self) -> Dict[str, Any]: """Read the registry from local cache file. @@ -207,7 +258,6 @@ def get_registry(self, force_refresh: bool = False) -> Dict[str, Any]: try: self.logger.debug("Using local cache file") registry_data = self._read_local_cache() - # Update in-memory cache self._registry_cache = registry_data self._last_fetch_time = current_time @@ -235,6 +285,9 @@ def get_registry(self, force_refresh: bool = False) -> Dict[str, Any]: self._registry_cache = registry_data self._last_fetch_time = current_time + # Update persistent timestamp + self._save_last_fetch_time() + return registry_data except Exception as e: @@ -244,8 +297,9 @@ def get_registry(self, force_refresh: bool = False) -> Dict[str, Any]: def is_cache_outdated(self) -> bool: """Check if the cached registry is outdated. - Determines if the cached registry is not from today's UTC date - or if the cache TTL has expired. + Determines if the cached registry is outdated based on the persistent + timestamp and cache TTL. Falls back to file mtime for backward compatibility + if no persistent timestamp is available. Returns: bool: True if cache is outdated, False if cache is current. @@ -254,21 +308,22 @@ def is_cache_outdated(self) -> bool: return True # If file doesn't exist, consider it outdated now = datetime.datetime.now(datetime.timezone.utc) - today_utc = now.date() - cache_stat = self.registry_cache_path.stat() - cache_mtime_dt = datetime.datetime.fromtimestamp( - cache_stat.st_mtime, tz=datetime.timezone.utc - ) - cache_mtime_date = cache_mtime_dt.date() - - # Outdated if not from today - if cache_mtime_date < today_utc: - return True - - # Outdated if TTL expired - if (now - cache_mtime_dt).total_seconds() > self.cache_ttl: - return True + + # Use persistent timestamp if available (primary method) + if self._last_fetch_time > 0: + time_since_fetch = now.timestamp() - self._last_fetch_time + if time_since_fetch > self.cache_ttl: + return True + + # Also check if cache is not from today (existing logic) + last_fetch_dt = datetime.datetime.fromtimestamp( + self._last_fetch_time, tz=datetime.timezone.utc + ) + if last_fetch_dt.date() < now.date(): + return True + return False + return False # Example usage diff --git a/tests/test_registry_retriever.py b/tests/test_registry_retriever.py index c85b352..64719b9 100644 --- a/tests/test_registry_retriever.py +++ b/tests/test_registry_retriever.py @@ -99,12 +99,19 @@ def test_registry_cache_management(self): # Verify the cache file was created self.assertTrue(retriever.registry_cache_path.exists(), "Cache file was not created") - # Modify the cache timestamp to test cache invalidation - registry_cache_path = retriever.registry_cache_path - yesterday = datetime.datetime.now() - datetime.timedelta(days=1) - yesterday_timestamp = yesterday.timestamp() - os.utime(registry_cache_path, (yesterday_timestamp, yesterday_timestamp)) - # Check if cache is outdated - should be since we modified the timestamp + # Modify the persistent timestamp to test cache invalidation + # We need to manipulate the persistent timestamp file, not just the cache file mtime + timestamp_file = retriever._last_fetch_time_path + if timestamp_file.exists(): + # Write an old timestamp to the persistent timestamp file + yesterday = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1) + old_timestamp_str = yesterday.isoformat().replace('+00:00', 'Z') + with open(timestamp_file, 'w', encoding='utf-8') as f: + f.write(old_timestamp_str) + # Reload the timestamp from file + retriever._load_last_fetch_time() + + # Check if cache is outdated - should be since we modified the persistent timestamp self.assertTrue(retriever.is_cache_outdated()) # Force refresh and verify new data is loaded (should fetch from online) @@ -133,7 +140,8 @@ def test_online_mode(self): # Get registry again with force refresh (should fetch from online) registry2 = retriever.get_registry(force_refresh=True) self.assertIn("repositories", registry2) - # Test error handling with an existing cache + + # Test error handling with an existing cache # First ensure we have a valid cache file self.assertTrue(retriever.registry_cache_path.exists(), "Cache file should exist after previous calls") @@ -154,7 +162,86 @@ def test_online_mode(self): bad_retriever.get_registry(force_refresh=True) except Exception: pass # Expected to fail, that's OK - + + def test_persistent_timestamp_across_cli_invocations(self): + """Test that persistent timestamp works across separate CLI invocations.""" + # First "CLI invocation" - create retriever and fetch registry + retriever1 = RegistryRetriever( + cache_ttl=300, # 5 minutes TTL + local_cache_dir=self.cache_dir, + simulation_mode=False + ) + + # Get registry (should fetch from online) + registry1 = retriever1.get_registry() + self.assertIsNotNone(registry1) + + # Verify timestamp file was created + self.assertTrue(retriever1._last_fetch_time_path.exists(), "Timestamp file should be created") + + # Get the timestamp from the first fetch + first_fetch_time = retriever1._last_fetch_time + self.assertGreater(first_fetch_time, 0, "First fetch time should be set") + + # Second "CLI invocation" - create new retriever with same cache directory + retriever2 = RegistryRetriever( + cache_ttl=300, # 5 minutes TTL + local_cache_dir=self.cache_dir, + simulation_mode=False + ) + + # Verify the timestamp was loaded from disk + self.assertGreater(retriever2._last_fetch_time, 0, "Timestamp should be loaded from disk") + + # Get registry (should use cache since timestamp is recent) + registry2 = retriever2.get_registry() + self.assertIsNotNone(registry2) + + # Verify cache was used and not a new fetch (timestamp should be same or very close) + time_diff = abs(retriever2._last_fetch_time - first_fetch_time) + self.assertLess(time_diff, 2.0, "Should use cached registry, not fetch new one") + + def test_persistent_timestamp_edge_cases(self): + """Test edge cases for persistent timestamp handling.""" + retriever = RegistryRetriever( + cache_ttl=300, # 5 minutes TTL + local_cache_dir=self.cache_dir, + simulation_mode=False + ) + + # Test 1: Corrupt timestamp file + timestamp_file = retriever._last_fetch_time_path + timestamp_file.parent.mkdir(parents=True, exist_ok=True) + + # Write corrupt data to timestamp file + with open(timestamp_file, 'w', encoding='utf-8') as f: + f.write("invalid_timestamp_data") + + # Should handle gracefully and treat as no timestamp + retriever._load_last_fetch_time() + self.assertEqual(retriever._last_fetch_time, 0, "Corrupt timestamp should be treated as no timestamp") + + # Test 2: Future timestamp (clock skew scenario) + future_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1) + future_timestamp_str = future_time.isoformat().replace('+00:00', 'Z') + with open(timestamp_file, 'w', encoding='utf-8') as f: + f.write(future_timestamp_str) + + retriever._load_last_fetch_time() + # Should handle future timestamps gracefully (treat as valid but check TTL normally) + self.assertGreater(retriever._last_fetch_time, 0, "Future timestamp should be loaded") + + # Test 3: Empty timestamp file + with open(timestamp_file, 'w', encoding='utf-8') as f: + f.write("") + + retriever._load_last_fetch_time() + self.assertEqual(retriever._last_fetch_time, 0, "Empty timestamp file should be treated as no timestamp") + + # Test 4: Missing timestamp file + timestamp_file.unlink() + retriever._load_last_fetch_time() + self.assertEqual(retriever._last_fetch_time, 0, "Missing timestamp file should be treated as no timestamp") if __name__ == "__main__": unittest.main() From 44bbe64076a33087b436335d355d266af93f8bc3 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Thu, 3 Jul 2025 22:53:41 +0900 Subject: [PATCH 33/48] [Update] Use named hatch environments **Major**: - Using `--prefix` to create the python environments associated to hatch environments was causing issues when under the directory of the hatch environment - These issues were encountered specifically during rebuild process of Hatchling image - Therefore it is a global bug for something that was not necessarily an issue with other deployment means - However it just means that we will let miniforge manage the environments which is not a bad thing either - Hence, now the API was updated to use `-n` (name) - Tests were updated to reflect the changes. All are passing. --- hatch/python_environment_manager.py | 229 +++++++++++++++-------- tests/test_python_environment_manager.py | 89 ++++++--- 2 files changed, 211 insertions(+), 107 deletions(-) diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index bff6b17..60f42bc 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -25,7 +25,7 @@ class PythonEnvironmentManager: """Manages Python environments using conda/mamba for cross-platform isolation. This class handles: - 1. Creating and managing conda/mamba environments locally under Hatch environment directories + 1. Creating and managing named conda/mamba environments 2. Python version management and executable path resolution 3. Cross-platform conda/mamba detection and validation 4. Environment lifecycle operations (create, remove, info) @@ -158,23 +158,11 @@ def _get_conda_env_name(self, env_name: str) -> str: """ return f"hatch_{env_name}" - def _get_conda_env_prefix(self, env_name: str) -> Path: - """Get the local conda environment prefix path. - - Args: - env_name (str): Hatch environment name. - - Returns: - Path: Local path where the conda environment should be installed. - """ - return self.environments_dir / env_name / "python_env" - def create_python_environment(self, env_name: str, python_version: Optional[str] = None, force: bool = False) -> bool: """Create a Python environment using conda/mamba. - Creates a conda environment locally under the Hatch environment directory - with the specified Python version. + Creates a named conda environment with the specified Python version. Args: env_name (str): Hatch environment name. @@ -193,20 +181,21 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] raise PythonEnvironmentError("Neither conda nor mamba is available for Python environment management") executable = self.get_preferred_executable() - env_prefix = self._get_conda_env_prefix(env_name) + env_name_conda = self._get_conda_env_name(env_name) + conda_env_exists = self._conda_env_exists(env_name) # Check if environment already exists - if self._conda_env_exists(env_name) and not force: + if conda_env_exists and not force: self.logger.warning(f"Python environment already exists for {env_name}") return True # Remove existing environment if force is True - if force and self._conda_env_exists(env_name): + if force and conda_env_exists: self.logger.info(f"Removing existing Python environment for {env_name}") self.remove_python_environment(env_name) # Build conda create command - cmd = [executable, "create", "--yes", "--prefix", str(env_prefix)] + cmd = [executable, "create", "--yes", "--name", env_name_conda] if python_version: cmd.extend(["python=" + python_version]) @@ -214,7 +203,7 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] cmd.append("python") try: - self.logger.debug(f"Creating Python environment for {env_name} at {env_prefix}") + self.logger.debug(f"Creating Python environment for {env_name} with name {env_name_conda}") if python_version: self.logger.debug(f"Using Python version: {python_version}") @@ -248,27 +237,72 @@ def _conda_env_exists(self, env_name: str) -> bool: Returns: bool: True if the conda environment exists, False otherwise. """ - env_prefix = self._get_conda_env_prefix(env_name) - python_executable = self._get_python_executable_path(env_name) + if not self.is_available(): + return False - # Check if the environment directory and Python executable exist - return env_prefix.exists() and python_executable.exists() + executable = self.get_preferred_executable() + env_name_conda = self._get_conda_env_name(env_name) + + try: + # Use conda env list to check if the environment exists + result = subprocess.run( + [executable, "env", "list", "--json"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + import json + envs_data = json.loads(result.stdout) + env_names = [Path(env).name for env in envs_data.get("envs", [])] + return env_name_conda in env_names + else: + return False + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + return False - def _get_python_executable_path(self, env_name: str) -> Path: + def _get_python_executable_path(self, env_name: str) -> Optional[Path]: """Get the Python executable path for a given environment. Args: env_name (str): Hatch environment name. Returns: - Path: Path to the Python executable in the environment. + Path: Path to the Python executable in the environment, None if not found. """ - env_prefix = self._get_conda_env_prefix(env_name) + if not self.is_available(): + return None - if platform.system() == "Windows": - return env_prefix / "python.exe" - else: - return env_prefix / "bin" / "python" + executable = self.get_preferred_executable() + env_name_conda = self._get_conda_env_name(env_name) + + try: + # Get environment info to find the prefix path + result = subprocess.run( + [executable, "info", "--envs", "--json"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + envs_data = json.loads(result.stdout) + envs = envs_data.get("envs", []) + + # Find the environment path + for env_path in envs: + if Path(env_path).name == env_name_conda: + if platform.system() == "Windows": + return Path(env_path) / "python.exe" + else: + return Path(env_path) / "bin" / "python" + + return None + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + return None def get_python_executable(self, env_name: str) -> Optional[str]: """Get the Python executable path for an environment if it exists. @@ -283,7 +317,7 @@ def get_python_executable(self, env_name: str) -> Optional[str]: return None python_path = self._get_python_executable_path(env_name) - return str(python_path) if python_path.exists() else None + return str(python_path) if python_path and python_path.exists() else None def remove_python_environment(self, env_name: str) -> bool: """Remove a Python environment. @@ -305,26 +339,19 @@ def remove_python_environment(self, env_name: str) -> bool: return True executable = self.get_preferred_executable() - env_prefix = self._get_conda_env_prefix(env_name) + env_name_conda = self._get_conda_env_name(env_name) try: self.logger.info(f"Removing Python environment for {env_name}") - # Use conda/mamba remove with --prefix + # Use conda/mamba remove with --name # Show output in terminal by not capturing output result = subprocess.run( - [executable, "env", "remove", "--yes", "--prefix", str(env_prefix)], + [executable, "env", "remove", "--yes", "--name", env_name_conda], timeout=120 # 2 minutes timeout ) if result.returncode == 0: - # Clean up any remaining directory structure - if env_prefix.exists(): - try: - shutil.rmtree(env_prefix) - except OSError as e: - self.logger.warning(f"Could not fully clean up environment directory: {e}") - return True else: error_msg = f"Failed to remove Python environment: (see terminal output)" @@ -354,14 +381,14 @@ def get_environment_info(self, env_name: str) -> Optional[Dict[str, Any]]: return None executable = self.get_preferred_executable() - env_prefix = self._get_conda_env_prefix(env_name) + env_name_conda = self._get_conda_env_name(env_name) python_executable = self._get_python_executable_path(env_name) info = { "environment_name": env_name, - "conda_env_name": self._get_conda_env_name(env_name), - "environment_path": str(env_prefix), - "python_executable": str(python_executable), + "conda_env_name": env_name_conda, + "environment_path": None, # Not applicable for named environments + "python_executable": str(python_executable) if python_executable else None, "python_version": self.get_python_version(env_name), "exists": True, "platform": platform.system() @@ -371,7 +398,7 @@ def get_environment_info(self, env_name: str) -> Optional[Dict[str, Any]]: if self.is_available(): try: result = subprocess.run( - [executable, "list", "--prefix", str(env_prefix), "--json"], + [executable, "list", "--name", env_name_conda, "--json"], capture_output=True, text=True, timeout=30 @@ -394,14 +421,29 @@ def list_environments(self) -> List[str]: """ environments = [] - if not self.environments_dir.exists(): + if not self.is_available(): return environments - for env_dir in self.environments_dir.iterdir(): - if env_dir.is_dir(): - env_name = env_dir.name - if self._conda_env_exists(env_name): - environments.append(env_name) + executable = self.get_preferred_executable() + + try: + result = subprocess.run( + [executable, "env", "list", "--json"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + envs_data = json.loads(result.stdout) + env_paths = envs_data.get("envs", []) + + # Filter for hatch environments + for env_path in env_paths: + environments.append(Path(env_path).name) + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + pass return environments @@ -441,7 +483,8 @@ def get_environment_activation_info(self, env_name: str) -> Optional[Dict[str, s This method returns the environment variables that should be set to properly activate the Python environment, but doesn't actually - modify the current process environment. + modify the current process environment. This can typically be used + when running subprocesses or in shell scripts to set up the environment. Args: env_name (str): Hatch environment name. @@ -452,28 +495,35 @@ def get_environment_activation_info(self, env_name: str) -> Optional[Dict[str, s if not self._conda_env_exists(env_name): return None - env_prefix = self._get_conda_env_prefix(env_name) + env_name_conda = self._get_conda_env_name(env_name) python_executable = self._get_python_executable_path(env_name) - env_vars = {} + if not python_executable: + return None - # Set CONDA_PREFIX and CONDA_DEFAULT_ENV - env_vars["CONDA_PREFIX"] = str(env_prefix) - env_vars["CONDA_DEFAULT_ENV"] = str(env_prefix) + env_vars = {} - # Update PATH to include environment's bin/Scripts directory - if platform.system() == "Windows": - scripts_dir = env_prefix / "Scripts" - library_bin = env_prefix / "Library" / "bin" - bin_paths = [str(env_prefix), str(scripts_dir), str(library_bin)] - else: - bin_dir = env_prefix / "bin" - bin_paths = [str(bin_dir)] + # Set CONDA_DEFAULT_ENV to the environment name + env_vars["CONDA_DEFAULT_ENV"] = env_name_conda - # Get current PATH and prepend environment paths - current_path = os.environ.get("PATH", "") - new_path = os.pathsep.join(bin_paths + [current_path]) - env_vars["PATH"] = new_path + # Get the actual environment path from conda + env_path = self._get_conda_env_path(env_name) + if env_path: + env_vars["CONDA_PREFIX"] = str(env_path) + + # Update PATH to include environment's bin/Scripts directory + if platform.system() == "Windows": + scripts_dir = env_path / "Scripts" + library_bin = env_path / "Library" / "bin" + bin_paths = [str(env_path), str(scripts_dir), str(library_bin)] + else: + bin_dir = env_path / "bin" + bin_paths = [str(bin_dir)] + + # Get current PATH and prepend environment paths + current_path = os.environ.get("PATH", "") + new_path = os.pathsep.join(bin_paths + [current_path]) + env_vars["PATH"] = new_path # Set PYTHON environment variable env_vars["PYTHON"] = str(python_executable) @@ -506,7 +556,7 @@ def get_environment_diagnostics(self, env_name: str) -> Dict[str, Any]: """ diagnostics = { "environment_name": env_name, - "conda_env_name": f"hatch-{env_name}", + "conda_env_name": self._get_conda_env_name(env_name), "exists": False, "conda_available": self.is_available(), "manager_executable": self.mamba_executable or self.conda_executable, @@ -620,7 +670,8 @@ def launch_shell(self, env_name: str, cmd: Optional[str] = None) -> bool: # On Windows, we need to activate the conda environment first if platform.system() == "Windows": - activate_cmd = f"{self.get_preferred_executable()} activate {self._get_conda_env_prefix(env_name)} && python" + env_name_conda = self._get_conda_env_name(env_name) + activate_cmd = f"{self.get_preferred_executable()} activate {env_name_conda} && python" result = subprocess.run( ["cmd", "/c", activate_cmd], cwd=os.getcwd() @@ -650,15 +701,39 @@ def environment_exists(self, env_name: str) -> bool: return self._conda_env_exists(env_name) def get_environment_path(self, env_name: str) -> Optional[Path]: - """Get the file system path for a Python environment. + """Get the actual filesystem path for a conda environment. Args: - env_name (str): Environment name. + env_name (str): Hatch environment name. Returns: - Path: Environment path or None if not found. + Path: Path to the conda environment directory, None if not found. """ - if not self.environment_exists(env_name): + if not self.is_available(): return None + + executable = self.get_preferred_executable() + env_name_conda = self._get_conda_env_name(env_name) + + try: + result = subprocess.run( + [executable, "info", "--envs", "--json"], + capture_output=True, + text=True, + timeout=30 + ) - return self._get_conda_env_prefix(env_name) + if result.returncode == 0: + import json + envs_data = json.loads(result.stdout) + envs = envs_data.get("envs", []) + + # Find the environment path + for env_path in envs: + if Path(env_path).name == env_name_conda: + return Path(env_path) + + return None + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError): + return None diff --git a/tests/test_python_environment_manager.py b/tests/test_python_environment_manager.py index 3ba79ee..bd672fa 100644 --- a/tests/test_python_environment_manager.py +++ b/tests/test_python_environment_manager.py @@ -64,27 +64,36 @@ def test_get_conda_env_name(self): conda_name = self.manager._get_conda_env_name(env_name) self.assertEqual(conda_name, "hatch_test_env") - def test_get_conda_env_prefix(self): - """Test conda environment prefix path generation.""" - env_name = "test_env" - prefix = self.manager._get_conda_env_prefix(env_name) - expected = self.environments_dir / "test_env" / "python_env" - self.assertEqual(prefix, expected) - - def test_get_python_executable_path_windows(self): + @patch('subprocess.run') + def test_get_python_executable_path_windows(self, mock_run): """Test Python executable path on Windows.""" with patch('platform.system', return_value='Windows'): env_name = "test_env" + + # Mock conda info command to return environment path + mock_run.return_value = Mock( + returncode=0, + stdout='{"envs": ["/conda/envs/hatch_test_env"]}' + ) + python_path = self.manager._get_python_executable_path(env_name) - expected = self.environments_dir / "test_env" / "python_env" / "python.exe" + expected = Path("/conda/envs/hatch_test_env/python.exe") self.assertEqual(python_path, expected) - def test_get_python_executable_path_unix(self): + @patch('subprocess.run') + def test_get_python_executable_path_unix(self, mock_run): """Test Python executable path on Unix/Linux.""" with patch('platform.system', return_value='Linux'): env_name = "test_env" + + # Mock conda info command to return environment path + mock_run.return_value = Mock( + returncode=0, + stdout='{"envs": ["/conda/envs/hatch_test_env"]}' + ) + python_path = self.manager._get_python_executable_path(env_name) - expected = self.environments_dir / "test_env" / "python_env" / "bin" / "python" + expected = Path("/conda/envs/hatch_test_env/bin/python") self.assertEqual(python_path, expected) def test_is_available_no_conda(self): @@ -182,38 +191,58 @@ def run_side_effect(cmd, *args, **kwargs): create_calls = [call for call in mock_run.call_args_list if "create" in call[0][0]] self.assertEqual(len(create_calls), 0) - def test_conda_env_exists(self): + @patch('subprocess.run') + def test_conda_env_exists(self, mock_run): """Test conda environment existence check.""" env_name = "test_env" - # Create the environment directory structure - env_prefix = self.manager._get_conda_env_prefix(env_name) - env_prefix.mkdir(parents=True, exist_ok=True) - - # Create Python executable - python_executable = self.manager._get_python_executable_path(env_name) - python_executable.parent.mkdir(parents=True, exist_ok=True) - python_executable.write_text("#!/usr/bin/env python") + # Mock conda env list to return the environment + mock_run.return_value = Mock( + returncode=0, + stdout='{"envs": ["/conda/envs/hatch_test_env", "/conda/envs/other_env"]}' + ) self.assertTrue(self.manager._conda_env_exists(env_name)) - def test_conda_env_not_exists(self): + @patch('subprocess.run') + def test_conda_env_not_exists(self, mock_run): """Test conda environment existence check when environment doesn't exist.""" env_name = "nonexistent_env" + + # Mock conda env list to not return the environment + mock_run.return_value = Mock( + returncode=0, + stdout='{"envs": ["/conda/envs/other_env"]}' + ) + self.assertFalse(self.manager._conda_env_exists(env_name)) - def test_get_python_executable_exists(self): + @patch('subprocess.run') + def test_get_python_executable_exists(self, mock_run): """Test getting Python executable when environment exists.""" env_name = "test_env" - # Create environment and Python executable - python_executable = self.manager._get_python_executable_path(env_name) - python_executable.parent.mkdir(parents=True, exist_ok=True) - python_executable.write_text("#!/usr/bin/env python") + # Mock conda env list to show environment exists + def run_side_effect(cmd, *args, **kwargs): + if "env" in cmd and "list" in cmd: + return Mock(returncode=0, stdout='{"envs": ["/conda/envs/hatch_test_env"]}') + elif "info" in cmd and "--envs" in cmd: + return Mock(returncode=0, stdout='{"envs": ["/conda/envs/hatch_test_env"]}') + else: + return Mock(returncode=0, stdout='{}') - with patch.object(self.manager, '_conda_env_exists', return_value=True): + mock_run.side_effect = run_side_effect + + # Mock that the file exists + with patch('pathlib.Path.exists', return_value=True): result = self.manager.get_python_executable(env_name) - self.assertEqual(result, str(python_executable)) + import platform + from pathlib import Path as _Path + if platform.system() == "Windows": + expected = str(_Path("\\conda\\envs\\hatch_test_env\\python.exe")) + else: + expected = str(_Path("/conda/envs/hatch_test_env/bin/python")) + self.assertEqual(result, expected) def test_get_python_executable_not_exists(self): """Test getting Python executable when environment doesn't exist.""" @@ -425,7 +454,7 @@ def test_force_recreation_real(self): def test_list_environments_real(self): """Test listing environments with real conda environments.""" - test_envs = ["test_env_1", "test_env_2"] + test_envs = ["hatch_test_env_1", "hatch_test_env_2"] # Clean up any existing test environments for env_name in test_envs: @@ -640,7 +669,7 @@ def test_environment_diagnostics_structure(self): # Verify basic structure self.assertEqual(diagnostics["environment_name"], env_name) - self.assertEqual(diagnostics["conda_env_name"], f"hatch-{env_name}") + self.assertEqual(diagnostics["conda_env_name"], f"hatch_{env_name}") self.assertIsInstance(diagnostics["exists"], bool) self.assertIsInstance(diagnostics["conda_available"], bool) From f95ea7afbe5575148a2accc0e699b554960a937f Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Fri, 4 Jul 2025 00:04:25 +0900 Subject: [PATCH 34/48] [Fix] Forgot to switch to new function name **Major**: - `_get_conda_env_path` had been removed in `python_environment_manager.py` because it was a simple indirection to `get_environment_path` - But I had missed an occurence of the old function - This revealed that `get_environment_activation_info` was untested - This is now included in the test --- hatch/python_environment_manager.py | 2 +- tests/test_python_environment_manager.py | 59 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index 60f42bc..a4c78a9 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -507,7 +507,7 @@ def get_environment_activation_info(self, env_name: str) -> Optional[Dict[str, s env_vars["CONDA_DEFAULT_ENV"] = env_name_conda # Get the actual environment path from conda - env_path = self._get_conda_env_path(env_name) + env_path = self.get_environment_path(env_name) if env_path: env_vars["CONDA_PREFIX"] = str(env_path) diff --git a/tests/test_python_environment_manager.py b/tests/test_python_environment_manager.py index bd672fa..0351405 100644 --- a/tests/test_python_environment_manager.py +++ b/tests/test_python_environment_manager.py @@ -28,6 +28,65 @@ def tearDown(self): """Clean up test environment.""" shutil.rmtree(self.temp_dir, ignore_errors=True) + @patch('hatch.python_environment_manager.PythonEnvironmentManager._conda_env_exists', return_value=True) + @patch('hatch.python_environment_manager.PythonEnvironmentManager._get_conda_env_name', return_value='hatch_test_env') + @patch('hatch.python_environment_manager.PythonEnvironmentManager._get_python_executable_path', return_value='C:/fake/env/Scripts/python.exe') + @patch('hatch.python_environment_manager.PythonEnvironmentManager.get_environment_path', return_value=Path('C:/fake/env')) + @patch('platform.system', return_value='Windows') + def test_get_environment_activation_info_windows(self, mock_platform, mock_get_env_path, mock_get_python_exec_path, mock_get_conda_env_name, mock_conda_env_exists): + """Test get_environment_activation_info returns correct env vars on Windows.""" + env_name = 'test_env' + manager = PythonEnvironmentManager(environments_dir=Path('C:/fake/envs')) + env_vars = manager.get_environment_activation_info(env_name) + self.assertIsInstance(env_vars, dict) + self.assertEqual(env_vars['CONDA_DEFAULT_ENV'], 'hatch_test_env') + self.assertEqual(env_vars['CONDA_PREFIX'], str(Path('C:/fake/env'))) + self.assertIn('PATH', env_vars) + # On Windows, the path separator is ';' and paths are backslash + # Split PATH and check each expected directory is present as a component + path_dirs = env_vars['PATH'].split(';') + self.assertIn('C:\\fake\\env', path_dirs) + self.assertIn('C:\\fake\\env\\Scripts', path_dirs) + self.assertIn('C:\\fake\\env\\Library\\bin', path_dirs) + self.assertEqual(env_vars['PYTHON'], 'C:/fake/env/Scripts/python.exe') + + @patch('hatch.python_environment_manager.PythonEnvironmentManager._conda_env_exists', return_value=True) + @patch('hatch.python_environment_manager.PythonEnvironmentManager._get_conda_env_name', return_value='hatch_test_env') + @patch('hatch.python_environment_manager.PythonEnvironmentManager._get_python_executable_path', return_value='/fake/env/bin/python') + @patch('hatch.python_environment_manager.PythonEnvironmentManager.get_environment_path', return_value=Path('/fake/env')) + @patch('platform.system', return_value='Linux') + def test_get_environment_activation_info_unix(self, mock_platform, mock_get_env_path, mock_get_python_exec_path, mock_get_conda_env_name, mock_conda_env_exists): + """Test get_environment_activation_info returns correct env vars on Unix.""" + env_name = 'test_env' + manager = PythonEnvironmentManager(environments_dir=Path('/fake/envs')) + env_vars = manager.get_environment_activation_info(env_name) + self.assertIsInstance(env_vars, dict) + self.assertEqual(env_vars['CONDA_DEFAULT_ENV'], 'hatch_test_env') + self.assertEqual(env_vars['CONDA_PREFIX'], str(Path('/fake/env'))) + self.assertIn('PATH', env_vars) + # On Unix, the path separator is ':' and paths are forward slash, but Path() may normalize to backslash on Windows + # Accept both possible representations for cross-platform test running + path_dirs = env_vars['PATH'] + self.assertTrue('/fake/env/bin' in path_dirs or '\\fake\\env\\bin' in path_dirs, f"Expected '/fake/env/bin' or '\\fake\\env\\bin' to be in PATH: {env_vars['PATH']}") + self.assertEqual(env_vars['PYTHON'], '/fake/env/bin/python') + + @patch('hatch.python_environment_manager.PythonEnvironmentManager._conda_env_exists', return_value=False) + def test_get_environment_activation_info_env_not_exists(self, mock_conda_env_exists): + """Test get_environment_activation_info returns None if env does not exist.""" + env_name = 'nonexistent_env' + manager = PythonEnvironmentManager(environments_dir=Path('/fake/envs')) + env_vars = manager.get_environment_activation_info(env_name) + self.assertIsNone(env_vars) + + @patch('hatch.python_environment_manager.PythonEnvironmentManager._conda_env_exists', return_value=True) + @patch('hatch.python_environment_manager.PythonEnvironmentManager._get_python_executable_path', return_value=None) + def test_get_environment_activation_info_no_python(self, mock_get_python_exec_path, mock_conda_env_exists): + """Test get_environment_activation_info returns None if python executable not found.""" + env_name = 'test_env' + manager = PythonEnvironmentManager(environments_dir=Path('/fake/envs')) + env_vars = manager.get_environment_activation_info(env_name) + self.assertIsNone(env_vars) + def test_init(self): """Test PythonEnvironmentManager initialization.""" self.assertEqual(self.manager.environments_dir, self.environments_dir) From ea8505ae973ae923c39505995f8b3ab96fa4584b Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 7 Jul 2025 02:05:16 +0900 Subject: [PATCH 35/48] [Add] Installation of `hatch_mcp_server` **Major**: - `hatch_mcp_server` is the wrapper of the Cracking Shells ecosystem around MCP server. - It was recently added as a package in the ecosystem: https://github.com/CrackingShells/Hatch-MCP-Server - It is necessary in most cases to add this package by default when creating a new hatch environment, which has a python environment - We give CLI arguments control to opt out of the installation of this package - We give a CLI argument to control the git tag (branch name of actual tag) which should be installed. --- hatch/cli_hatch.py | 8 +- hatch/environment_manager.py | 112 ++- .../dependency_installation_orchestrator.py | 80 +- tests/test_env_manip.py | 896 +++++++++++------- 4 files changed, 748 insertions(+), 348 deletions(-) diff --git a/hatch/cli_hatch.py b/hatch/cli_hatch.py index f590860..ebda517 100644 --- a/hatch/cli_hatch.py +++ b/hatch/cli_hatch.py @@ -59,6 +59,10 @@ def main(): env_create_parser.add_argument("--python-version", help="Python version for the environment (e.g., 3.11, 3.12)") env_create_parser.add_argument("--no-python", action="store_true", help="Don't create a Python environment using conda/mamba") + env_create_parser.add_argument("--no-hatch-mcp-server", action="store_true", + help="Don't install hatch_mcp_server in the new environment") + env_create_parser.add_argument("--hatch_mcp_server_tag", + help="Git tag/branch reference for hatch_mcp_server installation (e.g., 'dev', 'v0.1.0')") # Remove environment command env_remove_parser = env_subparsers.add_parser("remove", help="Remove an environment") @@ -167,7 +171,9 @@ def main(): if env_manager.create_environment(args.name, args.description, python_version=python_version, - create_python_env=create_python_env): + create_python_env=create_python_env, + no_hatch_mcp_server=args.no_hatch_mcp_server, + hatch_mcp_server_tag=args.hatch_mcp_server_tag): print(f"Environment created: {args.name}") # Show Python environment status diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 2fdda3d..a7b8046 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -14,6 +14,7 @@ from hatch.registry_retriever import RegistryRetriever from hatch.package_loader import HatchPackageLoader from hatch.installers.dependency_installation_orchestrator import DependencyInstallerOrchestrator +from hatch.installers.installation_context import InstallationContext from hatch.python_environment_manager import PythonEnvironmentManager, PythonEnvironmentError class HatchEnvironmentError(Exception): @@ -246,7 +247,9 @@ def list_environments(self) -> List[Dict]: def create_environment(self, name: str, description: str = "", python_version: Optional[str] = None, - create_python_env: bool = True) -> bool: + create_python_env: bool = True, + no_hatch_mcp_server: bool = False, + hatch_mcp_server_tag: Optional[str] = None) -> bool: """ Create a new environment. @@ -255,6 +258,8 @@ def create_environment(self, name: str, description: str = "", description: Description of the environment python_version: Python version for the environment (e.g., "3.11", "3.12") create_python_env: Whether to create a Python environment using conda/mamba + no_hatch_mcp_server: Whether to skip installing hatch_mcp_server in the environment + hatch_mcp_server_tag: Git tag/branch reference for hatch_mcp_server installation Returns: bool: True if created successfully, False if environment already exists @@ -295,7 +300,7 @@ def create_environment(self, name: str, description: str = "", # Fallback if detailed info is not available python_env_info = { "enabled": True, - "conda_env_name": f"hatch-{name}", + "conda_env_name": f"hatch_{name}", "python_executable": None, "created_at": datetime.datetime.now().isoformat(), "version": None, @@ -325,8 +330,111 @@ def create_environment(self, name: str, description: str = "", self._save_environments() self.logger.info(f"Created environment: {name}") + + # Install hatch_mcp_server by default unless opted out + if not no_hatch_mcp_server and python_env_info is not None: + try: + self._install_hatch_mcp_server(name, hatch_mcp_server_tag) + except Exception as e: + self.logger.warning(f"Failed to install hatch_mcp_server in environment {name}: {e}") + # Don't fail environment creation if MCP server installation fails + return True + def _install_hatch_mcp_server(self, env_name: str, tag: Optional[str] = None) -> None: + """Install hatch_mcp_server package in the specified environment. + + Args: + env_name (str): Name of the environment to install MCP server in. + tag (str, optional): Git tag/branch reference for the installation. Defaults to None (uses default branch). + + Raises: + HatchEnvironmentError: If installation fails. + """ + try: + # Construct the package URL with optional tag + if tag: + package_git_url = f"git+https://github.com/CrackingShells/Hatch-MCP-Server.git@{tag}" + else: + package_git_url = "git+https://github.com/CrackingShells/Hatch-MCP-Server.git" + + # Create dependency structure following the schema + mcp_dep = { + "name": f"hatch_mcp_server @ {package_git_url}", + "version_constraint": "*", + "package_manager": "pip", + "type": "python", + "uri": package_git_url + } + + # Get environment path + env_path = self.get_environment_path(env_name) + + # Create installation context + context = InstallationContext( + environment_path=env_path, + environment_name=env_name, + temp_dir=env_path / ".tmp", + cache_dir=self.package_loader.cache_dir if hasattr(self.package_loader, 'cache_dir') else None, + parallel_enabled=False, + force_reinstall=False, + simulation_mode=False, + extra_config={ + "package_loader": self.package_loader, + "registry_service": self.registry_service, + "registry_data": self.registry_data + } + ) + + # Configure Python environment variables if available + python_executable = self.python_env_manager.get_python_executable(env_name) + if python_executable: + python_env_vars = {"PYTHON": python_executable} + self.dependency_orchestrator.set_python_env_vars(python_env_vars) + context.set_config("python_env_vars", python_env_vars) + + # Install using the orchestrator + self.logger.info(f"Installing hatch_mcp_server in environment {env_name}") + self.logger.info(f"Using python executable: {python_executable}") + installed_package = self.dependency_orchestrator.install_single_dep(mcp_dep, context) + + self._save_environments() + self.logger.info(f"Successfully installed hatch_mcp_server in environment {env_name}") + + except Exception as e: + self.logger.error(f"Failed to install hatch_mcp_server: {e}") + raise HatchEnvironmentError(f"Failed to install hatch_mcp_server: {e}") from e + + def install_mcp_server(self, env_name: Optional[str] = None, tag: Optional[str] = None) -> bool: + """Install hatch_mcp_server package in an existing environment. + + Args: + env_name (str, optional): Name of the environment. Uses current environment if None. + tag (str, optional): Git tag/branch reference for the installation. Defaults to None (uses default branch). + + Returns: + bool: True if installation succeeded, False otherwise. + """ + if env_name is None: + env_name = self._current_env_name + + if not self.environment_exists(env_name): + self.logger.error(f"Environment does not exist: {env_name}") + return False + + # Check if environment has Python support + env_data = self._environments[env_name] + if not env_data.get("python_env"): + self.logger.error(f"Environment {env_name} does not have Python support") + return False + + try: + self._install_hatch_mcp_server(env_name, tag) + return True + except Exception as e: + self.logger.error(f"Failed to install MCP server in environment {env_name}: {e}") + return False + def remove_environment(self, name: str) -> bool: """ Remove an environment. diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py index 4b8030c..27ccda3 100644 --- a/hatch/installers/dependency_installation_orchestrator.py +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -92,6 +92,63 @@ def get_python_env_vars(self) -> Optional[Dict[str, str]]: """ return self._python_env_vars + def install_single_dep(self, dep: Dict[str, Any], context: InstallationContext) -> Dict[str, Any]: + """Install a single dependency into the specified environment context. + + This method installs a single dependency using the appropriate installer from the registry. + It extracts the core installation logic from _execute_install_plan for reuse in other contexts. + This method operates with auto_approve=True and does not require user consent. + + Args: + dep (Dict[str, Any]): Dependency dictionary following the schema for the dependency type. + For Python dependencies, should include: name, version_constraint, package_manager. + Example: {"name": "numpy", "version_constraint": "*", "package_manager": "pip", "type": "python"} + context (InstallationContext): Installation context with environment path and configuration. + + Returns: + Dict[str, Any]: Installed package information containing: + - name: Package name + - version: Installed version + - type: Dependency type + - source: Package source URI + + Raises: + DependencyInstallationError: If installation fails or dependency type is not supported. + """ + # Ensure dependency has type information + dep_type = dep.get("type") + if not dep_type: + raise DependencyInstallationError(f"Dependency missing 'type' field: {dep}") + + # Check if installer is registered for this dependency type + if not installer_registry.is_registered(dep_type): + raise DependencyInstallationError(f"No installer registered for dependency type: {dep_type}") + + installer = installer_registry.get_installer(dep_type) + + try: + self.logger.info(f"Installing {dep_type} dependency: {dep['name']}") + result = installer.install(dep, context) + if result.status == InstallationStatus.COMPLETED: + installed_package = { + "name": dep["name"], + "version": dep.get("resolved_version", dep.get("version")), + "type": dep_type, + "source": dep.get("uri", "unknown") + } + self.logger.info(f"Successfully installed {dep_type} dependency: {dep['name']}") + return installed_package + else: + raise DependencyInstallationError(f"Failed to install {dep['name']}: {result.error_message}") + + except InstallationError as e: + self.logger.error(f"Installation error for {dep_type} dependency {dep['name']}: {e.error_code}\n{e.message}") + raise DependencyInstallationError(f"Installation error for {dep['name']}: {e}") from e + + except Exception as e: + self.logger.error(f"Error installing {dep_type} dependency {dep['name']}: {e}") + raise DependencyInstallationError(f"Error installing {dep['name']}: {e}") from e + def install_dependencies(self, package_path_or_name: str, env_path: Path, @@ -501,26 +558,9 @@ def _execute_install_plan(self, installer = installer_registry.get_installer(dep_type) for dep in dependencies: - try: - result = installer.install(dep, context) - if result.status == InstallationStatus.COMPLETED: - installed_packages.append({ - "name": dep["name"], - "version": dep.get("resolved_version", dep.get("version")), - "type": dep_type, - "source": dep.get("uri", "unknown") - }) - self.logger.info(f"Successfully installed {dep_type} dependency: {dep['name']}") - else: - raise DependencyInstallationError(f"Failed to install {dep['name']}: {result.error_message}") - - except InstallationError as e: - self.logger.error(f"Installation error for {dep_type} dependency {dep['name']}: {e.error_code}\n{e.message}") - raise DependencyInstallationError(f"Installation error for {dep['name']}: {e}") from e - - except Exception as e: - self.logger.error(f"Error installing {dep_type} dependency {dep['name']}: {e}") - raise DependencyInstallationError(f"Error installing {dep['name']}: {e}") from e + # Use the extracted install_single_dep method + installed_package = self.install_single_dep(dep, context) + installed_packages.append(installed_package) # Install main package last main_pkg_info = self._install_main_package(context) diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index 95100b7..ad5cd4d 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -135,351 +135,597 @@ def tearDown(self): # Remove temporary directory shutil.rmtree(self.temp_dir) - def test_create_environment(self): - """Test creating an environment.""" - result = self.env_manager.create_environment("test_env", "Test environment") - self.assertTrue(result, "Failed to create environment") - - # Verify environment exists - self.assertTrue(self.env_manager.environment_exists("test_env"), "Environment doesn't exist after creation") - - # Verify environment data - env_data = self.env_manager.get_environments().get("test_env") - self.assertIsNotNone(env_data, "Environment data not found") - self.assertEqual(env_data["name"], "test_env") - self.assertEqual(env_data["description"], "Test environment") - self.assertIn("created_at", env_data) - self.assertIn("packages", env_data) - self.assertEqual(len(env_data["packages"]), 0) + # def test_create_environment(self): + # """Test creating an environment.""" + # result = self.env_manager.create_environment("test_env", "Test environment") + # self.assertTrue(result, "Failed to create environment") + + # # Verify environment exists + # self.assertTrue(self.env_manager.environment_exists("test_env"), "Environment doesn't exist after creation") + + # # Verify environment data + # env_data = self.env_manager.get_environments().get("test_env") + # self.assertIsNotNone(env_data, "Environment data not found") + # self.assertEqual(env_data["name"], "test_env") + # self.assertEqual(env_data["description"], "Test environment") + # self.assertIn("created_at", env_data) + # self.assertIn("packages", env_data) + # self.assertEqual(len(env_data["packages"]), 0) - def test_remove_environment(self): - """Test removing an environment.""" - # First create an environment - self.env_manager.create_environment("test_env", "Test environment") - self.assertTrue(self.env_manager.environment_exists("test_env")) - - # Then remove it - result = self.env_manager.remove_environment("test_env") - self.assertTrue(result, "Failed to remove environment") - - # Verify environment no longer exists - self.assertFalse(self.env_manager.environment_exists("test_env"), "Environment still exists after removal") + # def test_remove_environment(self): + # """Test removing an environment.""" + # # First create an environment + # self.env_manager.create_environment("test_env", "Test environment") + # self.assertTrue(self.env_manager.environment_exists("test_env")) + + # # Then remove it + # result = self.env_manager.remove_environment("test_env") + # self.assertTrue(result, "Failed to remove environment") + + # # Verify environment no longer exists + # self.assertFalse(self.env_manager.environment_exists("test_env"), "Environment still exists after removal") - def test_set_current_environment(self): - """Test setting the current environment.""" - # First create an environment - self.env_manager.create_environment("test_env", "Test environment") - - # Set it as current - result = self.env_manager.set_current_environment("test_env") - self.assertTrue(result, "Failed to set current environment") - - # Verify it's the current environment - current_env = self.env_manager.get_current_environment() - self.assertEqual(current_env, "test_env", "Current environment not set correctly") + # def test_set_current_environment(self): + # """Test setting the current environment.""" + # # First create an environment + # self.env_manager.create_environment("test_env", "Test environment") + + # # Set it as current + # result = self.env_manager.set_current_environment("test_env") + # self.assertTrue(result, "Failed to set current environment") + + # # Verify it's the current environment + # current_env = self.env_manager.get_current_environment() + # self.assertEqual(current_env, "test_env", "Current environment not set correctly") - def test_add_local_package(self): - """Test adding a local package to an environment.""" - # Create an environment - self.env_manager.create_environment("test_env", "Test environment") - self.env_manager.set_current_environment("test_env") - - # Use arithmetic_pkg from Hatching-Dev - pkg_path = self.hatch_dev_path / "arithmetic_pkg" - self.assertTrue(pkg_path.exists(), f"Test package not found: {pkg_path}") - - # Add package to environment - result = self.env_manager.add_package_to_environment( - str(pkg_path), # Convert to string to handle Path objects - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add local package to environment") - - # Verify package was added to environment data - env_data = self.env_manager.get_environments().get("test_env") - self.assertIsNotNone(env_data, "Environment data not found") - - packages = env_data.get("packages", []) - self.assertEqual(len(packages), 1, "Package not added to environment data") - - pkg_data = packages[0] - self.assertIn("name", pkg_data, "Package data missing name") - self.assertIn("version", pkg_data, "Package data missing version") - self.assertIn("type", pkg_data, "Package data missing type") - self.assertIn("source", pkg_data, "Package data missing source") + # def test_add_local_package(self): + # """Test adding a local package to an environment.""" + # # Create an environment + # self.env_manager.create_environment("test_env", "Test environment") + # self.env_manager.set_current_environment("test_env") + + # # Use arithmetic_pkg from Hatching-Dev + # pkg_path = self.hatch_dev_path / "arithmetic_pkg" + # self.assertTrue(pkg_path.exists(), f"Test package not found: {pkg_path}") + + # # Add package to environment + # result = self.env_manager.add_package_to_environment( + # str(pkg_path), # Convert to string to handle Path objects + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add local package to environment") + + # # Verify package was added to environment data + # env_data = self.env_manager.get_environments().get("test_env") + # self.assertIsNotNone(env_data, "Environment data not found") + + # packages = env_data.get("packages", []) + # self.assertEqual(len(packages), 1, "Package not added to environment data") + + # pkg_data = packages[0] + # self.assertIn("name", pkg_data, "Package data missing name") + # self.assertIn("version", pkg_data, "Package data missing version") + # self.assertIn("type", pkg_data, "Package data missing type") + # self.assertIn("source", pkg_data, "Package data missing source") - def test_add_package_with_dependencies(self): - """Test adding a package with dependencies to an environment.""" - # Create an environment - self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - self.env_manager.set_current_environment("test_env") + # def test_add_package_with_dependencies(self): + # """Test adding a package with dependencies to an environment.""" + # # Create an environment + # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # self.env_manager.set_current_environment("test_env") - # First add the base package that is a dependency - base_pkg_path = self.hatch_dev_path / "base_pkg_1" - self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(base_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - self.assertTrue(result, "Failed to add base package to environment") + # # First add the base package that is a dependency + # base_pkg_path = self.hatch_dev_path / "base_pkg_1" + # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(base_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + # self.assertTrue(result, "Failed to add base package to environment") - # Then add the package with dependencies - pkg_path = self.hatch_dev_path / "simple_dep_pkg" - self.assertTrue(pkg_path.exists(), f"Dependent package not found: {pkg_path}") - - # Add package to environment - result = self.env_manager.add_package_to_environment( - str(pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add package with dependencies") - - # Verify both packages are in the environment - env_data = self.env_manager.get_environments().get("test_env") - self.assertIsNotNone(env_data, "Environment data not found") - - packages = env_data.get("packages", []) - self.assertEqual(len(packages), 2, "Not all packages were added to environment") - - # Check that both packages are in the environment data - package_names = [pkg["name"] for pkg in packages] - self.assertIn("base_pkg_1", package_names, "Base package missing from environment") - self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") + # # Then add the package with dependencies + # pkg_path = self.hatch_dev_path / "simple_dep_pkg" + # self.assertTrue(pkg_path.exists(), f"Dependent package not found: {pkg_path}") + + # # Add package to environment + # result = self.env_manager.add_package_to_environment( + # str(pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add package with dependencies") + + # # Verify both packages are in the environment + # env_data = self.env_manager.get_environments().get("test_env") + # self.assertIsNotNone(env_data, "Environment data not found") + + # packages = env_data.get("packages", []) + # self.assertEqual(len(packages), 2, "Not all packages were added to environment") + + # # Check that both packages are in the environment data + # package_names = [pkg["name"] for pkg in packages] + # self.assertIn("base_pkg_1", package_names, "Base package missing from environment") + # self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") - def test_add_package_with_some_dependencies_already_present(self): - """Test adding a package where some dependencies are already present and others are not.""" - # Create an environment - self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - self.env_manager.set_current_environment("test_env") - # First add only one of the dependencies that complex_dep_pkg needs - base_pkg_path = self.hatch_dev_path / "base_pkg_1" - self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(base_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - self.assertTrue(result, "Failed to add base package to environment") - - # Verify base_pkg_1 is in the environment - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - self.assertEqual(len(packages), 1, "Base package not added correctly") - self.assertEqual(packages[0]["name"], "base_pkg_1", "Wrong package added") - - # Now add complex_dep_pkg which depends on base_pkg_1, base_pkg_2 - # base_pkg_1 should be satisfied, base_pkg_2 should need installation - complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" - self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(complex_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add package with mixed dependency states") - - # Verify all required packages are now in the environment - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) + # def test_add_package_with_some_dependencies_already_present(self): + # """Test adding a package where some dependencies are already present and others are not.""" + # # Create an environment + # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # self.env_manager.set_current_environment("test_env") + # # First add only one of the dependencies that complex_dep_pkg needs + # base_pkg_path = self.hatch_dev_path / "base_pkg_1" + # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(base_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + # self.assertTrue(result, "Failed to add base package to environment") + + # # Verify base_pkg_1 is in the environment + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # self.assertEqual(len(packages), 1, "Base package not added correctly") + # self.assertEqual(packages[0]["name"], "base_pkg_1", "Wrong package added") + + # # Now add complex_dep_pkg which depends on base_pkg_1, base_pkg_2 + # # base_pkg_1 should be satisfied, base_pkg_2 should need installation + # complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" + # self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(complex_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add package with mixed dependency states") + + # # Verify all required packages are now in the environment + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) - # Should have base_pkg_1 (already present), base_pkg_2, and complex_dep_pkg - expected_packages = ["base_pkg_1", "base_pkg_2", "complex_dep_pkg"] - package_names = [pkg["name"] for pkg in packages] + # # Should have base_pkg_1 (already present), base_pkg_2, and complex_dep_pkg + # expected_packages = ["base_pkg_1", "base_pkg_2", "complex_dep_pkg"] + # package_names = [pkg["name"] for pkg in packages] - for pkg_name in expected_packages: - self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") + # for pkg_name in expected_packages: + # self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") - def test_add_package_with_all_dependencies_already_present(self): - """Test adding a package where all dependencies are already present.""" - # Create an environment - self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - self.env_manager.set_current_environment("test_env") - # First add all dependencies that simple_dep_pkg needs - base_pkg_path = self.hatch_dev_path / "base_pkg_1" - self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(base_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - self.assertTrue(result, "Failed to add base package to environment") - - # Verify base package is installed - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - self.assertEqual(len(packages), 1, "Base package not added correctly") - - # Now add simple_dep_pkg which only depends on base_pkg_1 (which is already present) - simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" - self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(simple_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add package with all dependencies satisfied") - - # Verify both packages are in the environment - no new dependencies should be added - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - - # Should have base_pkg_1 (already present) and simple_dep_pkg (newly added) - expected_packages = ["base_pkg_1", "simple_dep_pkg"] - package_names = [pkg["name"] for pkg in packages] - - self.assertEqual(len(packages), 2, "Unexpected number of packages in environment") - for pkg_name in expected_packages: - self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") + # def test_add_package_with_all_dependencies_already_present(self): + # """Test adding a package where all dependencies are already present.""" + # # Create an environment + # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # self.env_manager.set_current_environment("test_env") + # # First add all dependencies that simple_dep_pkg needs + # base_pkg_path = self.hatch_dev_path / "base_pkg_1" + # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(base_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + # self.assertTrue(result, "Failed to add base package to environment") + + # # Verify base package is installed + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # self.assertEqual(len(packages), 1, "Base package not added correctly") + + # # Now add simple_dep_pkg which only depends on base_pkg_1 (which is already present) + # simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" + # self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(simple_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add package with all dependencies satisfied") + + # # Verify both packages are in the environment - no new dependencies should be added + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + + # # Should have base_pkg_1 (already present) and simple_dep_pkg (newly added) + # expected_packages = ["base_pkg_1", "simple_dep_pkg"] + # package_names = [pkg["name"] for pkg in packages] + + # self.assertEqual(len(packages), 2, "Unexpected number of packages in environment") + # for pkg_name in expected_packages: + # self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") - def test_add_package_with_version_constraint_satisfaction(self): - """Test adding a package with version constraints where dependencies are satisfied.""" - # Create an environment - self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - self.env_manager.set_current_environment("test_env") + # def test_add_package_with_version_constraint_satisfaction(self): + # """Test adding a package with version constraints where dependencies are satisfied.""" + # # Create an environment + # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # self.env_manager.set_current_environment("test_env") - # Add base_pkg_1 with a specific version - base_pkg_path = self.hatch_dev_path / "base_pkg_1" - self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(base_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - self.assertTrue(result, "Failed to add base package to environment") - - # Look for a package that has version constraints to test against - # For now, we'll simulate this by trying to add another package that depends on base_pkg_1 - simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" - self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(simple_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add package with version constraint dependencies") - - # Verify packages are correctly installed - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - package_names = [pkg["name"] for pkg in packages] - - self.assertIn("base_pkg_1", package_names, "Base package missing from environment") - self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") + # # Add base_pkg_1 with a specific version + # base_pkg_path = self.hatch_dev_path / "base_pkg_1" + # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(base_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + # self.assertTrue(result, "Failed to add base package to environment") + + # # Look for a package that has version constraints to test against + # # For now, we'll simulate this by trying to add another package that depends on base_pkg_1 + # simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" + # self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(simple_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add package with version constraint dependencies") + + # # Verify packages are correctly installed + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # package_names = [pkg["name"] for pkg in packages] + + # self.assertIn("base_pkg_1", package_names, "Base package missing from environment") + # self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") - def test_add_package_with_mixed_dependency_types(self): - """Test adding a package with mixed hatch and python dependencies.""" - # Create an environment - self.env_manager.create_environment("test_env", "Test environment") - self.env_manager.set_current_environment("test_env") - - # Add a package that has both hatch and python dependencies - python_dep_pkg_path = self.hatch_dev_path / "python_dep_pkg" - self.assertTrue(python_dep_pkg_path.exists(), f"Python dependency package not found: {python_dep_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(python_dep_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add package with mixed dependency types") - - # Verify package was added - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - package_names = [pkg["name"] for pkg in packages] - - self.assertIn("python_dep_pkg", package_names, "Package with mixed dependencies missing from environment") - - # Now add a package that depends on the python_dep_pkg (should be satisfied) - # and also depends on other packages (should need installation) - complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" - self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") - - result = self.env_manager.add_package_to_environment( - str(complex_pkg_path), - "test_env", - auto_approve=True # Auto-approve for testing - ) - - self.assertTrue(result, "Failed to add package with mixed satisfied/unsatisfied dependencies") - - # Verify all expected packages are present - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - package_names = [pkg["name"] for pkg in packages] - - # Should have python_dep_pkg (already present) plus any other dependencies of complex_dep_pkg - self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") - self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") + # def test_add_package_with_mixed_dependency_types(self): + # """Test adding a package with mixed hatch and python dependencies.""" + # # Create an environment + # self.env_manager.create_environment("test_env", "Test environment") + # self.env_manager.set_current_environment("test_env") + + # # Add a package that has both hatch and python dependencies + # python_dep_pkg_path = self.hatch_dev_path / "python_dep_pkg" + # self.assertTrue(python_dep_pkg_path.exists(), f"Python dependency package not found: {python_dep_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(python_dep_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add package with mixed dependency types") + + # # Verify package was added + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # package_names = [pkg["name"] for pkg in packages] + + # self.assertIn("python_dep_pkg", package_names, "Package with mixed dependencies missing from environment") + + # # Now add a package that depends on the python_dep_pkg (should be satisfied) + # # and also depends on other packages (should need installation) + # complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" + # self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") + + # result = self.env_manager.add_package_to_environment( + # str(complex_pkg_path), + # "test_env", + # auto_approve=True # Auto-approve for testing + # ) + + # self.assertTrue(result, "Failed to add package with mixed satisfied/unsatisfied dependencies") + + # # Verify all expected packages are present + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # package_names = [pkg["name"] for pkg in packages] + + # # Should have python_dep_pkg (already present) plus any other dependencies of complex_dep_pkg + # self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") + # self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") - # Python dep package has a dep to request. This should be satisfied in the python environment - python_env_info = self.env_manager.python_env_manager.get_environment_info("test_env") - packages = python_env_info.get("packages", []) - self.assertIsNotNone(packages, "Python environment packages not found") - self.assertGreater(len(packages), 0, "No packages found in Python environment") - package_names = [pkg["name"] for pkg in packages] - self.assertIn("requests", package_names, f"Expected 'requests' package not found in Python environment: {packages}") + # # Python dep package has a dep to request. This should be satisfied in the python environment + # python_env_info = self.env_manager.python_env_manager.get_environment_info("test_env") + # packages = python_env_info.get("packages", []) + # self.assertIsNotNone(packages, "Python environment packages not found") + # self.assertGreater(len(packages), 0, "No packages found in Python environment") + # package_names = [pkg["name"] for pkg in packages] + # self.assertIn("requests", package_names, f"Expected 'requests' package not found in Python environment: {packages}") - @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") - def test_add_package_with_system_dependency(self): - """Test adding a package with a system dependency.""" - self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - self.env_manager.set_current_environment("test_env") - # Add a package that declares a system dependency (e.g., 'curl') - system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" - self.assertTrue(system_dep_pkg_path.exists(), f"System dependency package not found: {system_dep_pkg_path}") + # @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") + # def test_add_package_with_system_dependency(self): + # """Test adding a package with a system dependency.""" + # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # self.env_manager.set_current_environment("test_env") + # # Add a package that declares a system dependency (e.g., 'curl') + # system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" + # self.assertTrue(system_dep_pkg_path.exists(), f"System dependency package not found: {system_dep_pkg_path}") - result = self.env_manager.add_package_to_environment( - str(system_dep_pkg_path), - "test_env", - auto_approve=True - ) - self.assertTrue(result, "Failed to add package with system dependency") + # result = self.env_manager.add_package_to_environment( + # str(system_dep_pkg_path), + # "test_env", + # auto_approve=True + # ) + # self.assertTrue(result, "Failed to add package with system dependency") - # Verify package was added - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - package_names = [pkg["name"] for pkg in packages] - self.assertIn("system_dep_pkg", package_names, "System dependency package missing from environment") + # # Verify package was added + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # package_names = [pkg["name"] for pkg in packages] + # self.assertIn("system_dep_pkg", package_names, "System dependency package missing from environment") - # Skip if Docker is not available - @unittest.skipUnless(DOCKER_DAEMON_AVAILABLE, "Docker dependency test skipped due to Docker not being available") - def test_add_package_with_docker_dependency(self): - """Test adding a package with a docker dependency.""" - self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - self.env_manager.set_current_environment("test_env") - # Add a package that declares a docker dependency (e.g., 'redis:latest') - docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" - self.assertTrue(docker_dep_pkg_path.exists(), f"Docker dependency package not found: {docker_dep_pkg_path}") + # # Skip if Docker is not available + # @unittest.skipUnless(DOCKER_DAEMON_AVAILABLE, "Docker dependency test skipped due to Docker not being available") + # def test_add_package_with_docker_dependency(self): + # """Test adding a package with a docker dependency.""" + # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + # self.env_manager.set_current_environment("test_env") + # # Add a package that declares a docker dependency (e.g., 'redis:latest') + # docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" + # self.assertTrue(docker_dep_pkg_path.exists(), f"Docker dependency package not found: {docker_dep_pkg_path}") - result = self.env_manager.add_package_to_environment( - str(docker_dep_pkg_path), - "test_env", - auto_approve=True - ) - self.assertTrue(result, "Failed to add package with docker dependency") + # result = self.env_manager.add_package_to_environment( + # str(docker_dep_pkg_path), + # "test_env", + # auto_approve=True + # ) + # self.assertTrue(result, "Failed to add package with docker dependency") - # Verify package was added - env_data = self.env_manager.get_environments().get("test_env") - packages = env_data.get("packages", []) - package_names = [pkg["name"] for pkg in packages] - self.assertIn("docker_dep_pkg", package_names, "Docker dependency package missing from environment") + # # Verify package was added + # env_data = self.env_manager.get_environments().get("test_env") + # packages = env_data.get("packages", []) + # package_names = [pkg["name"] for pkg in packages] + # self.assertIn("docker_dep_pkg", package_names, "Docker dependency package missing from environment") + + def test_create_environment_with_mcp_server_default(self): + """Test creating environment with default MCP server installation.""" + # Mock the MCP server installation to avoid actual network calls + original_install = self.env_manager._install_hatch_mcp_server + installed_env = None + installed_tag = None + + def mock_install(env_name, tag=None): + nonlocal installed_env, installed_tag + installed_env = env_name + installed_tag = tag + # Simulate successful installation + package_git_url = "git+https://github.com/CrackingShells/Hatch-MCP-Server.git" + env_data = self.env_manager._environments[env_name] + env_data["packages"].append({ + "name": f"hatch_mcp_server @ {package_git_url}", + "version": "dev", + "type": "python", + "source": package_git_url, + "installed_at": datetime.now().isoformat() + }) + + self.env_manager._install_hatch_mcp_server = mock_install + + try: + # Create environment without Python environment but simulate that it has one + success = self.env_manager.create_environment("test_mcp_default", + description="Test MCP default", + create_python_env=False, # Don't create actual Python env + no_hatch_mcp_server=False) + + # Manually set python_env info to simulate having Python support + self.env_manager._environments["test_mcp_default"]["python_env"] = { + "enabled": True, + "conda_env_name": "hatch-test_mcp_default", + "python_executable": "/fake/python", + "created_at": datetime.now().isoformat(), + "version": "3.11.0", + "manager": "conda" + } + + # Now call the MCP installation manually (since we bypassed Python env creation) + self.env_manager._install_hatch_mcp_server("test_mcp_default", None) + + self.assertTrue(success, "Environment creation should succeed") + self.assertEqual(installed_env, "test_mcp_default", "MCP server should be installed in correct environment") + self.assertIsNone(installed_tag, "Default installation should use no specific tag") + + # Verify MCP server package is in environment + env_data = self.env_manager._environments["test_mcp_default"] + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + expected_name = "hatch_mcp_server @ git+https://github.com/CrackingShells/Hatch-MCP-Server.git" + self.assertIn(expected_name, package_names, "MCP server should be installed by default with correct name syntax") + + finally: + # Restore original method + self.env_manager._install_hatch_mcp_server = original_install + + def test_create_environment_with_mcp_server_opt_out(self): + """Test creating environment with MCP server installation opted out.""" + # Mock the MCP server installation to track calls + original_install = self.env_manager._install_hatch_mcp_server + install_called = False + + def mock_install(env_name, tag=None): + nonlocal install_called + install_called = True + + self.env_manager._install_hatch_mcp_server = mock_install + + try: + # Create environment without Python environment, MCP server opted out + success = self.env_manager.create_environment("test_mcp_opt_out", + description="Test MCP opt out", + create_python_env=False, # Don't create actual Python env + no_hatch_mcp_server=True) + + # Manually set python_env info to simulate having Python support + self.env_manager._environments["test_mcp_opt_out"]["python_env"] = { + "enabled": True, + "conda_env_name": "hatch-test_mcp_opt_out", + "python_executable": "/fake/python", + "created_at": datetime.now().isoformat(), + "version": "3.11.0", + "manager": "conda" + } + + self.assertTrue(success, "Environment creation should succeed") + self.assertFalse(install_called, "MCP server installation should not be called when opted out") + + # Verify MCP server package is NOT in environment + env_data = self.env_manager._environments["test_mcp_opt_out"] + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + expected_name = "hatch_mcp_server @ git+https://github.com/CrackingShells/Hatch-MCP-Server.git" + self.assertNotIn(expected_name, package_names, "MCP server should not be installed when opted out") + + finally: + # Restore original method + self.env_manager._install_hatch_mcp_server = original_install + + def test_create_environment_with_mcp_server_custom_tag(self): + """Test creating environment with custom MCP server tag.""" + # Mock the MCP server installation to avoid actual network calls + original_install = self.env_manager._install_hatch_mcp_server + installed_tag = None + + def mock_install(env_name, tag=None): + nonlocal installed_tag + installed_tag = tag + # Simulate successful installation + package_git_url = f"git+https://github.com/CrackingShells/Hatch-MCP-Server.git@{tag}" + env_data = self.env_manager._environments[env_name] + env_data["packages"].append({ + "name": f"hatch_mcp_server @ {package_git_url}", + "version": tag or "latest", + "type": "python", + "source": package_git_url, + "installed_at": datetime.now().isoformat() + }) + + self.env_manager._install_hatch_mcp_server = mock_install + + try: + # Create environment without Python environment + success = self.env_manager.create_environment("test_mcp_custom_tag", + description="Test MCP custom tag", + create_python_env=False, # Don't create actual Python env + no_hatch_mcp_server=False, + hatch_mcp_server_tag="v0.1.0") + + # Manually set python_env info to simulate having Python support + self.env_manager._environments["test_mcp_custom_tag"]["python_env"] = { + "enabled": True, + "conda_env_name": "hatch-test_mcp_custom_tag", + "python_executable": "/fake/python", + "created_at": datetime.now().isoformat(), + "version": "3.11.0", + "manager": "conda" + } + + # Now call the MCP installation manually (since we bypassed Python env creation) + self.env_manager._install_hatch_mcp_server("test_mcp_custom_tag", "v0.1.0") + + self.assertTrue(success, "Environment creation should succeed") + self.assertEqual(installed_tag, "v0.1.0", "Custom tag should be passed to installation") + + # Verify MCP server package is in environment with correct version + env_data = self.env_manager._environments["test_mcp_custom_tag"] + packages = env_data.get("packages", []) + expected_name = "hatch_mcp_server @ git+https://github.com/CrackingShells/Hatch-MCP-Server.git@v0.1.0" + mcp_packages = [pkg for pkg in packages if pkg["name"] == expected_name] + self.assertEqual(len(mcp_packages), 1, "Exactly one MCP server package should be installed with correct name syntax") + self.assertEqual(mcp_packages[0]["version"], "v0.1.0", "MCP server should have correct version") + + finally: + # Restore original method + self.env_manager._install_hatch_mcp_server = original_install + + def test_create_environment_no_python_no_mcp_server(self): + """Test creating environment without Python support should not install MCP server.""" + # Mock the MCP server installation to track calls + original_install = self.env_manager._install_hatch_mcp_server + install_called = False + + def mock_install(env_name, tag=None): + nonlocal install_called + install_called = True + + self.env_manager._install_hatch_mcp_server = mock_install + + try: + # Create environment without Python support + success = self.env_manager.create_environment("test_no_python", + description="Test no Python", + create_python_env=False, + no_hatch_mcp_server=False) + + self.assertTrue(success, "Environment creation should succeed") + self.assertFalse(install_called, "MCP server installation should not be called without Python environment") + + finally: + # Restore original method + self.env_manager._install_hatch_mcp_server = original_install + + def test_install_mcp_server_existing_environment(self): + """Test installing MCP server in an existing environment.""" + # Create environment first without Python environment + success = self.env_manager.create_environment("test_existing_mcp", + description="Test existing MCP", + create_python_env=False, # Don't create actual Python env + no_hatch_mcp_server=True) # Opt out initially + self.assertTrue(success, "Environment creation should succeed") + + # Manually set python_env info to simulate having Python support + self.env_manager._environments["test_existing_mcp"]["python_env"] = { + "enabled": True, + "conda_env_name": "hatch-test_existing_mcp", + "python_executable": "/fake/python", + "created_at": datetime.now().isoformat(), + "version": "3.11.0", + "manager": "conda" + } + + # Mock the MCP server installation + original_install = self.env_manager._install_hatch_mcp_server + installed_env = None + installed_tag = None + + def mock_install(env_name, tag=None): + nonlocal installed_env, installed_tag + installed_env = env_name + installed_tag = tag + # Simulate successful installation + package_git_url = f"git+https://github.com/CrackingShells/Hatch-MCP-Server.git@{tag if tag else 'main'}" + env_data = self.env_manager._environments[env_name] + env_data["packages"].append({ + "name": f"hatch_mcp_server @ {package_git_url}", + "version": tag or "latest", + "type": "python", + "source": package_git_url, + "installed_at": datetime.now().isoformat() + }) + + self.env_manager._install_hatch_mcp_server = mock_install + + try: + # Install MCP server with custom tag + success = self.env_manager.install_mcp_server("test_existing_mcp", "v0.2.0") + + self.assertTrue(success, "MCP server installation should succeed") + self.assertEqual(installed_env, "test_existing_mcp", "MCP server should be installed in correct environment") + self.assertEqual(installed_tag, "v0.2.0", "Custom tag should be passed to installation") + + # Verify MCP server package is in environment + env_data = self.env_manager._environments["test_existing_mcp"] + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + expected_name = f"hatch_mcp_server @ git+https://github.com/CrackingShells/Hatch-MCP-Server.git@v0.2.0" + self.assertIn(expected_name, package_names, "MCP server should be installed in environment with correct name syntax") + + finally: + # Restore original method + self.env_manager._install_hatch_mcp_server = original_install if __name__ == "__main__": unittest.main() From cf13f78d11c27873f12ffe48f21a784f7980f98c Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 7 Jul 2025 18:42:35 +0900 Subject: [PATCH 36/48] [Remove] stdout & stderr of python installer **Major**: - We were reading the python stdout and std err after it became available by capture which was completely irrelevant for our purpose and was rather clutering the output - Removed them from `_run_pip_subprocess` - Tests slightly adapted for the mock result's return value - Except this everything is passing and printing in the console works as intended --- hatch/installers/python_installer.py | 56 ++++++---------------------- tests/test_python_installer.py | 8 ++-- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/hatch/installers/python_installer.py b/hatch/installers/python_installer.py index 23793e2..294f20d 100644 --- a/hatch/installers/python_installer.py +++ b/hatch/installers/python_installer.py @@ -86,15 +86,15 @@ def validate_dependency(self, dependency: Dict[str, Any]) -> bool: return True - def _run_pip_subprocess(self, cmd: List[str], env_vars: Dict[str, str] = None) -> tuple[int, str, str]: - """Run a pip subprocess and capture stdout and stderr. + def _run_pip_subprocess(self, cmd: List[str], env_vars: Dict[str, str] = None) -> int: + """Run a pip subprocess and return the exit code. Args: cmd (List[str]): The pip command to execute as a list. env_vars (Dict[str, str], optional): Additional environment variables to set for the subprocess. Returns: - Tuple[int, str, str]: (returncode, stdout, stderr) + int: The return code of the pip subprocess. Raises: subprocess.TimeoutExpired: If the process times out. @@ -108,33 +108,16 @@ def _run_pip_subprocess(self, cmd: List[str], env_vars: Dict[str, str] = None) - self.logger.debug(f"Running pip command: {' '.join(cmd)} with env: {json.dumps(env, indent=2)}") try: - process = subprocess.Popen( + result = subprocess.run( cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, env=env, - universal_newlines=True, - bufsize=1 # Line-buffered output + check=False, # Don't raise on non-zero exit codes + timeout=300 # 5 minute timeout ) - _stdout, _stderr = "", "" - for line in process.stdout: - if line: - self.logger.info(f"pip stdout: {line.strip()}") - _stdout += line - - for line in process.stderr: - if line: - self.logger.info(f"pip stderr: {line.strip()}") - _stderr += line - - process.wait() # Ensure cleanup - return process.returncode, _stdout, _stderr + return result.returncode except subprocess.TimeoutExpired: - process.kill() - process.wait() # Ensure cleanup raise InstallationError("Pip subprocess timed out", error_code="TIMEOUT", cause=None) except Exception as e: @@ -211,8 +194,8 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, if progress_callback: progress_callback("install", 0.3, f"Installing {package_spec}") - returncode, stdout, stderr = self._run_pip_subprocess(cmd, env_vars=python_env_vars) - self.logger.debug(f"pip command: {' '.join(cmd)}\nreturncode: {returncode}\nstdout: {stdout}\nstderr: {stderr}") + returncode = self._run_pip_subprocess(cmd, env_vars=python_env_vars) + self.logger.debug(f"pip command: {' '.join(cmd)}\nreturncode: {returncode}") if returncode == 0: @@ -222,16 +205,14 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, return InstallationResult( dependency_name=name, status=InstallationStatus.COMPLETED, - error_message=stderr, metadata={ - "pip_output": stdout, "command": cmd, "version_constraint": version_constraint } ) else: - error_msg = f"Failed to install {name}: {stderr}" + error_msg = f"Failed to install {name} (exit code: {returncode})" self.logger.error(error_msg) raise InstallationError( error_msg, @@ -289,7 +270,7 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, if progress_callback: progress_callback("uninstall", 0.5, f"Removing {name}") - returncode, stdout, stderr = self._run_pip_subprocess(cmd, env_vars=python_env_vars) + returncode = self._run_pip_subprocess(cmd, env_vars=python_env_vars) if returncode == 0: @@ -300,25 +281,12 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, return InstallationResult( dependency_name=name, status=InstallationStatus.COMPLETED, - error_message=stderr, metadata={ - "pip_output": stdout, "command": cmd } ) else: - - # pip uninstall can fail if package is not installed, which might be OK - if "not installed" in stderr.lower(): - self.logger.warning(f"Package {name} was not installed") - return InstallationResult( - dependency_name=name, - status=InstallationStatus.COMPLETED, - error_message=stderr, - metadata={"warning": "Package was not installed"} - ) - - error_msg = f"Failed to uninstall {name}: {stderr}" + error_msg = f"Failed to uninstall {name} (exit code: {returncode})" self.logger.error(error_msg) raise InstallationError( diff --git a/tests/test_python_installer.py b/tests/test_python_installer.py index fe76e8d..256b395 100644 --- a/tests/test_python_installer.py +++ b/tests/test_python_installer.py @@ -83,7 +83,7 @@ def test_install_simulation_mode(self): result = self.installer.install(dep, context) self.assertEqual(result.status, InstallationStatus.COMPLETED) - @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(0, "", "")) + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=0) def test_install_success(self, mock_run): """Test install returns COMPLETED on successful pip install.""" dep = {"name": "requests", "version_constraint": ">=2.0.0"} @@ -91,7 +91,7 @@ def test_install_success(self, mock_run): result = self.installer.install(dep, context) self.assertEqual(result.status, InstallationStatus.COMPLETED) - @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(1, "", "error")) + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=1) def test_install_failure(self, mock_run): """Test install raises InstallationError on pip failure.""" dep = {"name": "requests", "version_constraint": ">=2.0.0"} # The content don't matter here given the mock @@ -99,7 +99,7 @@ def test_install_failure(self, mock_run): with self.assertRaises(InstallationError): self.installer.install(dep, context) - @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(0, "", "")) + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=0) def test_uninstall_success(self, mock_run): """Test uninstall returns COMPLETED on successful pip uninstall.""" dep = {"name": "requests", "version_constraint": ">=2.0.0"} @@ -107,7 +107,7 @@ def test_uninstall_success(self, mock_run): result = self.installer.uninstall(dep, context) self.assertEqual(result.status, InstallationStatus.COMPLETED) - @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=(1, "", "error")) + @mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=1) def test_uninstall_failure(self, mock_run): """Test uninstall raises InstallationError on pip uninstall failure.""" dep = {"name": "requests", "version_constraint": ">=2.0.0"} From b99058a84e2e0ab40e82e8b37b14e92fd044a52a Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 7 Jul 2025 19:28:42 +0900 Subject: [PATCH 37/48] [Add] Controlled installation of hatch_mcp_server **Major**: - Similarly to how we can control the initialization of a python environment for a single hatch environment, we added the possibility to only add `hatch_mcp_server` - Actually, this will also allow to upgrade it when new version arrives, or pin it to another version. **Minor**: - Uncommented the tests for the environment manipulations --- hatch/cli_hatch.py | 203 ++++++---- hatch/environment_manager.py | 35 +- tests/test_env_manip.py | 759 ++++++++++++++++++++--------------- 3 files changed, 571 insertions(+), 426 deletions(-) diff --git a/hatch/cli_hatch.py b/hatch/cli_hatch.py index ebda517..7ec6db0 100644 --- a/hatch/cli_hatch.py +++ b/hatch/cli_hatch.py @@ -60,9 +60,9 @@ def main(): env_create_parser.add_argument("--no-python", action="store_true", help="Don't create a Python environment using conda/mamba") env_create_parser.add_argument("--no-hatch-mcp-server", action="store_true", - help="Don't install hatch_mcp_server in the new environment") + help="Don't install hatch_mcp_server wrapper in the new environment") env_create_parser.add_argument("--hatch_mcp_server_tag", - help="Git tag/branch reference for hatch_mcp_server installation (e.g., 'dev', 'v0.1.0')") + help="Git tag/branch reference for hatch_mcp_server wrapper installation (e.g., 'dev', 'v0.1.0')") # Remove environment command env_remove_parser = env_subparsers.add_parser("remove", help="Remove an environment") @@ -77,7 +77,7 @@ def main(): # Show current environment command env_subparsers.add_parser("current", help="Show the current environment") - + # Python environment management commands - advanced subcommands env_python_subparsers = env_subparsers.add_parser("python", help="Manage Python environments").add_subparsers( dest="python_command", help="Python environment command to execute" @@ -88,12 +88,22 @@ def main(): python_init_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") python_init_parser.add_argument("--python-version", help="Python version (e.g., 3.11, 3.12)") python_init_parser.add_argument("--force", action="store_true", help="Force recreation if exists") + python_init_parser.add_argument("--no-hatch-mcp-server", action="store_true", + help="Don't install hatch_mcp_server wrapper in the Python environment") + python_init_parser.add_argument("--hatch_mcp_server_tag", + help="Git tag/branch reference for hatch_mcp_server wrapper installation (e.g., 'dev', 'v0.1.0')") # Show Python environment info python_info_parser = env_python_subparsers.add_parser("info", help="Show Python environment information") python_info_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") python_info_parser.add_argument("--detailed", action="store_true", help="Show detailed diagnostics") + # Hatch MCP server wrapper management commands + hatch_mcp_parser = env_python_subparsers.add_parser("add-hatch-mcp", help="Add hatch_mcp_server wrapper to the environment") + ## Install MCP server command + hatch_mcp_parser.add_argument("--hatch_env", default=None, help="Hatch environment name. It must possess a valid Python environment. (default: current environment)") + hatch_mcp_parser.add_argument("--tag", default=None, help="Git tag/branch reference for wrapper installation (e.g., 'dev', 'v0.1.0')") + # Remove Python environment python_remove_parser = env_python_subparsers.add_parser("remove", help="Remove Python environment") python_remove_parser.add_argument("--hatch_env", default=None, help="Hatch environment name in which the Python environment is located (default: current environment)") @@ -124,7 +134,7 @@ def main(): # List packages command pkg_list_parser = pkg_subparsers.add_parser("list", help="List packages in an environment") - pkg_list_parser.add_argument("--env", "-e", help="Environment name (default: current environment)") # Parse arguments + pkg_list_parser.add_argument("--env", "-e", help="Environment name (default: current environment)") # General arguments for the environment manager parser.add_argument("--envs-dir", default=Path.home() / ".hatch" / "envs", help="Directory to store environments") @@ -258,104 +268,121 @@ def main(): elif args.env_command == "python": # Advanced Python environment management - if hasattr(args, 'python_command'): - if args.python_command == "init": - python_version = getattr(args, 'python_version', None) - force = getattr(args, 'force', False) - - if env_manager.create_python_environment_only(args.hatch_env, python_version, force): - print(f"Python environment initialized for: {args.hatch_env}") - - # Show Python environment info - python_info = env_manager.get_python_environment_info(args.hatch_env) - if python_info: - print(f" Python executable: {python_info['python_executable']}") - print(f" Python version: {python_info.get('python_version', 'Unknown')}") - print(f" Conda environment: {python_info.get('conda_env_name', 'N/A')}") - - return 0 - else: - env_name = args.hatch_env or env_manager.get_current_environment() - print(f"Failed to initialize Python environment for: {env_name}") - return 1 - - elif args.python_command == "info": - detailed = getattr(args, 'detailed', False) - - python_info = env_manager.get_python_environment_info(args.hatch_env) + if args.python_command == "init": + python_version = getattr(args, 'python_version', None) + force = getattr(args, 'force', False) + no_hatch_mcp_server = getattr(args, 'no_hatch_mcp_server', False) + hatch_mcp_server_tag = getattr(args, 'hatch_mcp_server_tag', None) + + if env_manager.create_python_environment_only( + args.hatch_env, + python_version, + force, + no_hatch_mcp_server=no_hatch_mcp_server, + hatch_mcp_server_tag=hatch_mcp_server_tag + ): + print(f"Python environment initialized for: {args.hatch_env}") + # Show Python environment info + python_info = env_manager.get_python_environment_info(args.hatch_env) if python_info: - env_name = args.hatch_env or env_manager.get_current_environment() - print(f"Python environment info for '{env_name}':") - print(f" Status: {'Active' if python_info.get('enabled', False) else 'Inactive'}") print(f" Python executable: {python_info['python_executable']}") print(f" Python version: {python_info.get('python_version', 'Unknown')}") print(f" Conda environment: {python_info.get('conda_env_name', 'N/A')}") - print(f" Environment path: {python_info['environment_path']}") - print(f" Created: {python_info.get('created_at', 'Unknown')}") - print(f" Package count: {python_info.get('package_count', 0)}") - print(f" Packages:") - for pkg in python_info.get('packages', []): - print(f" - {pkg['name']} ({pkg['version']})") - - if detailed: - print(f"\nDiagnostics:") - diagnostics = env_manager.get_python_environment_diagnostics(args.hatch_env) - if diagnostics: - for key, value in diagnostics.items(): - print(f" {key}: {value}") - else: - print(" No diagnostics available") - - return 0 - else: - env_name = args.hatch_env or env_manager.get_current_environment() - print(f"No Python environment found for: {env_name}") - - # Show diagnostics for missing environment - if detailed: - print("\nDiagnostics:") - general_diagnostics = env_manager.get_python_manager_diagnostics() - for key, value in general_diagnostics.items(): + + return 0 + else: + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Failed to initialize Python environment for: {env_name}") + return 1 + + elif args.python_command == "info": + detailed = getattr(args, 'detailed', False) + + python_info = env_manager.get_python_environment_info(args.hatch_env) + + if python_info: + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Python environment info for '{env_name}':") + print(f" Status: {'Active' if python_info.get('enabled', False) else 'Inactive'}") + print(f" Python executable: {python_info['python_executable']}") + print(f" Python version: {python_info.get('python_version', 'Unknown')}") + print(f" Conda environment: {python_info.get('conda_env_name', 'N/A')}") + print(f" Environment path: {python_info['environment_path']}") + print(f" Created: {python_info.get('created_at', 'Unknown')}") + print(f" Package count: {python_info.get('package_count', 0)}") + print(f" Packages:") + for pkg in python_info.get('packages', []): + print(f" - {pkg['name']} ({pkg['version']})") + + if detailed: + print(f"\nDiagnostics:") + diagnostics = env_manager.get_python_environment_diagnostics(args.hatch_env) + if diagnostics: + for key, value in diagnostics.items(): print(f" {key}: {value}") - - return 1 - - elif args.python_command == "remove": - force = getattr(args, 'force', False) + else: + print(" No diagnostics available") - if not force: - # Ask for confirmation - env_name = args.hatch_env or env_manager.get_current_environment() - response = input(f"Remove Python environment for '{env_name}'? [y/N]: ") - if response.lower() not in ['y', 'yes']: - print("Operation cancelled") - return 0 + return 0 + else: + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"No Python environment found for: {env_name}") - if env_manager.remove_python_environment_only(args.hatch_env): - env_name = args.hatch_env or env_manager.get_current_environment() - print(f"Python environment removed from: {env_name}") - return 0 - else: - env_name = args.hatch_env or env_manager.get_current_environment() - print(f"Failed to remove Python environment from: {env_name}") - return 1 - - elif args.python_command == "shell": - cmd = getattr(args, 'cmd', None) + # Show diagnostics for missing environment + if detailed: + print("\nDiagnostics:") + general_diagnostics = env_manager.get_python_manager_diagnostics() + for key, value in general_diagnostics.items(): + print(f" {key}: {value}") + + return 1 - if env_manager.launch_python_shell(args.hatch_env, cmd): + elif args.python_command == "remove": + force = getattr(args, 'force', False) + + if not force: + # Ask for confirmation + env_name = args.hatch_env or env_manager.get_current_environment() + response = input(f"Remove Python environment for '{env_name}'? [y/N]: ") + if response.lower() not in ['y', 'yes']: + print("Operation cancelled") return 0 - else: - env_name = args.hatch_env or env_manager.get_current_environment() - print(f"Failed to launch Python shell for: {env_name}") - return 1 + + if env_manager.remove_python_environment_only(args.hatch_env): + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Python environment removed from: {env_name}") + return 0 + else: + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Failed to remove Python environment from: {env_name}") + return 1 + + elif args.python_command == "shell": + cmd = getattr(args, 'cmd', None) + + if env_manager.launch_python_shell(args.hatch_env, cmd): + return 0 else: - print("Unknown Python environment command") + env_name = args.hatch_env or env_manager.get_current_environment() + print(f"Failed to launch Python shell for: {env_name}") return 1 + + elif args.python_command == "add-hatch-mcp": + env_name = args.hatch_env or env_manager.get_current_environment() + tag = args.tag + + if env_manager.install_mcp_server(env_name, tag): + print(f"hatch_mcp_server wrapper installed successfully in environment: {env_name}") + return 0 + else: + print(f"Failed to install hatch_mcp_server wrapper in environment: {env_name}") + return 1 + else: - print("No Python subcommand specified") + print("Unknown Python environment command") return 1 + elif args.command == "package": if args.pkg_command == "add": diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index a7b8046..bc014c5 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -336,16 +336,16 @@ def create_environment(self, name: str, description: str = "", try: self._install_hatch_mcp_server(name, hatch_mcp_server_tag) except Exception as e: - self.logger.warning(f"Failed to install hatch_mcp_server in environment {name}: {e}") - # Don't fail environment creation if MCP server installation fails + self.logger.warning(f"Failed to install hatch_mcp_server wrapper in environment {name}: {e}") + # Don't fail environment creation if MCP wrapper installation fails return True def _install_hatch_mcp_server(self, env_name: str, tag: Optional[str] = None) -> None: - """Install hatch_mcp_server package in the specified environment. + """Install hatch_mcp_server wrapper package in the specified environment. Args: - env_name (str): Name of the environment to install MCP server in. + env_name (str): Name of the environment to install MCP wrapper in. tag (str, optional): Git tag/branch reference for the installation. Defaults to None (uses default branch). Raises: @@ -394,22 +394,22 @@ def _install_hatch_mcp_server(self, env_name: str, tag: Optional[str] = None) -> context.set_config("python_env_vars", python_env_vars) # Install using the orchestrator - self.logger.info(f"Installing hatch_mcp_server in environment {env_name}") + self.logger.info(f"Installing hatch_mcp_server wrapper in environment {env_name}") self.logger.info(f"Using python executable: {python_executable}") installed_package = self.dependency_orchestrator.install_single_dep(mcp_dep, context) self._save_environments() - self.logger.info(f"Successfully installed hatch_mcp_server in environment {env_name}") + self.logger.info(f"Successfully installed hatch_mcp_server wrapper in environment {env_name}") except Exception as e: - self.logger.error(f"Failed to install hatch_mcp_server: {e}") - raise HatchEnvironmentError(f"Failed to install hatch_mcp_server: {e}") from e + self.logger.error(f"Failed to install hatch_mcp_server wrapper: {e}") + raise HatchEnvironmentError(f"Failed to install hatch_mcp_server wrapper: {e}") from e def install_mcp_server(self, env_name: Optional[str] = None, tag: Optional[str] = None) -> bool: - """Install hatch_mcp_server package in an existing environment. + """Install hatch_mcp_server wrapper package in an existing environment. Args: - env_name (str, optional): Name of the environment. Uses current environment if None. + env_name (str, optional): Name of the hatch environment. Uses current environment if None. tag (str, optional): Git tag/branch reference for the installation. Defaults to None (uses default branch). Returns: @@ -432,7 +432,7 @@ def install_mcp_server(self, env_name: Optional[str] = None, tag: Optional[str] self._install_hatch_mcp_server(env_name, tag) return True except Exception as e: - self.logger.error(f"Failed to install MCP server in environment {env_name}: {e}") + self.logger.error(f"Failed to install MCP wrapper in environment {env_name}: {e}") return False def remove_environment(self, name: str) -> bool: @@ -828,7 +828,8 @@ def list_python_environments(self) -> List[str]: return self.python_env_manager.list_environments() def create_python_environment_only(self, env_name: Optional[str] = None, python_version: Optional[str] = None, - force: bool = False) -> bool: + force: bool = False, no_hatch_mcp_server: bool = False, + hatch_mcp_server_tag: Optional[str] = None) -> bool: """Create only a Python environment without creating a Hatch environment. Useful for adding Python environments to existing Hatch environments. @@ -837,6 +838,8 @@ def create_python_environment_only(self, env_name: Optional[str] = None, python_ env_name (str, optional): Environment name. Defaults to current environment. python_version (str, optional): Python version (e.g., "3.11"). Defaults to None. force (bool, optional): Whether to recreate if exists. Defaults to False. + no_hatch_mcp_server (bool, optional): Whether to skip installing hatch_mcp_server wrapper in the environment. Defaults to False. + hatch_mcp_server_tag (str, optional): Git tag/branch reference for hatch_mcp_server wrapper installation. Defaults to None. Returns: bool: True if successful, False otherwise. @@ -893,6 +896,14 @@ def create_python_environment_only(self, env_name: Optional[str] = None, python_ # Reconfigure Python executable if this is the current environment if env_name == self._current_env_name: self._configure_python_executable(env_name) + + # Install hatch_mcp_server by default unless opted out + if not no_hatch_mcp_server: + try: + self._install_hatch_mcp_server(env_name, hatch_mcp_server_tag) + except Exception as e: + self.logger.warning(f"Failed to install hatch_mcp_server wrapper in environment {env_name}: {e}") + # Don't fail environment creation if MCP wrapper installation fails return success except PythonEnvironmentError as e: diff --git a/tests/test_env_manip.py b/tests/test_env_manip.py index ad5cd4d..fd0aeea 100644 --- a/tests/test_env_manip.py +++ b/tests/test_env_manip.py @@ -135,351 +135,351 @@ def tearDown(self): # Remove temporary directory shutil.rmtree(self.temp_dir) - # def test_create_environment(self): - # """Test creating an environment.""" - # result = self.env_manager.create_environment("test_env", "Test environment") - # self.assertTrue(result, "Failed to create environment") - - # # Verify environment exists - # self.assertTrue(self.env_manager.environment_exists("test_env"), "Environment doesn't exist after creation") - - # # Verify environment data - # env_data = self.env_manager.get_environments().get("test_env") - # self.assertIsNotNone(env_data, "Environment data not found") - # self.assertEqual(env_data["name"], "test_env") - # self.assertEqual(env_data["description"], "Test environment") - # self.assertIn("created_at", env_data) - # self.assertIn("packages", env_data) - # self.assertEqual(len(env_data["packages"]), 0) + def test_create_environment(self): + """Test creating an environment.""" + result = self.env_manager.create_environment("test_env", "Test environment") + self.assertTrue(result, "Failed to create environment") + + # Verify environment exists + self.assertTrue(self.env_manager.environment_exists("test_env"), "Environment doesn't exist after creation") + + # Verify environment data + env_data = self.env_manager.get_environments().get("test_env") + self.assertIsNotNone(env_data, "Environment data not found") + self.assertEqual(env_data["name"], "test_env") + self.assertEqual(env_data["description"], "Test environment") + self.assertIn("created_at", env_data) + self.assertIn("packages", env_data) + self.assertEqual(len(env_data["packages"]), 0) - # def test_remove_environment(self): - # """Test removing an environment.""" - # # First create an environment - # self.env_manager.create_environment("test_env", "Test environment") - # self.assertTrue(self.env_manager.environment_exists("test_env")) - - # # Then remove it - # result = self.env_manager.remove_environment("test_env") - # self.assertTrue(result, "Failed to remove environment") - - # # Verify environment no longer exists - # self.assertFalse(self.env_manager.environment_exists("test_env"), "Environment still exists after removal") + def test_remove_environment(self): + """Test removing an environment.""" + # First create an environment + self.env_manager.create_environment("test_env", "Test environment") + self.assertTrue(self.env_manager.environment_exists("test_env")) + + # Then remove it + result = self.env_manager.remove_environment("test_env") + self.assertTrue(result, "Failed to remove environment") + + # Verify environment no longer exists + self.assertFalse(self.env_manager.environment_exists("test_env"), "Environment still exists after removal") - # def test_set_current_environment(self): - # """Test setting the current environment.""" - # # First create an environment - # self.env_manager.create_environment("test_env", "Test environment") - - # # Set it as current - # result = self.env_manager.set_current_environment("test_env") - # self.assertTrue(result, "Failed to set current environment") - - # # Verify it's the current environment - # current_env = self.env_manager.get_current_environment() - # self.assertEqual(current_env, "test_env", "Current environment not set correctly") + def test_set_current_environment(self): + """Test setting the current environment.""" + # First create an environment + self.env_manager.create_environment("test_env", "Test environment") + + # Set it as current + result = self.env_manager.set_current_environment("test_env") + self.assertTrue(result, "Failed to set current environment") + + # Verify it's the current environment + current_env = self.env_manager.get_current_environment() + self.assertEqual(current_env, "test_env", "Current environment not set correctly") - # def test_add_local_package(self): - # """Test adding a local package to an environment.""" - # # Create an environment - # self.env_manager.create_environment("test_env", "Test environment") - # self.env_manager.set_current_environment("test_env") - - # # Use arithmetic_pkg from Hatching-Dev - # pkg_path = self.hatch_dev_path / "arithmetic_pkg" - # self.assertTrue(pkg_path.exists(), f"Test package not found: {pkg_path}") - - # # Add package to environment - # result = self.env_manager.add_package_to_environment( - # str(pkg_path), # Convert to string to handle Path objects - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add local package to environment") - - # # Verify package was added to environment data - # env_data = self.env_manager.get_environments().get("test_env") - # self.assertIsNotNone(env_data, "Environment data not found") - - # packages = env_data.get("packages", []) - # self.assertEqual(len(packages), 1, "Package not added to environment data") - - # pkg_data = packages[0] - # self.assertIn("name", pkg_data, "Package data missing name") - # self.assertIn("version", pkg_data, "Package data missing version") - # self.assertIn("type", pkg_data, "Package data missing type") - # self.assertIn("source", pkg_data, "Package data missing source") + def test_add_local_package(self): + """Test adding a local package to an environment.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.set_current_environment("test_env") + + # Use arithmetic_pkg from Hatching-Dev + pkg_path = self.hatch_dev_path / "arithmetic_pkg" + self.assertTrue(pkg_path.exists(), f"Test package not found: {pkg_path}") + + # Add package to environment + result = self.env_manager.add_package_to_environment( + str(pkg_path), # Convert to string to handle Path objects + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add local package to environment") + + # Verify package was added to environment data + env_data = self.env_manager.get_environments().get("test_env") + self.assertIsNotNone(env_data, "Environment data not found") + + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 1, "Package not added to environment data") + + pkg_data = packages[0] + self.assertIn("name", pkg_data, "Package data missing name") + self.assertIn("version", pkg_data, "Package data missing version") + self.assertIn("type", pkg_data, "Package data missing type") + self.assertIn("source", pkg_data, "Package data missing source") - # def test_add_package_with_dependencies(self): - # """Test adding a package with dependencies to an environment.""" - # # Create an environment - # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - # self.env_manager.set_current_environment("test_env") + def test_add_package_with_dependencies(self): + """Test adding a package with dependencies to an environment.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + self.env_manager.set_current_environment("test_env") - # # First add the base package that is a dependency - # base_pkg_path = self.hatch_dev_path / "base_pkg_1" - # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(base_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - # self.assertTrue(result, "Failed to add base package to environment") - - # # Then add the package with dependencies - # pkg_path = self.hatch_dev_path / "simple_dep_pkg" - # self.assertTrue(pkg_path.exists(), f"Dependent package not found: {pkg_path}") - - # # Add package to environment - # result = self.env_manager.add_package_to_environment( - # str(pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add package with dependencies") - - # # Verify both packages are in the environment - # env_data = self.env_manager.get_environments().get("test_env") - # self.assertIsNotNone(env_data, "Environment data not found") - - # packages = env_data.get("packages", []) - # self.assertEqual(len(packages), 2, "Not all packages were added to environment") - - # # Check that both packages are in the environment data - # package_names = [pkg["name"] for pkg in packages] - # self.assertIn("base_pkg_1", package_names, "Base package missing from environment") - # self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") + # First add the base package that is a dependency + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Then add the package with dependencies + pkg_path = self.hatch_dev_path / "simple_dep_pkg" + self.assertTrue(pkg_path.exists(), f"Dependent package not found: {pkg_path}") + + # Add package to environment + result = self.env_manager.add_package_to_environment( + str(pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with dependencies") + + # Verify both packages are in the environment + env_data = self.env_manager.get_environments().get("test_env") + self.assertIsNotNone(env_data, "Environment data not found") + + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 2, "Not all packages were added to environment") + + # Check that both packages are in the environment data + package_names = [pkg["name"] for pkg in packages] + self.assertIn("base_pkg_1", package_names, "Base package missing from environment") + self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") - # def test_add_package_with_some_dependencies_already_present(self): - # """Test adding a package where some dependencies are already present and others are not.""" - # # Create an environment - # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - # self.env_manager.set_current_environment("test_env") - # # First add only one of the dependencies that complex_dep_pkg needs - # base_pkg_path = self.hatch_dev_path / "base_pkg_1" - # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(base_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - # self.assertTrue(result, "Failed to add base package to environment") - - # # Verify base_pkg_1 is in the environment - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # self.assertEqual(len(packages), 1, "Base package not added correctly") - # self.assertEqual(packages[0]["name"], "base_pkg_1", "Wrong package added") - - # # Now add complex_dep_pkg which depends on base_pkg_1, base_pkg_2 - # # base_pkg_1 should be satisfied, base_pkg_2 should need installation - # complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" - # self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(complex_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add package with mixed dependency states") - - # # Verify all required packages are now in the environment - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) + def test_add_package_with_some_dependencies_already_present(self): + """Test adding a package where some dependencies are already present and others are not.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + self.env_manager.set_current_environment("test_env") + # First add only one of the dependencies that complex_dep_pkg needs + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Verify base_pkg_1 is in the environment + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 1, "Base package not added correctly") + self.assertEqual(packages[0]["name"], "base_pkg_1", "Wrong package added") + + # Now add complex_dep_pkg which depends on base_pkg_1, base_pkg_2 + # base_pkg_1 should be satisfied, base_pkg_2 should need installation + complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" + self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(complex_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with mixed dependency states") + + # Verify all required packages are now in the environment + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) - # # Should have base_pkg_1 (already present), base_pkg_2, and complex_dep_pkg - # expected_packages = ["base_pkg_1", "base_pkg_2", "complex_dep_pkg"] - # package_names = [pkg["name"] for pkg in packages] + # Should have base_pkg_1 (already present), base_pkg_2, and complex_dep_pkg + expected_packages = ["base_pkg_1", "base_pkg_2", "complex_dep_pkg"] + package_names = [pkg["name"] for pkg in packages] - # for pkg_name in expected_packages: - # self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") + for pkg_name in expected_packages: + self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") - # def test_add_package_with_all_dependencies_already_present(self): - # """Test adding a package where all dependencies are already present.""" - # # Create an environment - # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - # self.env_manager.set_current_environment("test_env") - # # First add all dependencies that simple_dep_pkg needs - # base_pkg_path = self.hatch_dev_path / "base_pkg_1" - # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(base_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - # self.assertTrue(result, "Failed to add base package to environment") - - # # Verify base package is installed - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # self.assertEqual(len(packages), 1, "Base package not added correctly") - - # # Now add simple_dep_pkg which only depends on base_pkg_1 (which is already present) - # simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" - # self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(simple_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add package with all dependencies satisfied") - - # # Verify both packages are in the environment - no new dependencies should be added - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - - # # Should have base_pkg_1 (already present) and simple_dep_pkg (newly added) - # expected_packages = ["base_pkg_1", "simple_dep_pkg"] - # package_names = [pkg["name"] for pkg in packages] - - # self.assertEqual(len(packages), 2, "Unexpected number of packages in environment") - # for pkg_name in expected_packages: - # self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") + def test_add_package_with_all_dependencies_already_present(self): + """Test adding a package where all dependencies are already present.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + self.env_manager.set_current_environment("test_env") + # First add all dependencies that simple_dep_pkg needs + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Verify base package is installed + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 1, "Base package not added correctly") + + # Now add simple_dep_pkg which only depends on base_pkg_1 (which is already present) + simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" + self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(simple_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with all dependencies satisfied") + + # Verify both packages are in the environment - no new dependencies should be added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + + # Should have base_pkg_1 (already present) and simple_dep_pkg (newly added) + expected_packages = ["base_pkg_1", "simple_dep_pkg"] + package_names = [pkg["name"] for pkg in packages] + + self.assertEqual(len(packages), 2, "Unexpected number of packages in environment") + for pkg_name in expected_packages: + self.assertIn(pkg_name, package_names, f"Package {pkg_name} missing from environment") - # def test_add_package_with_version_constraint_satisfaction(self): - # """Test adding a package with version constraints where dependencies are satisfied.""" - # # Create an environment - # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - # self.env_manager.set_current_environment("test_env") + def test_add_package_with_version_constraint_satisfaction(self): + """Test adding a package with version constraints where dependencies are satisfied.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + self.env_manager.set_current_environment("test_env") - # # Add base_pkg_1 with a specific version - # base_pkg_path = self.hatch_dev_path / "base_pkg_1" - # self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(base_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - # self.assertTrue(result, "Failed to add base package to environment") - - # # Look for a package that has version constraints to test against - # # For now, we'll simulate this by trying to add another package that depends on base_pkg_1 - # simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" - # self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(simple_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add package with version constraint dependencies") - - # # Verify packages are correctly installed - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # package_names = [pkg["name"] for pkg in packages] - - # self.assertIn("base_pkg_1", package_names, "Base package missing from environment") - # self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") + # Add base_pkg_1 with a specific version + base_pkg_path = self.hatch_dev_path / "base_pkg_1" + self.assertTrue(base_pkg_path.exists(), f"Base package not found: {base_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(base_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + self.assertTrue(result, "Failed to add base package to environment") + + # Look for a package that has version constraints to test against + # For now, we'll simulate this by trying to add another package that depends on base_pkg_1 + simple_pkg_path = self.hatch_dev_path / "simple_dep_pkg" + self.assertTrue(simple_pkg_path.exists(), f"Simple package not found: {simple_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(simple_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with version constraint dependencies") + + # Verify packages are correctly installed + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + + self.assertIn("base_pkg_1", package_names, "Base package missing from environment") + self.assertIn("simple_dep_pkg", package_names, "Dependent package missing from environment") - # def test_add_package_with_mixed_dependency_types(self): - # """Test adding a package with mixed hatch and python dependencies.""" - # # Create an environment - # self.env_manager.create_environment("test_env", "Test environment") - # self.env_manager.set_current_environment("test_env") - - # # Add a package that has both hatch and python dependencies - # python_dep_pkg_path = self.hatch_dev_path / "python_dep_pkg" - # self.assertTrue(python_dep_pkg_path.exists(), f"Python dependency package not found: {python_dep_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(python_dep_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add package with mixed dependency types") - - # # Verify package was added - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # package_names = [pkg["name"] for pkg in packages] - - # self.assertIn("python_dep_pkg", package_names, "Package with mixed dependencies missing from environment") - - # # Now add a package that depends on the python_dep_pkg (should be satisfied) - # # and also depends on other packages (should need installation) - # complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" - # self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") - - # result = self.env_manager.add_package_to_environment( - # str(complex_pkg_path), - # "test_env", - # auto_approve=True # Auto-approve for testing - # ) - - # self.assertTrue(result, "Failed to add package with mixed satisfied/unsatisfied dependencies") - - # # Verify all expected packages are present - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # package_names = [pkg["name"] for pkg in packages] - - # # Should have python_dep_pkg (already present) plus any other dependencies of complex_dep_pkg - # self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") - # self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") + def test_add_package_with_mixed_dependency_types(self): + """Test adding a package with mixed hatch and python dependencies.""" + # Create an environment + self.env_manager.create_environment("test_env", "Test environment") + self.env_manager.set_current_environment("test_env") + + # Add a package that has both hatch and python dependencies + python_dep_pkg_path = self.hatch_dev_path / "python_dep_pkg" + self.assertTrue(python_dep_pkg_path.exists(), f"Python dependency package not found: {python_dep_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(python_dep_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with mixed dependency types") + + # Verify package was added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + + self.assertIn("python_dep_pkg", package_names, "Package with mixed dependencies missing from environment") + + # Now add a package that depends on the python_dep_pkg (should be satisfied) + # and also depends on other packages (should need installation) + complex_pkg_path = self.hatch_dev_path / "complex_dep_pkg" + self.assertTrue(complex_pkg_path.exists(), f"Complex package not found: {complex_pkg_path}") + + result = self.env_manager.add_package_to_environment( + str(complex_pkg_path), + "test_env", + auto_approve=True # Auto-approve for testing + ) + + self.assertTrue(result, "Failed to add package with mixed satisfied/unsatisfied dependencies") + + # Verify all expected packages are present + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + + # Should have python_dep_pkg (already present) plus any other dependencies of complex_dep_pkg + self.assertIn("python_dep_pkg", package_names, "Originally installed package missing") + self.assertIn("complex_dep_pkg", package_names, "New package missing from environment") - # # Python dep package has a dep to request. This should be satisfied in the python environment - # python_env_info = self.env_manager.python_env_manager.get_environment_info("test_env") - # packages = python_env_info.get("packages", []) - # self.assertIsNotNone(packages, "Python environment packages not found") - # self.assertGreater(len(packages), 0, "No packages found in Python environment") - # package_names = [pkg["name"] for pkg in packages] - # self.assertIn("requests", package_names, f"Expected 'requests' package not found in Python environment: {packages}") + # Python dep package has a dep to request. This should be satisfied in the python environment + python_env_info = self.env_manager.python_env_manager.get_environment_info("test_env") + packages = python_env_info.get("packages", []) + self.assertIsNotNone(packages, "Python environment packages not found") + self.assertGreater(len(packages), 0, "No packages found in Python environment") + package_names = [pkg["name"] for pkg in packages] + self.assertIn("requests", package_names, f"Expected 'requests' package not found in Python environment: {packages}") - # @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") - # def test_add_package_with_system_dependency(self): - # """Test adding a package with a system dependency.""" - # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - # self.env_manager.set_current_environment("test_env") - # # Add a package that declares a system dependency (e.g., 'curl') - # system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" - # self.assertTrue(system_dep_pkg_path.exists(), f"System dependency package not found: {system_dep_pkg_path}") + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") + def test_add_package_with_system_dependency(self): + """Test adding a package with a system dependency.""" + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + self.env_manager.set_current_environment("test_env") + # Add a package that declares a system dependency (e.g., 'curl') + system_dep_pkg_path = self.hatch_dev_path / "system_dep_pkg" + self.assertTrue(system_dep_pkg_path.exists(), f"System dependency package not found: {system_dep_pkg_path}") - # result = self.env_manager.add_package_to_environment( - # str(system_dep_pkg_path), - # "test_env", - # auto_approve=True - # ) - # self.assertTrue(result, "Failed to add package with system dependency") + result = self.env_manager.add_package_to_environment( + str(system_dep_pkg_path), + "test_env", + auto_approve=True + ) + self.assertTrue(result, "Failed to add package with system dependency") - # # Verify package was added - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # package_names = [pkg["name"] for pkg in packages] - # self.assertIn("system_dep_pkg", package_names, "System dependency package missing from environment") + # Verify package was added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + self.assertIn("system_dep_pkg", package_names, "System dependency package missing from environment") - # # Skip if Docker is not available - # @unittest.skipUnless(DOCKER_DAEMON_AVAILABLE, "Docker dependency test skipped due to Docker not being available") - # def test_add_package_with_docker_dependency(self): - # """Test adding a package with a docker dependency.""" - # self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) - # self.env_manager.set_current_environment("test_env") - # # Add a package that declares a docker dependency (e.g., 'redis:latest') - # docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" - # self.assertTrue(docker_dep_pkg_path.exists(), f"Docker dependency package not found: {docker_dep_pkg_path}") + # Skip if Docker is not available + @unittest.skipUnless(DOCKER_DAEMON_AVAILABLE, "Docker dependency test skipped due to Docker not being available") + def test_add_package_with_docker_dependency(self): + """Test adding a package with a docker dependency.""" + self.env_manager.create_environment("test_env", "Test environment", create_python_env=False) + self.env_manager.set_current_environment("test_env") + # Add a package that declares a docker dependency (e.g., 'redis:latest') + docker_dep_pkg_path = self.hatch_dev_path / "docker_dep_pkg" + self.assertTrue(docker_dep_pkg_path.exists(), f"Docker dependency package not found: {docker_dep_pkg_path}") - # result = self.env_manager.add_package_to_environment( - # str(docker_dep_pkg_path), - # "test_env", - # auto_approve=True - # ) - # self.assertTrue(result, "Failed to add package with docker dependency") + result = self.env_manager.add_package_to_environment( + str(docker_dep_pkg_path), + "test_env", + auto_approve=True + ) + self.assertTrue(result, "Failed to add package with docker dependency") - # # Verify package was added - # env_data = self.env_manager.get_environments().get("test_env") - # packages = env_data.get("packages", []) - # package_names = [pkg["name"] for pkg in packages] - # self.assertIn("docker_dep_pkg", package_names, "Docker dependency package missing from environment") + # Verify package was added + env_data = self.env_manager.get_environments().get("test_env") + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + self.assertIn("docker_dep_pkg", package_names, "Docker dependency package missing from environment") def test_create_environment_with_mcp_server_default(self): """Test creating environment with default MCP server installation.""" @@ -727,5 +727,112 @@ def mock_install(env_name, tag=None): # Restore original method self.env_manager._install_hatch_mcp_server = original_install + def test_create_python_environment_only_with_mcp_wrapper(self): + """Test creating Python environment only with MCP wrapper support.""" + # First create a Hatch environment without Python + self.env_manager.create_environment("test_python_only", "Test Python Only", create_python_env=False) + self.assertTrue(self.env_manager.environment_exists("test_python_only")) + + # Mock Python environment creation to simulate success + original_create = self.env_manager.python_env_manager.create_python_environment + original_get_info = self.env_manager.python_env_manager.get_environment_info + + def mock_create_python_env(env_name, python_version=None, force=False): + return True + + def mock_get_env_info(env_name): + return { + "conda_env_name": f"hatch-{env_name}", + "python_executable": f"/path/to/conda/envs/hatch-{env_name}/bin/python", + "python_version": "3.11.0", + "manager": "conda" + } + + # Mock MCP wrapper installation + installed_env = None + installed_tag = None + original_install = self.env_manager._install_hatch_mcp_server + + def mock_install(env_name, tag=None): + nonlocal installed_env, installed_tag + installed_env = env_name + installed_tag = tag + # Simulate adding MCP wrapper to environment + package_git_url = f"git+https://github.com/CrackingShells/Hatch-MCP-Server.git" + if tag: + package_git_url += f"@{tag}" + env_data = self.env_manager._environments[env_name] + env_data["packages"].append({ + "name": f"hatch_mcp_server @ {package_git_url}", + "version": tag or "latest", + "type": "python", + "source": package_git_url, + "installed_at": datetime.now().isoformat() + }) + + self.env_manager.python_env_manager.create_python_environment = mock_create_python_env + self.env_manager.python_env_manager.get_environment_info = mock_get_env_info + self.env_manager._install_hatch_mcp_server = mock_install + + try: + # Test creating Python environment with default MCP wrapper installation + success = self.env_manager.create_python_environment_only("test_python_only") + + self.assertTrue(success, "Python environment creation should succeed") + self.assertEqual(installed_env, "test_python_only", "MCP wrapper should be installed in correct environment") + self.assertIsNone(installed_tag, "Default tag should be None") + + # Verify environment metadata was updated + env_data = self.env_manager._environments["test_python_only"] + self.assertTrue(env_data.get("python_environment"), "Python environment flag should be set") + self.assertIsNotNone(env_data.get("python_env"), "Python environment info should be set") + + # Verify MCP wrapper was installed + packages = env_data.get("packages", []) + package_names = [pkg["name"] for pkg in packages] + expected_name = "hatch_mcp_server @ git+https://github.com/CrackingShells/Hatch-MCP-Server.git" + self.assertIn(expected_name, package_names, "MCP wrapper should be installed") + + # Reset for next test + installed_env = None + installed_tag = None + env_data["packages"] = [] + + # Test creating Python environment with custom tag + success = self.env_manager.create_python_environment_only( + "test_python_only", + python_version="3.12", + force=True, + hatch_mcp_server_tag="dev" + ) + + self.assertTrue(success, "Python environment creation with custom tag should succeed") + self.assertEqual(installed_tag, "dev", "Custom tag should be passed to MCP wrapper installation") + + # Reset for next test + installed_env = None + env_data["packages"] = [] + + # Test opting out of MCP wrapper installation + success = self.env_manager.create_python_environment_only( + "test_python_only", + force=True, + no_hatch_mcp_server=True + ) + + self.assertTrue(success, "Python environment creation without MCP wrapper should succeed") + self.assertIsNone(installed_env, "MCP wrapper should not be installed when opted out") + + # Verify no MCP wrapper was installed + packages = env_data.get("packages", []) + self.assertEqual(len(packages), 0, "No packages should be installed when MCP wrapper is opted out") + + finally: + # Restore original methods + self.env_manager.python_env_manager.create_python_environment = original_create + self.env_manager.python_env_manager.get_environment_info = original_get_info + self.env_manager._install_hatch_mcp_server = original_install + + if __name__ == "__main__": unittest.main() From 9c8689b9a6057f707b0371741011ca7ea14e1864 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 9 Jul 2025 01:08:50 +0900 Subject: [PATCH 38/48] [Update] Prepend `sudo apt install` with `update` **Major**: - Removed the argument `sudo: bool=True` in `_build_apt_command` because this builds an `apt install` command which always requires to be sudo - Updated the command to include `sudo apt-get update` at the beginning before starting to install any package to make sure the registry copy is up to date. **Minor**: - Default log level in `system_installer.py` moved from `DEBUG` to `INFO` - Removed useles commented code --- hatch/installers/system_installer.py | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/hatch/installers/system_installer.py b/hatch/installers/system_installer.py index 30e77c3..30c1295 100644 --- a/hatch/installers/system_installer.py +++ b/hatch/installers/system_installer.py @@ -30,7 +30,7 @@ class SystemInstaller(DependencyInstaller): def __init__(self): """Initialize the SystemInstaller.""" self.logger = logging.getLogger("hatch.installers.system_installer") - self.logger.setLevel(logging.DEBUG) + self.logger.setLevel(logging.INFO) @property def installer_type(self) -> str: @@ -319,13 +319,12 @@ def _validate_version_constraint(self, version_constraint: str) -> bool: self.logger.error(f"Invalid version constraint format: {version_constraint}") return False - def _build_apt_command(self, dependency: Dict[str, Any], context: InstallationContext, sudo: bool = True) -> List[str]: + def _build_apt_command(self, dependency: Dict[str, Any], context: InstallationContext) -> List[str]: """Build the apt install command for the dependency. Args: dependency (Dict[str, Any]): Dependency object. context (InstallationContext): Installation context. - sudo (bool): Whether to use sudo for the command. Returns: List[str]: Apt command as list of arguments. @@ -334,10 +333,7 @@ def _build_apt_command(self, dependency: Dict[str, Any], context: InstallationCo version_constraint = dependency["version_constraint"] # Start with base command - if sudo: - command = ["sudo", "apt", "install"] - else: - command = ["apt", "install"] + command = ["sudo", "apt-get", "update", "sudo", "apt", "install"] # Add automation flag if configured if context.get_config("automated", False): @@ -380,26 +376,9 @@ def _run_apt_subprocess(self, cmd: List[str]) -> tuple[int, str, str]: process = subprocess.Popen( cmd, - #stdout=subprocess.PIPE, - #stderr=subprocess.PIPE, text=True, - #env=env, universal_newlines=True - #bufsize=1 # Line-buffered output ) - # _stdout, _stderr = "", "" - - # for line in process.stdout: - # if line: - # self.logger.info(f"apt stdout: {line.strip()}") - # _stdout += line - - # for line in process.stderr: - # if line: - # self.logger.info(f"apt stderr: {line.strip()}") - # _stderr += line - - # process.wait() # Ensure cleanup process.communicate() # Set a timeout for the command process.wait() # Ensure cleanup From 4f82132d2b8721b21a4248eec36f437ddce40f84 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 9 Jul 2025 01:54:34 +0900 Subject: [PATCH 39/48] [Remove] `stdout` & `stderr` in system installer **Major**: - Reprinting manually the commands' output was clutering the terminal's information - The benefits, on the other side were very low (just some output recording without doing anything with it. - The only function for which capturing the `stdout` was useful was `_verify_installation` to capture the version numbers. - We reimplemented it locally to work the same way but not disturb the general usage of `_run_apt_subprocess` - Updated the tests - All are passing --- hatch/installers/system_installer.py | 129 ++++++++++++------------ tests/test_system_installer.py | 141 ++++++++++++++++++++++----- 2 files changed, 177 insertions(+), 93 deletions(-) diff --git a/hatch/installers/system_installer.py b/hatch/installers/system_installer.py index 30c1295..c95ac78 100644 --- a/hatch/installers/system_installer.py +++ b/hatch/installers/system_installer.py @@ -136,18 +136,29 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, if context.simulation_mode: return self._simulate_installation(dependency, context, progress_callback) - # Build and execute apt command + # Run apt-get update first + update_cmd = ["sudo", "apt-get", "update"] + update_returncode = self._run_apt_subprocess(update_cmd) + if update_returncode != 0: + raise InstallationError( + f"apt-get update failed (see logs for details).", + dependency_name=package_name, + error_code="APT_UPDATE_FAILED", + cause=None + ) + + # Build and execute apt install command cmd = self._build_apt_command(dependency, context) if progress_callback: progress_callback(f"Installing {package_name}", 25.0, "Executing apt command") - returncode, stdout, stderr = self._run_apt_subprocess(cmd) - self.logger.debug(f"apt command: {cmd}\nreturn code: {returncode}\nstdout: {stdout}\nstderr: {stderr}") + returncode = self._run_apt_subprocess(cmd) + self.logger.debug(f"apt command: {cmd}\nreturn code: {returncode}") if returncode != 0: raise InstallationError( - f"Installation failed with error: {stderr.strip()}", + f"Installation failed for {package_name} (see logs for details).", dependency_name=package_name, error_code="APT_INSTALL_FAILED", cause=None @@ -171,7 +182,6 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, "command_executed": " ".join(cmd), "platform": platform.platform(), "automated": context.get_config("automated", False), - "output": stdout.strip(), } ) @@ -216,7 +226,7 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, return self._simulate_uninstall(dependency, context, progress_callback) # Build apt remove command - cmd = ["apt", "remove", package_name] + cmd = ["sudo", "apt", "remove", package_name] # Add automation flag if configured if context.get_config("automated", False): @@ -226,11 +236,11 @@ def uninstall(self, dependency: Dict[str, Any], context: InstallationContext, progress_callback(f"Uninstalling {package_name}", 50.0, "Executing apt remove") # Execute command - returncode, stdout, stderr = self._run_apt_subprocess(cmd) + returncode = self._run_apt_subprocess(cmd) if returncode != 0: raise InstallationError( - f"Uninstallation failed with error: {stderr.strip()}", + f"Uninstallation failed for {package_name} (see logs for details).", dependency_name=package_name, error_code="APT_UNINSTALL_FAILED", cause=None @@ -333,7 +343,7 @@ def _build_apt_command(self, dependency: Dict[str, Any], context: InstallationCo version_constraint = dependency["version_constraint"] # Start with base command - command = ["sudo", "apt-get", "update", "sudo", "apt", "install"] + command = ["sudo", "apt", "install"] # Add automation flag if configured if context.get_config("automated", False): @@ -354,59 +364,31 @@ def _build_apt_command(self, dependency: Dict[str, Any], context: InstallationCo command.append(package_spec) return command - def _run_apt_subprocess(self, cmd: List[str]) -> tuple[int, str, str]: - """Run an apt subprocess and capture stdout and stderr. + def _run_apt_subprocess(self, cmd: List[str]) -> int: + """Run an apt subprocess and return the return code. Args: cmd (List[str]): The apt command to execute as a list. Returns: - Tuple[int, str, str]: (returncode, stdout, stderr) + int: The return code of the process. Raises: subprocess.TimeoutExpired: If the process times out. - Exception: For unexpected errors. + InstallationError: For unexpected errors. """ env = os.environ.copy() try: - if cmd[0] == "sudo": - # Ensure sudo is available in the environment - if shutil.which("sudo") is None: - raise InstallationError("sudo command not found", error_code="SUDO_NOT_FOUND", cause=None) - - process = subprocess.Popen( - cmd, - text=True, - universal_newlines=True - ) - process.communicate() # Set a timeout for the command - process.wait() # Ensure cleanup - return process.returncode, "", "" - else: - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - universal_newlines=True, - bufsize=1 # Line-buffered output - ) - _stdout, _stderr = "", "" - - for line in process.stdout: - if line: - self.logger.info(f"apt stdout: {line.strip()}") - _stdout += line - - for line in process.stderr: - if line: - self.logger.info(f"apt stderr: {line.strip()}") - _stderr += line - - process.wait() # Ensure cleanup - return process.returncode, _stdout, _stderr + process = subprocess.Popen( + cmd, + text=True, + universal_newlines=True + ) + + process.communicate() # Set a timeout for the command + process.wait() # Ensure cleanup + return process.returncode except subprocess.TimeoutExpired: process.kill() @@ -430,14 +412,20 @@ def _verify_installation(self, package_name: str) -> Optional[str]: Optional[str]: Installed version if found, None otherwise. """ try: - cmd = ["apt-cache", "policy", package_name] - returncode, stdout, stderr = self._run_apt_subprocess(cmd) - if returncode == 0: - for line in stdout.splitlines(): + result = subprocess.run( + ["apt-cache", "policy", package_name], + text=True, + capture_output=True, + check=False + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): if "***" in line: - version = line.split()[1] - if version and version != "(none)": - return version + parts = line.split() + if len(parts) > 1: + version = parts[1] + if version and version != "(none)": + return version return None except Exception: return None @@ -485,14 +473,18 @@ def _simulate_installation(self, dependency: Dict[str, Any], context: Installati progress_callback(f"Simulating {package_name}", 0.5, "Running dry-run") try: - # Use apt's dry-run functionality - cmd = self._build_apt_command(dependency, context) - cmd.append("--simulate") - returncode, stdout, stderr = self._run_apt_subprocess(cmd) + # Use apt's dry-run functionality - need to use apt-get with --dry-run + cmd = ["apt-get", "install", "--dry-run", dependency["name"]] + + # Add automation flag if configured + if context.get_config("automated", False): + cmd.append("-y") + + returncode = self._run_apt_subprocess(cmd) if returncode != 0: raise InstallationError( - f"Simulation failed with error: {stderr}", + f"Simulation failed for {package_name} (see logs for details).", dependency_name=package_name, error_code="APT_SIMULATION_FAILED", cause=None @@ -506,9 +498,9 @@ def _simulate_installation(self, dependency: Dict[str, Any], context: Installati status=InstallationStatus.COMPLETED, metadata={ "simulation": True, - "dry_run_output": stdout, "command_simulated": " ".join(cmd), - "automated": context.get_config("automated", False) + "automated": context.get_config("automated", False), + "package_manager": "apt", } ) @@ -547,13 +539,13 @@ def _simulate_uninstall(self, dependency: Dict[str, Any], context: InstallationC progress_callback(f"Simulating uninstall {package_name}", 0.5, "Running dry-run") try: - # Use apt's dry-run functionality for remove - cmd = ["apt", "remove", dependency["name"], "--simulate"] - returncode, stdout, stderr = self._run_apt_subprocess(cmd) + # Use apt's dry-run functionality for remove - use apt-get with --dry-run + cmd = ["apt-get", "remove", "--dry-run", dependency["name"]] + returncode = self._run_apt_subprocess(cmd) if returncode != 0: raise InstallationError( - f"Uninstall simulation failed with error: {stderr.strip()}", + f"Uninstall simulation failed for {package_name} (see logs for details).", dependency_name=package_name, error_code="APT_UNINSTALL_SIMULATION_FAILED", cause=None @@ -568,7 +560,6 @@ def _simulate_uninstall(self, dependency: Dict[str, Any], context: InstallationC metadata={ "operation": "uninstall", "simulation": True, - "dry_run_output": stdout, "command_simulated": " ".join(cmd), "automated": context.get_config("automated", False) } diff --git a/tests/test_system_installer.py b/tests/test_system_installer.py index 4a2722c..daae1e4 100644 --- a/tests/test_system_installer.py +++ b/tests/test_system_installer.py @@ -221,9 +221,9 @@ def test_build_apt_command_automated(self): def test_verify_installation_success(self, mock_run): """Test successful installation verification.""" mock_run.return_value = subprocess.CompletedProcess( - args=["dpkg-query", "-W", "-f='${Version}'", "curl"], + args=["apt-cache", "policy", "curl"], returncode=0, - stdout="'7.68.0-1ubuntu2.7'", + stdout="curl:\n Installed: 7.68.0-1ubuntu2.7\n Candidate: 7.68.0-1ubuntu2.7\n Version table:\n *** 7.68.0-1ubuntu2.7 500\n 500 http://archive.ubuntu.com/ubuntu focal-updates/main amd64 Packages\n 100 /var/lib/dpkg/status", stderr="" ) @@ -295,7 +295,7 @@ def test_install_success(self, mock_verify, mock_execute, mock_build, mock_valid # Setup mocks mock_validate.return_value = True mock_build.return_value = ["apt", "install", "curl"] - mock_execute.return_value = (0, "", "") + mock_execute.return_value = 0 mock_verify.return_value = "7.68.0" dependency = { @@ -345,7 +345,8 @@ def test_install_apt_failure(self, mock_execute, mock_build, mock_validate): """Test installation failure due to apt command error.""" mock_validate.return_value = True mock_build.return_value = ["apt", "install", "curl"] - mock_execute.return_value = (1, "", "Permission denied") + # Simulate failure on the first call (apt-get update) + mock_execute.side_effect = [1, 0] dependency = { "name": "curl", @@ -356,9 +357,17 @@ def test_install_apt_failure(self, mock_execute, mock_build, mock_validate): with self.assertRaises(InstallationError) as exc_info: self.installer.install(dependency, self.mock_context) - self.assertEqual(exc_info.exception.error_code, "APT_INSTALL_FAILED") + # Accept either update or install failure + self.assertEqual(exc_info.exception.error_code, "APT_UPDATE_FAILED") self.assertEqual(exc_info.exception.dependency_name, "curl") + # Now simulate update success but install failure + mock_execute.side_effect = [0, 1] + with self.assertRaises(InstallationError) as exc_info2: + self.installer.install(dependency, self.mock_context) + self.assertEqual(exc_info2.exception.error_code, "APT_INSTALL_FAILED") + self.assertEqual(exc_info2.exception.dependency_name, "curl") + @patch.object(SystemInstaller, 'validate_dependency') @patch.object(SystemInstaller, '_simulate_installation') def test_install_simulation_mode(self, mock_simulate, mock_validate): @@ -385,15 +394,10 @@ def test_install_simulation_mode(self, mock_simulate, mock_validate): mock_simulate.assert_called_once() @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") - @patch('subprocess.run') + @patch.object(SystemInstaller, '_run_apt_subprocess') def test_simulate_installation_success(self, mock_run): """Test successful installation simulation.""" - mock_run.return_value = subprocess.CompletedProcess( - args=["apt", "install", "--dry-run", "curl"], - returncode=0, - stdout="Inst curl (7.68.0-1ubuntu2.7 Ubuntu:20.04/focal [amd64])", - stderr="" - ) + mock_run.return_value = 0 dependency = { "name": "curl", @@ -406,12 +410,11 @@ def test_simulate_installation_success(self, mock_run): self.assertEqual(result.dependency_name, "curl") self.assertEqual(result.status, InstallationStatus.COMPLETED) self.assertTrue(result.metadata["simulation"]) - self.assertIn("dry_run_output", result.metadata) @patch.object(SystemInstaller, '_run_apt_subprocess') def test_simulate_installation_failure(self, mock_run): """Test installation simulation failure.""" - mock_run.return_value = (1, "", "E: Unable to locate package nonexistent") + mock_run.return_value = 1 mock_run.side_effect = InstallationError( "Simulation failed", dependency_name="nonexistent", @@ -430,7 +433,7 @@ def test_simulate_installation_failure(self, mock_run): self.assertEqual(exc_info.exception.dependency_name, "nonexistent") self.assertEqual(exc_info.exception.error_code, "APT_SIMULATION_FAILED") - @patch.object(SystemInstaller, '_run_apt_subprocess', return_value=(0, "", "")) + @patch.object(SystemInstaller, '_run_apt_subprocess', return_value=0) def test_uninstall_success(self, mock_execute): """Test successful uninstall.""" @@ -446,7 +449,7 @@ def test_uninstall_success(self, mock_execute): self.assertEqual(result.status, InstallationStatus.COMPLETED) self.assertEqual(result.metadata["operation"], "uninstall") - @patch.object(SystemInstaller, '_run_apt_subprocess', return_value=(0, "", "")) + @patch.object(SystemInstaller, '_run_apt_subprocess', return_value=0) def test_uninstall_automated(self, mock_execute): """Test uninstall in automated mode.""" @@ -539,13 +542,8 @@ def test_simulate_curl_installation(self): } # Mock subprocess for simulation - with patch('subprocess.run') as mock_run: - mock_run.return_value = subprocess.CompletedProcess( - args=["apt", "install", "--dry-run", "curl"], - returncode=0, - stdout="Inst curl (7.68.0-1ubuntu2.7 Ubuntu:20.04/focal [amd64])", - stderr="" - ) + with patch.object(self.installer, '_run_apt_subprocess') as mock_run: + mock_run.return_value = 0 result = self.installer._simulate_installation(dependency, self.test_context) @@ -584,4 +582,99 @@ def test_install_real_dependency(self): self.assertEqual(result.status, InstallationStatus.COMPLETED) self.assertTrue(result.metadata["automated"]) - \ No newline at end of file + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") + def test_install_integration_with_real_subprocess(self): + """Test install method with real _run_apt_subprocess execution. + + This integration test ensures that _run_apt_subprocess can actually run + without mocking, using apt-get --dry-run for safe testing. + """ + dependency = { + "name": "curl", + "version_constraint": ">=7.0.0", + "package_manager": "apt" + } + + # Create a test context that uses simulation mode for safety + test_context = InstallationContext( + environment_path=Path("/tmp/test_env"), + environment_name="integration_test", + simulation_mode=True, + extra_config={"automated": True} + ) + + # This will call _run_apt_subprocess with real subprocess execution + # but in simulation mode, so it's safe + result = self.installer.install(dependency, test_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["simulation"]) + self.assertEqual(result.metadata["package_manager"], "apt") + self.assertTrue(result.metadata["automated"]) + + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") + def test_run_apt_subprocess_direct_integration(self): + """Test _run_apt_subprocess directly with real system commands. + + This test verifies that _run_apt_subprocess can handle actual apt commands + without any mocking, using safe commands that don't modify the system. + """ + # Test with apt-cache policy (read-only command) + cmd = ["apt-cache", "policy", "curl"] + returncode = self.installer._run_apt_subprocess(cmd) + + # Should return 0 (success) for a valid package query + self.assertEqual(returncode, 0) + + # Test with apt-get dry-run (safe simulation command) + cmd = ["apt-get", "install", "--dry-run", "-y", "curl"] + returncode = self.installer._run_apt_subprocess(cmd) + + # Should return 0 (success) for a valid dry-run + self.assertEqual(returncode, 0) + + # Test with invalid package (should fail gracefully) + cmd = ["apt-cache", "policy", "nonexistent-package-12345"] + returncode = self.installer._run_apt_subprocess(cmd) + + # Should return 0 even for non-existent package (apt-cache policy doesn't fail) + self.assertEqual(returncode, 0) + + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") + def test_install_with_version_constraint_integration(self): + """Test install method with version constraints and real subprocess calls.""" + # Test with exact version constraint + dependency = { + "name": "curl", + "version_constraint": "==7.68.0", + "package_manager": "apt" + } + + test_context = InstallationContext( + environment_path=Path("/tmp/test_env"), + environment_name="integration_test", + simulation_mode=True, + extra_config={"automated": True} + ) + + result = self.installer.install(dependency, test_context) + + self.assertEqual(result.dependency_name, "curl") + self.assertEqual(result.status, InstallationStatus.COMPLETED) + self.assertTrue(result.metadata["simulation"]) + # Check that the command includes the version constraint + self.assertIn("curl", result.metadata["command_simulated"]) + + @unittest.skipIf(sys.platform.startswith("win"), "System dependency test skipped on Windows") + def test_error_handling_in_run_apt_subprocess(self): + """Test error handling in _run_apt_subprocess with real commands.""" + # Test with completely invalid command + cmd = ["nonexistent-command-12345"] + + with self.assertRaises(InstallationError) as exc_info: + self.installer._run_apt_subprocess(cmd) + + self.assertEqual(exc_info.exception.error_code, "APT_SUBPROCESS_ERROR") + self.assertIn("Unexpected error running apt command", exc_info.exception.message) + From 0ed1724e7e5aee6f8278535fa8f12d747950074f Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 9 Jul 2025 18:49:55 +0900 Subject: [PATCH 40/48] [Update] Default env is initialized with python **Major**: - Given that all the default is to import `hatch_mcp_server` for Hatch MCP packages, any Hatch MCP package expect to have a python environment set up with this package. - So, the default should allow that (after all, it's the default...) --- hatch/environment_manager.py | 64 ++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index bc014c5..9d4678d 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -57,16 +57,6 @@ def __init__(self, self.environments_file = self.environments_dir / "environments.json" self.current_env_file = self.environments_dir / "current_env" - # Initialize files if they don't exist - if not self.environments_file.exists(): - self._initialize_environments_file() - - if not self.current_env_file.exists(): - self._initialize_current_env_file() - - # Load environments into cache - self._environments = self._load_environments() - self._current_env_name = self._load_current_env_name() # Initialize Python environment manager self.python_env_manager = PythonEnvironmentManager(environments_dir=self.environments_dir) @@ -87,24 +77,14 @@ def __init__(self, registry_service=self.registry_service, registry_data=self.registry_data ) - - # Configure Python executable for current environment - if self._current_env_name: - self._configure_python_executable(self._current_env_name) + + # Load environments into cache + self._environments = self._load_environments() + self._current_env_name = self._load_current_env_name() def _initialize_environments_file(self): """Create the initial environments file with default environment.""" - default_environments = { - "default": { - "name": "default", - "description": "Default environment", - "created_at": datetime.datetime.now().isoformat(), - "packages": [], - "python_environment": False, # Legacy field - "python_version": None, # Legacy field - "python_env": None # Enhanced metadata structure - } - } + default_environments = {} with open(self.environments_file, 'w') as f: json.dump(default_environments, f, indent=2) @@ -119,15 +99,41 @@ def _initialize_current_env_file(self): self.logger.info("Initialized current environment to default") def _load_environments(self) -> Dict: - """Load environments from the environments file.""" + """Load environments from the environments file. + + This method attempts to read the environments from the JSON file. + If the file is not found or contains invalid JSON, it initializes + the file with a default environment and returns that. + + Returns: + Dict: Dictionary of environments loaded from the file. + """ + try: with open(self.environments_file, 'r') as f: return json.load(f) except (json.JSONDecodeError, FileNotFoundError) as e: - self.logger.error(f"Failed to load environments: {e}") + self.logger.info(f"Failed to load environments: {e}. Initializing with default environment.") + + # Touch the files with default values self._initialize_environments_file() - return {"default": {"name": "default", "description": "Default environment", - "created_at": datetime.datetime.now().isoformat(), "packages": []}} + self._initialize_current_env_file() + + # Load created default environment + with open(self.environments_file, 'r') as f: + _environments = json.load(f) + + # Assign to cache + self._environments = _environments + + # Actually create the default environment + self.create_environment("default", description="Default environment") + + # Set correct Python executable info to the one of default environment + self._configure_python_executable("default") + + return _environments + def _load_current_env_name(self) -> str: """Load current environment name from disk.""" From 82736c14a01d84e53e28e5e1994739451d3441b2 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Wed, 9 Jul 2025 18:50:51 +0900 Subject: [PATCH 41/48] [Fix] Docker client retrieval **Major**: - Old bug discovered only now - It happened when we fiddled wiht the Docker availability global parameters --- hatch/installers/docker_installer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hatch/installers/docker_installer.py b/hatch/installers/docker_installer.py index 700ccb3..f3452cd 100644 --- a/hatch/installers/docker_installer.py +++ b/hatch/installers/docker_installer.py @@ -323,7 +323,8 @@ def _get_docker_client(self): error_code="DOCKER_DAEMON_NOT_AVAILABLE", cause=e ) - + if self._docker_client is None: + self._docker_client = docker.from_env() return self._docker_client def _validate_version_constraint(self, version_constraint: str) -> bool: @@ -407,7 +408,7 @@ def _pull_docker_image(self, image_name: str, progress_callback: Optional[Callab # Pull the image client.images.pull(image_name) - + logger.info(f"Successfully pulled Docker image: {image_name}") except ImageNotFound as e: From 25b31e85fc50aa29ebf00b075c91eb10f5562d24 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Fri, 18 Jul 2025 01:52:52 +0900 Subject: [PATCH 42/48] [Update] System dependencies first **Major**: - In the `DependencyInstallationOrchestrator` we regorganize the dictionary of deps to guarentee that system depedencies will be processed first **Minor**: - Added debug logs - Added default values for some `.get(...)` calls on dicts --- hatch/installers/dependency_installation_orchestrator.py | 6 ++++-- hatch/installers/python_installer.py | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/hatch/installers/dependency_installation_orchestrator.py b/hatch/installers/dependency_installation_orchestrator.py index 27ccda3..1d5041e 100644 --- a/hatch/installers/dependency_installation_orchestrator.py +++ b/hatch/installers/dependency_installation_orchestrator.py @@ -62,6 +62,7 @@ def __init__(self, registry_data (Dict[str, Any]): Registry data for dependency resolution. """ self.logger = logging.getLogger("hatch.dependency_orchestrator") + self.logger.setLevel(logging.INFO) self.package_loader = package_loader self.registry_service = registry_service self.registry_data = registry_data @@ -128,6 +129,7 @@ def install_single_dep(self, dep: Dict[str, Any], context: InstallationContext) try: self.logger.info(f"Installing {dep_type} dependency: {dep['name']}") + self.logger.debug(f"Dependency details: {dep}") result = installer.install(dep, context) if result.status == InstallationStatus.COMPLETED: installed_package = { @@ -312,9 +314,9 @@ def _get_all_dependencies(self) -> Dict[str, List[Dict[str, Any]]]: all_deps = self.package_service.get_dependencies() dependencies_by_type = { - "hatch": [], - "python": [], "system": [], + "python": [], + "hatch": [], "docker": [] } diff --git a/hatch/installers/python_installer.py b/hatch/installers/python_installer.py index 294f20d..40962f6 100644 --- a/hatch/installers/python_installer.py +++ b/hatch/installers/python_installer.py @@ -153,7 +153,9 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, # Get Python executable from context or use system default python_env_vars = context.get_config("python_env_vars", {}) + self.logger.debug(f"Using Python environment variables: {python_env_vars}") python_exec = python_env_vars.get("PYTHON", sys.executable) + self.logger.debug(f"Using Python executable: {python_exec}") # Build package specification with version constraint # Let pip resolve the actual version based on the constraint @@ -163,7 +165,7 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, package_spec = name # Handle extras if specified - extras = dependency.get("extras") + extras = dependency.get("extras", []) if extras: if isinstance(extras, list): extras_str = ",".join(extras) @@ -175,6 +177,7 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, package_spec = f"{name}[{extras_str}]" # Build pip command + self.logger.debug(f"Installing Python package: {package_spec} using {python_exec}") cmd = [str(python_exec), "-m", "pip", "install", package_spec] # Add additional pip options @@ -226,7 +229,7 @@ def install(self, dependency: Dict[str, Any], context: InstallationContext, raise InstallationError(error_msg, dependency_name=name, error_code="TIMEOUT") except Exception as e: - error_msg = f"Unexpected error installing {name}: {e}" + error_msg = f"Unexpected error installing {name}: {repr(e)}" self.logger.error(error_msg) raise InstallationError(error_msg, dependency_name=name, cause=e) @@ -329,7 +332,7 @@ def get_installation_info(self, dependency: Dict[str, Any], context: Installatio "package_manager": dependency.get("package_manager", "pip"), "package_spec": package_spec, "version_constraint": version_constraint, - "extras": dependency.get("extras") + "extras": dependency.get("extras", []), }) return info From 41654f96b95b50c32436985d2229da40736cb85c Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Fri, 18 Jul 2025 01:56:13 +0900 Subject: [PATCH 43/48] [Fix] Configuration of python environment **Major**: - Python environment was configured by default only when no environment files existed. - It would not be correctly configured on second usage forward of the package manager... - The fix is simply to move the `_configure_python_executable(...)` at the end of the `__init__()` --- hatch/environment_manager.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/hatch/environment_manager.py b/hatch/environment_manager.py index 9d4678d..68f6505 100644 --- a/hatch/environment_manager.py +++ b/hatch/environment_manager.py @@ -81,6 +81,8 @@ def __init__(self, # Load environments into cache self._environments = self._load_environments() self._current_env_name = self._load_current_env_name() + # Set correct Python executable info to the one of default environment + self._configure_python_executable(self._current_env_name) def _initialize_environments_file(self): """Create the initial environments file with default environment.""" @@ -128,9 +130,6 @@ def _load_environments(self) -> Dict: # Actually create the default environment self.create_environment("default", description="Default environment") - - # Set correct Python executable info to the one of default environment - self._configure_python_executable("default") return _environments From 4d9555b0d1029cd5bffa5e774e04ffc70d885f6d Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Fri, 18 Jul 2025 18:03:38 +0900 Subject: [PATCH 44/48] [Fix] Hatch package template **Major**: - HatchMCP is not in `hatchling` anymore but in `hatch_mcp_server` --- hatch/template_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hatch/template_generator.py b/hatch/template_generator.py index e4fc23b..83ab54b 100644 --- a/hatch/template_generator.py +++ b/hatch/template_generator.py @@ -28,7 +28,7 @@ def generate_server_py(package_name: str): str: Content for server.py file. """ return f"""import logging -from hatchling import HatchMCP +from hatch_mcp_server import HatchMCP # Initialize MCP server with metadata hatch_mcp = HatchMCP("{package_name}", From 65ee7807f31038092401ee045ea469747697f4a4 Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Mon, 28 Jul 2025 23:33:21 +0900 Subject: [PATCH 45/48] [Remove] Timeout of the command to create env **Major**: - Just face the issue on a slow internet connection that I couldn't initialize the environment - It makes no sense to hardcode timeouts without a clear rationale. And there is none for that one. - And it would take too long to make it a parameter instead of the whole Hatch for the current gain. --- hatch/python_environment_manager.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/hatch/python_environment_manager.py b/hatch/python_environment_manager.py index a4c78a9..256d467 100644 --- a/hatch/python_environment_manager.py +++ b/hatch/python_environment_manager.py @@ -208,8 +208,7 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] self.logger.debug(f"Using Python version: {python_version}") result = subprocess.run( - cmd, - timeout=300 # 5 minutes timeout + cmd ) if result.returncode == 0: @@ -218,11 +217,7 @@ def create_python_environment(self, env_name: str, python_version: Optional[str] error_msg = f"Failed to create Python environment (see terminal output)" self.logger.error(error_msg) raise PythonEnvironmentError(error_msg) - - except subprocess.TimeoutExpired: - error_msg = f"Timeout creating Python environment for {env_name}" - self.logger.error(error_msg) - raise PythonEnvironmentError(error_msg) + except Exception as e: error_msg = f"Unexpected error creating Python environment: {e}" self.logger.error(error_msg) From bcc850bd0bd259cc53de31bc64164d9cf920473f Mon Sep 17 00:00:00 2001 From: LittleCoinCoin Date: Thu, 7 Aug 2025 15:15:51 +0900 Subject: [PATCH 46/48] [Add] Versioning logic **Major**: - Copied over directly from Hatchling. - The only difference is that we renamed Hatchlings direcotry `/scripts` to `/versioning` for more clarity about what the folder contains. --- .github/workflows/commit_version_tag.yml | 46 ++++ .github/workflows/tag-cleanup.yml | 50 ++++ .github/workflows/tag-release.yml | 45 ++++ .github/workflows/test_build.yml | 72 +++++ VERSION | 1 + VERSION.meta | 10 + pyproject.toml | 7 +- versioning/prepare_version.py | 37 +++ versioning/tag_cleanup.py | 94 +++++++ versioning/version_manager.py | 327 +++++++++++++++++++++++ 10 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/commit_version_tag.yml create mode 100644 .github/workflows/tag-cleanup.yml create mode 100644 .github/workflows/tag-release.yml create mode 100644 .github/workflows/test_build.yml create mode 100644 VERSION create mode 100644 VERSION.meta create mode 100644 versioning/prepare_version.py create mode 100644 versioning/tag_cleanup.py create mode 100644 versioning/version_manager.py diff --git a/.github/workflows/commit_version_tag.yml b/.github/workflows/commit_version_tag.yml new file mode 100644 index 0000000..7425b11 --- /dev/null +++ b/.github/workflows/commit_version_tag.yml @@ -0,0 +1,46 @@ +name: Commit Version Tag + +on: + push: + branches: + - dev + - main + - feat/* + - fix/* + +jobs: + test-build: + uses: ./.github/workflows/test_build.yml + with: + branch: ${{ github.ref_name }} + permissions: + contents: write + + commit-version-tag: + needs: test-build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download VERSION files + uses: actions/download-artifact@v4 + with: + name: version-files + + - name: Commit updated VERSION files + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add VERSION VERSION.meta + git diff --staged --quiet || git commit -m "Update VERSION files in \`${{ github.ref_name }}\` to ${{ needs.test-build.outputs.version }}" + git push + + - name: Create lightweight tag + run: | + git tag -a "${{ needs.test-build.outputs.version }}" -m "Release ${{ needs.test-build.outputs.version }}" + git push origin "${{ needs.test-build.outputs.version }}" \ No newline at end of file diff --git a/.github/workflows/tag-cleanup.yml b/.github/workflows/tag-cleanup.yml new file mode 100644 index 0000000..50af89d --- /dev/null +++ b/.github/workflows/tag-cleanup.yml @@ -0,0 +1,50 @@ +name: Tag Cleanup + +on: + # We will run this workflow only manually to check behaviors for a while. + # Later we will enable it to run on a schedule that is yet to be determined (below weekly is only an example). + #schedule: + # Run weekly on Sundays at 2:00 AM UTC + #- cron: '0 2 * * 0' + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (show what would be deleted without actually deleting)' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + +env: + PYTHON_VERSION: '3.12' + +jobs: + cleanup-tags: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run tag cleanup + run: | + DRY_RUN="${{ github.event.inputs.dry_run || 'true' }}" + python scripts/tag_cleanup.py "$DRY_RUN" + + - name: Summary + run: | + if [ "${{ github.event.inputs.dry_run || 'true' }}" = "true" ]; then + echo "Tag cleanup completed in dry-run mode. No tags were actually deleted." + else + echo "Tag cleanup completed. Old build and dev tags have been removed." + fi \ No newline at end of file diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..e792c35 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,45 @@ +name: Tagged Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+.dev[0-9]+' + +jobs: + releasing: + runs-on: ubuntu-latest + steps: + - name: Create Pre-Release + if: ${{ contains(github.ref_name, '.dev') }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Pre-Release ${{ github.ref_name }} + body: | + Pre-release ${{ github.ref_name }} + + ## Changes + - See commit history for detailed changes + + ⚠️ **This is a pre-release version** + + draft: false + prerelease: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Release + if: ${{ !contains(github.ref_name, '.dev') }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body: | + Release ${{ github.ref_name }} + + ## Changes + - See commit history for detailed changes + + draft: false + prerelease: false + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml new file mode 100644 index 0000000..96c5824 --- /dev/null +++ b/.github/workflows/test_build.yml @@ -0,0 +1,72 @@ +name: Update VERSION and test build + +on: + workflow_call: + inputs: + python-version: + required: false + type: string + default: '3.12' + branch: + required: false + type: string + default: 'main' + outputs: + version: + description: The version of the package built + value: ${{ jobs.test-build.outputs.version }} + +jobs: + test-build: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version-extract.outputs.version }} + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Update VERSION file for branch + run: | + echo "Updating VERSION file for branch: ${{ inputs.branch }}" + python versioning/version_manager.py --update-for-branch ${{ inputs.branch }} + + - name: Get version info + id: version-extract + run: | + VERSION=$(python versioning/version_manager.py --get) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Updated to version: $VERSION" + + - name: Prepare version for build + run: | + python versioning/prepare_version.py + + - name: Build package + run: | + python -m build + + - name: Test package installation + run: | + pip install dist/*.whl + + - name: Upload VERSION files + uses: actions/upload-artifact@v4 + with: + name: version-files + path: | + VERSION + VERSION.meta diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..f9ae45c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.4.0.dev0+build1 \ No newline at end of file diff --git a/VERSION.meta b/VERSION.meta new file mode 100644 index 0000000..cdce2bd --- /dev/null +++ b/VERSION.meta @@ -0,0 +1,10 @@ +# Structured version file for human readability and CI/CD +# This file maintains detailed version component information +# The companion VERSION file contains the simple format for setuptools + +MAJOR=0 +MINOR=4 +PATCH=0 +DEV_NUMBER=0 +BUILD_NUMBER=1 +BRANCH=feat/versioning-system diff --git a/pyproject.toml b/pyproject.toml index c49c9d4..a433365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "hatch" -version = "0.4.0" +dynamic = ["version"] authors = [ { name = "Hatch Team" }, ] -description = "Package manager for the Hatch! ecosystem" +description = "Package manager for the Cracking Shells ecosystem" readme = "README.md" requires-python = ">=3.12" classifiers = [ @@ -36,5 +36,8 @@ hatch = "hatch.cli_hatch:main" [tool.setuptools] package-dir = {"" = "."} +[tool.setuptools.dynamic] +version = {file = "VERSION"} + [tool.setuptools.packages.find] where = ["."] \ No newline at end of file diff --git a/versioning/prepare_version.py b/versioning/prepare_version.py new file mode 100644 index 0000000..3bf9f11 --- /dev/null +++ b/versioning/prepare_version.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +Build helper script that prepares the VERSION file for setuptools. +This should be run before building the package. +""" + +import os +import sys +from pathlib import Path + +# Add the scripts directory to Python path +script_dir = Path(__file__).parent +sys.path.insert(0, str(script_dir)) + +from version_manager import VersionManager + +def main(): + """Convert VERSION.meta to simple VERSION format for setuptools.""" + try: + vm = VersionManager() + + # Read from VERSION.meta (structured format) + version_data = vm.read_version_file() + version_string = vm.get_version_string(version_data) + + # Write both files: keep VERSION.meta unchanged, update VERSION for setuptools + vm.write_simple_version_file(version_data) + + print(f"VERSION file prepared for build: {version_string.lstrip('v')}") + print("VERSION.meta preserved with structured format") + + except Exception as e: + print(f"Error preparing VERSION file: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/versioning/tag_cleanup.py b/versioning/tag_cleanup.py new file mode 100644 index 0000000..673477b --- /dev/null +++ b/versioning/tag_cleanup.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Tag cleanup helper script for CI/CD. +Removes old build and dev tags according to project policy. +""" + +import subprocess +import re +import sys +from datetime import datetime, timedelta +from typing import List, Tuple + +def get_all_tags() -> List[Tuple[str, str]]: + """Get all tags with their creation dates.""" + try: + result = subprocess.run( + ['git', 'for-each-ref', '--format=%(refname:short)|%(creatordate:iso8601)', 'refs/tags'], + capture_output=True, + text=True, + check=True + ) + tags = [] + for line in result.stdout.strip().split('\n'): + if '|' in line: + tag, date_str = line.split('|', 1) + tags.append((tag, date_str)) + return tags + except subprocess.CalledProcessError: + return [] + +def is_build_tag(tag: str) -> bool: + """Check if tag is a build tag (contains +build followed by number).""" + return bool(re.search(r'\+build\d+$', tag)) + +def is_dev_tag(tag: str) -> bool: + """Check if tag is a dev tag (contains .dev).""" + return bool(re.search(r'\.dev\d+', tag)) + +def is_old_tag(date_str: str, days: int = 30) -> bool: + """Check if tag is older than specified days.""" + try: + tag_date = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + cutoff_date = datetime.now().astimezone() - timedelta(days=days) + return tag_date < cutoff_date + except Exception: + return False + +def main(): + dry_run = sys.argv[1].lower() == 'true' if len(sys.argv) > 1 else True + + all_tags = get_all_tags() + if not all_tags: + print("No tags found.") + return + + tags_to_delete = [] + for tag, date_str in all_tags: + should_delete = False + reason = "" + if is_build_tag(tag) and is_old_tag(date_str, 7): + should_delete = True + reason = "old build tag (>7 days)" + elif is_dev_tag(tag) and is_old_tag(date_str, 30): + should_delete = True + reason = "old dev tag (>30 days)" + if should_delete: + tags_to_delete.append((tag, reason)) + + if not tags_to_delete: + print("No tags need cleanup.") + return + + print(f"Found {len(tags_to_delete)} tags for cleanup:") + for tag, reason in tags_to_delete: + print(f" - {tag} ({reason})") + + if dry_run: + print("\\nDry run mode - no tags were actually deleted.") + return + + deleted_count = 0 + for tag, reason in tags_to_delete: + try: + subprocess.run(['git', 'tag', '-d', tag], check=True, capture_output=True) + subprocess.run(['git', 'push', 'origin', '--delete', tag], check=True, capture_output=True) + print(f"Deleted {tag} ({reason})") + deleted_count += 1 + except subprocess.CalledProcessError as e: + print(f"Failed to delete {tag}: {e}") + + print(f"\\nDeleted {deleted_count} tags.") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/versioning/version_manager.py b/versioning/version_manager.py new file mode 100644 index 0000000..a5d369a --- /dev/null +++ b/versioning/version_manager.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +Version management script for Hatchling with dual-file system. + +This script manages two version files: +- VERSION.meta: Structured, human-readable format with detailed version components +- VERSION: Simple format for setuptools compatibility + +The dual-file system preserves detailed version information while maintaining +compatibility with Python packaging tools. +""" + +import os +import sys +import argparse +import subprocess +from pathlib import Path +from typing import Dict, Tuple, Optional + + +class VersionManager: + def __init__(self, version_file_path: str = "VERSION"): + self.version_file = Path(version_file_path) + self.version_meta_file = Path(str(version_file_path) + ".meta") + + # Check if we have a structured meta file, otherwise try to read from VERSION + if not self.version_meta_file.exists() and not self.version_file.exists(): + raise FileNotFoundError(f"Neither VERSION file nor VERSION.meta found at {version_file_path}") + + # If VERSION.meta doesn't exist but VERSION does, create it from current VERSION + if not self.version_meta_file.exists() and self.version_file.exists(): + self._create_meta_from_simple_version() + + def _create_meta_from_simple_version(self) -> None: + """Create VERSION.meta from a simple VERSION file if it doesn't exist.""" + try: + with open(self.version_file, 'r') as f: + simple_version = f.read().strip() + + # Parse the simple version string to extract components + # Example: "0.4.0.dev0+build0" -> MAJOR=0, MINOR=4, PATCH=0, DEV_NUMBER=0, BUILD_NUMBER=0 + version_parts = simple_version.replace('+build', '.build').replace('.dev', '.dev').split('.') + + major, minor, patch = version_parts[0], version_parts[1], version_parts[2] + dev_number = "" + build_number = "" + + for part in version_parts[3:]: + if part.startswith('dev'): + dev_number = part[3:] + elif part.startswith('build'): + build_number = part[5:] + + # Get current branch + branch = self.get_current_branch() + + # Write structured format to VERSION.meta + version_data = { + 'MAJOR': major, + 'MINOR': minor, + 'PATCH': patch, + 'DEV_NUMBER': dev_number, + 'BUILD_NUMBER': build_number, + 'BRANCH': branch + } + self.write_version_meta_file(version_data) + + except Exception as e: + # If we can't parse, create a default meta file + default_data = { + 'MAJOR': '0', + 'MINOR': '0', + 'PATCH': '0', + 'DEV_NUMBER': '', + 'BUILD_NUMBER': '', + 'BRANCH': self.get_current_branch() + } + self.write_version_meta_file(default_data) + + def read_version_file(self) -> Dict[str, str]: + """Read the VERSION.meta file and parse version components.""" + version_data = {} + + # Always read from VERSION.meta for structured data + if self.version_meta_file.exists(): + with open(self.version_meta_file, 'r') as f: + for line in f: + line = line.strip() + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + version_data[key.strip()] = value.strip() + else: + # Fallback: try to create from simple VERSION file + self._create_meta_from_simple_version() + return self.read_version_file() + + return version_data + + def write_version_file(self, version_data: Dict[str, str]) -> None: + """Write version data to both VERSION.meta (structured) and VERSION (simple).""" + # Write structured format to VERSION.meta + self.write_version_meta_file(version_data) + + # Write simple format to VERSION for setuptools compatibility + self.write_simple_version_file(version_data) + + def write_version_meta_file(self, version_data: Dict[str, str]) -> None: + """Write version data to VERSION.meta in structured format.""" + with open(self.version_meta_file, 'w') as f: + f.write("# Structured version file for human readability and CI/CD\n") + f.write("# This file maintains detailed version component information\n") + f.write("# The companion VERSION file contains the simple format for setuptools\n\n") + for key in ['MAJOR', 'MINOR', 'PATCH', 'DEV_NUMBER', 'BUILD_NUMBER', 'BRANCH']: + value = version_data.get(key, '') + f.write(f"{key}={value}\n") + + def write_simple_version_file(self, version_data: Optional[Dict[str, str]] = None) -> None: + """Write a simple version string to VERSION file for setuptools compatibility.""" + if version_data is None: + version_data = self.read_version_file() + + version_string = self.get_version_string(version_data) + # Remove 'v' prefix for setuptools + simple_version = version_string.lstrip('v') + + # Write to VERSION file in simple format for setuptools + with open(self.version_file, 'w') as f: + f.write(simple_version) + + def get_version_string(self, version_data: Optional[Dict[str, str]] = None) -> str: + """Generate semantic version string from version data.""" + if version_data is None: + version_data = self.read_version_file() + + major = version_data.get('MAJOR', '1') + minor = version_data.get('MINOR', '0') + patch = version_data.get('PATCH', '0') + dev_number = version_data.get('DEV_NUMBER', '') + build_number = version_data.get('BUILD_NUMBER', '') + + version = f"v{major}.{minor}.{patch}" + + if dev_number: + version += f".dev{dev_number}" + + if build_number: + version += f"+build{build_number}" + + return version + + def get_current_branch(self) -> str: + """Get the current git branch.""" + try: + result = subprocess.run( + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return "unknown" + + def increment_version(self, increment_type: str, branch: str = None) -> Dict[str, str]: + """Increment version based on type and branch.""" + version_data = self.read_version_file() + + if branch is None: + branch = self.get_current_branch() + + major = int(version_data.get('MAJOR', '1')) + minor = int(version_data.get('MINOR', '0')) + patch = int(version_data.get('PATCH', '0')) + dev_number = int(version_data.get('DEV_NUMBER', '0')) if version_data.get('DEV_NUMBER', '') else 0 + build_number = int(version_data.get('BUILD_NUMBER', '0')) if version_data.get('BUILD_NUMBER', '') else 0 + + # Handle different increment types + if increment_type == 'major': + major += 1 + minor = 0 + patch = 0 + elif increment_type == 'minor': + minor += 1 + patch = 0 + elif increment_type == 'patch': + patch += 1 + elif increment_type == 'dev': + dev_number += 1 + elif increment_type == 'build': + build_number += 1 + + # Set dev_number and build_number based on branch + dev_num_str = "" + build_num_str = "" + + if branch == 'main': + # Main branch gets clean releases + dev_num_str = "" + build_num_str = "" + elif branch == 'dev': + # Dev branch gets dev prerelease + dev_num_str = str(dev_number) if dev_number > 0 else "" + build_num_str = "" + elif branch.startswith('feat/') or branch.startswith('fix/'): + # Feature/fix branches get dev prerelease with build number + dev_num_str = str(dev_number) # Always include dev number for feat/fix branches + build_num_str = str(build_number) if build_number > 0 else "" + else: + # Other branches get dev prerelease + dev_num_str = str(dev_number) if dev_number > 0 else "" + build_num_str = "" + + return { + 'MAJOR': str(major), + 'MINOR': str(minor), + 'PATCH': str(patch), + 'DEV_NUMBER': dev_num_str, + 'BUILD_NUMBER': build_num_str, + 'BRANCH': branch + } + + def update_version_for_branch(self, branch: str = None) -> str: + """Update version based on branch type.""" + if branch is None: + branch = self.get_current_branch() + + current_data = self.read_version_file() + new_data = current_data.copy() + + if branch.startswith('feat/'): + + if current_data.get('BRANCH') == 'main': #it means we are creating a new feature branch + #increment minor version + new_data = self.increment_version('minor', branch) #takes care of incrementing and updating the branch field + new_data['DEV_NUMBER'] = '0' # Reset dev number for new feature branch + new_data['BUILD_NUMBER'] = '0' # Start with build0 + + else: #it means we are updating an existing feature branch, whether from dev or `feat/` + # Increment build number for the same feature branch + new_data = self.increment_version('build', branch) + + elif branch.startswith('fix/'): + # If current branch is the same as the one in VERSION.meta, + # increment the build number + if current_data.get('BRANCH') == branch: + # Increment build number for the same fix branch + new_data = self.increment_version('build', branch) + + # If the current branch is a fix branch but not the same as the one in VERSION.meta, + # increment the patch version, but don't change the build number + elif current_data.get('BRANCH').startswith('fix/'): + new_data = self.increment_version('patch', branch) + + else: #it means we are creating a new fix branch + new_data = self.increment_version('patch', branch) #takes care of incrementing and updating the branch field + new_data['BUILD_NUMBER'] = '0' # Start with build0 + + elif branch == 'main': + # Main branch gets clean releases + new_data = current_data.copy() + new_data['DEV_NUMBER'] = '' + new_data['BUILD_NUMBER'] = '' + + else: # Dev and other branches + # If starting from main (there was a rebased dev on a clean + # release), reset dev and build numbers + if current_data.get('BRANCH') == 'main': + new_data = self.increment_version('minor', branch) + new_data['DEV_NUMBER'] = '0' + + # If updating from another branch (fix, feat, dev itself, or docs, etc.), + # increment dev number and reset build number + else: + new_data = self.increment_version('dev', branch) + + new_data['BUILD_NUMBER'] = '' + + # Update branch field + new_data['BRANCH'] = branch + + self.write_version_file(new_data) + return self.get_version_string(new_data) + + def write_simple_version(self) -> None: + """Write a simple version string for setuptools compatibility.""" + version_data = self.read_version_file() + self.write_simple_version_file(version_data) + + +def main(): + parser = argparse.ArgumentParser(description='Manage project version using dual-file system (VERSION.meta + VERSION)') + parser.add_argument('--get', action='store_true', help='Get current version string') + parser.add_argument('--increment', choices=['major', 'minor', 'patch', 'dev', 'build'], + help='Increment version component (updates both VERSION.meta and VERSION)') + parser.add_argument('--update-for-branch', metavar='BRANCH', + help='Update version for specific branch (updates both files)') + parser.add_argument('--simple', action='store_true', + help='Write simple version format to VERSION file (from VERSION.meta)') + parser.add_argument('--branch', help='Override current branch detection') + + args = parser.parse_args() + + try: + vm = VersionManager() + + if args.get: + print(vm.get_version_string()) + elif args.increment: + new_data = vm.increment_version(args.increment, args.branch) + vm.write_version_file(new_data) + print(vm.get_version_string(new_data)) + elif args.update_for_branch is not None: + version = vm.update_version_for_branch(args.update_for_branch) + print(version) + elif args.simple: + vm.write_simple_version() + print("Simple version written to VERSION file") + else: + print(vm.get_version_string()) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() \ No newline at end of file From 9948fe466b60b25766bf03eb915ea0ce05606fe0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 7 Aug 2025 06:16:59 +0000 Subject: [PATCH 47/48] Update VERSION files in `feat/versioning-system` to v0.4.0.dev0+build2 --- VERSION | 2 +- VERSION.meta | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index f9ae45c..6fc88ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0.dev0+build1 \ No newline at end of file +0.4.0.dev0+build2 \ No newline at end of file diff --git a/VERSION.meta b/VERSION.meta index cdce2bd..1660442 100644 --- a/VERSION.meta +++ b/VERSION.meta @@ -6,5 +6,5 @@ MAJOR=0 MINOR=4 PATCH=0 DEV_NUMBER=0 -BUILD_NUMBER=1 +BUILD_NUMBER=2 BRANCH=feat/versioning-system From 16548236000a5b67423d109e3e702783e548bacc Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 7 Aug 2025 06:23:36 +0000 Subject: [PATCH 48/48] Update VERSION files in `dev` to v0.4.0.dev1 --- VERSION | 2 +- VERSION.meta | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 6fc88ca..062d57a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0.dev0+build2 \ No newline at end of file +0.4.0.dev1 \ No newline at end of file diff --git a/VERSION.meta b/VERSION.meta index 1660442..921caa8 100644 --- a/VERSION.meta +++ b/VERSION.meta @@ -5,6 +5,6 @@ MAJOR=0 MINOR=4 PATCH=0 -DEV_NUMBER=0 -BUILD_NUMBER=2 -BRANCH=feat/versioning-system +DEV_NUMBER=1 +BUILD_NUMBER= +BRANCH=dev