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,702 @@
1
+ """AD-18.2/AD-18.4 — the one writer of ``wp-config.php`` (ADR-0010, ADR-0023).
2
+
3
+ :class:`WpConfigWriter` implements ``ConfigQuery`` and ``ConfigCommand`` over
4
+ ``l3io-wordpress-config`` (a required dependency, owner decision 76, imported
5
+ inside the functions that use it; an installation that lacks it anyway is
6
+ ``IncompleteInstallError``). This is the only module in the package that
7
+ imports ``l3io.wp.config``; :func:`read_defined` is the reader
8
+ ``adapters/database.py`` uses for ``WP_SITEURL``/``WP_HOME``.
9
+
10
+ ``set_constants`` and ``replace_file`` write no config bytes themselves: each
11
+ key is one ``WpConfigFile.set``, and the ``--overwrite-wp-config`` replacement
12
+ (the Archive copy's text, handed over in memory, with the target's ``keep``
13
+ definitions carried in as source through ``WpConfigString.set_source``, see
14
+ :func:`carry_sources`) is one ``WpConfigFile.replace_content`` — the library's
15
+ atomic, read-back-verified write that keeps the file's mode and owner and
16
+ fsyncs the file and its directory (0.1.4, BL-E002-091). The module's one own
17
+ write is ``save_copy`` (a ``0600`` fsynced copy of the target's config), which
18
+ that package has no call for. Nothing translates a newline, so CRLF stays CRLF
19
+ byte for byte.
20
+
21
+ Every ``DB_PASSWORD`` value this module is handed or reads is registered with
22
+ the Run's ``SecretRegistry`` before anything else happens to it; records name
23
+ constant **keys**, never values.
24
+
25
+ **Values from the environment (owner decision 47).** :func:`read_settings`
26
+ and ``read_constants`` read a key through ``get_value``/``get_variable_value``
27
+ (0.1.5): a literal is used as written; an ``EnvLookup``
28
+ (``getenv_docker('NAME', <literal>)`` or ``getenv('NAME')``, as the official
29
+ ``wordpress`` image writes them) is resolved by the library's
30
+ ``EnvLookup.resolve`` against the Run's ``environ`` mapping, with
31
+ :func:`read_secret_file` for a ``NAME_FILE`` Docker secret; anything else is
32
+ an ``Expression`` and is refused (``UnsupportedConfigValueError``) without
33
+ quoting it. Nothing here parses PHP or reimplements a fallback, and no adapter
34
+ reads ``os.environ`` (BL-E005-007): the composition roots hand ``environ`` in.
35
+ :func:`refuse_environment_lookups` is a database restore's early refusal of an
36
+ environment-driven target (ADR-0027, Proposed).
37
+
38
+ **Definitions carried as source (owner decision 48, BL-E008-032).**
39
+ ``--overwrite-wp-config`` carries the target's ``DB_*`` into the Archive's
40
+ text as the source the target wrote (``get_source``/``set_source``, 0.1.6), so
41
+ ``getenv_docker('WORDPRESS_DB_HOST', 'mysql')`` stays a lookup. A carried
42
+ ``getenv_docker()`` call needs the Archive's text to declare that function
43
+ before the first carried define; the library answers that
44
+ (``declares_function(name, before=key)``, 0.1.7), a lexical query on its own
45
+ scanner whose limits it documents: a declaration in a branch PHP never takes,
46
+ a class method or a nested function still counts; an ``include``/``require``
47
+ is not followed; ``before`` is text order; heredocs are not recognised.
48
+ Nothing here reads the source text.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import os
54
+ import shutil
55
+ import tempfile
56
+ import time
57
+ from collections.abc import Callable, Mapping, Sequence
58
+ from pathlib import Path
59
+ from types import MappingProxyType
60
+ from typing import TYPE_CHECKING, Final
61
+
62
+ import structlog
63
+
64
+ from l3io.wp.backup.adapters.redaction import SecretRegistry
65
+ from l3io.wp.backup.adapters.workspace import private_directories
66
+ from l3io.wp.backup.domain.config import ConfigWriteResult, ConstantValue
67
+ from l3io.wp.backup.domain.errors import (
68
+ BackupError,
69
+ ConfigWriteFailedError,
70
+ CredentialsError,
71
+ EnvironmentDrivenTargetError,
72
+ IncompleteInstallError,
73
+ InvalidSiteDescriptorError,
74
+ UndefinedConfigFunctionError,
75
+ UnsupportedConfigValueError,
76
+ )
77
+ from l3io.wp.backup.ports.config import WHOLE_FILE
78
+
79
+ if TYPE_CHECKING:
80
+ from l3io.wp.config import EnvLookup, WpConfigFile
81
+
82
+ __all__ = [
83
+ "TABLE_PREFIX",
84
+ "WpConfigWriter",
85
+ "carry_sources",
86
+ "read_defined",
87
+ "read_secret_file",
88
+ "read_settings",
89
+ "refuse_environment_lookups",
90
+ "refuse_uncarriable",
91
+ ]
92
+
93
+ _log = structlog.get_logger(__name__)
94
+
95
+ _DISTRIBUTION: Final = "l3io-wordpress-config"
96
+ _NS_PER_MS: Final = 1_000_000
97
+ _PASSWORD_KEY: Final = "DB_PASSWORD" # noqa: S105 - a constant name
98
+ _TMP_SUFFIX: Final = ".l3io-tmp"
99
+ #: The text ``l3io-wordpress-config`` raises with when the owner cannot be kept.
100
+ _OWNER_MARKER: Final = "cannot keep the file's owner"
101
+ _OWNER_REASON: Final = (
102
+ "the file's owner cannot be kept (run as that owner, or as root); "
103
+ "the file was not changed"
104
+ )
105
+ _READ_BACK_REASON: Final = "read-back verification failed; the file was not changed"
106
+ _ABSENT_REASON: Final = "wp-config.php does not exist, and is never created here"
107
+ _UNDEFINED_REASON: Final = "the target does not define it, so it cannot be kept"
108
+ #: A carried source ``set_source`` refused. Fixed: the source is never quoted.
109
+ _UNCARRIABLE_REASON: Final = (
110
+ "its definition cannot be carried into the Archive's wp-config.php as "
111
+ "written; the file was not changed"
112
+ )
113
+ #: The official image's helper, the one lookup function PHP does not define.
114
+ _DOCKER_FUNCTION: Final = "getenv_docker"
115
+ #: ``$table_prefix``, named without its ``$`` as the library names a variable.
116
+ TABLE_PREFIX: Final = "table_prefix"
117
+ #: The keys whose lookup failure is a credentials failure, not a site one.
118
+ _CREDENTIAL_KEYS: Final = frozenset({"DB_USER", _PASSWORD_KEY})
119
+ #: The longest ``NAME_FILE`` this reads, in characters: a Docker secret is one
120
+ #: short value, and a larger file is refused rather than read whole.
121
+ SECRET_FILE_LIMIT: Final = 64 * 1024
122
+ _NO_ENVIRONMENT: Final[Mapping[str, str]] = MappingProxyType({})
123
+
124
+
125
+ def _require_config() -> None:
126
+ try:
127
+ import l3io.wp.config # noqa: F401 - imported for its presence only
128
+ except ImportError as exc:
129
+ raise IncompleteInstallError(_DISTRIBUTION) from exc
130
+
131
+
132
+ def _constant(value: object) -> ConstantValue:
133
+ """The library's value as a ``ConstantValue``; a float as its literal text."""
134
+ if value is None or isinstance(value, (str, int, bool)):
135
+ return value
136
+ return repr(value)
137
+
138
+
139
+ def read_defined(
140
+ config_file: Path, keys: Sequence[str], *, label: str
141
+ ) -> dict[str, ConstantValue]:
142
+ """``keys``' values in ``config_file``; ``None`` for an undefined one.
143
+
144
+ An unreadable file is ``CredentialsError(label)``: ``wp-config.php`` is
145
+ the credential provider whose file could not be read.
146
+ """
147
+ _require_config()
148
+ from l3io.wp.config import MISSING, WpConfigError, WpConfigFile
149
+
150
+ try:
151
+ config = WpConfigFile(config_file)
152
+ raw = {key: config.get(key) for key in keys}
153
+ except WpConfigError as exc:
154
+ raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
155
+ return {key: None if v is MISSING else _constant(v) for key, v in raw.items()}
156
+
157
+
158
+ def _shown(key: str) -> str:
159
+ """``key`` as an operator reads it: ``$table_prefix`` for the variable."""
160
+ return f"${key}" if key == TABLE_PREFIX else key
161
+
162
+
163
+ def read_secret_file(path: str) -> str:
164
+ """The text of the file a ``NAME_FILE`` variable names (a Docker secret).
165
+
166
+ ``EnvLookup.resolve``'s ``read_file``: it strips the trailing newline
167
+ itself. A file over :data:`SECRET_FILE_LIMIT` characters is a
168
+ ``ValueError`` carrying no text; the library names the failure by class.
169
+ """
170
+ with Path(path).open(encoding="utf-8") as handle:
171
+ text = handle.read(SECRET_FILE_LIMIT + 1)
172
+ if len(text) > SECRET_FILE_LIMIT:
173
+ raise ValueError
174
+ return text
175
+
176
+
177
+ def _lookup_failed(
178
+ key: str, variable: str, reason: str, *, config_file: Path, label: str
179
+ ) -> BackupError:
180
+ """A lookup that gave no value: credentials for ``DB_USER``/``DB_PASSWORD``."""
181
+ why = f"{_shown(key)} reads the environment variable {variable}: {reason}"
182
+ if key in _CREDENTIAL_KEYS:
183
+ return CredentialsError(label, reason=why)
184
+ return InvalidSiteDescriptorError(why, paths=(str(config_file),))
185
+
186
+
187
+ def _resolve(
188
+ key: str,
189
+ lookup: EnvLookup,
190
+ *,
191
+ environ: Mapping[str, str],
192
+ config_file: Path,
193
+ label: str,
194
+ ) -> str:
195
+ """``lookup``'s value as PHP would define it, as text; never logged.
196
+
197
+ The library's ``ConfigLookupError`` names the variable only; it is turned
198
+ into this package's error outside its handler, so neither it nor what the
199
+ file reader raised (a ``UnicodeDecodeError`` holds the file's bytes) is
200
+ chained. A lookup PHP resolves to ``false`` (a bare ``getenv('NAME')``
201
+ of an unset variable) defines no usable setting and is refused as unset.
202
+ """
203
+ from l3io.wp.config import ConfigLookupError
204
+
205
+ failure: str | None = None
206
+ value: object = None
207
+ try:
208
+ value = lookup.resolve(environ, read_file=read_secret_file)
209
+ except ConfigLookupError as exc:
210
+ failure = exc.reason
211
+ if failure is not None:
212
+ raise _lookup_failed(
213
+ key, lookup.name, failure, config_file=config_file, label=label
214
+ )
215
+ if value is False:
216
+ raise _lookup_failed(
217
+ key, lookup.name, "it is not set", config_file=config_file, label=label
218
+ )
219
+ _log.info(
220
+ "config_value_from_environment",
221
+ path=str(config_file),
222
+ key=_shown(key),
223
+ variable=lookup.name,
224
+ function=lookup.function,
225
+ )
226
+ return value if isinstance(value, str) else str(value)
227
+
228
+
229
+ def _read_values(
230
+ config_file: Path,
231
+ keys: Sequence[str],
232
+ *,
233
+ environ: Mapping[str, str],
234
+ label: str,
235
+ secrets: SecretRegistry | None,
236
+ ) -> dict[str, ConstantValue]:
237
+ """``keys``' values, an ``EnvLookup`` resolved; ``None`` for an undefined one.
238
+
239
+ :data:`TABLE_PREFIX` is read as the variable. A literal keeps its PHP
240
+ type; a resolved lookup is text. A resolved ``DB_PASSWORD`` is registered
241
+ with ``secrets`` before anything else happens to it.
242
+ """
243
+ _require_config()
244
+ from l3io.wp.config import (
245
+ MISSING,
246
+ EnvLookup,
247
+ Expression,
248
+ WpConfigError,
249
+ WpConfigFile,
250
+ )
251
+
252
+ try:
253
+ config = WpConfigFile(config_file)
254
+ raw = {
255
+ key: config.get_variable_value(key)
256
+ if key == TABLE_PREFIX
257
+ else config.get_value(key)
258
+ for key in keys
259
+ }
260
+ except WpConfigError as exc:
261
+ raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
262
+ for key, value in raw.items():
263
+ if isinstance(value, Expression):
264
+ raise UnsupportedConfigValueError(_shown(key), path=config_file)
265
+ values: dict[str, ConstantValue] = {}
266
+ for key, value in raw.items():
267
+ if value is MISSING:
268
+ values[key] = None
269
+ elif isinstance(value, EnvLookup):
270
+ text = _resolve(
271
+ key, value, environ=environ, config_file=config_file, label=label
272
+ )
273
+ if key == _PASSWORD_KEY and secrets is not None:
274
+ secrets.register(text, source=f"{label}:{value.name}")
275
+ values[key] = text
276
+ else:
277
+ values[key] = _constant(value)
278
+ return values
279
+
280
+
281
+ def read_settings(
282
+ config_file: Path,
283
+ keys: Sequence[str],
284
+ *,
285
+ environ: Mapping[str, str],
286
+ label: str,
287
+ secrets: SecretRegistry | None = None,
288
+ ) -> dict[str, str | None]:
289
+ """``keys``' values as text, each ``EnvLookup`` resolved against ``environ``.
290
+
291
+ The reader of the Site's ``DB_*`` and ``$table_prefix`` (owner decision
292
+ 47); :data:`TABLE_PREFIX` names the variable. ``None`` is an undefined
293
+ key. A literal that is not a string becomes its text, as database's
294
+ reader did. Refused: an ``Expression`` (``UnsupportedConfigValueError``,
295
+ ``REFUSED``); a lookup that cannot be resolved -- a ``NAME_FILE`` that
296
+ cannot be read, a bare ``getenv`` of an unset variable -- is
297
+ ``CredentialsError`` for ``DB_USER``/``DB_PASSWORD`` and
298
+ ``InvalidSiteDescriptorError`` otherwise, naming the constant and the
299
+ variable, never a value. An unreadable file is ``CredentialsError(label)``.
300
+ """
301
+ values = _read_values(
302
+ config_file, keys, environ=environ, label=label, secrets=secrets
303
+ )
304
+ return {key: None if v is None else str(v) for key, v in values.items()}
305
+
306
+
307
+ def refuse_environment_lookups(
308
+ config_file: Path, keys: Sequence[str], *, operation: str, label: str
309
+ ) -> None:
310
+ """Refuse when any of ``keys`` is read from the environment (ADR-0027).
311
+
312
+ Nothing is resolved, so no value is read. ``EnvironmentDrivenTargetError``
313
+ names every such key and its variable, and ``operation``; an
314
+ ``Expression`` among ``keys`` is ``UnsupportedConfigValueError``.
315
+ """
316
+ _require_config()
317
+ from l3io.wp.config import EnvLookup, Expression, WpConfigError, WpConfigFile
318
+
319
+ try:
320
+ config = WpConfigFile(config_file)
321
+ raw = {key: config.get_value(key) for key in keys}
322
+ except WpConfigError as exc:
323
+ raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
324
+ for key, value in raw.items():
325
+ if isinstance(value, Expression):
326
+ raise UnsupportedConfigValueError(_shown(key), path=config_file)
327
+ lookups = [(k, v.name) for k, v in raw.items() if isinstance(v, EnvLookup)]
328
+ if lookups:
329
+ raise EnvironmentDrivenTargetError(
330
+ keys=tuple(_shown(k) for k, _ in lookups),
331
+ variables=tuple(name for _, name in lookups),
332
+ path=config_file,
333
+ operation=operation,
334
+ )
335
+
336
+
337
+ def _set_failure(exc: Exception) -> str:
338
+ """Why a ``set`` failed, from the library error's structured fields."""
339
+ from l3io.wp.config import (
340
+ ConfigKeyError,
341
+ ConfigValueError,
342
+ ConfigWriteError,
343
+ ReadBackError,
344
+ )
345
+
346
+ if isinstance(exc, ConfigValueError):
347
+ return "unrenderable"
348
+ if isinstance(exc, ConfigKeyError):
349
+ return "not a PHP identifier"
350
+ if isinstance(exc, ReadBackError):
351
+ return _READ_BACK_REASON
352
+ if isinstance(exc, ConfigWriteError):
353
+ return _OWNER_REASON if _OWNER_MARKER in exc.reason else exc.reason
354
+ return type(exc).__name__
355
+
356
+
357
+ def _fsync_directory(directory: Path) -> None:
358
+ fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
359
+ try:
360
+ os.fsync(fd)
361
+ finally:
362
+ os.close(fd)
363
+
364
+
365
+ def _sync_directory_after_commit(directory: Path) -> None:
366
+ """Make a rename durable; logged, not raised, since the file has changed.
367
+
368
+ Raising here would report a replaced file as unchanged, as
369
+ ``l3io-wordpress-config`` reasons for its own writes.
370
+ """
371
+ try:
372
+ _fsync_directory(directory)
373
+ except OSError as exc:
374
+ _log.warning(
375
+ "config_directory_not_synced",
376
+ path=str(directory),
377
+ reason=exc.strerror or type(exc).__name__,
378
+ )
379
+
380
+
381
+ def _carriable(
382
+ config_file: Path, target: WpConfigFile, keep: Sequence[str]
383
+ ) -> tuple[dict[str, str], tuple[str, ...]]:
384
+ """``keep``'s sources in ``target``, and the keys calling ``getenv_docker``.
385
+
386
+ Refused: a key the target does not define (``ConfigWriteFailedError``,
387
+ as before 0.1.6) and an ``Expression`` (``UnsupportedConfigValueError``,
388
+ owner decision 47). The library tells a lookup from a literal; nothing
389
+ here reads the source text.
390
+ """
391
+ from l3io.wp.config import MISSING, EnvLookup, Expression
392
+
393
+ sources: dict[str, str] = {}
394
+ docker: list[str] = []
395
+ for key in keep:
396
+ value = target.get_value(key)
397
+ source = target.get_source(key)
398
+ if value is MISSING or source is MISSING:
399
+ raise ConfigWriteFailedError(config_file, key, _UNDEFINED_REASON)
400
+ if isinstance(value, Expression):
401
+ raise UnsupportedConfigValueError(_shown(key), path=config_file)
402
+ if isinstance(value, EnvLookup) and value.function == _DOCKER_FUNCTION:
403
+ docker.append(key)
404
+ sources[key] = str(source)
405
+ return sources, tuple(docker)
406
+
407
+
408
+ def _open_target(config_file: Path) -> WpConfigFile:
409
+ from l3io.wp.config import ConfigReadError, WpConfigFile
410
+
411
+ try:
412
+ return WpConfigFile(config_file)
413
+ except ConfigReadError as exc:
414
+ unread = _ABSENT_REASON if not config_file.exists() else exc.reason
415
+ raise ConfigWriteFailedError(config_file, WHOLE_FILE, unread) from exc
416
+
417
+
418
+ def _carry(
419
+ config_file: Path, target: WpConfigFile, content: str, keep: Sequence[str]
420
+ ) -> tuple[str, tuple[str, ...]]:
421
+ """:func:`carry_sources` over an opened target."""
422
+ from l3io.wp.config import ConfigValueError, WpConfigError, WpConfigString
423
+
424
+ sources, docker = _carriable(config_file, target, keep)
425
+ try:
426
+ working = WpConfigString(content)
427
+ except ValueError as exc:
428
+ unread = f"the replacement could not be read: {type(exc).__name__}"
429
+ raise ConfigWriteFailedError(config_file, WHOLE_FILE, unread) from exc
430
+ changed: list[str] = []
431
+ for key, source in sources.items():
432
+ try:
433
+ if working.set_source(key, source):
434
+ changed.append(key)
435
+ except ConfigValueError:
436
+ # A fixed reason, not the library's: the source is the target's
437
+ # and is never quoted (it may hold a credential).
438
+ raise ConfigWriteFailedError(
439
+ config_file, key, _UNCARRIABLE_REASON
440
+ ) from None
441
+ except WpConfigError as exc:
442
+ raise ConfigWriteFailedError(config_file, key, _set_failure(exc)) from exc
443
+ text = working.content
444
+ # Declared before each carried use, so before the first of them; the
445
+ # library's lexical query decides (0.1.7, its limits in the module doc).
446
+ if docker and not all(
447
+ working.declares_function(_DOCKER_FUNCTION, before=key) for key in docker
448
+ ):
449
+ raise UndefinedConfigFunctionError(
450
+ function=_DOCKER_FUNCTION,
451
+ keys=tuple(_shown(k) for k in docker),
452
+ path=config_file,
453
+ )
454
+ return text, tuple(changed)
455
+
456
+
457
+ def carry_sources(
458
+ config_file: Path, content: str, keep: Sequence[str]
459
+ ) -> tuple[str, tuple[str, ...]]:
460
+ """``content`` with ``config_file``'s ``keep`` definitions carried in as source.
461
+
462
+ Owner decision 48 (BL-E008-032): each key's value in the Archive's text
463
+ ``content`` is replaced, in memory, by the target's source exactly as
464
+ written (``get_source``/``set_source``), a literal or an ``EnvLookup``
465
+ alike, so a ``getenv_docker()`` definition stays a lookup. Returns the new
466
+ text and the keys whose text changed; writes nothing, so the Dry Run's
467
+ twin calls it too. Refused, before any write: a key the target does not
468
+ define, and a source ``set_source`` will not write verbatim
469
+ (``ConfigWriteFailedError``, fixed reasons, never the source); an
470
+ ``Expression`` (``UnsupportedConfigValueError``); a carried
471
+ ``getenv_docker()`` the new text does not declare before it is used
472
+ (``UndefinedConfigFunctionError``; the library's ``declares_function``).
473
+ """
474
+ _require_config()
475
+ return _carry(config_file, _open_target(config_file), content, keep)
476
+
477
+
478
+ def refuse_uncarriable(
479
+ config_file: Path, keep: Sequence[str], *, archive_content: Callable[[], str]
480
+ ) -> None:
481
+ """``--overwrite-wp-config``'s early refusal, before anything is changed.
482
+
483
+ An ``Expression`` among ``keep`` is ``UnsupportedConfigValueError``. When
484
+ the target reads one of ``keep`` through ``getenv_docker()``, the
485
+ Archive's text (``archive_content``, called only then) is carried into
486
+ in memory as :func:`carry_sources` would, so a missing definition is
487
+ refused here, a Dry Run included, and not at ``REWRITE_CONFIG``.
488
+ """
489
+ _require_config()
490
+ from l3io.wp.config import EnvLookup, Expression, WpConfigError, WpConfigFile
491
+
492
+ label = f"wp-config:{config_file}"
493
+ try:
494
+ target = WpConfigFile(config_file)
495
+ values = {key: target.get_value(key) for key in keep}
496
+ except WpConfigError as exc:
497
+ raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
498
+ for key, value in values.items():
499
+ if isinstance(value, Expression):
500
+ raise UnsupportedConfigValueError(_shown(key), path=config_file)
501
+ if any(
502
+ isinstance(v, EnvLookup) and v.function == _DOCKER_FUNCTION
503
+ for v in values.values()
504
+ ):
505
+ _carry(config_file, target, archive_content(), keep)
506
+
507
+
508
+ class WpConfigWriter:
509
+ """``ConfigQuery`` and ``ConfigCommand`` over ``l3io-wordpress-config``.
510
+
511
+ ``environ`` is the Run's environment mapping, which ``read_constants``
512
+ resolves an ``EnvLookup`` against (owner decision 47); empty by default,
513
+ so a lookup then takes its default, as PHP would with nothing set.
514
+ """
515
+
516
+ __slots__ = ("_environ", "_secrets")
517
+
518
+ def __init__(
519
+ self, secrets: SecretRegistry, *, environ: Mapping[str, str] = _NO_ENVIRONMENT
520
+ ) -> None:
521
+ self._secrets = secrets
522
+ self._environ = environ
523
+
524
+ def __repr__(self) -> str:
525
+ return "WpConfigWriter()"
526
+
527
+ def _register(self, values: Mapping[str, object], *, source: str) -> None:
528
+ value = values.get(_PASSWORD_KEY)
529
+ if isinstance(value, str):
530
+ self._secrets.register(value, source=source)
531
+
532
+ # ------------------------------------------------------------------
533
+ # ConfigQuery
534
+ # ------------------------------------------------------------------
535
+
536
+ def read_constants(
537
+ self, config_file: Path, keys: Sequence[str]
538
+ ) -> Mapping[str, ConstantValue]:
539
+ """The restore target's ``keys``; ``None`` for an undefined one.
540
+
541
+ A literal keeps its PHP type, an ``EnvLookup`` is resolved against the
542
+ writer's ``environ`` and an ``Expression`` is refused
543
+ (``UnsupportedConfigValueError``): its source text is never handed on
544
+ as if it were a value (BL-E008-032).
545
+ """
546
+ label = f"wp-config:{config_file}"
547
+ values = _read_values(
548
+ config_file,
549
+ keys,
550
+ environ=self._environ,
551
+ label=label,
552
+ secrets=self._secrets,
553
+ )
554
+ self._register(values, source=label)
555
+ return values
556
+
557
+ # ------------------------------------------------------------------
558
+ # ConfigCommand
559
+ # ------------------------------------------------------------------
560
+
561
+ def set_constants(
562
+ self, config_file: Path, values: Mapping[str, str | int | bool]
563
+ ) -> ConfigWriteResult:
564
+ """Set ``values`` in ``config_file``, one atomic verified write per key.
565
+
566
+ **Not one transaction.** Each key, in the order given, is its own
567
+ ``WpConfigFile.set``. When one fails, ``ConfigWriteFailedError`` names
568
+ it and ``written`` lists the keys whose write had already landed; the
569
+ file holds exactly those changes. A key already at its value is not
570
+ rewritten and is absent from ``changed``, which the caller journals
571
+ per key. The file must exist: it is never created here.
572
+ """
573
+ _require_config()
574
+ from l3io.wp.config import ConfigReadError, WpConfigError, WpConfigFile
575
+
576
+ self._register(values, source="set_constants")
577
+ started = time.monotonic_ns()
578
+ keys = tuple(values)
579
+ written: list[str] = []
580
+ first = keys[0] if keys else WHOLE_FILE
581
+ try:
582
+ config = WpConfigFile(config_file)
583
+ except ConfigReadError as exc:
584
+ unread = _ABSENT_REASON if not config_file.exists() else exc.reason
585
+ raise ConfigWriteFailedError(config_file, first, unread) from exc
586
+ for key in keys:
587
+ try:
588
+ if config.set(key, values[key]):
589
+ written.append(key)
590
+ except WpConfigError as exc:
591
+ failure = _set_failure(exc)
592
+ raise ConfigWriteFailedError(
593
+ config_file, key, failure, written=tuple(written)
594
+ ) from exc
595
+ _log.info(
596
+ "config_written",
597
+ path=str(config_file),
598
+ method="set_constants",
599
+ duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
600
+ keys=list(keys),
601
+ changed=list(written),
602
+ )
603
+ return ConfigWriteResult(changed=tuple(written), performed=True)
604
+
605
+ def save_copy(self, config_file: Path, destination: Path) -> ConfigWriteResult:
606
+ """Copy ``config_file`` to ``destination`` at mode ``0600``, fsynced.
607
+
608
+ Written beside ``destination`` and moved onto it, so ``destination``
609
+ is either absent or complete. A missing ``destination`` directory is
610
+ created ``0700`` first, so a Dry Run (whose twin writes nothing)
611
+ creates none either (AD-4).
612
+ """
613
+ started = time.monotonic_ns()
614
+ directory = destination.parent
615
+ try:
616
+ private_directories(directory)
617
+ fd, name = tempfile.mkstemp(
618
+ dir=directory, prefix=f".{destination.name}.", suffix=_TMP_SUFFIX
619
+ )
620
+ except OSError as exc:
621
+ reason = exc.strerror or type(exc).__name__
622
+ raise ConfigWriteFailedError(destination, WHOLE_FILE, reason) from exc
623
+ tmp = Path(name)
624
+ committed = False
625
+ try:
626
+ with os.fdopen(fd, "wb") as handle, config_file.open("rb") as src:
627
+ os.fchmod(handle.fileno(), 0o600)
628
+ shutil.copyfileobj(src, handle)
629
+ handle.flush()
630
+ os.fsync(handle.fileno())
631
+ tmp.replace(destination)
632
+ committed = True
633
+ except OSError as exc:
634
+ reason = exc.strerror or type(exc).__name__
635
+ raise ConfigWriteFailedError(destination, WHOLE_FILE, reason) from exc
636
+ finally:
637
+ if not committed:
638
+ tmp.unlink(missing_ok=True)
639
+ _sync_directory_after_commit(directory)
640
+ _log.info(
641
+ "config_written",
642
+ path=str(destination),
643
+ method="save_copy",
644
+ duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
645
+ keys=[],
646
+ )
647
+ return ConfigWriteResult(changed=(), performed=True)
648
+
649
+ def replace_file(
650
+ self,
651
+ config_file: Path,
652
+ content: str,
653
+ *,
654
+ keep: Sequence[str],
655
+ ) -> ConfigWriteResult:
656
+ """Replace ``config_file`` with the text ``content``, ``keep`` carried in first.
657
+
658
+ ``content`` arrives in memory, so no intermediate copy of the Archive's
659
+ config is ever written (BL-E003-057). Each ``keep`` key's definition
660
+ is carried from ``config_file`` into the text in memory, as the source
661
+ the target wrote (:func:`carry_sources`, owner decision 48), so the
662
+ replacement's values for those keys are never written anywhere and a
663
+ ``getenv_docker()`` definition stays a lookup. A key the target does
664
+ not define cannot be kept and is refused, as is a ``getenv_docker()``
665
+ the replacement does not define.
666
+ The write is ``l3io-wordpress-config``'s ``WpConfigFile.replace_content``
667
+ (0.1.4, BL-E002-091): a temp file beside the config takes the
668
+ original's owner and mode before any content — refusing rather than
669
+ changing the owner — is fsynced, read back, and ``os.replace``d over
670
+ ``config_file``; the directory is then fsynced. A text that defines a
671
+ key twice is refused. ``config_file`` must exist. On any failure the
672
+ original is byte-identical and no temp file is left behind.
673
+ """
674
+ _require_config()
675
+ from l3io.wp.config import WpConfigError, WpConfigString
676
+
677
+ started = time.monotonic_ns()
678
+ target = _open_target(config_file)
679
+ self._register(
680
+ {_PASSWORD_KEY: target.get_value(_PASSWORD_KEY)}, source="replace_file"
681
+ )
682
+ try:
683
+ archived = WpConfigString(content).get_value(_PASSWORD_KEY)
684
+ except ValueError:
685
+ archived = None # _carry refuses an unreadable replacement
686
+ self._register({_PASSWORD_KEY: archived}, source="replace_file")
687
+ text, changed = _carry(config_file, target, content, keep)
688
+ try:
689
+ target.replace_content(text)
690
+ except WpConfigError as exc:
691
+ raise ConfigWriteFailedError(
692
+ config_file, WHOLE_FILE, _set_failure(exc)
693
+ ) from exc
694
+ _log.info(
695
+ "config_written",
696
+ path=str(config_file),
697
+ method="replace_file",
698
+ duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
699
+ keys=list(keep),
700
+ changed=list(changed),
701
+ )
702
+ return ConfigWriteResult(changed=changed, performed=True)