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/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
+ )