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,751 @@
1
+ """Provisioning operations, each checked before it runs.
2
+
3
+ Every operation's preflight produces a :class:`Verdict`. ``check_X`` returns it;
4
+ ``X`` raises :class:`RefusedError` with it on a blocker, so the check is never
5
+ skipped and never differs from the run. ``ensure`` queries facts, plans with a
6
+ pure function, and executes only what the plan names, so a converged server
7
+ sees no writes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import pymysql
16
+
17
+ from ._observe import VERDICT_KEY, operation
18
+ from ._sql import (
19
+ ER_CANNOT_USER,
20
+ ER_DBACCESS_DENIED,
21
+ ER_NONEXISTING_GRANT,
22
+ ER_TABLEACCESS_DENIED,
23
+ classify,
24
+ error_code,
25
+ literal,
26
+ open_connection,
27
+ )
28
+ from .connection import Connection
29
+ from .credentials import CredentialSource, Explicit, ResolvedCredentials
30
+ from .errors import (
31
+ AccessDeniedError,
32
+ ConfigurationError,
33
+ CredentialsAbsentError,
34
+ CredentialsUnobtainableError,
35
+ DatabaseError,
36
+ DatabaseUnreachableError,
37
+ InvalidIdentifierError,
38
+ MissingDependencyError,
39
+ ProvisioningError,
40
+ RefusedError,
41
+ )
42
+ from .identifiers import (
43
+ grant_pattern,
44
+ quote_grant_database,
45
+ quote_identifier,
46
+ validate_charset,
47
+ validate_user_name,
48
+ )
49
+ from .inspection import inspect
50
+ from .server import Privileges, ServerInfo, account_privileges, detect_server, grantee
51
+ from .state import ConnectionState
52
+ from .verdict import Code, Finding, Verdict
53
+
54
+ WORDPRESS_PRIVILEGES = frozenset(
55
+ {"SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX"}
56
+ )
57
+ #: GRANT ALL needs the grantor to hold what it grants, not only GRANT OPTION.
58
+ ENSURE_PRIVILEGES = (
59
+ "CREATE",
60
+ "CREATE USER",
61
+ "GRANT OPTION",
62
+ *sorted(WORDPRESS_PRIVILEGES - {"CREATE"}),
63
+ )
64
+ #: Reading ``mysql.user`` needs SELECT on ``*.*``; a database-level SELECT is not enough.
65
+ ENSURE_GLOBAL_PRIVILEGES = ("SELECT",)
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class EnsureFacts:
70
+ """What already exists, as the administrator sees it."""
71
+
72
+ database_exists: bool
73
+ user_exists: bool
74
+ password_works: bool
75
+ grant_present: bool
76
+ #: Whether this machine matches the account's host, so a login proves anything.
77
+ verifiable: bool = True
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class EnsureResult:
82
+ """What :func:`ensure` changed. Each flag is true only if it changed something."""
83
+
84
+ created_database: bool
85
+ created_user: bool
86
+ password_set: bool
87
+ granted: bool
88
+ verdict: Verdict
89
+
90
+ @property
91
+ def changed(self) -> bool:
92
+ return self.created_database or self.created_user or self.password_set or self.granted
93
+
94
+
95
+ def plan_ensure(facts: EnsureFacts) -> tuple[str, ...]:
96
+ """The steps that converge the server on the desired state, in order."""
97
+ steps: list[str] = []
98
+ if not facts.database_exists:
99
+ steps.append("create_database")
100
+ if not facts.user_exists:
101
+ steps.append("create_user")
102
+ elif not facts.password_works:
103
+ steps.append("set_password")
104
+ if not facts.grant_present:
105
+ steps.append("grant")
106
+ return tuple(steps)
107
+
108
+
109
+ def _privilege_findings(
110
+ privileges: Privileges,
111
+ required: tuple[str, ...],
112
+ global_required: tuple[str, ...] = (),
113
+ ) -> list[Finding]:
114
+ if privileges.unverified:
115
+ return [
116
+ Finding(
117
+ Code.PRIVILEGES_UNVERIFIED,
118
+ "the administrator has an active role; its privileges cannot be "
119
+ "checked in advance, so the operation will be attempted",
120
+ )
121
+ ]
122
+ missing = [
123
+ Finding(
124
+ Code.MISSING_PRIVILEGE,
125
+ f"the administrator lacks {name}",
126
+ {"privilege": name},
127
+ )
128
+ for name in required
129
+ if not privileges.has(name)
130
+ ]
131
+ missing += [
132
+ Finding(
133
+ Code.MISSING_PRIVILEGE,
134
+ f"the administrator lacks {name} on *.*",
135
+ {"privilege": name, "scope": "global"},
136
+ )
137
+ for name in global_required
138
+ if not privileges.has_global(name)
139
+ ]
140
+ return missing
141
+
142
+
143
+ def _as_package_error(
144
+ exc: pymysql.err.MySQLError, fallback: type[DatabaseError], message: str | None = None
145
+ ) -> DatabaseError:
146
+ """Translate a server error raised mid-session into a package-owned one."""
147
+ if error_code(exc) in (ER_TABLEACCESS_DENIED, ER_DBACCESS_DENIED):
148
+ return AccessDeniedError(f"the administrator lacks a privilege this operation needs: {exc}")
149
+ return fallback(message or f"the server failed during the operation: {exc}")
150
+
151
+
152
+ def _resolve_or_finding(source: CredentialSource) -> ResolvedCredentials | Finding:
153
+ """Resolve a source, or say why it could not be, as a finding.
154
+
155
+ Every failure a source can have is a verdict, never an exception: an absent
156
+ or failed source, a config file that cannot supply the pair, and a missing
157
+ extra.
158
+ """
159
+ try:
160
+ return source.resolve()
161
+ except MissingDependencyError as exc:
162
+ return Finding(Code.MISSING_DEPENDENCY, str(exc), {"extra": exc.extra})
163
+ except (CredentialsUnobtainableError, CredentialsAbsentError, ConfigurationError) as exc:
164
+ return Finding(Code.CREDENTIALS_UNOBTAINABLE, str(exc))
165
+
166
+
167
+ def _preflight(
168
+ connection: Connection,
169
+ admin: CredentialSource,
170
+ operation_name: str,
171
+ required: tuple[str, ...],
172
+ global_required: tuple[str, ...] = (),
173
+ ) -> tuple[Verdict, Any | None]:
174
+ """Resolve the account, then :func:`_preflight_resolved`."""
175
+ resolved = _resolve_or_finding(admin)
176
+ if isinstance(resolved, Finding):
177
+ return Verdict(operation_name, (resolved,)), None
178
+ return _preflight_resolved(connection, resolved, operation_name, required, global_required)
179
+
180
+
181
+ def _preflight_resolved(
182
+ connection: Connection,
183
+ resolved: ResolvedCredentials,
184
+ operation_name: str,
185
+ required: tuple[str, ...],
186
+ global_required: tuple[str, ...] = (),
187
+ ) -> tuple[Verdict, Any | None]:
188
+ """Connect as this account and check server and privileges.
189
+
190
+ Returns the verdict and, only when it holds no blocker, the open session.
191
+ """
192
+ from .errors import AuthPluginRefusedError
193
+
194
+ verdict = Verdict(operation_name)
195
+ try:
196
+ session = open_connection(connection, resolved, database=None)
197
+ except AuthPluginRefusedError:
198
+ raise
199
+ except Exception as exc: # noqa: BLE001 - SSLError and driver errors both map here
200
+ state = classify(exc)
201
+ if state is ConnectionState.AUTHENTICATION_FAILED:
202
+ finding = Finding(
203
+ Code.AUTHENTICATION_FAILED,
204
+ f"the administrator account {resolved.username!r} was rejected",
205
+ )
206
+ elif state is ConnectionState.TLS_FAILED:
207
+ finding = Finding(
208
+ Code.UNREACHABLE,
209
+ f"{connection.host}:{connection.port} TLS failed",
210
+ {"host": connection.host, "port": connection.port, "cause": "tls"},
211
+ )
212
+ else:
213
+ finding = Finding(
214
+ Code.UNREACHABLE,
215
+ f"{connection.host}:{connection.port} could not be reached",
216
+ {"host": connection.host, "port": connection.port},
217
+ )
218
+ return verdict.with_findings(finding), None
219
+
220
+ try:
221
+ with session.cursor() as cur:
222
+ findings: list[Finding] = _server_findings(detect_server(cur))
223
+ findings += _privilege_findings(
224
+ account_privileges(cur, connection.database), required, global_required
225
+ )
226
+ verdict = verdict.with_findings(*findings)
227
+ except pymysql.err.MySQLError as exc:
228
+ session.close()
229
+ raise _as_package_error(exc, DatabaseUnreachableError) from exc
230
+ except BaseException:
231
+ session.close()
232
+ raise
233
+ if not verdict.ok:
234
+ session.close()
235
+ return verdict, None
236
+ return verdict, session
237
+
238
+
239
+ def _server_findings(server: ServerInfo) -> list[Finding]:
240
+ """A server below the floor is a blocker; one past upstream end of life, a warning."""
241
+ if not server.is_supported:
242
+ return [
243
+ Finding(
244
+ Code.SERVER_UNSUPPORTED,
245
+ f"{server.family} {server.version_text} is below the supported floor",
246
+ {"version": server.version_text},
247
+ )
248
+ ]
249
+ if server.is_end_of_life:
250
+ return [
251
+ Finding(
252
+ Code.END_OF_LIFE_SERVER,
253
+ f"{server.family} {server.version_text} is past upstream end of life",
254
+ {"version": server.version_text},
255
+ )
256
+ ]
257
+ return []
258
+
259
+
260
+ def _ensure_facts(
261
+ cur: Any, connection: Connection, wordpress: ResolvedCredentials, user_host: str
262
+ ) -> EnsureFacts:
263
+ database = connection.database
264
+ cur.execute(
265
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (database,)
266
+ )
267
+ database_exists = int(cur.fetchone()[0]) > 0
268
+ cur.execute(
269
+ "SELECT COUNT(*) FROM mysql.user WHERE User = %s AND Host = %s",
270
+ (wordpress.username, user_host),
271
+ )
272
+ user_exists = int(cur.fetchone()[0]) > 0
273
+ # A login from here only says something about the account if the server
274
+ # would match this machine to user_host; ask the server, which applies its
275
+ # own host-pattern rules.
276
+ cur.execute("SELECT SUBSTRING_INDEX(USER(), '@', -1) LIKE %s", (user_host,))
277
+ verifiable = bool(cur.fetchone()[0])
278
+ password_works = not verifiable # unverifiable: leave an existing password alone
279
+ if user_exists and verifiable:
280
+ try:
281
+ open_connection(connection, wordpress, database=None).close()
282
+ password_works = True
283
+ except pymysql.err.MySQLError as exc:
284
+ if classify(exc) is not ConnectionState.AUTHENTICATION_FAILED:
285
+ msg = f"could not test {wordpress.username!r}'s password"
286
+ raise ProvisioningError(msg) from exc
287
+ cur.execute(
288
+ "SELECT PRIVILEGE_TYPE FROM information_schema.SCHEMA_PRIVILEGES "
289
+ "WHERE GRANTEE = %s AND TABLE_SCHEMA = %s",
290
+ (grantee(wordpress.username, user_host), grant_pattern(database)),
291
+ )
292
+ held = frozenset(str(r[0]) for r in cur.fetchall())
293
+ return EnsureFacts(
294
+ database_exists, user_exists, password_works, held >= WORDPRESS_PRIVILEGES, verifiable
295
+ )
296
+
297
+
298
+ def check_ensure(
299
+ connection: Connection,
300
+ admin: CredentialSource,
301
+ wordpress: CredentialSource,
302
+ *,
303
+ user_host: str = "%", # noqa: ARG001 - kept so check_ensure mirrors ensure's signature
304
+ ) -> Verdict:
305
+ """What :func:`ensure` would find, without changing anything."""
306
+ with operation("check_ensure", connection) as fields:
307
+ verdict, session = _preflight(
308
+ connection, admin, "ensure", ENSURE_PRIVILEGES, ENSURE_GLOBAL_PRIVILEGES
309
+ )
310
+ if session is not None:
311
+ session.close()
312
+ wp = _resolve_or_finding(wordpress)
313
+ if isinstance(wp, Finding):
314
+ return _note(fields, verdict.with_findings(wp))
315
+ validate_user_name(wp.username)
316
+ return _note(fields, verdict)
317
+
318
+
319
+ def ensure(
320
+ connection: Connection,
321
+ admin: CredentialSource,
322
+ wordpress: CredentialSource,
323
+ *,
324
+ user_host: str = "%",
325
+ charset: str = "utf8mb4",
326
+ collation: str = "utf8mb4_unicode_ci",
327
+ ) -> EnsureResult:
328
+ """Converge the server on: database exists, account exists with this password,
329
+ account holds ALL on this database only.
330
+
331
+ Raises:
332
+ RefusedError: the check found a blocker; nothing ran.
333
+ ProvisioningError: a statement failed, or the account still cannot use
334
+ the database afterwards; ``completed`` lists the steps that ran.
335
+ """
336
+ validate_charset(charset, "charset")
337
+ validate_charset(collation, "collation")
338
+ with operation("ensure", connection) as fields:
339
+ verdict, session = _preflight(
340
+ connection, admin, "ensure", ENSURE_PRIVILEGES, ENSURE_GLOBAL_PRIVILEGES
341
+ )
342
+ wp = _resolve_or_finding(wordpress)
343
+ if isinstance(wp, Finding):
344
+ if session is not None:
345
+ session.close()
346
+ raise RefusedError(verdict.with_findings(wp))
347
+ try:
348
+ # Validated before the session check, exactly as check_ensure does,
349
+ # so both raise the same error for a bad name.
350
+ username = validate_user_name(wp.username)
351
+ except InvalidIdentifierError:
352
+ if session is not None:
353
+ session.close()
354
+ raise
355
+ if session is None:
356
+ raise RefusedError(verdict)
357
+ database = connection.database
358
+ statements = {
359
+ "create_database": (
360
+ f"CREATE DATABASE {quote_identifier(database)} "
361
+ f"CHARACTER SET {charset} COLLATE {collation}",
362
+ None,
363
+ ),
364
+ "create_user": (
365
+ "CREATE USER %s@%s IDENTIFIED BY %s",
366
+ (username, user_host, wp.password),
367
+ ),
368
+ "set_password": (
369
+ "ALTER USER %s@%s IDENTIFIED BY %s",
370
+ (username, user_host, wp.password),
371
+ ),
372
+ "grant": (
373
+ f"GRANT ALL PRIVILEGES ON {literal(quote_grant_database(database))}.* TO %s@%s",
374
+ (username, user_host),
375
+ ),
376
+ }
377
+ completed: list[str] = []
378
+ with session, session.cursor() as cur:
379
+ try:
380
+ facts = _ensure_facts(cur, connection, wp, user_host)
381
+ steps = plan_ensure(facts)
382
+ except pymysql.err.MySQLError as exc:
383
+ raise _as_package_error(exc, ProvisioningError) from exc
384
+ try:
385
+ for step in steps:
386
+ sql, args = statements[step]
387
+ cur.execute(sql, args)
388
+ completed.append(step)
389
+ session.commit()
390
+ except pymysql.err.MySQLError as exc:
391
+ msg = f"ensuring {database!r} failed after {len(completed)} step(s)"
392
+ raise ProvisioningError(msg, completed=tuple(completed)) from exc
393
+
394
+ if facts.verifiable:
395
+ final = inspect(connection, Explicit(wp.username, wp.password))
396
+ if not final.is_ready:
397
+ msg = f"ensure ran but {database!r} is still not usable by {username!r}: {final}"
398
+ raise ProvisioningError(msg, completed=tuple(completed))
399
+ else:
400
+ verdict = verdict.with_findings(
401
+ Finding(
402
+ Code.PASSWORD_UNVERIFIED,
403
+ f"{username!r}@{user_host!r} does not match this machine, so the result "
404
+ "was not verified by logging in and an existing password was left unchanged",
405
+ {"user_host": user_host},
406
+ )
407
+ )
408
+
409
+ result = EnsureResult(
410
+ created_database="create_database" in completed,
411
+ created_user="create_user" in completed,
412
+ password_set="create_user" in completed or "set_password" in completed,
413
+ granted="grant" in completed,
414
+ verdict=verdict,
415
+ )
416
+ fields[VERDICT_KEY] = verdict
417
+ fields.update(
418
+ created_database=result.created_database,
419
+ created_user=result.created_user,
420
+ password_set=result.password_set,
421
+ granted=result.granted,
422
+ )
423
+ return result
424
+
425
+
426
+ @dataclass(frozen=True, slots=True)
427
+ class OperationResult:
428
+ """Whether an operation changed anything, and what its check found."""
429
+
430
+ changed: bool
431
+ verdict: Verdict
432
+
433
+
434
+ #: Reading mysql.user to answer "does this account exist?" needs global SELECT.
435
+ _ACCOUNT_LOOKUP = ("SELECT",)
436
+
437
+
438
+ def _confirmation(target: str, confirm: str) -> list[Finding]:
439
+ if confirm == target:
440
+ return []
441
+ return [
442
+ Finding(
443
+ Code.CONFIRMATION_MISMATCH,
444
+ f"confirm={confirm!r} does not match the target {target!r}",
445
+ {"target": target, "confirm": confirm},
446
+ )
447
+ ]
448
+
449
+
450
+ def _user_exists(cur: Any, user: str, user_host: str) -> bool:
451
+ cur.execute("SELECT COUNT(*) FROM mysql.user WHERE User = %s AND Host = %s", (user, user_host))
452
+ return int(cur.fetchone()[0]) > 0
453
+
454
+
455
+ def _looks_live(cur: Any, database: str) -> bool:
456
+ """Whether the database holds a WordPress options table with a schema version."""
457
+ cur.execute(
458
+ "SELECT TABLE_NAME FROM information_schema.TABLES "
459
+ "WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s",
460
+ (database, "%options"),
461
+ )
462
+ for (table,) in cur.fetchall():
463
+ try:
464
+ cur.execute(
465
+ f"SELECT COUNT(*) FROM {literal(quote_identifier(database))}" # noqa: S608 - quoted identifiers
466
+ f".{literal(quote_identifier(table))} "
467
+ "WHERE option_name = %s",
468
+ ("db_version",),
469
+ )
470
+ except pymysql.err.MySQLError:
471
+ continue # an "...options" table that is not WordPress's
472
+ if int(cur.fetchone()[0]) > 0:
473
+ return True
474
+ return False
475
+
476
+
477
+ def _guarded(
478
+ connection: Connection,
479
+ admin: CredentialSource,
480
+ name: str,
481
+ required: tuple[str, ...],
482
+ extra: list[Finding],
483
+ *,
484
+ user: tuple[str, str] | None = None,
485
+ live_check: bool = False,
486
+ ) -> tuple[Verdict, Any | None]:
487
+ """The preflight, plus confirmation, account existence and the live-site warning.
488
+
489
+ Returns the verdict and, only when it holds no blocker, the open session.
490
+ """
491
+ verdict, session = _preflight(
492
+ connection, admin, name, required, _ACCOUNT_LOOKUP if user else ()
493
+ )
494
+ verdict = verdict.with_findings(*extra)
495
+ if session is None:
496
+ return verdict, None
497
+ try:
498
+ with session.cursor() as cur:
499
+ if user is not None and not _user_exists(cur, *user):
500
+ verdict = verdict.with_findings(
501
+ Finding(Code.USER_ABSENT, f"{user[0]!r}@{user[1]!r} does not exist")
502
+ )
503
+ if live_check and _looks_live(cur, connection.database):
504
+ verdict = verdict.with_findings(
505
+ Finding(
506
+ Code.LIVE_WORDPRESS_SITE,
507
+ f"{connection.database!r} holds a WordPress options table with a "
508
+ "schema version",
509
+ )
510
+ )
511
+ except pymysql.err.MySQLError as exc:
512
+ session.close()
513
+ raise _as_package_error(exc, DatabaseUnreachableError) from exc
514
+ except BaseException:
515
+ session.close()
516
+ raise
517
+ if not verdict.ok:
518
+ session.close()
519
+ return verdict, None
520
+ return verdict, session
521
+
522
+
523
+ def _note(fields: dict[str, object], verdict: Verdict) -> Verdict:
524
+ """Hand a check's verdict to the operation wrapper, which logs its warnings."""
525
+ fields[VERDICT_KEY] = verdict
526
+ return verdict
527
+
528
+
529
+ def _check(verdict_and_session: tuple[Verdict, Any | None]) -> Verdict:
530
+ verdict, session = verdict_and_session
531
+ if session is not None:
532
+ session.close()
533
+ return verdict
534
+
535
+
536
+ def _rotate_guard(
537
+ connection: Connection, admin: CredentialSource, username: str, user_host: str
538
+ ) -> tuple[Verdict, Any | None]:
539
+ return _guarded(
540
+ connection, admin, "rotate_password", ("CREATE USER",), [], user=(username, user_host)
541
+ )
542
+
543
+
544
+ def check_rotate_password(
545
+ connection: Connection,
546
+ admin: CredentialSource,
547
+ wordpress: CredentialSource,
548
+ *,
549
+ user_host: str = "%",
550
+ ) -> Verdict:
551
+ """What :func:`rotate_password` would find, without changing anything."""
552
+ with operation("check_rotate_password", connection) as fields:
553
+ wp = _resolve_or_finding(wordpress)
554
+ if isinstance(wp, Finding):
555
+ return _note(fields, Verdict("rotate_password", (wp,)))
556
+ username = validate_user_name(wp.username)
557
+ return _note(fields, _check(_rotate_guard(connection, admin, username, user_host)))
558
+
559
+
560
+ def rotate_password(
561
+ connection: Connection,
562
+ admin: CredentialSource,
563
+ wordpress: CredentialSource,
564
+ *,
565
+ user_host: str = "%",
566
+ ) -> OperationResult:
567
+ """Set the account's password to the one ``wordpress`` resolves to."""
568
+ with operation("rotate_password", connection) as fields:
569
+ wp = _resolve_or_finding(wordpress)
570
+ if isinstance(wp, Finding):
571
+ raise RefusedError(Verdict("rotate_password", (wp,)))
572
+ username = validate_user_name(wp.username)
573
+ verdict, session = _rotate_guard(connection, admin, username, user_host)
574
+ if session is None:
575
+ raise RefusedError(verdict)
576
+ with session, session.cursor() as cur:
577
+ try:
578
+ cur.execute("ALTER USER %s@%s IDENTIFIED BY %s", (username, user_host, wp.password))
579
+ session.commit()
580
+ except pymysql.err.MySQLError as exc:
581
+ msg = f"setting the password for {username!r} failed"
582
+ raise ProvisioningError(msg) from exc
583
+ fields["changed"] = True
584
+ fields[VERDICT_KEY] = verdict
585
+ return OperationResult(True, verdict)
586
+
587
+
588
+ def _revoke_guard(
589
+ connection: Connection, admin: CredentialSource, user: str, user_host: str, confirm: str
590
+ ) -> tuple[Verdict, Any | None]:
591
+ return _guarded(
592
+ connection,
593
+ admin,
594
+ "revoke",
595
+ ("GRANT OPTION",),
596
+ _confirmation(user, confirm),
597
+ user=(user, user_host),
598
+ )
599
+
600
+
601
+ def check_revoke(
602
+ connection: Connection,
603
+ admin: CredentialSource,
604
+ user: str,
605
+ *,
606
+ user_host: str = "%",
607
+ confirm: str,
608
+ ) -> Verdict:
609
+ """What :func:`revoke` would find, without changing anything."""
610
+ with operation("check_revoke", connection) as fields:
611
+ validate_user_name(user)
612
+ return _note(fields, _check(_revoke_guard(connection, admin, user, user_host, confirm)))
613
+
614
+
615
+ def revoke(
616
+ connection: Connection,
617
+ admin: CredentialSource,
618
+ user: str,
619
+ *,
620
+ user_host: str = "%",
621
+ confirm: str,
622
+ ) -> OperationResult:
623
+ """Revoke the account's privileges on this database only.
624
+
625
+ An account holding no grant here is ``changed=False``.
626
+ """
627
+ with operation("revoke", connection) as fields:
628
+ validate_user_name(user)
629
+ verdict, session = _revoke_guard(connection, admin, user, user_host, confirm)
630
+ if session is None:
631
+ raise RefusedError(verdict)
632
+ # The escaped pattern is what ensure grants; the unescaped name is what
633
+ # the 0.0.x package granted, and it also matched neighbouring names.
634
+ targets = dict.fromkeys(
635
+ [quote_grant_database(connection.database), quote_identifier(connection.database)]
636
+ )
637
+ changed = False
638
+ with session, session.cursor() as cur:
639
+ for target in targets:
640
+ try:
641
+ cur.execute(
642
+ f"REVOKE ALL PRIVILEGES ON {literal(target)}.* FROM %s@%s", # noqa: S608 - quoted identifier
643
+ (user, user_host),
644
+ )
645
+ except pymysql.err.MySQLError as exc:
646
+ if error_code(exc) != ER_NONEXISTING_GRANT:
647
+ msg = f"revoking {user!r} on {connection.database!r} failed"
648
+ raise _as_package_error(exc, ProvisioningError, msg) from exc
649
+ else:
650
+ changed = True
651
+ session.commit()
652
+ fields["changed"] = changed
653
+ fields[VERDICT_KEY] = verdict
654
+ return OperationResult(changed, verdict)
655
+
656
+
657
+ def _drop_user_guard(
658
+ connection: Connection, admin: CredentialSource, user: str, confirm: str
659
+ ) -> tuple[Verdict, Any | None]:
660
+ return _guarded(connection, admin, "drop_user", ("CREATE USER",), _confirmation(user, confirm))
661
+
662
+
663
+ def check_drop_user(
664
+ connection: Connection,
665
+ admin: CredentialSource,
666
+ user: str,
667
+ *,
668
+ user_host: str = "%", # noqa: ARG001 - kept so check_drop_user mirrors drop_user's signature
669
+ confirm: str,
670
+ ) -> Verdict:
671
+ """What :func:`drop_user` would find, without changing anything."""
672
+ with operation("check_drop_user", connection) as fields:
673
+ validate_user_name(user)
674
+ return _note(fields, _check(_drop_user_guard(connection, admin, user, confirm)))
675
+
676
+
677
+ def drop_user(
678
+ connection: Connection,
679
+ admin: CredentialSource,
680
+ user: str,
681
+ *,
682
+ user_host: str = "%",
683
+ confirm: str,
684
+ ) -> OperationResult:
685
+ """Drop the account. An absent account is ``changed=False``."""
686
+ with operation("drop_user", connection) as fields:
687
+ validate_user_name(user)
688
+ verdict, session = _drop_user_guard(connection, admin, user, confirm)
689
+ if session is None:
690
+ raise RefusedError(verdict)
691
+ with session, session.cursor() as cur:
692
+ # Existence is decided by the DROP itself (1396 = no such account), so
693
+ # the run needs only CREATE USER, exactly what the check verified.
694
+ existed = True
695
+ try:
696
+ cur.execute("DROP USER %s@%s", (user, user_host))
697
+ session.commit()
698
+ except pymysql.err.MySQLError as exc:
699
+ if error_code(exc) != ER_CANNOT_USER:
700
+ msg = f"dropping {user!r} failed"
701
+ raise _as_package_error(exc, ProvisioningError, msg) from exc
702
+ existed = False
703
+ fields["changed"] = existed
704
+ fields[VERDICT_KEY] = verdict
705
+ return OperationResult(existed, verdict)
706
+
707
+
708
+ def _drop_database_guard(
709
+ connection: Connection, admin: CredentialSource, confirm: str
710
+ ) -> tuple[Verdict, Any | None]:
711
+ return _guarded(
712
+ connection,
713
+ admin,
714
+ "drop_database",
715
+ ("DROP",),
716
+ _confirmation(connection.database, confirm),
717
+ live_check=True,
718
+ )
719
+
720
+
721
+ def check_drop_database(
722
+ connection: Connection, admin: CredentialSource, *, confirm: str
723
+ ) -> Verdict:
724
+ """What :func:`drop_database` would find, without changing anything."""
725
+ with operation("check_drop_database", connection) as fields:
726
+ return _note(fields, _check(_drop_database_guard(connection, admin, confirm)))
727
+
728
+
729
+ def drop_database(
730
+ connection: Connection, admin: CredentialSource, *, confirm: str
731
+ ) -> OperationResult:
732
+ """Drop the database. An absent database is ``changed=False``."""
733
+ with operation("drop_database", connection) as fields:
734
+ verdict, session = _drop_database_guard(connection, admin, confirm)
735
+ if session is None:
736
+ raise RefusedError(verdict)
737
+ with session, session.cursor() as cur:
738
+ try:
739
+ cur.execute(
740
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
741
+ (connection.database,),
742
+ )
743
+ existed = int(cur.fetchone()[0]) > 0
744
+ cur.execute(f"DROP DATABASE IF EXISTS {quote_identifier(connection.database)}")
745
+ session.commit()
746
+ except pymysql.err.MySQLError as exc:
747
+ msg = f"dropping {connection.database!r} failed"
748
+ raise _as_package_error(exc, ProvisioningError, msg) from exc
749
+ fields["changed"] = existed
750
+ fields[VERDICT_KEY] = verdict
751
+ return OperationResult(existed, verdict)