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,192 @@
1
+ """WordPress database operations, each checked before it runs.
2
+
3
+ ``l3io`` and ``l3io.wp`` are a PEP 420 namespace shared with
4
+ ``l3io-wordpress-config`` and ``l3io-wordpress-backup``; this is the only
5
+ ``__init__.py`` above this directory (AD-27).
6
+
7
+ The identifier helpers are public because AD-28 requires grant-target escaping
8
+ to go through one shared function: dependents import ``quote_grant_database``
9
+ from here rather than reimplementing it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ._client import ClientTool
15
+ from ._version import __version__
16
+ from .auth import ALLOWED_AUTH_PLUGINS, REFUSED_AUTH_PLUGINS
17
+ from .connection import DEFAULT_PORT, Connection
18
+ from .credentials import (
19
+ AwsSecretsManager,
20
+ CredentialChain,
21
+ CredentialSource,
22
+ Environment,
23
+ Explicit,
24
+ ResolvedCredentials,
25
+ )
26
+ from .encryption import (
27
+ check_passphrase,
28
+ check_recipients,
29
+ decrypt,
30
+ encrypt,
31
+ generate_identity,
32
+ )
33
+ from .errors import (
34
+ AccessDeniedError,
35
+ AuthenticationFailedError,
36
+ AuthPluginRefusedError,
37
+ ConfigUnreadableError,
38
+ ConfigurationError,
39
+ CorruptDumpError,
40
+ CredentialsAbsentError,
41
+ CredentialsUnobtainableError,
42
+ DatabaseError,
43
+ DatabaseUnreachableError,
44
+ DecryptionError,
45
+ InvalidArgumentsError,
46
+ InvalidDatabaseNameError,
47
+ InvalidIdentifierError,
48
+ MissingConfigValueError,
49
+ MissingDependencyError,
50
+ ProvisioningError,
51
+ RefusedError,
52
+ RegionNotKnownError,
53
+ TlsHandshakeError,
54
+ TlsVerificationError,
55
+ TransferError,
56
+ )
57
+ from .facade import Database
58
+ from .identifiers import (
59
+ quote_grant_database,
60
+ quote_identifier,
61
+ validate_database_name,
62
+ validate_user_name,
63
+ )
64
+ from .inspection import Survey, inspect, schema_version, survey
65
+ from .provision import (
66
+ EnsureResult,
67
+ OperationResult,
68
+ check_drop_database,
69
+ check_drop_user,
70
+ check_ensure,
71
+ check_revoke,
72
+ check_rotate_password,
73
+ drop_database,
74
+ drop_user,
75
+ ensure,
76
+ revoke,
77
+ rotate_password,
78
+ )
79
+ from .server import ServerFamily, ServerInfo
80
+ from .state import ConnectionState
81
+ from .tls import (
82
+ DEFAULT_LOCAL_HOSTS,
83
+ TlsMode,
84
+ TlsSettings,
85
+ tls_required_unless_local,
86
+ )
87
+ from .transfer import (
88
+ DropTablesResult,
89
+ DumpStream,
90
+ TransferResult,
91
+ check_drop_tables,
92
+ check_dump,
93
+ check_load,
94
+ drop_tables,
95
+ dump,
96
+ iter_dump,
97
+ load,
98
+ validate_table_prefix,
99
+ )
100
+ from .verdict import Code, Finding, Severity, Verdict
101
+ from .versions import SUPPORTED_FROM, SchemaVersion, release_for
102
+ from .wpconfig import DEFAULT_TABLE_PREFIX, WpConfigFile
103
+
104
+ __all__ = [
105
+ "ALLOWED_AUTH_PLUGINS",
106
+ "AccessDeniedError",
107
+ "AuthPluginRefusedError",
108
+ "AuthenticationFailedError",
109
+ "AwsSecretsManager",
110
+ "ClientTool",
111
+ "Code",
112
+ "ConfigUnreadableError",
113
+ "ConfigurationError",
114
+ "Connection",
115
+ "ConnectionState",
116
+ "CorruptDumpError",
117
+ "CredentialChain",
118
+ "CredentialSource",
119
+ "CredentialsAbsentError",
120
+ "CredentialsUnobtainableError",
121
+ "DEFAULT_LOCAL_HOSTS",
122
+ "DEFAULT_PORT",
123
+ "DEFAULT_TABLE_PREFIX",
124
+ "Database",
125
+ "DatabaseError",
126
+ "DatabaseUnreachableError",
127
+ "DecryptionError",
128
+ "DropTablesResult",
129
+ "DumpStream",
130
+ "EnsureResult",
131
+ "Environment",
132
+ "Explicit",
133
+ "Finding",
134
+ "InvalidArgumentsError",
135
+ "InvalidDatabaseNameError",
136
+ "InvalidIdentifierError",
137
+ "MissingConfigValueError",
138
+ "MissingDependencyError",
139
+ "OperationResult",
140
+ "ProvisioningError",
141
+ "REFUSED_AUTH_PLUGINS",
142
+ "RefusedError",
143
+ "RegionNotKnownError",
144
+ "ResolvedCredentials",
145
+ "SUPPORTED_FROM",
146
+ "SchemaVersion",
147
+ "ServerFamily",
148
+ "ServerInfo",
149
+ "Severity",
150
+ "Survey",
151
+ "TlsHandshakeError",
152
+ "TlsMode",
153
+ "TlsSettings",
154
+ "TlsVerificationError",
155
+ "TransferError",
156
+ "TransferResult",
157
+ "Verdict",
158
+ "WpConfigFile",
159
+ "__version__",
160
+ "check_drop_database",
161
+ "check_drop_tables",
162
+ "check_drop_user",
163
+ "check_dump",
164
+ "check_ensure",
165
+ "check_load",
166
+ "check_passphrase",
167
+ "check_recipients",
168
+ "check_revoke",
169
+ "check_rotate_password",
170
+ "decrypt",
171
+ "drop_database",
172
+ "drop_tables",
173
+ "drop_user",
174
+ "dump",
175
+ "encrypt",
176
+ "ensure",
177
+ "generate_identity",
178
+ "inspect",
179
+ "iter_dump",
180
+ "load",
181
+ "quote_grant_database",
182
+ "quote_identifier",
183
+ "release_for",
184
+ "revoke",
185
+ "rotate_password",
186
+ "schema_version",
187
+ "survey",
188
+ "tls_required_unless_local",
189
+ "validate_database_name",
190
+ "validate_table_prefix",
191
+ "validate_user_name",
192
+ ]
@@ -0,0 +1,10 @@
1
+ """``python -m l3io.wp.database``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,318 @@
1
+ """The MySQL and MariaDB client programs: finding them, and running them without
2
+ exposing a password. Internal.
3
+
4
+ A password never appears in an argument vector, a child's environment, or a file
5
+ on disk (spec §2.4). The client reads its options from an anonymous pipe named
6
+ ``--defaults-file=/dev/fd/N`` and placed first, which also stops it reading
7
+ ``~/.my.cnf``. MySQL clients still read ``~/.mylogin.cnf`` after that, so login
8
+ paths are suppressed: ``MYSQL_TEST_LOGIN_FILE`` points at nothing for every
9
+ client, and MySQL 8.4+ also gets ``--no-login-paths`` (probe V2; MariaDB has no
10
+ login-path file). The child environment holds nothing else that could redirect
11
+ or authenticate it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ from collections.abc import Iterator, Sequence
21
+ from contextlib import contextmanager
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from .connection import Connection
26
+ from .credentials import ResolvedCredentials
27
+ from .errors import InvalidArgumentsError
28
+ from .server import ServerFamily
29
+
30
+ PURPOSE_DUMP = "dump"
31
+ PURPOSE_LOAD = "load"
32
+
33
+ _CANDIDATES: dict[str, dict[ServerFamily, tuple[str, ...]]] = {
34
+ PURPOSE_DUMP: {
35
+ ServerFamily.MARIADB: ("mariadb-dump", "mysqldump"),
36
+ ServerFamily.MYSQL: ("mysqldump",),
37
+ },
38
+ PURPOSE_LOAD: {
39
+ ServerFamily.MARIADB: ("mariadb", "mysql"),
40
+ ServerFamily.MYSQL: ("mysql",),
41
+ },
42
+ }
43
+
44
+ _MARIADB_VERSION = re.compile(r"(?:Distrib|from) (\d+)\.(\d+)\.(\d+)-MariaDB")
45
+ _MYSQL_VERSION = re.compile(r"\bVer (\d+)\.(\d+)\.(\d+)")
46
+
47
+ #: First MariaDB client release, per series, that reads the sandbox line MariaDB
48
+ #: dumps begin with (MDEV-33727). MySQL clients read it as a comment (probe V1).
49
+ #: A series absent here and older than the newest listed is treated as unable.
50
+ _SANDBOX_SINCE: dict[tuple[int, int], int] = {
51
+ (10, 5): 25,
52
+ (10, 6): 18,
53
+ (10, 11): 8,
54
+ (11, 0): 6,
55
+ (11, 1): 5,
56
+ (11, 2): 4,
57
+ (11, 4): 2,
58
+ }
59
+
60
+ _NO_LOGIN_FILE = "/nonexistent/l3io-wordpress-database/no-login-file"
61
+
62
+ _MAX_VALUE_BYTES = 1024
63
+
64
+ #: Options a caller may add. Anything touching credentials, option files, the
65
+ #: host or an output file is refused by omission. ``--enable-cleartext-plugin``
66
+ #: is deliberately absent: the mysql client must keep cleartext auth off.
67
+ DUMP_OPTIONS = frozenset(
68
+ {
69
+ "--routines",
70
+ "--events",
71
+ "--skip-triggers",
72
+ "--ignore-table",
73
+ "--no-data",
74
+ "--skip-extended-insert",
75
+ "--where",
76
+ "--max-allowed-packet",
77
+ }
78
+ )
79
+ LOAD_OPTIONS = frozenset({"--binary-mode", "--max-allowed-packet"})
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class ClientTool:
84
+ """One client program, and what it is."""
85
+
86
+ name: str
87
+ path: str
88
+ family: ServerFamily
89
+ version: tuple[int, int, int]
90
+ version_text: str
91
+
92
+ @property
93
+ def label(self) -> str:
94
+ return f"{self.name} {self.version_text}"
95
+
96
+ @property
97
+ def understands_sandbox_line(self) -> bool:
98
+ if self.family is ServerFamily.MYSQL:
99
+ return True
100
+ series = self.version[:2]
101
+ if series > max(_SANDBOX_SINCE):
102
+ return True
103
+ since = _SANDBOX_SINCE.get(series)
104
+ return since is not None and self.version[2] >= since
105
+
106
+
107
+ def parse_client_version(name: str, path: str, text: str) -> ClientTool:
108
+ """Interpret a client program's ``--version`` output."""
109
+ match = _MARIADB_VERSION.search(text)
110
+ family = ServerFamily.MARIADB
111
+ if match is None:
112
+ match = _MYSQL_VERSION.search(text)
113
+ family = ServerFamily.MYSQL
114
+ if match is None:
115
+ msg = f"unrecognised client version {text.strip()!r}"
116
+ raise ValueError(msg)
117
+ major, minor, patch = (int(g) for g in match.groups())
118
+ return ClientTool(name, path, family, (major, minor, patch), text.strip())
119
+
120
+
121
+ def sanitized_path(raw: str | None = None) -> str:
122
+ """``PATH`` with empty and relative entries dropped (BL-E004-055).
123
+
124
+ A relative PATH entry would let a child resolve tools against the caller's
125
+ cwd. Only absolute directories remain.
126
+ """
127
+ source = os.environ.get("PATH", os.defpath) if raw is None else raw
128
+ return os.pathsep.join(
129
+ entry for entry in source.split(os.pathsep) if entry and Path(entry).is_absolute()
130
+ )
131
+
132
+
133
+ def which_absolute(name: str, *, path: str | None = None) -> str | None:
134
+ """``shutil.which`` over a sanitized PATH; refuse a non-absolute result."""
135
+ search = sanitized_path() if path is None else sanitized_path(path)
136
+ if not search:
137
+ return None
138
+ found = shutil.which(name, path=search)
139
+ if found is None or not Path(found).is_absolute():
140
+ return None
141
+ return found
142
+
143
+
144
+ def child_env() -> dict[str, str]:
145
+ """The entire environment a client program gets.
146
+
147
+ No ``HOME``, ``MYSQL_PWD``, ``MYSQL_HOST`` or ``MYSQL_TCP_PORT``: nothing
148
+ inherited can authenticate or redirect it. Credentials arrive only through
149
+ the option-file pipe. ``PATH`` is sanitized; ``LC_ALL`` is ``C``.
150
+ """
151
+ return {
152
+ "PATH": sanitized_path(),
153
+ "LC_ALL": "C",
154
+ "MYSQL_TEST_LOGIN_FILE": _NO_LOGIN_FILE,
155
+ }
156
+
157
+
158
+ def age_child_env(*, passphrase_fd: int) -> dict[str, str]:
159
+ """Minimal environment for ``age`` and plugins it may exec (BL-E004-055).
160
+
161
+ Absolute ``PATH`` only, ``LC_ALL=C``, an absolute ``HOME`` when the parent
162
+ has one (else a non-existent absolute path), and ``AGE_PASSPHRASE_FD``.
163
+ No other parent variables (cloud keys, ``L3IO_WP_BACKUP_*``, …).
164
+ """
165
+ home = os.environ.get("HOME", "")
166
+ return {
167
+ "PATH": sanitized_path(),
168
+ "HOME": home if home and Path(home).is_absolute() else "/nonexistent",
169
+ "LC_ALL": "C",
170
+ "AGE_PASSPHRASE_FD": str(passphrase_fd),
171
+ }
172
+
173
+
174
+ def candidates(purpose: str, family: ServerFamily) -> tuple[str, ...]:
175
+ """Program names to try for this server: its own family's first."""
176
+ other = ServerFamily.MYSQL if family is ServerFamily.MARIADB else ServerFamily.MARIADB
177
+ names = (*_CANDIDATES[purpose][family], *_CANDIDATES[purpose][other])
178
+ return tuple(dict.fromkeys(names))
179
+
180
+
181
+ def find_tool(purpose: str, family: ServerFamily) -> ClientTool | None:
182
+ """The best client program on ``PATH`` for this server, or ``None``."""
183
+ for name in candidates(purpose, family):
184
+ path = which_absolute(name)
185
+ if path is None:
186
+ continue
187
+ try:
188
+ done = subprocess.run( # noqa: S603 - an absolute path found on PATH, fixed argument
189
+ [path, "--version"],
190
+ capture_output=True,
191
+ text=True,
192
+ timeout=15,
193
+ env=child_env(),
194
+ check=False,
195
+ )
196
+ return parse_client_version(name, path, done.stdout or done.stderr)
197
+ except (OSError, subprocess.SubprocessError, ValueError):
198
+ continue
199
+ return None
200
+
201
+
202
+ def _quote(field: str, value: str) -> str:
203
+ """An option-file value: quoted, with backslash, quote and newline escaped (V5).
204
+
205
+ MariaDB reads an option line longer than its fixed buffer (about 4 KiB) as
206
+ two lines, quoted or not, so an overlong value would smuggle in an option of
207
+ the caller's choosing. Values are capped well below that. No message here
208
+ may carry the value, and no refusal may chain the encoder's exception, whose
209
+ ``object`` is the value itself.
210
+ """
211
+ if "\x00" in value:
212
+ msg = "a connection value cannot contain a NUL character"
213
+ raise InvalidArgumentsError(msg)
214
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
215
+ try:
216
+ size = len(escaped.encode())
217
+ except UnicodeEncodeError:
218
+ size = None
219
+ if size is None:
220
+ msg = f"the {field} is not valid UTF-8 text"
221
+ raise InvalidArgumentsError(msg)
222
+ if size > _MAX_VALUE_BYTES:
223
+ msg = f"the {field} is longer than {_MAX_VALUE_BYTES} bytes once escaped"
224
+ raise InvalidArgumentsError(msg)
225
+ return f'"{escaped}"'
226
+
227
+
228
+ def option_file_text(
229
+ credentials: ResolvedCredentials, connection: Connection, family: ServerFamily
230
+ ) -> str:
231
+ """The ``[client]`` options a program reads from the pipe.
232
+
233
+ ``family`` selects TLS option names the client accepts (MySQL ``ssl-mode``
234
+ vs MariaDB ``ssl`` / ``ssl-verify-server-cert``).
235
+ """
236
+ from .tls import option_file_lines
237
+
238
+ lines = [
239
+ "[client]",
240
+ f"user={_quote('user', credentials.username)}",
241
+ f"password={_quote('password', credentials.password)}",
242
+ f"host={_quote('host', connection.host)}",
243
+ f"port={connection.port}",
244
+ "protocol=TCP",
245
+ *option_file_lines(connection.tls, family),
246
+ ]
247
+ return "\n".join(lines) + "\n"
248
+
249
+
250
+ @contextmanager
251
+ def option_pipe(
252
+ credentials: ResolvedCredentials, connection: Connection, family: ServerFamily
253
+ ) -> Iterator[int]:
254
+ """A pipe holding the option file; yields its read end, closed on exit.
255
+
256
+ Pass the fd with ``pass_fds`` and name it ``/dev/fd/N``. Nothing touches disk.
257
+ """
258
+ data = option_file_text(credentials, connection, family).encode()
259
+ read_fd, write_fd = os.pipe()
260
+ try:
261
+ while data:
262
+ data = data[os.write(write_fd, data) :]
263
+ finally:
264
+ os.close(write_fd)
265
+ try:
266
+ yield read_fd
267
+ finally:
268
+ os.close(read_fd)
269
+
270
+
271
+ def disallowed_options(options: Sequence[str], allowed: frozenset[str]) -> list[str]:
272
+ """Caller options whose name is not on the allow-list."""
273
+ return [o for o in options if o.split("=", 1)[0] not in allowed]
274
+
275
+
276
+ def _login_path_args(tool: ClientTool) -> list[str]:
277
+ if tool.family is ServerFamily.MYSQL and tool.version >= (8, 4, 0):
278
+ return ["--no-login-paths"]
279
+ return []
280
+
281
+
282
+ def argv(tool: ClientTool, fd: int, args: Sequence[str]) -> list[str]:
283
+ """The full argument vector: the option file first, then login-path suppression."""
284
+ return [tool.path, f"--defaults-file=/dev/fd/{fd}", *_login_path_args(tool), *args]
285
+
286
+
287
+ def dump_args(
288
+ tool: ClientTool,
289
+ database: str,
290
+ options: Sequence[str],
291
+ *,
292
+ server_family: ServerFamily | None = None,
293
+ ) -> list[str]:
294
+ """Consistent, streaming, binary-safe, without CREATE DATABASE/USE (V7).
295
+
296
+ ``server_family`` is the server being dumped (not the client). MySQL
297
+ mysqldump 8.0+ queries ``information_schema.COLUMN_STATISTICS`` by default;
298
+ MariaDB has no such table, so a MySQL-client dump of a MariaDB server fails
299
+ with error 1109 unless ``--column-statistics=0`` is set. Ubuntu's
300
+ ``mysql-client`` package is often the only dump tool on PATH for MariaDB
301
+ hosts (``candidates`` falls back to ``mysqldump``).
302
+ """
303
+ args = [
304
+ "--single-transaction",
305
+ "--quick",
306
+ "--hex-blob",
307
+ "--default-character-set=utf8mb4",
308
+ "--no-tablespaces",
309
+ ]
310
+ if tool.family is ServerFamily.MYSQL:
311
+ args.append("--set-gtid-purged=OFF")
312
+ if server_family is ServerFamily.MARIADB and tool.version >= (8, 0, 0):
313
+ args.append("--column-statistics=0")
314
+ return [*args, *options, database]
315
+
316
+
317
+ def load_args(database: str, options: Sequence[str]) -> list[str]:
318
+ return ["--default-character-set=utf8mb4", f"--database={database}", *options]
@@ -0,0 +1,157 @@
1
+ """One span, one timed completion event, per public operation. Internal.
2
+
3
+ The package never configures logging. A caller that binds a Run ID with
4
+ ``structlog.contextvars`` sees it here because the caller's processor chain
5
+ merges it, not because this module does anything.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+
15
+ import structlog
16
+ from opentelemetry import trace
17
+ from opentelemetry.trace import Status, StatusCode
18
+
19
+ from .connection import Connection
20
+ from .errors import RefusedError
21
+ from .verdict import Verdict
22
+
23
+ _log = structlog.get_logger("l3io.wp.database")
24
+ _tracer = trace.get_tracer("l3io.wp.database")
25
+
26
+
27
+ def _mark_error(span: trace.Span, error: BaseException) -> None:
28
+ """Record failure on a span without message or stack (BL-E006-010).
29
+
30
+ MySQL and package errors can carry usernames, hosts or SQL. Only the
31
+ exception type is an attribute; status is ERROR with no description.
32
+ """
33
+ span.set_attribute("error.type", type(error).__name__)
34
+ span.set_status(Status(StatusCode.ERROR))
35
+
36
+
37
+ @contextmanager
38
+ def operation(name: str, connection: Connection) -> Iterator[dict[str, object]]:
39
+ """Wrap one public operation in a span and a completion event."""
40
+ fields: dict[str, object] = {}
41
+ started = time.monotonic()
42
+ with _tracer.start_as_current_span(
43
+ f"l3io.wp.database.{name}",
44
+ record_exception=False,
45
+ set_status_on_exception=False,
46
+ ) as span:
47
+ span.set_attribute("db.name", connection.database)
48
+ span.set_attribute("server.address", connection.host)
49
+ span.set_attribute("server.port", connection.port)
50
+ try:
51
+ yield fields
52
+ except RefusedError as exc:
53
+ span.set_attribute("l3io.verdict.codes", [str(f.code) for f in exc.verdict.blockers])
54
+ _mark_error(span, exc)
55
+ fields[VERDICT_KEY] = exc.verdict
56
+ _complete(name, connection, "refused", started, fields)
57
+ raise
58
+ except BaseException as exc:
59
+ _mark_error(span, exc)
60
+ _complete(name, connection, "failed", started, fields)
61
+ raise
62
+ _complete(name, connection, "succeeded", started, fields)
63
+
64
+
65
+ class Running:
66
+ """A public operation that outlives the call that began it, as a dump's stream does.
67
+
68
+ Its span is current only inside :meth:`current` and while :meth:`end` logs,
69
+ never across calls, so ending it from any thread or a finalizer leaves every
70
+ context as it was. Only the first :meth:`end` counts. Span, attributes,
71
+ completion event and outcomes are those of :func:`operation`.
72
+ """
73
+
74
+ def __init__(self, name: str, connection: Connection) -> None:
75
+ self.fields: dict[str, object] = {}
76
+ self._name = name
77
+ self._connection = connection
78
+ self._started = time.monotonic()
79
+ self._lock = threading.Lock()
80
+ self._ended = False
81
+ self._span = _tracer.start_span(f"l3io.wp.database.{name}")
82
+ self._span.set_attribute("db.name", connection.database)
83
+ self._span.set_attribute("server.address", connection.host)
84
+ self._span.set_attribute("server.port", connection.port)
85
+
86
+ @contextmanager
87
+ def current(self) -> Iterator[dict[str, object]]:
88
+ """Make the span current for a block on the calling thread."""
89
+ with trace.use_span(
90
+ self._span, end_on_exit=False, record_exception=False, set_status_on_exception=False
91
+ ):
92
+ yield self.fields
93
+
94
+ def end(self, error: BaseException | None) -> None:
95
+ """Complete the operation: succeeded, or refused or failed by ``error``."""
96
+ with self._lock:
97
+ if self._ended:
98
+ return
99
+ self._ended = True
100
+ span = self._span
101
+ if isinstance(error, RefusedError):
102
+ span.set_attribute("l3io.verdict.codes", [str(f.code) for f in error.verdict.blockers])
103
+ self.fields[VERDICT_KEY] = error.verdict
104
+ if isinstance(error, Exception):
105
+ _mark_error(span, error)
106
+ outcome = (
107
+ "succeeded"
108
+ if error is None
109
+ else "refused"
110
+ if isinstance(error, RefusedError)
111
+ else "failed"
112
+ )
113
+ try:
114
+ # Current only for the event itself, so a caller's log processor sees this span.
115
+ with trace.use_span(
116
+ span, end_on_exit=False, record_exception=False, set_status_on_exception=False
117
+ ):
118
+ _complete(self._name, self._connection, outcome, self._started, self.fields)
119
+ finally:
120
+ span.end()
121
+
122
+
123
+ #: An operation puts its Verdict in the yielded dict under this key, so the
124
+ #: warnings it found are logged once, at WARNING level, however it ends (§6).
125
+ VERDICT_KEY = "_verdict"
126
+
127
+ #: Parameter names of structlog's call chain (BoundLogger method and _proxy_to_logger).
128
+ #: A caller field with one of these names would collide when passed as a keyword.
129
+ _STRUCTLOG_PARAMETERS = frozenset({"self", "method_name"})
130
+
131
+
132
+ def _complete(
133
+ name: str, connection: Connection, outcome: str, started: float, fields: dict[str, object]
134
+ ) -> None:
135
+ # One dict, reserved keys written last: a caller field with a colliding name
136
+ # must never raise here, because this runs inside the exception handlers.
137
+ # The event name is structlog's first positional parameter, so it travels as a
138
+ # keyword too; otherwise a caller field named "event" collides with it.
139
+ verdict = fields.pop(VERDICT_KEY, None)
140
+ if isinstance(verdict, Verdict):
141
+ for finding in verdict.warnings:
142
+ _log.warning(
143
+ event=finding.message,
144
+ code=str(finding.code),
145
+ operation=name,
146
+ database=connection.database,
147
+ )
148
+ caller_fields = {k: v for k, v in fields.items() if k not in _STRUCTLOG_PARAMETERS}
149
+ event: dict[str, object] = {
150
+ **caller_fields,
151
+ "event": "operation complete",
152
+ "operation": name,
153
+ "outcome": outcome,
154
+ "duration_ms": round((time.monotonic() - started) * 1000, 1),
155
+ "database": connection.database,
156
+ }
157
+ _log.info(**event)