l3io-wordpress-database 0.3.5__tar.gz

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 (31) hide show
  1. l3io_wordpress_database-0.3.5/.gitignore +23 -0
  2. l3io_wordpress_database-0.3.5/LICENSE +21 -0
  3. l3io_wordpress_database-0.3.5/PKG-INFO +239 -0
  4. l3io_wordpress_database-0.3.5/README.md +204 -0
  5. l3io_wordpress_database-0.3.5/pyproject.toml +191 -0
  6. l3io_wordpress_database-0.3.5/src/l3io/wp/database/__init__.py +192 -0
  7. l3io_wordpress_database-0.3.5/src/l3io/wp/database/__main__.py +10 -0
  8. l3io_wordpress_database-0.3.5/src/l3io/wp/database/_client.py +318 -0
  9. l3io_wordpress_database-0.3.5/src/l3io/wp/database/_observe.py +157 -0
  10. l3io_wordpress_database-0.3.5/src/l3io/wp/database/_passphrase.py +237 -0
  11. l3io_wordpress_database-0.3.5/src/l3io/wp/database/_sql.py +79 -0
  12. l3io_wordpress_database-0.3.5/src/l3io/wp/database/_version.py +24 -0
  13. l3io_wordpress_database-0.3.5/src/l3io/wp/database/_version_map.py +35 -0
  14. l3io_wordpress_database-0.3.5/src/l3io/wp/database/auth.py +64 -0
  15. l3io_wordpress_database-0.3.5/src/l3io/wp/database/cli.py +531 -0
  16. l3io_wordpress_database-0.3.5/src/l3io/wp/database/connection.py +71 -0
  17. l3io_wordpress_database-0.3.5/src/l3io/wp/database/credentials.py +196 -0
  18. l3io_wordpress_database-0.3.5/src/l3io/wp/database/encryption.py +188 -0
  19. l3io_wordpress_database-0.3.5/src/l3io/wp/database/errors.py +186 -0
  20. l3io_wordpress_database-0.3.5/src/l3io/wp/database/facade.py +220 -0
  21. l3io_wordpress_database-0.3.5/src/l3io/wp/database/identifiers.py +95 -0
  22. l3io_wordpress_database-0.3.5/src/l3io/wp/database/inspection.py +254 -0
  23. l3io_wordpress_database-0.3.5/src/l3io/wp/database/provision.py +751 -0
  24. l3io_wordpress_database-0.3.5/src/l3io/wp/database/py.typed +0 -0
  25. l3io_wordpress_database-0.3.5/src/l3io/wp/database/server.py +139 -0
  26. l3io_wordpress_database-0.3.5/src/l3io/wp/database/state.py +82 -0
  27. l3io_wordpress_database-0.3.5/src/l3io/wp/database/tls.py +211 -0
  28. l3io_wordpress_database-0.3.5/src/l3io/wp/database/transfer.py +1165 -0
  29. l3io_wordpress_database-0.3.5/src/l3io/wp/database/verdict.py +193 -0
  30. l3io_wordpress_database-0.3.5/src/l3io/wp/database/versions.py +101 -0
  31. l3io_wordpress_database-0.3.5/src/l3io/wp/database/wpconfig.py +145 -0
@@ -0,0 +1,23 @@
1
+ __pycache__
2
+ .coverage
3
+ .DS_Store
4
+ .vscode
5
+ build
6
+ dist
7
+ htmlcov
8
+ *.egg-info
9
+ coverage.xml
10
+ .pytest_cache
11
+ .mypy_cache
12
+ .ruff_cache
13
+ .venv
14
+ dist-verify
15
+ # Local age ≥1.3 + batchpass binaries for CLI passphrase tests (not shipped).
16
+ .tools/
17
+
18
+ # BMad agent definitions: tool output, regenerated by the installer.
19
+ .github/agents/
20
+
21
+ # Subagent-driven-development scratch: ledgers, briefs, review packages.
22
+ .superpowers/
23
+ .cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shawn Anderson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -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.
@@ -0,0 +1,204 @@
1
+ # l3io-wordpress-database
2
+
3
+ The operations a WordPress site performs against its MySQL or MariaDB database:
4
+ inspecting it, provisioning it, changing its accounts, destroying it, and exporting
5
+ and importing it. Every operation acts with credentials the caller supplies, and
6
+ every operation that changes or moves something can be checked before it runs.
7
+
8
+ It imports as `l3io.wp.database`, in the `l3io.wp` namespace shared with
9
+ `l3io-wordpress-config` and `l3io-wordpress-backup`.
10
+
11
+ > **Status:** 0.x — documented and typed, with no stability promise until 1.0.0.
12
+ > Not yet on PyPI; releases are on
13
+ > [GitHub](https://github.com/LiquidLogicLabs/l3io-wordpress-database/releases).
14
+
15
+ ## Installing
16
+
17
+ ```shell
18
+ pip install l3io-wordpress-database # core: PyMySQL, structlog, opentelemetry-api
19
+ pip install 'l3io-wordpress-database[aws]' # credentials from AWS Secrets Manager
20
+ pip install 'l3io-wordpress-database[wpconfig]' # credentials and location from wp-config.php
21
+ pip install 'l3io-wordpress-database[encryption]' # age-encrypted dumps
22
+ ```
23
+
24
+ An optional dependency is an optional import: a base install never imports
25
+ `boto3`, the config reader or `pyrage`, and asking for a feature whose extra is
26
+ absent raises `MissingDependencyError` naming the extra.
27
+
28
+ ## Two accounts
29
+
30
+ WordPress's own account — the one in `wp-config.php` — is the account that is
31
+ created and granted. A separate administrative account does the creating, and is
32
+ never defaulted to the WordPress account. Whether a database exists is answered
33
+ by the administrator: a server authenticates before it resolves the database, so a
34
+ failed login by the WordPress account says nothing about the database.
35
+
36
+ ## Check, then run
37
+
38
+ ```python
39
+ from l3io.wp.database import Connection, Database, Environment, Explicit
40
+
41
+ db = Database(Connection("127.0.0.1", "wp_site"))
42
+ admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
43
+ wordpress = Explicit("wpuser", "wp-pass")
44
+
45
+ verdict = db.check_ensure(admin, wordpress)
46
+ for finding in verdict.findings:
47
+ print(finding.severity, finding.code, finding.message)
48
+
49
+ if verdict.ok:
50
+ result = db.ensure(admin, wordpress)
51
+ print(result.created_database, result.created_user, result.granted, result.changed)
52
+ ```
53
+
54
+ `ensure` runs the same check itself and raises `RefusedError`, carrying the
55
+ `Verdict`, on any blocker. It creates only what is missing, and a second run
56
+ against a converged server changes nothing. Grants are scoped to the one database,
57
+ with the `_` and `%` wildcards in its name escaped.
58
+
59
+ Destructive operations — `revoke`, `drop_user`, `drop_database` — take
60
+ `confirm=`, which must repeat the target's name.
61
+
62
+ ## From wp-config.php
63
+
64
+ ```python
65
+ from l3io.wp.database import Database, Environment, WpConfigFile
66
+
67
+ site = WpConfigFile("/var/www/html/wp-config.php")
68
+ db = Database(site.connection())
69
+ survey = db.survey(
70
+ Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD"),
71
+ wordpress_user=site.resolve().username,
72
+ table_prefix=site.table_prefix,
73
+ )
74
+ print(survey.database_exists, survey.table_count, survey.is_wordpress, survey.schema_version)
75
+ ```
76
+
77
+ `inspect` asks the WordPress account itself what it can do, and never raises
78
+ for an ordinary failure:
79
+
80
+ | State | Meaning |
81
+ |---|---|
82
+ | `ready` | connected, authenticated, and the database is usable |
83
+ | `database_absent` | authenticated, but the database does not exist |
84
+ | `access_denied` | authenticated, but may not use this database |
85
+ | `authentication_failed` | the server rejected the account |
86
+ | `unreachable` | the server could not be reached |
87
+ | `credentials_unobtainable` | the credentials could not be obtained; nothing was attempted |
88
+
89
+ ## Export and import
90
+
91
+ ```python
92
+ from l3io.wp.database import (
93
+ Connection,
94
+ Database,
95
+ Environment,
96
+ WpConfigFile,
97
+ encrypt,
98
+ generate_identity,
99
+ )
100
+
101
+ site = WpConfigFile("/var/www/html/wp-config.php")
102
+ identity, recipient = generate_identity()
103
+ Database(site.connection()).dump(site, "/backups/wp_site.sql.age", recipients=[recipient])
104
+ # Or passphrase mode (mutually exclusive with recipients= / identities=):
105
+ # Database(site.connection()).dump(site, "/backups/wp_site.sql.age", passphrase=secret)
106
+
107
+ admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
108
+ staging = Database(Connection("127.0.0.1", "wp_site_staging"))
109
+ staging.ensure(admin, site)
110
+ staging.load(site, "/backups/wp_site.sql.age", identities=[identity])
111
+ ```
112
+
113
+ Dumps are made by the server's own client tools (`mysqldump` or `mariadb-dump`),
114
+ and loads by `mysql` or `mariadb`. The password reaches them only through an
115
+ anonymous pipe: never an argument, an environment variable or a file. A dump to
116
+ a path is written under a temporary name and renamed only on success. `iter_dump`
117
+ yields the dump as chunks, and raises at the end if the tool failed, so a
118
+ truncated dump is never mistaken for a whole one.
119
+
120
+ Loading into a database that holds tables requires `replace=` naming it. A damaged
121
+ source is discovered mid-load, after the statements before the damage have run,
122
+ so load into a staging database and switch to it once the load succeeds.
123
+
124
+ Encryption is standard [age](https://age-encryption.org): recipient keys or a
125
+ passphrase (scrypt sole recipient). Either streams; any dump can be decrypted
126
+ with the stock `age` command line. Passphrase mode uses the
127
+ `l3io-age-passphrase` binder by default, or `age` + `age-plugin-batchpass` when
128
+ `L3IO_AGE_PASSPHRASE_BACKEND=cli` (or when the binder is absent under `auto`).
129
+ Archive callers can also
130
+ `from l3io.wp.database import encrypt, decrypt, check_passphrase`.
131
+
132
+ ## Command line
133
+
134
+ ```shell
135
+ l3io-wordpress-database survey --wp-config /var/www/html/wp-config.php \
136
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD
137
+ l3io-wordpress-database check ensure --wp-config /var/www/html/wp-config.php \
138
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD
139
+ l3io-wordpress-database ensure --wp-config /var/www/html/wp-config.php \
140
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD
141
+ l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
142
+ --output wp_site.sql.age --recipient age1examplerecipient
143
+ l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
144
+ --output wp_site.sql.age --passphrase-file /run/secrets/age-passphrase
145
+ l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
146
+ --db-user wpuser --db-password-env DB_PASSWORD \
147
+ --input wp_site.sql.age --identity key.txt
148
+ l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
149
+ --db-user wpuser --db-password-env DB_PASSWORD \
150
+ --input wp_site.sql.age --ask-passphrase
151
+ l3io-wordpress-database drop-database --db-host 127.0.0.1 --db-name wp_site_staging \
152
+ --admin-user root --admin-password-env DB_ADMIN_PASSWORD --confirm wp_site_staging
153
+ ```
154
+
155
+ No password or age passphrase is accepted as an argument value, because an
156
+ argument vector is visible to every user on the machine: a password comes from
157
+ `--wp-config`, from the environment variable named by a `--*-password-env`
158
+ option, from AWS Secrets Manager, or from a prompt; an age passphrase comes from
159
+ `--passphrase-file` or `--ask-passphrase` (mutually exclusive with
160
+ `--recipient` / `--identity`). Results are printed as JSON on stdout; with
161
+ `dump --output -` the dump is on stdout and the result on stderr.
162
+
163
+ | Exit code | Meaning |
164
+ |---|---|
165
+ | `0` | success |
166
+ | `1` | the operation started and failed |
167
+ | `2` | usage |
168
+ | `3` | refused: the check found a blocker, and nothing ran |
169
+ | `4` | credentials could not be obtained |
170
+ | `5` | the server could not be reached |
171
+ | `7` | not ready: `inspect` found the account cannot use the database |
172
+
173
+ ## Supported
174
+
175
+ - Python 3.11, 3.12, 3.13 and 3.14.
176
+ - MySQL 8.0 and later, MariaDB 10.2 and later. CI runs MySQL 8.0, 8.4 and 9.7 and
177
+ MariaDB 10.2 and 11.4.
178
+ - Export and import need `/dev/fd` (Linux or macOS) and the client tools on `PATH`.
179
+ Everything else is pure Python.
180
+
181
+ The package logs through `structlog` and never configures logging, and opens one
182
+ OpenTelemetry span per operation through `opentelemetry-api`, inert until the host
183
+ configures an SDK.
184
+
185
+ ## Design records
186
+
187
+ This package's decisions are in [docs/adr](docs/adr/README.md). The rules the
188
+ `l3io.wp` family shares (cited as AD-n) are in `l3io-wordpress-backup`'s
189
+ architecture spine.
190
+
191
+ ## Development
192
+
193
+ ```shell
194
+ make sync # locked environment, all extras
195
+ make check # lint, format, types, tests, contracts
196
+ make test-db DB_HOST=127.0.0.1 # integration tests against a server
197
+ make test-transfer ENGINE=mysql:8.4 # export/import with that image's own client tools
198
+ make verify-wheel # install the built wheel, bare and with each extra
199
+ make ci # replay the CI pipeline under act
200
+ ```
201
+
202
+ ## Licence
203
+
204
+ MIT.
@@ -0,0 +1,191 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "l3io-wordpress-database"
7
+ dynamic = ["version"]
8
+ description = "Create and inspect WordPress MySQL databases and their users, idempotently."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Shawn Anderson" }]
13
+ keywords = ["wordpress", "mysql", "mariadb", "database", "provisioning"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Operating System :: OS Independent",
25
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
26
+ "Typing :: Typed",
27
+ ]
28
+
29
+ # Core stays deliberately small. opentelemetry-api is a hard dependency on purpose:
30
+ # it is ~58KB with one dependency and already ships the no-op NonRecordingSpan /
31
+ # ProxyTracer, so putting it behind an extra would oblige us to hand-roll a shim.
32
+ dependencies = [
33
+ "PyMySQL[rsa]>=1.2,<2",
34
+ "structlog>=26.1,<27",
35
+ "opentelemetry-api>=1.30,<2",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ aws = ["boto3>=1.34,<2"]
40
+ wpconfig = ["l3io-wordpress-config>=0.1.2,<0.2"]
41
+ encryption = [
42
+ "pyrage>=1.4,<2",
43
+ "l3io-age-passphrase>=0.1.1,<0.2",
44
+ ]
45
+
46
+ # Mechanical enforcement of the family's uv version, matching l3io-wordpress-config.
47
+ # A lock generated by a different uv version can make `uv sync --locked` fail in
48
+ # CI while passing locally; pinning here makes that impossible rather than
49
+ # remembered. Bump deliberately and across all three repositories together.
50
+ [tool.uv]
51
+ required-version = "==0.11.6"
52
+
53
+ [tool.uv.sources]
54
+ # Development and CI only. Both packages are on TestPyPI and the family Gitea
55
+ # index, but a clean public clone still has no default index for them, so we
56
+ # keep git tags until consumers (and this package) resolve from an index by
57
+ # default. `uv build --no-sources` strips these; check_dist_metadata fails a
58
+ # build that carries a VCS dependency. Delete this block once public PyPI (or
59
+ # a documented default index) carries both.
60
+ l3io-wordpress-config = { git = "https://github.com/LiquidLogicLabs/l3io-wordpress-config.git", tag = "v0.1.3" }
61
+ l3io-age-passphrase = { git = "https://github.com/LiquidLogicLabs/l3io-age-passphrase.git", tag = "v0.1.1" }
62
+
63
+ [project.scripts]
64
+ l3io-wordpress-database = "l3io.wp.database.cli:main"
65
+
66
+ [project.urls]
67
+ Homepage = "https://github.com/LiquidLogicLabs/l3io-wordpress-database"
68
+ Source = "https://github.com/LiquidLogicLabs/l3io-wordpress-database"
69
+
70
+ [dependency-groups]
71
+ dev = [
72
+ "ruff==0.16.7",
73
+ "mypy==2.3.1",
74
+ "pytest==9.1.1",
75
+ "pytest-cov==7.1.0",
76
+ "types-pymysql>=1.2.0.20260807",
77
+ # The contract tests read the workflow files. A maintained parser, because the
78
+ # one thing this repo's rules will not tolerate is a hand-written YAML parser.
79
+ "pyyaml==6.0.3",
80
+ "types-pyyaml==6.0.12.20260906",
81
+ # tests/test_declared_facts.py compares requirement names, specifiers and
82
+ # versions. Declared directly rather than relied on through pytest's own
83
+ # dependency on it.
84
+ "packaging==26.3",
85
+ # Span assertions need a real SDK; the package itself depends on the API only.
86
+ "opentelemetry-sdk==1.45.1",
87
+ # The encryption extra, also here because `uv run` prunes extras.
88
+ "pyrage==1.4.0",
89
+ "l3io-age-passphrase>=0.1.1,<0.2",
90
+ # The optional extras are dev dependencies too, so a bare `uv run` can
91
+ # exercise the code paths behind them. `uv run` syncs the default groups
92
+ # only, which PRUNES extras -- without this, every `uv run` silently
93
+ # uninstalled the wpconfig extra and its tests began skipping.
94
+ #
95
+ # This does not weaken the optionality guarantee. That is enforced against
96
+ # the BUILT WHEEL in an isolated environment, by the verify-install job and
97
+ # `make verify-wheel`, not by whatever happens to be in the dev environment.
98
+ "boto3>=1.34,<2",
99
+ "l3io-wordpress-config>=0.1.2,<0.2",
100
+ ]
101
+
102
+ # AD-27: l3io.wp is a PEP 420 native namespace. Ship no __init__.py at
103
+ # src/l3io/ or src/l3io/wp/ -- doing so shadows the namespace and makes the
104
+ # sibling distributions unimportable, while this package's own tests still pass.
105
+ [tool.hatch.version]
106
+ # AD-30: the git tag IS the version. There is no literal anywhere in this repo
107
+ # to drift from it -- not in pyproject, not in the package -- so "keep the tag
108
+ # and the package in sync" is not a rule anyone can break.
109
+ source = "vcs"
110
+
111
+ [tool.hatch.version.raw-options]
112
+ # PyPI rejects local version labels, so a dev build must not carry +g<sha>.
113
+ local_scheme = "no-local-version"
114
+ # Only three-component tags count. Without this the abandoned 2018 `v1.0` tag
115
+ # is the nearest ancestor of every commit on main, and setuptools-scm reads its
116
+ # two components and guesses the next MINOR: builds came out as 1.1.dev39, which
117
+ # sorts ABOVE 1.0.0 in PEP 440, so publishing one would have permanently
118
+ # outranked the release.
119
+ #
120
+ # The match also needs a three-component ancestor to find, which is what the
121
+ # v0.0.0 BASELINE tag provides. Without it the fallback was 0.1.devN, and
122
+ # 0.1.dev1 > 0.0.1 in PEP 440 -- verified, not assumed -- so a dev build published
123
+ # before the first release would have outranked it permanently. From the baseline
124
+ # an untagged tree derives 0.0.1.devN, which sorts below 0.0.1.
125
+ #
126
+ # v0.0.0 is not a release and is excluded from publish.yml's tag trigger.
127
+ git_describe_command = "git describe --dirty --tags --long --match v[0-9]*.[0-9]*.[0-9]*"
128
+
129
+ [tool.hatch.build.targets.wheel]
130
+ packages = ["src/l3io"]
131
+
132
+ [tool.hatch.build.targets.sdist]
133
+ only-include = ["src/l3io", "README.md", "LICENSE"]
134
+
135
+ [tool.ruff]
136
+ line-length = 100
137
+ target-version = "py311"
138
+ # Installed tooling and generated artifacts, not this project's source. CI lints
139
+ # `.` rather than a hand-listed set of directories so a new package directory is
140
+ # covered automatically -- which means the exclusions, not the inclusions, are
141
+ # what has to be maintained.
142
+ extend-exclude = ["_bmad", "_bmad-output", ".claude", ".agents", ".github"]
143
+
144
+ [tool.ruff.lint]
145
+ select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RET", "ARG", "PTH", "BLE", "TRY", "EM", "S"]
146
+ # BLE001 (blind except): a bare `except Exception` silently turns distinct failures
147
+ # into one benign-looking result.
148
+ ignore = []
149
+
150
+ [tool.ruff.lint.per-file-ignores]
151
+ # S101 asserts are the point of a test; S105-S107 flag the fake
152
+ # credentials every credential test must construct.
153
+ "tests/*" = ["S101", "S105", "S106", "S107", "ARG"]
154
+
155
+ [tool.mypy]
156
+ python_version = "3.11"
157
+ strict = true
158
+ files = ["src", "tests"]
159
+ # AD-27: l3io/ and l3io/wp/ have no __init__.py by design, so mypy needs to be
160
+ # told where package roots begin. The fix for "source file found twice" here is
161
+ # this setting -- never adding an __init__.py, which would break the siblings.
162
+ mypy_path = "src"
163
+ namespace_packages = true
164
+ explicit_package_bases = true
165
+
166
+ [[tool.mypy.overrides]]
167
+ # boto3 lives behind the `aws` extra and is absent from a base install, which
168
+ # is the point of AD-8.
169
+ module = ["boto3", "boto3.*"]
170
+ ignore_missing_imports = true
171
+
172
+ [[tool.mypy.overrides]]
173
+ # pyrage lives behind the `encryption` extra and ships no type information.
174
+ module = ["pyrage", "pyrage.*"]
175
+ ignore_missing_imports = true
176
+
177
+ [[tool.mypy.overrides]]
178
+ # l3io-age-passphrase is a compiled extension behind the encryption extra.
179
+ module = ["l3io_age_passphrase", "l3io_age_passphrase.*"]
180
+ ignore_missing_imports = true
181
+
182
+ [tool.pytest.ini_options]
183
+ testpaths = ["tests"]
184
+ addopts = "-q"
185
+ markers = ["slow: builds a wheel or talks to a real database server", "transfer: export/import tests run by make test-transfer inside a server image"]
186
+
187
+ # Just under the line coverage the integration legs measure (89% on MySQL 8.4), so
188
+ # a deleted test file fails CI instead of passing quietly. pytest-cov applies it to
189
+ # every run that measures coverage; `make test` measures none.
190
+ [tool.coverage.report]
191
+ fail_under = 87