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