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,74 @@
1
+ """FR-5 — the exclusion value types both ``core/exclusions`` and ``adapters/tar`` name.
2
+
3
+ Pure. Epic 4 owns the policy and its default patterns; this module defines only
4
+ the values that cross the ``ExclusionPolicy`` port, and :data:`INCLUDE_EVERYTHING`,
5
+ the policy that excludes nothing — what a Safety Snapshot passes, and what the
6
+ default resolves to until E008-S01-001 binds Epic 4's set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+ from pathlib import PurePosixPath
14
+ from typing import Final
15
+
16
+ __all__ = [
17
+ "INCLUDE_EVERYTHING",
18
+ "ExcludedEntry",
19
+ "ExclusionDecision",
20
+ "ExclusionSource",
21
+ "IncludeEverything",
22
+ ]
23
+
24
+
25
+ class ExclusionSource(StrEnum):
26
+ """Where an exclusion policy's patterns came from (the Manifest's key).
27
+
28
+ The one type for the value (BL-E004-016): ``ExclusionSet.source``,
29
+ ``ManifestExclusions.source`` and :class:`IncludeEverything`. A host's own
30
+ ``ExclusionPolicy`` returns its ``str`` value; the backup converts it when
31
+ it records the Manifest.
32
+ """
33
+
34
+ DEFAULT = "default"
35
+ CUSTOM = "custom"
36
+ NONE = "none"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ExcludedEntry:
41
+ """One path left out of the Archive, and the pattern that left it out."""
42
+
43
+ path: str
44
+ pattern: str
45
+ is_dir: bool
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class ExclusionDecision:
50
+ """The policy's answer for one path."""
51
+
52
+ excluded: bool
53
+ pattern: str | None
54
+ protected: bool
55
+
56
+
57
+ _INCLUDED: Final = ExclusionDecision(excluded=False, pattern=None, protected=False)
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class IncludeEverything:
62
+ """The policy that excludes nothing; satisfies the ``ExclusionPolicy`` port."""
63
+
64
+ source: ExclusionSource = ExclusionSource.NONE
65
+ patterns: tuple[str, ...] = ()
66
+
67
+ def decide(self, relative: PurePosixPath, *, is_dir: bool) -> ExclusionDecision:
68
+ """Every path is included."""
69
+ del relative, is_dir
70
+ return _INCLUDED
71
+
72
+
73
+ #: The one instance.
74
+ INCLUDE_EVERYTHING: Final = IncludeEverything()
@@ -0,0 +1,87 @@
1
+ """FR-21 — the exit-code table: the one source of truth for what a Run exits with.
2
+
3
+ The family codes are those of ``l3io-wp-database``'s ``cli.Exit`` (v0.2.0):
4
+ 0 OK, 1 FAILED, 2 USAGE, 3 REFUSED, 4 CREDENTIALS, 5 UNREACHABLE and
5
+ 7 NOT_READY, with **6 reserved for the family** and therefore not a member.
6
+ Two codes are this package's own: 8 VERIFICATION_FAILED and 9 LOCKED — the
7
+ next free values after the family's 7, skipping the reserved 6 (decided
8
+ 2026-10-20). If the family later reserves a block above 7, these move before
9
+ the first release, never after. ``tests/contracts/test_exit_codes_family.py``
10
+ checks the family members against database's enum without an import here.
11
+
12
+ **Append-only (PRD §10).** A value is never renumbered or reused; a member is
13
+ added only for a genuinely new kind of failure, and only by E002.
14
+
15
+ **How later failures map onto this table**, without a new member:
16
+
17
+ - a non-empty restore target (FR-7) and an incomplete journal (AD-18) are
18
+ refusals: ``REFUSED``;
19
+ - a missing tool — WP-CLI (FR-13), or any FR-27 prerequisite — is
20
+ ``NOT_READY``;
21
+ - a decryption failure (Epic 4) is ``CREDENTIALS`` for a missing or wrong key
22
+ and ``VERIFICATION_FAILED`` for corrupt ciphertext — except under ``verify
23
+ --authenticate``, where a wrong passphrase is ``VERIFICATION_FAILED`` too
24
+ (``PassphraseRejectedError``; Shawn, 2026-11-03): the Archive could not be
25
+ authenticated.
26
+
27
+ Each error class carries its code as the class attribute
28
+ ``BackupError.exit_code`` (``domain/errors.py``); the CLI reads that attribute
29
+ and never matches over classes.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Mapping
35
+ from enum import IntEnum, unique
36
+ from types import MappingProxyType
37
+ from typing import Final
38
+
39
+ __all__ = [
40
+ "DESCRIPTIONS",
41
+ "INTERRUPTED_EXIT",
42
+ "INTERRUPTED_REASON",
43
+ "RESERVED",
44
+ "ExitCode",
45
+ ]
46
+
47
+
48
+ @unique
49
+ class ExitCode(IntEnum):
50
+ """The process exit code of a ``wpbackup2`` Run."""
51
+
52
+ OK = 0
53
+ FAILED = 1
54
+ USAGE = 2
55
+ REFUSED = 3
56
+ CREDENTIALS = 4
57
+ UNREACHABLE = 5
58
+ NOT_READY = 7
59
+ VERIFICATION_FAILED = 8
60
+ LOCKED = 9
61
+
62
+
63
+ #: A Run stopped by Ctrl-C (``KeyboardInterrupt``) exits with the shell's
64
+ #: convention, 128 + SIGINT, which is not a member of :class:`ExitCode`; its
65
+ #: ``run_finished`` and ``command_failed`` records name it
66
+ #: :data:`INTERRUPTED_REASON`.
67
+ INTERRUPTED_EXIT: Final = 130
68
+ #: The ``exit_reason`` an interrupted Run's records carry.
69
+ INTERRUPTED_REASON: Final = "INTERRUPTED"
70
+
71
+ #: Values the family reserves; never a member of :class:`ExitCode`.
72
+ RESERVED: Final[frozenset[int]] = frozenset({6})
73
+
74
+ #: One line per member, used by the CLI's stderr message, ``--help`` and README.
75
+ DESCRIPTIONS: Final[Mapping[ExitCode, str]] = MappingProxyType(
76
+ {
77
+ ExitCode.OK: "the Run succeeded",
78
+ ExitCode.FAILED: "the Run failed for a reason no other code names",
79
+ ExitCode.USAGE: "the command line was invalid",
80
+ ExitCode.REFUSED: "a check found a reason not to act; nothing was done",
81
+ ExitCode.CREDENTIALS: "credentials were missing, unusable or rejected",
82
+ ExitCode.UNREACHABLE: "the database server could not be reached",
83
+ ExitCode.NOT_READY: "a required tool or optional extra is not available",
84
+ ExitCode.VERIFICATION_FAILED: "the archive failed verification",
85
+ ExitCode.LOCKED: "another Run holds the site lock",
86
+ }
87
+ )
@@ -0,0 +1,246 @@
1
+ """AD-25 — the restore flags map to ``(RestoreMode, OverrideSet)`` through one table.
2
+
3
+ The restore subcommand's mode-selecting flags, and what each one means:
4
+
5
+ * ``--mode {files,db,all}`` chooses the parts restored. The table is keyed by
6
+ the :class:`~l3io.wp.backup.domain.backup_scope.BackupScope` the CLI's
7
+ ``CLI_MODE_TO_SCOPE`` translates it to, so there is no second spelling table.
8
+ * ``--cleanfirst`` authorises **clearing the Site Root**, and nothing else.
9
+ * ``--drop-database`` is accepted and changes nothing (owner decision 63):
10
+ every database restore loads straight into the live database and drops the
11
+ tables carrying the site's prefix first (decisions 49 and 53), so there is
12
+ no previous database to remove. It still selects ``database-clean`` with
13
+ ``--mode db``, which now behaves as ``database``; with ``--mode files`` or
14
+ ``--mode all`` it selects what the same flags without it select. The CLI
15
+ logs WARNING ``deprecated_option`` when it is given.
16
+ * ``--force`` waives FR-7's non-empty-target refusal for exactly the parts the
17
+ Mode restores. It selects no Mode and authorises no removal: it yields an
18
+ :class:`~l3io.wp.backup.domain.restore.OverrideSet`, a distinct type.
19
+
20
+ :data:`FLAG_MAP` is a **literal** of all 24 combinations, not a comprehension,
21
+ so each row can be reviewed against FR-6. It is total — there is no default
22
+ branch — and ``tests/contracts/test_flag_map.py`` derives the combinations from
23
+ the parser's ``restore mode`` argument group and asserts equality.
24
+
25
+ Behaviour change from v0.2.21 (B-3): ``--force`` no longer means
26
+ ``ALLCLEAN``, and ``--cleanfirst`` no longer removes the database. Since
27
+ owner decision 63, ``--mode all --cleanfirst`` alone selects ``all-clean``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Mapping
33
+ from dataclasses import dataclass
34
+ from types import MappingProxyType
35
+ from typing import Final, TypeAlias
36
+
37
+ from l3io.wp.backup.domain.backup_scope import BackupScope
38
+ from l3io.wp.backup.domain.errors import FlagCombinationRejectedError
39
+ from l3io.wp.backup.domain.restore import (
40
+ Override,
41
+ OverrideSet,
42
+ RestoreMode,
43
+ RestorePart,
44
+ )
45
+
46
+ __all__ = [
47
+ "COVERED_FLAGS",
48
+ "FLAG_MAP",
49
+ "FlagCombination",
50
+ "FlagEntry",
51
+ "FlagRejection",
52
+ "authorizing_combination",
53
+ "combination",
54
+ "resolve_flags",
55
+ ]
56
+
57
+ #: One reachable flag combination, as ``(dest, value)`` pairs sorted by ``dest``.
58
+ FlagCombination: TypeAlias = tuple[tuple[str, object], ...]
59
+
60
+ #: The ``dest`` names of the restore flags this table maps. Asserted equal to
61
+ #: the ``restore mode`` argument group of the parser itself.
62
+ COVERED_FLAGS: Final[frozenset[str]] = frozenset(
63
+ {"mode", "cleanfirst", "drop_database", "force"}
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class FlagRejection:
69
+ """A combination that expresses no Restore Mode, and what would."""
70
+
71
+ reason: str
72
+ use_instead: str
73
+
74
+
75
+ def combination(
76
+ *, mode: BackupScope, cleanfirst: bool, drop_database: bool, force: bool
77
+ ) -> FlagCombination:
78
+ """The table key for one set of restore flag values."""
79
+ return (
80
+ ("cleanfirst", cleanfirst),
81
+ ("drop_database", drop_database),
82
+ ("force", force),
83
+ ("mode", mode),
84
+ )
85
+
86
+
87
+ _NONE = OverrideSet.none()
88
+ _FILES = OverrideSet(waived=frozenset({Override.NONEMPTY_FILES}))
89
+ _DATABASE = OverrideSet(waived=frozenset({Override.NONEMPTY_DATABASE}))
90
+ _BOTH = OverrideSet(
91
+ waived=frozenset({Override.NONEMPTY_FILES, Override.NONEMPTY_DATABASE})
92
+ )
93
+
94
+ _DATABASE_CLEAN = FlagRejection(
95
+ reason="--cleanfirst clears the Site Root, which --mode db does not restore",
96
+ use_instead=(
97
+ "use --mode all --cleanfirst to clear the Site Root and restore both; "
98
+ "--mode db alone already replaces every table with the site's prefix"
99
+ ),
100
+ )
101
+
102
+ _F = BackupScope.FILES
103
+ _D = BackupScope.DATABASE
104
+ _A = BackupScope.ALL
105
+
106
+ #: AD-25's table, decided 2026-10-20. Every row is FR-6's; nothing re-derives it.
107
+ FlagEntry: TypeAlias = tuple[RestoreMode, OverrideSet] | FlagRejection
108
+ FLAG_MAP: Final[Mapping[FlagCombination, FlagEntry]] = MappingProxyType(
109
+ {
110
+ # --mode files
111
+ combination(mode=_F, cleanfirst=False, drop_database=False, force=False): (
112
+ RestoreMode.FILES,
113
+ _NONE,
114
+ ),
115
+ combination(mode=_F, cleanfirst=False, drop_database=False, force=True): (
116
+ RestoreMode.FILES,
117
+ _FILES,
118
+ ),
119
+ combination(mode=_F, cleanfirst=True, drop_database=False, force=False): (
120
+ RestoreMode.FILES_CLEAN,
121
+ _NONE,
122
+ ),
123
+ combination(mode=_F, cleanfirst=True, drop_database=False, force=True): (
124
+ RestoreMode.FILES_CLEAN,
125
+ _FILES,
126
+ ),
127
+ # --drop-database changes nothing (owner decision 63).
128
+ combination(mode=_F, cleanfirst=False, drop_database=True, force=False): (
129
+ RestoreMode.FILES,
130
+ _NONE,
131
+ ),
132
+ combination(mode=_F, cleanfirst=False, drop_database=True, force=True): (
133
+ RestoreMode.FILES,
134
+ _FILES,
135
+ ),
136
+ combination(mode=_F, cleanfirst=True, drop_database=True, force=False): (
137
+ RestoreMode.FILES_CLEAN,
138
+ _NONE,
139
+ ),
140
+ combination(mode=_F, cleanfirst=True, drop_database=True, force=True): (
141
+ RestoreMode.FILES_CLEAN,
142
+ _FILES,
143
+ ),
144
+ # --mode db
145
+ combination(mode=_D, cleanfirst=False, drop_database=False, force=False): (
146
+ RestoreMode.DATABASE,
147
+ _NONE,
148
+ ),
149
+ combination(mode=_D, cleanfirst=False, drop_database=False, force=True): (
150
+ RestoreMode.DATABASE,
151
+ _DATABASE,
152
+ ),
153
+ combination(mode=_D, cleanfirst=False, drop_database=True, force=False): (
154
+ RestoreMode.DATABASE_CLEAN,
155
+ _NONE,
156
+ ),
157
+ combination(mode=_D, cleanfirst=False, drop_database=True, force=True): (
158
+ RestoreMode.DATABASE_CLEAN,
159
+ _DATABASE,
160
+ ),
161
+ combination(
162
+ mode=_D, cleanfirst=True, drop_database=False, force=False
163
+ ): _DATABASE_CLEAN,
164
+ combination(
165
+ mode=_D, cleanfirst=True, drop_database=False, force=True
166
+ ): _DATABASE_CLEAN,
167
+ combination(
168
+ mode=_D, cleanfirst=True, drop_database=True, force=False
169
+ ): _DATABASE_CLEAN,
170
+ combination(
171
+ mode=_D, cleanfirst=True, drop_database=True, force=True
172
+ ): _DATABASE_CLEAN,
173
+ # --mode all
174
+ combination(mode=_A, cleanfirst=False, drop_database=False, force=False): (
175
+ RestoreMode.ALL,
176
+ _NONE,
177
+ ),
178
+ combination(mode=_A, cleanfirst=False, drop_database=False, force=True): (
179
+ RestoreMode.ALL,
180
+ _BOTH,
181
+ ),
182
+ combination(mode=_A, cleanfirst=True, drop_database=True, force=False): (
183
+ RestoreMode.ALL_CLEAN,
184
+ _NONE,
185
+ ),
186
+ combination(mode=_A, cleanfirst=True, drop_database=True, force=True): (
187
+ RestoreMode.ALL_CLEAN,
188
+ _BOTH,
189
+ ),
190
+ # Owner decision 63: --cleanfirst alone selects all-clean, and
191
+ # --drop-database changes nothing.
192
+ combination(mode=_A, cleanfirst=True, drop_database=False, force=False): (
193
+ RestoreMode.ALL_CLEAN,
194
+ _NONE,
195
+ ),
196
+ combination(mode=_A, cleanfirst=True, drop_database=False, force=True): (
197
+ RestoreMode.ALL_CLEAN,
198
+ _BOTH,
199
+ ),
200
+ combination(mode=_A, cleanfirst=False, drop_database=True, force=False): (
201
+ RestoreMode.ALL,
202
+ _NONE,
203
+ ),
204
+ combination(mode=_A, cleanfirst=False, drop_database=True, force=True): (
205
+ RestoreMode.ALL,
206
+ _BOTH,
207
+ ),
208
+ }
209
+ )
210
+
211
+
212
+ def resolve_flags(flags: FlagCombination) -> tuple[RestoreMode, OverrideSet]:
213
+ """AD-25's one total function: the Mode and waivers ``flags`` express.
214
+
215
+ Raises :class:`FlagCombinationRejectedError` (``USAGE``) for a
216
+ combination that expresses no Mode, and ``KeyError`` for one outside the
217
+ table — there is no default.
218
+ """
219
+ entry = FLAG_MAP[flags]
220
+ if isinstance(entry, FlagRejection):
221
+ raise FlagCombinationRejectedError(
222
+ reason=entry.reason, use_instead=entry.use_instead
223
+ )
224
+ return entry
225
+
226
+
227
+ def authorizing_combination(mode: RestoreMode, part: RestorePart) -> FlagCombination:
228
+ """The combination that selects ``mode`` and waives ``part``'s refusal.
229
+
230
+ Derived from :data:`FLAG_MAP`, so a refusal message names the waiving flag
231
+ from the table rather than from text typed into the message. Raises
232
+ ``ValueError`` when ``mode`` does not restore ``part``, since then there
233
+ is no refusal to waive. A combination without ``--drop-database`` is
234
+ preferred (owner decision 63: the flag changes nothing).
235
+ """
236
+ ordered = sorted(
237
+ FLAG_MAP.items(), key=lambda item: dict(item[0])["drop_database"] is True
238
+ )
239
+ for flags, entry in ordered:
240
+ if isinstance(entry, FlagRejection):
241
+ continue
242
+ entry_mode, overrides = entry
243
+ if entry_mode is mode and overrides.waives(part):
244
+ return flags
245
+ msg = f"Restore Mode {mode.value!r} does not restore the {part}"
246
+ raise ValueError(msg)
@@ -0,0 +1,142 @@
1
+ """AD-18.1 — the Run Journal's values (ADR-0020, ADR-0021).
2
+
3
+ Pure values; the durable write sequence and the file layout are
4
+ ``adapters/journal.py``'s (Epic 3, E003-S02-005).
5
+
6
+ :class:`JournalHeader` is what a restore records before its first effect.
7
+ ``cutover_targets`` and ``prior_constants`` may hold only
8
+ :data:`JOURNALED_CONSTANTS`, so ``DB_PASSWORD`` cannot be journaled; the
9
+ header's mappings are read-only, so no key is added after that check.
10
+ ``phase_undo`` is Epic 7's per-phase undo text and ``saved_config`` the path of
11
+ the config copy ``--overwrite-wp-config`` saves, so a later Run derives the
12
+ undo from the record alone.
13
+
14
+ :class:`JournalEntry` stores no undo: ``step`` names the step a
15
+ ``step_completed`` entry completed, and the undo is derived when the journal is
16
+ read (ADR-0020).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass
23
+ from pathlib import PurePosixPath
24
+ from typing import Final, Literal, NoReturn, TypeVar
25
+
26
+ from l3io.wp.backup.domain.restore import RestoreMode, RestorePhase
27
+
28
+ __all__ = [
29
+ "JOURNALED_CONSTANTS",
30
+ "JournalEntry",
31
+ "JournalEvent",
32
+ "JournalHeader",
33
+ "JournalRecord",
34
+ "ReadOnlyDict",
35
+ ]
36
+
37
+ #: The only ``wp-config.php`` constants a journal may hold. Never a secret.
38
+ JOURNALED_CONSTANTS: Final = frozenset({"DB_NAME", "DB_HOST", "DB_USER"})
39
+
40
+ #: What a journal entry records.
41
+ JournalEvent = Literal["phase_started", "step_completed", "phase_completed"]
42
+
43
+
44
+ _K = TypeVar("_K")
45
+ _V = TypeVar("_V")
46
+
47
+
48
+ class ReadOnlyDict(dict[_K, _V]):
49
+ """A ``dict`` no one can change after it is built.
50
+
51
+ Why not ``types.MappingProxyType``: ``dataclasses.asdict`` deep-copies any
52
+ value that is not a ``dict``, and a ``mappingproxy`` cannot be copied, so
53
+ the header would no longer round-trip (the story's testability AC). Python
54
+ 3.11 has no ``frozendict``. Being a ``dict``, ``asdict`` rebuilds it with
55
+ ``type(obj)(pairs)``; ``__reduce__`` lets ``copy``/``pickle`` do the same.
56
+ """
57
+
58
+ __slots__ = ()
59
+
60
+ def _refuse(self, *_: object, **__: object) -> NoReturn:
61
+ msg = "a journal mapping is read-only"
62
+ raise TypeError(msg)
63
+
64
+ __setitem__ = __delitem__ = _refuse
65
+ clear = pop = popitem = setdefault = update = _refuse
66
+ __ior__ = _refuse
67
+
68
+ def __hash__(self) -> int: # type: ignore[override]
69
+ return hash(frozenset(self.items()))
70
+
71
+ def __reduce__(self) -> tuple[type[ReadOnlyDict[_K, _V]], tuple[dict[_K, _V]]]:
72
+ return (type(self), (dict(self),))
73
+
74
+
75
+ def _journaled(field: str, keys: Mapping[str, object]) -> None:
76
+ refused = sorted(set(keys) - JOURNALED_CONSTANTS)
77
+ if refused:
78
+ msg = (
79
+ f"{field} may hold only {sorted(JOURNALED_CONSTANTS)}; "
80
+ f"refused keys: {refused}"
81
+ )
82
+ raise ValueError(msg)
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class JournalHeader:
87
+ """What a restore records before its first effect (AD-18.1).
88
+
89
+ ``live_database_before`` is the Site Descriptor's ``DB_NAME`` and
90
+ ``load_database`` the name the plan would load into, in every Mode: the
91
+ live database itself, or a relocation's (owner decision 49).
92
+ ``staging_database`` is always ``None`` since the direct load; it stays
93
+ in the record so a journal an earlier version wrote, which named one,
94
+ is read back unchanged. A files-only Mode loads nothing: it records them all
95
+ the same, and the undo text never reads them, since it has no
96
+ ``LOAD_DUMP`` phase.
97
+ """
98
+
99
+ run_id: str
100
+ started_at: str
101
+ site_root: PurePosixPath
102
+ restore_mode: RestoreMode
103
+ archive: str
104
+ snapshot_path: str | None
105
+ live_database_before: str
106
+ staging_database: str | None
107
+ wp_config_path: str
108
+ load_database: str
109
+ cutover_keys: tuple[str, ...]
110
+ cutover_targets: Mapping[str, str]
111
+ prior_constants: Mapping[str, str | None]
112
+ phase_undo: Mapping[RestorePhase, str]
113
+ saved_config: str | None
114
+
115
+ def __post_init__(self) -> None:
116
+ # Key names only in the message: a refused key's value is never shown.
117
+ _journaled("cutover_targets", self.cutover_targets)
118
+ _journaled("prior_constants", self.prior_constants)
119
+ # Read-only copies: neither the caller's later mutation nor a write
120
+ # through the header can add a key after the check above.
121
+ object.__setattr__(self, "cutover_keys", tuple(self.cutover_keys))
122
+ for name in ("cutover_targets", "prior_constants", "phase_undo"):
123
+ object.__setattr__(self, name, ReadOnlyDict(getattr(self, name)))
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class JournalEntry:
128
+ """One journal event. No undo is stored; it is derived on read (ADR-0020)."""
129
+
130
+ event: JournalEvent
131
+ phase: RestorePhase
132
+ at: str
133
+ target: str | None
134
+ step: str | None
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class JournalRecord:
139
+ """A journal as read back: its header and its entries, in order."""
140
+
141
+ header: JournalHeader
142
+ entries: tuple[JournalEntry, ...]
@@ -0,0 +1,61 @@
1
+ """AD-19 — the Site Lock's value types: who holds a Site.
2
+
3
+ :class:`LockHolder` is the record a holding Run leaves beside its lock file
4
+ (``adapters/lock.py``) so a refused Run can name it. It carries no credential
5
+ and no Site content — only the Run ID, PID, start time and hostname.
6
+ ``started_at`` is rendered UTC ISO-8601 with the ``Z`` designator, the one
7
+ spelling (E002-S01-001).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+
16
+ __all__ = ["LockHolder", "utc_z"]
17
+
18
+
19
+ def utc_z(moment: datetime) -> str:
20
+ """``moment`` as UTC ISO-8601 with the ``Z`` designator."""
21
+ return moment.astimezone(UTC).isoformat().replace("+00:00", "Z")
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class LockHolder:
26
+ """The Run holding a Site Lock."""
27
+
28
+ run_id: str
29
+ pid: int
30
+ started_at: datetime
31
+ hostname: str
32
+
33
+ def as_record(self, site_root: str) -> dict[str, object]:
34
+ """The holder record's fields, with the canonical Site Root in clear."""
35
+ return {
36
+ "run_id": self.run_id,
37
+ "pid": self.pid,
38
+ "started_at": utc_z(self.started_at),
39
+ "hostname": self.hostname,
40
+ "site_root": site_root,
41
+ }
42
+
43
+ @classmethod
44
+ def from_record(cls, record: Mapping[str, object]) -> LockHolder:
45
+ """The holder a record names; ``TypeError``/``ValueError`` if not one."""
46
+ run_id, pid = record.get("run_id"), record.get("pid")
47
+ started, hostname = record.get("started_at"), record.get("hostname")
48
+ if not (
49
+ isinstance(run_id, str)
50
+ and isinstance(pid, int)
51
+ and not isinstance(pid, bool)
52
+ and isinstance(started, str)
53
+ and isinstance(hostname, str)
54
+ ):
55
+ msg = "not a Site Lock holder record"
56
+ raise TypeError(msg)
57
+ moment = datetime.fromisoformat(started.replace("Z", "+00:00"))
58
+ if moment.tzinfo is None:
59
+ msg = "holder record started_at has no timezone"
60
+ raise ValueError(msg)
61
+ return cls(run_id=run_id, pid=pid, started_at=moment, hostname=hostname)