pum 1.2.3__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/config_model.py CHANGED
@@ -1,6 +1,6 @@
1
- import packaging
1
+ from packaging.version import Version
2
2
  from pydantic import BaseModel, ConfigDict, Field, model_validator
3
- from typing import List, Optional, Any, Literal
3
+ from typing import Any, Literal
4
4
 
5
5
 
6
6
  from .exceptions import PumConfigError
@@ -8,7 +8,7 @@ from .parameter import ParameterType
8
8
 
9
9
 
10
10
  class PumCustomBaseModel(BaseModel):
11
- model_config = ConfigDict(extra="forbid")
11
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
12
12
 
13
13
 
14
14
  class ParameterDefinitionModel(PumCustomBaseModel):
@@ -23,8 +23,8 @@ class ParameterDefinitionModel(PumCustomBaseModel):
23
23
 
24
24
  name: str
25
25
  type: ParameterType = Field(default=ParameterType.TEXT, description="Type of the parameter")
26
- default: Optional[Any] = None
27
- description: Optional[str] = None
26
+ default: Any | None = None
27
+ description: str | None = None
28
28
 
29
29
  @model_validator(mode="before")
30
30
  def validate_default(cls, values):
@@ -43,8 +43,8 @@ class HookModel(PumCustomBaseModel):
43
43
  code: Optional SQL code to execute as a hook.
44
44
  """
45
45
 
46
- file: Optional[str] = None
47
- code: Optional[str] = None
46
+ file: str | None = None
47
+ code: str | None = None
48
48
 
49
49
  @model_validator(mode="after")
50
50
  def validate_args(self):
@@ -54,17 +54,27 @@ class HookModel(PumCustomBaseModel):
54
54
  return self
55
55
 
56
56
 
57
- class MigrationHooksModel(PumCustomBaseModel):
57
+ class ApplicationModel(PumCustomBaseModel):
58
58
  """
59
- MigrationHooksModel holds the configuration for migration hooks.
59
+ ApplicationModel holds the configuration for application hooks.
60
60
 
61
61
  Attributes:
62
- pre: List of pre-migration hooks.
63
- post: List of post-migration hooks.
62
+ drop: Hooks to drop the application before applying migrations.
63
+ create: Hooks to create the application after applying migrations.
64
64
  """
65
65
 
66
- pre: Optional[List[HookModel]] = []
67
- post: Optional[List[HookModel]] = []
66
+ drop: list[HookModel] | None = Field(default=[], alias="pre")
67
+ create: list[HookModel] | None = Field(default=[], alias="post")
68
+
69
+ @model_validator(mode="before")
70
+ def handle_legacy_names(cls, values):
71
+ """Support legacy field names for backward compatibility."""
72
+ # If new names don't exist but old names do, use old names
73
+ if "drop" not in values and "pre" in values:
74
+ values["drop"] = values.pop("pre")
75
+ if "create" not in values and "post" in values:
76
+ values["create"] = values.pop("post")
77
+ return values
68
78
 
69
79
 
70
80
  class PumModel(PumCustomBaseModel):
@@ -72,16 +82,18 @@ class PumModel(PumCustomBaseModel):
72
82
  PumModel holds some PUM specifics.
73
83
 
74
84
  Attributes:
85
+ module: Name of the module being managed.
75
86
  migration_table_schema: Name of schema for the migration table. The table will always be named `pum_migrations`.
76
87
  minimum_version: Minimum required version of PUM.
77
88
  """
78
89
 
79
90
  model_config = {"arbitrary_types_allowed": True}
80
- migration_table_schema: Optional[str] = Field(
91
+ module: str = Field(..., description="Name of the module being managed")
92
+ migration_table_schema: str | None = Field(
81
93
  default="public", description="Name of schema for the migration table"
82
94
  )
83
95
 
84
- minimum_version: Optional[packaging.version.Version] = Field(
96
+ minimum_version: Version | None = Field(
85
97
  default=None,
86
98
  description="Minimum required version of pum.",
87
99
  )
@@ -90,7 +102,7 @@ class PumModel(PumCustomBaseModel):
90
102
  def parse_minimum_version(cls, values):
91
103
  min_ver = values.get("minimum_version")
92
104
  if isinstance(min_ver, str):
93
- values["minimum_version"] = packaging.version.Version(min_ver)
105
+ values["minimum_version"] = Version(min_ver)
94
106
  return values
95
107
 
96
108
 
@@ -104,7 +116,7 @@ class PermissionModel(PumCustomBaseModel):
104
116
  """
105
117
 
106
118
  type: Literal["read", "write"] = Field(..., description="Permission type ('read' or 'write').")
107
- schemas: List[str] = Field(
119
+ schemas: list[str] = Field(
108
120
  default_factory=list, description="List of schemas this permission applies to."
109
121
  )
110
122
 
@@ -120,11 +132,11 @@ class RoleModel(PumCustomBaseModel):
120
132
  """
121
133
 
122
134
  name: str = Field(..., description="Name of the role.")
123
- permissions: List[PermissionModel] = Field(
135
+ permissions: list[PermissionModel] = Field(
124
136
  default_factory=list, description="List of permissions for the role."
125
137
  )
126
- inherit: Optional[str] = Field(None, description="Name of the role to inherit from.")
127
- description: Optional[str] = Field(None, description="Description of the role.")
138
+ inherit: str | None = Field(None, description="Name of the role to inherit from.")
139
+ description: str | None = Field(None, description="Description of the role.")
128
140
 
129
141
 
130
142
  class DemoDataModel(PumCustomBaseModel):
@@ -139,8 +151,8 @@ class DemoDataModel(PumCustomBaseModel):
139
151
 
140
152
  name: str = Field(..., description="Name of the demo data.")
141
153
 
142
- file: Optional[str] = None
143
- files: Optional[List[str]] = None
154
+ file: str | None = None
155
+ files: list[str] | None = None
144
156
 
145
157
  @model_validator(mode="after")
146
158
  def validate_args(self):
@@ -162,11 +174,11 @@ class DependencyModel(PumCustomBaseModel):
162
174
  model_config = {"arbitrary_types_allowed": True}
163
175
 
164
176
  name: str = Field(..., description="Name of the Python dependency.")
165
- minimum_version: Optional[packaging.version.Version] = Field(
177
+ minimum_version: Version | None = Field(
166
178
  default=None,
167
179
  description="Specific minimum required version of the package.",
168
180
  )
169
- maximum_version: Optional[packaging.version.Version] = Field(
181
+ maximum_version: Version | None = Field(
170
182
  default=None,
171
183
  description="Specific maximum required version of the package.",
172
184
  )
@@ -176,7 +188,7 @@ class DependencyModel(PumCustomBaseModel):
176
188
  for value in ("minimum_version", "maximum_version"):
177
189
  ver = values.get(value)
178
190
  if isinstance(ver, str):
179
- values[value] = packaging.version.Version(ver)
191
+ values[value] = Version(ver)
180
192
  return values
181
193
 
182
194
 
@@ -187,15 +199,26 @@ class ConfigModel(PumCustomBaseModel):
187
199
  Attributes:
188
200
  pum: The PUM (Project Update Manager) configuration. Defaults to a new PumModel instance.
189
201
  parameters: List of parameter definitions. Defaults to an empty list.
190
- migration_hooks: Configuration for migration hooks. Defaults to a new MigrationHooksModel instance.
202
+ application: Configuration for application hooks. Defaults to a new ApplicationModel instance.
191
203
  changelogs_directory: Directory path for changelogs. Defaults to "changelogs".
192
204
  roles: List of role definitions. Defaults to None.
193
205
  """
194
206
 
195
- pum: Optional[PumModel] = Field(default_factory=PumModel)
196
- parameters: Optional[List[ParameterDefinitionModel]] = []
197
- migration_hooks: Optional[MigrationHooksModel] = Field(default_factory=MigrationHooksModel)
198
- changelogs_directory: Optional[str] = "changelogs"
199
- roles: Optional[List[RoleModel]] = []
200
- demo_data: Optional[List[DemoDataModel]] = []
201
- dependencies: Optional[List[DependencyModel]] = []
207
+ pum: PumModel | None = Field(default_factory=PumModel)
208
+ parameters: list[ParameterDefinitionModel] | None = []
209
+ application: ApplicationModel | None = Field(
210
+ default_factory=ApplicationModel, alias="migration_hooks"
211
+ )
212
+ changelogs_directory: str | None = "changelogs"
213
+ roles: list[RoleModel] | None = []
214
+ demo_data: list[DemoDataModel] | None = []
215
+ dependencies: list[DependencyModel] | None = []
216
+ uninstall: list[HookModel] | None = []
217
+
218
+ @model_validator(mode="before")
219
+ def handle_legacy_field_names(cls, values):
220
+ """Support legacy field names for backward compatibility."""
221
+ # If new name doesn't exist but old name does, use old name
222
+ if "application" not in values and "migration_hooks" in values:
223
+ values["application"] = values.pop("migration_hooks")
224
+ return values
pum/connection.py ADDED
@@ -0,0 +1,30 @@
1
+ """Utilities for handling PostgreSQL connection strings."""
2
+
3
+
4
+ def format_connection_string(pg_connection: str) -> str:
5
+ """Format a connection string for use with psycopg.
6
+
7
+ Detects whether the input is a service name or a full connection string.
8
+ If it's a service name (simple identifier), wraps it as 'service=name'.
9
+ If it's a connection string (contains '=' or '://'), returns as-is.
10
+
11
+ Args:
12
+ pg_connection: Either a service name or a PostgreSQL connection string
13
+
14
+ Returns:
15
+ A properly formatted connection string for psycopg
16
+
17
+ Examples:
18
+ >>> format_connection_string('myservice')
19
+ 'service=myservice'
20
+ >>> format_connection_string('postgresql://user:pass@localhost/db')
21
+ 'postgresql://user:pass@localhost/db'
22
+ >>> format_connection_string('host=localhost dbname=mydb')
23
+ 'host=localhost dbname=mydb'
24
+
25
+ """
26
+ # If it contains '=' or '://', it's already a connection string
27
+ if "=" in pg_connection or "://" in pg_connection:
28
+ return pg_connection
29
+ # Otherwise, it's a service name
30
+ return f"service={pg_connection}"
pum/dependency_handler.py CHANGED
@@ -5,6 +5,7 @@ import os
5
5
  import sys
6
6
  import importlib.metadata
7
7
  import subprocess
8
+ from pathlib import Path
8
9
 
9
10
  from .exceptions import PumDependencyError
10
11
 
@@ -51,7 +52,7 @@ class DependencyHandler:
51
52
  f"Installed version of `{self.name}` ({installed_version}) is higher than the maximum allowed ({self.maximum_version})."
52
53
  )
53
54
 
54
- logger.info(f"Dependency {self.name} is satisfied.")
55
+ logger.debug(f"Dependency {self.name} is satisfied.")
55
56
 
56
57
  except importlib.metadata.PackageNotFoundError as e:
57
58
  if not install_dependencies:
@@ -59,6 +60,11 @@ class DependencyHandler:
59
60
  f"Dependency `{self.name}` is not installed. You can activate the installation."
60
61
  ) from e
61
62
  else:
63
+ if install_path is None:
64
+ raise PumDependencyError(
65
+ f"Dependency `{self.name}` is not installed and no install path was provided."
66
+ )
67
+ logger.debug(f"Dependency {self.name} is not installed, proceeding to install.")
62
68
  logger.warning(f"Dependency {self.name} is not installed, trying to install {e}")
63
69
  self.pip_install(install_path=install_path)
64
70
  logger.warning(f"Dependency {self.name} is now installed in {install_path}")
@@ -77,10 +83,54 @@ class DependencyHandler:
77
83
  elif self.maximum_version:
78
84
  req += f"<={self.maximum_version}"
79
85
 
80
- command = [self.python_command(), "-m", "pip", "install", req, "--target", install_path]
86
+ python_cmd = self.python_command()
81
87
 
88
+ # First, ensure pip is installed in the target directory and upgrade it if needed
82
89
  try:
83
- output = subprocess.run(command, capture_output=True, text=True, check=False)
90
+ pip_version_output = subprocess.run(
91
+ [python_cmd, "-m", "pip", "--version"], capture_output=True, text=True, check=False
92
+ )
93
+ if pip_version_output.returncode == 0:
94
+ # Extract pip version (format: "pip X.Y.Z from ...")
95
+ pip_version_str = pip_version_output.stdout.split()[1]
96
+ pip_version = packaging.version.Version(pip_version_str)
97
+ if pip_version < packaging.version.Version("22.0"):
98
+ logger.warning(
99
+ f"pip version {pip_version} is outdated, installing newer pip to target directory..."
100
+ )
101
+ # Install a newer pip to the target directory first
102
+ # This will be used by subsequent installations
103
+ upgrade_cmd = [
104
+ python_cmd,
105
+ "-m",
106
+ "pip",
107
+ "install",
108
+ "--upgrade",
109
+ "pip>=22.0",
110
+ "--target",
111
+ install_path,
112
+ ]
113
+ upgrade_result = subprocess.run(
114
+ upgrade_cmd, capture_output=True, text=True, check=False
115
+ )
116
+ if upgrade_result.returncode == 0:
117
+ logger.info(f"Successfully upgraded pip in {install_path}")
118
+ except Exception as e:
119
+ logger.debug(f"Could not check/upgrade pip version: {e}")
120
+
121
+ # Set PYTHONPATH to include install_path so pip can find itself and other packages
122
+ env = os.environ.copy()
123
+ # Ensure install_path is a string for environment variables (Windows compatibility)
124
+ install_path_str = str(install_path)
125
+ if "PYTHONPATH" in env:
126
+ env["PYTHONPATH"] = f"{install_path_str}{os.pathsep}{env['PYTHONPATH']}"
127
+ else:
128
+ env["PYTHONPATH"] = install_path_str
129
+
130
+ command = [python_cmd, "-m", "pip", "install", req, "--target", install_path_str]
131
+
132
+ try:
133
+ output = subprocess.run(command, capture_output=True, text=True, check=False, env=env)
84
134
  if output.returncode != 0:
85
135
  logger.error("pip installed failed: %s", output.stderr)
86
136
  raise PumDependencyError(output.stderr)
@@ -91,4 +141,19 @@ class DependencyHandler:
91
141
  def python_command(self):
92
142
  # python is normally found at sys.executable, but there is an issue on windows qgis so use 'python' instead
93
143
  # https://github.com/qgis/QGIS/issues/45646
94
- return "python" if os.name == "nt" else sys.executable
144
+ if os.name == "nt":
145
+ return "python"
146
+
147
+ # On macOS and Linux, if we're running inside QGIS, sys.executable points to the QGIS app
148
+ # Look for the python executable in the same directory as sys.executable
149
+ if sys.executable and "QGIS" in sys.executable:
150
+ python_dir = Path(sys.executable).parent
151
+ python_executable = python_dir / "python"
152
+ if python_executable.exists():
153
+ return str(python_executable)
154
+ # Try python3 as fallback
155
+ python3_executable = python_dir / "python3"
156
+ if python3_executable.exists():
157
+ return str(python3_executable)
158
+
159
+ return sys.executable
pum/dumper.py CHANGED
@@ -8,6 +8,7 @@ from .exceptions import (
8
8
  PgRestoreCommandError,
9
9
  PgRestoreFailed,
10
10
  )
11
+ from .connection import format_connection_string
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
@@ -27,8 +28,17 @@ class DumpFormat(Enum):
27
28
  class Dumper:
28
29
  """This class is used to dump and restore a Postgres database."""
29
30
 
30
- def __init__(self, pg_service: str, dump_path: str):
31
- self.pg_service = pg_service
31
+ def __init__(self, pg_connection: str, dump_path: str):
32
+ """Initialize the Dumper.
33
+
34
+ Args:
35
+ pg_connection: PostgreSQL service name or connection string.
36
+ Can be a service name (e.g., 'mydb') or a full connection string
37
+ (e.g., 'postgresql://user:pass@host/db' or 'host=localhost dbname=mydb').
38
+ dump_path: Path where the dump file will be saved or loaded from.
39
+
40
+ """
41
+ self.pg_connection = pg_connection
32
42
  self.dump_path = dump_path
33
43
 
34
44
  def pg_dump(
@@ -49,7 +59,7 @@ class Dumper:
49
59
  format: DumpFormat, either custom (default) or plain
50
60
  """
51
61
 
52
- connection = f"service={self.pg_service}"
62
+ connection = format_connection_string(self.pg_connection)
53
63
  if dbname:
54
64
  connection = f"{connection} dbname={dbname}"
55
65
 
@@ -85,7 +95,7 @@ class Dumper:
85
95
  ):
86
96
  """ """
87
97
 
88
- connection = f"service={self.pg_service}"
98
+ connection = format_connection_string(self.pg_connection)
89
99
  if dbname:
90
100
  connection = f"{connection} dbname={dbname}"
91
101
 
pum/exceptions.py CHANGED
@@ -18,6 +18,15 @@ class PumInvalidChangelog(PumException):
18
18
  """Exception raised for invalid changelog."""
19
19
 
20
20
 
21
+ # --- Schema Migration Errors ---
22
+ class PumSchemaMigrationError(PumException):
23
+ """Exception raised for errors related to schema migrations."""
24
+
25
+
26
+ class PumSchemaMigrationNoBaselineError(PumSchemaMigrationError):
27
+ """Exception raised when no baseline version is found in the migration table."""
28
+
29
+
21
30
  # --- Hook Errors ---
22
31
 
23
32
 
pum/feedback.py ADDED
@@ -0,0 +1,119 @@
1
+ """Feedback system for reporting progress and handling cancellation during operations."""
2
+
3
+ import abc
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class Feedback(abc.ABC):
10
+ """Base class for feedback during install/upgrade operations.
11
+
12
+ This class provides methods for progress reporting and cancellation handling.
13
+ Subclasses should implement the abstract methods to provide custom feedback mechanisms.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ """Initialize the feedback instance."""
18
+ self._is_cancelled = False
19
+ self._cancellation_locked = False
20
+ self._current_step = 0
21
+ self._total_steps = 0
22
+
23
+ def set_total_steps(self, total: int) -> None:
24
+ """Set the total number of steps for the operation.
25
+
26
+ Args:
27
+ total: The total number of steps.
28
+ """
29
+ self._total_steps = total
30
+ self._current_step = 0
31
+
32
+ def increment_step(self) -> None:
33
+ """Increment the current step counter."""
34
+ self._current_step += 1
35
+
36
+ def get_progress(self) -> tuple[int, int]:
37
+ """Get the current progress.
38
+
39
+ Returns:
40
+ A tuple of (current_step, total_steps).
41
+ """
42
+ return (self._current_step, self._total_steps)
43
+
44
+ @abc.abstractmethod
45
+ def report_progress(self, message: str, current: int = 0, total: int = 0) -> None:
46
+ """Report progress during an operation.
47
+
48
+ Args:
49
+ message: A message describing the current operation.
50
+ current: The current progress value (e.g., changelog number).
51
+ total: The total number of steps.
52
+ """
53
+ pass
54
+
55
+ def is_cancelled(self) -> bool:
56
+ """Check if the operation has been cancelled.
57
+
58
+ Returns:
59
+ True if the operation should be cancelled, False otherwise.
60
+ Always returns False if cancellation has been locked (after commit).
61
+ """
62
+ if self._cancellation_locked:
63
+ return False
64
+ return self._is_cancelled
65
+
66
+ def cancel(self) -> None:
67
+ """Cancel the operation.
68
+
69
+ Note: This will have no effect if cancellation has been locked (after commit).
70
+ """
71
+ if not self._cancellation_locked:
72
+ self._is_cancelled = True
73
+
74
+ def reset(self) -> None:
75
+ """Reset the cancellation status."""
76
+ self._is_cancelled = False
77
+
78
+ def lock_cancellation(self) -> None:
79
+ """Lock cancellation to prevent it after a commit.
80
+
81
+ Once locked, is_cancelled() will always return False and cancel() will have no effect.
82
+ This should be called immediately before committing database changes.
83
+ """
84
+ self._cancellation_locked = True
85
+
86
+
87
+ class LogFeedback(Feedback):
88
+ """Feedback implementation that logs progress messages.
89
+
90
+ This is the default feedback implementation that simply logs messages
91
+ without any UI interaction.
92
+ """
93
+
94
+ def report_progress(self, message: str, current: int = 0, total: int = 0) -> None:
95
+ """Report progress by logging the message.
96
+
97
+ Args:
98
+ message: A message describing the current operation.
99
+ current: The current progress value (ignored, uses internal counter).
100
+ total: The total number of steps (ignored, uses internal counter).
101
+ """
102
+ logger.info(message)
103
+
104
+
105
+ class SilentFeedback(Feedback):
106
+ """Feedback implementation that does nothing.
107
+
108
+ This can be used when no feedback is desired.
109
+ """
110
+
111
+ def report_progress(self, message: str, current: int = 0, total: int = 0) -> None:
112
+ """Do nothing - silent feedback.
113
+
114
+ Args:
115
+ message: A message describing the current operation (ignored).
116
+ current: The current progress value (ignored).
117
+ total: The total number of steps (ignored).
118
+ """
119
+ pass