l3io-wordpress-backup 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- l3io/wp/backup/__init__.py +22 -0
- l3io/wp/backup/_version.py +31 -0
- l3io/wp/backup/adapters/__init__.py +9 -0
- l3io/wp/backup/adapters/clock.py +22 -0
- l3io/wp/backup/adapters/config_writer.py +702 -0
- l3io/wp/backup/adapters/credential_providers.py +236 -0
- l3io/wp/backup/adapters/crypto.py +237 -0
- l3io/wp/backup/adapters/database.py +1555 -0
- l3io/wp/backup/adapters/database_load.py +1263 -0
- l3io/wp/backup/adapters/exclusions.py +83 -0
- l3io/wp/backup/adapters/journal.py +507 -0
- l3io/wp/backup/adapters/lock.py +372 -0
- l3io/wp/backup/adapters/noop.py +666 -0
- l3io/wp/backup/adapters/phase.py +218 -0
- l3io/wp/backup/adapters/preflight.py +869 -0
- l3io/wp/backup/adapters/redaction.py +237 -0
- l3io/wp/backup/adapters/restore_extension.py +341 -0
- l3io/wp/backup/adapters/restore_runner.py +1030 -0
- l3io/wp/backup/adapters/site_files.py +191 -0
- l3io/wp/backup/adapters/store.py +227 -0
- l3io/wp/backup/adapters/tar.py +1648 -0
- l3io/wp/backup/adapters/tracing.py +235 -0
- l3io/wp/backup/adapters/workspace.py +299 -0
- l3io/wp/backup/adapters/wpcli.py +584 -0
- l3io/wp/backup/adapters/wpcli_child.py +135 -0
- l3io/wp/backup/api/__init__.py +104 -0
- l3io/wp/backup/api/errors.py +344 -0
- l3io/wp/backup/api/operations.py +427 -0
- l3io/wp/backup/cli/__init__.py +228 -0
- l3io/wp/backup/cli/adapter_sets.py +255 -0
- l3io/wp/backup/cli/backup.py +1118 -0
- l3io/wp/backup/cli/context.py +382 -0
- l3io/wp/backup/cli/credentials.py +310 -0
- l3io/wp/backup/cli/doctor.py +171 -0
- l3io/wp/backup/cli/exit.py +171 -0
- l3io/wp/backup/cli/logging.py +147 -0
- l3io/wp/backup/cli/output.py +111 -0
- l3io/wp/backup/cli/parser.py +853 -0
- l3io/wp/backup/cli/passphrase.py +301 -0
- l3io/wp/backup/cli/preflight.py +227 -0
- l3io/wp/backup/cli/privileges.py +327 -0
- l3io/wp/backup/cli/restore.py +1571 -0
- l3io/wp/backup/cli/restore_report.py +396 -0
- l3io/wp/backup/cli/restore_request.py +560 -0
- l3io/wp/backup/cli/scope.py +31 -0
- l3io/wp/backup/cli/settings.py +524 -0
- l3io/wp/backup/cli/verify.py +286 -0
- l3io/wp/backup/core/__init__.py +9 -0
- l3io/wp/backup/core/archive_choice.py +156 -0
- l3io/wp/backup/core/backup.py +105 -0
- l3io/wp/backup/core/config_location.py +257 -0
- l3io/wp/backup/core/exclusions.py +189 -0
- l3io/wp/backup/core/relocation.py +437 -0
- l3io/wp/backup/core/restore.py +1246 -0
- l3io/wp/backup/core/restore_journal.py +541 -0
- l3io/wp/backup/core/restore_space.py +224 -0
- l3io/wp/backup/core/verify.py +198 -0
- l3io/wp/backup/domain/__init__.py +9 -0
- l3io/wp/backup/domain/archive_format.py +1072 -0
- l3io/wp/backup/domain/backup_scope.py +21 -0
- l3io/wp/backup/domain/config.py +27 -0
- l3io/wp/backup/domain/credentials.py +125 -0
- l3io/wp/backup/domain/database.py +113 -0
- l3io/wp/backup/domain/errors.py +1434 -0
- l3io/wp/backup/domain/exclusion.py +74 -0
- l3io/wp/backup/domain/exit_codes.py +87 -0
- l3io/wp/backup/domain/flag_map.py +246 -0
- l3io/wp/backup/domain/journal.py +142 -0
- l3io/wp/backup/domain/lock.py +61 -0
- l3io/wp/backup/domain/preflight.py +126 -0
- l3io/wp/backup/domain/restore.py +181 -0
- l3io/wp/backup/domain/search_replace.py +59 -0
- l3io/wp/backup/domain/secret.py +61 -0
- l3io/wp/backup/domain/site.py +60 -0
- l3io/wp/backup/domain/storage_target.py +60 -0
- l3io/wp/backup/ports/__init__.py +33 -0
- l3io/wp/backup/ports/adapter_set.py +66 -0
- l3io/wp/backup/ports/archive.py +136 -0
- l3io/wp/backup/ports/clock.py +23 -0
- l3io/wp/backup/ports/config.py +71 -0
- l3io/wp/backup/ports/crypto.py +30 -0
- l3io/wp/backup/ports/database.py +129 -0
- l3io/wp/backup/ports/exclusion.py +33 -0
- l3io/wp/backup/ports/journal.py +53 -0
- l3io/wp/backup/ports/lock.py +48 -0
- l3io/wp/backup/ports/preflight.py +23 -0
- l3io/wp/backup/ports/restore.py +59 -0
- l3io/wp/backup/ports/search_replace.py +40 -0
- l3io/wp/backup/ports/site_files.py +95 -0
- l3io/wp/backup/ports/tracing.py +80 -0
- l3io/wp/backup/py.typed +0 -0
- l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
- l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
- l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
- l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
- l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
- wpbackup2/__init__.py +7 -0
- wpbackup2/__main__.py +8 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""AD-17, AD-18, FR-11 — what a relocating restore changes, derived from its flags.
|
|
2
|
+
|
|
3
|
+
Epic 3 built the seam (``core/restore.py::RestoreRelocation``); this module
|
|
4
|
+
derives the parts of it that are pure value work, for
|
|
5
|
+
``adapters/restore_extension.py::WpCliRestoreExtension.resolve``:
|
|
6
|
+
|
|
7
|
+
* :class:`RelocationRequest` — the seven relocation flags, read-only. It is
|
|
8
|
+
the type ``adapters/`` names, because ``adapters/`` may not import
|
|
9
|
+
``cli/`` (import-linter, "AD-1 — layers run inward only"), where
|
|
10
|
+
``RelocationFlags`` lives;
|
|
11
|
+
* :func:`validate_relocation` — the URL string rules, the user/password pair
|
|
12
|
+
rule (both also :func:`validate_relocation_values`, run as soon as the
|
|
13
|
+
flags are known) and the Mode rule, in that order, each a ``UsageError``
|
|
14
|
+
before any effect;
|
|
15
|
+
* :func:`rewrite_values` — what ``REWRITE_CONFIG`` writes: ``WP_SITEURL`` and
|
|
16
|
+
``WP_HOME`` only. The ``DB_*`` keys are the cutover's (AD-17 as amended
|
|
17
|
+
2026-10-27), carried by ``RestoreRelocation.cutover_*``;
|
|
18
|
+
* :func:`relocation_actions` and :func:`relocation_undo` — the plan's extra
|
|
19
|
+
action line (keys only) and the journal's undo text (the URL keys with
|
|
20
|
+
their prior values, which nothing else records);
|
|
21
|
+
* :func:`url_relocation`, :func:`requires_search_replace`,
|
|
22
|
+
:func:`replacement_pairs`, :func:`safe_order`, :func:`pairs_merged` and
|
|
23
|
+
:func:`nested` — what
|
|
24
|
+
``SEARCH_REPLACE`` rewrites (E007-S01-003): the pairs WP-CLI's
|
|
25
|
+
``search-replace`` is run with, derived from the stored ``siteurl``/``home``
|
|
26
|
+
and the new URLs. No serialized value is read or written here;
|
|
27
|
+
* :data:`WP_CLI_MINIMUM`, :data:`WP_CLI_REQUIREMENT` and
|
|
28
|
+
:func:`required_tools` — the tool a relocation needs (E007-S01-004, FR-13):
|
|
29
|
+
WP-CLI iff a URL is relocated, checked in ``PREFLIGHT`` before the first
|
|
30
|
+
effect. The minimum is the release CI pins
|
|
31
|
+
(``tests/contracts/test_wp_cli_pin.py`` enforces the equality).
|
|
32
|
+
|
|
33
|
+
The new database password never enters this module beyond the fact that it
|
|
34
|
+
was supplied: :func:`validate_relocation` reads whether it is ``None`` or
|
|
35
|
+
empty, and nothing here stores, renders or returns it.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import itertools
|
|
41
|
+
import unicodedata
|
|
42
|
+
from collections.abc import Mapping
|
|
43
|
+
from pathlib import PurePath
|
|
44
|
+
from types import MappingProxyType
|
|
45
|
+
from typing import Final, Protocol
|
|
46
|
+
|
|
47
|
+
from l3io.wp.backup.core.restore import (
|
|
48
|
+
CONFIG_FILE_NAME,
|
|
49
|
+
RestoreRelocation,
|
|
50
|
+
planned_cutover_keys,
|
|
51
|
+
)
|
|
52
|
+
from l3io.wp.backup.domain.errors import (
|
|
53
|
+
IncompleteRelocationCredentialsError,
|
|
54
|
+
InterferingReplacementsError,
|
|
55
|
+
InvalidRelocationValueError,
|
|
56
|
+
RelocationNeedsDatabaseRestoreError,
|
|
57
|
+
)
|
|
58
|
+
from l3io.wp.backup.domain.preflight import ToolRequirement
|
|
59
|
+
from l3io.wp.backup.domain.restore import RestoreMode, RestorePhase, restores_database
|
|
60
|
+
from l3io.wp.backup.domain.search_replace import Replacement, StoredUrls, UrlRelocation
|
|
61
|
+
from l3io.wp.backup.domain.secret import Secret
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"RELOCATION_FLAGS",
|
|
65
|
+
"URL_SCHEMES",
|
|
66
|
+
"WP_CLI_MINIMUM",
|
|
67
|
+
"WP_CLI_REQUIREMENT",
|
|
68
|
+
"RelocationRequest",
|
|
69
|
+
"nested",
|
|
70
|
+
"pairs_merged",
|
|
71
|
+
"relocation_actions",
|
|
72
|
+
"relocation_undo",
|
|
73
|
+
"replacement_pairs",
|
|
74
|
+
"required_tools",
|
|
75
|
+
"requires_search_replace",
|
|
76
|
+
"rewrite_values",
|
|
77
|
+
"safe_order",
|
|
78
|
+
"stored_url_rule_broken",
|
|
79
|
+
"url_relocation",
|
|
80
|
+
"validate_relocation",
|
|
81
|
+
"validate_relocation_values",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RelocationRequest(Protocol):
|
|
86
|
+
"""The relocation flags, read-only (``cli/restore_request.RelocationFlags``)."""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def new_site_url(self) -> str | None: ...
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def new_home_url(self) -> str | None: ...
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def new_db_host(self) -> str | None: ...
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def new_db_name(self) -> str | None: ...
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def new_db_user(self) -> str | None: ...
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def new_db_password(self) -> Secret | str | None: ...
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def wp_config(self) -> PurePath | None: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: Each ``--new-*`` attribute of :class:`RelocationRequest` and the flag that
|
|
111
|
+
#: sets it, as the operator spells it (errors name the flag). ``wp_config`` is
|
|
112
|
+
#: E007-S01-002's and not here. A test checks this against the parser.
|
|
113
|
+
RELOCATION_FLAGS: Final = MappingProxyType(
|
|
114
|
+
{
|
|
115
|
+
"new_site_url": "--new-site-url",
|
|
116
|
+
"new_home_url": "--new-site-home-url",
|
|
117
|
+
"new_db_host": "--new-db-host",
|
|
118
|
+
"new_db_name": "--new-db-name",
|
|
119
|
+
"new_db_user": "--new-db-user",
|
|
120
|
+
"new_db_password": "--new-db-password",
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
#: The URL flags and the ``wp-config.php`` constant each sets, in write order.
|
|
124
|
+
_URL_KEYS: Final = (("new_site_url", "WP_SITEURL"), ("new_home_url", "WP_HOME"))
|
|
125
|
+
#: The schemes a WordPress site or home URL may carry.
|
|
126
|
+
URL_SCHEMES: Final = ("http://", "https://")
|
|
127
|
+
#: Characters a URL constant may not hold: quotes and a backslash.
|
|
128
|
+
_QUOTES: Final = frozenset("'\"\\")
|
|
129
|
+
#: Unicode categories refused in a URL: control, format and surrogate.
|
|
130
|
+
_CONTROL_CATEGORIES: Final = frozenset({"Cc", "Cf", "Cs"})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
#: Characters that end a URL's authority (its host and port).
|
|
134
|
+
_AUTHORITY_ENDS: Final = "/?#"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _authority(rest: str) -> str:
|
|
138
|
+
"""The text after the scheme up to the first of ``/``, ``?`` or ``#``."""
|
|
139
|
+
cut = min((rest.find(c) for c in _AUTHORITY_ENDS if c in rest), default=len(rest))
|
|
140
|
+
return rest[:cut]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _url_rule_broken(value: str, *, stored: bool = False) -> str | None:
|
|
144
|
+
"""The first URL string rule ``value`` breaks, or ``None``. No ``urllib``.
|
|
145
|
+
|
|
146
|
+
``stored`` is for a value read back from a restored database, which WordPress
|
|
147
|
+
may hold with a trailing ``/``; every other rule applies to it unchanged.
|
|
148
|
+
"""
|
|
149
|
+
if value.startswith("-"):
|
|
150
|
+
return "must not start with '-'"
|
|
151
|
+
if not value.startswith(URL_SCHEMES):
|
|
152
|
+
return "must start with http:// or https://"
|
|
153
|
+
for char in value:
|
|
154
|
+
if char.isspace():
|
|
155
|
+
return "must not contain whitespace"
|
|
156
|
+
if unicodedata.category(char) in _CONTROL_CATEGORIES:
|
|
157
|
+
return "must not contain a control character"
|
|
158
|
+
if char in _QUOTES:
|
|
159
|
+
return "must not contain a quote or a backslash"
|
|
160
|
+
authority = _authority(value.split("://", 1)[1])
|
|
161
|
+
if not authority or authority.startswith((":", "@")) or authority.endswith("@"):
|
|
162
|
+
return "must name a host after the scheme"
|
|
163
|
+
if "@" in authority:
|
|
164
|
+
# A ``user:password@`` part would reach wp's argv, the logs and the
|
|
165
|
+
# report (AR-6); WordPress URLs never carry one.
|
|
166
|
+
return "must not contain user information (user@ or user:password@)"
|
|
167
|
+
if value.endswith("/") and not stored:
|
|
168
|
+
return "must not end with '/' (WordPress stores its URLs without one)"
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def stored_url_rule_broken(value: str) -> str | None:
|
|
173
|
+
"""The first rule a ``siteurl``/``home`` read from the restored database breaks.
|
|
174
|
+
|
|
175
|
+
The value is untrusted (it came from the Archive) and becomes a positional
|
|
176
|
+
``wp search-replace`` argument, so the string rules for a ``--new-*`` URL
|
|
177
|
+
apply: not a leading ``-``, an http(s) scheme, no whitespace, control or
|
|
178
|
+
format character, no quote.
|
|
179
|
+
"""
|
|
180
|
+
return _url_rule_broken(value, stored=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _given(request: RelocationRequest) -> tuple[str, ...]:
|
|
184
|
+
"""The ``--new-*`` flags ``request`` sets, in :data:`RELOCATION_FLAGS` order."""
|
|
185
|
+
return tuple(
|
|
186
|
+
flag
|
|
187
|
+
for name, flag in RELOCATION_FLAGS.items()
|
|
188
|
+
if getattr(request, name) is not None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def validate_relocation_values(request: RelocationRequest) -> None:
|
|
193
|
+
"""The Mode-independent rules, checkable as soon as the flags are known.
|
|
194
|
+
|
|
195
|
+
:class:`InvalidRelocationValueError` for a URL breaking a string rule, or
|
|
196
|
+
an empty ``--new-db-user``/``--new-db-password``; then
|
|
197
|
+
:class:`IncompleteRelocationCredentialsError` for one half of the
|
|
198
|
+
user/password pair. ``--new-db-host`` and ``--new-db-name`` are checked by
|
|
199
|
+
the database package where they are parsed
|
|
200
|
+
(``adapters/database.py::database_location_from_db_host``).
|
|
201
|
+
"""
|
|
202
|
+
for name, _ in _URL_KEYS:
|
|
203
|
+
value = getattr(request, name)
|
|
204
|
+
if value is None:
|
|
205
|
+
continue
|
|
206
|
+
broken = _url_rule_broken(value)
|
|
207
|
+
if broken is not None:
|
|
208
|
+
raise InvalidRelocationValueError(
|
|
209
|
+
RELOCATION_FLAGS[name], broken, argument=f"relocation.{name}"
|
|
210
|
+
)
|
|
211
|
+
user, password = request.new_db_user, request.new_db_password
|
|
212
|
+
for name, value in (("new_db_user", user), ("new_db_password", password)):
|
|
213
|
+
if value is not None and len(value) == 0:
|
|
214
|
+
flag = RELOCATION_FLAGS[name]
|
|
215
|
+
raise InvalidRelocationValueError(
|
|
216
|
+
flag, "must not be empty", argument=f"relocation.{name}"
|
|
217
|
+
)
|
|
218
|
+
if (user is None) != (password is None):
|
|
219
|
+
given, missing = "new_db_user", "new_db_password"
|
|
220
|
+
if user is None:
|
|
221
|
+
given, missing = missing, given
|
|
222
|
+
raise IncompleteRelocationCredentialsError(
|
|
223
|
+
given=RELOCATION_FLAGS[given],
|
|
224
|
+
missing=RELOCATION_FLAGS[missing],
|
|
225
|
+
arguments=(f"relocation.{given}", f"relocation.{missing}"),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def validate_relocation(request: RelocationRequest, *, mode: RestoreMode) -> None:
|
|
230
|
+
"""Refuse a relocation that cannot be carried out as given, before any effect.
|
|
231
|
+
|
|
232
|
+
In order: :func:`validate_relocation_values`, then
|
|
233
|
+
:class:`RelocationNeedsDatabaseRestoreError` for any ``--new-*`` flag in a
|
|
234
|
+
Mode that restores no database (it has no cutover, so the value would be
|
|
235
|
+
accepted and discarded).
|
|
236
|
+
"""
|
|
237
|
+
validate_relocation_values(request)
|
|
238
|
+
given_flags = _given(request)
|
|
239
|
+
if given_flags and not restores_database(mode):
|
|
240
|
+
named = tuple(
|
|
241
|
+
f"relocation.{name}"
|
|
242
|
+
for name in RELOCATION_FLAGS
|
|
243
|
+
if getattr(request, name) is not None
|
|
244
|
+
)
|
|
245
|
+
raise RelocationNeedsDatabaseRestoreError(
|
|
246
|
+
flags=given_flags, mode=mode.value, arguments=named
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def rewrite_values(request: RelocationRequest) -> tuple[tuple[str, str], ...]:
|
|
251
|
+
"""What ``REWRITE_CONFIG`` sets: ``WP_SITEURL`` then ``WP_HOME``, those supplied.
|
|
252
|
+
|
|
253
|
+
Never a :data:`~l3io.wp.backup.core.restore.CUTOVER_KEYS` member.
|
|
254
|
+
"""
|
|
255
|
+
return tuple(
|
|
256
|
+
(key, value)
|
|
257
|
+
for name, key in _URL_KEYS
|
|
258
|
+
if (value := getattr(request, name)) is not None
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def relocation_actions(
|
|
263
|
+
relocation: RestoreRelocation,
|
|
264
|
+
) -> tuple[tuple[RestorePhase, str], ...]:
|
|
265
|
+
"""The plan's extra action line: the keys a relocated cutover writes.
|
|
266
|
+
|
|
267
|
+
The keys are :func:`~l3io.wp.backup.core.restore.planned_cutover_keys`'s,
|
|
268
|
+
the one derivation the cutover and the journal header also use; the line
|
|
269
|
+
is added only when the relocation names a database, host or account.
|
|
270
|
+
``REWRITE_CONFIG``'s own line already names its keys. Keys only, never a
|
|
271
|
+
value.
|
|
272
|
+
"""
|
|
273
|
+
relocated = (
|
|
274
|
+
relocation.load_database is not None
|
|
275
|
+
or relocation.cutover_host is not None
|
|
276
|
+
or relocation.cutover_user is not None
|
|
277
|
+
or relocation.cutover_password
|
|
278
|
+
)
|
|
279
|
+
if not relocated:
|
|
280
|
+
return ()
|
|
281
|
+
keys = planned_cutover_keys(relocation)
|
|
282
|
+
return (
|
|
283
|
+
(
|
|
284
|
+
RestorePhase.LOAD_DUMP,
|
|
285
|
+
f"relocate: the cutover sets {', '.join(keys)} in {CONFIG_FILE_NAME} "
|
|
286
|
+
"to the relocated database",
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _restore_step(key: str, prior: str | None) -> str:
|
|
292
|
+
if prior is None:
|
|
293
|
+
return f"remove the {key} define (it was not defined)"
|
|
294
|
+
return f"set {key} back to '{prior}'"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def relocation_undo(
|
|
298
|
+
request: RelocationRequest, *, prior: Mapping[str, str | None]
|
|
299
|
+
) -> tuple[tuple[RestorePhase, str], ...]:
|
|
300
|
+
"""The journal's undo text for ``REWRITE_CONFIG``, when it writes a URL key.
|
|
301
|
+
|
|
302
|
+
Names the keys in write order with their **prior values**, from ``prior``
|
|
303
|
+
(the target's ``WP_SITEURL``/``WP_HOME`` as the Site Descriptor read
|
|
304
|
+
them): no Safety Snapshot is certain to hold the prior ``wp-config.php``
|
|
305
|
+
(a database Mode's snapshot does not cover files, and a snapshot may be
|
|
306
|
+
skipped), and the journal records only ``DB_NAME``/``DB_HOST``/``DB_USER``.
|
|
307
|
+
These are site URLs, never a credential. ``LOAD_DUMP``'s undo stays
|
|
308
|
+
``core/restore_journal.py::undo_for``'s.
|
|
309
|
+
"""
|
|
310
|
+
keys = [key for key, _ in rewrite_values(request)]
|
|
311
|
+
if not keys:
|
|
312
|
+
return ()
|
|
313
|
+
steps = "; ".join(_restore_step(key, prior.get(key)) for key in keys)
|
|
314
|
+
return ((RestorePhase.REWRITE_CONFIG, f"in {CONFIG_FILE_NAME}: {steps}"),)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# --------------------------------------------------------------------------
|
|
318
|
+
# SEARCH_REPLACE (E007-S01-003): the pairs, never the serialized values
|
|
319
|
+
# --------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def url_relocation(request: RelocationRequest) -> UrlRelocation | None:
|
|
323
|
+
"""The URLs ``SEARCH_REPLACE`` moves the content to, or ``None`` for neither."""
|
|
324
|
+
if not requires_search_replace(request):
|
|
325
|
+
return None
|
|
326
|
+
return UrlRelocation(site_url=request.new_site_url, home_url=request.new_home_url)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def requires_search_replace(request: RelocationRequest) -> bool:
|
|
330
|
+
"""True iff ``--new-site-url`` or ``--new-site-home-url`` is supplied."""
|
|
331
|
+
return request.new_site_url is not None or request.new_home_url is not None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
#: The oldest WP-CLI ``SEARCH_REPLACE`` accepts: the release
|
|
335
|
+
#: ``.github/workflows/ci.yml`` pins (``WP_CLI_VERSION``), so the floor never
|
|
336
|
+
#: claims a version the integration suite did not run.
|
|
337
|
+
WP_CLI_MINIMUM: Final[tuple[int, ...]] = (2, 12, 0)
|
|
338
|
+
|
|
339
|
+
#: What a URL relocation needs. ``install_hint`` matches
|
|
340
|
+
#: ``adapters/preflight.py::KNOWN_TOOLS["wp"]`` (a test checks it); the refusal
|
|
341
|
+
#: renders both through ``PreflightFailedError``.
|
|
342
|
+
WP_CLI_REQUIREMENT: Final = ToolRequirement(
|
|
343
|
+
name="wp",
|
|
344
|
+
minimum_version=WP_CLI_MINIMUM,
|
|
345
|
+
needed_for=(
|
|
346
|
+
"SEARCH_REPLACE: serialization-safe content URL rewrite for "
|
|
347
|
+
"--new-site-url/--new-site-home-url (FR-12)"
|
|
348
|
+
),
|
|
349
|
+
install_hint="https://wp-cli.org/#installing",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def required_tools(request: RelocationRequest) -> tuple[ToolRequirement, ...]:
|
|
354
|
+
"""``(WP_CLI_REQUIREMENT,)`` iff a URL is relocated, else ``()``.
|
|
355
|
+
|
|
356
|
+
A config-only relocation (``--new-db-*`` alone) needs no ``wp``, so a
|
|
357
|
+
restore without a URL flag never looks for it.
|
|
358
|
+
"""
|
|
359
|
+
return (WP_CLI_REQUIREMENT,) if requires_search_replace(request) else ()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def pairs_merged(stored: StoredUrls, urls: UrlRelocation) -> bool:
|
|
363
|
+
"""True when both URLs are relocated but the stored two are one value.
|
|
364
|
+
|
|
365
|
+
The content then holds one old URL, so one pair (``home_url``'s) is made
|
|
366
|
+
for both requests (:func:`replacement_pairs`), and the report says so.
|
|
367
|
+
"""
|
|
368
|
+
both = urls.site_url is not None and urls.home_url is not None
|
|
369
|
+
return both and stored.siteurl == stored.home
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def replacement_pairs(
|
|
373
|
+
stored: StoredUrls, urls: UrlRelocation
|
|
374
|
+
) -> tuple[Replacement, ...]:
|
|
375
|
+
"""The search-replace pairs, longest ``old`` first, identities dropped.
|
|
376
|
+
|
|
377
|
+
One pair per supplied URL: ``stored.siteurl`` → ``urls.site_url`` and
|
|
378
|
+
``stored.home`` → ``urls.home_url``. When the stored values are equal a
|
|
379
|
+
single pair is made, to ``home_url`` if supplied, else ``site_url``. The
|
|
380
|
+
order is by descending ``len(old)``, so ``https://x.test/wp`` is rewritten
|
|
381
|
+
before ``https://x.test`` would rewrite its prefix.
|
|
382
|
+
"""
|
|
383
|
+
if stored.siteurl == stored.home:
|
|
384
|
+
new = urls.home_url if urls.home_url is not None else urls.site_url
|
|
385
|
+
candidates = [] if new is None else [Replacement(stored.home, new)]
|
|
386
|
+
else:
|
|
387
|
+
candidates = [
|
|
388
|
+
Replacement(old, new)
|
|
389
|
+
for old, new in (
|
|
390
|
+
(stored.siteurl, urls.site_url),
|
|
391
|
+
(stored.home, urls.home_url),
|
|
392
|
+
)
|
|
393
|
+
if new is not None
|
|
394
|
+
]
|
|
395
|
+
kept = [pair for pair in candidates if pair.old != pair.new]
|
|
396
|
+
return tuple(sorted(kept, key=lambda pair: len(pair.old), reverse=True))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def nested(pair: Replacement) -> bool:
|
|
400
|
+
"""True when ``pair.new`` contains ``pair.old``: no zero-remaining check then.
|
|
401
|
+
|
|
402
|
+
After the replacement the new text legitimately holds the old, so a
|
|
403
|
+
re-count of ``old`` cannot be zero.
|
|
404
|
+
"""
|
|
405
|
+
return pair.old in pair.new
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _safe(order: tuple[Replacement, ...]) -> bool:
|
|
409
|
+
"""No pair in ``order`` touches what an earlier one wrote or left to a later one.
|
|
410
|
+
|
|
411
|
+
For an earlier pair ``e`` and a later pair ``l``: ``l.old`` must not occur
|
|
412
|
+
in ``e.new`` (``l`` would rewrite ``e``'s output), and ``e.old`` must not
|
|
413
|
+
occur in ``l.old`` (``e`` would consume ``l``'s matches first).
|
|
414
|
+
"""
|
|
415
|
+
return not any(
|
|
416
|
+
late.old in early.new or early.old in late.old
|
|
417
|
+
for i, early in enumerate(order)
|
|
418
|
+
for late in order[i + 1 :]
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def safe_order(pairs: tuple[Replacement, ...]) -> tuple[Replacement, ...]:
|
|
423
|
+
"""``pairs`` in an order where no pass rewrites another's output, or a refusal.
|
|
424
|
+
|
|
425
|
+
The pairs run as separate ``wp search-replace`` passes, so a later pair
|
|
426
|
+
whose old URL occurs inside an earlier pair's new URL would rewrite it
|
|
427
|
+
(``example.com/wp`` and ``example.com`` moved to ``example.com.au``
|
|
428
|
+
would end as ``example.com.au.au``). The given order (longest ``old``
|
|
429
|
+
first) is kept when it is safe; otherwise the first safe permutation is
|
|
430
|
+
used; when none is, :class:`InterferingReplacementsError` is raised, before
|
|
431
|
+
any replacement. A pair's own nesting (``new`` containing ``old``) is not
|
|
432
|
+
interference: one pass reads each value once.
|
|
433
|
+
"""
|
|
434
|
+
for order in itertools.permutations(pairs):
|
|
435
|
+
if _safe(order):
|
|
436
|
+
return order
|
|
437
|
+
raise InterferingReplacementsError(tuple((pair.old, pair.new) for pair in pairs))
|