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.
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/PKG-INFO +2 -1
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/__init__.py +1 -1
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/base.py +18 -2
- django_dbbackup-5.4.1/dbbackup/db/postgresql.py +561 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/log.py +2 -1
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/pyproject.toml +9 -0
- django_dbbackup-5.4.0/dbbackup/db/postgresql.py +0 -251
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/.gitignore +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/LICENSE.md +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/README.md +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/apps.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/checks.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/__init__.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/django.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/exceptions.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/mongodb.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/mysql.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/db/sqlite.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/__init__.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/__init__.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/_base.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/dbbackup.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/dbrestore.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/listbackups.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediabackup.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediarestore.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/settings.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/signals.py +0 -0
- {django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/storage.py +0 -0
- {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.
|
|
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
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_dbbackup-5.4.0 → django_dbbackup-5.4.1}/dbbackup/management/commands/mediarestore.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|