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/config_model.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
from packaging.version import Version
|
|
2
2
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
3
|
-
from typing import
|
|
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:
|
|
27
|
-
description:
|
|
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:
|
|
47
|
-
code:
|
|
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
|
|
57
|
+
class ApplicationModel(PumCustomBaseModel):
|
|
58
58
|
"""
|
|
59
|
-
|
|
59
|
+
ApplicationModel holds the configuration for application hooks.
|
|
60
60
|
|
|
61
61
|
Attributes:
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
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"] =
|
|
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:
|
|
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:
|
|
135
|
+
permissions: list[PermissionModel] = Field(
|
|
124
136
|
default_factory=list, description="List of permissions for the role."
|
|
125
137
|
)
|
|
126
|
-
inherit:
|
|
127
|
-
description:
|
|
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:
|
|
143
|
-
files:
|
|
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:
|
|
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:
|
|
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] =
|
|
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
|
-
|
|
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:
|
|
196
|
-
parameters:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
31
|
-
|
|
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 =
|
|
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 =
|
|
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
|