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,458 @@
1
+ """MCP host configuration backup system.
2
+
3
+ This module provides comprehensive backup and restore functionality for MCP
4
+ host configuration files with atomic operations and Pydantic data validation.
5
+ """
6
+
7
+ import json
8
+ import shutil
9
+ import tempfile
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Any
13
+
14
+ from pydantic import BaseModel, Field, validator
15
+
16
+
17
+ class BackupError(Exception):
18
+ """Exception raised when backup operations fail."""
19
+ pass
20
+
21
+
22
+ class RestoreError(Exception):
23
+ """Exception raised when restore operations fail."""
24
+ pass
25
+
26
+
27
+ class BackupInfo(BaseModel):
28
+ """Information about a backup file with validation."""
29
+ hostname: str = Field(..., description="Host identifier")
30
+ timestamp: datetime = Field(..., description="Backup creation timestamp")
31
+ file_path: Path = Field(..., description="Path to backup file")
32
+ file_size: int = Field(..., ge=0, description="Backup file size in bytes")
33
+ original_config_path: Path = Field(..., description="Original configuration file path")
34
+
35
+ @validator('hostname')
36
+ def validate_hostname(cls, v):
37
+ """Validate hostname is supported."""
38
+ supported_hosts = {
39
+ 'claude-desktop', 'claude-code', 'vscode',
40
+ 'cursor', 'lmstudio', 'gemini'
41
+ }
42
+ if v not in supported_hosts:
43
+ raise ValueError(f"Unsupported hostname: {v}. Supported: {supported_hosts}")
44
+ return v
45
+
46
+ @validator('file_path')
47
+ def validate_file_exists(cls, v):
48
+ """Validate backup file exists."""
49
+ if not v.exists():
50
+ raise ValueError(f"Backup file does not exist: {v}")
51
+ return v
52
+
53
+ @property
54
+ def backup_name(self) -> str:
55
+ """Get backup filename."""
56
+ return f"mcp.json.{self.hostname}.{self.timestamp.strftime('%Y%m%d_%H%M%S_%f')}"
57
+
58
+ @property
59
+ def age_days(self) -> int:
60
+ """Get backup age in days."""
61
+ return (datetime.now() - self.timestamp).days
62
+
63
+ class Config:
64
+ """Pydantic configuration."""
65
+ arbitrary_types_allowed = True
66
+ json_encoders = {
67
+ Path: str,
68
+ datetime: lambda v: v.isoformat()
69
+ }
70
+
71
+
72
+ class BackupResult(BaseModel):
73
+ """Result of backup operation with validation."""
74
+ success: bool = Field(..., description="Operation success status")
75
+ backup_path: Optional[Path] = Field(None, description="Path to created backup")
76
+ error_message: Optional[str] = Field(None, description="Error message if failed")
77
+ original_size: int = Field(0, ge=0, description="Original file size in bytes")
78
+ backup_size: int = Field(0, ge=0, description="Backup file size in bytes")
79
+
80
+ @validator('backup_path')
81
+ def validate_backup_path_on_success(cls, v, values):
82
+ """Validate backup_path is provided when success is True."""
83
+ if values.get('success') and v is None:
84
+ raise ValueError("backup_path must be provided when success is True")
85
+ return v
86
+
87
+ @validator('error_message')
88
+ def validate_error_message_on_failure(cls, v, values):
89
+ """Validate error_message is provided when success is False."""
90
+ if not values.get('success') and not v:
91
+ raise ValueError("error_message must be provided when success is False")
92
+ return v
93
+
94
+ class Config:
95
+ """Pydantic configuration."""
96
+ arbitrary_types_allowed = True
97
+ json_encoders = {
98
+ Path: str
99
+ }
100
+
101
+
102
+ class AtomicFileOperations:
103
+ """Atomic file operations for safe configuration updates."""
104
+
105
+ def atomic_write_with_backup(self, file_path: Path, data: Dict[str, Any],
106
+ backup_manager: "MCPHostConfigBackupManager",
107
+ hostname: str, skip_backup: bool = False) -> bool:
108
+ """Atomic write with automatic backup creation.
109
+
110
+ Args:
111
+ file_path (Path): Target file path for writing
112
+ data (Dict[str, Any]): Data to write as JSON
113
+ backup_manager (MCPHostConfigBackupManager): Backup manager instance
114
+ hostname (str): Host identifier for backup
115
+ skip_backup (bool, optional): Skip backup creation. Defaults to False.
116
+
117
+ Returns:
118
+ bool: True if operation successful, False otherwise
119
+
120
+ Raises:
121
+ BackupError: If backup creation fails and skip_backup is False
122
+ """
123
+ # Create backup if file exists and backup not skipped
124
+ backup_result = None
125
+ if file_path.exists() and not skip_backup:
126
+ backup_result = backup_manager.create_backup(file_path, hostname)
127
+ if not backup_result.success:
128
+ raise BackupError(f"Required backup failed: {backup_result.error_message}")
129
+
130
+ # Create temporary file for atomic write
131
+ temp_file = None
132
+ try:
133
+ # Write to temporary file first
134
+ temp_file = file_path.with_suffix(f"{file_path.suffix}.tmp")
135
+ with open(temp_file, 'w', encoding='utf-8') as f:
136
+ json.dump(data, f, indent=2, ensure_ascii=False)
137
+
138
+ # Atomic move to target location
139
+ temp_file.replace(file_path)
140
+ return True
141
+
142
+ except Exception as e:
143
+ # Clean up temporary file on failure
144
+ if temp_file and temp_file.exists():
145
+ temp_file.unlink()
146
+
147
+ # Restore from backup if available
148
+ if backup_result and backup_result.backup_path:
149
+ try:
150
+ backup_manager.restore_backup(hostname, backup_result.backup_path.name)
151
+ except Exception:
152
+ pass # Log but don't raise - original error is more important
153
+
154
+ raise BackupError(f"Atomic write failed: {str(e)}")
155
+
156
+ def atomic_copy(self, source: Path, target: Path) -> bool:
157
+ """Atomic file copy operation.
158
+
159
+ Args:
160
+ source (Path): Source file path
161
+ target (Path): Target file path
162
+
163
+ Returns:
164
+ bool: True if copy successful, False otherwise
165
+ """
166
+ try:
167
+ # Create temporary target file
168
+ temp_target = target.with_suffix(f"{target.suffix}.tmp")
169
+
170
+ # Copy to temporary location
171
+ shutil.copy2(source, temp_target)
172
+
173
+ # Atomic move to final location
174
+ temp_target.replace(target)
175
+ return True
176
+
177
+ except Exception:
178
+ # Clean up temporary file on failure
179
+ temp_target = target.with_suffix(f"{target.suffix}.tmp")
180
+ if temp_target.exists():
181
+ temp_target.unlink()
182
+ return False
183
+
184
+
185
+ class MCPHostConfigBackupManager:
186
+ """Manages MCP host configuration backups."""
187
+
188
+ def __init__(self, backup_root: Optional[Path] = None):
189
+ """Initialize backup manager.
190
+
191
+ Args:
192
+ backup_root (Path, optional): Root directory for backups.
193
+ Defaults to ~/.hatch/mcp_host_config_backups/
194
+ """
195
+ self.backup_root = backup_root or Path.home() / ".hatch" / "mcp_host_config_backups"
196
+ self.backup_root.mkdir(parents=True, exist_ok=True)
197
+ self.atomic_ops = AtomicFileOperations()
198
+
199
+ def create_backup(self, config_path: Path, hostname: str) -> BackupResult:
200
+ """Create timestamped backup of host configuration.
201
+
202
+ Args:
203
+ config_path (Path): Path to original configuration file
204
+ hostname (str): Host identifier (claude-desktop, claude-code, vscode, cursor, lmstudio, gemini)
205
+
206
+ Returns:
207
+ BackupResult: Operation result with backup path or error message
208
+ """
209
+ try:
210
+ # Validate inputs
211
+ if not config_path.exists():
212
+ return BackupResult(
213
+ success=False,
214
+ error_message=f"Configuration file not found: {config_path}"
215
+ )
216
+
217
+ # Validate hostname using Pydantic
218
+ try:
219
+ BackupInfo.validate_hostname(hostname)
220
+ except ValueError as e:
221
+ return BackupResult(
222
+ success=False,
223
+ error_message=str(e)
224
+ )
225
+
226
+ # Create host-specific backup directory
227
+ host_backup_dir = self.backup_root / hostname
228
+ host_backup_dir.mkdir(exist_ok=True)
229
+
230
+ # Generate timestamped backup filename with microseconds for uniqueness
231
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
232
+ backup_name = f"mcp.json.{hostname}.{timestamp}"
233
+ backup_path = host_backup_dir / backup_name
234
+
235
+ # Get original file size
236
+ original_size = config_path.stat().st_size
237
+
238
+ # Atomic copy operation
239
+ if not self.atomic_ops.atomic_copy(config_path, backup_path):
240
+ return BackupResult(
241
+ success=False,
242
+ error_message="Atomic copy operation failed"
243
+ )
244
+
245
+ # Verify backup integrity
246
+ backup_size = backup_path.stat().st_size
247
+ if backup_size != original_size:
248
+ backup_path.unlink()
249
+ return BackupResult(
250
+ success=False,
251
+ error_message="Backup size mismatch - backup deleted"
252
+ )
253
+
254
+ return BackupResult(
255
+ success=True,
256
+ backup_path=backup_path,
257
+ original_size=original_size,
258
+ backup_size=backup_size
259
+ )
260
+
261
+ except Exception as e:
262
+ return BackupResult(
263
+ success=False,
264
+ error_message=f"Backup creation failed: {str(e)}"
265
+ )
266
+
267
+ def restore_backup(self, hostname: str, backup_file: Optional[str] = None) -> bool:
268
+ """Restore configuration from backup.
269
+
270
+ Args:
271
+ hostname (str): Host identifier
272
+ backup_file (str, optional): Specific backup file name. Defaults to latest.
273
+
274
+ Returns:
275
+ bool: True if restoration successful, False otherwise
276
+ """
277
+ try:
278
+ # Get backup file path
279
+ if backup_file:
280
+ backup_path = self.backup_root / hostname / backup_file
281
+ else:
282
+ backup_path = self._get_latest_backup(hostname)
283
+
284
+ if not backup_path or not backup_path.exists():
285
+ return False
286
+
287
+ # Get target configuration path using host registry
288
+ from .host_management import MCPHostRegistry
289
+ from .models import MCPHostType
290
+
291
+ try:
292
+ host_type = MCPHostType(hostname)
293
+ target_path = MCPHostRegistry.get_host_config_path(host_type)
294
+
295
+ if not target_path:
296
+ return False
297
+
298
+ # Ensure target directory exists
299
+ target_path.parent.mkdir(parents=True, exist_ok=True)
300
+
301
+ # Perform atomic restore operation
302
+ return self.atomic_ops.atomic_copy(backup_path, target_path)
303
+
304
+ except ValueError:
305
+ # Invalid hostname
306
+ return False
307
+
308
+ except Exception:
309
+ return False
310
+
311
+ def list_backups(self, hostname: str) -> List[BackupInfo]:
312
+ """List available backups for hostname.
313
+
314
+ Args:
315
+ hostname (str): Host identifier
316
+
317
+ Returns:
318
+ List[BackupInfo]: List of backup information objects
319
+ """
320
+ host_backup_dir = self.backup_root / hostname
321
+
322
+ if not host_backup_dir.exists():
323
+ return []
324
+
325
+ backups = []
326
+
327
+ # Search for both correct format and legacy incorrect format for backward compatibility
328
+ patterns = [
329
+ f"mcp.json.{hostname}.*", # Correct format: mcp.json.gemini.*
330
+ f"mcp.json.MCPHostType.{hostname.upper()}.*" # Legacy incorrect format: mcp.json.MCPHostType.GEMINI.*
331
+ ]
332
+
333
+ for pattern in patterns:
334
+ for backup_file in host_backup_dir.glob(pattern):
335
+ try:
336
+ # Parse timestamp from filename
337
+ timestamp_str = backup_file.name.split('.')[-1]
338
+ timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S_%f")
339
+
340
+ backup_info = BackupInfo(
341
+ hostname=hostname,
342
+ timestamp=timestamp,
343
+ file_path=backup_file,
344
+ file_size=backup_file.stat().st_size,
345
+ original_config_path=Path("placeholder") # Will be implemented in host config phase
346
+ )
347
+ backups.append(backup_info)
348
+
349
+ except (ValueError, OSError):
350
+ # Skip invalid backup files
351
+ continue
352
+
353
+ # Sort by timestamp (newest first)
354
+ return sorted(backups, key=lambda b: b.timestamp, reverse=True)
355
+
356
+ def clean_backups(self, hostname: str, **filters) -> int:
357
+ """Clean old backups based on filters.
358
+
359
+ Args:
360
+ hostname (str): Host identifier
361
+ **filters: Filter criteria (e.g., older_than_days, keep_count)
362
+
363
+ Returns:
364
+ int: Number of backups cleaned
365
+ """
366
+ backups = self.list_backups(hostname)
367
+ cleaned_count = 0
368
+
369
+ # Apply filters
370
+ older_than_days = filters.get('older_than_days')
371
+ keep_count = filters.get('keep_count')
372
+
373
+ if older_than_days:
374
+ for backup in backups:
375
+ if backup.age_days > older_than_days:
376
+ try:
377
+ backup.file_path.unlink()
378
+ cleaned_count += 1
379
+ except OSError:
380
+ continue
381
+
382
+ if keep_count and len(backups) > keep_count:
383
+ # Keep newest backups, remove oldest
384
+ to_remove = backups[keep_count:]
385
+ for backup in to_remove:
386
+ try:
387
+ backup.file_path.unlink()
388
+ cleaned_count += 1
389
+ except OSError:
390
+ continue
391
+
392
+ return cleaned_count
393
+
394
+ def _get_latest_backup(self, hostname: str) -> Optional[Path]:
395
+ """Get path to latest backup for hostname.
396
+
397
+ Args:
398
+ hostname (str): Host identifier
399
+
400
+ Returns:
401
+ Optional[Path]: Path to latest backup or None if no backups exist
402
+ """
403
+ backups = self.list_backups(hostname)
404
+ return backups[0].file_path if backups else None
405
+
406
+
407
+ class BackupAwareOperation:
408
+ """Base class for operations that require backup awareness."""
409
+
410
+ def __init__(self, backup_manager: MCPHostConfigBackupManager):
411
+ """Initialize backup-aware operation.
412
+
413
+ Args:
414
+ backup_manager (MCPHostConfigBackupManager): Backup manager instance
415
+ """
416
+ self.backup_manager = backup_manager
417
+
418
+ def prepare_backup(self, config_path: Path, hostname: str,
419
+ no_backup: bool = False) -> Optional[BackupResult]:
420
+ """Prepare backup before operation if required.
421
+
422
+ Args:
423
+ config_path (Path): Path to configuration file
424
+ hostname (str): Host identifier
425
+ no_backup (bool, optional): Skip backup creation. Defaults to False.
426
+
427
+ Returns:
428
+ Optional[BackupResult]: BackupResult if backup created, None if skipped
429
+
430
+ Raises:
431
+ BackupError: If backup required but fails
432
+ """
433
+ if no_backup:
434
+ return None
435
+
436
+ backup_result = self.backup_manager.create_backup(config_path, hostname)
437
+ if not backup_result.success:
438
+ raise BackupError(f"Required backup failed: {backup_result.error_message}")
439
+
440
+ return backup_result
441
+
442
+ def rollback_on_failure(self, backup_result: Optional[BackupResult],
443
+ config_path: Path, hostname: str) -> bool:
444
+ """Rollback configuration on operation failure.
445
+
446
+ Args:
447
+ backup_result (Optional[BackupResult]): Result from prepare_backup
448
+ config_path (Path): Path to configuration file
449
+ hostname (str): Host identifier
450
+
451
+ Returns:
452
+ bool: True if rollback successful, False otherwise
453
+ """
454
+ if backup_result and backup_result.backup_path:
455
+ return self.backup_manager.restore_backup(
456
+ hostname, backup_result.backup_path.name
457
+ )
458
+ return False