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