hatch-xclam 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hatch/__init__.py +21 -0
- hatch/cli_hatch.py +2748 -0
- hatch/environment_manager.py +1375 -0
- hatch/installers/__init__.py +25 -0
- hatch/installers/dependency_installation_orchestrator.py +636 -0
- hatch/installers/docker_installer.py +545 -0
- hatch/installers/hatch_installer.py +198 -0
- hatch/installers/installation_context.py +109 -0
- hatch/installers/installer_base.py +195 -0
- hatch/installers/python_installer.py +342 -0
- hatch/installers/registry.py +179 -0
- hatch/installers/system_installer.py +588 -0
- hatch/mcp_host_config/__init__.py +38 -0
- hatch/mcp_host_config/backup.py +458 -0
- hatch/mcp_host_config/host_management.py +572 -0
- hatch/mcp_host_config/models.py +602 -0
- hatch/mcp_host_config/reporting.py +181 -0
- hatch/mcp_host_config/strategies.py +513 -0
- hatch/package_loader.py +263 -0
- hatch/python_environment_manager.py +734 -0
- hatch/registry_explorer.py +171 -0
- hatch/registry_retriever.py +335 -0
- hatch/template_generator.py +179 -0
- hatch_xclam-0.7.0.dist-info/METADATA +150 -0
- hatch_xclam-0.7.0.dist-info/RECORD +93 -0
- hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
- hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
- hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
- hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/run_environment_tests.py +124 -0
- tests/test_cli_version.py +122 -0
- tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
- tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
- tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
- tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
- tests/test_data_utils.py +472 -0
- tests/test_dependency_orchestrator_consent.py +266 -0
- tests/test_docker_installer.py +524 -0
- tests/test_env_manip.py +991 -0
- tests/test_hatch_installer.py +179 -0
- tests/test_installer_base.py +221 -0
- tests/test_mcp_atomic_operations.py +276 -0
- tests/test_mcp_backup_integration.py +308 -0
- tests/test_mcp_cli_all_host_specific_args.py +303 -0
- tests/test_mcp_cli_backup_management.py +295 -0
- tests/test_mcp_cli_direct_management.py +453 -0
- tests/test_mcp_cli_discovery_listing.py +582 -0
- tests/test_mcp_cli_host_config_integration.py +823 -0
- tests/test_mcp_cli_package_management.py +360 -0
- tests/test_mcp_cli_partial_updates.py +859 -0
- tests/test_mcp_environment_integration.py +520 -0
- tests/test_mcp_host_config_backup.py +257 -0
- tests/test_mcp_host_configuration_manager.py +331 -0
- tests/test_mcp_host_registry_decorator.py +348 -0
- tests/test_mcp_pydantic_architecture_v4.py +603 -0
- tests/test_mcp_server_config_models.py +242 -0
- tests/test_mcp_server_config_type_field.py +221 -0
- tests/test_mcp_sync_functionality.py +316 -0
- tests/test_mcp_user_feedback_reporting.py +359 -0
- tests/test_non_tty_integration.py +281 -0
- tests/test_online_package_loader.py +202 -0
- tests/test_python_environment_manager.py +882 -0
- tests/test_python_installer.py +327 -0
- tests/test_registry.py +51 -0
- tests/test_registry_retriever.py +250 -0
- tests/test_system_installer.py +733 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
"""Environment Manager for Hatch package system.
|
|
2
|
+
|
|
3
|
+
This module provides the core functionality for managing isolated environments
|
|
4
|
+
for Hatch packages.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional, Any, Tuple
|
|
12
|
+
|
|
13
|
+
from hatch_validator.registry.registry_service import RegistryService, RegistryError
|
|
14
|
+
from hatch.registry_retriever import RegistryRetriever
|
|
15
|
+
from hatch_validator.package.package_service import PackageService
|
|
16
|
+
from hatch.package_loader import HatchPackageLoader
|
|
17
|
+
from hatch.installers.dependency_installation_orchestrator import DependencyInstallerOrchestrator
|
|
18
|
+
from hatch.installers.installation_context import InstallationContext
|
|
19
|
+
from hatch.python_environment_manager import PythonEnvironmentManager, PythonEnvironmentError
|
|
20
|
+
from hatch.mcp_host_config.models import MCPServerConfig
|
|
21
|
+
|
|
22
|
+
class HatchEnvironmentError(Exception):
|
|
23
|
+
"""Exception raised for environment-related errors."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HatchEnvironmentManager:
|
|
28
|
+
"""Manages Hatch environments for package installation and isolation.
|
|
29
|
+
|
|
30
|
+
This class handles:
|
|
31
|
+
1. Creating and managing isolated environments
|
|
32
|
+
2. Environment lifecycle and state management
|
|
33
|
+
3. Delegating package installation to the DependencyInstallerOrchestrator
|
|
34
|
+
4. Managing environment metadata and persistence
|
|
35
|
+
"""
|
|
36
|
+
def __init__(self,
|
|
37
|
+
environments_dir: Optional[Path] = None,
|
|
38
|
+
cache_ttl: int = 86400, # Default TTL is 24 hours
|
|
39
|
+
cache_dir: Optional[Path] = None,
|
|
40
|
+
simulation_mode: bool = False,
|
|
41
|
+
local_registry_cache_path: Optional[Path] = None):
|
|
42
|
+
"""Initialize the Hatch environment manager.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
environments_dir (Path, optional): Directory to store environments. Defaults to ~/.hatch/envs.
|
|
46
|
+
cache_ttl (int): Time-to-live for cache in seconds. Defaults to 86400 (24 hours).
|
|
47
|
+
cache_dir (Path, optional): Directory to store local cache files. Defaults to ~/.hatch.
|
|
48
|
+
simulation_mode (bool): Whether to operate in local simulation mode. Defaults to False.
|
|
49
|
+
local_registry_cache_path (Path, optional): Path to local registry file. Defaults to None.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
self.logger = logging.getLogger("hatch.environment_manager")
|
|
54
|
+
self.logger.setLevel(logging.INFO)
|
|
55
|
+
# Set up environment directories
|
|
56
|
+
self.environments_dir = environments_dir or (Path.home() / ".hatch" / "envs")
|
|
57
|
+
self.environments_dir.mkdir(exist_ok=True)
|
|
58
|
+
|
|
59
|
+
self.environments_file = self.environments_dir / "environments.json"
|
|
60
|
+
self.current_env_file = self.environments_dir / "current_env"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Initialize Python environment manager
|
|
64
|
+
self.python_env_manager = PythonEnvironmentManager(environments_dir=self.environments_dir)
|
|
65
|
+
|
|
66
|
+
# Initialize dependencies
|
|
67
|
+
self.package_loader = HatchPackageLoader(cache_dir=cache_dir)
|
|
68
|
+
self.retriever = RegistryRetriever(cache_ttl=cache_ttl,
|
|
69
|
+
local_cache_dir=cache_dir,
|
|
70
|
+
simulation_mode=simulation_mode,
|
|
71
|
+
local_registry_cache_path=local_registry_cache_path)
|
|
72
|
+
self.registry_data = self.retriever.get_registry()
|
|
73
|
+
|
|
74
|
+
# Initialize services for dependency management
|
|
75
|
+
self.registry_service = RegistryService(self.registry_data)
|
|
76
|
+
|
|
77
|
+
self.dependency_orchestrator = DependencyInstallerOrchestrator(
|
|
78
|
+
package_loader=self.package_loader,
|
|
79
|
+
registry_service=self.registry_service,
|
|
80
|
+
registry_data=self.registry_data
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Load environments into cache
|
|
84
|
+
self._environments = self._load_environments()
|
|
85
|
+
self._current_env_name = self._load_current_env_name()
|
|
86
|
+
# Set correct Python executable info to the one of default environment
|
|
87
|
+
self._configure_python_executable(self._current_env_name)
|
|
88
|
+
|
|
89
|
+
def _initialize_environments_file(self):
|
|
90
|
+
"""Create the initial environments file with default environment."""
|
|
91
|
+
default_environments = {}
|
|
92
|
+
|
|
93
|
+
with open(self.environments_file, 'w') as f:
|
|
94
|
+
json.dump(default_environments, f, indent=2)
|
|
95
|
+
|
|
96
|
+
self.logger.info("Initialized environments file with default environment")
|
|
97
|
+
|
|
98
|
+
def _initialize_current_env_file(self):
|
|
99
|
+
"""Create the current environment file pointing to the default environment."""
|
|
100
|
+
with open(self.current_env_file, 'w') as f:
|
|
101
|
+
f.write("default")
|
|
102
|
+
|
|
103
|
+
self.logger.info("Initialized current environment to default")
|
|
104
|
+
|
|
105
|
+
def _load_environments(self) -> Dict:
|
|
106
|
+
"""Load environments from the environments file.
|
|
107
|
+
|
|
108
|
+
This method attempts to read the environments from the JSON file.
|
|
109
|
+
If the file is not found or contains invalid JSON, it initializes
|
|
110
|
+
the file with a default environment and returns that.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dict: Dictionary of environments loaded from the file.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with open(self.environments_file, 'r') as f:
|
|
118
|
+
return json.load(f)
|
|
119
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
120
|
+
self.logger.info(f"Failed to load environments: {e}. Initializing with default environment.")
|
|
121
|
+
|
|
122
|
+
# Touch the files with default values
|
|
123
|
+
self._initialize_environments_file()
|
|
124
|
+
self._initialize_current_env_file()
|
|
125
|
+
|
|
126
|
+
# Load created default environment
|
|
127
|
+
with open(self.environments_file, 'r') as f:
|
|
128
|
+
_environments = json.load(f)
|
|
129
|
+
|
|
130
|
+
# Assign to cache
|
|
131
|
+
self._environments = _environments
|
|
132
|
+
|
|
133
|
+
# Actually create the default environment
|
|
134
|
+
self.create_environment("default", description="Default environment")
|
|
135
|
+
|
|
136
|
+
return _environments
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_current_env_name(self) -> str:
|
|
140
|
+
"""Load current environment name from disk."""
|
|
141
|
+
try:
|
|
142
|
+
with open(self.current_env_file, 'r') as f:
|
|
143
|
+
return f.read().strip()
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
self._initialize_current_env_file()
|
|
146
|
+
return "default"
|
|
147
|
+
|
|
148
|
+
def get_environments(self) -> Dict:
|
|
149
|
+
"""Get environments from cache."""
|
|
150
|
+
return self._environments
|
|
151
|
+
|
|
152
|
+
def reload_environments(self):
|
|
153
|
+
"""Reload environments from disk."""
|
|
154
|
+
self._environments = self._load_environments()
|
|
155
|
+
self._current_env_name = self._load_current_env_name()
|
|
156
|
+
self.logger.info("Reloaded environments from disk")
|
|
157
|
+
|
|
158
|
+
def _save_environments(self):
|
|
159
|
+
"""Save environments to the environments file."""
|
|
160
|
+
try:
|
|
161
|
+
with open(self.environments_file, 'w') as f:
|
|
162
|
+
json.dump(self._environments, f, indent=2)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self.logger.error(f"Failed to save environments: {e}")
|
|
165
|
+
raise HatchEnvironmentError(f"Failed to save environments: {e}")
|
|
166
|
+
|
|
167
|
+
def get_current_environment(self) -> str:
|
|
168
|
+
"""Get the name of the current environment from cache."""
|
|
169
|
+
return self._current_env_name
|
|
170
|
+
|
|
171
|
+
def get_current_environment_data(self) -> Dict:
|
|
172
|
+
"""Get the data for the current environment."""
|
|
173
|
+
return self._environments[self._current_env_name]
|
|
174
|
+
|
|
175
|
+
def get_environment_data(self, env_name: str) -> Dict:
|
|
176
|
+
"""Get the data for a specific environment.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
env_name: Name of the environment
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Dict: Environment data
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
KeyError: If environment doesn't exist
|
|
186
|
+
"""
|
|
187
|
+
return self._environments[env_name]
|
|
188
|
+
|
|
189
|
+
def set_current_environment(self, env_name: str) -> bool:
|
|
190
|
+
"""
|
|
191
|
+
Set the current environment.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
env_name: Name of the environment to set as current
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
bool: True if successful, False if environment doesn't exist
|
|
198
|
+
"""
|
|
199
|
+
# Check if environment exists
|
|
200
|
+
if env_name not in self._environments:
|
|
201
|
+
self.logger.error(f"Environment does not exist: {env_name}")
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# Set current environment
|
|
205
|
+
try:
|
|
206
|
+
with open(self.current_env_file, 'w') as f:
|
|
207
|
+
f.write(env_name)
|
|
208
|
+
|
|
209
|
+
# Update cache
|
|
210
|
+
self._current_env_name = env_name
|
|
211
|
+
|
|
212
|
+
# Configure Python executable for dependency installation
|
|
213
|
+
self._configure_python_executable(env_name)
|
|
214
|
+
|
|
215
|
+
self.logger.info(f"Current environment set to: {env_name}")
|
|
216
|
+
return True
|
|
217
|
+
except Exception as e:
|
|
218
|
+
self.logger.error(f"Failed to set current environment: {e}")
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def _configure_python_executable(self, env_name: str) -> None:
|
|
222
|
+
"""Configure the Python executable for the current environment.
|
|
223
|
+
|
|
224
|
+
This method sets the Python executable in the dependency orchestrator's
|
|
225
|
+
InstallationContext so that python_installer.py uses the correct interpreter.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
env_name: Name of the environment to configure Python for
|
|
229
|
+
"""
|
|
230
|
+
# Get Python executable from Python environment manager
|
|
231
|
+
python_executable = self.python_env_manager.get_python_executable(env_name)
|
|
232
|
+
|
|
233
|
+
if python_executable:
|
|
234
|
+
# Configure the dependency orchestrator with the Python executable
|
|
235
|
+
python_env_vars = self.python_env_manager.get_environment_activation_info(env_name)
|
|
236
|
+
self.dependency_orchestrator.set_python_env_vars(python_env_vars)
|
|
237
|
+
else:
|
|
238
|
+
# Use system Python as fallback
|
|
239
|
+
system_python = sys.executable
|
|
240
|
+
python_env_vars = {"PYTHON": system_python}
|
|
241
|
+
self.dependency_orchestrator.set_python_env_vars(python_env_vars)
|
|
242
|
+
|
|
243
|
+
def get_current_python_executable(self) -> Optional[str]:
|
|
244
|
+
"""Get the Python executable for the current environment.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
str: Path to Python executable, None if no current environment or no Python env
|
|
248
|
+
"""
|
|
249
|
+
if not self._current_env_name:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
return self.python_env_manager.get_python_executable(self._current_env_name)
|
|
253
|
+
|
|
254
|
+
def list_environments(self) -> List[Dict]:
|
|
255
|
+
"""
|
|
256
|
+
List all available environments.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List[Dict]: List of environment information dictionaries
|
|
260
|
+
"""
|
|
261
|
+
result = []
|
|
262
|
+
for name, env_data in self._environments.items():
|
|
263
|
+
env_info = env_data.copy()
|
|
264
|
+
env_info["is_current"] = (name == self._current_env_name)
|
|
265
|
+
result.append(env_info)
|
|
266
|
+
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
def create_environment(self, name: str, description: str = "",
|
|
270
|
+
python_version: Optional[str] = None,
|
|
271
|
+
create_python_env: bool = True,
|
|
272
|
+
no_hatch_mcp_server: bool = False,
|
|
273
|
+
hatch_mcp_server_tag: Optional[str] = None) -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Create a new environment.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
name: Name of the environment
|
|
279
|
+
description: Description of the environment
|
|
280
|
+
python_version: Python version for the environment (e.g., "3.11", "3.12")
|
|
281
|
+
create_python_env: Whether to create a Python environment using conda/mamba
|
|
282
|
+
no_hatch_mcp_server: Whether to skip installing hatch_mcp_server in the environment
|
|
283
|
+
hatch_mcp_server_tag: Git tag/branch reference for hatch_mcp_server installation
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
bool: True if created successfully, False if environment already exists
|
|
287
|
+
"""
|
|
288
|
+
# Allow alphanumeric characters and underscores
|
|
289
|
+
if not name or not all(c.isalnum() or c == '_' for c in name):
|
|
290
|
+
self.logger.error("Environment name must be alphanumeric or underscore")
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
# Check if environment already exists
|
|
294
|
+
if name in self._environments:
|
|
295
|
+
self.logger.warning(f"Environment already exists: {name}")
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
# Create Python environment if requested and conda/mamba is available
|
|
299
|
+
python_env_info = None
|
|
300
|
+
if create_python_env and self.python_env_manager.is_available():
|
|
301
|
+
try:
|
|
302
|
+
python_env_created = self.python_env_manager.create_python_environment(
|
|
303
|
+
name, python_version=python_version
|
|
304
|
+
)
|
|
305
|
+
if python_env_created:
|
|
306
|
+
self.logger.info(f"Created Python environment for {name}")
|
|
307
|
+
|
|
308
|
+
# Get detailed Python environment information
|
|
309
|
+
python_info = self.python_env_manager.get_environment_info(name)
|
|
310
|
+
if python_info:
|
|
311
|
+
python_env_info = {
|
|
312
|
+
"enabled": True,
|
|
313
|
+
"conda_env_name": python_info.get("conda_env_name"),
|
|
314
|
+
"python_executable": python_info.get("python_executable"),
|
|
315
|
+
"created_at": datetime.datetime.now().isoformat(),
|
|
316
|
+
"version": python_info.get("python_version"),
|
|
317
|
+
"requested_version": python_version,
|
|
318
|
+
"manager": python_info.get("manager", "conda")
|
|
319
|
+
}
|
|
320
|
+
else:
|
|
321
|
+
# Fallback if detailed info is not available
|
|
322
|
+
python_env_info = {
|
|
323
|
+
"enabled": True,
|
|
324
|
+
"conda_env_name": f"hatch_{name}",
|
|
325
|
+
"python_executable": None,
|
|
326
|
+
"created_at": datetime.datetime.now().isoformat(),
|
|
327
|
+
"version": None,
|
|
328
|
+
"requested_version": python_version,
|
|
329
|
+
"manager": "conda"
|
|
330
|
+
}
|
|
331
|
+
else:
|
|
332
|
+
self.logger.warning(f"Failed to create Python environment for {name}")
|
|
333
|
+
except PythonEnvironmentError as e:
|
|
334
|
+
self.logger.error(f"Failed to create Python environment: {e}")
|
|
335
|
+
# Continue with Hatch environment creation even if Python env creation fails
|
|
336
|
+
elif create_python_env:
|
|
337
|
+
self.logger.warning("Python environment creation requested but conda/mamba not available")
|
|
338
|
+
|
|
339
|
+
# Create new Hatch environment with enhanced metadata
|
|
340
|
+
env_data = {
|
|
341
|
+
"name": name,
|
|
342
|
+
"description": description,
|
|
343
|
+
"created_at": datetime.datetime.now().isoformat(),
|
|
344
|
+
"packages": [],
|
|
345
|
+
"python_environment": python_env_info is not None, # Legacy field for backward compatibility
|
|
346
|
+
"python_version": python_version, # Legacy field for backward compatibility
|
|
347
|
+
"python_env": python_env_info # Enhanced metadata structure
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
self._environments[name] = env_data
|
|
351
|
+
|
|
352
|
+
self._save_environments()
|
|
353
|
+
self.logger.info(f"Created environment: {name}")
|
|
354
|
+
|
|
355
|
+
# Install hatch_mcp_server by default unless opted out
|
|
356
|
+
if not no_hatch_mcp_server and python_env_info is not None:
|
|
357
|
+
try:
|
|
358
|
+
self._install_hatch_mcp_server(name, hatch_mcp_server_tag)
|
|
359
|
+
except Exception as e:
|
|
360
|
+
self.logger.warning(f"Failed to install hatch_mcp_server wrapper in environment {name}: {e}")
|
|
361
|
+
# Don't fail environment creation if MCP wrapper installation fails
|
|
362
|
+
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
def _install_hatch_mcp_server(self, env_name: str, tag: Optional[str] = None) -> None:
|
|
366
|
+
"""Install hatch_mcp_server wrapper package in the specified environment.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
env_name (str): Name of the environment to install MCP wrapper in.
|
|
370
|
+
tag (str, optional): Git tag/branch reference for the installation. Defaults to None (uses default branch).
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
HatchEnvironmentError: If installation fails.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
# Construct the package URL with optional tag
|
|
377
|
+
if tag:
|
|
378
|
+
package_git_url = f"git+https://github.com/CrackingShells/Hatch-MCP-Server.git@{tag}"
|
|
379
|
+
else:
|
|
380
|
+
package_git_url = "git+https://github.com/CrackingShells/Hatch-MCP-Server.git"
|
|
381
|
+
|
|
382
|
+
# Create dependency structure following the schema
|
|
383
|
+
mcp_dep = {
|
|
384
|
+
"name": f"hatch_mcp_server @ {package_git_url}",
|
|
385
|
+
"version_constraint": "*",
|
|
386
|
+
"package_manager": "pip",
|
|
387
|
+
"type": "python",
|
|
388
|
+
"uri": package_git_url
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
# Get environment path
|
|
392
|
+
env_path = self.get_environment_path(env_name)
|
|
393
|
+
|
|
394
|
+
# Create installation context
|
|
395
|
+
context = InstallationContext(
|
|
396
|
+
environment_path=env_path,
|
|
397
|
+
environment_name=env_name,
|
|
398
|
+
temp_dir=env_path / ".tmp",
|
|
399
|
+
cache_dir=self.package_loader.cache_dir if hasattr(self.package_loader, 'cache_dir') else None,
|
|
400
|
+
parallel_enabled=False,
|
|
401
|
+
force_reinstall=False,
|
|
402
|
+
simulation_mode=False,
|
|
403
|
+
extra_config={
|
|
404
|
+
"package_loader": self.package_loader,
|
|
405
|
+
"registry_service": self.registry_service,
|
|
406
|
+
"registry_data": self.registry_data
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Configure Python environment variables if available
|
|
411
|
+
python_executable = self.python_env_manager.get_python_executable(env_name)
|
|
412
|
+
if python_executable:
|
|
413
|
+
python_env_vars = {"PYTHON": python_executable}
|
|
414
|
+
self.dependency_orchestrator.set_python_env_vars(python_env_vars)
|
|
415
|
+
context.set_config("python_env_vars", python_env_vars)
|
|
416
|
+
|
|
417
|
+
# Install using the orchestrator
|
|
418
|
+
self.logger.info(f"Installing hatch_mcp_server wrapper in environment {env_name}")
|
|
419
|
+
self.logger.info(f"Using python executable: {python_executable}")
|
|
420
|
+
installed_package = self.dependency_orchestrator.install_single_dep(mcp_dep, context)
|
|
421
|
+
|
|
422
|
+
self._save_environments()
|
|
423
|
+
self.logger.info(f"Successfully installed hatch_mcp_server wrapper in environment {env_name}")
|
|
424
|
+
|
|
425
|
+
except Exception as e:
|
|
426
|
+
self.logger.error(f"Failed to install hatch_mcp_server wrapper: {e}")
|
|
427
|
+
raise HatchEnvironmentError(f"Failed to install hatch_mcp_server wrapper: {e}") from e
|
|
428
|
+
|
|
429
|
+
def install_mcp_server(self, env_name: Optional[str] = None, tag: Optional[str] = None) -> bool:
|
|
430
|
+
"""Install hatch_mcp_server wrapper package in an existing environment.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
env_name (str, optional): Name of the hatch environment. Uses current environment if None.
|
|
434
|
+
tag (str, optional): Git tag/branch reference for the installation. Defaults to None (uses default branch).
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
bool: True if installation succeeded, False otherwise.
|
|
438
|
+
"""
|
|
439
|
+
if env_name is None:
|
|
440
|
+
env_name = self._current_env_name
|
|
441
|
+
|
|
442
|
+
if not self.environment_exists(env_name):
|
|
443
|
+
self.logger.error(f"Environment does not exist: {env_name}")
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
# Check if environment has Python support
|
|
447
|
+
env_data = self._environments[env_name]
|
|
448
|
+
if not env_data.get("python_env"):
|
|
449
|
+
self.logger.error(f"Environment {env_name} does not have Python support")
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
self._install_hatch_mcp_server(env_name, tag)
|
|
454
|
+
return True
|
|
455
|
+
except Exception as e:
|
|
456
|
+
self.logger.error(f"Failed to install MCP wrapper in environment {env_name}: {e}")
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
def remove_environment(self, name: str) -> bool:
|
|
460
|
+
"""
|
|
461
|
+
Remove an environment.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
name: Name of the environment to remove
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
bool: True if removed successfully, False otherwise
|
|
468
|
+
"""
|
|
469
|
+
# Cannot remove default environment
|
|
470
|
+
if name == "default":
|
|
471
|
+
self.logger.error("Cannot remove default environment")
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
# Check if environment exists
|
|
475
|
+
if name not in self._environments:
|
|
476
|
+
self.logger.warning(f"Environment does not exist: {name}")
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
# If removing current environment, switch to default
|
|
480
|
+
if name == self._current_env_name:
|
|
481
|
+
self.set_current_environment("default")
|
|
482
|
+
|
|
483
|
+
# Clean up MCP server configurations for all packages in this environment
|
|
484
|
+
env_data = self._environments[name]
|
|
485
|
+
packages = env_data.get("packages", [])
|
|
486
|
+
if packages:
|
|
487
|
+
self.logger.info(f"Cleaning up MCP server configurations for {len(packages)} packages in environment {name}")
|
|
488
|
+
try:
|
|
489
|
+
from .mcp_host_config.host_management import MCPHostConfigurationManager
|
|
490
|
+
mcp_manager = MCPHostConfigurationManager()
|
|
491
|
+
|
|
492
|
+
for pkg in packages:
|
|
493
|
+
package_name = pkg.get("name")
|
|
494
|
+
configured_hosts = pkg.get("configured_hosts", {})
|
|
495
|
+
|
|
496
|
+
if configured_hosts and package_name:
|
|
497
|
+
for hostname in configured_hosts.keys():
|
|
498
|
+
try:
|
|
499
|
+
# Remove server from host configuration file
|
|
500
|
+
result = mcp_manager.remove_server(
|
|
501
|
+
server_name=package_name, # In current 1:1 design, package name = server name
|
|
502
|
+
hostname=hostname,
|
|
503
|
+
no_backup=False # Create backup for safety
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if result.success:
|
|
507
|
+
self.logger.info(f"Removed MCP server '{package_name}' from host '{hostname}' (env removal)")
|
|
508
|
+
else:
|
|
509
|
+
self.logger.warning(f"Failed to remove MCP server '{package_name}' from host '{hostname}': {result.error_message}")
|
|
510
|
+
except Exception as e:
|
|
511
|
+
self.logger.warning(f"Error removing MCP server '{package_name}' from host '{hostname}': {e}")
|
|
512
|
+
|
|
513
|
+
except ImportError:
|
|
514
|
+
self.logger.warning("MCP host configuration manager not available for cleanup")
|
|
515
|
+
except Exception as e:
|
|
516
|
+
self.logger.warning(f"Error during MCP server cleanup for environment removal: {e}")
|
|
517
|
+
|
|
518
|
+
# Remove Python environment if it exists
|
|
519
|
+
if env_data.get("python_environment", False):
|
|
520
|
+
try:
|
|
521
|
+
self.python_env_manager.remove_python_environment(name)
|
|
522
|
+
self.logger.info(f"Removed Python environment for {name}")
|
|
523
|
+
except PythonEnvironmentError as e:
|
|
524
|
+
self.logger.warning(f"Failed to remove Python environment: {e}")
|
|
525
|
+
|
|
526
|
+
# Remove environment
|
|
527
|
+
del self._environments[name]
|
|
528
|
+
|
|
529
|
+
# Save environments and update cache
|
|
530
|
+
self._save_environments()
|
|
531
|
+
self.logger.info(f"Removed environment: {name}")
|
|
532
|
+
return True
|
|
533
|
+
|
|
534
|
+
def environment_exists(self, name: str) -> bool:
|
|
535
|
+
"""
|
|
536
|
+
Check if an environment exists.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
name: Name of the environment to check
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
bool: True if environment exists, False otherwise
|
|
543
|
+
"""
|
|
544
|
+
return name in self._environments
|
|
545
|
+
|
|
546
|
+
def add_package_to_environment(self, package_path_or_name: str,
|
|
547
|
+
env_name: Optional[str] = None,
|
|
548
|
+
version_constraint: Optional[str] = None,
|
|
549
|
+
force_download: bool = False,
|
|
550
|
+
refresh_registry: bool = False,
|
|
551
|
+
auto_approve: bool = False) -> bool:
|
|
552
|
+
"""Add a package to an environment.
|
|
553
|
+
|
|
554
|
+
This method delegates all installation orchestration to the DependencyInstallerOrchestrator
|
|
555
|
+
while maintaining responsibility for environment lifecycle and state management.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
package_path_or_name (str): Path to local package or name of remote package.
|
|
559
|
+
env_name (str, optional): Environment to add to. Defaults to current environment.
|
|
560
|
+
version_constraint (str, optional): Version constraint for remote packages. Defaults to None.
|
|
561
|
+
force_download (bool, optional): Force download even if package is cached. When True,
|
|
562
|
+
bypass the package cache and download directly from the source. Defaults to False.
|
|
563
|
+
refresh_registry (bool, optional): Force refresh of registry data. When True,
|
|
564
|
+
fetch the latest registry data before resolving dependencies. Defaults to False.
|
|
565
|
+
auto_approve (bool, optional): Skip user consent prompt for automation scenarios. Defaults to False.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
bool: True if successful, False otherwise.
|
|
569
|
+
"""
|
|
570
|
+
env_name = env_name or self._current_env_name
|
|
571
|
+
|
|
572
|
+
if not self.environment_exists(env_name):
|
|
573
|
+
self.logger.error(f"Environment {env_name} does not exist")
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
# Refresh registry if requested
|
|
577
|
+
if refresh_registry:
|
|
578
|
+
self.refresh_registry(force_refresh=True)
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
# Get currently installed packages for filtering
|
|
582
|
+
existing_packages = {}
|
|
583
|
+
for pkg in self._environments[env_name].get("packages", []):
|
|
584
|
+
existing_packages[pkg["name"]] = pkg["version"]
|
|
585
|
+
|
|
586
|
+
# Delegate installation to orchestrator
|
|
587
|
+
success, installed_packages = self.dependency_orchestrator.install_dependencies(
|
|
588
|
+
package_path_or_name=package_path_or_name,
|
|
589
|
+
env_path=self.get_environment_path(env_name),
|
|
590
|
+
env_name=env_name,
|
|
591
|
+
existing_packages=existing_packages,
|
|
592
|
+
version_constraint=version_constraint,
|
|
593
|
+
force_download=force_download,
|
|
594
|
+
auto_approve=auto_approve
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if success:
|
|
598
|
+
# Update environment metadata with installed Hatch packages
|
|
599
|
+
for pkg_info in installed_packages:
|
|
600
|
+
if pkg_info["type"] == "hatch":
|
|
601
|
+
self._add_package_to_env_data(
|
|
602
|
+
env_name=env_name,
|
|
603
|
+
package_name=pkg_info["name"],
|
|
604
|
+
package_version=pkg_info["version"],
|
|
605
|
+
package_type=pkg_info["type"],
|
|
606
|
+
source=pkg_info["source"]
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
self.logger.info(f"Successfully installed {len(installed_packages)} packages to environment {env_name}")
|
|
610
|
+
return True
|
|
611
|
+
else:
|
|
612
|
+
self.logger.info("Package installation was cancelled or failed")
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
except Exception as e:
|
|
616
|
+
self.logger.error(f"Failed to add package to environment: {e}")
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
def _add_package_to_env_data(self, env_name: str, package_name: str,
|
|
620
|
+
package_version: str, package_type: str,
|
|
621
|
+
source: str) -> None:
|
|
622
|
+
"""Update environment data with package information."""
|
|
623
|
+
if env_name not in self._environments:
|
|
624
|
+
raise HatchEnvironmentError(f"Environment {env_name} does not exist")
|
|
625
|
+
|
|
626
|
+
# Check if package already exists
|
|
627
|
+
for i, pkg in enumerate(self._environments[env_name].get("packages", [])):
|
|
628
|
+
if pkg.get("name") == package_name:
|
|
629
|
+
# Replace existing package entry
|
|
630
|
+
self._environments[env_name]["packages"][i] = {
|
|
631
|
+
"name": package_name,
|
|
632
|
+
"version": package_version,
|
|
633
|
+
"type": package_type,
|
|
634
|
+
"source": source,
|
|
635
|
+
"installed_at": datetime.datetime.now().isoformat()
|
|
636
|
+
}
|
|
637
|
+
self._save_environments()
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
# if it doesn't exist add new package entry
|
|
641
|
+
self._environments[env_name]["packages"] += [{
|
|
642
|
+
"name": package_name,
|
|
643
|
+
"version": package_version,
|
|
644
|
+
"type": package_type,
|
|
645
|
+
"source": source,
|
|
646
|
+
"installed_at": datetime.datetime.now().isoformat()
|
|
647
|
+
}]
|
|
648
|
+
|
|
649
|
+
self._save_environments()
|
|
650
|
+
|
|
651
|
+
def update_package_host_configuration(self, env_name: str, package_name: str,
|
|
652
|
+
hostname: str, server_config: dict) -> bool:
|
|
653
|
+
"""Update package metadata with host configuration tracking.
|
|
654
|
+
|
|
655
|
+
Enforces constraint: Only one environment can control a package-host combination.
|
|
656
|
+
Automatically cleans up conflicting configurations from other environments.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
env_name (str): Environment name
|
|
660
|
+
package_name (str): Package name
|
|
661
|
+
hostname (str): Host identifier (e.g., 'gemini', 'claude-desktop')
|
|
662
|
+
server_config (dict): Server configuration data
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
bool: True if update successful, False otherwise
|
|
666
|
+
"""
|
|
667
|
+
try:
|
|
668
|
+
if env_name not in self._environments:
|
|
669
|
+
self.logger.error(f"Environment {env_name} does not exist")
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
# Step 1: Clean up conflicting configurations from other environments
|
|
673
|
+
conflicts_removed = self._cleanup_package_host_conflicts(
|
|
674
|
+
target_env=env_name,
|
|
675
|
+
package_name=package_name,
|
|
676
|
+
hostname=hostname
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# Step 2: Update target environment configuration
|
|
680
|
+
success = self._update_target_environment_configuration(
|
|
681
|
+
env_name, package_name, hostname, server_config
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# Step 3: User notification for conflict resolution
|
|
685
|
+
if conflicts_removed > 0 and success:
|
|
686
|
+
self.logger.warning(
|
|
687
|
+
f"Package '{package_name}' host configuration for '{hostname}' "
|
|
688
|
+
f"transferred from {conflicts_removed} other environment(s) to '{env_name}'"
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
return success
|
|
692
|
+
|
|
693
|
+
except Exception as e:
|
|
694
|
+
self.logger.error(f"Failed to update package host configuration: {e}")
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
def _cleanup_package_host_conflicts(self, target_env: str, package_name: str, hostname: str) -> int:
|
|
698
|
+
"""Remove conflicting package-host configurations from other environments.
|
|
699
|
+
|
|
700
|
+
This method enforces the constraint that only one environment can control
|
|
701
|
+
a package-host combination by removing conflicting configurations from
|
|
702
|
+
all environments except the target environment.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
target_env (str): Environment that should control the configuration
|
|
706
|
+
package_name (str): Package name
|
|
707
|
+
hostname (str): Host identifier
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
int: Number of conflicting configurations removed
|
|
711
|
+
"""
|
|
712
|
+
conflicts_removed = 0
|
|
713
|
+
|
|
714
|
+
for env_name, env_data in self._environments.items():
|
|
715
|
+
if env_name == target_env:
|
|
716
|
+
continue # Skip target environment
|
|
717
|
+
|
|
718
|
+
packages = env_data.get("packages", [])
|
|
719
|
+
for i, pkg in enumerate(packages):
|
|
720
|
+
if pkg.get("name") == package_name:
|
|
721
|
+
configured_hosts = pkg.get("configured_hosts", {})
|
|
722
|
+
if hostname in configured_hosts:
|
|
723
|
+
# Remove the conflicting host configuration
|
|
724
|
+
del configured_hosts[hostname]
|
|
725
|
+
conflicts_removed += 1
|
|
726
|
+
|
|
727
|
+
# Update package metadata
|
|
728
|
+
pkg["configured_hosts"] = configured_hosts
|
|
729
|
+
self._environments[env_name]["packages"][i] = pkg
|
|
730
|
+
|
|
731
|
+
self.logger.info(
|
|
732
|
+
f"Removed conflicting '{hostname}' configuration for package '{package_name}' "
|
|
733
|
+
f"from environment '{env_name}'"
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
if conflicts_removed > 0:
|
|
737
|
+
self._save_environments()
|
|
738
|
+
|
|
739
|
+
return conflicts_removed
|
|
740
|
+
|
|
741
|
+
def _update_target_environment_configuration(self, env_name: str, package_name: str,
|
|
742
|
+
hostname: str, server_config: dict) -> bool:
|
|
743
|
+
"""Update the target environment's package host configuration.
|
|
744
|
+
|
|
745
|
+
This method handles the actual configuration update for the target environment
|
|
746
|
+
after conflicts have been cleaned up.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
env_name (str): Environment name
|
|
750
|
+
package_name (str): Package name
|
|
751
|
+
hostname (str): Host identifier
|
|
752
|
+
server_config (dict): Server configuration data
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
bool: True if update successful, False otherwise
|
|
756
|
+
"""
|
|
757
|
+
# Find the package in the environment
|
|
758
|
+
packages = self._environments[env_name].get("packages", [])
|
|
759
|
+
for i, pkg in enumerate(packages):
|
|
760
|
+
if pkg.get("name") == package_name:
|
|
761
|
+
# Initialize configured_hosts if it doesn't exist
|
|
762
|
+
if "configured_hosts" not in pkg:
|
|
763
|
+
pkg["configured_hosts"] = {}
|
|
764
|
+
|
|
765
|
+
# Add or update host configuration
|
|
766
|
+
from datetime import datetime
|
|
767
|
+
pkg["configured_hosts"][hostname] = {
|
|
768
|
+
"config_path": self._get_host_config_path(hostname),
|
|
769
|
+
"configured_at": datetime.now().isoformat(),
|
|
770
|
+
"last_synced": datetime.now().isoformat(),
|
|
771
|
+
"server_config": server_config
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
# Update the package in the environment
|
|
775
|
+
self._environments[env_name]["packages"][i] = pkg
|
|
776
|
+
self._save_environments()
|
|
777
|
+
|
|
778
|
+
self.logger.info(f"Updated host configuration for package {package_name} on {hostname}")
|
|
779
|
+
return True
|
|
780
|
+
|
|
781
|
+
self.logger.error(f"Package {package_name} not found in environment {env_name}")
|
|
782
|
+
return False
|
|
783
|
+
|
|
784
|
+
def remove_package_host_configuration(self, env_name: str, package_name: str, hostname: str) -> bool:
|
|
785
|
+
"""Remove host configuration tracking for a specific package.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
env_name: Environment name
|
|
789
|
+
package_name: Package name (maps to server name in current 1:1 design)
|
|
790
|
+
hostname: Host identifier to remove
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
bool: True if removal occurred, False if package/host not found
|
|
794
|
+
"""
|
|
795
|
+
try:
|
|
796
|
+
if env_name not in self._environments:
|
|
797
|
+
self.logger.warning(f"Environment {env_name} does not exist")
|
|
798
|
+
return False
|
|
799
|
+
|
|
800
|
+
packages = self._environments[env_name].get("packages", [])
|
|
801
|
+
for pkg in packages:
|
|
802
|
+
if pkg.get("name") == package_name:
|
|
803
|
+
configured_hosts = pkg.get("configured_hosts", {})
|
|
804
|
+
if hostname in configured_hosts:
|
|
805
|
+
del configured_hosts[hostname]
|
|
806
|
+
self._save_environments()
|
|
807
|
+
self.logger.info(f"Removed host {hostname} from package {package_name} in env {env_name}")
|
|
808
|
+
return True
|
|
809
|
+
|
|
810
|
+
return False
|
|
811
|
+
|
|
812
|
+
except Exception as e:
|
|
813
|
+
self.logger.error(f"Failed to remove package host configuration: {e}")
|
|
814
|
+
return False
|
|
815
|
+
|
|
816
|
+
def clear_host_from_all_packages_all_envs(self, hostname: str) -> int:
|
|
817
|
+
"""Remove host from all packages across all environments.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
hostname: Host identifier to remove globally
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
int: Number of package entries updated
|
|
824
|
+
"""
|
|
825
|
+
updates_count = 0
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
for env_name, env_data in self._environments.items():
|
|
829
|
+
packages = env_data.get("packages", [])
|
|
830
|
+
for pkg in packages:
|
|
831
|
+
configured_hosts = pkg.get("configured_hosts", {})
|
|
832
|
+
if hostname in configured_hosts:
|
|
833
|
+
del configured_hosts[hostname]
|
|
834
|
+
updates_count += 1
|
|
835
|
+
self.logger.info(f"Removed host {hostname} from package {pkg.get('name')} in env {env_name}")
|
|
836
|
+
|
|
837
|
+
if updates_count > 0:
|
|
838
|
+
self._save_environments()
|
|
839
|
+
|
|
840
|
+
return updates_count
|
|
841
|
+
|
|
842
|
+
except Exception as e:
|
|
843
|
+
self.logger.error(f"Failed to clear host from all packages: {e}")
|
|
844
|
+
return 0
|
|
845
|
+
|
|
846
|
+
def apply_restored_host_configuration_to_environments(self, hostname: str, restored_servers: Dict[str, MCPServerConfig]) -> int:
|
|
847
|
+
"""Update environment tracking to match restored host configuration.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
hostname: Host that was restored
|
|
851
|
+
restored_servers: Dict mapping server_name -> server_config from restored host file
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
int: Number of package entries updated across all environments
|
|
855
|
+
"""
|
|
856
|
+
updates_count = 0
|
|
857
|
+
|
|
858
|
+
try:
|
|
859
|
+
from datetime import datetime
|
|
860
|
+
current_time = datetime.now().isoformat()
|
|
861
|
+
|
|
862
|
+
for env_name, env_data in self._environments.items():
|
|
863
|
+
packages = env_data.get("packages", [])
|
|
864
|
+
for pkg in packages:
|
|
865
|
+
package_name = pkg.get("name")
|
|
866
|
+
configured_hosts = pkg.get("configured_hosts", {})
|
|
867
|
+
|
|
868
|
+
# Check if this package corresponds to a restored server
|
|
869
|
+
if package_name in restored_servers:
|
|
870
|
+
# Server exists in restored config - ensure tracking exists and is current
|
|
871
|
+
server_config = restored_servers[package_name]
|
|
872
|
+
configured_hosts[hostname] = {
|
|
873
|
+
"config_path": self._get_host_config_path(hostname),
|
|
874
|
+
"configured_at": configured_hosts.get(hostname, {}).get("configured_at", current_time),
|
|
875
|
+
"last_synced": current_time,
|
|
876
|
+
"server_config": server_config.model_dump(exclude_none=True)
|
|
877
|
+
}
|
|
878
|
+
updates_count += 1
|
|
879
|
+
self.logger.info(f"Updated host {hostname} tracking for package {package_name} in env {env_name}")
|
|
880
|
+
|
|
881
|
+
elif hostname in configured_hosts:
|
|
882
|
+
# Server not in restored config but was previously tracked - remove stale tracking
|
|
883
|
+
del configured_hosts[hostname]
|
|
884
|
+
updates_count += 1
|
|
885
|
+
self.logger.info(f"Removed stale host {hostname} tracking for package {package_name} in env {env_name}")
|
|
886
|
+
|
|
887
|
+
if updates_count > 0:
|
|
888
|
+
self._save_environments()
|
|
889
|
+
|
|
890
|
+
return updates_count
|
|
891
|
+
|
|
892
|
+
except Exception as e:
|
|
893
|
+
self.logger.error(f"Failed to apply restored host configuration: {e}")
|
|
894
|
+
return 0
|
|
895
|
+
|
|
896
|
+
def _get_host_config_path(self, hostname: str) -> str:
|
|
897
|
+
"""Get configuration file path for a host.
|
|
898
|
+
|
|
899
|
+
Args:
|
|
900
|
+
hostname (str): Host identifier
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
str: Configuration file path
|
|
904
|
+
"""
|
|
905
|
+
# Map hostnames to their typical config paths
|
|
906
|
+
host_config_paths = {
|
|
907
|
+
'gemini': '~/.gemini/settings.json',
|
|
908
|
+
'claude-desktop': '~/.claude/claude_desktop_config.json',
|
|
909
|
+
'claude-code': '.claude/mcp_config.json',
|
|
910
|
+
'vscode': '.vscode/settings.json',
|
|
911
|
+
'cursor': '~/.cursor/mcp.json',
|
|
912
|
+
'lmstudio': '~/.lmstudio/mcp.json'
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return host_config_paths.get(hostname, f'~/.{hostname}/config.json')
|
|
916
|
+
|
|
917
|
+
def get_environment_path(self, env_name: str) -> Path:
|
|
918
|
+
"""
|
|
919
|
+
Get the path to the environment directory.
|
|
920
|
+
|
|
921
|
+
Args:
|
|
922
|
+
env_name: Name of the environment
|
|
923
|
+
|
|
924
|
+
Returns:
|
|
925
|
+
Path: Path to the environment directory
|
|
926
|
+
|
|
927
|
+
Raises:
|
|
928
|
+
HatchEnvironmentError: If environment doesn't exist
|
|
929
|
+
"""
|
|
930
|
+
if not self.environment_exists(env_name):
|
|
931
|
+
raise HatchEnvironmentError(f"Environment {env_name} does not exist")
|
|
932
|
+
|
|
933
|
+
env_path = self.environments_dir / env_name
|
|
934
|
+
env_path.mkdir(exist_ok=True)
|
|
935
|
+
return env_path
|
|
936
|
+
|
|
937
|
+
def list_packages(self, env_name: Optional[str] = None) -> List[Dict]:
|
|
938
|
+
"""
|
|
939
|
+
List all packages installed in an environment.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
env_name: Name of the environment (uses current if None)
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
List[Dict]: List of package information dictionaries
|
|
946
|
+
|
|
947
|
+
Raises:
|
|
948
|
+
HatchEnvironmentError: If environment doesn't exist
|
|
949
|
+
"""
|
|
950
|
+
env_name = env_name or self._current_env_name
|
|
951
|
+
if not self.environment_exists(env_name):
|
|
952
|
+
raise HatchEnvironmentError(f"Environment {env_name} does not exist")
|
|
953
|
+
|
|
954
|
+
packages = []
|
|
955
|
+
for pkg in self._environments[env_name].get("packages", []):
|
|
956
|
+
# Add full package info including paths
|
|
957
|
+
pkg_info = pkg.copy()
|
|
958
|
+
pkg_info["path"] = str(self.get_environment_path(env_name) / pkg["name"])
|
|
959
|
+
# Check if the package is Hatch compliant (has hatch_metadata.json)
|
|
960
|
+
pkg_path = self.get_environment_path(env_name) / pkg["name"]
|
|
961
|
+
pkg_info["hatch_compliant"] = (pkg_path / "hatch_metadata.json").exists()
|
|
962
|
+
|
|
963
|
+
# Add source information
|
|
964
|
+
pkg_info["source"] = {
|
|
965
|
+
"uri": pkg.get("source", "unknown"),
|
|
966
|
+
"path": str(pkg_path)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
packages.append(pkg_info)
|
|
970
|
+
|
|
971
|
+
return packages
|
|
972
|
+
|
|
973
|
+
def remove_package(self, package_name: str, env_name: Optional[str] = None) -> bool:
|
|
974
|
+
"""
|
|
975
|
+
Remove a package from an environment.
|
|
976
|
+
|
|
977
|
+
Args:
|
|
978
|
+
package_name: Name of the package to remove
|
|
979
|
+
env_name: Environment to remove from (uses current if None)
|
|
980
|
+
|
|
981
|
+
Returns:
|
|
982
|
+
bool: True if successful, False otherwise
|
|
983
|
+
"""
|
|
984
|
+
env_name = env_name or self._current_env_name
|
|
985
|
+
if not self.environment_exists(env_name):
|
|
986
|
+
self.logger.error(f"Environment {env_name} does not exist")
|
|
987
|
+
return False
|
|
988
|
+
|
|
989
|
+
# Check if package exists in environment
|
|
990
|
+
env_packages = self._environments[env_name].get("packages", [])
|
|
991
|
+
pkg_index = None
|
|
992
|
+
package_to_remove = None
|
|
993
|
+
for i, pkg in enumerate(env_packages):
|
|
994
|
+
if pkg.get("name") == package_name:
|
|
995
|
+
pkg_index = i
|
|
996
|
+
package_to_remove = pkg
|
|
997
|
+
break
|
|
998
|
+
|
|
999
|
+
if pkg_index is None:
|
|
1000
|
+
self.logger.warning(f"Package {package_name} not found in environment {env_name}")
|
|
1001
|
+
return False
|
|
1002
|
+
|
|
1003
|
+
# Clean up MCP server configurations from all configured hosts
|
|
1004
|
+
configured_hosts = package_to_remove.get("configured_hosts", {})
|
|
1005
|
+
if configured_hosts:
|
|
1006
|
+
self.logger.info(f"Cleaning up MCP server configurations for package {package_name}")
|
|
1007
|
+
try:
|
|
1008
|
+
from .mcp_host_config.host_management import MCPHostConfigurationManager
|
|
1009
|
+
mcp_manager = MCPHostConfigurationManager()
|
|
1010
|
+
|
|
1011
|
+
for hostname in configured_hosts.keys():
|
|
1012
|
+
try:
|
|
1013
|
+
# Remove server from host configuration file
|
|
1014
|
+
result = mcp_manager.remove_server(
|
|
1015
|
+
server_name=package_name, # In current 1:1 design, package name = server name
|
|
1016
|
+
hostname=hostname,
|
|
1017
|
+
no_backup=False # Create backup for safety
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
if result.success:
|
|
1021
|
+
self.logger.info(f"Removed MCP server '{package_name}' from host '{hostname}'")
|
|
1022
|
+
else:
|
|
1023
|
+
self.logger.warning(f"Failed to remove MCP server '{package_name}' from host '{hostname}': {result.error_message}")
|
|
1024
|
+
except Exception as e:
|
|
1025
|
+
self.logger.warning(f"Error removing MCP server '{package_name}' from host '{hostname}': {e}")
|
|
1026
|
+
|
|
1027
|
+
except ImportError:
|
|
1028
|
+
self.logger.warning("MCP host configuration manager not available for cleanup")
|
|
1029
|
+
except Exception as e:
|
|
1030
|
+
self.logger.warning(f"Error during MCP server cleanup: {e}")
|
|
1031
|
+
|
|
1032
|
+
# Remove package from filesystem
|
|
1033
|
+
pkg_path = self.get_environment_path(env_name) / package_name
|
|
1034
|
+
try:
|
|
1035
|
+
import shutil
|
|
1036
|
+
if pkg_path.exists():
|
|
1037
|
+
shutil.rmtree(pkg_path)
|
|
1038
|
+
except Exception as e:
|
|
1039
|
+
self.logger.error(f"Failed to remove package files for {package_name}: {e}")
|
|
1040
|
+
return False
|
|
1041
|
+
|
|
1042
|
+
# Remove package from environment data
|
|
1043
|
+
env_packages.pop(pkg_index)
|
|
1044
|
+
self._save_environments()
|
|
1045
|
+
|
|
1046
|
+
self.logger.info(f"Removed package {package_name} from environment {env_name}")
|
|
1047
|
+
return True
|
|
1048
|
+
|
|
1049
|
+
def get_servers_entry_points(self, env_name: Optional[str] = None) -> List[str]:
|
|
1050
|
+
"""
|
|
1051
|
+
Get the list of entry points for the MCP servers of each package in an environment.
|
|
1052
|
+
|
|
1053
|
+
Args:
|
|
1054
|
+
env_name: Environment to get servers from (uses current if None)
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
List[str]: List of server entry points
|
|
1058
|
+
"""
|
|
1059
|
+
env_name = env_name or self._current_env_name
|
|
1060
|
+
if not self.environment_exists(env_name):
|
|
1061
|
+
raise HatchEnvironmentError(f"Environment {env_name} does not exist")
|
|
1062
|
+
|
|
1063
|
+
ep = []
|
|
1064
|
+
for pkg in self._environments[env_name].get("packages", []):
|
|
1065
|
+
# Open the package's metadata file
|
|
1066
|
+
with open(self.environments_dir / env_name / pkg["name"] / "hatch_metadata.json", 'r') as f:
|
|
1067
|
+
hatch_metadata = json.load(f)
|
|
1068
|
+
|
|
1069
|
+
package_service = PackageService(hatch_metadata)
|
|
1070
|
+
|
|
1071
|
+
# retrieve entry points
|
|
1072
|
+
ep += [(self.environments_dir / env_name / pkg["name"] / package_service.get_hatch_mcp_entry_point()).resolve()]
|
|
1073
|
+
|
|
1074
|
+
return ep
|
|
1075
|
+
|
|
1076
|
+
def refresh_registry(self, force_refresh: bool = True) -> None:
|
|
1077
|
+
"""Refresh the registry data from the source.
|
|
1078
|
+
|
|
1079
|
+
This method forces a refresh of the registry data to ensure the environment manager
|
|
1080
|
+
has the most recent package information available. After refreshing, it updates the
|
|
1081
|
+
orchestrator and associated services to use the new registry data.
|
|
1082
|
+
|
|
1083
|
+
Args:
|
|
1084
|
+
force_refresh (bool, optional): Force refresh the registry even if cache is valid.
|
|
1085
|
+
When True, bypasses all caching mechanisms and fetches directly from source.
|
|
1086
|
+
Defaults to True.
|
|
1087
|
+
|
|
1088
|
+
Raises:
|
|
1089
|
+
Exception: If fetching the registry data fails for any reason.
|
|
1090
|
+
"""
|
|
1091
|
+
self.logger.info("Refreshing registry data...")
|
|
1092
|
+
try:
|
|
1093
|
+
self.registry_data = self.retriever.get_registry(force_refresh=force_refresh)
|
|
1094
|
+
# Update registry service with new registry data
|
|
1095
|
+
self.registry_service = RegistryService(self.registry_data)
|
|
1096
|
+
|
|
1097
|
+
# Update orchestrator with new registry data
|
|
1098
|
+
self.dependency_orchestrator.registry_service = self.registry_service
|
|
1099
|
+
self.dependency_orchestrator.registry_data = self.registry_data
|
|
1100
|
+
|
|
1101
|
+
self.logger.info("Registry data refreshed successfully")
|
|
1102
|
+
except Exception as e:
|
|
1103
|
+
self.logger.error(f"Failed to refresh registry data: {e}")
|
|
1104
|
+
raise
|
|
1105
|
+
|
|
1106
|
+
def is_python_environment_available(self) -> bool:
|
|
1107
|
+
"""Check if Python environment management is available.
|
|
1108
|
+
|
|
1109
|
+
Returns:
|
|
1110
|
+
bool: True if conda/mamba is available, False otherwise.
|
|
1111
|
+
"""
|
|
1112
|
+
return self.python_env_manager.is_available()
|
|
1113
|
+
|
|
1114
|
+
def get_python_environment_info(self, env_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
1115
|
+
"""Get comprehensive Python environment information for an environment.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
env_name (str, optional): Environment name. Defaults to current environment.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
dict: Comprehensive Python environment info, None if no Python environment exists.
|
|
1122
|
+
|
|
1123
|
+
Raises:
|
|
1124
|
+
HatchEnvironmentError: If no environment name provided and no current environment set.
|
|
1125
|
+
"""
|
|
1126
|
+
if env_name is None:
|
|
1127
|
+
env_name = self.get_current_environment()
|
|
1128
|
+
if not env_name:
|
|
1129
|
+
raise HatchEnvironmentError("No environment name provided and no current environment set")
|
|
1130
|
+
|
|
1131
|
+
if env_name not in self._environments:
|
|
1132
|
+
return None
|
|
1133
|
+
|
|
1134
|
+
env_data = self._environments[env_name]
|
|
1135
|
+
|
|
1136
|
+
# Check if Python environment exists
|
|
1137
|
+
if not env_data.get("python_environment", False):
|
|
1138
|
+
return None
|
|
1139
|
+
|
|
1140
|
+
# Start with enhanced metadata from Hatch environment
|
|
1141
|
+
python_env_data = env_data.get("python_env", {})
|
|
1142
|
+
|
|
1143
|
+
# Get real-time information from Python environment manager
|
|
1144
|
+
live_info = self.python_env_manager.get_environment_info(env_name)
|
|
1145
|
+
|
|
1146
|
+
# Combine metadata with live information
|
|
1147
|
+
result = {
|
|
1148
|
+
# Basic identification
|
|
1149
|
+
"environment_name": env_name,
|
|
1150
|
+
"enabled": python_env_data.get("enabled", True),
|
|
1151
|
+
|
|
1152
|
+
# Conda/mamba information
|
|
1153
|
+
"conda_env_name": python_env_data.get("conda_env_name") or (live_info.get("conda_env_name") if live_info else None),
|
|
1154
|
+
"manager": python_env_data.get("manager", "conda"),
|
|
1155
|
+
|
|
1156
|
+
# Python executable and version
|
|
1157
|
+
"python_executable": live_info.get("python_executable") if live_info else python_env_data.get("python_executable"),
|
|
1158
|
+
"python_version": live_info.get("python_version") if live_info else python_env_data.get("version"),
|
|
1159
|
+
"requested_version": python_env_data.get("requested_version"),
|
|
1160
|
+
|
|
1161
|
+
# Paths and timestamps
|
|
1162
|
+
"environment_path": live_info.get("environment_path") if live_info else None,
|
|
1163
|
+
"created_at": python_env_data.get("created_at"),
|
|
1164
|
+
|
|
1165
|
+
# Package information
|
|
1166
|
+
"package_count": live_info.get("package_count", 0) if live_info else 0,
|
|
1167
|
+
"packages": live_info.get("packages", []) if live_info else [],
|
|
1168
|
+
|
|
1169
|
+
# Status information
|
|
1170
|
+
"exists": live_info is not None,
|
|
1171
|
+
"accessible": live_info.get("python_executable") is not None if live_info else False
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
return result
|
|
1175
|
+
|
|
1176
|
+
def list_python_environments(self) -> List[str]:
|
|
1177
|
+
"""List all environments that have Python environments.
|
|
1178
|
+
|
|
1179
|
+
Returns:
|
|
1180
|
+
list: List of environment names with Python environments.
|
|
1181
|
+
"""
|
|
1182
|
+
return self.python_env_manager.list_environments()
|
|
1183
|
+
|
|
1184
|
+
def create_python_environment_only(self, env_name: Optional[str] = None, python_version: Optional[str] = None,
|
|
1185
|
+
force: bool = False, no_hatch_mcp_server: bool = False,
|
|
1186
|
+
hatch_mcp_server_tag: Optional[str] = None) -> bool:
|
|
1187
|
+
"""Create only a Python environment without creating a Hatch environment.
|
|
1188
|
+
|
|
1189
|
+
Useful for adding Python environments to existing Hatch environments.
|
|
1190
|
+
|
|
1191
|
+
Args:
|
|
1192
|
+
env_name (str, optional): Environment name. Defaults to current environment.
|
|
1193
|
+
python_version (str, optional): Python version (e.g., "3.11"). Defaults to None.
|
|
1194
|
+
force (bool, optional): Whether to recreate if exists. Defaults to False.
|
|
1195
|
+
no_hatch_mcp_server (bool, optional): Whether to skip installing hatch_mcp_server wrapper in the environment. Defaults to False.
|
|
1196
|
+
hatch_mcp_server_tag (str, optional): Git tag/branch reference for hatch_mcp_server wrapper installation. Defaults to None.
|
|
1197
|
+
|
|
1198
|
+
Returns:
|
|
1199
|
+
bool: True if successful, False otherwise.
|
|
1200
|
+
|
|
1201
|
+
Raises:
|
|
1202
|
+
HatchEnvironmentError: If no environment name provided and no current environment set.
|
|
1203
|
+
"""
|
|
1204
|
+
if env_name is None:
|
|
1205
|
+
env_name = self.get_current_environment()
|
|
1206
|
+
if not env_name:
|
|
1207
|
+
raise HatchEnvironmentError("No environment name provided and no current environment set")
|
|
1208
|
+
|
|
1209
|
+
if env_name not in self._environments:
|
|
1210
|
+
self.logger.error(f"Hatch environment {env_name} must exist first")
|
|
1211
|
+
return False
|
|
1212
|
+
|
|
1213
|
+
try:
|
|
1214
|
+
success = self.python_env_manager.create_python_environment(
|
|
1215
|
+
env_name, python_version=python_version, force=force
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
if success:
|
|
1219
|
+
# Get detailed Python environment information
|
|
1220
|
+
python_info = self.python_env_manager.get_environment_info(env_name)
|
|
1221
|
+
if python_info:
|
|
1222
|
+
python_env_info = {
|
|
1223
|
+
"enabled": True,
|
|
1224
|
+
"conda_env_name": python_info.get("conda_env_name"),
|
|
1225
|
+
"python_executable": python_info.get("python_executable"),
|
|
1226
|
+
"created_at": datetime.datetime.now().isoformat(),
|
|
1227
|
+
"version": python_info.get("python_version"),
|
|
1228
|
+
"requested_version": python_version,
|
|
1229
|
+
"manager": python_info.get("manager", "conda")
|
|
1230
|
+
}
|
|
1231
|
+
else:
|
|
1232
|
+
# Fallback if detailed info is not available
|
|
1233
|
+
python_env_info = {
|
|
1234
|
+
"enabled": True,
|
|
1235
|
+
"conda_env_name": f"hatch-{env_name}",
|
|
1236
|
+
"python_executable": None,
|
|
1237
|
+
"created_at": datetime.datetime.now().isoformat(),
|
|
1238
|
+
"version": None,
|
|
1239
|
+
"requested_version": python_version,
|
|
1240
|
+
"manager": "conda"
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
# Update environment metadata with enhanced structure
|
|
1244
|
+
self._environments[env_name]["python_environment"] = True # Legacy field
|
|
1245
|
+
self._environments[env_name]["python_env"] = python_env_info # Enhanced structure
|
|
1246
|
+
if python_version:
|
|
1247
|
+
self._environments[env_name]["python_version"] = python_version # Legacy field
|
|
1248
|
+
self._save_environments()
|
|
1249
|
+
|
|
1250
|
+
# Reconfigure Python executable if this is the current environment
|
|
1251
|
+
if env_name == self._current_env_name:
|
|
1252
|
+
self._configure_python_executable(env_name)
|
|
1253
|
+
|
|
1254
|
+
# Install hatch_mcp_server by default unless opted out
|
|
1255
|
+
if not no_hatch_mcp_server:
|
|
1256
|
+
try:
|
|
1257
|
+
self._install_hatch_mcp_server(env_name, hatch_mcp_server_tag)
|
|
1258
|
+
except Exception as e:
|
|
1259
|
+
self.logger.warning(f"Failed to install hatch_mcp_server wrapper in environment {env_name}: {e}")
|
|
1260
|
+
# Don't fail environment creation if MCP wrapper installation fails
|
|
1261
|
+
|
|
1262
|
+
return success
|
|
1263
|
+
except PythonEnvironmentError as e:
|
|
1264
|
+
self.logger.error(f"Failed to create Python environment: {e}")
|
|
1265
|
+
return False
|
|
1266
|
+
|
|
1267
|
+
def remove_python_environment_only(self, env_name: Optional[str] = None) -> bool:
|
|
1268
|
+
"""Remove only the Python environment, keeping the Hatch environment.
|
|
1269
|
+
|
|
1270
|
+
Args:
|
|
1271
|
+
env_name (str, optional): Environment name. Defaults to current environment.
|
|
1272
|
+
|
|
1273
|
+
Returns:
|
|
1274
|
+
bool: True if successful, False otherwise.
|
|
1275
|
+
|
|
1276
|
+
Raises:
|
|
1277
|
+
HatchEnvironmentError: If no environment name provided and no current environment set.
|
|
1278
|
+
"""
|
|
1279
|
+
if env_name is None:
|
|
1280
|
+
env_name = self.get_current_environment()
|
|
1281
|
+
if not env_name:
|
|
1282
|
+
raise HatchEnvironmentError("No environment name provided and no current environment set")
|
|
1283
|
+
|
|
1284
|
+
if env_name not in self._environments:
|
|
1285
|
+
self.logger.warning(f"Hatch environment {env_name} does not exist")
|
|
1286
|
+
return False
|
|
1287
|
+
|
|
1288
|
+
try:
|
|
1289
|
+
success = self.python_env_manager.remove_python_environment(env_name)
|
|
1290
|
+
|
|
1291
|
+
if success:
|
|
1292
|
+
# Update environment metadata - remove Python environment info
|
|
1293
|
+
self._environments[env_name]["python_environment"] = False # Legacy field
|
|
1294
|
+
self._environments[env_name]["python_env"] = None # Enhanced structure
|
|
1295
|
+
self._environments[env_name].pop("python_version", None) # Legacy field cleanup
|
|
1296
|
+
self._save_environments()
|
|
1297
|
+
|
|
1298
|
+
# Reconfigure Python executable if this is the current environment
|
|
1299
|
+
if env_name == self._current_env_name:
|
|
1300
|
+
self._configure_python_executable(env_name)
|
|
1301
|
+
|
|
1302
|
+
return success
|
|
1303
|
+
except PythonEnvironmentError as e:
|
|
1304
|
+
self.logger.error(f"Failed to remove Python environment: {e}")
|
|
1305
|
+
return False
|
|
1306
|
+
|
|
1307
|
+
def get_python_environment_diagnostics(self, env_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
1308
|
+
"""Get detailed diagnostics for a Python environment.
|
|
1309
|
+
|
|
1310
|
+
Args:
|
|
1311
|
+
env_name (str, optional): Environment name. Defaults to current environment.
|
|
1312
|
+
|
|
1313
|
+
Returns:
|
|
1314
|
+
dict: Diagnostics information or None if environment doesn't exist.
|
|
1315
|
+
|
|
1316
|
+
Raises:
|
|
1317
|
+
HatchEnvironmentError: If no environment name provided and no current environment set.
|
|
1318
|
+
"""
|
|
1319
|
+
if env_name is None:
|
|
1320
|
+
env_name = self.get_current_environment()
|
|
1321
|
+
if not env_name:
|
|
1322
|
+
raise HatchEnvironmentError("No environment name provided and no current environment set")
|
|
1323
|
+
|
|
1324
|
+
if env_name not in self._environments:
|
|
1325
|
+
return None
|
|
1326
|
+
|
|
1327
|
+
try:
|
|
1328
|
+
return self.python_env_manager.get_environment_diagnostics(env_name)
|
|
1329
|
+
except PythonEnvironmentError as e:
|
|
1330
|
+
self.logger.error(f"Failed to get diagnostics for {env_name}: {e}")
|
|
1331
|
+
return None
|
|
1332
|
+
|
|
1333
|
+
def get_python_manager_diagnostics(self) -> Dict[str, Any]:
|
|
1334
|
+
"""Get general diagnostics for the Python environment manager.
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
dict: General diagnostics information.
|
|
1338
|
+
"""
|
|
1339
|
+
try:
|
|
1340
|
+
return self.python_env_manager.get_manager_diagnostics()
|
|
1341
|
+
except Exception as e:
|
|
1342
|
+
self.logger.error(f"Failed to get manager diagnostics: {e}")
|
|
1343
|
+
return {"error": str(e)}
|
|
1344
|
+
|
|
1345
|
+
def launch_python_shell(self, env_name: Optional[str] = None, cmd: Optional[str] = None) -> bool:
|
|
1346
|
+
"""Launch a Python shell or execute a command in the environment.
|
|
1347
|
+
|
|
1348
|
+
Args:
|
|
1349
|
+
env_name (str, optional): Environment name. Defaults to current environment.
|
|
1350
|
+
cmd (str, optional): Command to execute. If None, launches interactive shell. Defaults to None.
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
bool: True if successful, False otherwise.
|
|
1354
|
+
|
|
1355
|
+
Raises:
|
|
1356
|
+
HatchEnvironmentError: If no environment name provided and no current environment set.
|
|
1357
|
+
"""
|
|
1358
|
+
if env_name is None:
|
|
1359
|
+
env_name = self.get_current_environment()
|
|
1360
|
+
if not env_name:
|
|
1361
|
+
raise HatchEnvironmentError("No environment name provided and no current environment set")
|
|
1362
|
+
|
|
1363
|
+
if env_name not in self._environments:
|
|
1364
|
+
self.logger.error(f"Environment {env_name} does not exist")
|
|
1365
|
+
return False
|
|
1366
|
+
|
|
1367
|
+
if not self._environments[env_name].get("python_environment", False):
|
|
1368
|
+
self.logger.error(f"No Python environment configured for {env_name}")
|
|
1369
|
+
return False
|
|
1370
|
+
|
|
1371
|
+
try:
|
|
1372
|
+
return self.python_env_manager.launch_shell(env_name, cmd)
|
|
1373
|
+
except PythonEnvironmentError as e:
|
|
1374
|
+
self.logger.error(f"Failed to launch shell for {env_name}: {e}")
|
|
1375
|
+
return False
|