django-dbbackup 5.3.0__tar.gz → 5.4.0__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 (29) hide show
  1. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/PKG-INFO +2 -2
  2. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/__init__.py +1 -1
  3. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/base.py +22 -2
  4. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/postgresql.py +59 -4
  5. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/_base.py +13 -0
  6. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/dbrestore.py +8 -8
  7. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/settings.py +1 -1
  8. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/.gitignore +0 -0
  9. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/LICENSE.md +0 -0
  10. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/README.md +0 -0
  11. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/apps.py +0 -0
  12. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/checks.py +0 -0
  13. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/__init__.py +0 -0
  14. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/django.py +0 -0
  15. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/exceptions.py +0 -0
  16. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/mongodb.py +0 -0
  17. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/mysql.py +0 -0
  18. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/db/sqlite.py +0 -0
  19. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/log.py +0 -0
  20. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/__init__.py +0 -0
  21. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/__init__.py +0 -0
  22. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/dbbackup.py +0 -0
  23. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/listbackups.py +0 -0
  24. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/mediabackup.py +0 -0
  25. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/management/commands/mediarestore.py +0 -0
  26. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/signals.py +0 -0
  27. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/storage.py +0 -0
  28. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/dbbackup/utils.py +0 -0
  29. {django_dbbackup-5.3.0 → django_dbbackup-5.4.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: django-dbbackup
3
- Version: 5.3.0
3
+ Version: 5.4.0
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
@@ -1,3 +1,3 @@
1
1
  """Management commands to help backup and restore a project database and media"""
2
2
 
3
- __version__ = "5.3.0"
3
+ __version__ = "5.4.0"
@@ -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):
@@ -9,6 +9,47 @@ from dbbackup.db.base import BaseCommandDBConnector
9
9
 
10
10
  logger = logging.getLogger("dbbackup.command")
11
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
+
12
53
 
13
54
  def parse_postgres_settings(connector: PgDumpBinaryConnector | PgDumpConnector) -> tuple[str, dict[Any, Any]]:
14
55
  """
@@ -22,7 +63,11 @@ def parse_postgres_settings(connector: PgDumpBinaryConnector | PgDumpConnector)
22
63
  Returns:
23
64
  tuple: (cmd_part, environment_dict)
24
65
  """
25
- host = connector.settings.get("HOST", "localhost")
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="")
26
71
  cmd_part = connector.settings.get("NAME", "")
27
72
  user = quote(connector.settings.get("USER") or "")
28
73
  password = connector.settings.get("PASSWORD", -1)
@@ -30,7 +75,7 @@ def parse_postgres_settings(connector: PgDumpBinaryConnector | PgDumpConnector)
30
75
  host = f"@{host}"
31
76
  port = f":{connector.settings.get('PORT')}" if connector.settings.get("PORT") else ""
32
77
  cmd_part = f"--dbname=postgresql://{user}{host}{port}/{cmd_part}"
33
- env = {}
78
+ env = parse_postgres_options_env(connector)
34
79
  if password is None:
35
80
  cmd_part += " --no-password"
36
81
  elif password not in ("", -1):
@@ -112,7 +157,17 @@ class PgDumpGisConnector(PgDumpConnector):
112
157
  if self.settings.get("PORT"):
113
158
  cmd += f" --port={shlex.quote(str(self.settings['PORT']))}"
114
159
 
115
- return self.run_command(cmd)
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)
116
171
 
117
172
  def _restore_dump(self, dump):
118
173
  if self.settings.get("ADMIN_USER"):
@@ -191,6 +246,6 @@ class PgDumpBinaryConnector(PgDumpConnector):
191
246
  cmd.extend(self.restore_suffix if isinstance(self.restore_suffix, list) else [self.restore_suffix])
192
247
 
193
248
  cmd_str = " ".join(cmd)
194
- stdout, _ = self.run_command(cmd_str, stdin=dump, env={**self.dump_env, **pg_env})
249
+ stdout, _ = self.run_command(cmd_str, stdin=dump, env={**self.restore_env, **pg_env})
195
250
 
196
251
  return stdout
@@ -85,6 +85,19 @@ class BaseDbBackupCommand(BaseCommand):
85
85
  level = 60 if self.quiet else LOGGING_VERBOSITY[int(self.verbosity)]
86
86
  self.logger.setLevel(level)
87
87
 
88
+ # Find or create a StreamHandler for console output so that
89
+ # --verbosity works regardless of the user's Django LOGGING
90
+ # configuration. Only add one handler to avoid duplicates.
91
+ for handler in self.logger.handlers:
92
+ if isinstance(handler, logging.StreamHandler):
93
+ handler.setLevel(level)
94
+ break
95
+ else:
96
+ handler = logging.StreamHandler(sys.stderr)
97
+ handler.setLevel(level)
98
+ handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
99
+ self.logger.addHandler(handler)
100
+
88
101
  def _ask_confirmation(self):
89
102
  answer = input("Are you sure you want to continue? [Y/n] ")
90
103
  if answer.lower().startswith("n"):
@@ -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, {})