django-dbbackup 5.3.1__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.3.1 → django_dbbackup-5.4.1}/PKG-INFO +2 -1
  2. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/__init__.py +1 -1
  3. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/base.py +40 -4
  4. django_dbbackup-5.4.1/dbbackup/db/postgresql.py +561 -0
  5. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/log.py +2 -1
  6. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/dbrestore.py +8 -8
  7. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/settings.py +1 -1
  8. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/pyproject.toml +9 -0
  9. django_dbbackup-5.3.1/dbbackup/db/postgresql.py +0 -207
  10. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/.gitignore +0 -0
  11. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/LICENSE.md +0 -0
  12. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/README.md +0 -0
  13. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/apps.py +0 -0
  14. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/checks.py +0 -0
  15. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/__init__.py +0 -0
  16. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/django.py +0 -0
  17. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/exceptions.py +0 -0
  18. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/mongodb.py +0 -0
  19. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/mysql.py +0 -0
  20. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/db/sqlite.py +0 -0
  21. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/__init__.py +0 -0
  22. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/__init__.py +0 -0
  23. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/_base.py +0 -0
  24. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/dbbackup.py +0 -0
  25. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/listbackups.py +0 -0
  26. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediabackup.py +0 -0
  27. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediarestore.py +0 -0
  28. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/signals.py +0 -0
  29. {django_dbbackup-5.3.1 → django_dbbackup-5.4.1}/dbbackup/storage.py +0 -0
  30. {django_dbbackup-5.3.1 → 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.3.1
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.3.1"
3
+ __version__ = "5.4.1"
@@ -40,9 +40,16 @@ if settings.CUSTOM_CONNECTOR_MAPPING:
40
40
  CONNECTOR_MAPPING.update(settings.CUSTOM_CONNECTOR_MAPPING)
41
41
 
42
42
 
43
- def get_connector(database_name=None):
43
+ def get_connector(database_name: str | None = None, *, connector_path: str | None = None):
44
44
  """
45
45
  Get a connector from its database key in settings.
46
+
47
+ :param database_name: Database alias from ``DATABASES``, defaults to Django's default alias.
48
+ :param connector_path: Dotted path to the connector class to instantiate, for when the
49
+ connector is already known (e.g. from a backup's metadata file). When omitted it is
50
+ resolved from ``DBBACKUP_CONNECTORS[database_name]["CONNECTOR"]``, falling back to the
51
+ engine mapping. Either way the connector is always instantiated with the
52
+ ``DBBACKUP_CONNECTORS[database_name]`` options applied.
46
53
  """
47
54
  from django.db import DEFAULT_DB_ALIAS, connections
48
55
 
@@ -53,7 +60,8 @@ def get_connector(database_name=None):
53
60
  connector_settings = settings.CONNECTORS.get(database_name, {})
54
61
 
55
62
  # Use Django connector as fallback for unmapped engines
56
- connector_path = connector_settings.get("CONNECTOR", CONNECTOR_MAPPING.get(engine, DEFAULT_CONNECTOR))
63
+ if not connector_path:
64
+ connector_path = connector_settings.get("CONNECTOR", CONNECTOR_MAPPING.get(engine, DEFAULT_CONNECTOR))
57
65
 
58
66
  connector_module_path = ".".join(connector_path.split(".")[:-1])
59
67
  module = import_module(connector_module_path)
@@ -71,6 +79,12 @@ class BaseDBConnector:
71
79
  extension = "dump"
72
80
  exclude: ClassVar[list[Any]] = []
73
81
 
82
+ #: Attributes which either hold a mutable class default or are bound to the very
83
+ #: list/dict objects the user put in ``DBBACKUP_CONNECTORS``. They are copied per
84
+ #: instance so mutating one connector can neither leak into another connector nor
85
+ #: into the user's settings.
86
+ MUTABLE_ATTRIBUTES: ClassVar[tuple[str, ...]] = ("exclude", "schemas", "env", "dump_env", "restore_env")
87
+
74
88
  def __init__(self, database_name=None, **kwargs):
75
89
  from django.db import DEFAULT_DB_ALIAS, connections
76
90
 
@@ -78,6 +92,12 @@ class BaseDBConnector:
78
92
  self.connection = connections[self.database_name]
79
93
  for attr, value in kwargs.items():
80
94
  setattr(self, attr.lower(), value)
95
+ for attr in self.MUTABLE_ATTRIBUTES:
96
+ value = getattr(self, attr, None)
97
+ if isinstance(value, list):
98
+ setattr(self, attr, list(value))
99
+ elif isinstance(value, dict):
100
+ setattr(self, attr, dict(value))
81
101
 
82
102
  @property
83
103
  def settings(self):
@@ -118,6 +138,22 @@ class BaseDBConnector:
118
138
  raise NotImplementedError(msg)
119
139
 
120
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
+
121
157
  class BaseCommandDBConnector(BaseDBConnector):
122
158
  """
123
159
  Base class for create database connector based on command line tools.
@@ -148,7 +184,7 @@ class BaseCommandDBConnector(BaseDBConnector):
148
184
  """
149
185
  logger.debug(command)
150
186
  original_command = command
151
- cmd = shlex.split(command)
187
+ cmd = _split_command(command)
152
188
  stdout = SpooledTemporaryFile(max_size=settings.TMP_FILE_MAX_SIZE, dir=settings.TMP_DIR)
153
189
  stderr = SpooledTemporaryFile(max_size=settings.TMP_FILE_MAX_SIZE, dir=settings.TMP_DIR)
154
190
  full_env = os.environ.copy() if self.use_parent_env else {}
@@ -189,7 +225,7 @@ class BaseCommandDBConnector(BaseDBConnector):
189
225
  except OSError as err:
190
226
  # Check if this is a "command not found" error (errno 2)
191
227
  if err.errno == 2: # No such file or directory
192
- cmd_name = shlex.split(command)[0] if command else "command"
228
+ cmd_name = _split_command(command)[0] if command else "command"
193
229
  error_msg = (
194
230
  f"Database command '{cmd_name}' not found. "
195
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):
@@ -8,7 +8,6 @@ import io
8
8
  import json
9
9
  import os
10
10
  import sys
11
- from importlib import import_module
12
11
 
13
12
  from django.conf import settings
14
13
  from django.core.management.base import CommandError
@@ -227,11 +226,7 @@ class Command(BaseDbBackupCommand):
227
226
  if metadata and "connector" in metadata:
228
227
  connector_path = metadata["connector"]
229
228
  try:
230
- module_name = ".".join(connector_path.split(".")[:-1])
231
- class_name = connector_path.split(".")[-1]
232
- module = import_module(module_name)
233
- connector_class = getattr(module, class_name)
234
- self.connector = connector_class(self.database_name)
229
+ self.connector = get_connector(self.database_name, connector_path=connector_path)
235
230
  self.logger.info("Using connector from metadata: '%s'", connector_path)
236
231
  except (ImportError, AttributeError):
237
232
  self.logger.warning(
@@ -250,8 +245,13 @@ class Command(BaseDbBackupCommand):
250
245
 
251
246
  if self.schemas:
252
247
  self.connector.schemas = self.schemas
253
- self.connector.drop = not self.no_drop
254
- self.connector.pg_options = self.pg_options
248
+ # Only override connector attributes when the flag was actually passed, otherwise
249
+ # options configured through DBBACKUP_CONNECTORS (e.g. DROP or PG_OPTIONS) are
250
+ # silently discarded in favour of the option defaults.
251
+ if self.no_drop:
252
+ self.connector.drop = False
253
+ if self.pg_options:
254
+ self.connector.pg_options = self.pg_options
255
255
  self.connector.restore_dump(input_file)
256
256
 
257
257
  # Send post_restore signal
@@ -29,7 +29,7 @@ FILENAME_TEMPLATE = getattr(
29
29
  )
30
30
  MEDIA_FILENAME_TEMPLATE = getattr(settings, "DBBACKUP_MEDIA_FILENAME_TEMPLATE", "{servername}-{datetime}.{extension}")
31
31
  GPG_ALWAYS_TRUST = getattr(settings, "DBBACKUP_GPG_ALWAYS_TRUST", False)
32
- GPG_RECIPIENT = GPG_ALWAYS_TRUST = getattr(settings, "DBBACKUP_GPG_RECIPIENT", None)
32
+ GPG_RECIPIENT = getattr(settings, "DBBACKUP_GPG_RECIPIENT", None)
33
33
  STORAGES_DBBACKUP_ALIAS = "dbbackup"
34
34
  DJANGO_STORAGES = getattr(settings, "STORAGES", {})
35
35
  storage: dict = DJANGO_STORAGES.get(STORAGES_DBBACKUP_ALIAS, {})
@@ -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,207 +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
-
13
- def _host_requires_uri_quoting(host: str) -> bool:
14
- # Match *nix paths (e.g. /run/postgres) and Windows paths (e.g. C:\path\to\socket)
15
- return host.startswith(("/", "@")) or (
16
- len(host) >= 3 and host[0].isalpha() and host[1] == ":" and host[2] in ("/", "\\")
17
- )
18
-
19
-
20
- def parse_postgres_settings(connector: PgDumpBinaryConnector | PgDumpConnector) -> tuple[str, dict[Any, Any]]:
21
- """
22
- Parse the common Postgres connectors settings and
23
- generate a portion of the command string and any
24
- relevant environment variables.
25
-
26
- Args:
27
- connector: Database connector instance with settings
28
-
29
- Returns:
30
- tuple: (cmd_part, environment_dict)
31
- """
32
- host = connector.settings.get("HOST")
33
- if host is None:
34
- host = "localhost"
35
- elif _host_requires_uri_quoting(host):
36
- host = quote(host, safe="")
37
- cmd_part = connector.settings.get("NAME", "")
38
- user = quote(connector.settings.get("USER") or "")
39
- password = connector.settings.get("PASSWORD", -1)
40
- if user:
41
- host = f"@{host}"
42
- port = f":{connector.settings.get('PORT')}" if connector.settings.get("PORT") else ""
43
- cmd_part = f"--dbname=postgresql://{user}{host}{port}/{cmd_part}"
44
- env = {}
45
- if password is None:
46
- cmd_part += " --no-password"
47
- elif password not in ("", -1):
48
- env["PGPASSWORD"] = password
49
- return cmd_part, env
50
-
51
-
52
- class PgDumpConnector(BaseCommandDBConnector):
53
- """
54
- PostgreSQL connector, it uses `pg_dump` to create an SQL text file
55
- and `psql` for restore it.
56
- """
57
-
58
- extension = "psql"
59
- dump_cmd = "pg_dump"
60
- restore_cmd = "psql"
61
- single_transaction = True
62
- drop = True
63
- if_exists = True
64
- schemas: ClassVar[list[str] | None] = []
65
-
66
- def _create_dump(self):
67
- cmd_part, pg_env = parse_postgres_settings(self)
68
- cmd = f"{self.dump_cmd} {cmd_part}"
69
-
70
- for table in self.exclude:
71
- cmd += f" --exclude-table-data={table}"
72
-
73
- if self.drop:
74
- cmd += " --clean"
75
-
76
- if self.if_exists or self.drop:
77
- cmd += " --if-exists"
78
-
79
- if self.schemas:
80
- # First schema is not prefixed with -n
81
- # when using join function so add it manually.
82
- cmd += " -n " + " -n ".join(self.schemas)
83
-
84
- cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
85
- stdout, _stderr = self.run_command(cmd, env={**self.dump_env, **pg_env})
86
- return stdout
87
-
88
- def _restore_dump(self, dump):
89
- cmd_part, pg_env = parse_postgres_settings(self)
90
- cmd = f"{self.restore_cmd} {cmd_part}"
91
-
92
- # without this, psql terminates with an exit value of 0 regardless of errors
93
- cmd += " --set ON_ERROR_STOP=on"
94
-
95
- if self.schemas:
96
- cmd += " -n " + " -n ".join(self.schemas)
97
-
98
- if self.single_transaction:
99
- cmd += " --single-transaction"
100
-
101
- cmd += f" {self.settings['NAME']}"
102
- cmd = f"{self.restore_prefix} {cmd} {self.restore_suffix}"
103
- stdout, stderr = self.run_command(cmd, stdin=dump, env={**self.restore_env, **pg_env})
104
- return stdout, stderr
105
-
106
-
107
- class PgDumpGisConnector(PgDumpConnector):
108
- """
109
- PostgreGIS connector, same than :class:`PgDumpGisConnector` but enable
110
- postgis if not made.
111
- """
112
-
113
- psql_cmd = "psql"
114
-
115
- def _enable_postgis(self):
116
- cmd = f'{self.psql_cmd} -c "CREATE EXTENSION IF NOT EXISTS postgis;"'
117
- cmd += f" --username={shlex.quote(self.settings['ADMIN_USER'])}"
118
- cmd += " --no-password"
119
-
120
- if self.settings.get("HOST"):
121
- cmd += f" --host={shlex.quote(self.settings['HOST'])}"
122
-
123
- if self.settings.get("PORT"):
124
- cmd += f" --port={shlex.quote(str(self.settings['PORT']))}"
125
-
126
- return self.run_command(cmd)
127
-
128
- def _restore_dump(self, dump):
129
- if self.settings.get("ADMIN_USER"):
130
- self._enable_postgis()
131
- return super()._restore_dump(dump)
132
-
133
-
134
- class PgDumpBinaryConnector(PgDumpConnector):
135
- """
136
- PostgreSQL connector, it uses `pg_dump` to create an SQL text file
137
- and `pg_restore` for restore it.
138
- """
139
-
140
- extension = "psql.bin"
141
- dump_cmd = "pg_dump"
142
- restore_cmd = "pg_restore"
143
- single_transaction = True
144
- drop = True
145
- if_exists = True
146
- pg_options = None
147
-
148
- def _create_dump(self):
149
- cmd_part, pg_env = parse_postgres_settings(self)
150
- cmd = f"{self.dump_cmd} {cmd_part}"
151
-
152
- cmd += " --format=custom"
153
- for table in self.exclude:
154
- cmd += f" --exclude-table-data={table}"
155
-
156
- if self.schemas:
157
- cmd += " -n " + " -n ".join(self.schemas)
158
-
159
- cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
160
- stdout, _ = self.run_command(cmd, env={**self.dump_env, **pg_env})
161
- return stdout
162
-
163
- def _restore_dump(self, dump: str):
164
- """
165
- Restore a PostgreSQL dump using subprocess with argument list.
166
-
167
- Assumes that restore_prefix, restore_cmd, pg_options, and restore_suffix
168
- are either None, strings (single args), or lists of strings.
169
-
170
- Builds the command as a list.
171
- """
172
-
173
- cmd_part, pg_env = parse_postgres_settings(self)
174
- cmd = []
175
-
176
- # Flatten optional values
177
- if self.restore_prefix:
178
- cmd.extend(self.restore_prefix if isinstance(self.restore_prefix, list) else [self.restore_prefix])
179
-
180
- if self.restore_cmd:
181
- cmd.extend(self.restore_cmd if isinstance(self.restore_cmd, list) else [self.restore_cmd])
182
-
183
- if self.pg_options:
184
- cmd.extend(self.pg_options if isinstance(self.pg_options, list) else [self.pg_options])
185
-
186
- cmd.extend([cmd_part])
187
-
188
- if self.single_transaction:
189
- cmd.extend(["--single-transaction"])
190
-
191
- if self.drop:
192
- cmd.extend(["--clean"])
193
-
194
- if self.schemas:
195
- for schema in self.schemas:
196
- cmd.extend(["-n", schema])
197
-
198
- if self.if_exists or self.drop:
199
- cmd.extend(["--if-exists"])
200
-
201
- if self.restore_suffix:
202
- cmd.extend(self.restore_suffix if isinstance(self.restore_suffix, list) else [self.restore_suffix])
203
-
204
- cmd_str = " ".join(cmd)
205
- stdout, _ = self.run_command(cmd_str, stdin=dump, env={**self.dump_env, **pg_env})
206
-
207
- return stdout