pum 1.2.3__py3-none-any.whl → 1.3.1__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/__init__.py +71 -10
- pum/changelog.py +61 -1
- pum/checker.py +444 -214
- pum/cli.py +279 -135
- pum/config_model.py +56 -33
- pum/connection.py +30 -0
- pum/dependency_handler.py +69 -4
- pum/dumper.py +14 -4
- pum/exceptions.py +9 -0
- pum/feedback.py +119 -0
- pum/hook.py +95 -29
- pum/info.py +0 -2
- pum/parameter.py +4 -0
- pum/pum_config.py +103 -20
- pum/report_generator.py +1043 -0
- pum/role_manager.py +151 -23
- pum/schema_migrations.py +163 -30
- pum/sql_content.py +83 -21
- pum/upgrader.py +287 -23
- {pum-1.2.3.dist-info → pum-1.3.1.dist-info}/METADATA +6 -2
- pum-1.3.1.dist-info/RECORD +25 -0
- {pum-1.2.3.dist-info → pum-1.3.1.dist-info}/WHEEL +1 -1
- pum-1.2.3.dist-info/RECORD +0 -22
- {pum-1.2.3.dist-info → pum-1.3.1.dist-info}/entry_points.txt +0 -0
- {pum-1.2.3.dist-info → pum-1.3.1.dist-info}/licenses/LICENSE +0 -0
- {pum-1.2.3.dist-info → pum-1.3.1.dist-info}/top_level.txt +0 -0
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
|
|
19
|
-
This class defines the interface for
|
|
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
|
|
72
|
-
This class manages the execution of
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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(
|
|
45
|
+
PUM_VERSION = max(packaging.version.Version(v) for v in versions)
|
|
41
46
|
else:
|
|
42
|
-
|
|
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())
|
|
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(
|
|
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
|
|
238
|
-
"""Return the list of
|
|
239
|
-
|
|
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.
|
|
322
|
+
for hook in self.config.application.create
|
|
243
323
|
]
|
|
244
|
-
if self.config.
|
|
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
|
|
249
|
-
"""Return the list of
|
|
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.
|
|
336
|
+
for hook in self.config.uninstall
|
|
254
337
|
]
|
|
255
|
-
if self.config.
|
|
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.
|
|
304
|
-
hook_handlers.extend(self.
|
|
305
|
-
if self.config.
|
|
306
|
-
hook_handlers.extend(self.
|
|
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)
|