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.
Files changed (93) hide show
  1. hatch/__init__.py +21 -0
  2. hatch/cli_hatch.py +2748 -0
  3. hatch/environment_manager.py +1375 -0
  4. hatch/installers/__init__.py +25 -0
  5. hatch/installers/dependency_installation_orchestrator.py +636 -0
  6. hatch/installers/docker_installer.py +545 -0
  7. hatch/installers/hatch_installer.py +198 -0
  8. hatch/installers/installation_context.py +109 -0
  9. hatch/installers/installer_base.py +195 -0
  10. hatch/installers/python_installer.py +342 -0
  11. hatch/installers/registry.py +179 -0
  12. hatch/installers/system_installer.py +588 -0
  13. hatch/mcp_host_config/__init__.py +38 -0
  14. hatch/mcp_host_config/backup.py +458 -0
  15. hatch/mcp_host_config/host_management.py +572 -0
  16. hatch/mcp_host_config/models.py +602 -0
  17. hatch/mcp_host_config/reporting.py +181 -0
  18. hatch/mcp_host_config/strategies.py +513 -0
  19. hatch/package_loader.py +263 -0
  20. hatch/python_environment_manager.py +734 -0
  21. hatch/registry_explorer.py +171 -0
  22. hatch/registry_retriever.py +335 -0
  23. hatch/template_generator.py +179 -0
  24. hatch_xclam-0.7.0.dist-info/METADATA +150 -0
  25. hatch_xclam-0.7.0.dist-info/RECORD +93 -0
  26. hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
  27. hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
  28. hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
  29. hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
  30. tests/__init__.py +1 -0
  31. tests/run_environment_tests.py +124 -0
  32. tests/test_cli_version.py +122 -0
  33. tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
  34. tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
  35. tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
  36. tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
  37. tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
  38. tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
  39. tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
  40. tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
  41. tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
  42. tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
  43. tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
  44. tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
  45. tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
  46. tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
  47. tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
  48. tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
  49. tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
  50. tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
  51. tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
  52. tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
  53. tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
  54. tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
  55. tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
  56. tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
  57. tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
  58. tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
  59. tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
  60. tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
  61. tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
  62. tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
  63. tests/test_data_utils.py +472 -0
  64. tests/test_dependency_orchestrator_consent.py +266 -0
  65. tests/test_docker_installer.py +524 -0
  66. tests/test_env_manip.py +991 -0
  67. tests/test_hatch_installer.py +179 -0
  68. tests/test_installer_base.py +221 -0
  69. tests/test_mcp_atomic_operations.py +276 -0
  70. tests/test_mcp_backup_integration.py +308 -0
  71. tests/test_mcp_cli_all_host_specific_args.py +303 -0
  72. tests/test_mcp_cli_backup_management.py +295 -0
  73. tests/test_mcp_cli_direct_management.py +453 -0
  74. tests/test_mcp_cli_discovery_listing.py +582 -0
  75. tests/test_mcp_cli_host_config_integration.py +823 -0
  76. tests/test_mcp_cli_package_management.py +360 -0
  77. tests/test_mcp_cli_partial_updates.py +859 -0
  78. tests/test_mcp_environment_integration.py +520 -0
  79. tests/test_mcp_host_config_backup.py +257 -0
  80. tests/test_mcp_host_configuration_manager.py +331 -0
  81. tests/test_mcp_host_registry_decorator.py +348 -0
  82. tests/test_mcp_pydantic_architecture_v4.py +603 -0
  83. tests/test_mcp_server_config_models.py +242 -0
  84. tests/test_mcp_server_config_type_field.py +221 -0
  85. tests/test_mcp_sync_functionality.py +316 -0
  86. tests/test_mcp_user_feedback_reporting.py +359 -0
  87. tests/test_non_tty_integration.py +281 -0
  88. tests/test_online_package_loader.py +202 -0
  89. tests/test_python_environment_manager.py +882 -0
  90. tests/test_python_installer.py +327 -0
  91. tests/test_registry.py +51 -0
  92. tests/test_registry_retriever.py +250 -0
  93. 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