l3io-wordpress-database 0.3.5__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.
@@ -0,0 +1,193 @@
1
+ """What a check found, as data a caller can branch on.
2
+
3
+ Every check returns a :class:`Verdict`. An operation runs its check first and
4
+ refuses with :class:`~l3io.wp.database.errors.RefusedError` carrying the same
5
+ verdict when it holds a blocker; on success the verdict's warnings travel on the
6
+ result. A caller therefore branches on :class:`Code` values and never parses a
7
+ message.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+ from enum import StrEnum
15
+ from types import MappingProxyType
16
+
17
+
18
+ class Severity(StrEnum):
19
+ """Whether a finding stops the operation."""
20
+
21
+ BLOCKER = "blocker"
22
+ WARNING = "warning"
23
+
24
+
25
+ class Code(StrEnum):
26
+ """Every finding a check can report. Each code has exactly one severity."""
27
+
28
+ UNREACHABLE = "unreachable"
29
+ CREDENTIALS_UNOBTAINABLE = "credentials_unobtainable"
30
+ AUTHENTICATION_FAILED = "authentication_failed"
31
+ SERVER_UNSUPPORTED = "server_unsupported"
32
+ MISSING_PRIVILEGE = "missing_privilege"
33
+ PRIVILEGES_UNVERIFIED = "privileges_unverified"
34
+ DATABASE_ABSENT = "database_absent"
35
+ USER_ABSENT = "user_absent"
36
+ CONFIRMATION_MISMATCH = "confirmation_mismatch"
37
+ LIVE_WORDPRESS_SITE = "live_wordpress_site"
38
+ END_OF_LIFE_SERVER = "end_of_life_server"
39
+ MISSING_DEPENDENCY = "missing_dependency"
40
+ PASSWORD_UNVERIFIED = "password_unverified" # noqa: S105 - a finding code, not a password
41
+ CLIENT_TOOL_MISSING = "client_tool_missing"
42
+ CLIENT_SERVER_SKEW = "client_server_skew"
43
+ PLATFORM_UNSUPPORTED = "platform_unsupported"
44
+ OPTION_NOT_ALLOWED = "option_not_allowed"
45
+ DATABASE_NOT_EMPTY = "database_not_empty"
46
+ INCOMPATIBLE_DUMP_SOURCE = "incompatible_dump_source"
47
+ CROSS_FAMILY_DUMP = "cross_family_dump"
48
+ STORED_OBJECTS_PRESENT = "stored_objects_present"
49
+ IDENTITY_REQUIRED = "identity_required"
50
+
51
+ @property
52
+ def severity(self) -> Severity:
53
+ """The fixed severity of this code."""
54
+ return _SEVERITY[self]
55
+
56
+ @property
57
+ def description(self) -> str:
58
+ """When this code is raised."""
59
+ return _DESCRIPTION[self]
60
+
61
+
62
+ _B, _W = Severity.BLOCKER, Severity.WARNING
63
+
64
+ _SEVERITY: Mapping[Code, Severity] = MappingProxyType(
65
+ {
66
+ Code.UNREACHABLE: _B,
67
+ Code.CREDENTIALS_UNOBTAINABLE: _B,
68
+ Code.AUTHENTICATION_FAILED: _B,
69
+ Code.SERVER_UNSUPPORTED: _B,
70
+ Code.MISSING_PRIVILEGE: _B,
71
+ Code.PRIVILEGES_UNVERIFIED: _W,
72
+ Code.DATABASE_ABSENT: _B,
73
+ Code.USER_ABSENT: _B,
74
+ Code.CONFIRMATION_MISMATCH: _B,
75
+ Code.LIVE_WORDPRESS_SITE: _W,
76
+ Code.END_OF_LIFE_SERVER: _W,
77
+ Code.MISSING_DEPENDENCY: _B,
78
+ Code.PASSWORD_UNVERIFIED: _W,
79
+ Code.CLIENT_TOOL_MISSING: _B,
80
+ Code.CLIENT_SERVER_SKEW: _W,
81
+ Code.PLATFORM_UNSUPPORTED: _B,
82
+ Code.OPTION_NOT_ALLOWED: _B,
83
+ Code.DATABASE_NOT_EMPTY: _B,
84
+ Code.INCOMPATIBLE_DUMP_SOURCE: _B,
85
+ Code.CROSS_FAMILY_DUMP: _W,
86
+ Code.STORED_OBJECTS_PRESENT: _W,
87
+ Code.IDENTITY_REQUIRED: _B,
88
+ }
89
+ )
90
+
91
+ _DESCRIPTION: Mapping[Code, str] = MappingProxyType(
92
+ {
93
+ Code.UNREACHABLE: "the server could not be reached",
94
+ Code.CREDENTIALS_UNOBTAINABLE: "a credential source failed to supply credentials",
95
+ Code.AUTHENTICATION_FAILED: "the server rejected the supplied account",
96
+ Code.SERVER_UNSUPPORTED: "the server is below MySQL 8.0 or MariaDB 10.2",
97
+ Code.MISSING_PRIVILEGE: (
98
+ "the account lacks a privilege the operation needs; fields.privilege names it"
99
+ ),
100
+ Code.PRIVILEGES_UNVERIFIED: (
101
+ "the account holds an active role, whose privileges information_schema does "
102
+ "not show, so privilege checks were skipped"
103
+ ),
104
+ Code.DATABASE_ABSENT: "the operation needs an existing database",
105
+ Code.USER_ABSENT: "the operation needs an existing account",
106
+ Code.CONFIRMATION_MISMATCH: "confirm= does not equal the operation's target",
107
+ Code.LIVE_WORDPRESS_SITE: (
108
+ "a destructive target has a WordPress options table and schema version"
109
+ ),
110
+ Code.END_OF_LIFE_SERVER: "the server version is past upstream end of life",
111
+ Code.PASSWORD_UNVERIFIED: (
112
+ "the account's host does not match this machine, so its password could not be "
113
+ "checked by logging in and was left unchanged; use rotate_password to set it"
114
+ ),
115
+ Code.MISSING_DEPENDENCY: (
116
+ "a credential source needs an extra that is not installed; fields.extra names it"
117
+ ),
118
+ Code.CLIENT_TOOL_MISSING: (
119
+ "no suitable dump or client program is on PATH; fields.tools names those tried"
120
+ ),
121
+ Code.CLIENT_SERVER_SKEW: (
122
+ "the client program's family, or its major.minor version, is older than or differs "
123
+ "from the server's"
124
+ ),
125
+ Code.PLATFORM_UNSUPPORTED: "export and import need /dev/fd, which this platform lacks",
126
+ Code.OPTION_NOT_ALLOWED: (
127
+ "a caller-supplied client option is not on the allow-list; fields.option names it"
128
+ ),
129
+ Code.DATABASE_NOT_EMPTY: "the load target holds tables and replace= was not given",
130
+ Code.INCOMPATIBLE_DUMP_SOURCE: (
131
+ "the dump begins with MariaDB's sandbox line and this client cannot read it; "
132
+ "allow_unsandboxed=True strips that line"
133
+ ),
134
+ Code.CROSS_FAMILY_DUMP: (
135
+ "the dump comes from the other server family; collations it uses may not exist here"
136
+ ),
137
+ Code.STORED_OBJECTS_PRESENT: (
138
+ "the database has views or triggers: they carry DEFINER clauses that loading needs "
139
+ "privilege for, and MariaDB keeps their original text, which may name this schema"
140
+ ),
141
+ Code.IDENTITY_REQUIRED: "the source is age-encrypted and no identity was supplied",
142
+ }
143
+ )
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class Finding:
148
+ """One thing a check found."""
149
+
150
+ code: Code
151
+ message: str
152
+ fields: Mapping[str, str | int | bool] = field(default_factory=dict)
153
+
154
+ def __post_init__(self) -> None:
155
+ # Frozen dataclasses still hold a mutable dict; freeze the view too.
156
+ object.__setattr__(self, "fields", MappingProxyType(dict(self.fields)))
157
+
158
+ @property
159
+ def severity(self) -> Severity:
160
+ """The code's fixed severity."""
161
+ return self.code.severity
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class Verdict:
166
+ """Everything one check found about one operation."""
167
+
168
+ operation: str
169
+ findings: tuple[Finding, ...] = ()
170
+
171
+ @property
172
+ def blockers(self) -> tuple[Finding, ...]:
173
+ """Findings that stop the operation."""
174
+ return tuple(f for f in self.findings if f.severity is Severity.BLOCKER)
175
+
176
+ @property
177
+ def warnings(self) -> tuple[Finding, ...]:
178
+ """Findings that let the operation run but describe a risk."""
179
+ return tuple(f for f in self.findings if f.severity is Severity.WARNING)
180
+
181
+ @property
182
+ def ok(self) -> bool:
183
+ """Whether nothing blocks the operation."""
184
+ return not self.blockers
185
+
186
+ @property
187
+ def codes(self) -> frozenset[Code]:
188
+ """Every code present."""
189
+ return frozenset(f.code for f in self.findings)
190
+
191
+ def with_findings(self, *findings: Finding) -> Verdict:
192
+ """A new verdict with ``findings`` appended."""
193
+ return Verdict(self.operation, self.findings + findings)
@@ -0,0 +1,101 @@
1
+ """Interpreting WordPress's ``db_version``.
2
+
3
+ WordPress stores a schema revision in its options table. A hand-maintained
4
+ dictionary mapping it to a release number stops at whatever release its author
5
+ last added, and then returns ``None`` for every WordPress after it: it quietly
6
+ stops working rather than merely ageing.
7
+
8
+ The map is now derived from the source of truth and committed as generated
9
+ data, so the runtime needs no network. See ``tools/generate_version_map.py``;
10
+ regenerating is a maintenance task, not a deployment step.
11
+
12
+ Two properties worth stating:
13
+
14
+ * **The floor is WordPress 5.0**, which is a support decision rather than a
15
+ limit of the derivation -- ``$wp_db_version`` is readable back to 2.0 if the
16
+ floor ever needs lowering. Every one of the 328 releases at or above 5.0
17
+ yields a value, so the map has no gaps within its supported range. Anything
18
+ below the floor reports ``None`` and is distinguishable via
19
+ :attr:`SchemaVersion.is_older_than_supported`.
20
+ * **The mapping is many-to-one.** Several releases share a ``db_version``
21
+ (7.0 and 7.1 both report 61833), so a value identifies the release that
22
+ *introduced* that schema, not the exact release installed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import bisect
28
+ from dataclasses import dataclass
29
+
30
+ from ._version_map import DB_VERSION_TO_RELEASE, MIN_RELEASE
31
+
32
+ #: Sorted db_version values, for the bisecting lookup below.
33
+ _KNOWN: list[int] = sorted(DB_VERSION_TO_RELEASE)
34
+
35
+ #: The db_version of the oldest supported release.
36
+ FLOOR_DB_VERSION: int = _KNOWN[0]
37
+
38
+ #: The db_version of the newest release the committed map knows about.
39
+ CEILING_DB_VERSION: int = _KNOWN[-1]
40
+
41
+ #: The oldest WordPress release this package reports on.
42
+ SUPPORTED_FROM: str = MIN_RELEASE
43
+
44
+
45
+ def release_for(db_version: int) -> str | None:
46
+ """The WordPress release that introduced this schema revision.
47
+
48
+ Returns the highest known release whose ``db_version`` is less than or
49
+ equal to the one observed -- not an exact-match lookup, because a site can
50
+ report a value between two releases (a development build), and because the
51
+ map is many-to-one.
52
+
53
+ Comparison is numeric. The original stored these values as strings, where
54
+ ``'9872' <= '48748'`` is ``False``, so an ordered lookup over the old map
55
+ would have been wrong even where its data was right.
56
+
57
+ Returns ``None`` below :data:`FLOOR_DB_VERSION`.
58
+ """
59
+ if db_version < FLOOR_DB_VERSION:
60
+ return None
61
+ index = bisect.bisect_right(_KNOWN, db_version) - 1
62
+ return DB_VERSION_TO_RELEASE[_KNOWN[index]]
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class SchemaVersion:
67
+ """A WordPress schema revision read from a site's options table."""
68
+
69
+ db_version: int
70
+
71
+ @property
72
+ def release(self) -> str | None:
73
+ """The WordPress release that introduced this schema, if supported.
74
+
75
+ A plain property, not cached: `slots=True` leaves no `__dict__` for
76
+ functools to cache into, and a bisect over a handful of entries is not
77
+ worth caching anyway.
78
+ """
79
+ return release_for(self.db_version)
80
+
81
+ @property
82
+ def is_newer_than_known(self) -> bool:
83
+ """Whether this schema postdates the committed map.
84
+
85
+ Distinct from unsupported: the map needs regenerating, rather than the
86
+ site being too old to report on.
87
+ """
88
+ return self.db_version > CEILING_DB_VERSION
89
+
90
+ @property
91
+ def is_older_than_supported(self) -> bool:
92
+ """Whether this site predates the oldest supported release."""
93
+ return self.db_version < FLOOR_DB_VERSION
94
+
95
+ def __str__(self) -> str:
96
+ if self.is_older_than_supported:
97
+ return f"db_version {self.db_version} (older than WordPress {SUPPORTED_FROM})"
98
+ if self.release is None: # pragma: no cover - unreachable above the floor
99
+ return f"db_version {self.db_version} (no known release)"
100
+ suffix = " or newer" if self.is_newer_than_known else ""
101
+ return f"WordPress {self.release}{suffix} (db_version {self.db_version})"
@@ -0,0 +1,145 @@
1
+ """Read connection details out of a WordPress ``wp-config.php``.
2
+
3
+ An adapter over ``l3io-wordpress-config``, which lives behind the ``wpconfig`` extra.
4
+ Per AD-1 the adapter *holds* the config object and exposes only what this
5
+ package needs, rather than subclassing it -- subclassing would make every
6
+ method of the underlying type public here automatically, so the declared
7
+ surface would leak by construction and ``__all__`` could not bound it.
8
+
9
+ Three properties of the upstream API matter enough to state:
10
+
11
+ * An absent key returns a ``MISSING`` sentinel, not ``None``. ``MISSING`` is
12
+ falsy, so ``if config.get("DB_PASSWORD"):`` cannot distinguish "not defined"
13
+ from a legitimately empty value. Every read here tests ``is MISSING``.
14
+ * PHP types are preserved, so ``define('DB_PORT', 3306)`` is an ``int`` while
15
+ ``define('DB_PASSWORD', '3306')`` stays a ``str``. Values are coerced
16
+ deliberately rather than assumed to be strings.
17
+ * ``$table_prefix`` is a PHP *variable*, not a ``define()``, and lives in a
18
+ separate namespace: ``get_variable("table_prefix")``. ``get("table_prefix")``
19
+ correctly returns ``MISSING``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from importlib import metadata
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from .connection import Connection
30
+ from .credentials import ResolvedCredentials
31
+ from .errors import (
32
+ ConfigUnreadableError,
33
+ CredentialsAbsentError,
34
+ CredentialsUnobtainableError,
35
+ MissingConfigValueError,
36
+ MissingDependencyError,
37
+ )
38
+
39
+ #: WordPress's own default when ``$table_prefix`` is not set.
40
+ DEFAULT_TABLE_PREFIX = "wp_"
41
+
42
+ #: The ``l3io-wordpress-config`` releases this adapter accepts: the ``wpconfig``
43
+ #: extra's specifier, which tests/test_wpconfig.py holds equal. An earlier release
44
+ #: truncates a quoted value containing ``);``, and ``ensure`` would then set the
45
+ #: account's password to the truncated value. Only an install through the extra
46
+ #: enforces the range, so it is checked again here.
47
+ REQUIRED_CONFIG = ">=0.1.2,<0.2"
48
+ _CONFIG_FLOOR, _CONFIG_CEILING = (0, 1, 2), (0, 2, 0)
49
+
50
+
51
+ def _accepts(version: str) -> bool:
52
+ match = re.match(r"\d+(?:\.\d+)*", version)
53
+ release = tuple(int(part) for part in match.group().split(".")) if match else ()
54
+ release += (0,) * (3 - len(release))
55
+ return _CONFIG_FLOOR <= release < _CONFIG_CEILING
56
+
57
+
58
+ def _installed_config_version() -> str | None:
59
+ try:
60
+ return metadata.version("l3io-wordpress-config")
61
+ except metadata.PackageNotFoundError:
62
+ return None
63
+
64
+
65
+ class WpConfigFile:
66
+ """A site's ``wp-config.php``: a credential source, and where its database lives.
67
+
68
+ Needs the ``wpconfig`` extra.
69
+ """
70
+
71
+ __slots__ = ("_config", "_missing", "_path")
72
+
73
+ def __init__(self, path: str | Path) -> None:
74
+ self._path = str(path)
75
+ try:
76
+ from l3io.wp.config import MISSING, WpConfigError
77
+ from l3io.wp.config import WpConfigFile as _Upstream
78
+ except ImportError as exc:
79
+ raise MissingDependencyError(
80
+ extra="wpconfig", distribution="l3io-wordpress-config"
81
+ ) from exc
82
+ installed = _installed_config_version()
83
+ if installed is not None and not _accepts(installed):
84
+ raise MissingDependencyError(
85
+ extra="wpconfig",
86
+ distribution="l3io-wordpress-config",
87
+ found=installed,
88
+ required=REQUIRED_CONFIG,
89
+ )
90
+ self._missing = MISSING
91
+ try:
92
+ self._config: Any = _Upstream(filename=self._path)
93
+ except WpConfigError as exc:
94
+ raise ConfigUnreadableError(self._path, str(exc)) from exc
95
+
96
+ @property
97
+ def path(self) -> str:
98
+ return self._path
99
+
100
+ @property
101
+ def source(self) -> str:
102
+ return f"wp-config:{self._path}"
103
+
104
+ @property
105
+ def table_prefix(self) -> str:
106
+ """WordPress's ``$table_prefix``, or ``wp_`` when unset."""
107
+ value = self._config.get_variable("table_prefix")
108
+ return DEFAULT_TABLE_PREFIX if value is self._missing else str(value)
109
+
110
+ def resolve(self) -> ResolvedCredentials:
111
+ user, password = self._get("DB_USER"), self._get("DB_PASSWORD")
112
+ if user is None and password is None:
113
+ msg = f"{self._path} defines neither DB_USER nor DB_PASSWORD"
114
+ raise CredentialsAbsentError(msg, sources=(self.source,))
115
+ try:
116
+ return ResolvedCredentials(
117
+ self._require("DB_USER", user),
118
+ self._require("DB_PASSWORD", password),
119
+ self.source,
120
+ )
121
+ except MissingConfigValueError as exc:
122
+ # Half a pair is a failure, not an absence: a chain must not fall
123
+ # through to another credential after it (spec §5).
124
+ raise CredentialsUnobtainableError(str(exc)) from exc
125
+
126
+ def connection(self) -> Connection:
127
+ """Where the configured database lives."""
128
+ host = self._require("DB_HOST", self._get("DB_HOST"))
129
+ name = self._require("DB_NAME", self._get("DB_NAME"))
130
+ return Connection.from_db_host(host, name)
131
+
132
+ def _require(self, key: str, value: str | None) -> str:
133
+ """Return a value read by ``_get``, or raise naming the missing key."""
134
+ if value is None:
135
+ raise MissingConfigValueError(key, self._path)
136
+ return value
137
+
138
+ def _get(self, key: str) -> str | None:
139
+ value: Any = self._config.get(key)
140
+ if value is self._missing:
141
+ return None
142
+ return value if isinstance(value, str) else str(value)
143
+
144
+ def __repr__(self) -> str:
145
+ return f"WpConfigFile(path={self._path!r})"
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.5
2
+ Name: l3io-wordpress-database
3
+ Version: 0.3.5
4
+ Summary: Create and inspect WordPress MySQL databases and their users, idempotently.
5
+ Project-URL: Homepage, https://github.com/LiquidLogicLabs/l3io-wordpress-database
6
+ Project-URL: Source, https://github.com/LiquidLogicLabs/l3io-wordpress-database
7
+ Author: Shawn Anderson
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: database,mariadb,mysql,provisioning,wordpress
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: opentelemetry-api<2,>=1.30
25
+ Requires-Dist: pymysql[rsa]<2,>=1.2
26
+ Requires-Dist: structlog<27,>=26.1
27
+ Provides-Extra: aws
28
+ Requires-Dist: boto3<2,>=1.34; extra == 'aws'
29
+ Provides-Extra: encryption
30
+ Requires-Dist: l3io-age-passphrase<0.2,>=0.1.1; extra == 'encryption'
31
+ Requires-Dist: pyrage<2,>=1.4; extra == 'encryption'
32
+ Provides-Extra: wpconfig
33
+ Requires-Dist: l3io-wordpress-config<0.2,>=0.1.2; extra == 'wpconfig'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # l3io-wordpress-database
37
+
38
+ The operations a WordPress site performs against its MySQL or MariaDB database:
39
+ inspecting it, provisioning it, changing its accounts, destroying it, and exporting
40
+ and importing it. Every operation acts with credentials the caller supplies, and
41
+ every operation that changes or moves something can be checked before it runs.
42
+
43
+ It imports as `l3io.wp.database`, in the `l3io.wp` namespace shared with
44
+ `l3io-wordpress-config` and `l3io-wordpress-backup`.
45
+
46
+ > **Status:** 0.x — documented and typed, with no stability promise until 1.0.0.
47
+ > Not yet on PyPI; releases are on
48
+ > [GitHub](https://github.com/LiquidLogicLabs/l3io-wordpress-database/releases).
49
+
50
+ ## Installing
51
+
52
+ ```shell
53
+ pip install l3io-wordpress-database # core: PyMySQL, structlog, opentelemetry-api
54
+ pip install 'l3io-wordpress-database[aws]' # credentials from AWS Secrets Manager
55
+ pip install 'l3io-wordpress-database[wpconfig]' # credentials and location from wp-config.php
56
+ pip install 'l3io-wordpress-database[encryption]' # age-encrypted dumps
57
+ ```
58
+
59
+ An optional dependency is an optional import: a base install never imports
60
+ `boto3`, the config reader or `pyrage`, and asking for a feature whose extra is
61
+ absent raises `MissingDependencyError` naming the extra.
62
+
63
+ ## Two accounts
64
+
65
+ WordPress's own account — the one in `wp-config.php` — is the account that is
66
+ created and granted. A separate administrative account does the creating, and is
67
+ never defaulted to the WordPress account. Whether a database exists is answered
68
+ by the administrator: a server authenticates before it resolves the database, so a
69
+ failed login by the WordPress account says nothing about the database.
70
+
71
+ ## Check, then run
72
+
73
+ ```python
74
+ from l3io.wp.database import Connection, Database, Environment, Explicit
75
+
76
+ db = Database(Connection("127.0.0.1", "wp_site"))
77
+ admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
78
+ wordpress = Explicit("wpuser", "wp-pass")
79
+
80
+ verdict = db.check_ensure(admin, wordpress)
81
+ for finding in verdict.findings:
82
+ print(finding.severity, finding.code, finding.message)
83
+
84
+ if verdict.ok:
85
+ result = db.ensure(admin, wordpress)
86
+ print(result.created_database, result.created_user, result.granted, result.changed)
87
+ ```
88
+
89
+ `ensure` runs the same check itself and raises `RefusedError`, carrying the
90
+ `Verdict`, on any blocker. It creates only what is missing, and a second run
91
+ against a converged server changes nothing. Grants are scoped to the one database,
92
+ with the `_` and `%` wildcards in its name escaped.
93
+
94
+ Destructive operations — `revoke`, `drop_user`, `drop_database` — take
95
+ `confirm=`, which must repeat the target's name.
96
+
97
+ ## From wp-config.php
98
+
99
+ ```python
100
+ from l3io.wp.database import Database, Environment, WpConfigFile
101
+
102
+ site = WpConfigFile("/var/www/html/wp-config.php")
103
+ db = Database(site.connection())
104
+ survey = db.survey(
105
+ Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD"),
106
+ wordpress_user=site.resolve().username,
107
+ table_prefix=site.table_prefix,
108
+ )
109
+ print(survey.database_exists, survey.table_count, survey.is_wordpress, survey.schema_version)
110
+ ```
111
+
112
+ `inspect` asks the WordPress account itself what it can do, and never raises
113
+ for an ordinary failure:
114
+
115
+ | State | Meaning |
116
+ |---|---|
117
+ | `ready` | connected, authenticated, and the database is usable |
118
+ | `database_absent` | authenticated, but the database does not exist |
119
+ | `access_denied` | authenticated, but may not use this database |
120
+ | `authentication_failed` | the server rejected the account |
121
+ | `unreachable` | the server could not be reached |
122
+ | `credentials_unobtainable` | the credentials could not be obtained; nothing was attempted |
123
+
124
+ ## Export and import
125
+
126
+ ```python
127
+ from l3io.wp.database import (
128
+ Connection,
129
+ Database,
130
+ Environment,
131
+ WpConfigFile,
132
+ encrypt,
133
+ generate_identity,
134
+ )
135
+
136
+ site = WpConfigFile("/var/www/html/wp-config.php")
137
+ identity, recipient = generate_identity()
138
+ Database(site.connection()).dump(site, "/backups/wp_site.sql.age", recipients=[recipient])
139
+ # Or passphrase mode (mutually exclusive with recipients= / identities=):
140
+ # Database(site.connection()).dump(site, "/backups/wp_site.sql.age", passphrase=secret)
141
+
142
+ admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
143
+ staging = Database(Connection("127.0.0.1", "wp_site_staging"))
144
+ staging.ensure(admin, site)
145
+ staging.load(site, "/backups/wp_site.sql.age", identities=[identity])
146
+ ```
147
+
148
+ Dumps are made by the server's own client tools (`mysqldump` or `mariadb-dump`),
149
+ and loads by `mysql` or `mariadb`. The password reaches them only through an
150
+ anonymous pipe: never an argument, an environment variable or a file. A dump to
151
+ a path is written under a temporary name and renamed only on success. `iter_dump`
152
+ yields the dump as chunks, and raises at the end if the tool failed, so a
153
+ truncated dump is never mistaken for a whole one.
154
+
155
+ Loading into a database that holds tables requires `replace=` naming it. A damaged
156
+ source is discovered mid-load, after the statements before the damage have run,
157
+ so load into a staging database and switch to it once the load succeeds.
158
+
159
+ Encryption is standard [age](https://age-encryption.org): recipient keys or a
160
+ passphrase (scrypt sole recipient). Either streams; any dump can be decrypted
161
+ with the stock `age` command line. Passphrase mode uses the
162
+ `l3io-age-passphrase` binder by default, or `age` + `age-plugin-batchpass` when
163
+ `L3IO_AGE_PASSPHRASE_BACKEND=cli` (or when the binder is absent under `auto`).
164
+ Archive callers can also
165
+ `from l3io.wp.database import encrypt, decrypt, check_passphrase`.
166
+
167
+ ## Command line
168
+
169
+ ```shell
170
+ l3io-wordpress-database survey --wp-config /var/www/html/wp-config.php \
171
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD
172
+ l3io-wordpress-database check ensure --wp-config /var/www/html/wp-config.php \
173
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD
174
+ l3io-wordpress-database ensure --wp-config /var/www/html/wp-config.php \
175
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD
176
+ l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
177
+ --output wp_site.sql.age --recipient age1examplerecipient
178
+ l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
179
+ --output wp_site.sql.age --passphrase-file /run/secrets/age-passphrase
180
+ l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
181
+ --db-user wpuser --db-password-env DB_PASSWORD \
182
+ --input wp_site.sql.age --identity key.txt
183
+ l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
184
+ --db-user wpuser --db-password-env DB_PASSWORD \
185
+ --input wp_site.sql.age --ask-passphrase
186
+ l3io-wordpress-database drop-database --db-host 127.0.0.1 --db-name wp_site_staging \
187
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD --confirm wp_site_staging
188
+ ```
189
+
190
+ No password or age passphrase is accepted as an argument value, because an
191
+ argument vector is visible to every user on the machine: a password comes from
192
+ `--wp-config`, from the environment variable named by a `--*-password-env`
193
+ option, from AWS Secrets Manager, or from a prompt; an age passphrase comes from
194
+ `--passphrase-file` or `--ask-passphrase` (mutually exclusive with
195
+ `--recipient` / `--identity`). Results are printed as JSON on stdout; with
196
+ `dump --output -` the dump is on stdout and the result on stderr.
197
+
198
+ | Exit code | Meaning |
199
+ |---|---|
200
+ | `0` | success |
201
+ | `1` | the operation started and failed |
202
+ | `2` | usage |
203
+ | `3` | refused: the check found a blocker, and nothing ran |
204
+ | `4` | credentials could not be obtained |
205
+ | `5` | the server could not be reached |
206
+ | `7` | not ready: `inspect` found the account cannot use the database |
207
+
208
+ ## Supported
209
+
210
+ - Python 3.11, 3.12, 3.13 and 3.14.
211
+ - MySQL 8.0 and later, MariaDB 10.2 and later. CI runs MySQL 8.0, 8.4 and 9.7 and
212
+ MariaDB 10.2 and 11.4.
213
+ - Export and import need `/dev/fd` (Linux or macOS) and the client tools on `PATH`.
214
+ Everything else is pure Python.
215
+
216
+ The package logs through `structlog` and never configures logging, and opens one
217
+ OpenTelemetry span per operation through `opentelemetry-api`, inert until the host
218
+ configures an SDK.
219
+
220
+ ## Design records
221
+
222
+ This package's decisions are in [docs/adr](docs/adr/README.md). The rules the
223
+ `l3io.wp` family shares (cited as AD-n) are in `l3io-wordpress-backup`'s
224
+ architecture spine.
225
+
226
+ ## Development
227
+
228
+ ```shell
229
+ make sync # locked environment, all extras
230
+ make check # lint, format, types, tests, contracts
231
+ make test-db DB_HOST=127.0.0.1 # integration tests against a server
232
+ make test-transfer ENGINE=mysql:8.4 # export/import with that image's own client tools
233
+ make verify-wheel # install the built wheel, bare and with each extra
234
+ make ci # replay the CI pipeline under act
235
+ ```
236
+
237
+ ## Licence
238
+
239
+ MIT.