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,179 @@
1
+ import unittest
2
+ import tempfile
3
+ import shutil
4
+ import logging
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+
8
+ from wobble.decorators import regression_test, integration_test, slow_test
9
+
10
+ from hatch.installers.hatch_installer import HatchInstaller
11
+ from hatch.package_loader import HatchPackageLoader
12
+ from hatch_validator.package_validator import HatchPackageValidator
13
+ from hatch_validator.package.package_service import PackageService
14
+
15
+ from hatch.installers.installation_context import InstallationStatus
16
+
17
+ class TestHatchInstaller(unittest.TestCase):
18
+ """Tests for the HatchInstaller using dummy packages from Hatching-Dev."""
19
+
20
+ @classmethod
21
+ def setUpClass(cls):
22
+ # Path to Hatching-Dev dummy packages
23
+ cls.hatch_dev_path = Path(__file__).parent.parent.parent / "Hatching-Dev"
24
+ assert cls.hatch_dev_path.exists(), f"Hatching-Dev directory not found at {cls.hatch_dev_path}"
25
+
26
+ # Build a mock registry from Hatching-Dev packages (pattern from test_package_validator.py)
27
+ cls.registry_data = cls._build_test_registry(cls.hatch_dev_path)
28
+ cls.validator = HatchPackageValidator(registry_data=cls.registry_data)
29
+ cls.package_loader = HatchPackageLoader()
30
+ cls.installer = HatchInstaller()
31
+
32
+ @staticmethod
33
+ def _build_test_registry(hatch_dev_path):
34
+ registry = {
35
+ "registry_schema_version": "1.1.0",
36
+ "last_updated": datetime.now().isoformat(),
37
+ "repositories": [
38
+ {
39
+ "name": "Hatch-Dev",
40
+ "url": "file://" + str(hatch_dev_path),
41
+ "packages": [],
42
+ "last_indexed": datetime.now().isoformat()
43
+ }
44
+ ]
45
+ }
46
+ # Use self-contained test packages instead of external Hatching-Dev
47
+ from test_data_utils import TestDataLoader
48
+ test_loader = TestDataLoader()
49
+
50
+ pkg_names = [
51
+ "base_pkg", "utility_pkg", "python_dep_pkg",
52
+ "circular_dep_pkg", "circular_dep_pkg_b", "complex_dep_pkg",
53
+ "simple_dep_pkg", "invalid_dep_pkg", "version_conflict_pkg"
54
+ ]
55
+ for pkg_name in pkg_names:
56
+ # Map to self-contained package locations
57
+ if pkg_name in ["base_pkg", "utility_pkg"]:
58
+ pkg_path = test_loader.packages_dir / "basic" / pkg_name
59
+ elif pkg_name in ["complex_dep_pkg", "simple_dep_pkg", "python_dep_pkg"]:
60
+ pkg_path = test_loader.packages_dir / "dependencies" / pkg_name
61
+ elif pkg_name in ["circular_dep_pkg", "circular_dep_pkg_b", "invalid_dep_pkg", "version_conflict_pkg"]:
62
+ pkg_path = test_loader.packages_dir / "error_scenarios" / pkg_name
63
+ else:
64
+ pkg_path = test_loader.packages_dir / pkg_name
65
+ if pkg_path.exists():
66
+ metadata_path = pkg_path / "hatch_metadata.json"
67
+ if metadata_path.exists():
68
+ with open(metadata_path, 'r') as f:
69
+ import json
70
+ metadata = json.load(f)
71
+ pkg_entry = {
72
+ "name": metadata.get("name", pkg_name),
73
+ "description": metadata.get("description", ""),
74
+ "category": "development",
75
+ "tags": metadata.get("tags", []),
76
+ "latest_version": metadata.get("version", "1.0.0"),
77
+ "versions": [
78
+ {
79
+ "version": metadata.get("version", "1.0.0"),
80
+ "release_uri": f"file://{pkg_path}",
81
+ "author": {
82
+ "GitHubID": metadata.get("author", {}).get("name", "test_user"),
83
+ "email": metadata.get("author", {}).get("email", "test@example.com")
84
+ },
85
+ "added_date": datetime.now().isoformat(),
86
+ "hatch_dependencies_added": [
87
+ {
88
+ "name": dep["name"],
89
+ "version_constraint": dep.get("version_constraint", "")
90
+ }
91
+ for dep in metadata.get("hatch_dependencies", [])
92
+ ],
93
+ "python_dependencies_added": [
94
+ {
95
+ "name": dep["name"],
96
+ "version_constraint": dep.get("version_constraint", ""),
97
+ "package_manager": dep.get("package_manager", "pip")
98
+ }
99
+ for dep in metadata.get("python_dependencies", [])
100
+ ],
101
+ }
102
+ ]
103
+ }
104
+ registry["repositories"][0]["packages"].append(pkg_entry)
105
+ return registry
106
+
107
+ def setUp(self):
108
+ # Create a temporary directory for installs
109
+ self.temp_dir = tempfile.mkdtemp()
110
+ self.target_dir = Path(self.temp_dir) / "target"
111
+ self.target_dir.mkdir(parents=True, exist_ok=True)
112
+
113
+ def tearDown(self):
114
+ shutil.rmtree(self.temp_dir)
115
+
116
+ @regression_test
117
+ def test_installer_can_install_and_uninstall(self):
118
+ """Test the full install and uninstall cycle for a dummy Hatch package using the installer."""
119
+ pkg_name = "base_pkg"
120
+ from test_data_utils import TestDataLoader
121
+ test_loader = TestDataLoader()
122
+ pkg_path = test_loader.packages_dir / "basic" / pkg_name
123
+ metadata_path = pkg_path / "hatch_metadata.json"
124
+ with open(metadata_path, 'r') as f:
125
+ import json
126
+ metadata = json.load(f)
127
+ dependency = {
128
+ "name": pkg_name,
129
+ "version_constraint": metadata.get("version", "1.0.0"),
130
+ "resolved_version": metadata.get("version", "1.0.0"),
131
+ "type": "hatch",
132
+ "uri": f"file://{pkg_path}"
133
+ }
134
+ # Prepare a minimal InstallationContext
135
+ class DummyContext:
136
+ environment_path = str(self.target_dir)
137
+ context = DummyContext()
138
+ # Install
139
+ result = self.installer.install(dependency, context)
140
+ self.assertEqual(result.status, InstallationStatus.COMPLETED)
141
+ installed_path = Path(result.installed_path)
142
+ self.assertTrue(installed_path.exists())
143
+ # Uninstall
144
+ uninstall_result = self.installer.uninstall(dependency, context)
145
+ self.assertEqual(uninstall_result.status, InstallationStatus.COMPLETED)
146
+ self.assertFalse(installed_path.exists())
147
+
148
+ @regression_test
149
+ def test_installer_rejects_invalid_dependency(self):
150
+ """Test that the installer rejects dependencies missing required fields."""
151
+ invalid_dep = {"name": "foo"} # Missing required fields
152
+ self.assertFalse(self.installer.validate_dependency(invalid_dep))
153
+
154
+ @regression_test
155
+ def test_installation_error_on_missing_uri(self):
156
+ """Test that the installer raises InstallationError if no URI is provided."""
157
+ pkg_name = "base_pkg"
158
+ dependency = {
159
+ "name": pkg_name,
160
+ "version_constraint": "1.0.0",
161
+ "resolved_version": "1.0.0",
162
+ "type": "hatch"
163
+ }
164
+ class DummyContext:
165
+ environment_path = str(self.target_dir)
166
+ context = DummyContext()
167
+ with self.assertRaises(Exception):
168
+ self.installer.install(dependency, context)
169
+
170
+ @regression_test
171
+ def test_can_install_method(self):
172
+ """Test the can_install method for correct dependency type recognition."""
173
+ dep = {"type": "hatch"}
174
+ self.assertTrue(self.installer.can_install(dep))
175
+ dep2 = {"type": "python"}
176
+ self.assertFalse(self.installer.can_install(dep2))
177
+
178
+ if __name__ == "__main__":
179
+ unittest.main()
@@ -0,0 +1,221 @@
1
+ import sys
2
+ import unittest
3
+ import logging
4
+ import tempfile
5
+ import shutil
6
+ from pathlib import Path
7
+ from unittest.mock import Mock
8
+ from typing import Dict, Any, List
9
+
10
+ from wobble.decorators import regression_test, integration_test, slow_test
11
+
12
+ # Import path management removed - using test_data_utils for test dependencies
13
+
14
+ from hatch.installers.installer_base import (
15
+ DependencyInstaller,
16
+ InstallationError
17
+ )
18
+
19
+ from hatch.installers.installation_context import (
20
+ InstallationContext,
21
+ InstallationResult,
22
+ InstallationStatus
23
+ )
24
+
25
+ # Configure logging
26
+ logging.basicConfig(
27
+ level=logging.INFO,
28
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
29
+ )
30
+ logger = logging.getLogger("hatch.installer_interface_tests")
31
+
32
+ class MockInstaller(DependencyInstaller):
33
+ """Mock installer for testing the base interface."""
34
+
35
+ @property
36
+ def installer_type(self) -> str:
37
+ return "mock"
38
+
39
+ @property
40
+ def supported_schemes(self) -> List[str]:
41
+ return ["test", "mock"]
42
+
43
+ def can_install(self, dependency: Dict[str, Any]) -> bool:
44
+ return dependency.get("type") == "mock"
45
+
46
+ def install(self, dependency: Dict[str, Any], context: InstallationContext,
47
+ progress_callback=None) -> InstallationResult:
48
+ return InstallationResult(
49
+ dependency_name=dependency["name"],
50
+ status=InstallationStatus.COMPLETED,
51
+ installed_path=context.environment_path / dependency["name"],
52
+ installed_version=dependency["resolved_version"]
53
+ )
54
+
55
+ class BaseInstallerTests(unittest.TestCase):
56
+ """Tests for the DependencyInstaller base class interface."""
57
+
58
+ def setUp(self):
59
+ """Set up test environment before each test."""
60
+ # Create a temporary directory for test environments
61
+ self.temp_dir = tempfile.mkdtemp()
62
+ self.env_path = Path(self.temp_dir) / "test_env"
63
+ self.env_path.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Create a mock installer instance for testing
66
+ self.installer = MockInstaller()
67
+
68
+ # Create test context
69
+ self.context = InstallationContext(
70
+ environment_path=self.env_path,
71
+ environment_name="test_env"
72
+ )
73
+
74
+ logger.info(f"Set up test environment at {self.temp_dir}")
75
+
76
+ def tearDown(self):
77
+ """Clean up test environment after each test."""
78
+ if hasattr(self, 'temp_dir') and Path(self.temp_dir).exists():
79
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
80
+ logger.info(f"Cleaned up test environment at {self.temp_dir}")
81
+ @regression_test
82
+ def test_installation_context_creation(self):
83
+ """Test that InstallationContext can be created with required fields."""
84
+ context = InstallationContext(
85
+ environment_path=Path("/test/env"),
86
+ environment_name="test_env"
87
+ )
88
+ self.assertEqual(context.environment_path, Path("/test/env"), f"Expected environment_path=/test/env, got {context.environment_path}")
89
+ self.assertEqual(context.environment_name, "test_env", f"Expected environment_name='test_env', got {context.environment_name}")
90
+ self.assertTrue(context.parallel_enabled, f"Expected parallel_enabled=True, got {context.parallel_enabled}") # Default value
91
+ self.assertEqual(context.get_config("nonexistent", "default"), "default", f"Expected default config fallback, got {context.get_config('nonexistent', 'default')}")
92
+ logger.info("InstallationContext creation test passed")
93
+ @regression_test
94
+ def test_installation_context_with_config(self):
95
+ """Test InstallationContext with extra configuration."""
96
+ context = InstallationContext(
97
+ environment_path=Path("/test/env"),
98
+ environment_name="test_env",
99
+ extra_config={"custom_setting": "value"}
100
+ )
101
+ self.assertEqual(context.get_config("custom_setting"), "value", f"Expected custom_setting='value', got {context.get_config('custom_setting')}")
102
+ self.assertEqual(context.get_config("missing_key", "fallback"), "fallback", f"Expected fallback for missing_key, got {context.get_config('missing_key', 'fallback')}")
103
+ logger.info("InstallationContext with config test passed")
104
+ @regression_test
105
+ def test_installation_result_creation(self):
106
+ """Test that InstallationResult can be created."""
107
+ result = InstallationResult(
108
+ dependency_name="test_package",
109
+ status=InstallationStatus.COMPLETED,
110
+ installed_path=Path("/env/test_package"),
111
+ installed_version="1.0.0"
112
+ )
113
+ self.assertEqual(result.dependency_name, "test_package", f"Expected dependency_name='test_package', got {result.dependency_name}")
114
+ self.assertEqual(result.status, InstallationStatus.COMPLETED, f"Expected status=COMPLETED, got {result.status}")
115
+ self.assertEqual(result.installed_path, Path("/env/test_package"), f"Expected installed_path=/env/test_package, got {result.installed_path}")
116
+ self.assertEqual(result.installed_version, "1.0.0", f"Expected installed_version='1.0.0', got {result.installed_version}")
117
+ logger.info("InstallationResult creation test passed")
118
+ @regression_test
119
+ def test_installation_error(self):
120
+ """Test InstallationError creation and attributes."""
121
+ error = InstallationError(
122
+ message="Installation failed",
123
+ dependency_name="test_package",
124
+ error_code="DOWNLOAD_FAILED"
125
+ )
126
+ self.assertEqual(error.message, "Installation failed", f"Expected error message 'Installation failed', got '{error.message}'")
127
+ self.assertEqual(error.dependency_name, "test_package", f"Expected dependency_name='test_package', got {error.dependency_name}")
128
+ self.assertEqual(error.error_code, "DOWNLOAD_FAILED", f"Expected error_code='DOWNLOAD_FAILED', got {error.error_code}")
129
+ logger.info("InstallationError test passed")
130
+ @regression_test
131
+ def test_mock_installer_interface(self):
132
+ """Test that MockInstaller implements the interface correctly."""
133
+ # Test properties
134
+ self.assertEqual(self.installer.installer_type, "mock", f"Expected installer_type='mock', got {self.installer.installer_type}")
135
+ self.assertEqual(self.installer.supported_schemes, ["test", "mock"], f"Expected supported_schemes=['test', 'mock'], got {self.installer.supported_schemes}")
136
+ # Test can_install
137
+ mock_dep = {"type": "mock", "name": "test"}
138
+ non_mock_dep = {"type": "other", "name": "test"}
139
+ self.assertTrue(self.installer.can_install(mock_dep), f"Expected can_install to be True for {mock_dep}")
140
+ self.assertFalse(self.installer.can_install(non_mock_dep), f"Expected can_install to be False for {non_mock_dep}")
141
+ logger.info("MockInstaller interface test passed")
142
+ @regression_test
143
+ def test_mock_installer_install(self):
144
+ """Test the install method of MockInstaller."""
145
+ dependency = {
146
+ "name": "test_package",
147
+ "type": "mock",
148
+ "version_constraint": ">=1.0.0",
149
+ "resolved_version": "1.2.0"
150
+ }
151
+ result = self.installer.install(dependency, self.context)
152
+ self.assertEqual(result.dependency_name, "test_package", f"Expected dependency_name='test_package', got {result.dependency_name}")
153
+ self.assertEqual(result.status, InstallationStatus.COMPLETED, f"Expected status=COMPLETED, got {result.status}")
154
+ self.assertEqual(result.installed_path, self.env_path / "test_package", f"Expected installed_path={self.env_path / 'test_package'}, got {result.installed_path}")
155
+ self.assertEqual(result.installed_version, "1.2.0", f"Expected installed_version='1.2.0', got {result.installed_version}")
156
+ logger.info("MockInstaller install test passed")
157
+ @regression_test
158
+ def test_mock_installer_validation(self):
159
+ """Test dependency validation."""
160
+ valid_dep = {
161
+ "name": "test",
162
+ "version_constraint": ">=1.0.0",
163
+ "resolved_version": "1.0.0"
164
+ }
165
+ invalid_dep = {
166
+ "name": "test"
167
+ # Missing required fields
168
+ }
169
+ self.assertTrue(self.installer.validate_dependency(valid_dep), f"Expected valid dependency to pass validation: {valid_dep}")
170
+ self.assertFalse(self.installer.validate_dependency(invalid_dep), f"Expected invalid dependency to fail validation: {invalid_dep}")
171
+ logger.info("MockInstaller validation test passed")
172
+ @regression_test
173
+ def test_mock_installer_get_installation_info(self):
174
+ """Test getting installation information."""
175
+ dependency = {
176
+ "name": "test_package",
177
+ "type": "mock",
178
+ "resolved_version": "1.0.0"
179
+ }
180
+ info = self.installer.get_installation_info(dependency, self.context)
181
+ self.assertEqual(info["installer_type"], "mock", f"Expected installer_type='mock', got {info['installer_type']}")
182
+ self.assertEqual(info["dependency_name"], "test_package", f"Expected dependency_name='test_package', got {info['dependency_name']}")
183
+ self.assertEqual(info["resolved_version"], "1.0.0", f"Expected resolved_version='1.0.0', got {info['resolved_version']}")
184
+ self.assertEqual(info["target_path"], str(self.env_path), f"Expected target_path={self.env_path}, got {info['target_path']}")
185
+ self.assertTrue(info["supported"], f"Expected supported=True, got {info['supported']}")
186
+ logger.info("MockInstaller get_installation_info test passed")
187
+ @regression_test
188
+ def test_mock_installer_uninstall_not_implemented(self):
189
+ """Test that uninstall raises NotImplementedError by default."""
190
+ dependency = {"name": "test", "type": "mock"}
191
+ with self.assertRaises(NotImplementedError, msg="Expected NotImplementedError for uninstall on MockInstaller"):
192
+ self.installer.uninstall(dependency, self.context)
193
+ logger.info("MockInstaller uninstall NotImplementedError test passed")
194
+ @regression_test
195
+ def test_installation_status_enum(self):
196
+ """Test InstallationStatus enum values."""
197
+ self.assertEqual(InstallationStatus.PENDING.value, "pending", f"Expected PENDING='pending', got {InstallationStatus.PENDING.value}")
198
+ self.assertEqual(InstallationStatus.IN_PROGRESS.value, "in_progress", f"Expected IN_PROGRESS='in_progress', got {InstallationStatus.IN_PROGRESS.value}")
199
+ self.assertEqual(InstallationStatus.COMPLETED.value, "completed", f"Expected COMPLETED='completed', got {InstallationStatus.COMPLETED.value}")
200
+ self.assertEqual(InstallationStatus.FAILED.value, "failed", f"Expected FAILED='failed', got {InstallationStatus.FAILED.value}")
201
+ self.assertEqual(InstallationStatus.ROLLED_BACK.value, "rolled_back", f"Expected ROLLED_BACK='rolled_back', got {InstallationStatus.ROLLED_BACK.value}")
202
+ logger.info("InstallationStatus enum test passed")
203
+ @regression_test
204
+ def test_progress_callback_support(self):
205
+ """Test that installer accepts progress callback."""
206
+ dependency = {
207
+ "name": "test_package",
208
+ "type": "mock",
209
+ "resolved_version": "1.0.0"
210
+ }
211
+ callback_called = []
212
+ def progress_callback(progress: float, message: str = ""):
213
+ callback_called.append((progress, message))
214
+ # Install with callback - should not raise error
215
+ result = self.installer.install(dependency, self.context, progress_callback)
216
+ self.assertEqual(result.status, InstallationStatus.COMPLETED, f"Expected status=COMPLETED, got {result.status}")
217
+ logger.info("Progress callback support test passed")
218
+
219
+ if __name__ == "__main__":
220
+ # Run the tests
221
+ unittest.main(verbosity=2)
@@ -0,0 +1,276 @@
1
+ """Tests for MCP atomic file operations.
2
+
3
+ This module contains tests for atomic file operations and backup-aware
4
+ operations with host-agnostic design.
5
+ """
6
+
7
+ import unittest
8
+ import tempfile
9
+ import shutil
10
+ import json
11
+ from pathlib import Path
12
+ from unittest.mock import patch, mock_open
13
+
14
+ from wobble.decorators import regression_test
15
+ from test_data_utils import MCPBackupTestDataLoader
16
+
17
+ from hatch.mcp_host_config.backup import (
18
+ AtomicFileOperations,
19
+ MCPHostConfigBackupManager,
20
+ BackupAwareOperation,
21
+ BackupError
22
+ )
23
+
24
+
25
+ class TestAtomicFileOperations(unittest.TestCase):
26
+ """Test atomic file operations with host-agnostic design."""
27
+
28
+ def setUp(self):
29
+ """Set up test environment."""
30
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="test_atomic_"))
31
+ self.test_file = self.temp_dir / "test_config.json"
32
+ self.backup_manager = MCPHostConfigBackupManager(backup_root=self.temp_dir / "backups")
33
+ self.atomic_ops = AtomicFileOperations()
34
+ self.test_data = MCPBackupTestDataLoader()
35
+
36
+ def tearDown(self):
37
+ """Clean up test environment."""
38
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
39
+
40
+ @regression_test
41
+ def test_atomic_write_success_host_agnostic(self):
42
+ """Test successful atomic write with any JSON configuration format."""
43
+ test_data = self.test_data.load_host_agnostic_config("complex_server")
44
+
45
+ result = self.atomic_ops.atomic_write_with_backup(
46
+ self.test_file, test_data, self.backup_manager, "claude-desktop"
47
+ )
48
+
49
+ self.assertTrue(result)
50
+ self.assertTrue(self.test_file.exists())
51
+
52
+ # Verify content (host-agnostic)
53
+ with open(self.test_file) as f:
54
+ written_data = json.load(f)
55
+ self.assertEqual(written_data, test_data)
56
+
57
+ @regression_test
58
+ def test_atomic_write_with_existing_file(self):
59
+ """Test atomic write with existing file creates backup."""
60
+ # Create initial file
61
+ initial_data = self.test_data.load_host_agnostic_config("simple_server")
62
+ with open(self.test_file, 'w') as f:
63
+ json.dump(initial_data, f)
64
+
65
+ # Update with atomic write
66
+ new_data = self.test_data.load_host_agnostic_config("complex_server")
67
+ result = self.atomic_ops.atomic_write_with_backup(
68
+ self.test_file, new_data, self.backup_manager, "vscode"
69
+ )
70
+
71
+ self.assertTrue(result)
72
+
73
+ # Verify backup was created
74
+ backups = self.backup_manager.list_backups("vscode")
75
+ self.assertEqual(len(backups), 1)
76
+
77
+ # Verify backup contains original data
78
+ with open(backups[0].file_path) as f:
79
+ backup_data = json.load(f)
80
+ self.assertEqual(backup_data, initial_data)
81
+
82
+ # Verify file contains new data
83
+ with open(self.test_file) as f:
84
+ current_data = json.load(f)
85
+ self.assertEqual(current_data, new_data)
86
+
87
+ @regression_test
88
+ def test_atomic_write_skip_backup(self):
89
+ """Test atomic write with backup skipped."""
90
+ # Create initial file
91
+ initial_data = self.test_data.load_host_agnostic_config("simple_server")
92
+ with open(self.test_file, 'w') as f:
93
+ json.dump(initial_data, f)
94
+
95
+ # Update with atomic write, skipping backup
96
+ new_data = self.test_data.load_host_agnostic_config("complex_server")
97
+ result = self.atomic_ops.atomic_write_with_backup(
98
+ self.test_file, new_data, self.backup_manager, "cursor", skip_backup=True
99
+ )
100
+
101
+ self.assertTrue(result)
102
+
103
+ # Verify no backup was created
104
+ backups = self.backup_manager.list_backups("cursor")
105
+ self.assertEqual(len(backups), 0)
106
+
107
+ # Verify file contains new data
108
+ with open(self.test_file) as f:
109
+ current_data = json.load(f)
110
+ self.assertEqual(current_data, new_data)
111
+
112
+ @regression_test
113
+ def test_atomic_write_failure_rollback(self):
114
+ """Test atomic write failure triggers rollback."""
115
+ # Create initial file
116
+ initial_data = self.test_data.load_host_agnostic_config("simple_server")
117
+ with open(self.test_file, 'w') as f:
118
+ json.dump(initial_data, f)
119
+
120
+ # Mock file write failure after backup creation
121
+ with patch('builtins.open', side_effect=[
122
+ # First call succeeds (backup creation)
123
+ open(self.test_file, 'r'),
124
+ # Second call fails (atomic write)
125
+ PermissionError("Access denied")
126
+ ]):
127
+ with self.assertRaises(BackupError):
128
+ self.atomic_ops.atomic_write_with_backup(
129
+ self.test_file, {"new": "data"}, self.backup_manager, "lmstudio"
130
+ )
131
+
132
+ # Verify original file is unchanged
133
+ with open(self.test_file) as f:
134
+ current_data = json.load(f)
135
+ self.assertEqual(current_data, initial_data)
136
+
137
+ @regression_test
138
+ def test_atomic_copy_success(self):
139
+ """Test successful atomic copy operation."""
140
+ source_file = self.temp_dir / "source.json"
141
+ target_file = self.temp_dir / "target.json"
142
+
143
+ test_data = self.test_data.load_host_agnostic_config("simple_server")
144
+ with open(source_file, 'w') as f:
145
+ json.dump(test_data, f)
146
+
147
+ result = self.atomic_ops.atomic_copy(source_file, target_file)
148
+
149
+ self.assertTrue(result)
150
+ self.assertTrue(target_file.exists())
151
+
152
+ # Verify content integrity
153
+ with open(target_file) as f:
154
+ copied_data = json.load(f)
155
+ self.assertEqual(copied_data, test_data)
156
+
157
+ @regression_test
158
+ def test_atomic_copy_failure_cleanup(self):
159
+ """Test atomic copy failure cleans up temporary files."""
160
+ source_file = self.temp_dir / "source.json"
161
+ target_file = self.temp_dir / "target.json"
162
+
163
+ test_data = self.test_data.load_host_agnostic_config("simple_server")
164
+ with open(source_file, 'w') as f:
165
+ json.dump(test_data, f)
166
+
167
+ # Mock copy failure
168
+ with patch('shutil.copy2', side_effect=PermissionError("Access denied")):
169
+ result = self.atomic_ops.atomic_copy(source_file, target_file)
170
+
171
+ self.assertFalse(result)
172
+ self.assertFalse(target_file.exists())
173
+
174
+ # Verify no temporary files left behind
175
+ temp_files = list(self.temp_dir.glob("*.tmp"))
176
+ self.assertEqual(len(temp_files), 0)
177
+
178
+
179
+ class TestBackupAwareOperation(unittest.TestCase):
180
+ """Test backup-aware operation API."""
181
+
182
+ def setUp(self):
183
+ """Set up test environment."""
184
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="test_backup_aware_"))
185
+ self.test_file = self.temp_dir / "test_config.json"
186
+ self.backup_manager = MCPHostConfigBackupManager(backup_root=self.temp_dir / "backups")
187
+ self.test_data = MCPBackupTestDataLoader()
188
+
189
+ def tearDown(self):
190
+ """Clean up test environment."""
191
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
192
+
193
+ @regression_test
194
+ def test_prepare_backup_success(self):
195
+ """Test explicit backup preparation."""
196
+ # Create initial configuration
197
+ initial_data = self.test_data.load_host_agnostic_config("simple_server")
198
+ with open(self.test_file, 'w') as f:
199
+ json.dump(initial_data, f)
200
+
201
+ # Test backup-aware operation
202
+ operation = BackupAwareOperation(self.backup_manager)
203
+
204
+ # Test explicit backup preparation
205
+ backup_result = operation.prepare_backup(self.test_file, "gemini", no_backup=False)
206
+ self.assertIsNotNone(backup_result)
207
+ self.assertTrue(backup_result.success)
208
+
209
+ # Verify backup was created
210
+ backups = self.backup_manager.list_backups("gemini")
211
+ self.assertEqual(len(backups), 1)
212
+
213
+ @regression_test
214
+ def test_prepare_backup_no_backup_mode(self):
215
+ """Test no-backup mode."""
216
+ # Create initial configuration
217
+ initial_data = self.test_data.load_host_agnostic_config("simple_server")
218
+ with open(self.test_file, 'w') as f:
219
+ json.dump(initial_data, f)
220
+
221
+ operation = BackupAwareOperation(self.backup_manager)
222
+
223
+ # Test no-backup mode
224
+ no_backup_result = operation.prepare_backup(self.test_file, "claude-code", no_backup=True)
225
+ self.assertIsNone(no_backup_result)
226
+
227
+ # Verify no backup was created
228
+ backups = self.backup_manager.list_backups("claude-code")
229
+ self.assertEqual(len(backups), 0)
230
+
231
+ @regression_test
232
+ def test_prepare_backup_failure_raises_exception(self):
233
+ """Test backup preparation failure raises BackupError."""
234
+ # Test with nonexistent file
235
+ nonexistent_file = self.temp_dir / "nonexistent.json"
236
+
237
+ operation = BackupAwareOperation(self.backup_manager)
238
+
239
+ with self.assertRaises(BackupError):
240
+ operation.prepare_backup(nonexistent_file, "vscode", no_backup=False)
241
+
242
+ @regression_test
243
+ def test_rollback_on_failure_success(self):
244
+ """Test successful rollback functionality."""
245
+ # Create initial configuration
246
+ initial_data = self.test_data.load_host_agnostic_config("simple_server")
247
+ with open(self.test_file, 'w') as f:
248
+ json.dump(initial_data, f)
249
+
250
+ operation = BackupAwareOperation(self.backup_manager)
251
+
252
+ # Create backup
253
+ backup_result = operation.prepare_backup(self.test_file, "cursor", no_backup=False)
254
+ self.assertTrue(backup_result.success)
255
+
256
+ # Modify file (simulate failed operation)
257
+ modified_data = self.test_data.load_host_agnostic_config("complex_server")
258
+ with open(self.test_file, 'w') as f:
259
+ json.dump(modified_data, f)
260
+
261
+ # Test rollback functionality
262
+ rollback_success = operation.rollback_on_failure(backup_result, self.test_file, "cursor")
263
+ self.assertTrue(rollback_success)
264
+
265
+ @regression_test
266
+ def test_rollback_on_failure_no_backup(self):
267
+ """Test rollback with no backup result."""
268
+ operation = BackupAwareOperation(self.backup_manager)
269
+
270
+ # Test rollback with None backup result
271
+ rollback_success = operation.rollback_on_failure(None, self.test_file, "lmstudio")
272
+ self.assertFalse(rollback_success)
273
+
274
+
275
+ if __name__ == '__main__':
276
+ unittest.main()