pum 1.0.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/exceptions.py ADDED
@@ -0,0 +1,47 @@
1
+ # Base exception for all PUM errors
2
+ class PumException(Exception):
3
+ """Base class for all exceptions raised by PUM."""
4
+
5
+
6
+ # --- Configuration and Validation Errors ---
7
+
8
+
9
+ class PumConfigError(PumException):
10
+ """Exception raised for errors in the PUM configuration."""
11
+
12
+
13
+ class PumInvalidChangelog(PumException):
14
+ """Exception raised for invalid changelog."""
15
+
16
+
17
+ # --- Hook Errors ---
18
+
19
+
20
+ class PumHookError(PumException):
21
+ """Exception raised for errors by an invalid hook."""
22
+
23
+
24
+ # --- Changelog/SQL Errors ---
25
+
26
+
27
+ class PumSqlError(PumException):
28
+ """Exception raised for SQL-related errors in PUM."""
29
+
30
+
31
+ # --- Dump/Restore Errors (for dumper.py, if needed) ---
32
+
33
+
34
+ class PgDumpCommandError(PumException):
35
+ """Exception raised for invalid pg_dump command."""
36
+
37
+
38
+ class PgDumpFailed(PumException):
39
+ """Exception raised when pg_dump fails."""
40
+
41
+
42
+ class PgRestoreCommandError(PumException):
43
+ """Exception raised for invalid pg_restore command."""
44
+
45
+
46
+ class PgRestoreFailed(PumException):
47
+ """Exception raised when pg_restore fails."""
pum/hook.py ADDED
@@ -0,0 +1,231 @@
1
+ import importlib.util
2
+ import inspect
3
+ import logging
4
+ import sys
5
+ import psycopg
6
+ import copy
7
+
8
+ from pathlib import Path
9
+
10
+ from .exceptions import PumHookError, PumSqlError
11
+ from .sql_content import SqlContent
12
+ import abc
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
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.
20
+ It requires the implementation of the `run_hook` method, which will be called during the migration process.
21
+ It can call the execute method to run SQL statements with the provided connection and parameters.
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ """Initialize the HookBase class."""
26
+ self._parameters: dict | None = None
27
+
28
+ def _prepare(self, connection: psycopg.Connection, parameters: dict | None = None) -> None:
29
+ """Prepare the hook with the given connection and parameters.
30
+ Args:
31
+ connection: The database connection.
32
+ parameters: Parameters to bind to the SQL statement. Defaults to None.
33
+ Note:
34
+ Parameters are stored as a deep copy, any modification will not be used when calling execute.
35
+ """
36
+ self._connection = connection
37
+ self._parameters = copy.deepcopy(parameters)
38
+
39
+ @abc.abstractmethod
40
+ def run_hook(self, connection: psycopg.Connection, parameters: dict | None = None) -> None:
41
+ """Run the migration hook.
42
+ Args:
43
+ connection: The database connection.
44
+ parameters: Parameters to bind to the SQL statement. Defaults to None.
45
+
46
+ Note:
47
+ Parameters are given as a deep copy, any modification will not be used when calling execute.
48
+ """
49
+ raise NotImplementedError("The run_hook method must be implemented in the subclass.")
50
+
51
+ def execute(
52
+ self,
53
+ sql: str | psycopg.sql.SQL | Path,
54
+ ) -> None:
55
+ """Execute the migration hook with the provided SQL and parameters for the migration.
56
+ This is not committing the transaction and will be handled afterwards.
57
+
58
+ Args:
59
+ connection: The database connection.
60
+ sql: The SQL statement to execute or a path to a SQL file..
61
+ """
62
+ SqlContent(sql).execute(
63
+ connection=self._connection, parameters=self._parameters, commit=False
64
+ )
65
+
66
+ execute.__isfinal__ = True
67
+
68
+
69
+ class HookHandler:
70
+ """Handler for migration hooks.
71
+ This class manages the execution of migration hooks, which can be either SQL files or Python functions."""
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ file: str | Path | None = None,
77
+ code: str | None = None,
78
+ base_path: Path | None = None,
79
+ ) -> None:
80
+ """Initialize a Hook instance.
81
+
82
+ Args:
83
+ type: The type of the hook (e.g., "pre", "post").
84
+ file: The file path of the hook.
85
+ code: The SQL code for the hook.
86
+
87
+ """
88
+ if file and code:
89
+ raise ValueError("Cannot specify both file and code. Choose one.")
90
+
91
+ self.file = file
92
+ self.code = code
93
+ self.hook_instance = None
94
+
95
+ if file:
96
+ if isinstance(file, str):
97
+ self.file = Path(file)
98
+ if not self.file.is_absolute():
99
+ if base_path is None:
100
+ raise ValueError("Base path must be provided for relative file paths.")
101
+ self.file = base_path.absolute() / self.file
102
+ if not self.file.exists():
103
+ raise PumHookError(f"Hook file {self.file} does not exist.")
104
+ if not self.file.is_file():
105
+ raise PumHookError(f"Hook file {self.file} is not a file.")
106
+
107
+ if self.file and self.file.suffix == ".py":
108
+ # Support local imports in hook files by adding parent dir to sys.path
109
+ parent_dir = str(self.file.parent.resolve())
110
+ sys_path_modified = False
111
+ if parent_dir not in sys.path:
112
+ sys.path.insert(0, parent_dir)
113
+ sys_path_modified = True
114
+ try:
115
+ spec = importlib.util.spec_from_file_location(self.file.stem, self.file)
116
+ module = importlib.util.module_from_spec(spec)
117
+ spec.loader.exec_module(module)
118
+ finally:
119
+ if sys_path_modified:
120
+ sys.path.remove(parent_dir)
121
+ # Check that the module contains a class named Hook inheriting from HookBase
122
+ hook_class = getattr(module, "Hook", None)
123
+ if not hook_class or not inspect.isclass(hook_class):
124
+ raise PumHookError(
125
+ f"Python hook file {self.file} must define a class named 'Hook'."
126
+ )
127
+ if not issubclass(hook_class, HookBase):
128
+ raise PumHookError(f"Class 'Hook' in {self.file} must inherit from HookBase.")
129
+ if not hasattr(hook_class, "run_hook"):
130
+ raise PumHookError(f"Hook function 'run_hook' not found in {self.file}.")
131
+
132
+ self.hook_instance = hook_class()
133
+ arg_names = list(inspect.signature(hook_class.run_hook).parameters.keys())
134
+ if "connection" not in arg_names:
135
+ raise PumHookError(
136
+ f"Hook function 'run_hook' in {self.file} must accept 'connection' as an argument."
137
+ )
138
+ self.parameter_args = [arg for arg in arg_names if arg not in ("self", "connection")]
139
+
140
+ def __repr__(self) -> str:
141
+ """Return a string representation of the Hook instance."""
142
+ return f"<hook: {self.file}>"
143
+
144
+ def __eq__(self, other: "HookHandler") -> bool:
145
+ """Check if two Hook instances are equal."""
146
+ if not isinstance(other, HookHandler):
147
+ return NotImplemented
148
+ return (not self.file or self.file == other.file) and (
149
+ not self.code or self.code == other.code
150
+ )
151
+
152
+ def validate(self, parameters: dict) -> None:
153
+ """Check if the parameters match the expected parameter definitions.
154
+ This is only effective for Python hooks for now.
155
+
156
+ Args:
157
+ parameters (dict): The parameters to check.
158
+
159
+ Raises:
160
+ PumHookError: If the parameters do not match the expected definitions.
161
+
162
+ """
163
+ if self.file and self.file.suffix == ".py":
164
+ for parameter_arg in self.parameter_args:
165
+ if parameter_arg not in parameters:
166
+ raise PumHookError(
167
+ f"Hook function 'run_hook' in {self.file} has an unexpected argument "
168
+ f"'{parameter_arg}' which is not specified in the parameters."
169
+ )
170
+
171
+ if self.file and self.file.suffix == ".sql":
172
+ SqlContent(self.file).validate(parameters=parameters)
173
+
174
+ def execute(
175
+ self,
176
+ connection: psycopg.Connection,
177
+ *,
178
+ commit: bool = False,
179
+ parameters: dict | None = None,
180
+ ) -> None:
181
+ """Execute the migration hook.
182
+ This method executes the SQL code or the Python file specified in the hook.
183
+
184
+ Args:
185
+ connection: The database connection.
186
+ commit: Whether to commit the transaction after executing the SQL.
187
+ parameters (dict, optional): Parameters to bind to the SQL statement. Defaults to ().
188
+
189
+ """
190
+ logger.info(
191
+ f"Executing hook from file: {self.file} or SQL code with parameters: {parameters}",
192
+ )
193
+
194
+ if self.file is None and self.code is None:
195
+ raise ValueError("No file or SQL code specified for the migration hook.")
196
+
197
+ if self.file:
198
+ if self.file.suffix == ".sql":
199
+ SqlContent(self.file).execute(
200
+ connection=connection, commit=False, parameters=parameters
201
+ )
202
+ elif self.file.suffix == ".py":
203
+ for parameter_arg in self.parameter_args:
204
+ if not parameters or parameter_arg not in self.parameter_args:
205
+ raise PumHookError(
206
+ f"Hook function 'run_hook' in {self.file} has an unexpected "
207
+ f"argument '{parameter_arg}' which is not specified in the parameters."
208
+ )
209
+
210
+ _hook_parameters = {}
211
+ for key, value in parameters.items():
212
+ if key in self.parameter_args:
213
+ _hook_parameters[key] = value
214
+ self.hook_instance._prepare(connection=connection, parameters=parameters)
215
+ try:
216
+ if _hook_parameters:
217
+ self.hook_instance.run_hook(connection=connection, **_hook_parameters)
218
+ else:
219
+ self.hook_instance.run_hook(connection=connection)
220
+ except PumSqlError as e:
221
+ raise PumHookError(f"Error executing Python hook from {self.file}: {e}") from e
222
+
223
+ else:
224
+ raise PumHookError(
225
+ f"Unsupported file type for migration hook: {self.file.suffix}. Only .sql and .py files are supported."
226
+ )
227
+ elif self.code:
228
+ SqlContent(self.code).execute(connection, parameters=parameters, commit=False)
229
+
230
+ if commit:
231
+ connection.commit()
pum/info.py ADDED
@@ -0,0 +1,30 @@
1
+ import logging
2
+ import sys
3
+ import psycopg
4
+
5
+ from .pum_config import PumConfig
6
+ from .schema_migrations import SchemaMigrations
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def run_info(connection: psycopg.Connection, config: PumConfig) -> None:
12
+ """Print info about the schema migrations.
13
+
14
+ Args:
15
+ connection: The database connection to use for checking migrations.
16
+ config: An instance of the PumConfig class containing configuration settings for the PUM system.
17
+
18
+ """
19
+ try:
20
+ schema_migrations = SchemaMigrations(connection, config)
21
+ if not schema_migrations.exists():
22
+ logger.info(
23
+ f"No migrations found in {config.pum.migration_table_schema}.{config.pum.migration_table_name}."
24
+ )
25
+ else:
26
+ # Add your logic for when migrations exist; for now, we simply print a message.
27
+ logger.info("Migrations found.")
28
+ except Exception:
29
+ logger.exception("An error occurred while checking for migrations.")
30
+ sys.exit(1)
pum/parameter.py ADDED
@@ -0,0 +1,72 @@
1
+ from enum import Enum
2
+ import psycopg
3
+
4
+
5
+ class ParameterType(Enum):
6
+ """An enumeration of parameter types.
7
+ This class defines the types of parameters that can be used in migration definitions.
8
+
9
+ Attributes:
10
+ BOOLEAN (str): Represents a boolean parameter type.
11
+ INTEGER (str): Represents an integer parameter type.
12
+ TEXT (str): Represents a text parameter type.
13
+ DECIMAL (str): Represents a decimal parameter type.
14
+
15
+ """
16
+
17
+ BOOLEAN = "boolean"
18
+ INTEGER = "integer"
19
+ TEXT = "text"
20
+ DECIMAL = "decimal"
21
+
22
+
23
+ class ParameterDefinition:
24
+ """A class to define a migration parameter."""
25
+
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ type: str | ParameterType,
30
+ default: str | float | int | None = None,
31
+ description: str | None = None,
32
+ ) -> None:
33
+ """Initialize a ParameterDefintion instance.
34
+
35
+ Args:
36
+ name: The name of the parameter.
37
+ type: The type of the parameter, as a string or ParameterType.
38
+ default: The default value for the parameter. Defaults to None.
39
+ description: A description of the parameter. Defaults to None.
40
+
41
+ Raises:
42
+ ValueError: If type is a string and not a valid ParameterType.
43
+ TypeError: If type is not a string or ParameterType.
44
+
45
+ """
46
+ self.name = name
47
+ if isinstance(type, ParameterType):
48
+ self.type = type
49
+ elif isinstance(type, str):
50
+ try:
51
+ self.type = ParameterType(type)
52
+ except ValueError:
53
+ raise ValueError(f"Parameter '{name}' has an invalid type: {type}. ") from None
54
+ else:
55
+ raise TypeError("type must be a str or ParameterType")
56
+ self.default = psycopg.sql.Literal(default)
57
+ self.description = description
58
+
59
+ def __repr__(self) -> str:
60
+ """Return a string representation of the ParameterDefinition instance."""
61
+ return f"Parameter({self.name}, type: {self.type}, default: {self.default})"
62
+
63
+ def __eq__(self, other: "ParameterDefinition") -> bool:
64
+ """Check if two ParameterDefinition instances are equal."""
65
+ if not isinstance(other, ParameterDefinition):
66
+ return NotImplemented
67
+ return (
68
+ self.name == other.name
69
+ and self.type == other.type
70
+ and self.default == other.default
71
+ and self.description == other.description
72
+ )
pum/pum_config.py ADDED
@@ -0,0 +1,231 @@
1
+ from pathlib import Path
2
+ import psycopg
3
+ import yaml
4
+ import packaging
5
+ from pydantic import ValidationError
6
+ import logging
7
+ import importlib.metadata
8
+
9
+
10
+ from .changelog import Changelog
11
+ from .exceptions import PumConfigError, PumException, PumHookError, PumInvalidChangelog, PumSqlError
12
+ from .parameter import ParameterDefinition
13
+ from .role_manager import RoleManager
14
+ from .config_model import ConfigModel
15
+ from .hook import HookHandler
16
+
17
+
18
+ try:
19
+ PUM_VERSION = packaging.version.Version(importlib.metadata.version("pum"))
20
+ except importlib.metadata.PackageNotFoundError:
21
+ PUM_VERSION = packaging.version.Version("0.0.0")
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class PumConfig:
28
+ """A class to hold configuration settings."""
29
+
30
+ def __init__(self, base_path: str | Path, validate: bool = True, **kwargs: dict) -> None:
31
+ """Initialize the configuration with key-value pairs.
32
+
33
+ Args:
34
+ base_path: The directory where the changelogs are located.
35
+ validate: Whether to validate the changelogs and hooks.
36
+ **kwargs: Key-value pairs representing configuration settings.
37
+
38
+ Raises:
39
+ PumConfigError: If the configuration is invalid.
40
+
41
+ """
42
+
43
+ if not isinstance(base_path, Path):
44
+ base_path = Path(base_path)
45
+ if not base_path.is_dir():
46
+ raise PumConfigError(f"Directory `{base_path}` does not exist.")
47
+ self._base_path = base_path
48
+
49
+ try:
50
+ self.config = ConfigModel(**kwargs)
51
+ except ValidationError as e:
52
+ logger.error("Config validation error: %s", e)
53
+ raise PumConfigError(e) from e
54
+
55
+ if validate:
56
+ if self.config.pum.minimum_version and PUM_VERSION < self.config.pum.minimum_version:
57
+ raise PumConfigError(
58
+ f"Minimum required version of pum is {self.config.pum.minimum_version}, but the current version is {PUM_VERSION}. Please upgrade pum."
59
+ )
60
+ try:
61
+ self.validate()
62
+ except (PumInvalidChangelog, PumHookError) as e:
63
+ raise PumConfigError(
64
+ f"Configuration is invalid: {e}. You can disable the validation when constructing the config."
65
+ ) from e
66
+
67
+ @classmethod
68
+ def from_yaml(cls, file_path: str | Path, *, validate: bool = True) -> "PumConfig":
69
+ """Create a PumConfig instance from a YAML file.
70
+
71
+ Args:
72
+ file_path: The path to the YAML file.
73
+ validate: Whether to validate the changelogs and hooks.
74
+
75
+ Returns:
76
+ PumConfig: An instance of the PumConfig class.
77
+
78
+ Raises:
79
+ FileNotFoundError: If the file does not exist.
80
+ yaml.YAMLError: If there is an error parsing the YAML file.
81
+
82
+ """
83
+ with Path.open(file_path) as file:
84
+ data = yaml.safe_load(file)
85
+
86
+ if "base_path" in data:
87
+ raise PumConfigError("base_path not allowed in configuration instead.")
88
+
89
+ base_path = Path(file_path).parent
90
+ return cls(base_path=base_path, validate=validate, **data)
91
+
92
+ @property
93
+ def base_path(self) -> Path:
94
+ """Return the base path used for configuration and changelogs."""
95
+ return self._base_path
96
+
97
+ def parameter(self, name: str) -> ParameterDefinition:
98
+ """Get a specific migration parameter by name.
99
+
100
+ Args:
101
+ name: The name of the parameter.
102
+
103
+ Returns:
104
+ ParameterDefintion: The migration parameter definition.
105
+
106
+ Raises:
107
+ PumConfigError: If the parameter name does not exist.
108
+
109
+ """
110
+ for parameter in self.config.parameters:
111
+ if parameter.name == name:
112
+ return ParameterDefinition(**parameter.model_dump())
113
+ raise PumConfigError(f"Parameter '{name}' not found in configuration.") from KeyError
114
+
115
+ def last_version(
116
+ self, min_version: str | None = None, max_version: str | None = None
117
+ ) -> str | None:
118
+ """Return the last version of the changelogs.
119
+ The changelogs are sorted by version.
120
+
121
+ Args:
122
+ min_version (str | None): The version to start from (inclusive).
123
+ max_version (str | None): The version to end at (inclusive).
124
+
125
+ Returns:
126
+ str | None: The last version of the changelogs. If no changelogs are found, None is returned.
127
+
128
+ """
129
+ changelogs = self.changelogs(min_version, max_version)
130
+ if not changelogs:
131
+ return None
132
+ if min_version:
133
+ changelogs = [
134
+ c for c in changelogs if c.version >= packaging.version.parse(min_version)
135
+ ]
136
+ if max_version:
137
+ changelogs = [
138
+ c for c in changelogs if c.version <= packaging.version.parse(max_version)
139
+ ]
140
+ if not changelogs:
141
+ return None
142
+ return changelogs[-1].version
143
+
144
+ def changelogs(self, min_version: str | None = None, max_version: str | None = None) -> list:
145
+ """Return a list of changelogs.
146
+ The changelogs are sorted by version.
147
+
148
+ Args:
149
+ min_version (str | None): The version to start from (inclusive).
150
+ max_version (str | None): The version to end at (inclusive).
151
+
152
+ Returns:
153
+ list: A list of changelogs. Each changelog is represented by a Changelog object.
154
+
155
+ """
156
+ path = self._base_path / self.config.changelogs_directory
157
+ if not path.is_dir():
158
+ raise PumException(f"Changelogs directory `{path}` does not exist.")
159
+ if not path.iterdir():
160
+ raise PumException(f"Changelogs directory `{path}` is empty.")
161
+
162
+ changelogs = [Changelog(d) for d in path.iterdir() if d.is_dir()]
163
+
164
+ if min_version:
165
+ changelogs = [
166
+ c for c in changelogs if c.version >= packaging.version.parse(min_version)
167
+ ]
168
+ if max_version:
169
+ changelogs = [
170
+ c for c in changelogs if c.version <= packaging.version.parse(max_version)
171
+ ]
172
+
173
+ changelogs.sort(key=lambda c: c.version)
174
+ return changelogs
175
+
176
+ def role_manager(self) -> RoleManager:
177
+ """Return a RoleManager instance based on the roles defined in the configuration."""
178
+ if not self.config.roles:
179
+ logger.warning("No roles defined in the configuration. Returning an empty RoleManager.")
180
+ return RoleManager()
181
+ return RoleManager([role.model_dump() for role in self.config.roles])
182
+
183
+ def pre_hook_handlers(self) -> list[HookHandler]:
184
+ """Return the list of pre-migration hook handlers."""
185
+ return (
186
+ [
187
+ HookHandler(base_path=self._base_path, **hook.model_dump())
188
+ for hook in self.config.migration_hooks.pre
189
+ ]
190
+ if self.config.migration_hooks.pre
191
+ else []
192
+ )
193
+
194
+ def post_hook_handlers(self) -> list[HookHandler]:
195
+ """Return the list of post-migration hook handlers."""
196
+ return (
197
+ [
198
+ HookHandler(base_path=self._base_path, **hook.model_dump())
199
+ for hook in self.config.migration_hooks.post
200
+ ]
201
+ if self.config.migration_hooks.post
202
+ else []
203
+ )
204
+
205
+ def demo_data(self) -> dict[str, str]:
206
+ """Return a dictionary of demo data files defined in the configuration."""
207
+ return {dm.name: dm.file for dm in self.config.demo_data}
208
+
209
+ def validate(self) -> None:
210
+ """Validate the chanbgelogs and hooks."""
211
+
212
+ parameter_defaults = {}
213
+ for parameter in self.config.parameters:
214
+ parameter_defaults[parameter.name] = psycopg.sql.Literal(parameter.default)
215
+
216
+ for changelog in self.changelogs():
217
+ try:
218
+ changelog.validate(parameters=parameter_defaults)
219
+ except (PumInvalidChangelog, PumSqlError) as e:
220
+ raise PumInvalidChangelog(f"Changelog `{changelog}` is invalid.") from e
221
+
222
+ hook_handlers = []
223
+ if self.config.migration_hooks.pre:
224
+ hook_handlers.extend(self.pre_hook_handlers())
225
+ if self.config.migration_hooks.post:
226
+ hook_handlers.extend(self.post_hook_handlers())
227
+ for hook_handler in hook_handlers:
228
+ try:
229
+ hook_handler.validate(parameter_defaults)
230
+ except PumHookError as e:
231
+ raise PumHookError(f"Hook `{hook_handler}` is invalid.") from e