pum 1.2.2__py3-none-any.whl → 1.3.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.
pum/hook.py CHANGED
@@ -15,8 +15,8 @@ logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
17
  class HookBase(abc.ABC):
18
- """Base class for Python migration hooks.
19
- This class defines the interface for migration hooks that can be implemented in Python.
18
+ """Base class for Python application hooks.
19
+ This class defines the interface for application hooks that can be implemented in Python.
20
20
  It requires the implementation of the `run_hook` method, which will be called during the migration process.
21
21
  It can call the execute method to run SQL statements with the provided connection and parameters.
22
22
  """
@@ -68,8 +68,8 @@ class HookBase(abc.ABC):
68
68
 
69
69
 
70
70
  class HookHandler:
71
- """Handler for migration hooks.
72
- This class manages the execution of migration hooks, which can be either SQL files or Python functions."""
71
+ """Handler for application hooks.
72
+ This class manages the execution of application hooks, which can be either SQL files or Python functions."""
73
73
 
74
74
  def __init__(
75
75
  self,
@@ -92,6 +92,8 @@ class HookHandler:
92
92
  self.file = file
93
93
  self.code = code
94
94
  self.hook_instance = None
95
+ self.sys_path_additions = [] # Store paths to add during execution
96
+ self._imported_modules = [] # Track modules imported by this hook
95
97
 
96
98
  if file:
97
99
  if isinstance(file, str):
@@ -106,37 +108,89 @@ class HookHandler:
106
108
  raise PumHookError(f"Hook file {self.file} is not a file.")
107
109
 
108
110
  if self.file and self.file.suffix == ".py":
109
- # Support local imports in hook files by adding parent dir to sys.path
111
+ # Support local imports in hook files by adding parent dir and base_path to sys.path
110
112
  parent_dir = str(self.file.parent.resolve())
111
- sys_path_modified = False
113
+
114
+ # Store paths that need to be added for hook execution
115
+ # Add parent directory of the hook file
112
116
  if parent_dir not in sys.path:
113
- sys.path.insert(0, parent_dir)
114
- sys_path_modified = True
117
+ self.sys_path_additions.append(parent_dir)
118
+
119
+ # Also add base_path if provided, to support imports from sibling directories
120
+ if base_path is not None:
121
+ base_path_str = str(base_path.resolve())
122
+ if base_path_str not in sys.path and base_path_str != parent_dir:
123
+ self.sys_path_additions.append(base_path_str)
124
+
125
+ # Temporarily add paths for module loading
126
+ for path in self.sys_path_additions:
127
+ sys.path.insert(0, path)
128
+
129
+ # Track modules before loading to detect new imports
130
+ modules_before = set(sys.modules.keys())
131
+
115
132
  try:
116
133
  spec = importlib.util.spec_from_file_location(self.file.stem, self.file)
117
134
  module = importlib.util.module_from_spec(spec)
118
135
  spec.loader.exec_module(module)
136
+
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
+ # Check that the module contains a class named Hook inheriting from HookBase
142
+ # Do this BEFORE removing paths from sys.path
143
+ hook_class = getattr(module, "Hook", None)
144
+ if not hook_class or not inspect.isclass(hook_class):
145
+ raise PumHookError(
146
+ f"Python hook file {self.file} must define a class named 'Hook'."
147
+ )
148
+
149
+ # Check inheritance by class name to handle multiple pum installations
150
+ # (e.g., installed pum vs. libs/pum in QGIS plugin)
151
+ base_classes = [base.__name__ for base in inspect.getmro(hook_class)]
152
+ if "HookBase" not in base_classes:
153
+ # Get more info for debugging
154
+ hook_bases = [
155
+ f"{base.__module__}.{base.__name__}" for base in inspect.getmro(hook_class)
156
+ ]
157
+ logger.error(f"Hook class MRO: {hook_bases}")
158
+ logger.error(
159
+ f"Expected HookBase from: {HookBase.__module__}.{HookBase.__name__}"
160
+ )
161
+ raise PumHookError(
162
+ f"Class 'Hook' in {self.file} must inherit from HookBase. "
163
+ f"Found bases: {', '.join(base_classes)}"
164
+ )
165
+
166
+ if not hasattr(hook_class, "run_hook"):
167
+ raise PumHookError(f"Hook function 'run_hook' not found in {self.file}.")
168
+
169
+ self.hook_instance = hook_class()
170
+ arg_names = list(inspect.signature(hook_class.run_hook).parameters.keys())
171
+ if "connection" not in arg_names:
172
+ raise PumHookError(
173
+ f"Hook function 'run_hook' in {self.file} must accept 'connection' as an argument."
174
+ )
175
+ self.parameter_args = [
176
+ arg for arg in arg_names if arg not in ("self", "connection")
177
+ ]
178
+
119
179
  finally:
120
- if sys_path_modified:
121
- sys.path.remove(parent_dir)
122
- # Check that the module contains a class named Hook inheriting from HookBase
123
- hook_class = getattr(module, "Hook", None)
124
- if not hook_class or not inspect.isclass(hook_class):
125
- raise PumHookError(
126
- f"Python hook file {self.file} must define a class named 'Hook'."
127
- )
128
- if not issubclass(hook_class, HookBase):
129
- raise PumHookError(f"Class 'Hook' in {self.file} must inherit from HookBase.")
130
- if not hasattr(hook_class, "run_hook"):
131
- raise PumHookError(f"Hook function 'run_hook' not found in {self.file}.")
132
-
133
- self.hook_instance = hook_class()
134
- arg_names = list(inspect.signature(hook_class.run_hook).parameters.keys())
135
- if "connection" not in arg_names:
136
- raise PumHookError(
137
- f"Hook function 'run_hook' in {self.file} must accept 'connection' as an argument."
138
- )
139
- self.parameter_args = [arg for arg in arg_names if arg not in ("self", "connection")]
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
+
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.
189
+ """
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()
140
194
 
141
195
  def __repr__(self) -> str:
142
196
  """Return a string representation of the Hook instance."""
@@ -188,7 +242,7 @@ class HookHandler:
188
242
  parameters (dict, optional): Parameters to bind to the SQL statement. Defaults to ().
189
243
 
190
244
  """
191
- logger.info(
245
+ logger.debug(
192
246
  f"Executing hook from file: {self.file} or SQL code with parameters: {parameters}",
193
247
  )
194
248
 
@@ -216,6 +270,13 @@ class HookHandler:
216
270
  if key in self.parameter_args:
217
271
  _hook_parameters[key] = value
218
272
  self.hook_instance._prepare(connection=connection, parameters=parameters)
273
+
274
+ # Temporarily add sys.path entries for hook execution
275
+ # This allows dynamic imports inside run_hook to work
276
+ for path in self.sys_path_additions:
277
+ if path not in sys.path:
278
+ sys.path.insert(0, path)
279
+
219
280
  try:
220
281
  if _hook_parameters:
221
282
  self.hook_instance.run_hook(connection=connection, **_hook_parameters)
@@ -223,6 +284,11 @@ class HookHandler:
223
284
  self.hook_instance.run_hook(connection=connection)
224
285
  except PumSqlError as e:
225
286
  raise PumHookError(f"Error executing Python hook from {self.file}: {e}") from e
287
+ finally:
288
+ # Remove the paths after execution
289
+ for path in self.sys_path_additions:
290
+ if path in sys.path:
291
+ sys.path.remove(path)
226
292
 
227
293
  else:
228
294
  raise PumHookError(
pum/info.py CHANGED
@@ -6,8 +6,6 @@ from .pum_config import PumConfig
6
6
  from .schema_migrations import SchemaMigrations, MIGRATION_TABLE_NAME
7
7
 
8
8
  logger = logging.getLogger(__name__)
9
- # set to info here
10
- logger.setLevel(logging.INFO)
11
9
 
12
10
 
13
11
  def run_info(connection: psycopg.Connection, config: PumConfig) -> None:
pum/parameter.py CHANGED
@@ -20,6 +20,10 @@ class ParameterType(Enum):
20
20
  DECIMAL = "decimal"
21
21
  PATH = "path"
22
22
 
23
+ def __str__(self) -> str:
24
+ """Return the string value of the enum (e.g., 'integer' instead of 'ParameterType.INTEGER')."""
25
+ return self.value
26
+
23
27
 
24
28
  class ParameterDefinition:
25
29
  """A class to define a migration parameter."""
pum/pum_config.py CHANGED
@@ -2,14 +2,15 @@ from pathlib import Path
2
2
  import psycopg
3
3
  import yaml
4
4
  import packaging
5
+ import packaging.version
5
6
  from pydantic import ValidationError
6
7
  import logging
7
8
  import importlib.metadata
8
9
  import glob
9
10
  import os
11
+ import subprocess
12
+ from typing import TYPE_CHECKING
10
13
 
11
-
12
- from .changelog import Changelog
13
14
  from .dependency_handler import DependencyHandler
14
15
  from .exceptions import PumConfigError, PumException, PumHookError, PumInvalidChangelog, PumSqlError
15
16
  from .parameter import ParameterDefinition
@@ -20,6 +21,10 @@ import tempfile
20
21
  import sys
21
22
 
22
23
 
24
+ if TYPE_CHECKING:
25
+ from .changelog import Changelog
26
+
27
+
23
28
  try:
24
29
  PUM_VERSION = packaging.version.Version(importlib.metadata.version("pum"))
25
30
  except importlib.metadata.PackageNotFoundError:
@@ -37,9 +42,49 @@ except importlib.metadata.PackageNotFoundError:
37
42
  break
38
43
  if versions:
39
44
  # Pick the highest version
40
- PUM_VERSION = max((packaging.version.Version(v) for v in versions))
45
+ PUM_VERSION = max(packaging.version.Version(v) for v in versions)
41
46
  else:
42
- PUM_VERSION = packaging.version.Version("0.0.0")
47
+ # Fallback: try to get version from git (for development from source)
48
+ try:
49
+ git_dir = Path(__file__).parent.parent / ".git"
50
+ if git_dir.exists():
51
+ result = subprocess.run(
52
+ ["git", "describe", "--tags", "--always", "--dirty"],
53
+ cwd=Path(__file__).parent.parent,
54
+ capture_output=True,
55
+ text=True,
56
+ check=False,
57
+ )
58
+ if result.returncode == 0 and result.stdout.strip():
59
+ git_version = result.stdout.strip()
60
+ # Clean up git version to be PEP 440 compatible
61
+ # e.g., "0.9.2-10-g1234567" -> "0.9.2.post10"
62
+ # e.g., "0.9.2" -> "0.9.2"
63
+ # e.g., "1234567" (no tags) -> "0.0.0+1234567"
64
+ if "-" in git_version:
65
+ parts = git_version.split("-")
66
+ if len(parts) >= 3 and parts[0][0].isdigit():
67
+ # Tagged version with commits after: "0.9.2-10-g1234567"
68
+ base_version = parts[0]
69
+ commits_after = parts[1]
70
+ PUM_VERSION = packaging.version.Version(
71
+ f"{base_version}.post{commits_after}"
72
+ )
73
+ else:
74
+ # Untagged: just use the commit hash
75
+ PUM_VERSION = packaging.version.Version(f"0.0.0+{parts[0]}")
76
+ elif git_version[0].isdigit():
77
+ # Clean tag version
78
+ PUM_VERSION = packaging.version.Version(git_version)
79
+ else:
80
+ # Just a commit hash (no tags)
81
+ PUM_VERSION = packaging.version.Version(f"0.0.0+{git_version}")
82
+ else:
83
+ PUM_VERSION = packaging.version.Version("0.0.0")
84
+ else:
85
+ PUM_VERSION = packaging.version.Version("0.0.0")
86
+ except Exception:
87
+ PUM_VERSION = packaging.version.Version("0.0.0")
43
88
 
44
89
 
45
90
  logger = logging.getLogger(__name__)
@@ -76,6 +121,7 @@ class PumConfig:
76
121
  self._base_path = base_path
77
122
 
78
123
  self.dependency_path = None
124
+ self._cached_handlers = [] # Cache handlers for cleanup
79
125
 
80
126
  try:
81
127
  self.config = ConfigModel(**kwargs)
@@ -137,6 +183,18 @@ class PumConfig:
137
183
  """Return the base path used for configuration and changelogs."""
138
184
  return self._base_path
139
185
 
186
+ def cleanup_hook_imports(self) -> None:
187
+ """Clean up imported modules from hooks to prevent conflicts when switching versions.
188
+
189
+ This should be called when switching to a different module version to ensure
190
+ that cached imports from the previous version don't cause conflicts.
191
+ """
192
+ for handler in self._cached_handlers:
193
+ if hasattr(handler, "cleanup_imports"):
194
+ handler.cleanup_imports()
195
+ # Clear the cache after cleanup
196
+ self._cached_handlers.clear()
197
+
140
198
  def parameters(self) -> list[ParameterDefinition]:
141
199
  """Return a list of migration parameters.
142
200
 
@@ -145,7 +203,8 @@ class PumConfig:
145
203
 
146
204
  """
147
205
  return [
148
- ParameterDefinition(**parameter.model_dump()) for parameter in self.config.parameters
206
+ ParameterDefinition(**parameter.model_dump(mode="python"))
207
+ for parameter in self.config.parameters
149
208
  ]
150
209
 
151
210
  def parameter(self, name: str) -> ParameterDefinition:
@@ -163,7 +222,7 @@ class PumConfig:
163
222
  """
164
223
  for parameter in self.config.parameters:
165
224
  if parameter.name == name:
166
- return ParameterDefinition(**parameter.model_dump())
225
+ return ParameterDefinition(**parameter.model_dump(mode="python"))
167
226
  raise PumConfigError(f"Parameter '{name}' not found in configuration.") from KeyError
168
227
 
169
228
  def last_version(
@@ -195,7 +254,11 @@ class PumConfig:
195
254
  return None
196
255
  return changelogs[-1].version
197
256
 
198
- def changelogs(self, min_version: str | None = None, max_version: str | None = None) -> list:
257
+ def changelogs(
258
+ self,
259
+ min_version: str | packaging.version.Version | None = None,
260
+ max_version: str | packaging.version.Version | None = None,
261
+ ) -> "list[Changelog]":
199
262
  """Return a list of changelogs.
200
263
  The changelogs are sorted by version.
201
264
 
@@ -213,6 +276,9 @@ class PumConfig:
213
276
  if not path.iterdir():
214
277
  raise PumException(f"Changelogs directory `{path}` is empty.")
215
278
 
279
+ # Local import avoids circular imports at module import time.
280
+ from .changelog import Changelog
281
+
216
282
  changelogs = [Changelog(d) for d in path.iterdir() if d.is_dir()]
217
283
 
218
284
  if min_version:
@@ -234,25 +300,42 @@ class PumConfig:
234
300
  return RoleManager([])
235
301
  return RoleManager([role.model_dump() for role in self.config.roles])
236
302
 
237
- def pre_hook_handlers(self) -> list[HookHandler]:
238
- """Return the list of pre-migration hook handlers."""
239
- return (
303
+ def drop_app_handlers(self) -> list[HookHandler]:
304
+ """Return the list of drop app hook handlers."""
305
+ handlers = (
306
+ [
307
+ HookHandler(base_path=self._base_path, **hook.model_dump())
308
+ for hook in self.config.application.drop
309
+ ]
310
+ if self.config.application.drop
311
+ else []
312
+ )
313
+ # Cache handlers for cleanup
314
+ self._cached_handlers.extend(handlers)
315
+ return handlers
316
+
317
+ def create_app_handlers(self) -> list[HookHandler]:
318
+ """Return the list of create app hook handlers."""
319
+ handlers = (
240
320
  [
241
321
  HookHandler(base_path=self._base_path, **hook.model_dump())
242
- for hook in self.config.migration_hooks.pre
322
+ for hook in self.config.application.create
243
323
  ]
244
- if self.config.migration_hooks.pre
324
+ if self.config.application.create
245
325
  else []
246
326
  )
327
+ # Cache handlers for cleanup
328
+ self._cached_handlers.extend(handlers)
329
+ return handlers
247
330
 
248
- def post_hook_handlers(self) -> list[HookHandler]:
249
- """Return the list of post-migration hook handlers."""
331
+ def uninstall_handlers(self) -> list[HookHandler]:
332
+ """Return the list of uninstall hook handlers."""
250
333
  return (
251
334
  [
252
335
  HookHandler(base_path=self._base_path, **hook.model_dump())
253
- for hook in self.config.migration_hooks.post
336
+ for hook in self.config.uninstall
254
337
  ]
255
- if self.config.migration_hooks.post
338
+ if self.config.uninstall
256
339
  else []
257
340
  )
258
341
 
@@ -300,10 +383,10 @@ class PumConfig:
300
383
  raise PumInvalidChangelog(f"Changelog `{changelog}` is invalid.") from e
301
384
 
302
385
  hook_handlers = []
303
- if self.config.migration_hooks.pre:
304
- hook_handlers.extend(self.pre_hook_handlers())
305
- if self.config.migration_hooks.post:
306
- hook_handlers.extend(self.post_hook_handlers())
386
+ if self.config.application.drop:
387
+ hook_handlers.extend(self.drop_app_handlers())
388
+ if self.config.application.create:
389
+ hook_handlers.extend(self.create_app_handlers())
307
390
  for hook_handler in hook_handlers:
308
391
  try:
309
392
  hook_handler.validate(parameter_defaults)