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,342 @@
|
|
|
1
|
+
"""Installer for Python package dependencies using pip.
|
|
2
|
+
|
|
3
|
+
This module implements installation logic for Python packages using pip via subprocess,
|
|
4
|
+
with support for configurable Python environments and comprehensive error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import subprocess
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, Any, Optional, Callable, List
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Any, Optional, Callable, List
|
|
17
|
+
|
|
18
|
+
from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError
|
|
19
|
+
from .installation_context import InstallationStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PythonInstaller(DependencyInstaller):
|
|
23
|
+
"""Installer for Python package dependencies using pip.
|
|
24
|
+
|
|
25
|
+
Handles installation of Python packages using pip via subprocess, with support
|
|
26
|
+
for configurable Python environments through InstallationContext.extra_config.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
"""Initialize the PythonInstaller."""
|
|
31
|
+
self.logger = logging.getLogger("hatch.installers.python_installer")
|
|
32
|
+
self.logger.setLevel(logging.INFO)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def installer_type(self) -> str:
|
|
36
|
+
"""Get the type identifier for this installer.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
str: Unique identifier for the installer type ("python").
|
|
40
|
+
"""
|
|
41
|
+
return "python"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def supported_schemes(self) -> List[str]:
|
|
45
|
+
"""Get the URI schemes this installer can handle.
|
|
46
|
+
|
|
47
|
+
This installer supports:
|
|
48
|
+
- "pypi" for PyPI packages
|
|
49
|
+
- "git+https" for Git repositories over HTTPS
|
|
50
|
+
- "git+ssh" for Git repositories over SSH
|
|
51
|
+
- "file" for local file paths
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List[str]: List of URI schemes (["pypi", "git+https", "git+ssh", "file"]).
|
|
55
|
+
"""
|
|
56
|
+
return ["pypi", "git+https", "git+ssh", "file"]
|
|
57
|
+
|
|
58
|
+
def can_install(self, dependency: Dict[str, Any]) -> bool:
|
|
59
|
+
"""Check if this installer can handle the given dependency.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
dependency (Dict[str, Any]): Dependency object.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
bool: True if this installer can handle the dependency, False otherwise.
|
|
66
|
+
"""
|
|
67
|
+
return dependency.get("type") == self.installer_type
|
|
68
|
+
|
|
69
|
+
def validate_dependency(self, dependency: Dict[str, Any]) -> bool:
|
|
70
|
+
"""Validate that a dependency object has required fields for Python packages.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
dependency (Dict[str, Any]): Dependency object to validate.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
bool: True if dependency is valid, False otherwise.
|
|
77
|
+
"""
|
|
78
|
+
required_fields = ["name", "version_constraint"]
|
|
79
|
+
if not all(field in dependency for field in required_fields):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
# Check for valid package manager if specified
|
|
83
|
+
package_manager = dependency.get("package_manager", "pip")
|
|
84
|
+
if package_manager not in ["pip"]:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def _run_pip_subprocess(self, cmd: List[str], env_vars: Dict[str, str] = None) -> int:
|
|
90
|
+
"""Run a pip subprocess and return the exit code.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
cmd (List[str]): The pip command to execute as a list.
|
|
94
|
+
env_vars (Dict[str, str], optional): Additional environment variables to set for the subprocess.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
int: The return code of the pip subprocess.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
subprocess.TimeoutExpired: If the process times out.
|
|
101
|
+
Exception: For unexpected errors.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
env = os.environ.copy()
|
|
105
|
+
env['PYTHONUNBUFFERED'] = '1'
|
|
106
|
+
env.update(env_vars or {}) # Merge in any additional environment variables
|
|
107
|
+
|
|
108
|
+
self.logger.debug(f"Running pip command: {' '.join(cmd)} with env: {json.dumps(env, indent=2)}")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
cmd,
|
|
113
|
+
env=env,
|
|
114
|
+
check=False, # Don't raise on non-zero exit codes
|
|
115
|
+
timeout=300 # 5 minute timeout
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return result.returncode
|
|
119
|
+
|
|
120
|
+
except subprocess.TimeoutExpired:
|
|
121
|
+
raise InstallationError("Pip subprocess timed out", error_code="TIMEOUT", cause=None)
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise InstallationError(
|
|
125
|
+
f"Unexpected error running pip command: {e}",
|
|
126
|
+
error_code="PIP_SUBPROCESS_ERROR",
|
|
127
|
+
cause=e
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def install(self, dependency: Dict[str, Any], context: InstallationContext,
|
|
131
|
+
progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult:
|
|
132
|
+
"""Install a Python package dependency using pip.
|
|
133
|
+
|
|
134
|
+
This method uses subprocess to call pip with the appropriate Python executable,
|
|
135
|
+
which can be configured via context.extra_config["python_executable"].
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
dependency (Dict[str, Any]): Dependency object containing name, version, etc.
|
|
139
|
+
context (InstallationContext): Installation context with environment info.
|
|
140
|
+
progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
InstallationResult: Result of the installation operation.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
InstallationError: If installation fails for any reason.
|
|
147
|
+
"""
|
|
148
|
+
name = dependency["name"]
|
|
149
|
+
version_constraint = dependency["version_constraint"]
|
|
150
|
+
|
|
151
|
+
if progress_callback:
|
|
152
|
+
progress_callback("validate", 0.0, f"Validating Python package {name}")
|
|
153
|
+
|
|
154
|
+
# Get Python executable from context or use system default
|
|
155
|
+
python_env_vars = context.get_config("python_env_vars", {})
|
|
156
|
+
self.logger.debug(f"Using Python environment variables: {python_env_vars}")
|
|
157
|
+
python_exec = python_env_vars.get("PYTHON", sys.executable)
|
|
158
|
+
self.logger.debug(f"Using Python executable: {python_exec}")
|
|
159
|
+
|
|
160
|
+
# Build package specification with version constraint
|
|
161
|
+
# Let pip resolve the actual version based on the constraint
|
|
162
|
+
if version_constraint and version_constraint != "*":
|
|
163
|
+
package_spec = f"{name}{version_constraint}"
|
|
164
|
+
else:
|
|
165
|
+
package_spec = name
|
|
166
|
+
|
|
167
|
+
# Handle extras if specified
|
|
168
|
+
extras = dependency.get("extras", [])
|
|
169
|
+
if extras:
|
|
170
|
+
if isinstance(extras, list):
|
|
171
|
+
extras_str = ",".join(extras)
|
|
172
|
+
else:
|
|
173
|
+
extras_str = str(extras)
|
|
174
|
+
if version_constraint and version_constraint != "*":
|
|
175
|
+
package_spec = f"{name}[{extras_str}]{version_constraint}"
|
|
176
|
+
else:
|
|
177
|
+
package_spec = f"{name}[{extras_str}]"
|
|
178
|
+
|
|
179
|
+
# Build pip command
|
|
180
|
+
self.logger.debug(f"Installing Python package: {package_spec} using {python_exec}")
|
|
181
|
+
cmd = [str(python_exec), "-m", "pip", "install", package_spec]
|
|
182
|
+
|
|
183
|
+
# Add additional pip options
|
|
184
|
+
cmd.extend(["--no-cache-dir"]) # Avoid cache issues in different environments
|
|
185
|
+
|
|
186
|
+
if context.simulation_mode:
|
|
187
|
+
# In simulation mode, just return success without actually installing
|
|
188
|
+
self.logger.info(f"Simulation mode: would install {package_spec}")
|
|
189
|
+
return InstallationResult(
|
|
190
|
+
dependency_name=name,
|
|
191
|
+
status=InstallationStatus.COMPLETED,
|
|
192
|
+
installed_version=version_constraint,
|
|
193
|
+
metadata={"simulation": True, "command": cmd}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
if progress_callback:
|
|
198
|
+
progress_callback("install", 0.3, f"Installing {package_spec}")
|
|
199
|
+
|
|
200
|
+
returncode = self._run_pip_subprocess(cmd, env_vars=python_env_vars)
|
|
201
|
+
self.logger.debug(f"pip command: {' '.join(cmd)}\nreturncode: {returncode}")
|
|
202
|
+
|
|
203
|
+
if returncode == 0:
|
|
204
|
+
|
|
205
|
+
if progress_callback:
|
|
206
|
+
progress_callback("install", 1.0, f"Successfully installed {name}")
|
|
207
|
+
|
|
208
|
+
return InstallationResult(
|
|
209
|
+
dependency_name=name,
|
|
210
|
+
status=InstallationStatus.COMPLETED,
|
|
211
|
+
metadata={
|
|
212
|
+
"command": cmd,
|
|
213
|
+
"version_constraint": version_constraint
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
else:
|
|
218
|
+
error_msg = f"Failed to install {name} (exit code: {returncode})"
|
|
219
|
+
self.logger.error(error_msg)
|
|
220
|
+
raise InstallationError(
|
|
221
|
+
error_msg,
|
|
222
|
+
dependency_name=name,
|
|
223
|
+
error_code="PIP_FAILED",
|
|
224
|
+
cause=None
|
|
225
|
+
)
|
|
226
|
+
except subprocess.TimeoutExpired:
|
|
227
|
+
error_msg = f"Installation of {name} timed out after 5 minutes"
|
|
228
|
+
self.logger.error(error_msg)
|
|
229
|
+
raise InstallationError(error_msg, dependency_name=name, error_code="TIMEOUT")
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
error_msg = f"Unexpected error installing {name}: {repr(e)}"
|
|
233
|
+
self.logger.error(error_msg)
|
|
234
|
+
raise InstallationError(error_msg, dependency_name=name, cause=e)
|
|
235
|
+
|
|
236
|
+
def uninstall(self, dependency: Dict[str, Any], context: InstallationContext,
|
|
237
|
+
progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult:
|
|
238
|
+
"""Uninstall a Python package dependency using pip.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
dependency (Dict[str, Any]): Dependency object to uninstall.
|
|
242
|
+
context (InstallationContext): Installation context with environment info.
|
|
243
|
+
progress_callback (Callable[[str, float, str], None], optional): Progress reporting callback.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
InstallationResult: Result of the uninstall operation.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
InstallationError: If uninstall fails for any reason.
|
|
250
|
+
"""
|
|
251
|
+
name = dependency["name"]
|
|
252
|
+
|
|
253
|
+
if progress_callback:
|
|
254
|
+
progress_callback("uninstall", 0.0, f"Uninstalling Python package {name}")
|
|
255
|
+
|
|
256
|
+
# Get Python executable from context
|
|
257
|
+
python_env_vars = context.get_config("python_env_vars", {})
|
|
258
|
+
# Use the configured Python executable or fall back to system default
|
|
259
|
+
python_exec = python_env_vars.get("PYTHON", sys.executable)
|
|
260
|
+
|
|
261
|
+
# Build pip uninstall command
|
|
262
|
+
cmd = [str(python_exec), "-m", "pip", "uninstall", "-y", name]
|
|
263
|
+
|
|
264
|
+
if context.simulation_mode:
|
|
265
|
+
self.logger.info(f"Simulation mode: would uninstall {name}")
|
|
266
|
+
return InstallationResult(
|
|
267
|
+
dependency_name=name,
|
|
268
|
+
status=InstallationStatus.COMPLETED,
|
|
269
|
+
metadata={"simulation": True, "command": cmd}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
if progress_callback:
|
|
274
|
+
progress_callback("uninstall", 0.5, f"Removing {name}")
|
|
275
|
+
|
|
276
|
+
returncode = self._run_pip_subprocess(cmd, env_vars=python_env_vars)
|
|
277
|
+
|
|
278
|
+
if returncode == 0:
|
|
279
|
+
|
|
280
|
+
if progress_callback:
|
|
281
|
+
progress_callback("uninstall", 1.0, f"Successfully uninstalled {name}")
|
|
282
|
+
self.logger.info(f"Successfully uninstalled Python package {name}")
|
|
283
|
+
|
|
284
|
+
return InstallationResult(
|
|
285
|
+
dependency_name=name,
|
|
286
|
+
status=InstallationStatus.COMPLETED,
|
|
287
|
+
metadata={
|
|
288
|
+
"command": cmd
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
error_msg = f"Failed to uninstall {name} (exit code: {returncode})"
|
|
293
|
+
self.logger.error(error_msg)
|
|
294
|
+
|
|
295
|
+
raise InstallationError(
|
|
296
|
+
error_msg,
|
|
297
|
+
dependency_name=name,
|
|
298
|
+
error_code="PIP_UNINSTALL_FAILED",
|
|
299
|
+
cause=None
|
|
300
|
+
)
|
|
301
|
+
except subprocess.TimeoutExpired:
|
|
302
|
+
error_msg = f"Uninstallation of {name} timed out after 1 minute"
|
|
303
|
+
self.logger.error(error_msg)
|
|
304
|
+
raise InstallationError(error_msg, dependency_name=name, error_code="TIMEOUT")
|
|
305
|
+
except Exception as e:
|
|
306
|
+
error_msg = f"Unexpected error uninstalling {name}: {e}"
|
|
307
|
+
self.logger.error(error_msg)
|
|
308
|
+
raise InstallationError(error_msg, dependency_name=name, cause=e)
|
|
309
|
+
|
|
310
|
+
def get_installation_info(self, dependency: Dict[str, Any], context: InstallationContext) -> Dict[str, Any]:
|
|
311
|
+
"""Get information about what would be installed without actually installing.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
dependency (Dict[str, Any]): Dependency object to analyze.
|
|
315
|
+
context (InstallationContext): Installation context.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Dict[str, Any]: Information about the planned installation.
|
|
319
|
+
"""
|
|
320
|
+
python_exec = context.get_config("python_executable", sys.executable)
|
|
321
|
+
version_constraint = dependency.get("version_constraint", "*")
|
|
322
|
+
|
|
323
|
+
# Build package spec for display
|
|
324
|
+
if version_constraint and version_constraint != "*":
|
|
325
|
+
package_spec = f"{dependency['name']}{version_constraint}"
|
|
326
|
+
else:
|
|
327
|
+
package_spec = dependency['name']
|
|
328
|
+
|
|
329
|
+
info = super().get_installation_info(dependency, context)
|
|
330
|
+
info.update({
|
|
331
|
+
"python_executable": str(python_exec),
|
|
332
|
+
"package_manager": dependency.get("package_manager", "pip"),
|
|
333
|
+
"package_spec": package_spec,
|
|
334
|
+
"version_constraint": version_constraint,
|
|
335
|
+
"extras": dependency.get("extras", []),
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
return info
|
|
339
|
+
|
|
340
|
+
# Register this installer with the global registry
|
|
341
|
+
from .registry import installer_registry
|
|
342
|
+
installer_registry.register_installer("python", PythonInstaller)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Installer registry for dependency installers.
|
|
2
|
+
|
|
3
|
+
This module provides a centralized registry for mapping dependency types to their
|
|
4
|
+
corresponding installer implementations, enabling dynamic lookup and delegation
|
|
5
|
+
of installation operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Dict, Type, List, Optional, Any
|
|
10
|
+
|
|
11
|
+
from .installer_base import DependencyInstaller
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("hatch.installer_registry")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InstallerRegistry:
|
|
17
|
+
"""Registry for dependency installers by type.
|
|
18
|
+
|
|
19
|
+
This class provides a centralized mapping between dependency types and their
|
|
20
|
+
corresponding installer implementations. It enables the orchestrator to remain
|
|
21
|
+
agnostic to installer details while providing extensible installer management.
|
|
22
|
+
|
|
23
|
+
The registry follows these principles:
|
|
24
|
+
- Single source of truth for installer-to-type mappings
|
|
25
|
+
- Dynamic registration and lookup
|
|
26
|
+
- Clear error handling for unsupported types
|
|
27
|
+
- Extensibility for future installer types
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Initialize the installer registry."""
|
|
32
|
+
self._installers: Dict[str, Type[DependencyInstaller]] = {}
|
|
33
|
+
logger.debug("Initialized installer registry")
|
|
34
|
+
|
|
35
|
+
def register_installer(self, dep_type: str, installer_cls: Type[DependencyInstaller]) -> None:
|
|
36
|
+
"""Register an installer class for a dependency type.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
dep_type (str): The dependency type identifier (e.g., "hatch", "python", "docker").
|
|
40
|
+
installer_cls (Type[DependencyInstaller]): The installer class to register.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If the installer class does not implement DependencyInstaller.
|
|
44
|
+
TypeError: If the installer_cls is not a class or is None.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(installer_cls, type):
|
|
47
|
+
raise TypeError(f"installer_cls must be a class, got {type(installer_cls)}")
|
|
48
|
+
|
|
49
|
+
if not issubclass(installer_cls, DependencyInstaller):
|
|
50
|
+
raise ValueError(f"installer_cls must be a subclass of DependencyInstaller, got {installer_cls}")
|
|
51
|
+
|
|
52
|
+
if dep_type in self._installers:
|
|
53
|
+
logger.warning(f"Overriding existing installer for type '{dep_type}': {self._installers[dep_type]} -> {installer_cls}")
|
|
54
|
+
|
|
55
|
+
self._installers[dep_type] = installer_cls
|
|
56
|
+
logger.debug(f"Registered installer for type '{dep_type}': {installer_cls.__name__}")
|
|
57
|
+
|
|
58
|
+
def get_installer(self, dep_type: str) -> DependencyInstaller:
|
|
59
|
+
"""Get an installer instance for the given dependency type.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
dep_type (str): The dependency type to get an installer for.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
DependencyInstaller: A new instance of the appropriate installer.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If no installer is registered for the given dependency type.
|
|
69
|
+
"""
|
|
70
|
+
if dep_type not in self._installers:
|
|
71
|
+
available_types = list(self._installers.keys())
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"No installer registered for dependency type '{dep_type}'. "
|
|
74
|
+
f"Available types: {available_types}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
installer_cls = self._installers[dep_type]
|
|
78
|
+
installer = installer_cls()
|
|
79
|
+
logger.debug(f"Created installer instance for type '{dep_type}': {installer_cls.__name__}")
|
|
80
|
+
return installer
|
|
81
|
+
|
|
82
|
+
def can_install(self, dep_type: str, dependency: Dict[str, Any]) -> bool:
|
|
83
|
+
"""Check if the registry can handle the given dependency.
|
|
84
|
+
|
|
85
|
+
This method first checks if an installer is registered for the dependency's
|
|
86
|
+
type, then delegates to the installer's can_install method for more
|
|
87
|
+
detailed validation.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
dependency (Dict[str, Any]): Dependency object to check.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if the dependency can be installed, False otherwise.
|
|
94
|
+
"""
|
|
95
|
+
if dep_type not in self._installers:
|
|
96
|
+
logger.error(f"No installer registered for dependency type '{dep_type}'")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
installer = self.get_installer(dep_type)
|
|
101
|
+
return installer.can_install(dependency)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.warning(f"Error checking if dependency can be installed: {e}")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def get_registered_types(self) -> List[str]:
|
|
107
|
+
"""Get a list of all registered dependency types.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List[str]: List of registered dependency type identifiers.
|
|
111
|
+
"""
|
|
112
|
+
return list(self._installers.keys())
|
|
113
|
+
|
|
114
|
+
def is_registered(self, dep_type: str) -> bool:
|
|
115
|
+
"""Check if an installer is registered for the given type.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
dep_type (str): The dependency type to check.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
bool: True if an installer is registered for the type, False otherwise.
|
|
122
|
+
"""
|
|
123
|
+
return dep_type in self._installers
|
|
124
|
+
|
|
125
|
+
def unregister_installer(self, dep_type: str) -> Optional[Type[DependencyInstaller]]:
|
|
126
|
+
"""Unregister an installer for the given dependency type.
|
|
127
|
+
|
|
128
|
+
This method is primarily intended for testing and advanced use cases.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
dep_type (str): The dependency type to unregister.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Type[DependencyInstaller]: The unregistered installer class, or None if not found.
|
|
135
|
+
"""
|
|
136
|
+
installer_cls = self._installers.pop(dep_type, None)
|
|
137
|
+
if installer_cls:
|
|
138
|
+
logger.debug(f"Unregistered installer for type '{dep_type}': {installer_cls.__name__}")
|
|
139
|
+
return installer_cls
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
"""Clear all registered installers.
|
|
143
|
+
|
|
144
|
+
This method is primarily intended for testing purposes.
|
|
145
|
+
"""
|
|
146
|
+
self._installers.clear()
|
|
147
|
+
logger.debug("Cleared all registered installers")
|
|
148
|
+
|
|
149
|
+
def __len__(self) -> int:
|
|
150
|
+
"""Get the number of registered installers.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
int: Number of registered installers.
|
|
154
|
+
"""
|
|
155
|
+
return len(self._installers)
|
|
156
|
+
|
|
157
|
+
def __contains__(self, dep_type: str) -> bool:
|
|
158
|
+
"""Check if a dependency type is registered.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
dep_type (str): The dependency type to check.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
bool: True if the type is registered, False otherwise.
|
|
165
|
+
"""
|
|
166
|
+
return dep_type in self._installers
|
|
167
|
+
|
|
168
|
+
def __repr__(self) -> str:
|
|
169
|
+
"""Get a string representation of the registry.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
str: String representation showing registered types.
|
|
173
|
+
"""
|
|
174
|
+
types = list(self._installers.keys())
|
|
175
|
+
return f"InstallerRegistry(types={types})"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Global singleton instance
|
|
179
|
+
installer_registry = InstallerRegistry()
|