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,545 @@
|
|
|
1
|
+
"""Installer for Docker image dependencies.
|
|
2
|
+
|
|
3
|
+
This module implements installation logic for Docker images using docker-py library,
|
|
4
|
+
with support for version constraints, registry management, and comprehensive error handling.
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Any, Optional, Callable, List
|
|
9
|
+
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
|
10
|
+
from packaging.version import Version, InvalidVersion
|
|
11
|
+
|
|
12
|
+
from .installer_base import DependencyInstaller, InstallationContext, InstallationResult, InstallationError
|
|
13
|
+
from .installation_context import InstallationStatus
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("hatch.installers.docker_installer")
|
|
16
|
+
logger.setLevel(logging.INFO)
|
|
17
|
+
|
|
18
|
+
# Handle docker-py import with graceful fallback
|
|
19
|
+
DOCKER_AVAILABLE = False
|
|
20
|
+
DOCKER_DAEMON_AVAILABLE = False
|
|
21
|
+
try:
|
|
22
|
+
import docker
|
|
23
|
+
from docker.errors import DockerException, ImageNotFound, APIError
|
|
24
|
+
DOCKER_AVAILABLE = True
|
|
25
|
+
try:
|
|
26
|
+
_docker_client = docker.from_env()
|
|
27
|
+
_docker_client.ping()
|
|
28
|
+
DOCKER_DAEMON_AVAILABLE = True
|
|
29
|
+
except DockerException as e:
|
|
30
|
+
logger.debug(f"docker-py library is available but Docker daemon is not running or not reachable: {e}")
|
|
31
|
+
except ImportError:
|
|
32
|
+
docker = None
|
|
33
|
+
DockerException = Exception
|
|
34
|
+
ImageNotFound = Exception
|
|
35
|
+
APIError = Exception
|
|
36
|
+
logger.debug("docker-py library not available. Docker installer will be disabled.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DockerInstaller(DependencyInstaller):
|
|
40
|
+
"""Installer for Docker image dependencies.
|
|
41
|
+
|
|
42
|
+
Handles installation and removal of Docker images using the docker-py library.
|
|
43
|
+
Supports version constraint mapping to Docker tags and progress reporting during
|
|
44
|
+
image pull operations.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
"""Initialize the DockerInstaller.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
InstallationError: If docker-py library is not available.
|
|
52
|
+
"""
|
|
53
|
+
if not DOCKER_AVAILABLE:
|
|
54
|
+
logger.error("Docker installer requires docker-py library")
|
|
55
|
+
self._docker_client = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def installer_type(self) -> str:
|
|
59
|
+
"""Get the installer type identifier.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
str: The installer type "docker".
|
|
63
|
+
"""
|
|
64
|
+
return "docker"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def supported_schemes(self) -> List[str]:
|
|
68
|
+
"""Get the list of supported registry schemes.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List[str]: List of supported schemes, currently only ["dockerhub"].
|
|
72
|
+
"""
|
|
73
|
+
return ["dockerhub"]
|
|
74
|
+
|
|
75
|
+
def can_install(self, dependency: Dict[str, Any]) -> bool:
|
|
76
|
+
"""Check if this installer can handle the given dependency.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
dependency (Dict[str, Any]): The dependency specification.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
bool: True if the dependency can be installed, False otherwise.
|
|
83
|
+
"""
|
|
84
|
+
if dependency.get("type") != "docker":
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
return self._is_docker_available()
|
|
88
|
+
|
|
89
|
+
def validate_dependency(self, dependency: Dict[str, Any]) -> bool:
|
|
90
|
+
"""Validate a Docker dependency specification.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
dependency (Dict[str, Any]): The dependency specification to validate.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
bool: True if the dependency is valid, False otherwise.
|
|
97
|
+
"""
|
|
98
|
+
required_fields = ["name", "version_constraint"]
|
|
99
|
+
|
|
100
|
+
# Check required fields
|
|
101
|
+
if not all(field in dependency for field in required_fields):
|
|
102
|
+
logger.error(f"Docker dependency missing required fields. Required: {required_fields}")
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Validate type
|
|
106
|
+
if dependency.get("type") != "docker":
|
|
107
|
+
logger.error(f"Invalid dependency type: {dependency.get('type')}, expected 'docker'")
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
# Validate registry if specified
|
|
111
|
+
registry = dependency.get("registry", "unknown")
|
|
112
|
+
if registry not in self.supported_schemes:
|
|
113
|
+
logger.error(f"Unsupported registry: {registry}, supported: {self.supported_schemes}")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# Validate version constraint format
|
|
117
|
+
version_constraint = dependency.get("version_constraint", "")
|
|
118
|
+
if not self._validate_version_constraint(version_constraint):
|
|
119
|
+
logger.error(f"Invalid version constraint format: {version_constraint}")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def install(self, dependency: Dict[str, Any], context: InstallationContext,
|
|
125
|
+
progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult:
|
|
126
|
+
"""Install a Docker image dependency.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
dependency (Dict[str, Any]): The dependency specification.
|
|
130
|
+
context (InstallationContext): Installation context and configuration.
|
|
131
|
+
progress_callback (Optional[Callable[[str, float, str], None]]): Progress reporting callback.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
InstallationResult: Result of the installation operation.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
InstallationError: If installation fails.
|
|
138
|
+
"""
|
|
139
|
+
if not self.validate_dependency(dependency):
|
|
140
|
+
raise InstallationError(
|
|
141
|
+
f"Invalid Docker dependency specification: {dependency}",
|
|
142
|
+
dependency_name=dependency.get("name", "unknown"),
|
|
143
|
+
error_code="DOCKER_DEPENDENCY_INVALID",
|
|
144
|
+
cause=ValueError("Dependency validation failed")
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
image_name = dependency["name"]
|
|
148
|
+
version_constraint = dependency["version_constraint"]
|
|
149
|
+
registry = dependency.get("registry", "dockerhub")
|
|
150
|
+
|
|
151
|
+
if progress_callback:
|
|
152
|
+
progress_callback(f"Starting Docker image pull: {image_name}", 0.0, "starting")
|
|
153
|
+
|
|
154
|
+
# Handle simulation mode
|
|
155
|
+
if context.simulation_mode:
|
|
156
|
+
logger.info(f"[SIMULATION] Would pull Docker image: {image_name}:{version_constraint}")
|
|
157
|
+
if progress_callback:
|
|
158
|
+
progress_callback(f"Simulated pull: {image_name}", 100.0, "completed")
|
|
159
|
+
return InstallationResult(
|
|
160
|
+
dependency_name=image_name,
|
|
161
|
+
status=InstallationStatus.COMPLETED,
|
|
162
|
+
installed_version=version_constraint,
|
|
163
|
+
artifacts=[],
|
|
164
|
+
metadata={
|
|
165
|
+
"message": f"Simulated installation of Docker image: {image_name}:{version_constraint}",
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# Resolve version constraint to Docker tag
|
|
171
|
+
docker_tag = self._resolve_docker_tag(version_constraint)
|
|
172
|
+
full_image_name = f"{image_name}:{docker_tag}"
|
|
173
|
+
|
|
174
|
+
# Pull the Docker image
|
|
175
|
+
self._pull_docker_image(full_image_name, progress_callback)
|
|
176
|
+
|
|
177
|
+
if progress_callback:
|
|
178
|
+
progress_callback(f"Completed pull: {image_name}", 100.0, "completed")
|
|
179
|
+
|
|
180
|
+
return InstallationResult(
|
|
181
|
+
dependency_name=image_name,
|
|
182
|
+
status=InstallationStatus.COMPLETED,
|
|
183
|
+
installed_version=docker_tag,
|
|
184
|
+
artifacts=[full_image_name],
|
|
185
|
+
metadata={
|
|
186
|
+
"message": f"Successfully installed Docker image: {full_image_name}",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
error_msg = f"Failed to install Docker image {image_name}: {str(e)}"
|
|
192
|
+
logger.error(error_msg)
|
|
193
|
+
if progress_callback:
|
|
194
|
+
progress_callback(f"Failed: {image_name}", 0.0, "error")
|
|
195
|
+
raise InstallationError(error_msg,
|
|
196
|
+
dependency_name=image_name,
|
|
197
|
+
error_code="DOCKER_INSTALL_ERROR",
|
|
198
|
+
cause=e)
|
|
199
|
+
|
|
200
|
+
def uninstall(self, dependency: Dict[str, Any], context: InstallationContext,
|
|
201
|
+
progress_callback: Optional[Callable[[str, float, str], None]] = None) -> InstallationResult:
|
|
202
|
+
"""Uninstall a Docker image dependency.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
dependency (Dict[str, Any]): The dependency specification.
|
|
206
|
+
context (InstallationContext): Installation context and configuration.
|
|
207
|
+
progress_callback (Optional[Callable[[str, float, str], None]]): Progress reporting callback.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
InstallationResult: Result of the uninstallation operation.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
InstallationError: If uninstallation fails.
|
|
214
|
+
"""
|
|
215
|
+
if not self.validate_dependency(dependency):
|
|
216
|
+
raise InstallationError(f"Invalid Docker dependency specification: {dependency}")
|
|
217
|
+
|
|
218
|
+
image_name = dependency["name"]
|
|
219
|
+
version_constraint = dependency["version_constraint"]
|
|
220
|
+
|
|
221
|
+
if progress_callback:
|
|
222
|
+
progress_callback(f"Starting Docker image removal: {image_name}", 0.0, "starting")
|
|
223
|
+
|
|
224
|
+
# Handle simulation mode
|
|
225
|
+
if context.simulation_mode:
|
|
226
|
+
logger.info(f"[SIMULATION] Would remove Docker image: {image_name}:{version_constraint}")
|
|
227
|
+
if progress_callback:
|
|
228
|
+
progress_callback(f"Simulated removal: {image_name}", 100.0, "completed")
|
|
229
|
+
return InstallationResult(
|
|
230
|
+
dependency_name=image_name,
|
|
231
|
+
status=InstallationStatus.COMPLETED,
|
|
232
|
+
installed_version=version_constraint,
|
|
233
|
+
artifacts=[],
|
|
234
|
+
metadata={
|
|
235
|
+
"message": f"Simulated removal of Docker image: {image_name}:{version_constraint}",
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Resolve version constraint to Docker tag
|
|
241
|
+
docker_tag = self._resolve_docker_tag(version_constraint)
|
|
242
|
+
full_image_name = f"{image_name}:{docker_tag}"
|
|
243
|
+
|
|
244
|
+
# Remove the Docker image
|
|
245
|
+
self._remove_docker_image(full_image_name, context, progress_callback)
|
|
246
|
+
|
|
247
|
+
if progress_callback:
|
|
248
|
+
progress_callback(f"Completed removal: {image_name}", 100.0, "completed")
|
|
249
|
+
|
|
250
|
+
return InstallationResult(
|
|
251
|
+
dependency_name=image_name,
|
|
252
|
+
status=InstallationStatus.COMPLETED,
|
|
253
|
+
installed_version=docker_tag,
|
|
254
|
+
artifacts=[],
|
|
255
|
+
metadata={
|
|
256
|
+
"message": f"Successfully removed Docker image: {full_image_name}",
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
error_msg = f"Failed to remove Docker image {image_name}: {str(e)}"
|
|
262
|
+
logger.error(error_msg)
|
|
263
|
+
if progress_callback:
|
|
264
|
+
progress_callback(f"Failed removal: {image_name}", 0.0, "error")
|
|
265
|
+
raise InstallationError(error_msg,
|
|
266
|
+
dependency_name=image_name,
|
|
267
|
+
error_code="DOCKER_UNINSTALL_ERROR",
|
|
268
|
+
cause=e)
|
|
269
|
+
|
|
270
|
+
def cleanup_failed_installation(self, dependency: Dict[str, Any], context: InstallationContext,
|
|
271
|
+
artifacts: Optional[List[Path]] = None) -> None:
|
|
272
|
+
"""Clean up artifacts from a failed installation.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
dependency (Dict[str, Any]): The dependency that failed to install.
|
|
276
|
+
context (InstallationContext): Installation context.
|
|
277
|
+
artifacts (Optional[List[Path]]): List of artifacts to clean up.
|
|
278
|
+
"""
|
|
279
|
+
if not artifacts:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
logger.info(f"Cleaning up failed Docker installation for {dependency.get('name', 'unknown')}")
|
|
283
|
+
|
|
284
|
+
for artifact in artifacts:
|
|
285
|
+
if isinstance(artifact, str): # Docker image name
|
|
286
|
+
try:
|
|
287
|
+
self._remove_docker_image(artifact, context, None, force=True)
|
|
288
|
+
logger.info(f"Cleaned up Docker image: {artifact}")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.warning(f"Failed to clean up Docker image {artifact}: {e}")
|
|
291
|
+
|
|
292
|
+
def _is_docker_available(self) -> bool:
|
|
293
|
+
"""Check if Docker daemon is available.
|
|
294
|
+
|
|
295
|
+
We use the global DOCKER_DAEMON_AVAILABLE flag to determine
|
|
296
|
+
if Docker is available. It is set to True if the docker-py
|
|
297
|
+
library is available and the Docker daemon is reachable.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
bool: True if Docker daemon is available, False otherwise.
|
|
301
|
+
"""
|
|
302
|
+
return DOCKER_DAEMON_AVAILABLE
|
|
303
|
+
|
|
304
|
+
def _get_docker_client(self):
|
|
305
|
+
"""Get or create Docker client.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
docker.DockerClient: Docker client instance.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
InstallationError: If Docker client cannot be created.
|
|
312
|
+
"""
|
|
313
|
+
if not DOCKER_AVAILABLE:
|
|
314
|
+
raise InstallationError(
|
|
315
|
+
"Docker library not available",
|
|
316
|
+
error_code="DOCKER_LIBRARY_NOT_AVAILABLE",
|
|
317
|
+
cause=ImportError("docker-py library is required for Docker support")
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if not DOCKER_DAEMON_AVAILABLE:
|
|
321
|
+
raise InstallationError(
|
|
322
|
+
"Docker daemon not available",
|
|
323
|
+
error_code="DOCKER_DAEMON_NOT_AVAILABLE",
|
|
324
|
+
cause=e
|
|
325
|
+
)
|
|
326
|
+
if self._docker_client is None:
|
|
327
|
+
self._docker_client = docker.from_env()
|
|
328
|
+
return self._docker_client
|
|
329
|
+
|
|
330
|
+
def _validate_version_constraint(self, version_constraint: str) -> bool:
|
|
331
|
+
"""Validate version constraint format.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
version_constraint (str): Version constraint to validate.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
bool: True if valid, False otherwise.
|
|
338
|
+
"""
|
|
339
|
+
if not version_constraint or not isinstance(version_constraint, str):
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
# Accept "latest" as a valid constraint
|
|
343
|
+
if version_constraint.strip() == "latest":
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
constraint = version_constraint.strip()
|
|
347
|
+
|
|
348
|
+
# Accept bare version numbers (e.g. 1.25.0) as valid
|
|
349
|
+
try:
|
|
350
|
+
Version(constraint)
|
|
351
|
+
return True
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
# Accept valid PEP 440 specifiers (e.g. >=1.25.0, ==1.25.0)
|
|
356
|
+
try:
|
|
357
|
+
SpecifierSet(constraint)
|
|
358
|
+
return True
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.error(f"Invalid version constraint format: {version_constraint}")
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
def _resolve_docker_tag(self, version_constraint: str) -> str:
|
|
364
|
+
"""Resolve version constraint to Docker tag.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
version_constraint (str): Version constraint specification.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
str: Docker tag to use.
|
|
371
|
+
"""
|
|
372
|
+
constraint = version_constraint.strip()
|
|
373
|
+
# Handle simple cases
|
|
374
|
+
if constraint == "latest":
|
|
375
|
+
return "latest"
|
|
376
|
+
|
|
377
|
+
# Accept bare version numbers as tags
|
|
378
|
+
try:
|
|
379
|
+
Version(constraint)
|
|
380
|
+
return constraint
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
# Try to parse as a version specifier
|
|
385
|
+
try:
|
|
386
|
+
spec = SpecifierSet(constraint)
|
|
387
|
+
except InvalidSpecifier:
|
|
388
|
+
logger.warning(f"Invalid version constraint '{constraint}', defaulting to 'latest'")
|
|
389
|
+
return "latest"
|
|
390
|
+
|
|
391
|
+
return next(iter(spec)).version # always returns the first matching spec's version
|
|
392
|
+
|
|
393
|
+
def _pull_docker_image(self, image_name: str, progress_callback: Optional[Callable[[str, float, str], None]]):
|
|
394
|
+
"""Pull Docker image with progress reporting.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
image_name (str): Full image name with tag.
|
|
398
|
+
progress_callback (Optional[Callable[[str, float, str], None]]): Progress callback.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
InstallationError: If pull fails.
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
client = self._get_docker_client()
|
|
405
|
+
|
|
406
|
+
if progress_callback:
|
|
407
|
+
progress_callback(f"Pulling {image_name}", 50.0, "pulling")
|
|
408
|
+
|
|
409
|
+
# Pull the image
|
|
410
|
+
client.images.pull(image_name)
|
|
411
|
+
|
|
412
|
+
logger.info(f"Successfully pulled Docker image: {image_name}")
|
|
413
|
+
|
|
414
|
+
except ImageNotFound as e:
|
|
415
|
+
raise InstallationError(
|
|
416
|
+
f"Docker image not found: {image_name}",
|
|
417
|
+
error_code="DOCKER_IMAGE_NOT_FOUND",
|
|
418
|
+
cause=e
|
|
419
|
+
)
|
|
420
|
+
except APIError as e:
|
|
421
|
+
raise InstallationError(
|
|
422
|
+
f"Docker API error while pulling {image_name}: {e}",
|
|
423
|
+
error_code="DOCKER_API_ERROR",
|
|
424
|
+
cause=e
|
|
425
|
+
)
|
|
426
|
+
except DockerException as e:
|
|
427
|
+
raise InstallationError(
|
|
428
|
+
f"Docker error while pulling {image_name}: {e}",
|
|
429
|
+
error_code="DOCKER_ERROR",
|
|
430
|
+
cause=e
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _remove_docker_image(self, image_name: str, context: InstallationContext,
|
|
434
|
+
progress_callback: Optional[Callable[[str, float, str], None]],
|
|
435
|
+
force: bool = False):
|
|
436
|
+
"""Remove Docker image.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
image_name (str): Full image name with tag.
|
|
440
|
+
context (InstallationContext): Installation context.
|
|
441
|
+
progress_callback (Optional[Callable[[str, float, str], None]]): Progress callback.
|
|
442
|
+
force (bool): Whether to force removal even if image is in use.
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
InstallationError: If removal fails.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
client = self._get_docker_client()
|
|
449
|
+
|
|
450
|
+
if progress_callback:
|
|
451
|
+
progress_callback(f"Removing {image_name}", 50.0, "removing")
|
|
452
|
+
|
|
453
|
+
# Check if image is in use (unless forcing)
|
|
454
|
+
if not force and self._is_image_in_use(image_name):
|
|
455
|
+
raise InstallationError(
|
|
456
|
+
f"Cannot remove Docker image {image_name} as it is in use by running containers",
|
|
457
|
+
error_code="DOCKER_IMAGE_IN_USE"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Remove the image
|
|
461
|
+
client.images.remove(image_name, force=force)
|
|
462
|
+
|
|
463
|
+
logger.info(f"Successfully removed Docker image: {image_name}")
|
|
464
|
+
|
|
465
|
+
except ImageNotFound:
|
|
466
|
+
logger.warning(f"Docker image not found during removal: {image_name}. Nothing to remove.")
|
|
467
|
+
except APIError as e:
|
|
468
|
+
raise InstallationError(
|
|
469
|
+
f"Docker API error while removing {image_name}: {e}",
|
|
470
|
+
error_code="DOCKER_API_ERROR",
|
|
471
|
+
cause=e
|
|
472
|
+
)
|
|
473
|
+
except DockerException as e:
|
|
474
|
+
raise InstallationError(
|
|
475
|
+
f"Docker error while removing {image_name}: {e}",
|
|
476
|
+
error_code="DOCKER_ERROR",
|
|
477
|
+
cause=e
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def _is_image_in_use(self, image_name: str) -> bool:
|
|
481
|
+
"""Check if Docker image is in use by running containers.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
image_name (str): Image name to check.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
bool: True if image is in use, False otherwise.
|
|
488
|
+
"""
|
|
489
|
+
try:
|
|
490
|
+
client = self._get_docker_client()
|
|
491
|
+
containers = client.containers.list(all=True)
|
|
492
|
+
|
|
493
|
+
for container in containers:
|
|
494
|
+
if container.image.tags and any(tag == image_name for tag in container.image.tags):
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.warning(f"Could not check if image {image_name} is in use: {e}\n Assuming NOT in use.")
|
|
501
|
+
return False # Assume not in use if we can't check
|
|
502
|
+
|
|
503
|
+
def get_installation_info(self, dependency: Dict[str, Any], context: InstallationContext) -> Dict[str, Any]:
|
|
504
|
+
"""Get information about Docker image installation.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
dependency (Dict[str, Any]): The dependency specification.
|
|
508
|
+
context (InstallationContext): Installation context.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Dict[str, Any]: Installation information including availability and status.
|
|
512
|
+
"""
|
|
513
|
+
image_name = dependency.get("name", "unknown")
|
|
514
|
+
version_constraint = dependency.get("version_constraint", "latest")
|
|
515
|
+
|
|
516
|
+
info = {
|
|
517
|
+
"installer_type": self.installer_type,
|
|
518
|
+
"dependency_name": image_name,
|
|
519
|
+
"version_constraint": version_constraint,
|
|
520
|
+
"docker_available": self._is_docker_available(),
|
|
521
|
+
"can_install": self.can_install(dependency)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if self._is_docker_available():
|
|
525
|
+
try:
|
|
526
|
+
docker_tag = self._resolve_docker_tag(version_constraint)
|
|
527
|
+
full_image_name = f"{image_name}:{docker_tag}"
|
|
528
|
+
|
|
529
|
+
client = self._get_docker_client()
|
|
530
|
+
try:
|
|
531
|
+
image = client.images.get(full_image_name)
|
|
532
|
+
info["installed"] = True
|
|
533
|
+
info["image_id"] = image.id
|
|
534
|
+
info["image_tags"] = image.tags
|
|
535
|
+
except ImageNotFound:
|
|
536
|
+
info["installed"] = False
|
|
537
|
+
|
|
538
|
+
except Exception as e:
|
|
539
|
+
info["error"] = str(e)
|
|
540
|
+
|
|
541
|
+
return info
|
|
542
|
+
|
|
543
|
+
# Register this installer with the global registry
|
|
544
|
+
from .registry import installer_registry
|
|
545
|
+
installer_registry.register_installer("docker", DockerInstaller)
|