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/__init__.py +27 -0
- pum/changelog.py +111 -0
- pum/checker.py +431 -0
- pum/cli.py +402 -0
- pum/conf/pum_config_example.yaml +19 -0
- pum/config_model.py +152 -0
- pum/dumper.py +110 -0
- pum/exceptions.py +47 -0
- pum/hook.py +231 -0
- pum/info.py +30 -0
- pum/parameter.py +72 -0
- pum/pum_config.py +231 -0
- pum/role_manager.py +253 -0
- pum/schema_migrations.py +306 -0
- pum/sql_content.py +265 -0
- pum/upgrader.py +188 -0
- pum-1.0.0.dist-info/METADATA +61 -0
- pum-1.0.0.dist-info/RECORD +22 -0
- pum-1.0.0.dist-info/WHEEL +5 -0
- pum-1.0.0.dist-info/entry_points.txt +2 -0
- pum-1.0.0.dist-info/licenses/LICENSE +339 -0
- pum-1.0.0.dist-info/top_level.txt +1 -0
pum/cli.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
|
|
11
|
+
from .checker import Checker
|
|
12
|
+
from .pum_config import PumConfig
|
|
13
|
+
|
|
14
|
+
from .info import run_info
|
|
15
|
+
from .upgrader import Upgrader
|
|
16
|
+
from .parameter import ParameterType
|
|
17
|
+
from .schema_migrations import SchemaMigrations
|
|
18
|
+
from .dumper import DumpFormat
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_logging(verbosity: int = 0):
|
|
22
|
+
"""Setup logging based on verbosity level (0=WARNING, 1=INFO, 2+=DEBUG) with colored output."""
|
|
23
|
+
level = logging.WARNING # default
|
|
24
|
+
|
|
25
|
+
if verbosity == 1:
|
|
26
|
+
level = logging.INFO
|
|
27
|
+
elif verbosity >= 2:
|
|
28
|
+
level = logging.DEBUG
|
|
29
|
+
|
|
30
|
+
class ColorFormatter(logging.Formatter):
|
|
31
|
+
COLORS = {
|
|
32
|
+
logging.ERROR: "\033[31m", # Red
|
|
33
|
+
logging.WARNING: "\033[33m", # Yellow
|
|
34
|
+
logging.INFO: "\033[36m", # Cyan
|
|
35
|
+
logging.DEBUG: "\033[35m", # Magenta
|
|
36
|
+
}
|
|
37
|
+
RESET = "\033[0m"
|
|
38
|
+
|
|
39
|
+
def format(self, record):
|
|
40
|
+
color = self.COLORS.get(record.levelno, "")
|
|
41
|
+
message = super().format(record)
|
|
42
|
+
if color:
|
|
43
|
+
message = f"{color}{message}{self.RESET}"
|
|
44
|
+
return message
|
|
45
|
+
|
|
46
|
+
handler = logging.StreamHandler()
|
|
47
|
+
handler.setFormatter(ColorFormatter("%(message)s"))
|
|
48
|
+
logging.basicConfig(
|
|
49
|
+
level=level,
|
|
50
|
+
handlers=[handler],
|
|
51
|
+
format="%(message)s",
|
|
52
|
+
force=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Pum:
|
|
57
|
+
def __init__(self, pg_service: str, config: str | PumConfig = None) -> None:
|
|
58
|
+
"""Initialize the PUM class with a database connection and configuration.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
pg_service (str): The name of the postgres service (defined in pg_service.conf)
|
|
62
|
+
config (str | PumConfig): The configuration file path or a PumConfig object.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
self.pg_service = pg_service
|
|
66
|
+
|
|
67
|
+
if isinstance(config, str):
|
|
68
|
+
self.config = PumConfig.from_yaml(config)
|
|
69
|
+
else:
|
|
70
|
+
self.config = config
|
|
71
|
+
|
|
72
|
+
def run_check(
|
|
73
|
+
self,
|
|
74
|
+
pg_service1: str,
|
|
75
|
+
pg_service2: str,
|
|
76
|
+
ignore_list: list[str] | None,
|
|
77
|
+
exclude_schema: list[str] | None,
|
|
78
|
+
exclude_field_pattern: list[str] | None,
|
|
79
|
+
verbose_level: int = 1,
|
|
80
|
+
output_file: str | None = None,
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Run the check command.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
pg_service1:
|
|
86
|
+
The name of the postgres service (defined in pg_service.conf)
|
|
87
|
+
related to the first db to be compared
|
|
88
|
+
pg_service2:
|
|
89
|
+
The name of the postgres service (defined in pg_service.conf)
|
|
90
|
+
related to the first db to be compared
|
|
91
|
+
ignore_list:
|
|
92
|
+
List of elements to be ignored in check (ex. tables, columns,
|
|
93
|
+
views, ...)
|
|
94
|
+
exclude_schema:
|
|
95
|
+
List of schemas to be ignored in check.
|
|
96
|
+
exclude_field_pattern:
|
|
97
|
+
List of field patterns to be ignored in check.
|
|
98
|
+
verbose_level:
|
|
99
|
+
verbose level, 0 -> nothing, 1 -> print first 80 char of each
|
|
100
|
+
difference, 2 -> print all the difference details
|
|
101
|
+
output_file:
|
|
102
|
+
a file path where write the differences
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True if no differences are found, False otherwise.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
# self.__out("Check...")
|
|
109
|
+
verbose_level = verbose_level or 1
|
|
110
|
+
ignore_list = ignore_list or []
|
|
111
|
+
exclude_schema = exclude_schema or []
|
|
112
|
+
exclude_field_pattern = exclude_field_pattern or []
|
|
113
|
+
try:
|
|
114
|
+
checker = Checker(
|
|
115
|
+
pg_service1,
|
|
116
|
+
pg_service2,
|
|
117
|
+
exclude_schema=exclude_schema,
|
|
118
|
+
exclude_field_pattern=exclude_field_pattern,
|
|
119
|
+
ignore_list=ignore_list,
|
|
120
|
+
verbose_level=verbose_level,
|
|
121
|
+
)
|
|
122
|
+
result, differences = checker.run_checks()
|
|
123
|
+
|
|
124
|
+
if result:
|
|
125
|
+
self.__out("OK")
|
|
126
|
+
else:
|
|
127
|
+
self.__out("DIFFERENCES FOUND")
|
|
128
|
+
|
|
129
|
+
if differences:
|
|
130
|
+
if output_file:
|
|
131
|
+
with open(output_file, "w") as f:
|
|
132
|
+
for k, values in differences.items():
|
|
133
|
+
f.write(k + "\n")
|
|
134
|
+
f.writelines(f"{v}\n" for v in values)
|
|
135
|
+
else:
|
|
136
|
+
for k, values in differences.items():
|
|
137
|
+
print(k)
|
|
138
|
+
for v in values:
|
|
139
|
+
print(v)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
except psycopg.Error as e:
|
|
143
|
+
self.__out("ERROR")
|
|
144
|
+
self.__out(e.args[0] if e.args else str(e))
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
self.__out("ERROR")
|
|
149
|
+
# if e.args is empty then use str(e)
|
|
150
|
+
self.__out(e.args[0] if e.args else str(e))
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
155
|
+
"""Creates the main parser with its sub-parsers"""
|
|
156
|
+
parser = argparse.ArgumentParser()
|
|
157
|
+
parser.add_argument("-c", "--config_file", help="set the config file. Default: .pum.yaml")
|
|
158
|
+
parser.add_argument("-s", "--pg-service", help="Name of the postgres service", required=True)
|
|
159
|
+
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"-d", "--dir", help="Directory or URL of the module. Default: .", default="."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
"-v",
|
|
166
|
+
"--verbose",
|
|
167
|
+
action="count",
|
|
168
|
+
default=0,
|
|
169
|
+
help="Increase output verbosity (e.g. -v, -vv)",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
version = importlib.metadata.version("pum")
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--version",
|
|
175
|
+
action="version",
|
|
176
|
+
version=f"pum {version}",
|
|
177
|
+
help="Show program's version number and exit.",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
subparsers = parser.add_subparsers(
|
|
181
|
+
title="commands", description="valid pum commands", dest="command"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Parser for the "info" command
|
|
185
|
+
parser_info = subparsers.add_parser("info", help="show info about schema migrations history.") # NOQA
|
|
186
|
+
|
|
187
|
+
# Parser for the "install" command
|
|
188
|
+
parser_install = subparsers.add_parser("install", help="Installs the module.")
|
|
189
|
+
parser_install.add_argument(
|
|
190
|
+
"-p",
|
|
191
|
+
"--parameter",
|
|
192
|
+
nargs=2,
|
|
193
|
+
help="Assign variable for running SQL deltas. Format is name value.",
|
|
194
|
+
action="append",
|
|
195
|
+
)
|
|
196
|
+
parser_install.add_argument("--max-version", help="maximum version to install")
|
|
197
|
+
parser_install.add_argument("-r", "--roles", help="Create roles", action="store_true")
|
|
198
|
+
parser_install.add_argument(
|
|
199
|
+
"-g", "--grant", help="Grant permissions to roles", action="store_true"
|
|
200
|
+
)
|
|
201
|
+
parser_install.add_argument(
|
|
202
|
+
"-d", "--demo-data", help="Load demo data with the given name", type=str, default=None
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Role management parser
|
|
206
|
+
parser_role = subparsers.add_parser("role", help="manage roles in the database")
|
|
207
|
+
parser_role.add_argument(
|
|
208
|
+
"action", choices=["create", "grant", "revoke", "drop"], help="Action to perform"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Parser for the "check" command
|
|
212
|
+
parser_check = subparsers.add_parser(
|
|
213
|
+
"check", help="check the differences between two databases"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
parser_check.add_argument(
|
|
217
|
+
"-i",
|
|
218
|
+
"--ignore",
|
|
219
|
+
help="Elements to be ignored",
|
|
220
|
+
nargs="+",
|
|
221
|
+
choices=[
|
|
222
|
+
"tables",
|
|
223
|
+
"columns",
|
|
224
|
+
"constraints",
|
|
225
|
+
"views",
|
|
226
|
+
"sequences",
|
|
227
|
+
"indexes",
|
|
228
|
+
"triggers",
|
|
229
|
+
"functions",
|
|
230
|
+
"rules",
|
|
231
|
+
],
|
|
232
|
+
)
|
|
233
|
+
parser_check.add_argument(
|
|
234
|
+
"-N", "--exclude-schema", help="Schema to be ignored.", action="append"
|
|
235
|
+
)
|
|
236
|
+
parser_check.add_argument(
|
|
237
|
+
"-P",
|
|
238
|
+
"--exclude-field-pattern",
|
|
239
|
+
help="Fields to be ignored based on a pattern compatible with SQL LIKE.",
|
|
240
|
+
action="append",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
parser_check.add_argument("-o", "--output_file", help="Output file")
|
|
244
|
+
|
|
245
|
+
# Parser for the "dump" command
|
|
246
|
+
parser_dump = subparsers.add_parser("dump", help="dump a Postgres database")
|
|
247
|
+
parser_dump.add_argument(
|
|
248
|
+
"-f",
|
|
249
|
+
"--format",
|
|
250
|
+
type=lambda s: DumpFormat[s.upper()],
|
|
251
|
+
choices=list(DumpFormat),
|
|
252
|
+
default=DumpFormat.PLAIN,
|
|
253
|
+
help=f"Dump format. Choices: {[e.name.lower() for e in DumpFormat]}. Default: plain.",
|
|
254
|
+
)
|
|
255
|
+
parser_dump.add_argument(
|
|
256
|
+
"-N", "--exclude-schema", help="Schema to be ignored.", action="append"
|
|
257
|
+
)
|
|
258
|
+
parser_dump.add_argument("file", help="The backup file")
|
|
259
|
+
|
|
260
|
+
# Parser for the "restore" command
|
|
261
|
+
parser_restore = subparsers.add_parser(
|
|
262
|
+
"restore", help="restore a Postgres database from a dump file"
|
|
263
|
+
)
|
|
264
|
+
parser_restore.add_argument("-x", help="ignore pg_restore errors", action="store_true")
|
|
265
|
+
parser_restore.add_argument(
|
|
266
|
+
"-N", "--exclude-schema", help="Schema to be ignored.", action="append"
|
|
267
|
+
)
|
|
268
|
+
parser_restore.add_argument("file", help="The backup file")
|
|
269
|
+
|
|
270
|
+
# Parser for the "baseline" command
|
|
271
|
+
parser_baseline = subparsers.add_parser(
|
|
272
|
+
"baseline", help="Create upgrade information table and set baseline"
|
|
273
|
+
)
|
|
274
|
+
parser_baseline.add_argument(
|
|
275
|
+
"-b", "--baseline", help="Set baseline in the format x.x.x", required=True
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Parser for the "upgrade" command
|
|
279
|
+
parser_upgrade = subparsers.add_parser("upgrade", help="upgrade db")
|
|
280
|
+
parser_upgrade.add_argument("-u", "--max-version", help="upper bound limit version")
|
|
281
|
+
parser_upgrade.add_argument(
|
|
282
|
+
"-p",
|
|
283
|
+
"--parameter",
|
|
284
|
+
nargs=2,
|
|
285
|
+
help="Assign variable for running SQL deltas. Format is: name value.",
|
|
286
|
+
action="append",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return parser
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cli() -> int: # noqa: PLR0912
|
|
293
|
+
"""Main function to run the command line interface."""
|
|
294
|
+
parser = create_parser()
|
|
295
|
+
args = parser.parse_args()
|
|
296
|
+
|
|
297
|
+
setup_logging(args.verbose)
|
|
298
|
+
logger = logging.getLogger(__name__)
|
|
299
|
+
|
|
300
|
+
# if no command is passed, print the help and exit
|
|
301
|
+
if not args.command:
|
|
302
|
+
parser.print_help()
|
|
303
|
+
parser.exit()
|
|
304
|
+
|
|
305
|
+
if args.config_file:
|
|
306
|
+
config = PumConfig.from_yaml(args.config_file)
|
|
307
|
+
else:
|
|
308
|
+
config = PumConfig.from_yaml(Path(args.dir) / ".pum.yaml")
|
|
309
|
+
|
|
310
|
+
with psycopg.connect(f"service={args.pg_service}") as conn:
|
|
311
|
+
# Check if the connection is successful
|
|
312
|
+
if not conn:
|
|
313
|
+
logger.error(f"Could not connect to the database using service: {args.pg_service}")
|
|
314
|
+
sys.exit(1)
|
|
315
|
+
|
|
316
|
+
# Build parameters dict for install and upgrade commands
|
|
317
|
+
parameters = {}
|
|
318
|
+
if args.command in ("install", "upgrade"):
|
|
319
|
+
for p in args.parameter or ():
|
|
320
|
+
param = config.parameter(p[0])
|
|
321
|
+
if not param:
|
|
322
|
+
logger.error(f"Unknown parameter: {p[0]}")
|
|
323
|
+
sys.exit(1)
|
|
324
|
+
if param.type == ParameterType.DECIMAL:
|
|
325
|
+
parameters[p[0]] = float(p[1])
|
|
326
|
+
elif param.type == ParameterType.INTEGER:
|
|
327
|
+
parameters[p[0]] = int(p[1])
|
|
328
|
+
elif param.type == ParameterType.BOOLEAN:
|
|
329
|
+
parameters[p[0]] = p[1].lower() in ("true", "1", "yes")
|
|
330
|
+
elif param.type == ParameterType.TEXT:
|
|
331
|
+
parameters[p[0]] = p[1]
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError(f"Unsupported parameter type for {p[0]}: {param.type}")
|
|
334
|
+
logger.debug(f"Parameters: {parameters}")
|
|
335
|
+
|
|
336
|
+
pum = Pum(args.pg_service, config)
|
|
337
|
+
exit_code = 0
|
|
338
|
+
|
|
339
|
+
if args.command == "info":
|
|
340
|
+
run_info(connection=conn, config=config)
|
|
341
|
+
elif args.command == "install":
|
|
342
|
+
upg = Upgrader(config=config)
|
|
343
|
+
upg.install(
|
|
344
|
+
connection=conn,
|
|
345
|
+
parameters=parameters,
|
|
346
|
+
max_version=args.max_version,
|
|
347
|
+
roles=args.roles,
|
|
348
|
+
grant=args.grant,
|
|
349
|
+
demo_data=args.demo_data,
|
|
350
|
+
)
|
|
351
|
+
conn.commit()
|
|
352
|
+
upg.install_demo_data(name=args.demo_data, connection=conn, parameters=parameters)
|
|
353
|
+
elif args.command == "role":
|
|
354
|
+
if not args.action:
|
|
355
|
+
logger.error(
|
|
356
|
+
"You must specify an action for the role command (create, grant, revoke, drop)."
|
|
357
|
+
)
|
|
358
|
+
exit_code = 1
|
|
359
|
+
else:
|
|
360
|
+
if args.action == "create":
|
|
361
|
+
config.role_manager().create_roles(connection=conn)
|
|
362
|
+
elif args.action == "grant":
|
|
363
|
+
config.role_manager().grant_permissions(connection=conn)
|
|
364
|
+
elif args.action == "revoke":
|
|
365
|
+
config.role_manager().revoke_permissions(connection=conn)
|
|
366
|
+
elif args.action == "drop":
|
|
367
|
+
config.role_manager().drop_roles(connection=conn)
|
|
368
|
+
else:
|
|
369
|
+
logger.error(f"Unknown action: {args.action}")
|
|
370
|
+
exit_code = 1
|
|
371
|
+
elif args.command == "check":
|
|
372
|
+
success = pum.run_check(
|
|
373
|
+
args.pg_service1,
|
|
374
|
+
args.pg_service2,
|
|
375
|
+
ignore_list=args.ignore,
|
|
376
|
+
exclude_schema=args.exclude_schema,
|
|
377
|
+
exclude_field_pattern=args.exclude_field_pattern,
|
|
378
|
+
verbose_level=args.verbose_level,
|
|
379
|
+
output_file=args.output_file,
|
|
380
|
+
)
|
|
381
|
+
if not success:
|
|
382
|
+
exit_code = 1
|
|
383
|
+
elif args.command == "dump":
|
|
384
|
+
pass
|
|
385
|
+
elif args.command == "restore":
|
|
386
|
+
pum.run_restore(args.pg_service, args.file, args.x, args.exclude_schema)
|
|
387
|
+
elif args.command == "baseline":
|
|
388
|
+
SchemaMigrations(config=config).set_baseline(connection=conn, version=args.baseline)
|
|
389
|
+
|
|
390
|
+
elif args.command == "upgrade":
|
|
391
|
+
# TODO
|
|
392
|
+
logger.error("Upgrade is not implemented yet")
|
|
393
|
+
else:
|
|
394
|
+
logger.error(f"Unknown command: {args.command}")
|
|
395
|
+
logger.error("Use -h or --help for help.")
|
|
396
|
+
exit_code = 1
|
|
397
|
+
|
|
398
|
+
return exit_code
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
sys.exit(cli())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#TODO comments
|
|
2
|
+
upgrades_table: qwat_sys.upgrades
|
|
3
|
+
delta_dir: ../update/delta/
|
|
4
|
+
backup_file: /tmp/backup.dump
|
|
5
|
+
ignore_elements:
|
|
6
|
+
- columns
|
|
7
|
+
- constraints
|
|
8
|
+
- views
|
|
9
|
+
- sequences
|
|
10
|
+
- indexes
|
|
11
|
+
- triggers
|
|
12
|
+
- functions
|
|
13
|
+
- rules
|
|
14
|
+
|
|
15
|
+
# pg_dump_exe = 'C:\\Program Files\\PostgreSQL\\9.3\\bin\\pg_dump.exe'
|
|
16
|
+
pg_dump_exe: pg_dump
|
|
17
|
+
|
|
18
|
+
# pg_restore_exe = 'C:\\Program Files\\PostgreSQL\\9.3\\bin\\pg_restore.exe'
|
|
19
|
+
pg_restore_exe: pg_restore
|
pum/config_model.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import packaging
|
|
2
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
3
|
+
from typing import List, Optional, Any, Literal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from .exceptions import PumConfigError
|
|
7
|
+
from .parameter import ParameterType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PumCustomBaseModel(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParameterDefinitionModel(PumCustomBaseModel):
|
|
15
|
+
"""ParameterDefinitionModel represents a parameter definition in the configuration.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
name: Name of the parameter.
|
|
19
|
+
type: Type of the parameter (default is TEXT).
|
|
20
|
+
default: Optional default value for the parameter.
|
|
21
|
+
description: Optional description of the parameter.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
type: ParameterType = Field(default=ParameterType.TEXT, description="Type of the parameter")
|
|
26
|
+
default: Optional[Any] = None
|
|
27
|
+
description: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HookModel(PumCustomBaseModel):
|
|
31
|
+
"""
|
|
32
|
+
HookModel represents a migration hook configuration.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
file: Optional path to a SQL file to execute as a hook.
|
|
36
|
+
code: Optional Python code to execute as a hook.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
file: Optional[str] = None
|
|
40
|
+
code: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def validate_args(self):
|
|
44
|
+
file, code = self.file, self.code
|
|
45
|
+
if (file and code) or (not file and not code):
|
|
46
|
+
raise PumConfigError("Exactly one of 'file' or 'code' must be set in a hook.")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MigrationHooksModel(PumCustomBaseModel):
|
|
51
|
+
"""
|
|
52
|
+
MigrationHooksModel holds the configuration for migration hooks.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
pre: List of pre-migration hooks.
|
|
56
|
+
post: List of post-migration hooks.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
pre: Optional[List[HookModel]] = []
|
|
60
|
+
post: Optional[List[HookModel]] = []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PumModel(PumCustomBaseModel):
|
|
64
|
+
"""
|
|
65
|
+
PumModel holds some PUM specifics.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
migration_table_schema: Name of schema for the migration table.
|
|
69
|
+
minimum_version: Minimum required version of PUM.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
73
|
+
migration_table_schema: Optional[str] = Field(
|
|
74
|
+
default="public", description="Name of schema for the migration table"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
minimum_version: Optional[packaging.version.Version] = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="Minimum required version of pum.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@model_validator(mode="before")
|
|
83
|
+
def parse_minimum_version(cls, values):
|
|
84
|
+
min_ver = values.get("minimum_version")
|
|
85
|
+
if isinstance(min_ver, str):
|
|
86
|
+
values["minimum_version"] = packaging.version.Version(min_ver)
|
|
87
|
+
return values
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PermissionModel(PumCustomBaseModel):
|
|
91
|
+
"""
|
|
92
|
+
PermissionModel represents a permission for a database role.
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
type: Type of permission ('read' or 'write').
|
|
96
|
+
schemas: List of schemas this permission applies to.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
type: Literal["read", "write"] = Field(..., description="Permission type ('read' or 'write').")
|
|
100
|
+
schemas: List[str] = Field(
|
|
101
|
+
default_factory=list, description="List of schemas this permission applies to."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RoleModel(PumCustomBaseModel):
|
|
106
|
+
"""
|
|
107
|
+
RoleModel represents a database role with associated permissions.
|
|
108
|
+
Attributes:
|
|
109
|
+
name: Name of the role.
|
|
110
|
+
permissions: List of permissions associated with the role.
|
|
111
|
+
inherit: Optional name of another role to inherit permissions from.
|
|
112
|
+
description: Optional description of the role.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
name: str = Field(..., description="Name of the role.")
|
|
116
|
+
permissions: List[PermissionModel] = Field(
|
|
117
|
+
default_factory=list, description="List of permissions for the role."
|
|
118
|
+
)
|
|
119
|
+
inherit: Optional[str] = Field(None, description="Name of the role to inherit from.")
|
|
120
|
+
description: Optional[str] = Field(None, description="Description of the role.")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DemoDataModel(PumCustomBaseModel):
|
|
124
|
+
"""
|
|
125
|
+
DemoDataModel represents a configuration for demo data.
|
|
126
|
+
|
|
127
|
+
Attributes:
|
|
128
|
+
files: Optional list of named demo data files.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
name: str = Field(..., description="Name of the demo data.")
|
|
132
|
+
file: str = Field(..., description="Path to the demo data file.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ConfigModel(PumCustomBaseModel):
|
|
136
|
+
"""
|
|
137
|
+
ConfigModel represents the main configuration schema for the application.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
pum: The PUM (Project Update Manager) configuration. Defaults to a new PumModel instance.
|
|
141
|
+
parameters: List of parameter definitions. Defaults to an empty list.
|
|
142
|
+
migration_hooks: Configuration for migration hooks. Defaults to a new MigrationHooksModel instance.
|
|
143
|
+
changelogs_directory: Directory path for changelogs. Defaults to "changelogs".
|
|
144
|
+
roles: List of role definitions. Defaults to None.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
pum: Optional[PumModel] = Field(default_factory=PumModel)
|
|
148
|
+
parameters: Optional[List[ParameterDefinitionModel]] = []
|
|
149
|
+
migration_hooks: Optional[MigrationHooksModel] = Field(default_factory=MigrationHooksModel)
|
|
150
|
+
changelogs_directory: Optional[str] = "changelogs"
|
|
151
|
+
roles: Optional[List[RoleModel]] = []
|
|
152
|
+
demo_data: Optional[List[DemoDataModel]] = []
|
pum/dumper.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import logging
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
PgDumpCommandError,
|
|
7
|
+
PgDumpFailed,
|
|
8
|
+
PgRestoreCommandError,
|
|
9
|
+
PgRestoreFailed,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DumpFormat(Enum):
|
|
16
|
+
CUSTOM = "custom"
|
|
17
|
+
PLAIN = "plain"
|
|
18
|
+
|
|
19
|
+
def to_pg_dump_flag(self):
|
|
20
|
+
if self == DumpFormat.CUSTOM:
|
|
21
|
+
return "-Fc"
|
|
22
|
+
elif self == DumpFormat.PLAIN:
|
|
23
|
+
return "-Fp"
|
|
24
|
+
raise ValueError(f"Unknown dump format: {self}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Dumper:
|
|
28
|
+
"""This class is used to dump and restore a Postgres database."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, pg_service: str, dump_path: str):
|
|
31
|
+
self.pg_service = pg_service
|
|
32
|
+
self.dump_path = dump_path
|
|
33
|
+
|
|
34
|
+
def pg_dump(
|
|
35
|
+
self,
|
|
36
|
+
dbname: str | None = None,
|
|
37
|
+
*,
|
|
38
|
+
pg_dump_exe: str = "pg_dump",
|
|
39
|
+
exclude_schema: list[str] | None = None,
|
|
40
|
+
format: DumpFormat = DumpFormat.CUSTOM,
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Call the pg_dump command to dump a db backup
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
dbname: Name of the database to dump.
|
|
47
|
+
pg_dump_exe: Path to the pg_dump executable.
|
|
48
|
+
exclude_schema: List of schemas to exclude from the dump.
|
|
49
|
+
format: DumpFormat, either custom (default) or plain
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
connection = f"service={self.pg_service}"
|
|
53
|
+
if dbname:
|
|
54
|
+
connection = f"{connection} dbname={dbname}"
|
|
55
|
+
|
|
56
|
+
command = [
|
|
57
|
+
pg_dump_exe,
|
|
58
|
+
format.to_pg_dump_flag(),
|
|
59
|
+
"--no-owner",
|
|
60
|
+
"--no-privileges",
|
|
61
|
+
"-f",
|
|
62
|
+
self.dump_path,
|
|
63
|
+
]
|
|
64
|
+
if exclude_schema:
|
|
65
|
+
for schema in exclude_schema:
|
|
66
|
+
command.append(f"--exclude-schema={schema}")
|
|
67
|
+
command.extend(["-d", connection])
|
|
68
|
+
|
|
69
|
+
logger.debug("Running pg_dump command: %s", " ".join(command))
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
output = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
73
|
+
if output.returncode != 0:
|
|
74
|
+
logger.error("pg_dump failed: %s", output.stderr)
|
|
75
|
+
raise PgDumpFailed(output.stderr)
|
|
76
|
+
except TypeError:
|
|
77
|
+
logger.error("Invalid command: %s", " ".join(command))
|
|
78
|
+
raise PgDumpCommandError("invalid command: {}".format(" ".join(filter(None, command))))
|
|
79
|
+
|
|
80
|
+
def pg_restore(
|
|
81
|
+
self,
|
|
82
|
+
dbname: str | None = None,
|
|
83
|
+
pg_restore_exe: str = "pg_restore",
|
|
84
|
+
exclude_schema: list[str] | None = None,
|
|
85
|
+
):
|
|
86
|
+
""" """
|
|
87
|
+
|
|
88
|
+
connection = f"service={self.pg_service}"
|
|
89
|
+
if dbname:
|
|
90
|
+
connection = f"{connection} dbname={dbname}"
|
|
91
|
+
|
|
92
|
+
command = [pg_restore_exe, "-d", connection, "--no-owner"]
|
|
93
|
+
|
|
94
|
+
if exclude_schema:
|
|
95
|
+
for schema in exclude_schema:
|
|
96
|
+
command.append(f"--exclude-schema={schema}")
|
|
97
|
+
command.append(self.dump_path)
|
|
98
|
+
|
|
99
|
+
logger.debug("Running pg_restore command: %s", " ".join(command))
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
output = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
103
|
+
if output.returncode != 0:
|
|
104
|
+
logger.error("pg_restore failed: %s", output.stderr)
|
|
105
|
+
raise PgRestoreFailed(output.stderr)
|
|
106
|
+
except TypeError:
|
|
107
|
+
logger.error("Invalid command: %s", " ".join(command))
|
|
108
|
+
raise PgRestoreCommandError(
|
|
109
|
+
"invalid command: {}".format(" ".join(filter(None, command)))
|
|
110
|
+
)
|