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,327 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import unittest
|
|
3
|
+
import tempfile
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
# Import wobble decorators for test categorization
|
|
10
|
+
from wobble.decorators import regression_test, integration_test, slow_test
|
|
11
|
+
|
|
12
|
+
from hatch.installers.python_installer import PythonInstaller
|
|
13
|
+
from hatch.installers.installation_context import InstallationContext, InstallationStatus
|
|
14
|
+
from hatch.installers.installer_base import InstallationError
|
|
15
|
+
|
|
16
|
+
class DummyContext(InstallationContext):
|
|
17
|
+
def __init__(self, env_path=None, env_name=None, simulation_mode=False, extra_config=None):
|
|
18
|
+
self.simulation_mode = simulation_mode
|
|
19
|
+
self.extra_config = extra_config or {}
|
|
20
|
+
self.environment_path = env_path
|
|
21
|
+
self.environment_name = env_name
|
|
22
|
+
|
|
23
|
+
def get_config(self, key, default=None):
|
|
24
|
+
return self.extra_config.get(key, default)
|
|
25
|
+
|
|
26
|
+
class TestPythonInstaller(unittest.TestCase):
|
|
27
|
+
"""Tests for the PythonInstaller class covering validation, installation, and error handling."""
|
|
28
|
+
|
|
29
|
+
def setUp(self):
|
|
30
|
+
"""Set up a temporary directory and PythonInstaller instance for each test."""
|
|
31
|
+
|
|
32
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
33
|
+
self.env_path = Path(self.temp_dir) / "test_env"
|
|
34
|
+
|
|
35
|
+
# make the directory
|
|
36
|
+
self.env_path.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
# assert the virtual environment was created successfully
|
|
39
|
+
self.assertTrue(self.env_path.exists() and self.env_path.is_dir())
|
|
40
|
+
|
|
41
|
+
self.installer = PythonInstaller()
|
|
42
|
+
self.dummy_context = DummyContext(self.env_path, env_name="test_env", extra_config={
|
|
43
|
+
"target_dir": str(self.env_path)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
def tearDown(self):
|
|
47
|
+
"""Clean up the temporary directory after each test."""
|
|
48
|
+
shutil.rmtree(self.temp_dir)
|
|
49
|
+
|
|
50
|
+
@regression_test
|
|
51
|
+
def test_validate_dependency_valid(self):
|
|
52
|
+
"""Test validate_dependency returns True for valid dependency dict."""
|
|
53
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0"}
|
|
54
|
+
self.assertTrue(self.installer.validate_dependency(dep))
|
|
55
|
+
|
|
56
|
+
@regression_test
|
|
57
|
+
def test_validate_dependency_invalid_missing_fields(self):
|
|
58
|
+
"""Test validate_dependency returns False if required fields are missing."""
|
|
59
|
+
dep = {"name": "requests"}
|
|
60
|
+
self.assertFalse(self.installer.validate_dependency(dep))
|
|
61
|
+
|
|
62
|
+
@regression_test
|
|
63
|
+
def test_validate_dependency_invalid_package_manager(self):
|
|
64
|
+
"""Test validate_dependency returns False for unsupported package manager."""
|
|
65
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0", "package_manager": "unknown"}
|
|
66
|
+
self.assertFalse(self.installer.validate_dependency(dep))
|
|
67
|
+
|
|
68
|
+
@regression_test
|
|
69
|
+
def test_can_install_python_type(self):
|
|
70
|
+
"""Test can_install returns True for type 'python'."""
|
|
71
|
+
dep = {"type": self.installer.installer_type}
|
|
72
|
+
self.assertTrue(self.installer.can_install(dep))
|
|
73
|
+
|
|
74
|
+
@regression_test
|
|
75
|
+
def test_can_install_wrong_type(self):
|
|
76
|
+
"""Test can_install returns False for non-python type."""
|
|
77
|
+
dep = {"type": "hatch"}
|
|
78
|
+
self.assertFalse(self.installer.can_install(dep))
|
|
79
|
+
|
|
80
|
+
@regression_test
|
|
81
|
+
@mock.patch("hatch.installers.python_installer.subprocess.Popen", side_effect=Exception("fail"))
|
|
82
|
+
def test_run_pip_subprocess_exception(self, mock_popen):
|
|
83
|
+
"""Test _run_pip_subprocess raises InstallationError on exception."""
|
|
84
|
+
cmd = [sys.executable, "-m", "pip", "--version"]
|
|
85
|
+
with self.assertRaises(InstallationError):
|
|
86
|
+
self.installer._run_pip_subprocess(cmd)
|
|
87
|
+
|
|
88
|
+
@regression_test
|
|
89
|
+
def test_install_simulation_mode(self):
|
|
90
|
+
"""Test install returns COMPLETED immediately in simulation mode."""
|
|
91
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0"}
|
|
92
|
+
context = DummyContext(simulation_mode=True)
|
|
93
|
+
result = self.installer.install(dep, context)
|
|
94
|
+
self.assertEqual(result.status, InstallationStatus.COMPLETED)
|
|
95
|
+
|
|
96
|
+
@regression_test
|
|
97
|
+
@mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=0)
|
|
98
|
+
def test_install_success(self, mock_run):
|
|
99
|
+
"""Test install returns COMPLETED on successful pip install."""
|
|
100
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0"}
|
|
101
|
+
context = DummyContext()
|
|
102
|
+
result = self.installer.install(dep, context)
|
|
103
|
+
self.assertEqual(result.status, InstallationStatus.COMPLETED)
|
|
104
|
+
|
|
105
|
+
@regression_test
|
|
106
|
+
@mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=1)
|
|
107
|
+
def test_install_failure(self, mock_run):
|
|
108
|
+
"""Test install raises InstallationError on pip failure."""
|
|
109
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0"} # The content don't matter here given the mock
|
|
110
|
+
context = DummyContext()
|
|
111
|
+
with self.assertRaises(InstallationError):
|
|
112
|
+
self.installer.install(dep, context)
|
|
113
|
+
|
|
114
|
+
@regression_test
|
|
115
|
+
@mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=0)
|
|
116
|
+
def test_uninstall_success(self, mock_run):
|
|
117
|
+
"""Test uninstall returns COMPLETED on successful pip uninstall."""
|
|
118
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0"}
|
|
119
|
+
context = DummyContext()
|
|
120
|
+
result = self.installer.uninstall(dep, context)
|
|
121
|
+
self.assertEqual(result.status, InstallationStatus.COMPLETED)
|
|
122
|
+
|
|
123
|
+
@regression_test
|
|
124
|
+
@mock.patch.object(PythonInstaller, "_run_pip_subprocess", return_value=1)
|
|
125
|
+
def test_uninstall_failure(self, mock_run):
|
|
126
|
+
"""Test uninstall raises InstallationError on pip uninstall failure."""
|
|
127
|
+
dep = {"name": "requests", "version_constraint": ">=2.0.0"}
|
|
128
|
+
context = DummyContext()
|
|
129
|
+
with self.assertRaises(InstallationError):
|
|
130
|
+
self.installer.uninstall(dep, context)
|
|
131
|
+
|
|
132
|
+
class TestPythonInstallerIntegration(unittest.TestCase):
|
|
133
|
+
|
|
134
|
+
"""Integration tests for PythonInstaller that perform actual package installations."""
|
|
135
|
+
|
|
136
|
+
def setUp(self):
|
|
137
|
+
"""Set up a temporary directory and PythonInstaller instance for each test."""
|
|
138
|
+
|
|
139
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
140
|
+
self.env_path = Path(self.temp_dir) / "test_env"
|
|
141
|
+
|
|
142
|
+
# Use pip to create a virtual environment
|
|
143
|
+
subprocess.check_call([sys.executable, "-m", "venv", str(self.env_path)])
|
|
144
|
+
|
|
145
|
+
# assert the virtual environment was created successfully
|
|
146
|
+
self.assertTrue(self.env_path.exists() and self.env_path.is_dir())
|
|
147
|
+
|
|
148
|
+
# Get the Python executable in the virtual environment
|
|
149
|
+
if sys.platform == "win32":
|
|
150
|
+
self.python_executable = self.env_path / "Scripts" / "python.exe"
|
|
151
|
+
else:
|
|
152
|
+
self.python_executable = self.env_path / "bin" / "python"
|
|
153
|
+
|
|
154
|
+
self.installer = PythonInstaller()
|
|
155
|
+
self.dummy_context = DummyContext(self.env_path, env_name="test_env", extra_config={
|
|
156
|
+
"python_executable": self.python_executable,
|
|
157
|
+
"target_dir": str(self.env_path)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
def tearDown(self):
|
|
161
|
+
"""Clean up the temporary directory after each test."""
|
|
162
|
+
shutil.rmtree(self.temp_dir)
|
|
163
|
+
|
|
164
|
+
@integration_test(scope="component")
|
|
165
|
+
@slow_test
|
|
166
|
+
def test_install_actual_package_success(self):
|
|
167
|
+
"""Test actual installation of a real Python package without mocking.
|
|
168
|
+
|
|
169
|
+
Uses a lightweight package that's commonly available and installs quickly.
|
|
170
|
+
This validates the entire installation pipeline including subprocess handling.
|
|
171
|
+
"""
|
|
172
|
+
# Use a lightweight, commonly available package for testing
|
|
173
|
+
dep = {
|
|
174
|
+
"name": "wheel",
|
|
175
|
+
"version_constraint": "*",
|
|
176
|
+
"type": "python"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Create a virtual environment context to avoid polluting system packages
|
|
180
|
+
context = DummyContext(
|
|
181
|
+
env_path=self.env_path,
|
|
182
|
+
env_name="test_env",
|
|
183
|
+
extra_config={
|
|
184
|
+
"python_executable": self.python_executable,
|
|
185
|
+
"target_dir": str(self.env_path)
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
result = self.installer.install(dep, context)
|
|
189
|
+
self.assertEqual(result.status, InstallationStatus.COMPLETED)
|
|
190
|
+
self.assertIn("wheel", result.dependency_name)
|
|
191
|
+
|
|
192
|
+
@integration_test(scope="component")
|
|
193
|
+
@slow_test
|
|
194
|
+
def test_install_package_with_version_constraint(self):
|
|
195
|
+
"""Test installation with specific version constraint.
|
|
196
|
+
|
|
197
|
+
Validates that version constraints are properly passed to pip
|
|
198
|
+
and that the installation succeeds with real package resolution.
|
|
199
|
+
"""
|
|
200
|
+
dep = {
|
|
201
|
+
"name": "setuptools",
|
|
202
|
+
"version_constraint": ">=40.0.0",
|
|
203
|
+
"type": "python"
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
context = DummyContext(
|
|
207
|
+
env_path=self.env_path,
|
|
208
|
+
env_name="test_env",
|
|
209
|
+
extra_config={
|
|
210
|
+
"python_executable": self.python_executable
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
result = self.installer.install(dep, context)
|
|
214
|
+
self.assertEqual(result.status, InstallationStatus.COMPLETED)
|
|
215
|
+
# Verify the dependency was processed correctly
|
|
216
|
+
self.assertIsNotNone(result.metadata)
|
|
217
|
+
|
|
218
|
+
@integration_test(scope="component")
|
|
219
|
+
@slow_test
|
|
220
|
+
def test_install_package_with_extras(self):
|
|
221
|
+
"""Test installation of a package with extras specification.
|
|
222
|
+
|
|
223
|
+
Tests the extras handling functionality with a real package installation.
|
|
224
|
+
"""
|
|
225
|
+
dep = {
|
|
226
|
+
"name": "requests",
|
|
227
|
+
"version_constraint": "*",
|
|
228
|
+
"type": "python",
|
|
229
|
+
"extras": ["security"] # pip[security] if available
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
context = DummyContext(
|
|
233
|
+
env_path=self.env_path,
|
|
234
|
+
env_name="test_env",
|
|
235
|
+
extra_config={
|
|
236
|
+
"python_executable": self.python_executable
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
result = self.installer.install(dep, context)
|
|
240
|
+
self.assertEqual(result.status, InstallationStatus.COMPLETED)
|
|
241
|
+
|
|
242
|
+
@integration_test(scope="component")
|
|
243
|
+
@slow_test
|
|
244
|
+
def test_uninstall_actual_package(self):
|
|
245
|
+
"""Test actual uninstallation of a Python package.
|
|
246
|
+
|
|
247
|
+
First installs a package, then uninstalls it to test the complete cycle.
|
|
248
|
+
This validates both installation and uninstallation without mocking.
|
|
249
|
+
"""
|
|
250
|
+
dep = {
|
|
251
|
+
"name": "wheel",
|
|
252
|
+
"version_constraint": "*",
|
|
253
|
+
"type": "python"
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
context = DummyContext(
|
|
257
|
+
env_path=self.env_path,
|
|
258
|
+
env_name="test_env",
|
|
259
|
+
extra_config={
|
|
260
|
+
"python_executable": self.python_executable
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
# First install the package
|
|
264
|
+
install_result = self.installer.install(dep, context)
|
|
265
|
+
self.assertEqual(install_result.status, InstallationStatus.COMPLETED)
|
|
266
|
+
|
|
267
|
+
# Then uninstall it
|
|
268
|
+
uninstall_result = self.installer.uninstall(dep, context)
|
|
269
|
+
self.assertEqual(uninstall_result.status, InstallationStatus.COMPLETED)
|
|
270
|
+
|
|
271
|
+
@integration_test(scope="component")
|
|
272
|
+
@slow_test
|
|
273
|
+
def test_install_nonexistent_package_failure(self):
|
|
274
|
+
"""Test that installation fails appropriately for non-existent packages.
|
|
275
|
+
|
|
276
|
+
This validates error handling when pip encounters a package that doesn't exist,
|
|
277
|
+
without using mocks to simulate the failure.
|
|
278
|
+
"""
|
|
279
|
+
dep = {
|
|
280
|
+
"name": "this-package-definitely-does-not-exist-12345",
|
|
281
|
+
"version_constraint": "*",
|
|
282
|
+
"type": "python"
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
context = DummyContext(
|
|
286
|
+
env_path=self.env_path,
|
|
287
|
+
env_name="test_env",
|
|
288
|
+
extra_config={
|
|
289
|
+
"python_executable": self.python_executable
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
with self.assertRaises(InstallationError) as cm:
|
|
293
|
+
self.installer.install(dep, context)
|
|
294
|
+
|
|
295
|
+
# Verify the error contains useful information
|
|
296
|
+
error_msg = str(cm.exception)
|
|
297
|
+
self.assertIn("this-package-definitely-does-not-exist-12345", error_msg)
|
|
298
|
+
|
|
299
|
+
@integration_test(scope="component")
|
|
300
|
+
@slow_test
|
|
301
|
+
def test_get_installation_info_for_installed_package(self):
|
|
302
|
+
"""Test retrieval of installation info for an actually installed package.
|
|
303
|
+
|
|
304
|
+
This tests the get_installation_info method with a real package
|
|
305
|
+
that should be available in most Python environments.
|
|
306
|
+
"""
|
|
307
|
+
dep = {
|
|
308
|
+
"name": "pip", # pip should be available in most environments
|
|
309
|
+
"version_constraint": "*",
|
|
310
|
+
"type": "python"
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
context = DummyContext(
|
|
314
|
+
env_path=self.env_path,
|
|
315
|
+
env_name="test_env",
|
|
316
|
+
extra_config={
|
|
317
|
+
"python_executable": self.python_executable
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
info = self.installer.get_installation_info(dep, context)
|
|
321
|
+
self.assertIsInstance(info, dict)
|
|
322
|
+
# Basic checks for expected info structure
|
|
323
|
+
if info: # Only check if info was returned (some implementations might return empty dict)
|
|
324
|
+
self.assertIn("dependency_name", info)
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
unittest.main()
|
tests/test_registry.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Basic test for installer registry functionality.
|
|
2
|
+
|
|
3
|
+
This test verifies that all installers are properly registered and can be
|
|
4
|
+
retrieved from the registry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
from wobble.decorators import regression_test, integration_test, slow_test
|
|
12
|
+
|
|
13
|
+
# Import path management removed - using test_data_utils for test dependencies
|
|
14
|
+
|
|
15
|
+
# It is mandatory to import the installer classes to ensure they are registered
|
|
16
|
+
from hatch.installers.hatch_installer import HatchInstaller
|
|
17
|
+
from hatch.installers.python_installer import PythonInstaller
|
|
18
|
+
from hatch.installers.system_installer import SystemInstaller
|
|
19
|
+
from hatch.installers.docker_installer import DockerInstaller
|
|
20
|
+
from hatch.installers import installer_registry, DependencyInstaller
|
|
21
|
+
|
|
22
|
+
class TestInstallerRegistry(unittest.TestCase):
|
|
23
|
+
"""Test suite for the installer registry."""
|
|
24
|
+
@regression_test
|
|
25
|
+
def test_registered_types(self):
|
|
26
|
+
"""Test that all expected installer types are registered."""
|
|
27
|
+
registered_types = installer_registry.get_registered_types()
|
|
28
|
+
expected_types = ["hatch", "python", "system", "docker"]
|
|
29
|
+
for expected_type in expected_types:
|
|
30
|
+
self.assertIn(expected_type, registered_types, f"{expected_type} installer should be registered")
|
|
31
|
+
@regression_test
|
|
32
|
+
def test_get_installer_instance(self):
|
|
33
|
+
"""Test that the registry returns a valid installer instance for each type."""
|
|
34
|
+
for dep_type in ["hatch", "python", "system", "docker"]:
|
|
35
|
+
installer = installer_registry.get_installer(dep_type)
|
|
36
|
+
self.assertIsInstance(installer, DependencyInstaller)
|
|
37
|
+
self.assertEqual(installer.installer_type, dep_type)
|
|
38
|
+
@regression_test
|
|
39
|
+
def test_error_on_unknown_type(self):
|
|
40
|
+
"""Test that requesting an unknown type raises ValueError."""
|
|
41
|
+
with self.assertRaises(ValueError):
|
|
42
|
+
installer_registry.get_installer("unknown_type")
|
|
43
|
+
@regression_test
|
|
44
|
+
def test_registry_repr_and_len(self):
|
|
45
|
+
"""Test __repr__ and __len__ methods for coverage."""
|
|
46
|
+
repr_str = repr(installer_registry)
|
|
47
|
+
self.assertIn("InstallerRegistry", repr_str)
|
|
48
|
+
self.assertGreaterEqual(len(installer_registry), 4)
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import unittest
|
|
3
|
+
import tempfile
|
|
4
|
+
import shutil
|
|
5
|
+
import logging
|
|
6
|
+
import json
|
|
7
|
+
import datetime
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from wobble.decorators import regression_test, integration_test, slow_test
|
|
12
|
+
|
|
13
|
+
# Import path management removed - using test_data_utils for test dependencies
|
|
14
|
+
|
|
15
|
+
from hatch.registry_retriever import RegistryRetriever
|
|
16
|
+
|
|
17
|
+
# Configure logging
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.DEBUG,
|
|
20
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
21
|
+
)
|
|
22
|
+
logger = logging.getLogger("hatch.registry_tests")
|
|
23
|
+
|
|
24
|
+
class RegistryRetrieverTests(unittest.TestCase):
|
|
25
|
+
"""Tests for Registry Retriever functionality."""
|
|
26
|
+
|
|
27
|
+
def setUp(self):
|
|
28
|
+
"""Set up test environment before each test."""
|
|
29
|
+
# Create temporary directories
|
|
30
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
31
|
+
self.cache_dir = Path(self.temp_dir) / "cache"
|
|
32
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
# Path to the registry file (using the one in project data) - for fallback/reference only
|
|
35
|
+
self.registry_path = Path(__file__).parent.parent.parent / "data" / "hatch_packages_registry.json"
|
|
36
|
+
if not self.registry_path.exists():
|
|
37
|
+
# Try alternate location
|
|
38
|
+
self.registry_path = Path(__file__).parent.parent.parent / "Hatch-Registry" / "data" / "hatch_packages_registry.json"
|
|
39
|
+
|
|
40
|
+
# We're testing online mode, but keep a local copy for comparison and backup
|
|
41
|
+
self.local_registry_path = Path(self.temp_dir) / "hatch_packages_registry.json"
|
|
42
|
+
if self.registry_path.exists():
|
|
43
|
+
shutil.copy(self.registry_path, self.local_registry_path)
|
|
44
|
+
|
|
45
|
+
def tearDown(self):
|
|
46
|
+
"""Clean up test environment after each test."""
|
|
47
|
+
# Remove temporary directory
|
|
48
|
+
shutil.rmtree(self.temp_dir)
|
|
49
|
+
@regression_test
|
|
50
|
+
def test_registry_init(self):
|
|
51
|
+
"""Test initialization of registry retriever."""
|
|
52
|
+
# Test initialization in online mode (primary test focus)
|
|
53
|
+
online_retriever = RegistryRetriever(
|
|
54
|
+
local_cache_dir=self.cache_dir,
|
|
55
|
+
simulation_mode=False
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Verify URL format for online mode
|
|
59
|
+
self.assertTrue(online_retriever.registry_url.startswith("https://"))
|
|
60
|
+
self.assertTrue("github.com" in online_retriever.registry_url)
|
|
61
|
+
|
|
62
|
+
# Verify cache path is set correctly
|
|
63
|
+
self.assertEqual(
|
|
64
|
+
online_retriever.registry_cache_path,
|
|
65
|
+
self.cache_dir / "registry" / "hatch_packages_registry.json"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Also test initialization with local file in simulation mode (for reference)
|
|
69
|
+
sim_retriever = RegistryRetriever(
|
|
70
|
+
local_cache_dir=self.cache_dir,
|
|
71
|
+
simulation_mode=True,
|
|
72
|
+
local_registry_cache_path=self.local_registry_path
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Verify registry cache path is set correctly in simulation mode
|
|
76
|
+
self.assertEqual(sim_retriever.registry_cache_path, self.local_registry_path)
|
|
77
|
+
self.assertTrue(sim_retriever.registry_url.startswith("file://"))
|
|
78
|
+
|
|
79
|
+
@integration_test(scope="component")
|
|
80
|
+
def test_registry_cache_management(self):
|
|
81
|
+
"""Test registry cache management."""
|
|
82
|
+
# Initialize retriever with a short TTL in online mode
|
|
83
|
+
retriever = RegistryRetriever(
|
|
84
|
+
cache_ttl=5, # 5 seconds TTL
|
|
85
|
+
local_cache_dir=self.cache_dir
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Get registry data (first fetch from online)
|
|
89
|
+
registry_data1 = retriever.get_registry()
|
|
90
|
+
self.assertIsNotNone(registry_data1)
|
|
91
|
+
|
|
92
|
+
# Verify in-memory cache works (should not read from disk)
|
|
93
|
+
registry_data2 = retriever.get_registry()
|
|
94
|
+
self.assertIs(registry_data1, registry_data2) # Should be the same object in memory
|
|
95
|
+
|
|
96
|
+
# Force refresh and verify it gets loaded again (potentially from online)
|
|
97
|
+
registry_data3 = retriever.get_registry(force_refresh=True)
|
|
98
|
+
self.assertIsNotNone(registry_data3)
|
|
99
|
+
|
|
100
|
+
# Verify the cache file was created
|
|
101
|
+
self.assertTrue(retriever.registry_cache_path.exists(), "Cache file was not created")
|
|
102
|
+
|
|
103
|
+
# Modify the persistent timestamp to test cache invalidation
|
|
104
|
+
# We need to manipulate the persistent timestamp file, not just the cache file mtime
|
|
105
|
+
timestamp_file = retriever._last_fetch_time_path
|
|
106
|
+
if timestamp_file.exists():
|
|
107
|
+
# Write an old timestamp to the persistent timestamp file
|
|
108
|
+
yesterday = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
|
|
109
|
+
old_timestamp_str = yesterday.isoformat().replace('+00:00', 'Z')
|
|
110
|
+
with open(timestamp_file, 'w', encoding='utf-8') as f:
|
|
111
|
+
f.write(old_timestamp_str)
|
|
112
|
+
# Reload the timestamp from file
|
|
113
|
+
retriever._load_last_fetch_time()
|
|
114
|
+
|
|
115
|
+
# Check if cache is outdated - should be since we modified the persistent timestamp
|
|
116
|
+
self.assertTrue(retriever.is_cache_outdated())
|
|
117
|
+
|
|
118
|
+
# Force refresh and verify new data is loaded (should fetch from online)
|
|
119
|
+
registry_data4 = retriever.get_registry(force_refresh=True)
|
|
120
|
+
self.assertIsNotNone(registry_data4)
|
|
121
|
+
self.assertIn("repositories", registry_data4)
|
|
122
|
+
self.assertIn("last_updated", registry_data4)
|
|
123
|
+
@integration_test(scope="service")
|
|
124
|
+
def test_online_mode(self):
|
|
125
|
+
"""Test registry retriever in online mode."""
|
|
126
|
+
# Initialize in online mode
|
|
127
|
+
retriever = RegistryRetriever(
|
|
128
|
+
local_cache_dir=self.cache_dir,
|
|
129
|
+
simulation_mode=False
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Get registry and verify it contains expected data
|
|
133
|
+
registry = retriever.get_registry()
|
|
134
|
+
self.assertIn("repositories", registry)
|
|
135
|
+
self.assertIn("last_updated", registry)
|
|
136
|
+
|
|
137
|
+
# Verify registry structure
|
|
138
|
+
self.assertIsInstance(registry.get("repositories"), list)
|
|
139
|
+
self.assertGreater(len(registry.get("repositories", [])), 0, "Registry should contain repositories")
|
|
140
|
+
|
|
141
|
+
# Get registry again with force refresh (should fetch from online)
|
|
142
|
+
registry2 = retriever.get_registry(force_refresh=True)
|
|
143
|
+
self.assertIn("repositories", registry2)
|
|
144
|
+
|
|
145
|
+
# Test error handling with an existing cache
|
|
146
|
+
# First ensure we have a valid cache file
|
|
147
|
+
self.assertTrue(retriever.registry_cache_path.exists(), "Cache file should exist after previous calls")
|
|
148
|
+
|
|
149
|
+
# Create a new retriever with invalid URL but using the same cache
|
|
150
|
+
bad_retriever = RegistryRetriever(
|
|
151
|
+
local_cache_dir=self.cache_dir,
|
|
152
|
+
simulation_mode=False
|
|
153
|
+
)
|
|
154
|
+
# Mock the URL to be invalid
|
|
155
|
+
bad_retriever.registry_url = "https://nonexistent.example.com/registry.json"
|
|
156
|
+
|
|
157
|
+
# First call should use the cache that was created by the earlier tests
|
|
158
|
+
registry_data = bad_retriever.get_registry()
|
|
159
|
+
self.assertIsNotNone(registry_data)
|
|
160
|
+
|
|
161
|
+
# Verify an attempt to force refresh with invalid URL doesn't break the test
|
|
162
|
+
try:
|
|
163
|
+
bad_retriever.get_registry(force_refresh=True)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass # Expected to fail, that's OK
|
|
166
|
+
|
|
167
|
+
@regression_test
|
|
168
|
+
def test_persistent_timestamp_across_cli_invocations(self):
|
|
169
|
+
"""Test that persistent timestamp works across separate CLI invocations."""
|
|
170
|
+
# First "CLI invocation" - create retriever and fetch registry
|
|
171
|
+
retriever1 = RegistryRetriever(
|
|
172
|
+
cache_ttl=300, # 5 minutes TTL
|
|
173
|
+
local_cache_dir=self.cache_dir,
|
|
174
|
+
simulation_mode=False
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Get registry (should fetch from online)
|
|
178
|
+
registry1 = retriever1.get_registry()
|
|
179
|
+
self.assertIsNotNone(registry1)
|
|
180
|
+
|
|
181
|
+
# Verify timestamp file was created
|
|
182
|
+
self.assertTrue(retriever1._last_fetch_time_path.exists(), "Timestamp file should be created")
|
|
183
|
+
|
|
184
|
+
# Get the timestamp from the first fetch
|
|
185
|
+
first_fetch_time = retriever1._last_fetch_time
|
|
186
|
+
self.assertGreater(first_fetch_time, 0, "First fetch time should be set")
|
|
187
|
+
|
|
188
|
+
# Second "CLI invocation" - create new retriever with same cache directory
|
|
189
|
+
retriever2 = RegistryRetriever(
|
|
190
|
+
cache_ttl=300, # 5 minutes TTL
|
|
191
|
+
local_cache_dir=self.cache_dir,
|
|
192
|
+
simulation_mode=False
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Verify the timestamp was loaded from disk
|
|
196
|
+
self.assertGreater(retriever2._last_fetch_time, 0, "Timestamp should be loaded from disk")
|
|
197
|
+
|
|
198
|
+
# Get registry (should use cache since timestamp is recent)
|
|
199
|
+
registry2 = retriever2.get_registry()
|
|
200
|
+
self.assertIsNotNone(registry2)
|
|
201
|
+
|
|
202
|
+
# Verify cache was used and not a new fetch (timestamp should be same or very close)
|
|
203
|
+
time_diff = abs(retriever2._last_fetch_time - first_fetch_time)
|
|
204
|
+
self.assertLess(time_diff, 2.0, "Should use cached registry, not fetch new one")
|
|
205
|
+
|
|
206
|
+
@regression_test
|
|
207
|
+
def test_persistent_timestamp_edge_cases(self):
|
|
208
|
+
"""Test edge cases for persistent timestamp handling."""
|
|
209
|
+
retriever = RegistryRetriever(
|
|
210
|
+
cache_ttl=300, # 5 minutes TTL
|
|
211
|
+
local_cache_dir=self.cache_dir,
|
|
212
|
+
simulation_mode=False
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Test 1: Corrupt timestamp file
|
|
216
|
+
timestamp_file = retriever._last_fetch_time_path
|
|
217
|
+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
|
|
219
|
+
# Write corrupt data to timestamp file
|
|
220
|
+
with open(timestamp_file, 'w', encoding='utf-8') as f:
|
|
221
|
+
f.write("invalid_timestamp_data")
|
|
222
|
+
|
|
223
|
+
# Should handle gracefully and treat as no timestamp
|
|
224
|
+
retriever._load_last_fetch_time()
|
|
225
|
+
self.assertEqual(retriever._last_fetch_time, 0, "Corrupt timestamp should be treated as no timestamp")
|
|
226
|
+
|
|
227
|
+
# Test 2: Future timestamp (clock skew scenario)
|
|
228
|
+
future_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)
|
|
229
|
+
future_timestamp_str = future_time.isoformat().replace('+00:00', 'Z')
|
|
230
|
+
with open(timestamp_file, 'w', encoding='utf-8') as f:
|
|
231
|
+
f.write(future_timestamp_str)
|
|
232
|
+
|
|
233
|
+
retriever._load_last_fetch_time()
|
|
234
|
+
# Should handle future timestamps gracefully (treat as valid but check TTL normally)
|
|
235
|
+
self.assertGreater(retriever._last_fetch_time, 0, "Future timestamp should be loaded")
|
|
236
|
+
|
|
237
|
+
# Test 3: Empty timestamp file
|
|
238
|
+
with open(timestamp_file, 'w', encoding='utf-8') as f:
|
|
239
|
+
f.write("")
|
|
240
|
+
|
|
241
|
+
retriever._load_last_fetch_time()
|
|
242
|
+
self.assertEqual(retriever._last_fetch_time, 0, "Empty timestamp file should be treated as no timestamp")
|
|
243
|
+
|
|
244
|
+
# Test 4: Missing timestamp file
|
|
245
|
+
timestamp_file.unlink()
|
|
246
|
+
retriever._load_last_fetch_time()
|
|
247
|
+
self.assertEqual(retriever._last_fetch_time, 0, "Missing timestamp file should be treated as no timestamp")
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
unittest.main()
|