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,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)