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.
Files changed (98) hide show
  1. l3io/wp/backup/__init__.py +22 -0
  2. l3io/wp/backup/_version.py +31 -0
  3. l3io/wp/backup/adapters/__init__.py +9 -0
  4. l3io/wp/backup/adapters/clock.py +22 -0
  5. l3io/wp/backup/adapters/config_writer.py +702 -0
  6. l3io/wp/backup/adapters/credential_providers.py +236 -0
  7. l3io/wp/backup/adapters/crypto.py +237 -0
  8. l3io/wp/backup/adapters/database.py +1555 -0
  9. l3io/wp/backup/adapters/database_load.py +1263 -0
  10. l3io/wp/backup/adapters/exclusions.py +83 -0
  11. l3io/wp/backup/adapters/journal.py +507 -0
  12. l3io/wp/backup/adapters/lock.py +372 -0
  13. l3io/wp/backup/adapters/noop.py +666 -0
  14. l3io/wp/backup/adapters/phase.py +218 -0
  15. l3io/wp/backup/adapters/preflight.py +869 -0
  16. l3io/wp/backup/adapters/redaction.py +237 -0
  17. l3io/wp/backup/adapters/restore_extension.py +341 -0
  18. l3io/wp/backup/adapters/restore_runner.py +1030 -0
  19. l3io/wp/backup/adapters/site_files.py +191 -0
  20. l3io/wp/backup/adapters/store.py +227 -0
  21. l3io/wp/backup/adapters/tar.py +1648 -0
  22. l3io/wp/backup/adapters/tracing.py +235 -0
  23. l3io/wp/backup/adapters/workspace.py +299 -0
  24. l3io/wp/backup/adapters/wpcli.py +584 -0
  25. l3io/wp/backup/adapters/wpcli_child.py +135 -0
  26. l3io/wp/backup/api/__init__.py +104 -0
  27. l3io/wp/backup/api/errors.py +344 -0
  28. l3io/wp/backup/api/operations.py +427 -0
  29. l3io/wp/backup/cli/__init__.py +228 -0
  30. l3io/wp/backup/cli/adapter_sets.py +255 -0
  31. l3io/wp/backup/cli/backup.py +1118 -0
  32. l3io/wp/backup/cli/context.py +382 -0
  33. l3io/wp/backup/cli/credentials.py +310 -0
  34. l3io/wp/backup/cli/doctor.py +171 -0
  35. l3io/wp/backup/cli/exit.py +171 -0
  36. l3io/wp/backup/cli/logging.py +147 -0
  37. l3io/wp/backup/cli/output.py +111 -0
  38. l3io/wp/backup/cli/parser.py +853 -0
  39. l3io/wp/backup/cli/passphrase.py +301 -0
  40. l3io/wp/backup/cli/preflight.py +227 -0
  41. l3io/wp/backup/cli/privileges.py +327 -0
  42. l3io/wp/backup/cli/restore.py +1571 -0
  43. l3io/wp/backup/cli/restore_report.py +396 -0
  44. l3io/wp/backup/cli/restore_request.py +560 -0
  45. l3io/wp/backup/cli/scope.py +31 -0
  46. l3io/wp/backup/cli/settings.py +524 -0
  47. l3io/wp/backup/cli/verify.py +286 -0
  48. l3io/wp/backup/core/__init__.py +9 -0
  49. l3io/wp/backup/core/archive_choice.py +156 -0
  50. l3io/wp/backup/core/backup.py +105 -0
  51. l3io/wp/backup/core/config_location.py +257 -0
  52. l3io/wp/backup/core/exclusions.py +189 -0
  53. l3io/wp/backup/core/relocation.py +437 -0
  54. l3io/wp/backup/core/restore.py +1246 -0
  55. l3io/wp/backup/core/restore_journal.py +541 -0
  56. l3io/wp/backup/core/restore_space.py +224 -0
  57. l3io/wp/backup/core/verify.py +198 -0
  58. l3io/wp/backup/domain/__init__.py +9 -0
  59. l3io/wp/backup/domain/archive_format.py +1072 -0
  60. l3io/wp/backup/domain/backup_scope.py +21 -0
  61. l3io/wp/backup/domain/config.py +27 -0
  62. l3io/wp/backup/domain/credentials.py +125 -0
  63. l3io/wp/backup/domain/database.py +113 -0
  64. l3io/wp/backup/domain/errors.py +1434 -0
  65. l3io/wp/backup/domain/exclusion.py +74 -0
  66. l3io/wp/backup/domain/exit_codes.py +87 -0
  67. l3io/wp/backup/domain/flag_map.py +246 -0
  68. l3io/wp/backup/domain/journal.py +142 -0
  69. l3io/wp/backup/domain/lock.py +61 -0
  70. l3io/wp/backup/domain/preflight.py +126 -0
  71. l3io/wp/backup/domain/restore.py +181 -0
  72. l3io/wp/backup/domain/search_replace.py +59 -0
  73. l3io/wp/backup/domain/secret.py +61 -0
  74. l3io/wp/backup/domain/site.py +60 -0
  75. l3io/wp/backup/domain/storage_target.py +60 -0
  76. l3io/wp/backup/ports/__init__.py +33 -0
  77. l3io/wp/backup/ports/adapter_set.py +66 -0
  78. l3io/wp/backup/ports/archive.py +136 -0
  79. l3io/wp/backup/ports/clock.py +23 -0
  80. l3io/wp/backup/ports/config.py +71 -0
  81. l3io/wp/backup/ports/crypto.py +30 -0
  82. l3io/wp/backup/ports/database.py +129 -0
  83. l3io/wp/backup/ports/exclusion.py +33 -0
  84. l3io/wp/backup/ports/journal.py +53 -0
  85. l3io/wp/backup/ports/lock.py +48 -0
  86. l3io/wp/backup/ports/preflight.py +23 -0
  87. l3io/wp/backup/ports/restore.py +59 -0
  88. l3io/wp/backup/ports/search_replace.py +40 -0
  89. l3io/wp/backup/ports/site_files.py +95 -0
  90. l3io/wp/backup/ports/tracing.py +80 -0
  91. l3io/wp/backup/py.typed +0 -0
  92. l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
  93. l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
  94. l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
  95. l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
  96. l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
  97. wpbackup2/__init__.py +7 -0
  98. 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))