django-dbbackup 5.4.0__tar.gz → 5.4.1__tar.gz

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.
Files changed (30) hide show
  1. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/PKG-INFO +2 -1
  2. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/__init__.py +1 -1
  3. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/base.py +18 -2
  4. django_dbbackup-5.4.1/dbbackup/db/postgresql.py +561 -0
  5. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/log.py +2 -1
  6. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/pyproject.toml +9 -0
  7. django_dbbackup-5.4.0/dbbackup/db/postgresql.py +0 -251
  8. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/.gitignore +0 -0
  9. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/LICENSE.md +0 -0
  10. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/README.md +0 -0
  11. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/apps.py +0 -0
  12. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/checks.py +0 -0
  13. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/__init__.py +0 -0
  14. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/django.py +0 -0
  15. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/exceptions.py +0 -0
  16. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/mongodb.py +0 -0
  17. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/mysql.py +0 -0
  18. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/sqlite.py +0 -0
  19. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/__init__.py +0 -0
  20. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/__init__.py +0 -0
  21. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/_base.py +0 -0
  22. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/dbbackup.py +0 -0
  23. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/dbrestore.py +0 -0
  24. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/listbackups.py +0 -0
  25. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediabackup.py +0 -0
  26. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediarestore.py +0 -0
  27. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/settings.py +0 -0
  28. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/signals.py +0 -0
  29. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/storage.py +0 -0
  30. {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-dbbackup
3
- Version: 5.4.0
3
+ Version: 5.4.1
4
4
  Summary: Management commands to help backup and restore a project database and media.
5
5
  Project-URL: Changelog, https://archmonger.github.io/django-dbbackup/latest/changelog/
6
6
  Project-URL: Documentation, https://archmonger.github.io/django-dbbackup
@@ -16,6 +16,7 @@ Classifier: Framework :: Django :: 5.0
16
16
  Classifier: Framework :: Django :: 5.1
17
17
  Classifier: Framework :: Django :: 5.2
18
18
  Classifier: Framework :: Django :: 6.0
19
+ Classifier: Framework :: Django :: 6.1
19
20
  Classifier: Intended Audience :: Developers
20
21
  Classifier: Intended Audience :: System Administrators
21
22
  Classifier: License :: OSI Approved :: BSD License
@@ -1,3 +1,3 @@
1
1
  """Management commands to help backup and restore a project database and media"""
2
2
 
3
- __version__ = "5.4.0"
3
+ __version__ = "5.4.1"
@@ -138,6 +138,22 @@ class BaseDBConnector:
138
138
  raise NotImplementedError(msg)
139
139
 
140
140
 
141
+ def _split_command(command):
142
+ r"""
143
+ Split a command line into arguments like :func:`shlex.split`.
144
+
145
+ On Windows a backslash is the path separator, not an escape character, so
146
+ it is kept. Otherwise a ``DUMP_CMD`` such as ``C:\pgsql\bin\pg_dump.exe``
147
+ would be run as ``C:pgsqlbinpg_dump.exe``.
148
+ """
149
+ lexer = shlex.shlex(command, posix=True)
150
+ lexer.whitespace_split = True
151
+ lexer.commenters = ""
152
+ if os.name == "nt":
153
+ lexer.escape = ""
154
+ return list(lexer)
155
+
156
+
141
157
  class BaseCommandDBConnector(BaseDBConnector):
142
158
  """
143
159
  Base class for create database connector based on command line tools.
@@ -168,7 +184,7 @@ class BaseCommandDBConnector(BaseDBConnector):
168
184
  """
169
185
  logger.debug(command)
170
186
  original_command = command
171
- cmd = shlex.split(command)
187
+ cmd = _split_command(command)
172
188
  stdout = SpooledTemporaryFile(max_size=settings.TMP_FILE_MAX_SIZE, dir=settings.TMP_DIR)
173
189
  stderr = SpooledTemporaryFile(max_size=settings.TMP_FILE_MAX_SIZE, dir=settings.TMP_DIR)
174
190
  full_env = os.environ.copy() if self.use_parent_env else {}
@@ -209,7 +225,7 @@ class BaseCommandDBConnector(BaseDBConnector):
209
225
  except OSError as err:
210
226
  # Check if this is a "command not found" error (errno 2)
211
227
  if err.errno == 2: # No such file or directory
212
- cmd_name = shlex.split(command)[0] if command else "command"
228
+ cmd_name = _split_command(command)[0] if command else "command"
213
229
  error_msg = (
214
230
  f"Database command '{cmd_name}' not found. "
215
231
  f"Please ensure the required database client tools are installed.\n\n"
@@ -0,0 +1,561 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import shlex
6
+ import tempfile
7
+ from contextlib import contextmanager
8
+ from typing import Any, ClassVar
9
+ from urllib.parse import quote
10
+
11
+ from django.db import Error as DjangoDBError
12
+
13
+ from dbbackup import settings
14
+ from dbbackup.db.base import BaseCommandDBConnector, _split_command
15
+ from dbbackup.db.exceptions import CommandConnectorError
16
+
17
+ logger = logging.getLogger("dbbackup.command")
18
+
19
+ # ``pg_dump`` emits a DROP DEFAULT statement in the clean section of a dump for
20
+ # every serial-backed column. When the target table now uses an identity column,
21
+ # PostgreSQL rejects that statement, so the identity property has to be removed
22
+ # first. The replacement is a no-op on columns which are not identity columns,
23
+ # which keeps serial-to-serial restores working.
24
+ _DROP_DEFAULT_SUFFIX = b" DROP DEFAULT;"
25
+ _DROP_IDENTITY_SUFFIX = b" DROP IDENTITY IF EXISTS;"
26
+
27
+
28
+ def _identity_prelude_from_sql(sql: bytes) -> bytes:
29
+ """Build DROP IDENTITY statements for the DROP DEFAULT statements in *sql*."""
30
+ prelude = []
31
+ for line in sql.splitlines(keepends=True):
32
+ stripped = line.strip()
33
+ if (
34
+ stripped.startswith(b"ALTER TABLE ")
35
+ and b" ALTER COLUMN " in stripped
36
+ and stripped.endswith(_DROP_DEFAULT_SUFFIX)
37
+ ):
38
+ prelude.append(
39
+ stripped[: -len(_DROP_DEFAULT_SUFFIX)] + _DROP_IDENTITY_SUFFIX + b"\n"
40
+ )
41
+ return b"".join(prelude)
42
+
43
+
44
+ def _identity_prelude_from_text_dump(dump) -> bytes:
45
+ """Build DROP IDENTITY statements from the clean section of a text dump."""
46
+ prelude = []
47
+ for line in dump:
48
+ stripped = line.strip()
49
+ # The clean section precedes the objects it drops. Once the first object
50
+ # is created (or data is copied) there is nothing left to fix.
51
+ if stripped.startswith((b"CREATE ", b"COPY ", b"INSERT INTO ")):
52
+ break
53
+ if (
54
+ stripped.startswith(b"ALTER TABLE ")
55
+ and b" ALTER COLUMN " in stripped
56
+ and stripped.endswith(_DROP_DEFAULT_SUFFIX)
57
+ ):
58
+ prelude.append(
59
+ stripped[: -len(_DROP_DEFAULT_SUFFIX)] + _DROP_IDENTITY_SUFFIX + b"\n"
60
+ )
61
+ return b"".join(prelude)
62
+
63
+
64
+ @contextmanager
65
+ def _identity_prelude_file(prelude: bytes):
66
+ """Write *prelude* to a temporary SQL file and yield its path."""
67
+ fd, path = tempfile.mkstemp(
68
+ prefix="dbbackup_identity_", suffix=".sql", dir=settings.TMP_DIR
69
+ )
70
+ try:
71
+ with os.fdopen(fd, "wb") as prelude_file:
72
+ prelude_file.write(prelude)
73
+ yield path
74
+ finally:
75
+ try:
76
+ os.unlink(path)
77
+ except OSError:
78
+ logger.debug(
79
+ "Could not remove the identity prelude file %s", path, exc_info=True
80
+ )
81
+
82
+ # Connection options understood by the PostgreSQL command line tools, mapped to
83
+ # the ``libpq`` environment variable carrying each one. It mirrors
84
+ # ``settings_to_cmd_args_env`` in ``django/db/backends/postgresql/client.py``,
85
+ # so that ``pg_dump``, ``psql`` and ``pg_restore`` connect with the same TLS,
86
+ # client certificate, password file and service settings as the ORM does.
87
+ PG_OPTIONS_ENV_VARS: dict[str, str] = {
88
+ "sslmode": "PGSSLMODE",
89
+ "sslrootcert": "PGSSLROOTCERT",
90
+ "sslcert": "PGSSLCERT",
91
+ "sslkey": "PGSSLKEY",
92
+ "passfile": "PGPASSFILE",
93
+ "service": "PGSERVICE",
94
+ }
95
+
96
+
97
+ def _host_requires_uri_quoting(host: str) -> bool:
98
+ # Match *nix paths (e.g. /run/postgres) and Windows paths (e.g. C:\path\to\socket)
99
+ return host.startswith(("/", "@")) or (
100
+ len(host) >= 3 and host[0].isalpha() and host[1] == ":" and host[2] in ("/", "\\")
101
+ )
102
+
103
+
104
+ def parse_postgres_options_env(connector: PgDumpBinaryConnector | PgDumpConnector) -> dict[str, str]:
105
+ """
106
+ Translate the ``OPTIONS`` setting of a Postgres connection into the ``libpq``
107
+ environment variables understood by the command line tools.
108
+
109
+ The options are passed through the environment rather than appended to the
110
+ connection URI because ``libpq`` expands ``%XX`` triplets only: a path
111
+ encoded with ``quote_plus`` semantics would reach the tools with its spaces
112
+ turned into ``+`` and would not be found.
113
+
114
+ Args:
115
+ connector: Database connector instance with settings
116
+
117
+ Returns:
118
+ dict: Environment variables to apply to the command
119
+ """
120
+ options = connector.settings.get("OPTIONS") or {}
121
+ return {env_var: str(options[option]) for option, env_var in PG_OPTIONS_ENV_VARS.items() if options.get(option)}
122
+
123
+
124
+ def parse_postgres_settings(connector: PgDumpBinaryConnector | PgDumpConnector) -> tuple[str, dict[Any, Any]]:
125
+ """
126
+ Parse the common Postgres connectors settings and
127
+ generate a portion of the command string and any
128
+ relevant environment variables.
129
+
130
+ Args:
131
+ connector: Database connector instance with settings
132
+
133
+ Returns:
134
+ tuple: (cmd_part, environment_dict)
135
+ """
136
+ host = connector.settings.get("HOST")
137
+ if host is None:
138
+ host = "localhost"
139
+ elif _host_requires_uri_quoting(host):
140
+ host = quote(host, safe="")
141
+ cmd_part = connector.settings.get("NAME", "")
142
+ user = quote(connector.settings.get("USER") or "")
143
+ password = connector.settings.get("PASSWORD", -1)
144
+ if user:
145
+ host = f"@{host}"
146
+ port = f":{connector.settings.get('PORT')}" if connector.settings.get("PORT") else ""
147
+ cmd_part = f"--dbname=postgresql://{user}{host}{port}/{cmd_part}"
148
+ env = parse_postgres_options_env(connector)
149
+ if password is None:
150
+ cmd_part += " --no-password"
151
+ elif password not in ("", -1):
152
+ env["PGPASSWORD"] = password
153
+ return cmd_part, env
154
+
155
+
156
+ class PgDumpConnector(BaseCommandDBConnector):
157
+ """
158
+ PostgreSQL connector, it uses `pg_dump` to create an SQL text file
159
+ and `psql` for restore it.
160
+ """
161
+
162
+ extension = "psql"
163
+ dump_cmd = "pg_dump"
164
+ restore_cmd = "psql"
165
+ single_transaction = True
166
+ drop = True
167
+ if_exists = True
168
+ schemas: ClassVar[list[str] | None] = []
169
+
170
+ def _create_dump(self):
171
+ cmd_part, pg_env = parse_postgres_settings(self)
172
+ cmd = f"{self.dump_cmd} {cmd_part}"
173
+
174
+ for table in self.exclude:
175
+ cmd += f" --exclude-table-data={table}"
176
+
177
+ if self.drop:
178
+ cmd += " --clean"
179
+
180
+ if self.if_exists or self.drop:
181
+ cmd += " --if-exists"
182
+
183
+ if self.schemas:
184
+ # First schema is not prefixed with -n
185
+ # when using join function so add it manually.
186
+ cmd += " -n " + " -n ".join(self.schemas)
187
+
188
+ cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
189
+ stdout, _stderr = self.run_command(cmd, env={**self.dump_env, **pg_env})
190
+ return stdout
191
+
192
+ def _target_has_identity_columns(self) -> bool:
193
+ """
194
+ Return whether the target database contains identity columns.
195
+
196
+ A dump created from serial columns only fails when it is restored over a
197
+ table which now uses an identity column, so the extra restore work is
198
+ skipped for databases without identity columns.
199
+ """
200
+ if self.connection.vendor != "postgresql":
201
+ return False
202
+
203
+ try:
204
+ with self.connection.cursor() as cursor:
205
+ cursor.execute(
206
+ "SELECT EXISTS ("
207
+ "SELECT 1 FROM pg_catalog.pg_attribute "
208
+ "WHERE attidentity IN ('a', 'd') AND NOT attisdropped"
209
+ ")"
210
+ )
211
+ row = cursor.fetchone()
212
+ return bool(row and row[0])
213
+ except DjangoDBError:
214
+ logger.warning(
215
+ "Could not check the target database for identity columns; "
216
+ "using the standard restore command.",
217
+ exc_info=True,
218
+ )
219
+ return False
220
+
221
+ def _restore_dump(self, dump):
222
+ cmd_part, pg_env = parse_postgres_settings(self)
223
+ cmd = f"{self.restore_cmd} {cmd_part}"
224
+
225
+ # without this, psql terminates with an exit value of 0 regardless of errors
226
+ cmd += " --set ON_ERROR_STOP=on"
227
+
228
+ if self.schemas:
229
+ cmd += " -n " + " -n ".join(self.schemas)
230
+
231
+ if self.single_transaction:
232
+ cmd += " --single-transaction"
233
+
234
+ prelude = b""
235
+ if self._target_has_identity_columns():
236
+ # Text dumps carry their clean statements inside the file, so they
237
+ # must be handled even when the restore is told not to drop objects.
238
+ prelude = _identity_prelude_from_text_dump(dump)
239
+ dump.seek(0)
240
+
241
+ if not prelude:
242
+ cmd += f" {self.settings['NAME']}"
243
+ cmd = f"{self.restore_prefix} {cmd} {self.restore_suffix}"
244
+ stdout, stderr = self.run_command(
245
+ cmd, stdin=dump, env={**self.restore_env, **pg_env}
246
+ )
247
+ return stdout, stderr
248
+
249
+ # Run the generated statements and the dump in a single psql session, so
250
+ # the identity properties are dropped inside the same transaction as the
251
+ # restore and rolled back if the restore fails.
252
+ with _identity_prelude_file(prelude) as prelude_path:
253
+ cmd += f" -f {shlex.quote(prelude_path)} -f - {self.settings['NAME']}"
254
+ cmd = f"{self.restore_prefix} {cmd} {self.restore_suffix}"
255
+ stdout, stderr = self.run_command(
256
+ cmd, stdin=dump, env={**self.restore_env, **pg_env}
257
+ )
258
+ return stdout, stderr
259
+
260
+
261
+ class PgDumpGisConnector(PgDumpConnector):
262
+ """
263
+ PostgreGIS connector, same than :class:`PgDumpGisConnector` but enable
264
+ postgis if not made.
265
+ """
266
+
267
+ psql_cmd = "psql"
268
+
269
+ def _enable_postgis(self):
270
+ cmd = f'{self.psql_cmd} -c "CREATE EXTENSION IF NOT EXISTS postgis;"'
271
+ cmd += f" --username={shlex.quote(self.settings['ADMIN_USER'])}"
272
+ cmd += " --no-password"
273
+
274
+ if self.settings.get("HOST"):
275
+ cmd += f" --host={shlex.quote(self.settings['HOST'])}"
276
+
277
+ if self.settings.get("PORT"):
278
+ cmd += f" --port={shlex.quote(str(self.settings['PORT']))}"
279
+
280
+ # This is a separate psql invocation: it needs the same TLS settings as
281
+ # the restore it precedes, otherwise it cannot authenticate with a
282
+ # client certificate and the PostGIS activation fails.
283
+ env = {**self.restore_env, **parse_postgres_options_env(self)}
284
+ # "--no-password" only forbids the interactive prompt, libpq still
285
+ # reads PGPASSWORD, so the credentials of ADMIN_USER go there.
286
+ admin_password = self.settings.get("ADMIN_PASSWORD")
287
+ if admin_password:
288
+ env["PGPASSWORD"] = str(admin_password)
289
+
290
+ return self.run_command(cmd, env=env)
291
+
292
+ def _restore_dump(self, dump):
293
+ if self.settings.get("ADMIN_USER"):
294
+ self._enable_postgis()
295
+ return super()._restore_dump(dump)
296
+
297
+
298
+ class PgDumpBinaryConnector(PgDumpConnector):
299
+ """
300
+ PostgreSQL connector, it uses `pg_dump` to create an SQL text file
301
+ and `pg_restore` for restore it.
302
+ """
303
+
304
+ extension = "psql.bin"
305
+ dump_cmd = "pg_dump"
306
+ restore_cmd = "pg_restore"
307
+ psql_cmd = "psql"
308
+ single_transaction = True
309
+ drop = True
310
+ if_exists = True
311
+ pg_options = None
312
+
313
+ def _create_dump(self):
314
+ cmd_part, pg_env = parse_postgres_settings(self)
315
+ cmd = f"{self.dump_cmd} {cmd_part}"
316
+
317
+ cmd += " --format=custom"
318
+ for table in self.exclude:
319
+ cmd += f" --exclude-table-data={table}"
320
+
321
+ if self.schemas:
322
+ cmd += " -n " + " -n ".join(self.schemas)
323
+
324
+ cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
325
+ stdout, _ = self.run_command(cmd, env={**self.dump_env, **pg_env})
326
+ return stdout
327
+
328
+ def _pg_options_allow_sql_generation(self) -> bool:
329
+ """
330
+ Return whether ``pg_options`` can be combined with ``pg_restore -f``.
331
+
332
+ ``--jobs``, ``--dbname``, ``--file`` and ``--role`` are incompatible with
333
+ writing the archive to standard output, which the identity-column restore
334
+ requires. ``--role`` would be silently ignored by ``pg_restore -f``.
335
+ """
336
+ for option in self._pg_options_tokens():
337
+ if option in (
338
+ "-j",
339
+ "--jobs",
340
+ "-d",
341
+ "--dbname",
342
+ "-f",
343
+ "--file",
344
+ "--role",
345
+ ):
346
+ return False
347
+ if option.startswith(("--jobs=", "--dbname=", "--file=", "--role=")):
348
+ return False
349
+ if option.startswith("-j") and not option.startswith("--"):
350
+ return False
351
+ if option.startswith("-d") and not option.startswith("--"):
352
+ return False
353
+ if option.startswith("-f") and not option.startswith("--"):
354
+ return False
355
+
356
+ return True
357
+
358
+ def _pg_options_tokens(self) -> list[str]:
359
+ """Return ``pg_options`` as a list of command line arguments."""
360
+ options = self.pg_options or []
361
+ if isinstance(options, str):
362
+ return _split_command(options)
363
+ return list(options)
364
+
365
+ def _pg_options_select_pre_data(self) -> bool:
366
+ """
367
+ Return whether ``pg_options`` restores the pre-data section.
368
+
369
+ Only the pre-data section contains the clean statements which drop
370
+ serial defaults, so the identity-column workaround is unnecessary when
371
+ the caller restores data or post-data only.
372
+ """
373
+ options = self._pg_options_tokens()
374
+ if "-a" in options or "--data-only" in options:
375
+ return False
376
+
377
+ sections = []
378
+ for index, option in enumerate(options):
379
+ if option == "--section":
380
+ if index + 1 < len(options):
381
+ sections.append(options[index + 1])
382
+ elif option.startswith("--section="):
383
+ sections.append(option.split("=", 1)[1])
384
+
385
+ return not sections or "pre-data" in sections
386
+
387
+ def _pg_options_single_transaction(self) -> bool:
388
+ """Return whether ``pg_options`` asks for a single transaction."""
389
+ return any(
390
+ option in ("-1", "--single-transaction")
391
+ for option in self._pg_options_tokens()
392
+ )
393
+
394
+ def _build_pg_restore_sql_cmd(self, pre_data: bool = False) -> str:
395
+ """Build a ``pg_restore`` command which writes SQL to standard output."""
396
+ cmd = []
397
+ if self.restore_prefix:
398
+ cmd.extend(
399
+ self.restore_prefix
400
+ if isinstance(self.restore_prefix, list)
401
+ else [self.restore_prefix]
402
+ )
403
+ if self.restore_cmd:
404
+ cmd.extend(
405
+ self.restore_cmd
406
+ if isinstance(self.restore_cmd, list)
407
+ else [self.restore_cmd]
408
+ )
409
+ if self.pg_options:
410
+ # ``--single-transaction`` only wraps the generated SQL in
411
+ # BEGIN/COMMIT. The prelude has to run in the same transaction as
412
+ # the dump, so the psql invocation owns transaction handling.
413
+ options = [
414
+ option
415
+ for option in self._pg_options_tokens()
416
+ if option not in ("-1", "--single-transaction")
417
+ ]
418
+ if options:
419
+ cmd.append(" ".join(shlex.quote(option) for option in options))
420
+
421
+ cmd.extend(["-f", "-"])
422
+
423
+ if self.drop:
424
+ cmd.append("--clean")
425
+
426
+ if self.schemas:
427
+ for schema in self.schemas:
428
+ cmd.extend(["-n", shlex.quote(schema)])
429
+
430
+ if self.if_exists or self.drop:
431
+ cmd.append("--if-exists")
432
+
433
+ if pre_data:
434
+ cmd.append("--section=pre-data")
435
+
436
+ if self.restore_suffix:
437
+ cmd.extend(
438
+ self.restore_suffix
439
+ if isinstance(self.restore_suffix, list)
440
+ else [self.restore_suffix]
441
+ )
442
+
443
+ return " ".join(cmd)
444
+
445
+ def _identity_prelude_from_archive(self, dump, pg_env) -> bytes:
446
+ """Return the DROP IDENTITY statements needed by the archive in *dump*."""
447
+ cmd = self._build_pg_restore_sql_cmd(pre_data=True)
448
+ stdout, _ = self.run_command(cmd, stdin=dump, env={**self.restore_env, **pg_env})
449
+ dump.seek(0)
450
+ return _identity_prelude_from_sql(stdout.read())
451
+
452
+ def _restore_dump_with_identity_prelude(self, prelude, full_sql, cmd_part, pg_env):
453
+ """Restore the generated SQL together with the identity prelude."""
454
+ with _identity_prelude_file(prelude) as prelude_path:
455
+ cmd = []
456
+ if self.restore_prefix:
457
+ cmd.extend(
458
+ self.restore_prefix
459
+ if isinstance(self.restore_prefix, list)
460
+ else [self.restore_prefix]
461
+ )
462
+ cmd.append(self.psql_cmd)
463
+ cmd.append(cmd_part)
464
+ cmd.extend(["--set", "ON_ERROR_STOP=on"])
465
+ if self.single_transaction or self._pg_options_single_transaction():
466
+ cmd.append("--single-transaction")
467
+ else:
468
+ logger.warning(
469
+ "Restoring a serial-backed dump over identity columns without "
470
+ "a single transaction; a failed restore may leave the identity "
471
+ "property dropped."
472
+ )
473
+ cmd.extend(["-f", shlex.quote(prelude_path), "-f", "-"])
474
+
475
+ stdout, _ = self.run_command(
476
+ " ".join(cmd), stdin=full_sql, env={**self.restore_env, **pg_env}
477
+ )
478
+ return stdout
479
+
480
+ def _restore_dump(self, dump: str):
481
+ """
482
+ Restore a PostgreSQL dump using subprocess with argument list.
483
+
484
+ Assumes that restore_prefix, restore_cmd, pg_options, and restore_suffix
485
+ are either None, strings (single args), or lists of strings.
486
+
487
+ Builds the command as a list.
488
+ """
489
+
490
+ cmd_part, pg_env = parse_postgres_settings(self)
491
+
492
+ if (
493
+ self.drop
494
+ and self._target_has_identity_columns()
495
+ and self._pg_options_allow_sql_generation()
496
+ and self._pg_options_select_pre_data()
497
+ ):
498
+ try:
499
+ prelude = self._identity_prelude_from_archive(dump, pg_env)
500
+ except CommandConnectorError:
501
+ logger.warning(
502
+ "Could not generate SQL from the dump to prepare the "
503
+ "identity-column restore; using the standard restore command.",
504
+ exc_info=True,
505
+ )
506
+ dump.seek(0)
507
+ else:
508
+ if prelude:
509
+ try:
510
+ full_sql, _ = self.run_command(
511
+ self._build_pg_restore_sql_cmd(),
512
+ stdin=dump,
513
+ env={**self.restore_env, **pg_env},
514
+ )
515
+ except CommandConnectorError:
516
+ logger.warning(
517
+ "Could not generate the dump's SQL for the "
518
+ "identity-column restore; using the standard restore "
519
+ "command.",
520
+ exc_info=True,
521
+ )
522
+ dump.seek(0)
523
+ else:
524
+ return self._restore_dump_with_identity_prelude(
525
+ prelude, full_sql, cmd_part, pg_env
526
+ )
527
+
528
+ cmd = []
529
+
530
+ # Flatten optional values
531
+ if self.restore_prefix:
532
+ cmd.extend(self.restore_prefix if isinstance(self.restore_prefix, list) else [self.restore_prefix])
533
+
534
+ if self.restore_cmd:
535
+ cmd.extend(self.restore_cmd if isinstance(self.restore_cmd, list) else [self.restore_cmd])
536
+
537
+ if self.pg_options:
538
+ cmd.extend(self.pg_options if isinstance(self.pg_options, list) else [self.pg_options])
539
+
540
+ cmd.extend([cmd_part])
541
+
542
+ if self.single_transaction:
543
+ cmd.extend(["--single-transaction"])
544
+
545
+ if self.drop:
546
+ cmd.extend(["--clean"])
547
+
548
+ if self.schemas:
549
+ for schema in self.schemas:
550
+ cmd.extend(["-n", shlex.quote(schema)])
551
+
552
+ if self.if_exists or self.drop:
553
+ cmd.extend(["--if-exists"])
554
+
555
+ if self.restore_suffix:
556
+ cmd.extend(self.restore_suffix if isinstance(self.restore_suffix, list) else [self.restore_suffix])
557
+
558
+ cmd_str = " ".join(cmd)
559
+ stdout, _ = self.run_command(cmd_str, stdin=dump, env={**self.restore_env, **pg_env})
560
+
561
+ return stdout
@@ -7,7 +7,8 @@ class DbbackupAdminEmailHandler(AdminEmailHandler):
7
7
  def send_mail(self, subject, message, *args, **kwargs):
8
8
  from dbbackup import utils
9
9
 
10
- utils.mail_admins(subject, message, *args, connection=self.connection(), **kwargs)
10
+ kwargs["fail_silently"] = True
11
+ utils.mail_admins(subject, message, *args, **kwargs)
11
12
 
12
13
 
13
14
  class MailEnabledFilter(logging.Filter):
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Framework :: Django :: 5.1",
28
28
  "Framework :: Django :: 5.2",
29
29
  "Framework :: Django :: 6.0",
30
+ "Framework :: Django :: 6.1",
30
31
  "Intended Audience :: Developers",
31
32
  "Intended Audience :: System Administrators",
32
33
  "License :: OSI Approved :: BSD License",
@@ -100,6 +101,11 @@ django = ["5.2"]
100
101
  python = ["3.12", "3.13", "3.14"]
101
102
  django = ["6.0"]
102
103
 
104
+ # Django 6.1
105
+ [[tool.hatch.envs.hatch-test.matrix]]
106
+ python = ["3.12", "3.13", "3.14"]
107
+ django = ["6.1"]
108
+
103
109
  [tool.hatch.envs.hatch-test.overrides]
104
110
  matrix.django.dependencies = [
105
111
  { if = [
@@ -114,6 +120,9 @@ matrix.django.dependencies = [
114
120
  { if = [
115
121
  "6.0",
116
122
  ], value = "django>=6.0,<6.1" },
123
+ { if = [
124
+ "6.1",
125
+ ], value = "django>=6.1,<6.2" },
117
126
  ]
118
127
 
119
128
  # >>> Documentation Scripts <<<
@@ -1,251 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- import shlex
5
- from typing import Any, ClassVar
6
- from urllib.parse import quote
7
-
8
- from dbbackup.db.base import BaseCommandDBConnector
9
-
10
- logger = logging.getLogger("dbbackup.command")
11
-
12
- # Connection options understood by the PostgreSQL command line tools, mapped to
13
- # the ``libpq`` environment variable carrying each one. It mirrors
14
- # ``settings_to_cmd_args_env`` in ``django/db/backends/postgresql/client.py``,
15
- # so that ``pg_dump``, ``psql`` and ``pg_restore`` connect with the same TLS,
16
- # client certificate, password file and service settings as the ORM does.
17
- PG_OPTIONS_ENV_VARS: dict[str, str] = {
18
- "sslmode": "PGSSLMODE",
19
- "sslrootcert": "PGSSLROOTCERT",
20
- "sslcert": "PGSSLCERT",
21
- "sslkey": "PGSSLKEY",
22
- "passfile": "PGPASSFILE",
23
- "service": "PGSERVICE",
24
- }
25
-
26
-
27
- def _host_requires_uri_quoting(host: str) -> bool:
28
- # Match *nix paths (e.g. /run/postgres) and Windows paths (e.g. C:\path\to\socket)
29
- return host.startswith(("/", "@")) or (
30
- len(host) >= 3 and host[0].isalpha() and host[1] == ":" and host[2] in ("/", "\\")
31
- )
32
-
33
-
34
- def parse_postgres_options_env(connector: PgDumpBinaryConnector | PgDumpConnector) -> dict[str, str]:
35
- """
36
- Translate the ``OPTIONS`` setting of a Postgres connection into the ``libpq``
37
- environment variables understood by the command line tools.
38
-
39
- The options are passed through the environment rather than appended to the
40
- connection URI because ``libpq`` expands ``%XX`` triplets only: a path
41
- encoded with ``quote_plus`` semantics would reach the tools with its spaces
42
- turned into ``+`` and would not be found.
43
-
44
- Args:
45
- connector: Database connector instance with settings
46
-
47
- Returns:
48
- dict: Environment variables to apply to the command
49
- """
50
- options = connector.settings.get("OPTIONS") or {}
51
- return {env_var: str(options[option]) for option, env_var in PG_OPTIONS_ENV_VARS.items() if options.get(option)}
52
-
53
-
54
- def parse_postgres_settings(connector: PgDumpBinaryConnector | PgDumpConnector) -> tuple[str, dict[Any, Any]]:
55
- """
56
- Parse the common Postgres connectors settings and
57
- generate a portion of the command string and any
58
- relevant environment variables.
59
-
60
- Args:
61
- connector: Database connector instance with settings
62
-
63
- Returns:
64
- tuple: (cmd_part, environment_dict)
65
- """
66
- host = connector.settings.get("HOST")
67
- if host is None:
68
- host = "localhost"
69
- elif _host_requires_uri_quoting(host):
70
- host = quote(host, safe="")
71
- cmd_part = connector.settings.get("NAME", "")
72
- user = quote(connector.settings.get("USER") or "")
73
- password = connector.settings.get("PASSWORD", -1)
74
- if user:
75
- host = f"@{host}"
76
- port = f":{connector.settings.get('PORT')}" if connector.settings.get("PORT") else ""
77
- cmd_part = f"--dbname=postgresql://{user}{host}{port}/{cmd_part}"
78
- env = parse_postgres_options_env(connector)
79
- if password is None:
80
- cmd_part += " --no-password"
81
- elif password not in ("", -1):
82
- env["PGPASSWORD"] = password
83
- return cmd_part, env
84
-
85
-
86
- class PgDumpConnector(BaseCommandDBConnector):
87
- """
88
- PostgreSQL connector, it uses `pg_dump` to create an SQL text file
89
- and `psql` for restore it.
90
- """
91
-
92
- extension = "psql"
93
- dump_cmd = "pg_dump"
94
- restore_cmd = "psql"
95
- single_transaction = True
96
- drop = True
97
- if_exists = True
98
- schemas: ClassVar[list[str] | None] = []
99
-
100
- def _create_dump(self):
101
- cmd_part, pg_env = parse_postgres_settings(self)
102
- cmd = f"{self.dump_cmd} {cmd_part}"
103
-
104
- for table in self.exclude:
105
- cmd += f" --exclude-table-data={table}"
106
-
107
- if self.drop:
108
- cmd += " --clean"
109
-
110
- if self.if_exists or self.drop:
111
- cmd += " --if-exists"
112
-
113
- if self.schemas:
114
- # First schema is not prefixed with -n
115
- # when using join function so add it manually.
116
- cmd += " -n " + " -n ".join(self.schemas)
117
-
118
- cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
119
- stdout, _stderr = self.run_command(cmd, env={**self.dump_env, **pg_env})
120
- return stdout
121
-
122
- def _restore_dump(self, dump):
123
- cmd_part, pg_env = parse_postgres_settings(self)
124
- cmd = f"{self.restore_cmd} {cmd_part}"
125
-
126
- # without this, psql terminates with an exit value of 0 regardless of errors
127
- cmd += " --set ON_ERROR_STOP=on"
128
-
129
- if self.schemas:
130
- cmd += " -n " + " -n ".join(self.schemas)
131
-
132
- if self.single_transaction:
133
- cmd += " --single-transaction"
134
-
135
- cmd += f" {self.settings['NAME']}"
136
- cmd = f"{self.restore_prefix} {cmd} {self.restore_suffix}"
137
- stdout, stderr = self.run_command(cmd, stdin=dump, env={**self.restore_env, **pg_env})
138
- return stdout, stderr
139
-
140
-
141
- class PgDumpGisConnector(PgDumpConnector):
142
- """
143
- PostgreGIS connector, same than :class:`PgDumpGisConnector` but enable
144
- postgis if not made.
145
- """
146
-
147
- psql_cmd = "psql"
148
-
149
- def _enable_postgis(self):
150
- cmd = f'{self.psql_cmd} -c "CREATE EXTENSION IF NOT EXISTS postgis;"'
151
- cmd += f" --username={shlex.quote(self.settings['ADMIN_USER'])}"
152
- cmd += " --no-password"
153
-
154
- if self.settings.get("HOST"):
155
- cmd += f" --host={shlex.quote(self.settings['HOST'])}"
156
-
157
- if self.settings.get("PORT"):
158
- cmd += f" --port={shlex.quote(str(self.settings['PORT']))}"
159
-
160
- # This is a separate psql invocation: it needs the same TLS settings as
161
- # the restore it precedes, otherwise it cannot authenticate with a
162
- # client certificate and the PostGIS activation fails.
163
- env = {**self.restore_env, **parse_postgres_options_env(self)}
164
- # "--no-password" only forbids the interactive prompt, libpq still
165
- # reads PGPASSWORD, so the credentials of ADMIN_USER go there.
166
- admin_password = self.settings.get("ADMIN_PASSWORD")
167
- if admin_password:
168
- env["PGPASSWORD"] = str(admin_password)
169
-
170
- return self.run_command(cmd, env=env)
171
-
172
- def _restore_dump(self, dump):
173
- if self.settings.get("ADMIN_USER"):
174
- self._enable_postgis()
175
- return super()._restore_dump(dump)
176
-
177
-
178
- class PgDumpBinaryConnector(PgDumpConnector):
179
- """
180
- PostgreSQL connector, it uses `pg_dump` to create an SQL text file
181
- and `pg_restore` for restore it.
182
- """
183
-
184
- extension = "psql.bin"
185
- dump_cmd = "pg_dump"
186
- restore_cmd = "pg_restore"
187
- single_transaction = True
188
- drop = True
189
- if_exists = True
190
- pg_options = None
191
-
192
- def _create_dump(self):
193
- cmd_part, pg_env = parse_postgres_settings(self)
194
- cmd = f"{self.dump_cmd} {cmd_part}"
195
-
196
- cmd += " --format=custom"
197
- for table in self.exclude:
198
- cmd += f" --exclude-table-data={table}"
199
-
200
- if self.schemas:
201
- cmd += " -n " + " -n ".join(self.schemas)
202
-
203
- cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
204
- stdout, _ = self.run_command(cmd, env={**self.dump_env, **pg_env})
205
- return stdout
206
-
207
- def _restore_dump(self, dump: str):
208
- """
209
- Restore a PostgreSQL dump using subprocess with argument list.
210
-
211
- Assumes that restore_prefix, restore_cmd, pg_options, and restore_suffix
212
- are either None, strings (single args), or lists of strings.
213
-
214
- Builds the command as a list.
215
- """
216
-
217
- cmd_part, pg_env = parse_postgres_settings(self)
218
- cmd = []
219
-
220
- # Flatten optional values
221
- if self.restore_prefix:
222
- cmd.extend(self.restore_prefix if isinstance(self.restore_prefix, list) else [self.restore_prefix])
223
-
224
- if self.restore_cmd:
225
- cmd.extend(self.restore_cmd if isinstance(self.restore_cmd, list) else [self.restore_cmd])
226
-
227
- if self.pg_options:
228
- cmd.extend(self.pg_options if isinstance(self.pg_options, list) else [self.pg_options])
229
-
230
- cmd.extend([cmd_part])
231
-
232
- if self.single_transaction:
233
- cmd.extend(["--single-transaction"])
234
-
235
- if self.drop:
236
- cmd.extend(["--clean"])
237
-
238
- if self.schemas:
239
- for schema in self.schemas:
240
- cmd.extend(["-n", schema])
241
-
242
- if self.if_exists or self.drop:
243
- cmd.extend(["--if-exists"])
244
-
245
- if self.restore_suffix:
246
- cmd.extend(self.restore_suffix if isinstance(self.restore_suffix, list) else [self.restore_suffix])
247
-
248
- cmd_str = " ".join(cmd)
249
- stdout, _ = self.run_command(cmd_str, stdin=dump, env={**self.restore_env, **pg_env})
250
-
251
- return stdout