pum 1.3.2__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.2 → pum-1.3.3}/PKG-INFO +1 -1
  2. {pum-1.3.2 → pum-1.3.3}/pum/hook.py +24 -10
  3. {pum-1.3.2 → pum-1.3.3}/pum/pum_config.py +5 -1
  4. {pum-1.3.2 → pum-1.3.3}/pum.egg-info/PKG-INFO +1 -1
  5. {pum-1.3.2 → 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.2/test/test_hooks.py +0 -252
  8. {pum-1.3.2 → pum-1.3.3}/LICENSE +0 -0
  9. {pum-1.3.2 → pum-1.3.3}/README.md +0 -0
  10. {pum-1.3.2 → pum-1.3.3}/pum/__init__.py +0 -0
  11. {pum-1.3.2 → pum-1.3.3}/pum/changelog.py +0 -0
  12. {pum-1.3.2 → pum-1.3.3}/pum/checker.py +0 -0
  13. {pum-1.3.2 → pum-1.3.3}/pum/cli.py +0 -0
  14. {pum-1.3.2 → pum-1.3.3}/pum/config_model.py +0 -0
  15. {pum-1.3.2 → pum-1.3.3}/pum/connection.py +0 -0
  16. {pum-1.3.2 → pum-1.3.3}/pum/dependency_handler.py +0 -0
  17. {pum-1.3.2 → pum-1.3.3}/pum/dumper.py +0 -0
  18. {pum-1.3.2 → pum-1.3.3}/pum/exceptions.py +0 -0
  19. {pum-1.3.2 → pum-1.3.3}/pum/feedback.py +0 -0
  20. {pum-1.3.2 → pum-1.3.3}/pum/info.py +0 -0
  21. {pum-1.3.2 → pum-1.3.3}/pum/parameter.py +0 -0
  22. {pum-1.3.2 → pum-1.3.3}/pum/report_generator.py +0 -0
  23. {pum-1.3.2 → pum-1.3.3}/pum/role_manager.py +0 -0
  24. {pum-1.3.2 → pum-1.3.3}/pum/schema_migrations.py +0 -0
  25. {pum-1.3.2 → pum-1.3.3}/pum/sql_content.py +0 -0
  26. {pum-1.3.2 → pum-1.3.3}/pum/upgrader.py +0 -0
  27. {pum-1.3.2 → pum-1.3.3}/pum.egg-info/SOURCES.txt +0 -0
  28. {pum-1.3.2 → pum-1.3.3}/pum.egg-info/dependency_links.txt +0 -0
  29. {pum-1.3.2 → pum-1.3.3}/pum.egg-info/entry_points.txt +0 -0
  30. {pum-1.3.2 → pum-1.3.3}/pum.egg-info/requires.txt +0 -0
  31. {pum-1.3.2 → pum-1.3.3}/pum.egg-info/top_level.txt +0 -0
  32. {pum-1.3.2 → pum-1.3.3}/pyproject.toml +0 -0
  33. {pum-1.3.2 → pum-1.3.3}/requirements/base.txt +0 -0
  34. {pum-1.3.2 → pum-1.3.3}/requirements/development.txt +0 -0
  35. {pum-1.3.2 → pum-1.3.3}/requirements/html.txt +0 -0
  36. {pum-1.3.2 → pum-1.3.3}/setup.cfg +0 -0
  37. {pum-1.3.2 → pum-1.3.3}/test/test_changelog.py +0 -0
  38. {pum-1.3.2 → pum-1.3.3}/test/test_checker.py +0 -0
  39. {pum-1.3.2 → pum-1.3.3}/test/test_dumper.py +0 -0
  40. {pum-1.3.2 → pum-1.3.3}/test/test_feedback.py +0 -0
  41. {pum-1.3.2 → pum-1.3.3}/test/test_roles.py +0 -0
  42. {pum-1.3.2 → pum-1.3.3}/test/test_schema_migrations.py +0 -0
  43. {pum-1.3.2 → pum-1.3.3}/test/test_sql_content.py +0 -0
  44. {pum-1.3.2 → 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.2
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
@@ -111,20 +111,23 @@ class HookHandler:
111
111
  parent_dir = str(self.file.parent.resolve())
112
112
 
113
113
  # Store paths that need to be added for hook execution
114
- # Add parent directory of the hook file
115
- if parent_dir not in sys.path:
116
- 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)
117
117
 
118
118
  # Also add base_path if provided, to support imports from sibling directories
119
119
  if base_path is not None:
120
120
  base_path_str = str(base_path.resolve())
121
- if base_path_str not in sys.path and base_path_str != parent_dir:
121
+ if base_path_str != parent_dir:
122
122
  self.sys_path_additions.append(base_path_str)
123
123
 
124
- # Temporarily add paths for module loading - insert at position 0 for priority
124
+ # Add paths for module loading - insert at position 0 for priority
125
125
  for path in reversed(self.sys_path_additions):
126
126
  sys.path.insert(0, path)
127
127
 
128
+ # Invalidate caches so Python recognizes the new paths
129
+ importlib.invalidate_caches()
130
+
128
131
  try:
129
132
  logger.debug(f"Loading hook from: {self.file}")
130
133
  logger.debug(f"sys.path additions: {self.sys_path_additions}")
@@ -178,11 +181,22 @@ class HookHandler:
178
181
  arg for arg in arg_names if arg not in ("self", "connection")
179
182
  ]
180
183
 
181
- finally:
182
- # Remove all paths that were added
183
- for path in self.sys_path_additions:
184
- if path in sys.path:
185
- 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.
191
+
192
+ This should be called when the hook is no longer needed to prevent
193
+ sys.path pollution.
194
+ """
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()
186
200
 
187
201
  def __repr__(self) -> str:
188
202
  """Return a string representation of the Hook instance."""
@@ -184,11 +184,15 @@ 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
193
+ for handler in self._cached_handlers:
194
+ handler.cleanup_sys_path()
195
+
192
196
  # Clear all modules that were loaded from this base_path
193
197
  base_path_str = str(self._base_path.resolve())
194
198
  modules_to_remove = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pum
3
- Version: 1.3.2
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,252 +0,0 @@
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
- def cleanup_modules_by_path(base_path: Path) -> None:
12
- """Clean up all modules imported from a base path.
13
-
14
- This is a test helper that mimics the cleanup logic from PumConfig.cleanup_hook_imports().
15
- """
16
- base_path_str = str(base_path.resolve())
17
- modules_to_remove = []
18
- for module_name, module in list(sys.modules.items()):
19
- if module is None:
20
- continue
21
- module_file = getattr(module, "__file__", None)
22
- if module_file and module_file.startswith(base_path_str):
23
- modules_to_remove.append(module_name)
24
- for module_name in modules_to_remove:
25
- if module_name in sys.modules:
26
- del sys.modules[module_name]
27
-
28
-
29
- class TestHooks(unittest.TestCase):
30
- """Test the hook functionality."""
31
-
32
- def test_hook_with_sibling_imports(self) -> None:
33
- """Test that a hook file can import from a sibling directory.
34
-
35
- This test verifies that PUM hook files can properly import from sibling
36
- directories. The test uses a hook file in app/ that imports from view/,
37
- which is a sibling directory.
38
- """
39
- import sys
40
-
41
- test_dir = Path("test") / "data" / "hook_sibling_imports"
42
- hook_file = test_dir / "app" / "create_hook.py"
43
-
44
- # Clear any previously imported modules to ensure fresh import
45
- modules_to_remove = [key for key in sys.modules if "view.helper" in key or "view" == key]
46
- for module in modules_to_remove:
47
- del sys.modules[module]
48
-
49
- # Create hook handler - this should not raise an error
50
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
51
-
52
- # Verify the hook was loaded correctly
53
- self.assertIsNotNone(handler.hook_instance)
54
- self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
55
-
56
- # At this point sys.path should not contain the hook paths anymore
57
- test_dir_str = str(test_dir.resolve())
58
- app_dir_str = str((test_dir / "app").resolve())
59
- self.assertNotIn(test_dir_str, sys.path, "base_path should have been removed from sys.path")
60
- self.assertNotIn(app_dir_str, sys.path, "parent dir should have been removed from sys.path")
61
-
62
- # Execute the hook to ensure imports work at runtime
63
- # This is where the bug should manifest - view.helper won't be importable
64
- mock_conn = Mock()
65
- handler.execute(connection=mock_conn, parameters={})
66
-
67
- def test_hook_with_dynamic_sibling_imports(self) -> None:
68
- """Test that a hook file can dynamically import from a sibling directory at runtime.
69
-
70
- This test verifies that PUM hook files can properly import from sibling
71
- directories even when the import happens inside run_hook (not at module load time).
72
- """
73
- import sys
74
-
75
- test_dir = Path("test") / "data" / "hook_sibling_imports"
76
- hook_file = test_dir / "app" / "dynamic_import_hook.py"
77
-
78
- # Clear any previously imported modules to ensure fresh import
79
- modules_to_remove = [key for key in sys.modules if "view.helper" in key or "view" == key]
80
- for module in modules_to_remove:
81
- del sys.modules[module]
82
-
83
- # Create hook handler
84
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
85
-
86
- # Verify the hook was loaded correctly
87
- self.assertIsNotNone(handler.hook_instance)
88
- self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
89
-
90
- # Execute the hook - this should fail because sys.path modifications were removed
91
- mock_conn = Mock()
92
- handler.execute(connection=mock_conn, parameters={})
93
-
94
- def test_hook_with_local_imports(self) -> None:
95
- """Test that a hook file can import from its own directory.
96
-
97
- This test verifies that PUM hook files can properly import from their
98
- own directory. The test uses a hook file in app/ that imports from
99
- local_helper.py in the same directory.
100
- """
101
- test_dir = Path("test") / "data" / "hook_local_imports"
102
- hook_file = test_dir / "app" / "create_hook.py"
103
-
104
- # Create hook handler - this should not raise an error
105
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
106
-
107
- # Verify the hook was loaded correctly
108
- self.assertIsNotNone(handler.hook_instance)
109
- self.assertTrue(hasattr(handler.hook_instance, "run_hook"))
110
-
111
- # Execute the hook to ensure imports work at runtime
112
- mock_conn = Mock()
113
- handler.execute(connection=mock_conn, parameters={})
114
-
115
- def test_hook_cleanup_imports(self) -> None:
116
- """Test that hook imports can be cleaned up to prevent conflicts when switching versions.
117
-
118
- This test verifies that when hooks are cleaned up via path-based cleanup,
119
- their imported modules are removed from sys.modules cache, allowing fresh imports
120
- when switching to a different module version.
121
- """
122
- test_dir = Path("test") / "data" / "hook_sibling_imports"
123
- hook_file = test_dir / "app" / "create_hook.py"
124
-
125
- # Clear any previously imported modules to ensure fresh import
126
- modules_to_remove = [key for key in sys.modules if "view" in key]
127
- for module in modules_to_remove:
128
- del sys.modules[module]
129
-
130
- # Load the hook - this will import view.helper
131
- handler = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
132
-
133
- # Verify view.helper is in sys.modules
134
- view_module_found = any("view" in mod for mod in sys.modules)
135
- self.assertTrue(
136
- view_module_found, "view module should be in sys.modules after loading hook"
137
- )
138
-
139
- # Execute the hook to make sure it works before cleanup
140
- mock_conn = Mock()
141
- handler.execute(connection=mock_conn, parameters={})
142
-
143
- # Clean up imports via path-based cleanup
144
- cleanup_modules_by_path(test_dir)
145
-
146
- # Verify that view modules were removed from sys.modules
147
- view_modules_after = [mod for mod in sys.modules if "view.helper" in mod or mod == "view"]
148
- self.assertEqual(
149
- len(view_modules_after),
150
- 0,
151
- f"view modules should be removed from sys.modules after cleanup, but found: {view_modules_after}",
152
- )
153
-
154
- def test_hook_cleanup_and_reload(self) -> None:
155
- """Test that hooks can be reloaded after cleanup without conflicts.
156
-
157
- This test simulates switching between module versions by loading a hook,
158
- cleaning it up, and loading it again.
159
- """
160
- test_dir = Path("test") / "data" / "hook_sibling_imports"
161
- hook_file = test_dir / "app" / "create_hook.py"
162
-
163
- # Clear any previously imported modules
164
- modules_to_remove = [key for key in sys.modules if "view" in key]
165
- for module in modules_to_remove:
166
- del sys.modules[module]
167
-
168
- # First load
169
- handler1 = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
170
- mock_conn = Mock()
171
- handler1.execute(connection=mock_conn, parameters={})
172
-
173
- # Get the module object for comparison
174
- view_module_id_1 = None
175
- for mod_name in sys.modules:
176
- if mod_name == "view" or mod_name.startswith("view."):
177
- view_module_id_1 = id(sys.modules[mod_name])
178
- break
179
-
180
- # Clean up via path-based cleanup
181
- cleanup_modules_by_path(test_dir)
182
-
183
- # Verify cleanup worked
184
- view_modules = [mod for mod in sys.modules if "view.helper" in mod or mod == "view"]
185
- self.assertEqual(len(view_modules), 0, "Modules should be cleaned up")
186
-
187
- # Second load - should work without conflicts
188
- handler2 = HookHandler(base_path=test_dir, file=str(hook_file.relative_to(test_dir)))
189
- handler2.execute(connection=mock_conn, parameters={})
190
-
191
- # Verify it's a fresh import (different module object)
192
- view_module_id_2 = None
193
- for mod_name in sys.modules:
194
- if mod_name == "view" or mod_name.startswith("view."):
195
- view_module_id_2 = id(sys.modules[mod_name])
196
- break
197
-
198
- # Both should have found view modules
199
- self.assertIsNotNone(view_module_id_1, "First load should have imported view")
200
- self.assertIsNotNone(view_module_id_2, "Second load should have imported view")
201
-
202
- # Clean up after test
203
- cleanup_modules_by_path(test_dir)
204
-
205
- def test_hook_submodule_cleanup_on_version_switch(self) -> None:
206
- """Test that submodules are properly cleaned up when switching between module versions.
207
-
208
- This test simulates the real-world scenario where a user switches between
209
- different versions of a module that imports from nested submodules (e.g., view.submodule.helper).
210
- Without proper submodule cleanup, the cached view module from v1 would prevent
211
- v2 view.submodule.helper from being imported correctly.
212
- """
213
- v1_dir = Path("test") / "data" / "hook_submodule_cleanup" / "v1"
214
- v2_dir = Path("test") / "data" / "hook_submodule_cleanup" / "v2"
215
- hook_file = Path("app") / "create_hook.py"
216
-
217
- # Clear any previously imported view modules
218
- modules_to_remove = [key for key in sys.modules if key == "view" or key.startswith("view.")]
219
- for module in modules_to_remove:
220
- del sys.modules[module]
221
-
222
- # Load v1 hook - imports view.submodule.helper which returns value_from_submodule_v1
223
- handler_v1 = HookHandler(base_path=v1_dir, file=str(hook_file))
224
- mock_conn = Mock()
225
- # Execute v1 hook - the assertion inside run_hook will fail if wrong module is imported
226
- handler_v1.execute(connection=mock_conn, parameters={})
227
-
228
- # Verify submodules were imported
229
- view_submodules = [mod for mod in sys.modules if mod.startswith("view.submodule")]
230
- self.assertGreater(len(view_submodules), 0, "Should have imported view.submodule modules")
231
-
232
- # Clean up v1 imports via path-based cleanup
233
- cleanup_modules_by_path(v1_dir)
234
-
235
- # Verify ALL view modules (including submodules) were cleaned up
236
- remaining_view_modules = [
237
- mod for mod in sys.modules if mod == "view" or mod.startswith("view.")
238
- ]
239
- self.assertEqual(
240
- len(remaining_view_modules),
241
- 0,
242
- f"All view modules should be cleaned up, but found: {remaining_view_modules}",
243
- )
244
-
245
- # Load v2 hook - should import fresh view.submodule.helper which returns value_from_submodule_v2
246
- # This is the critical part - without submodule cleanup, Python would use the cached
247
- # view.submodule.helper from v1 and the assertion inside run_hook would fail
248
- handler_v2 = HookHandler(base_path=v2_dir, file=str(hook_file))
249
- handler_v2.execute(connection=mock_conn, parameters={})
250
-
251
- # Clean up
252
- cleanup_modules_by_path(v2_dir)
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