pum 1.3.1__tar.gz → 1.3.3__tar.gz

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 (44) hide show
  1. {pum-1.3.1 → pum-1.3.3}/PKG-INFO +1 -1
  2. {pum-1.3.1 → pum-1.3.3}/pum/hook.py +33 -27
  3. {pum-1.3.1 → pum-1.3.3}/pum/pum_config.py +20 -4
  4. {pum-1.3.1 → pum-1.3.3}/pum.egg-info/PKG-INFO +1 -1
  5. {pum-1.3.1 → pum-1.3.3}/test/test_config.py +134 -2
  6. pum-1.3.3/test/test_hooks.py +64 -0
  7. pum-1.3.1/test/test_hooks.py +0 -202
  8. {pum-1.3.1 → pum-1.3.3}/LICENSE +0 -0
  9. {pum-1.3.1 → pum-1.3.3}/README.md +0 -0
  10. {pum-1.3.1 → pum-1.3.3}/pum/__init__.py +0 -0
  11. {pum-1.3.1 → pum-1.3.3}/pum/changelog.py +0 -0
  12. {pum-1.3.1 → pum-1.3.3}/pum/checker.py +0 -0
  13. {pum-1.3.1 → pum-1.3.3}/pum/cli.py +0 -0
  14. {pum-1.3.1 → pum-1.3.3}/pum/config_model.py +0 -0
  15. {pum-1.3.1 → pum-1.3.3}/pum/connection.py +0 -0
  16. {pum-1.3.1 → pum-1.3.3}/pum/dependency_handler.py +0 -0
  17. {pum-1.3.1 → pum-1.3.3}/pum/dumper.py +0 -0
  18. {pum-1.3.1 → pum-1.3.3}/pum/exceptions.py +0 -0
  19. {pum-1.3.1 → pum-1.3.3}/pum/feedback.py +0 -0
  20. {pum-1.3.1 → pum-1.3.3}/pum/info.py +0 -0
  21. {pum-1.3.1 → pum-1.3.3}/pum/parameter.py +0 -0
  22. {pum-1.3.1 → pum-1.3.3}/pum/report_generator.py +0 -0
  23. {pum-1.3.1 → pum-1.3.3}/pum/role_manager.py +0 -0
  24. {pum-1.3.1 → pum-1.3.3}/pum/schema_migrations.py +0 -0
  25. {pum-1.3.1 → pum-1.3.3}/pum/sql_content.py +0 -0
  26. {pum-1.3.1 → pum-1.3.3}/pum/upgrader.py +0 -0
  27. {pum-1.3.1 → pum-1.3.3}/pum.egg-info/SOURCES.txt +0 -0
  28. {pum-1.3.1 → pum-1.3.3}/pum.egg-info/dependency_links.txt +0 -0
  29. {pum-1.3.1 → pum-1.3.3}/pum.egg-info/entry_points.txt +0 -0
  30. {pum-1.3.1 → pum-1.3.3}/pum.egg-info/requires.txt +0 -0
  31. {pum-1.3.1 → pum-1.3.3}/pum.egg-info/top_level.txt +0 -0
  32. {pum-1.3.1 → pum-1.3.3}/pyproject.toml +0 -0
  33. {pum-1.3.1 → pum-1.3.3}/requirements/base.txt +0 -0
  34. {pum-1.3.1 → pum-1.3.3}/requirements/development.txt +0 -0
  35. {pum-1.3.1 → pum-1.3.3}/requirements/html.txt +0 -0
  36. {pum-1.3.1 → pum-1.3.3}/setup.cfg +0 -0
  37. {pum-1.3.1 → pum-1.3.3}/test/test_changelog.py +0 -0
  38. {pum-1.3.1 → pum-1.3.3}/test/test_checker.py +0 -0
  39. {pum-1.3.1 → pum-1.3.3}/test/test_dumper.py +0 -0
  40. {pum-1.3.1 → pum-1.3.3}/test/test_feedback.py +0 -0
  41. {pum-1.3.1 → pum-1.3.3}/test/test_roles.py +0 -0
  42. {pum-1.3.1 → pum-1.3.3}/test/test_schema_migrations.py +0 -0
  43. {pum-1.3.1 → pum-1.3.3}/test/test_sql_content.py +0 -0
  44. {pum-1.3.1 → pum-1.3.3}/test/test_upgrader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pum
3
- Version: 1.3.1
3
+ Version: 1.3.3
4
4
  Summary: Pum stands for "Postgres Upgrades Manager". It is a Database migration management tool very similar to flyway-db or Liquibase, based on metadata tables.
5
5
  Author-email: Denis Rouzaud <denis@opengis.ch>
6
6
  License-Expression: GPL-2.0-or-later
@@ -93,7 +93,6 @@ class HookHandler:
93
93
  self.code = code
94
94
  self.hook_instance = None
95
95
  self.sys_path_additions = [] # Store paths to add during execution
96
- self._imported_modules = [] # Track modules imported by this hook
97
96
 
98
97
  if file:
99
98
  if isinstance(file, str):
@@ -112,32 +111,38 @@ class HookHandler:
112
111
  parent_dir = str(self.file.parent.resolve())
113
112
 
114
113
  # Store paths that need to be added for hook execution
115
- # Add parent directory of the hook file
116
- if parent_dir not in sys.path:
117
- self.sys_path_additions.append(parent_dir)
114
+ # Always add paths even if already in sys.path - we need them at position 0
115
+ # for priority and we'll track what we added for cleanup
116
+ self.sys_path_additions.append(parent_dir)
118
117
 
119
118
  # Also add base_path if provided, to support imports from sibling directories
120
119
  if base_path is not None:
121
120
  base_path_str = str(base_path.resolve())
122
- if base_path_str not in sys.path and base_path_str != parent_dir:
121
+ if base_path_str != parent_dir:
123
122
  self.sys_path_additions.append(base_path_str)
124
123
 
125
- # Temporarily add paths for module loading
126
- for path in self.sys_path_additions:
124
+ # Add paths for module loading - insert at position 0 for priority
125
+ for path in reversed(self.sys_path_additions):
127
126
  sys.path.insert(0, path)
128
127
 
129
- # Track modules before loading to detect new imports
130
- modules_before = set(sys.modules.keys())
128
+ # Invalidate caches so Python recognizes the new paths
129
+ importlib.invalidate_caches()
131
130
 
132
131
  try:
133
- spec = importlib.util.spec_from_file_location(self.file.stem, self.file)
132
+ logger.debug(f"Loading hook from: {self.file}")
133
+ logger.debug(f"sys.path additions: {self.sys_path_additions}")
134
+ spec = importlib.util.spec_from_file_location(
135
+ self.file.stem,
136
+ self.file,
137
+ submodule_search_locations=[parent_dir],
138
+ )
134
139
  module = importlib.util.module_from_spec(spec)
140
+ # Set __path__ to enable package-like imports from the hook's directory
141
+ module.__path__ = [parent_dir]
142
+ # Add to sys.modules before executing so imports can find it
143
+ sys.modules[self.file.stem] = module
135
144
  spec.loader.exec_module(module)
136
145
 
137
- # Track modules that were imported by this hook
138
- modules_after = set(sys.modules.keys())
139
- self._imported_modules = list(modules_after - modules_before)
140
-
141
146
  # Check that the module contains a class named Hook inheriting from HookBase
142
147
  # Do this BEFORE removing paths from sys.path
143
148
  hook_class = getattr(module, "Hook", None)
@@ -176,21 +181,22 @@ class HookHandler:
176
181
  arg for arg in arg_names if arg not in ("self", "connection")
177
182
  ]
178
183
 
179
- finally:
180
- # Remove all paths that were added
181
- for path in self.sys_path_additions:
182
- if path in sys.path:
183
- sys.path.remove(path)
184
+ except Exception:
185
+ # On error, clean up paths we added
186
+ self.cleanup_sys_path()
187
+ raise
188
+
189
+ def cleanup_sys_path(self) -> None:
190
+ """Remove paths that were added to sys.path for this hook.
184
191
 
185
- def cleanup_imports(self):
186
- """Remove imported modules from sys.modules cache.
187
- This should be called when switching to a different module version
188
- to prevent import conflicts.
192
+ This should be called when the hook is no longer needed to prevent
193
+ sys.path pollution.
189
194
  """
190
- for module_name in self._imported_modules:
191
- if module_name in sys.modules:
192
- del sys.modules[module_name]
193
- self._imported_modules.clear()
195
+ for path in self.sys_path_additions:
196
+ # Remove all occurrences of this path (we may have added it multiple times)
197
+ while path in sys.path:
198
+ sys.path.remove(path)
199
+ self.sys_path_additions.clear()
194
200
 
195
201
  def __repr__(self) -> str:
196
202
  """Return a string representation of the Hook instance."""
@@ -184,15 +184,31 @@ class PumConfig:
184
184
  return self._base_path
185
185
 
186
186
  def cleanup_hook_imports(self) -> None:
187
- """Clean up imported modules from hooks to prevent conflicts when switching versions.
187
+ """Clean up imported modules and sys.path entries from hooks.
188
188
 
189
189
  This should be called when switching to a different module version to ensure
190
190
  that cached imports from the previous version don't cause conflicts.
191
191
  """
192
+ # First, clean up sys.path additions from all cached handlers
192
193
  for handler in self._cached_handlers:
193
- if hasattr(handler, "cleanup_imports"):
194
- handler.cleanup_imports()
195
- # Clear the cache after cleanup
194
+ handler.cleanup_sys_path()
195
+
196
+ # Clear all modules that were loaded from this base_path
197
+ base_path_str = str(self._base_path.resolve())
198
+ modules_to_remove = []
199
+
200
+ for module_name, module in list(sys.modules.items()):
201
+ if module is None:
202
+ continue
203
+ module_file = getattr(module, "__file__", None)
204
+ if module_file and module_file.startswith(base_path_str):
205
+ modules_to_remove.append(module_name)
206
+
207
+ for module_name in modules_to_remove:
208
+ if module_name in sys.modules:
209
+ logger.debug(f"Removing cached module: {module_name}")
210
+ del sys.modules[module_name]
211
+
196
212
  self._cached_handlers.clear()
197
213
 
198
214
  def parameters(self) -> list[ParameterDefinition]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pum
3
- Version: 1.3.1
3
+ Version: 1.3.3
4
4
  Summary: Pum stands for "Postgres Upgrades Manager". It is a Database migration management tool very similar to flyway-db or Liquibase, based on metadata tables.
5
5
  Author-email: Denis Rouzaud <denis@opengis.ch>
6
6
  License-Expression: GPL-2.0-or-later
@@ -199,17 +199,28 @@ class TestConfig(unittest.TestCase):
199
199
  This test simulates the QGIS plugin scenario where a user switches from one
200
200
  module version to another, which can cause import conflicts if old modules
201
201
  are still cached in sys.modules.
202
+
203
+ Tests three critical scenarios:
204
+ 1. Simple module imports (top-level imports at module load time)
205
+ 2. Nested submodule imports (e.g., view.submodule.helper)
206
+ 3. Dynamic imports (imports inside run_hook at execution time)
207
+
208
+ All three scenarios need proper cleanup to prevent version conflicts.
202
209
  """
203
210
  import sys
204
211
  from unittest.mock import Mock
205
212
 
206
- # Clear any view modules from sys.modules
213
+ # Initial cleanup to ensure clean state from any previous test runs
214
+ # This is the ONLY manual cleanup - everything else should use the API
207
215
  modules_to_remove = [
208
- key for key in list(sys.modules.keys()) if "view" in key or "helper_v" in key
216
+ key
217
+ for key in list(sys.modules.keys())
218
+ if "view" in key or "helper_v" in key or "helper_dynamic" in key
209
219
  ]
210
220
  for module in modules_to_remove:
211
221
  del sys.modules[module]
212
222
 
223
+ # Test 1: Simple module imports
213
224
  # Load version 1
214
225
  v1_path = Path("test") / "data" / "hook_version_switch" / "v1"
215
226
  cfg_v1 = PumConfig(
@@ -271,6 +282,127 @@ class TestConfig(unittest.TestCase):
271
282
  "helper_v2 should be removed from sys.modules after cleanup",
272
283
  )
273
284
 
285
+ # Test 2: Nested submodule imports (view.submodule.helper)
286
+ # This tests the critical case where parent modules cache can prevent
287
+ # submodule reloading if not properly cleaned up
288
+ # No manual cleanup needed - the API should handle everything
289
+
290
+ # Load submodule cleanup v1
291
+ submodule_v1_path = Path("test") / "data" / "hook_submodule_cleanup" / "v1"
292
+ cfg_submodule_v1 = PumConfig(
293
+ base_path=submodule_v1_path,
294
+ pum={"module": "test_submodule_v1"},
295
+ application={"create": [{"file": "app/create_hook.py"}]},
296
+ validate=False,
297
+ )
298
+
299
+ handlers_submodule_v1 = cfg_submodule_v1.create_app_handlers()
300
+ self.assertEqual(len(handlers_submodule_v1), 1)
301
+
302
+ # Execute v1 hook - imports view.submodule.helper which returns value_from_submodule_v1
303
+ # The hook has an assertion that will fail if wrong version is imported
304
+ handlers_submodule_v1[0].execute(connection=mock_conn, parameters={})
305
+
306
+ # Verify submodules were imported
307
+ view_submodules = [mod for mod in sys.modules if mod.startswith("view.submodule")]
308
+ self.assertGreater(len(view_submodules), 0, "Should have imported view.submodule modules")
309
+
310
+ # Clean up v1 submodule imports
311
+ cfg_submodule_v1.cleanup_hook_imports()
312
+
313
+ # Verify ALL view modules (including submodules) were cleaned up
314
+ remaining_view_modules = [
315
+ mod for mod in sys.modules if mod == "view" or mod.startswith("view.")
316
+ ]
317
+ self.assertEqual(
318
+ len(remaining_view_modules),
319
+ 0,
320
+ f"All view modules should be cleaned up, but found: {remaining_view_modules}",
321
+ )
322
+
323
+ # Load submodule cleanup v2 - critical test for proper submodule cleanup
324
+ # Without proper cleanup, Python would use cached view.submodule.helper from v1
325
+ submodule_v2_path = Path("test") / "data" / "hook_submodule_cleanup" / "v2"
326
+ cfg_submodule_v2 = PumConfig(
327
+ base_path=submodule_v2_path,
328
+ pum={"module": "test_submodule_v2"},
329
+ application={"create": [{"file": "app/create_hook.py"}]},
330
+ validate=False,
331
+ )
332
+
333
+ handlers_submodule_v2 = cfg_submodule_v2.create_app_handlers()
334
+ self.assertEqual(len(handlers_submodule_v2), 1)
335
+
336
+ # Execute v2 hook - should import fresh view.submodule.helper which returns value_from_submodule_v2
337
+ # The assertion inside the hook will fail if the wrong version is loaded
338
+ handlers_submodule_v2[0].execute(connection=mock_conn, parameters={})
339
+
340
+ # Clean up
341
+ cfg_submodule_v2.cleanup_hook_imports()
342
+
343
+ # Test 3: Dynamic imports (imports inside run_hook, not at module load time)
344
+ # This is the critical case that wasn't covered before - imports that happen
345
+ # during hook execution need cleanup too
346
+ # No manual cleanup needed - the API should handle everything
347
+
348
+ # Load dynamic import v1
349
+ dynamic_v1_path = Path("test") / "data" / "hook_dynamic_import_switch" / "v1"
350
+ cfg_dynamic_v1 = PumConfig(
351
+ base_path=dynamic_v1_path,
352
+ pum={"module": "test_dynamic_v1"},
353
+ application={"create": [{"file": "app/create_hook.py"}]},
354
+ validate=False,
355
+ )
356
+
357
+ handlers_dynamic_v1 = cfg_dynamic_v1.create_app_handlers()
358
+ self.assertEqual(len(handlers_dynamic_v1), 1)
359
+
360
+ # Execute v1 hook - this does a dynamic import of helper_dynamic_v1 inside run_hook
361
+ handlers_dynamic_v1[0].execute(connection=mock_conn, parameters={})
362
+
363
+ # Verify the dynamically imported module is in sys.modules
364
+ self.assertTrue(
365
+ any("helper_dynamic_v1" in mod for mod in sys.modules),
366
+ "helper_dynamic_v1 should be in sys.modules after dynamic import",
367
+ )
368
+
369
+ # Clean up v1 dynamic imports
370
+ cfg_dynamic_v1.cleanup_hook_imports()
371
+
372
+ # Verify dynamically imported modules were cleaned up
373
+ remaining_dynamic_v1_modules = [mod for mod in sys.modules if "helper_dynamic_v1" in mod]
374
+ self.assertEqual(
375
+ len(remaining_dynamic_v1_modules),
376
+ 0,
377
+ f"Dynamic import modules should be cleaned up, but found: {remaining_dynamic_v1_modules}",
378
+ )
379
+
380
+ # Load dynamic import v2 - critical test for cleanup after dynamic imports
381
+ # Without proper cleanup, Python might use cached helper_dynamic_v1
382
+ dynamic_v2_path = Path("test") / "data" / "hook_dynamic_import_switch" / "v2"
383
+ cfg_dynamic_v2 = PumConfig(
384
+ base_path=dynamic_v2_path,
385
+ pum={"module": "test_dynamic_v2"},
386
+ application={"create": [{"file": "app/create_hook.py"}]},
387
+ validate=False,
388
+ )
389
+
390
+ handlers_dynamic_v2 = cfg_dynamic_v2.create_app_handlers()
391
+ self.assertEqual(len(handlers_dynamic_v2), 1)
392
+
393
+ # Execute v2 hook - should dynamically import fresh helper_dynamic_v2
394
+ # The assertion inside the hook will fail if the wrong version is loaded
395
+ handlers_dynamic_v2[0].execute(connection=mock_conn, parameters={})
396
+
397
+ # Verify v2 was imported
398
+ self.assertTrue(
399
+ any("helper_dynamic_v2" in mod for mod in sys.modules),
400
+ "helper_dynamic_v2 should be in sys.modules",
401
+ )
402
+
403
+ # Clean up
404
+ cfg_dynamic_v2.cleanup_hook_imports()
405
+
274
406
  def test_parameter_type_string_conversion(self) -> None:
275
407
  """Test that ParameterType enums convert to string values correctly.
276
408
 
@@ -0,0 +1,64 @@
1
+ """Test module for hook functionality."""
2
+
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import Mock
7
+
8
+ from pum.hook import HookHandler
9
+
10
+
11
+ class TestHooks(unittest.TestCase):
12
+ """Test the hook functionality."""
13
+
14
+ def test_hook_with_local_imports(self) -> None:
15
+ """Test that a hook file can import from its own directory.
16
+
17
+ This test verifies that PUM hook files can properly import from their
18
+ own directory. The test uses a hook file in app/ that imports from
19
+ local_helper.py in the same directory.
20
+ """
21
+ test_dir = Path("test") / "data" / "hook_local_imports"
22
+ hook_file = test_dir / "app" / "create_hook.py"
23
+
24
+ # Create hook handler - this should not raise an error
25
+ handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
26
+
27
+ # Verify the hook was loaded correctly
28
+ self.assertIsNotNone(handler.hook_instance)
29
+ self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
30
+
31
+ # Execute the hook to ensure imports work at runtime
32
+ mock_conn = Mock()
33
+ handler.execute(connection=mock_conn, parameters={})
34
+
35
+ def test_hook_with_dynamic_imports(self) -> None:
36
+ """Test that a hook file can import modules dynamically inside run_hook.
37
+
38
+ This test verifies that PUM hook files can properly import modules
39
+ during hook execution (inside run_hook), not just at module load time.
40
+ This is critical because hooks may need to import modules based on runtime
41
+ conditions or call functions that themselves import modules.
42
+
43
+ This was the bug that wasn't caught by existing tests - all previous tests
44
+ used top-level imports which happen during __init__, not during execute().
45
+
46
+ Also tests that hooks can be executed multiple times with consistent behavior.
47
+ """
48
+ test_dir = Path("test") / "data" / "hook_dynamic_import"
49
+ hook_file = test_dir / "app" / "create_hook.py"
50
+
51
+ # Create hook handler - this should not raise an error
52
+ handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
53
+
54
+ # Verify the hook was loaded correctly
55
+ self.assertIsNotNone(handler.hook_instance)
56
+ self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
57
+
58
+ # Execute the hook - this is where the dynamic import happens
59
+ # Without proper sys.path management during execute(), this will fail
60
+ mock_conn = Mock()
61
+ handler.execute(connection=mock_conn, parameters={})
62
+
63
+ # Execute again to verify it works multiple times
64
+ handler.execute(connection=mock_conn, parameters={})
@@ -1,202 +0,0 @@
1
- """Test module for hook functionality."""
2
-
3
- import unittest
4
- from pathlib import Path
5
- from unittest.mock import Mock
6
-
7
- from pum.hook import HookHandler
8
-
9
-
10
- class TestHooks(unittest.TestCase):
11
- """Test the hook functionality."""
12
-
13
- def test_hook_with_sibling_imports(self) -> None:
14
- """Test that a hook file can import from a sibling directory.
15
-
16
- This test verifies that PUM hook files can properly import from sibling
17
- directories. The test uses a hook file in app/ that imports from view/,
18
- which is a sibling directory.
19
- """
20
- import sys
21
-
22
- test_dir = Path("test") / "data" / "hook_sibling_imports"
23
- hook_file = test_dir / "app" / "create_hook.py"
24
-
25
- # Clear any previously imported modules to ensure fresh import
26
- modules_to_remove = [key for key in sys.modules if "view.helper" in key or "view" == key]
27
- for module in modules_to_remove:
28
- del sys.modules[module]
29
-
30
- # Create hook handler - this should not raise an error
31
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
32
-
33
- # Verify the hook was loaded correctly
34
- self.assertIsNotNone(handler.hook_instance)
35
- self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
36
-
37
- # At this point sys.path should not contain the hook paths anymore
38
- test_dir_str = str(test_dir.resolve())
39
- app_dir_str = str((test_dir / "app").resolve())
40
- self.assertNotIn(test_dir_str, sys.path, "base_path should have been removed from sys.path")
41
- self.assertNotIn(app_dir_str, sys.path, "parent dir should have been removed from sys.path")
42
-
43
- # Execute the hook to ensure imports work at runtime
44
- # This is where the bug should manifest - view.helper won't be importable
45
- mock_conn = Mock()
46
- handler.execute(connection=mock_conn, parameters={})
47
-
48
- def test_hook_with_dynamic_sibling_imports(self) -> None:
49
- """Test that a hook file can dynamically import from a sibling directory at runtime.
50
-
51
- This test verifies that PUM hook files can properly import from sibling
52
- directories even when the import happens inside run_hook (not at module load time).
53
- """
54
- import sys
55
-
56
- test_dir = Path("test") / "data" / "hook_sibling_imports"
57
- hook_file = test_dir / "app" / "dynamic_import_hook.py"
58
-
59
- # Clear any previously imported modules to ensure fresh import
60
- modules_to_remove = [key for key in sys.modules if "view.helper" in key or "view" == key]
61
- for module in modules_to_remove:
62
- del sys.modules[module]
63
-
64
- # Create hook handler
65
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
66
-
67
- # Verify the hook was loaded correctly
68
- self.assertIsNotNone(handler.hook_instance)
69
- self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
70
-
71
- # Execute the hook - this should fail because sys.path modifications were removed
72
- mock_conn = Mock()
73
- handler.execute(connection=mock_conn, parameters={})
74
-
75
- def test_hook_with_local_imports(self) -> None:
76
- """Test that a hook file can import from its own directory.
77
-
78
- This test verifies that PUM hook files can properly import from their
79
- own directory. The test uses a hook file in app/ that imports from
80
- local_helper.py in the same directory.
81
- """
82
- test_dir = Path("test") / "data" / "hook_local_imports"
83
- hook_file = test_dir / "app" / "create_hook.py"
84
-
85
- # Create hook handler - this should not raise an error
86
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
87
-
88
- # Verify the hook was loaded correctly
89
- self.assertIsNotNone(handler.hook_instance)
90
- self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
91
-
92
- # Execute the hook to ensure imports work at runtime
93
- mock_conn = Mock()
94
- handler.execute(connection=mock_conn, parameters={})
95
-
96
- def test_hook_cleanup_imports(self) -> None:
97
- """Test that hook imports can be cleaned up to prevent conflicts when switching versions.
98
-
99
- This test verifies that when hooks are cleaned up, their imported modules
100
- are removed from sys.modules cache, allowing fresh imports when switching
101
- to a different module version.
102
- """
103
- import sys
104
-
105
- test_dir = Path("test") / "data" / "hook_sibling_imports"
106
- hook_file = test_dir / "app" / "create_hook.py"
107
-
108
- # Clear any previously imported modules to ensure fresh import
109
- modules_to_remove = [key for key in sys.modules if "view" in key]
110
- for module in modules_to_remove:
111
- del sys.modules[module]
112
-
113
- # Load the hook - this will import view.helper
114
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
115
-
116
- # Verify that view.helper was imported and tracked
117
- self.assertGreater(
118
- len(handler._imported_modules), 0, "Should have tracked imported modules"
119
- )
120
- self.assertTrue(
121
- any("view" in mod for mod in handler._imported_modules),
122
- "Should have tracked view module",
123
- )
124
-
125
- # Verify view.helper is in sys.modules
126
- view_module_found = any("view" in mod for mod in sys.modules)
127
- self.assertTrue(
128
- view_module_found, "view module should be in sys.modules after loading hook"
129
- )
130
-
131
- # Execute the hook to make sure it works before cleanup
132
- mock_conn = Mock()
133
- handler.execute(connection=mock_conn, parameters={})
134
-
135
- # Clean up imports
136
- handler.cleanup_imports()
137
-
138
- # Verify that tracked modules were cleared
139
- self.assertEqual(
140
- len(handler._imported_modules), 0, "Should have cleared tracked modules list"
141
- )
142
-
143
- # Verify that view modules were removed from sys.modules
144
- view_modules_after = [mod for mod in sys.modules if "view.helper" in mod or mod == "view"]
145
- self.assertEqual(
146
- len(view_modules_after),
147
- 0,
148
- f"view modules should be removed from sys.modules after cleanup, but found: {view_modules_after}",
149
- )
150
-
151
- def test_hook_cleanup_and_reload(self) -> None:
152
- """Test that hooks can be reloaded after cleanup without conflicts.
153
-
154
- This test simulates switching between module versions by loading a hook,
155
- cleaning it up, and loading it again.
156
- """
157
- import sys
158
-
159
- test_dir = Path("test") / "data" / "hook_sibling_imports"
160
- hook_file = test_dir / "app" / "create_hook.py"
161
-
162
- # Clear any previously imported modules
163
- modules_to_remove = [key for key in sys.modules if "view" in key]
164
- for module in modules_to_remove:
165
- del sys.modules[module]
166
-
167
- # First load
168
- handler1 = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
169
- mock_conn = Mock()
170
- handler1.execute(connection=mock_conn, parameters={})
171
-
172
- # Get the module object for comparison
173
- view_module_id_1 = None
174
- for mod_name in sys.modules:
175
- if mod_name == "view" or mod_name.startswith("view."):
176
- view_module_id_1 = id(sys.modules[mod_name])
177
- break
178
-
179
- # Clean up
180
- handler1.cleanup_imports()
181
-
182
- # Verify cleanup worked
183
- view_modules = [mod for mod in sys.modules if "view.helper" in mod or mod == "view"]
184
- self.assertEqual(len(view_modules), 0, "Modules should be cleaned up")
185
-
186
- # Second load - should work without conflicts
187
- handler2 = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
188
- handler2.execute(connection=mock_conn, parameters={})
189
-
190
- # Verify it's a fresh import (different module object)
191
- view_module_id_2 = None
192
- for mod_name in sys.modules:
193
- if mod_name == "view" or mod_name.startswith("view."):
194
- view_module_id_2 = id(sys.modules[mod_name])
195
- break
196
-
197
- # Both should have found view modules
198
- self.assertIsNotNone(view_module_id_1, "First load should have imported view")
199
- self.assertIsNotNone(view_module_id_2, "Second load should have imported view")
200
-
201
- # Clean up after test
202
- handler2.cleanup_imports()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes