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/cli.py CHANGED
@@ -9,23 +9,36 @@ from pathlib import Path
9
9
  import psycopg
10
10
 
11
11
  from .checker import Checker
12
+ from .report_generator import ReportGenerator
12
13
  from .pum_config import PumConfig
14
+ from .connection import format_connection_string
13
15
 
14
16
  from .info import run_info
15
17
  from .upgrader import Upgrader
16
18
  from .parameter import ParameterType
17
19
  from .schema_migrations import SchemaMigrations
18
- from .dumper import DumpFormat
20
+ from .dumper import DumpFormat, Dumper
21
+ from . import SQL
19
22
 
20
23
 
21
24
  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
25
+ """Configure logging for the CLI.
24
26
 
25
- if verbosity == 1:
26
- level = logging.INFO
27
+ Args:
28
+ verbosity: Verbosity level (-1=quiet/WARNING, 0=INFO, 1=DEBUG, 2+=SQL).
29
+
30
+ """
31
+ # Register custom SQL log level
32
+ logging.addLevelName(SQL, "SQL")
33
+
34
+ if verbosity < 0:
35
+ level = logging.WARNING # quiet mode
27
36
  elif verbosity >= 2:
37
+ level = SQL # Most verbose - shows all SQL statements
38
+ elif verbosity >= 1:
28
39
  level = logging.DEBUG
40
+ else:
41
+ level = logging.INFO # default
29
42
 
30
43
  class ColorFormatter(logging.Formatter):
31
44
  COLORS = {
@@ -33,6 +46,7 @@ def setup_logging(verbosity: int = 0):
33
46
  logging.WARNING: "\033[33m", # Yellow
34
47
  logging.INFO: "\033[36m", # Cyan
35
48
  logging.DEBUG: "\033[35m", # Magenta
49
+ SQL: "\033[90m", # Gray for SQL statements
36
50
  }
37
51
  RESET = "\033[0m"
38
52
 
@@ -54,108 +68,56 @@ def setup_logging(verbosity: int = 0):
54
68
 
55
69
 
56
70
  class Pum:
57
- def __init__(self, pg_service: str, config: str | PumConfig = None) -> None:
71
+ def __init__(self, pg_connection: str, config: str | PumConfig = None) -> None:
58
72
  """Initialize the PUM class with a database connection and configuration.
59
73
 
60
74
  Args:
61
- pg_service (str): The name of the postgres service (defined in pg_service.conf)
75
+ pg_connection (str): PostgreSQL service name or connection string.
76
+ Can be a service name (e.g., 'mydb') or a full connection string
77
+ (e.g., 'postgresql://user:pass@host/db' or 'host=localhost dbname=mydb').
62
78
  config (str | PumConfig): The configuration file path or a PumConfig object.
63
79
 
64
80
  """
65
- self.pg_service = pg_service
81
+ self.pg_connection = pg_connection
66
82
 
67
83
  if isinstance(config, str):
68
84
  self.config = PumConfig.from_yaml(config)
69
85
  else:
70
86
  self.config = config
71
87
 
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
88
 
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()
89
+ def create_parser(
90
+ max_help_position: int | None = None, width: int | None = None
91
+ ) -> argparse.ArgumentParser:
92
+ """Create the main argument parser and all subparsers.
123
93
 
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)
94
+ Args:
95
+ max_help_position: Maximum help position for formatting.
96
+ width: Width for formatting.
146
97
 
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)
98
+ Returns:
99
+ The fully configured argument parser.
152
100
 
101
+ """
102
+ if max_help_position is not None or width is not None:
153
103
 
154
- def create_parser() -> argparse.ArgumentParser:
155
- """Creates the main parser with its sub-parsers"""
156
- parser = argparse.ArgumentParser()
104
+ def formatter_class(prog):
105
+ return argparse.HelpFormatter(
106
+ prog, max_help_position=max_help_position or 40, width=width or 200
107
+ )
108
+ else:
109
+ formatter_class = argparse.HelpFormatter
110
+ parser = argparse.ArgumentParser(
111
+ prog="pum",
112
+ formatter_class=formatter_class,
113
+ )
157
114
  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)
115
+ parser.add_argument(
116
+ "-p",
117
+ "--pg-connection",
118
+ help="PostgreSQL service name or connection string (e.g., 'mydb' or 'postgresql://user:pass@host/db')",
119
+ required=True,
120
+ )
159
121
 
160
122
  parser.add_argument(
161
123
  "-d", "--dir", help="Directory or URL of the module. Default: .", default="."
@@ -166,7 +128,14 @@ def create_parser() -> argparse.ArgumentParser:
166
128
  "--verbose",
167
129
  action="count",
168
130
  default=0,
169
- help="Increase output verbosity (e.g. -v, -vv)",
131
+ help="Increase verbosity (-v for DEBUG, -vv for SQL statements)",
132
+ )
133
+
134
+ parser.add_argument(
135
+ "-q",
136
+ "--quiet",
137
+ action="store_true",
138
+ help="Suppress info messages, only show warnings and errors",
170
139
  )
171
140
 
172
141
  version = importlib.metadata.version("pum")
@@ -182,10 +151,16 @@ def create_parser() -> argparse.ArgumentParser:
182
151
  )
183
152
 
184
153
  # Parser for the "info" command
185
- parser_info = subparsers.add_parser("info", help="show info about schema migrations history.") # NOQA
154
+ parser_info = subparsers.add_parser( # NOQA
155
+ "info",
156
+ help="show info about schema migrations history.",
157
+ formatter_class=formatter_class,
158
+ )
186
159
 
187
160
  # Parser for the "install" command
188
- parser_install = subparsers.add_parser("install", help="Installs the module.")
161
+ parser_install = subparsers.add_parser(
162
+ "install", help="Installs the module.", formatter_class=formatter_class
163
+ )
189
164
  parser_install.add_argument(
190
165
  "-p",
191
166
  "--parameter",
@@ -194,9 +169,9 @@ def create_parser() -> argparse.ArgumentParser:
194
169
  action="append",
195
170
  )
196
171
  parser_install.add_argument("--max-version", help="maximum version to install")
197
- parser_install.add_argument("-r", "--roles", help="Create roles", action="store_true")
172
+ parser_install.add_argument("--skip-roles", help="Skip creating roles", action="store_true")
198
173
  parser_install.add_argument(
199
- "-g", "--grant", help="Grant permissions to roles", action="store_true"
174
+ "--skip-grant", help="Skip granting permissions to roles", action="store_true"
200
175
  )
201
176
  parser_install.add_argument(
202
177
  "-d", "--demo-data", help="Load demo data with the given name", type=str, default=None
@@ -206,19 +181,70 @@ def create_parser() -> argparse.ArgumentParser:
206
181
  help="This will install the module in beta testing, meaning that it will not be possible to receive any future updates.",
207
182
  action="store_true",
208
183
  )
184
+ parser_install.add_argument(
185
+ "--skip-drop-app",
186
+ help="Skip drop app handlers during installation.",
187
+ action="store_true",
188
+ )
189
+ parser_install.add_argument(
190
+ "--skip-create-app",
191
+ help="Skip create app handlers during installation.",
192
+ action="store_true",
193
+ )
194
+
195
+ # Upgrade parser
196
+ parser_upgrade = subparsers.add_parser(
197
+ "upgrade", help="Upgrade the database.", formatter_class=formatter_class
198
+ )
199
+ parser_upgrade.add_argument(
200
+ "-p",
201
+ "--parameter",
202
+ nargs=2,
203
+ help="Assign variable for running SQL deltas. Format is name value.",
204
+ action="append",
205
+ )
206
+ parser_upgrade.add_argument("-u", "--max-version", help="maximum version to upgrade")
207
+ parser_upgrade.add_argument(
208
+ "--skip-grant", help="Skip granting permissions to roles", action="store_true"
209
+ )
210
+ parser_upgrade.add_argument(
211
+ "--beta-testing", help="Install in beta testing mode.", action="store_true"
212
+ )
213
+ parser_upgrade.add_argument(
214
+ "--force",
215
+ help="Allow upgrading a module installed in beta testing mode.",
216
+ action="store_true",
217
+ )
218
+ parser_upgrade.add_argument(
219
+ "--skip-drop-app",
220
+ help="Skip drop app handlers during upgrade.",
221
+ action="store_true",
222
+ )
223
+ parser_upgrade.add_argument(
224
+ "--skip-create-app",
225
+ help="Skip create app handlers during upgrade.",
226
+ action="store_true",
227
+ )
209
228
 
210
229
  # Role management parser
211
- parser_role = subparsers.add_parser("role", help="manage roles in the database")
230
+ parser_role = subparsers.add_parser(
231
+ "role", help="manage roles in the database", formatter_class=formatter_class
232
+ )
212
233
  parser_role.add_argument(
213
234
  "action", choices=["create", "grant", "revoke", "drop"], help="Action to perform"
214
235
  )
215
236
 
216
237
  # Parser for the "check" command
217
- parser_check = subparsers.add_parser(
218
- "check", help="check the differences between two databases"
238
+ parser_checker = subparsers.add_parser(
239
+ "check", help="check the differences between two databases", formatter_class=formatter_class
240
+ )
241
+
242
+ parser_checker.add_argument(
243
+ "pg_connection_compared",
244
+ help="PostgreSQL service name or connection string for the database to compare against",
219
245
  )
220
246
 
221
- parser_check.add_argument(
247
+ parser_checker.add_argument(
222
248
  "-i",
223
249
  "--ignore",
224
250
  help="Elements to be ignored",
@@ -235,20 +261,29 @@ def create_parser() -> argparse.ArgumentParser:
235
261
  "rules",
236
262
  ],
237
263
  )
238
- parser_check.add_argument(
264
+ parser_checker.add_argument(
239
265
  "-N", "--exclude-schema", help="Schema to be ignored.", action="append"
240
266
  )
241
- parser_check.add_argument(
267
+ parser_checker.add_argument(
242
268
  "-P",
243
269
  "--exclude-field-pattern",
244
270
  help="Fields to be ignored based on a pattern compatible with SQL LIKE.",
245
271
  action="append",
246
272
  )
247
273
 
248
- parser_check.add_argument("-o", "--output_file", help="Output file")
274
+ parser_checker.add_argument("-o", "--output_file", help="Output file")
275
+ parser_checker.add_argument(
276
+ "-f",
277
+ "--format",
278
+ choices=["text", "html", "json"],
279
+ default="text",
280
+ help="Output format: text, html, or json. Default: text",
281
+ )
249
282
 
250
283
  # Parser for the "dump" command
251
- parser_dump = subparsers.add_parser("dump", help="dump a Postgres database")
284
+ parser_dump = subparsers.add_parser(
285
+ "dump", help="dump a Postgres database", formatter_class=formatter_class
286
+ )
252
287
  parser_dump.add_argument(
253
288
  "-f",
254
289
  "--format",
@@ -264,7 +299,9 @@ def create_parser() -> argparse.ArgumentParser:
264
299
 
265
300
  # Parser for the "restore" command
266
301
  parser_restore = subparsers.add_parser(
267
- "restore", help="restore a Postgres database from a dump file"
302
+ "restore",
303
+ help="restore a Postgres database from a dump file",
304
+ formatter_class=formatter_class,
268
305
  )
269
306
  parser_restore.add_argument("-x", help="ignore pg_restore errors", action="store_true")
270
307
  parser_restore.add_argument(
@@ -274,7 +311,9 @@ def create_parser() -> argparse.ArgumentParser:
274
311
 
275
312
  # Parser for the "baseline" command
276
313
  parser_baseline = subparsers.add_parser(
277
- "baseline", help="Create upgrade information table and set baseline"
314
+ "baseline",
315
+ help="Create upgrade information table and set baseline",
316
+ formatter_class=formatter_class,
278
317
  )
279
318
  parser_baseline.add_argument(
280
319
  "-b", "--baseline", help="Set baseline in the format x.x.x", required=True
@@ -285,26 +324,50 @@ def create_parser() -> argparse.ArgumentParser:
285
324
  action="store_true",
286
325
  )
287
326
 
288
- # Parser for the "upgrade" command
289
- parser_upgrade = subparsers.add_parser("upgrade", help="upgrade db")
290
- parser_upgrade.add_argument("-u", "--max-version", help="upper bound limit version")
291
- parser_upgrade.add_argument(
327
+ # Parser for the "uninstall" command
328
+ parser_uninstall = subparsers.add_parser(
329
+ "uninstall",
330
+ help="Uninstall the module by executing uninstall hooks",
331
+ formatter_class=formatter_class,
332
+ )
333
+ parser_uninstall.add_argument(
292
334
  "-p",
293
335
  "--parameter",
294
336
  nargs=2,
295
- help="Assign variable for running SQL deltas. Format is: name value.",
337
+ help="Assign variable for running SQL hooks. Format is name value.",
296
338
  action="append",
297
339
  )
340
+ parser_uninstall.add_argument(
341
+ "--force",
342
+ help="Skip confirmation prompt and proceed with uninstall",
343
+ action="store_true",
344
+ dest="force",
345
+ )
298
346
 
299
347
  return parser
300
348
 
301
349
 
302
350
  def cli() -> int: # noqa: PLR0912
303
- """Main function to run the command line interface."""
351
+ """Run the command line interface.
352
+
353
+ Returns:
354
+ Process exit code.
355
+
356
+ """
304
357
  parser = create_parser()
305
358
  args = parser.parse_args()
306
359
 
307
- setup_logging(args.verbose)
360
+ # Validate mutually exclusive flags
361
+ if args.quiet and args.verbose:
362
+ parser.error("--quiet and --verbose are mutually exclusive")
363
+
364
+ # Set verbosity level (-1 for quiet, 0 for normal, 1+ for verbose)
365
+ if args.quiet:
366
+ verbosity = -1
367
+ else:
368
+ verbosity = args.verbose
369
+
370
+ setup_logging(verbosity)
308
371
  logger = logging.getLogger(__name__)
309
372
 
310
373
  # if no command is passed, print the help and exit
@@ -312,6 +375,55 @@ def cli() -> int: # noqa: PLR0912
312
375
  parser.print_help()
313
376
  parser.exit()
314
377
 
378
+ # Handle check command separately (doesn't need db connection)
379
+ if args.command == "check":
380
+ exit_code = 0
381
+ checker = Checker(
382
+ args.pg_connection,
383
+ args.pg_connection_compared,
384
+ exclude_schema=args.exclude_schema or [],
385
+ exclude_field_pattern=args.exclude_field_pattern or [],
386
+ ignore_list=args.ignore or [],
387
+ )
388
+ report = checker.run_checks()
389
+ checker.conn1.close()
390
+ checker.conn2.close()
391
+
392
+ if report.passed:
393
+ logger.info("OK")
394
+ else:
395
+ logger.info("DIFFERENCES FOUND")
396
+
397
+ if args.format == "html":
398
+ html_report = ReportGenerator.generate_html(report)
399
+ if args.output_file:
400
+ with open(args.output_file, "w", encoding="utf-8") as f:
401
+ f.write(html_report)
402
+ logger.info(f"HTML report written to {args.output_file}")
403
+ else:
404
+ print(html_report)
405
+ elif args.format == "json":
406
+ json_report = ReportGenerator.generate_json(report)
407
+ if args.output_file:
408
+ with open(args.output_file, "w", encoding="utf-8") as f:
409
+ f.write(json_report)
410
+ logger.info(f"JSON report written to {args.output_file}")
411
+ else:
412
+ print(json_report)
413
+ else:
414
+ # Text output (backward compatible)
415
+ text_output = ReportGenerator.generate_text(report)
416
+ if args.output_file:
417
+ with open(args.output_file, "w") as f:
418
+ f.write(text_output)
419
+ else:
420
+ print(text_output)
421
+
422
+ if not report.passed:
423
+ exit_code = 1
424
+
425
+ return exit_code
426
+
315
427
  validate = args.command not in ("info", "baseline")
316
428
  if args.config_file:
317
429
  config = PumConfig.from_yaml(args.config_file, validate=validate, install_dependencies=True)
@@ -320,15 +432,15 @@ def cli() -> int: # noqa: PLR0912
320
432
  Path(args.dir) / ".pum.yaml", validate=validate, install_dependencies=True
321
433
  )
322
434
 
323
- with psycopg.connect(f"service={args.pg_service}") as conn:
435
+ with psycopg.connect(format_connection_string(args.pg_connection)) as conn:
324
436
  # Check if the connection is successful
325
437
  if not conn:
326
- logger.error(f"Could not connect to the database using service: {args.pg_service}")
438
+ logger.error(f"Could not connect to the database: {args.pg_connection}")
327
439
  sys.exit(1)
328
440
 
329
441
  # Build parameters dict for install and upgrade commands
330
442
  parameters = {}
331
- if args.command in ("install", "upgrade"):
443
+ if args.command in ("install", "upgrade", "uninstall"):
332
444
  for p in args.parameter or ():
333
445
  param = config.parameter(p[0])
334
446
  if not param:
@@ -346,7 +458,6 @@ def cli() -> int: # noqa: PLR0912
346
458
  raise ValueError(f"Unsupported parameter type for {p[0]}: {param.type}")
347
459
  logger.debug(f"Parameters: {parameters}")
348
460
 
349
- pum = Pum(args.pg_service, config)
350
461
  exit_code = 0
351
462
 
352
463
  if args.command == "info":
@@ -357,13 +468,34 @@ def cli() -> int: # noqa: PLR0912
357
468
  connection=conn,
358
469
  parameters=parameters,
359
470
  max_version=args.max_version,
360
- roles=args.roles,
361
- grant=args.grant,
471
+ roles=not args.skip_roles,
472
+ grant=not args.skip_grant,
362
473
  beta_testing=args.beta_testing,
474
+ skip_drop_app=args.skip_drop_app,
475
+ skip_create_app=args.skip_create_app,
363
476
  )
364
477
  conn.commit()
365
478
  if args.demo_data:
366
- upg.install_demo_data(name=args.demo_data, connection=conn, parameters=parameters)
479
+ upg.install_demo_data(
480
+ name=args.demo_data,
481
+ connection=conn,
482
+ parameters=parameters,
483
+ grant=not args.skip_grant,
484
+ skip_create_app=args.skip_create_app,
485
+ skip_drop_app=args.skip_drop_app,
486
+ )
487
+ elif args.command == "upgrade":
488
+ upg = Upgrader(config=config)
489
+ upg.upgrade(
490
+ connection=conn,
491
+ parameters=parameters,
492
+ max_version=args.max_version,
493
+ grant=not args.skip_grant,
494
+ beta_testing=args.beta_testing,
495
+ force=args.force,
496
+ skip_drop_app=args.skip_drop_app,
497
+ skip_create_app=args.skip_create_app,
498
+ )
367
499
  elif args.command == "role":
368
500
  if not args.action:
369
501
  logger.error(
@@ -382,22 +514,22 @@ def cli() -> int: # noqa: PLR0912
382
514
  else:
383
515
  logger.error(f"Unknown action: {args.action}")
384
516
  exit_code = 1
385
- elif args.command == "check":
386
- success = pum.run_check(
387
- args.pg_service1,
388
- args.pg_service2,
389
- ignore_list=args.ignore,
390
- exclude_schema=args.exclude_schema,
391
- exclude_field_pattern=args.exclude_field_pattern,
392
- verbose_level=args.verbose_level,
393
- output_file=args.output_file,
394
- )
395
- if not success:
396
- exit_code = 1
397
517
  elif args.command == "dump":
398
- pass
518
+ dumper = Dumper(args.pg_connection, args.file)
519
+ dumper.pg_dump(
520
+ exclude_schema=args.exclude_schema or [],
521
+ format=args.format,
522
+ )
523
+ logger.info(f"Database dumped to {args.file}")
399
524
  elif args.command == "restore":
400
- pum.run_restore(args.pg_service, args.file, args.x, args.exclude_schema)
525
+ dumper = Dumper(args.pg_connection, args.file)
526
+ try:
527
+ dumper.pg_restore(exclude_schema=args.exclude_schema or [])
528
+ logger.info(f"Database restored from {args.file}")
529
+ except Exception as e:
530
+ if not args.x:
531
+ raise
532
+ logger.warning(f"Restore completed with errors (ignored): {e}")
401
533
  elif args.command == "baseline":
402
534
  sm = SchemaMigrations(config=config)
403
535
  if not sm.exists(connection=conn):
@@ -412,9 +544,21 @@ def cli() -> int: # noqa: PLR0912
412
544
  return exit_code
413
545
  SchemaMigrations(config=config).set_baseline(connection=conn, version=args.baseline)
414
546
 
415
- elif args.command == "upgrade":
416
- # TODO
417
- logger.error("Upgrade is not implemented yet")
547
+ elif args.command == "uninstall":
548
+ # Confirmation prompt unless --force is used
549
+ if not args.force:
550
+ logger.warning(
551
+ "⚠️ WARNING: This will execute uninstall hooks which may drop schemas and data!"
552
+ )
553
+ response = input("Are you sure you want to proceed? (yes/no): ").strip().lower()
554
+ if response not in ("yes", "y"):
555
+ logger.info("Uninstall cancelled.")
556
+ return 0
557
+
558
+ upg = Upgrader(config=config)
559
+ upg.uninstall(connection=conn, parameters=parameters)
560
+ logger.info("Uninstall completed successfully.")
561
+
418
562
  else:
419
563
  logger.error(f"Unknown command: {args.command}")
420
564
  logger.error("Use -h or --help for help.")