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,1165 @@
1
+ """Export and import, through the server's own client programs (spec §7.3).
2
+
3
+ One engine per direction. :class:`DumpStream` is the dump engine: an iterator of
4
+ chunks that raises :class:`TransferError` at the end, instead of stopping, when
5
+ the program failed, so a truncated dump is never mistaken for a whole one.
6
+ ``dump`` writes it to a stream or, atomically, to a path. ``load`` (below) peeks
7
+ the source's first bytes to detect age encryption, the dump's family and
8
+ MariaDB's sandbox line; it never parses SQL (global rule 1).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import itertools
15
+ import os
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ import uuid
20
+ import weakref
21
+ from collections.abc import Iterable, Iterator, Sequence
22
+ from contextlib import ExitStack, suppress
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import IO, Any
26
+
27
+ import pymysql
28
+
29
+ from . import _client
30
+ from ._client import ClientTool
31
+ from ._observe import VERDICT_KEY, Running, operation
32
+ from ._sql import open_connection
33
+ from .connection import Connection
34
+ from .credentials import CredentialSource, ResolvedCredentials
35
+ from .errors import (
36
+ DatabaseError,
37
+ DatabaseUnreachableError,
38
+ InvalidArgumentsError,
39
+ RefusedError,
40
+ TransferError,
41
+ )
42
+ from .identifiers import grant_pattern, quote_identifier
43
+ from .provision import _as_package_error, _confirmation, _preflight_resolved, _resolve_or_finding
44
+ from .server import ServerFamily, ServerInfo, detect_server
45
+ from .verdict import Code, Finding, Verdict
46
+
47
+ CHUNK = 64 * 1024
48
+ STDERR_CAP = 64 * 1024
49
+
50
+ #: Where the option-file pipe is named for the child; absent on Windows.
51
+ DEV_FD = Path("/dev/fd")
52
+
53
+ DUMP_PRIVILEGES = ("SELECT", "SHOW VIEW", "TRIGGER")
54
+ LOAD_PRIVILEGES = ("CREATE", "INSERT", "ALTER", "DROP", "INDEX", "REFERENCES", "CREATE VIEW")
55
+ DROP_TABLES_PRIVILEGES = ("DROP",)
56
+
57
+ #: Same character set as :func:`identifiers.validate_database_name`, applied to a
58
+ #: table-name prefix that will be LIKE-matched and then used only to select
59
+ #: already-quoted identifiers for ``DROP``.
60
+ _PREFIX_FORBIDDEN = frozenset({"\x00", "\n", "\r", "`", "/", "\\", "."})
61
+ MAX_TABLE_PREFIX = 64
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class TransferResult:
66
+ """What a dump or load moved, and what its check found."""
67
+
68
+ bytes: int
69
+ duration_ms: float
70
+ tool: str
71
+ server: ServerInfo
72
+ verdict: Verdict
73
+ dropped: tuple[str, ...] = ()
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class DropTablesResult:
78
+ """Tables matching a prefix, and what the check found."""
79
+
80
+ tables: tuple[str, ...]
81
+ verdict: Verdict
82
+
83
+
84
+ class _Stderr:
85
+ """Drains a program's stderr so it can never block, keeping the first STDERR_CAP bytes."""
86
+
87
+ def __init__(self, pipe: IO[bytes]) -> None:
88
+ self._pipe = pipe
89
+ self._kept = bytearray()
90
+ self._thread = threading.Thread(target=self._drain, daemon=True)
91
+ self._thread.start()
92
+
93
+ def _drain(self) -> None:
94
+ with self._pipe:
95
+ for block in iter(lambda: self._pipe.read(CHUNK), b""):
96
+ room = STDERR_CAP - len(self._kept)
97
+ if room > 0:
98
+ self._kept += block[:room]
99
+
100
+ def text(self, password: str) -> str:
101
+ self._thread.join(timeout=10)
102
+ text = self._kept.decode("utf-8", errors="replace")
103
+ return text.replace(password, "***") if password else text
104
+
105
+
106
+ def _start(
107
+ tool: ClientTool,
108
+ args: Sequence[str],
109
+ credentials: ResolvedCredentials,
110
+ connection: Connection,
111
+ *,
112
+ for_load: bool,
113
+ ) -> subprocess.Popen[bytes]:
114
+ """Run a client program with its options in a pipe and a minimal environment."""
115
+ with _client.option_pipe(credentials, connection, tool.family) as fd:
116
+ return subprocess.Popen( # noqa: S603 - argv built from a found tool and an allow-list
117
+ _client.argv(tool, fd, args),
118
+ stdin=subprocess.PIPE if for_load else subprocess.DEVNULL,
119
+ stdout=subprocess.DEVNULL if for_load else subprocess.PIPE,
120
+ stderr=subprocess.PIPE,
121
+ env=_client.child_env(),
122
+ pass_fds=(fd,),
123
+ close_fds=True,
124
+ )
125
+
126
+
127
+ class _DumpRun:
128
+ """A running dump's parts, held apart from its :class:`DumpStream`.
129
+
130
+ The stream's finalizer and the encryption thread reference this, never the
131
+ stream, so a stream dropped unfinished can still be collected and stopped.
132
+ """
133
+
134
+ def __init__(self, process: subprocess.Popen[bytes], verdict: Verdict, on_finish: Any) -> None:
135
+ if process.stdout is None or process.stderr is None:
136
+ msg = "a dump process needs piped stdout and stderr"
137
+ raise ValueError(msg)
138
+ self.process = process
139
+ self.verdict = verdict
140
+ self.stderr = _Stderr(process.stderr)
141
+ self.reader: IO[bytes] = process.stdout
142
+ self.thread: threading.Thread | None = None
143
+ self.error: BaseException | None = None
144
+ self.bytes = 0
145
+ self._on_finish = on_finish
146
+ self._lock = threading.Lock()
147
+
148
+ def notify(self, error: BaseException | None) -> None:
149
+ """Tell ``on_finish`` how the dump ended; only the first call counts."""
150
+ with self._lock:
151
+ callback, self._on_finish = self._on_finish, None
152
+ if callback is not None:
153
+ callback(error, self.bytes)
154
+
155
+
156
+ def _stop(run: _DumpRun) -> None:
157
+ """Kill and reap a dump's program, release its pipes and end its operation."""
158
+ try:
159
+ run.process.kill()
160
+ run.process.wait()
161
+ with suppress(OSError):
162
+ run.reader.close()
163
+ # A collection can run this on the encryption thread itself, which cannot join itself.
164
+ if run.thread is not None and run.thread is not threading.current_thread():
165
+ # Killed, the program closes the plaintext pipe; with the reader closed,
166
+ # a thread blocked writing ciphertext fails with EPIPE.
167
+ run.thread.join(timeout=10)
168
+ finally:
169
+ run.notify(TransferError("dump closed before it finished", run.verdict, None, ""))
170
+
171
+
172
+ def _start_encryption(
173
+ run: _DumpRun,
174
+ *,
175
+ recipients: Sequence[str] | None = None,
176
+ passphrase: str | None = None,
177
+ ) -> None:
178
+ """Encrypt the program's output on a thread; ``run.reader`` becomes the ciphertext."""
179
+ from . import encryption
180
+
181
+ plaintext = run.reader
182
+ read_fd, write_fd = os.pipe()
183
+ run.reader = os.fdopen(read_fd, "rb")
184
+ writer = os.fdopen(write_fd, "wb")
185
+
186
+ def encrypt() -> None:
187
+ try:
188
+ if passphrase is not None:
189
+ encryption.encrypt(plaintext, writer, passphrase=passphrase)
190
+ else:
191
+ encryption.encrypt(plaintext, writer, recipients or ())
192
+ except BaseException as exc: # noqa: BLE001 - re-raised on the consumer's thread
193
+ run.error = exc
194
+ finally:
195
+ with suppress(OSError): # flushing to a reader that was closed
196
+ writer.close()
197
+ # Unread output would otherwise block the program, and wait() with it.
198
+ plaintext.close()
199
+
200
+ run.thread = threading.Thread(target=encrypt, daemon=True)
201
+ run.thread.start()
202
+
203
+
204
+ class DumpStream:
205
+ """A dump as an iterator of byte chunks.
206
+
207
+ If the program fails or is killed, iteration raises :class:`TransferError`
208
+ at the end instead of stopping. :meth:`close` (or leaving a ``with`` block)
209
+ before the end stops the program, as does dropping the stream unfinished.
210
+ ``result`` is set once iteration finishes.
211
+ """
212
+
213
+ def __init__(
214
+ self,
215
+ process: subprocess.Popen[bytes],
216
+ *,
217
+ tool: ClientTool,
218
+ server: ServerInfo,
219
+ verdict: Verdict,
220
+ recipients: Sequence[str] | None,
221
+ password: str,
222
+ on_finish: Any = None,
223
+ passphrase: str | None = None,
224
+ ) -> None:
225
+ self._run = _DumpRun(process, verdict, on_finish)
226
+ self._tool = tool
227
+ self._server = server
228
+ self._verdict = verdict
229
+ self._password = password
230
+ self._started = time.monotonic()
231
+ self._done = False
232
+ self.result: TransferResult | None = None
233
+ self._finalizer = weakref.finalize(self, _stop, self._run)
234
+ if recipients is not None or passphrase is not None:
235
+ _start_encryption(self._run, recipients=recipients, passphrase=passphrase)
236
+
237
+ @property
238
+ def pid(self) -> int:
239
+ """The program's process id, for diagnostics."""
240
+ return self._run.process.pid
241
+
242
+ def __iter__(self) -> DumpStream:
243
+ return self
244
+
245
+ def __next__(self) -> bytes:
246
+ if self._done:
247
+ raise StopIteration
248
+ run = self._run
249
+ chunk = run.reader.read(CHUNK)
250
+ if chunk:
251
+ run.bytes += len(chunk)
252
+ return chunk
253
+ self._finish()
254
+ raise StopIteration
255
+
256
+ def _finish(self) -> None:
257
+ self._done = True
258
+ run = self._run
259
+ if run.thread is not None:
260
+ run.thread.join()
261
+ code = run.process.wait()
262
+ stderr = run.stderr.text(self._password)
263
+ run.reader.close()
264
+ self._finalizer.detach()
265
+ if code != 0 or run.error is not None:
266
+ if run.error is not None:
267
+ msg = f"dump failed: encryption: {run.error}"
268
+ else:
269
+ first = stderr.strip().splitlines()[0] if stderr.strip() else "no error output"
270
+ msg = f"dump failed: {self._tool.name} exited {code}: {first}"
271
+ # Backup often drops client stderr; keep a known cause in the message.
272
+ if "COLUMN_STATISTICS" in stderr:
273
+ msg += "; MySQL 8+ mysqldump against MariaDB needs --column-statistics=0"
274
+ error = TransferError(msg, self._verdict, code, stderr)
275
+ run.notify(error)
276
+ raise error from run.error
277
+ self.result = TransferResult(
278
+ run.bytes,
279
+ round((time.monotonic() - self._started) * 1000, 1),
280
+ self._tool.label,
281
+ self._server,
282
+ self._verdict,
283
+ )
284
+ run.notify(None)
285
+
286
+ def close(self) -> None:
287
+ """Stop the program if it is still running."""
288
+ self._done = True
289
+ self._finalizer()
290
+
291
+ def __enter__(self) -> DumpStream:
292
+ return self
293
+
294
+ def __exit__(self, *exc: object) -> None:
295
+ self.close()
296
+
297
+
298
+ @dataclass(frozen=True, slots=True)
299
+ class _Plan:
300
+ verdict: Verdict
301
+ credentials: ResolvedCredentials | None
302
+ tool: ClientTool | None
303
+ server: ServerInfo | None
304
+
305
+
306
+ def _platform_and_option_findings(options: Sequence[str], allowed: frozenset[str]) -> list[Finding]:
307
+ findings: list[Finding] = []
308
+ if not DEV_FD.is_dir():
309
+ findings.append(
310
+ Finding(Code.PLATFORM_UNSUPPORTED, "export and import need /dev/fd (Linux or macOS)")
311
+ )
312
+ findings += [
313
+ Finding(
314
+ Code.OPTION_NOT_ALLOWED,
315
+ f"{option.split('=', 1)[0]} is not an allowed option",
316
+ {"option": option.split("=", 1)[0]},
317
+ )
318
+ for option in _client.disallowed_options(options, allowed)
319
+ ]
320
+ return findings
321
+
322
+
323
+ def _tool_findings(purpose: str, server: ServerInfo) -> tuple[ClientTool | None, list[Finding]]:
324
+ tool = _client.find_tool(purpose, server.family)
325
+ if tool is None:
326
+ names = ", ".join(_client.candidates(purpose, server.family))
327
+ return None, [
328
+ Finding(Code.CLIENT_TOOL_MISSING, f"none of {names} is on PATH", {"tools": names})
329
+ ]
330
+ if tool.family is not server.family or tool.version[:2] < server.version[:2]:
331
+ return tool, [
332
+ Finding(
333
+ Code.CLIENT_SERVER_SKEW,
334
+ f"{tool.label} is older than, or a different family from, "
335
+ f"{server.family} {server.version_text}",
336
+ {"tool": tool.label, "server": server.version_text},
337
+ )
338
+ ]
339
+ return tool, []
340
+
341
+
342
+ def _dump_plan(
343
+ connection: Connection, credentials: CredentialSource, options: Sequence[str]
344
+ ) -> _Plan:
345
+ findings = _platform_and_option_findings(options, _client.DUMP_OPTIONS)
346
+ resolved = _resolve_or_finding(credentials)
347
+ if isinstance(resolved, Finding):
348
+ return _Plan(Verdict("dump", (*findings, resolved)), None, None, None)
349
+ verdict, session = _preflight_resolved(connection, resolved, "dump", DUMP_PRIVILEGES)
350
+ verdict = verdict.with_findings(*findings)
351
+ if session is None:
352
+ return _Plan(verdict, resolved, None, None)
353
+ database = connection.database
354
+ try:
355
+ with session, session.cursor() as cur:
356
+ server = detect_server(cur)
357
+ cur.execute(
358
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
359
+ (database,),
360
+ )
361
+ exists = int(cur.fetchone()[0]) > 0
362
+ stored = 0
363
+ if exists:
364
+ cur.execute(
365
+ "SELECT (SELECT COUNT(*) FROM information_schema.VIEWS"
366
+ " WHERE TABLE_SCHEMA = %s)"
367
+ " + (SELECT COUNT(*) FROM information_schema.TRIGGERS"
368
+ " WHERE TRIGGER_SCHEMA = %s)",
369
+ (database, database),
370
+ )
371
+ stored = int(cur.fetchone()[0])
372
+ except pymysql.err.MySQLError as exc:
373
+ raise _as_package_error(exc, DatabaseUnreachableError) from exc
374
+ if not exists:
375
+ verdict = verdict.with_findings(
376
+ Finding(Code.DATABASE_ABSENT, f"{database!r} does not exist")
377
+ )
378
+ elif stored:
379
+ verdict = verdict.with_findings(
380
+ Finding(
381
+ Code.STORED_OBJECTS_PRESENT,
382
+ f"{database!r} has {stored} view(s) or trigger(s); loading them elsewhere needs "
383
+ "privilege for their DEFINER, and MariaDB keeps their original text",
384
+ {"count": stored},
385
+ )
386
+ )
387
+ tool, tool_findings = _tool_findings(_client.PURPOSE_DUMP, server)
388
+ return _Plan(verdict.with_findings(*tool_findings), resolved, tool, server)
389
+
390
+
391
+ def check_dump(
392
+ connection: Connection, credentials: CredentialSource, *, options: Sequence[str] = ()
393
+ ) -> Verdict:
394
+ """What :func:`dump` would find, without running the dump."""
395
+ with operation("check_dump", connection) as fields:
396
+ verdict = _dump_plan(connection, credentials, options).verdict
397
+ fields[VERDICT_KEY] = verdict
398
+ return verdict
399
+
400
+
401
+ def iter_dump(
402
+ connection: Connection,
403
+ credentials: CredentialSource,
404
+ *,
405
+ recipients: Sequence[str] | None = None,
406
+ passphrase: str | None = None,
407
+ options: Sequence[str] = (),
408
+ ) -> DumpStream:
409
+ """The dump as a :class:`DumpStream` of byte chunks (age-encrypted if requested)."""
410
+ # The operation outlives this call: a failure here ends it at once, and
411
+ # success hands it to the stream, which ends it when the dump does.
412
+ running = Running("dump", connection)
413
+
414
+ def finish(error: BaseException | None, count: int) -> None:
415
+ running.fields["bytes"] = count
416
+ running.end(error)
417
+
418
+ try:
419
+ with running.current() as fields:
420
+ return _start_dump(
421
+ connection, credentials, recipients, passphrase, options, fields, finish
422
+ )
423
+ except BaseException as exc:
424
+ running.end(exc)
425
+ raise
426
+
427
+
428
+ def _start_dump(
429
+ connection: Connection,
430
+ credentials: CredentialSource,
431
+ recipients: Sequence[str] | None,
432
+ passphrase: str | None,
433
+ options: Sequence[str],
434
+ fields: dict[str, object],
435
+ finish: Any,
436
+ ) -> DumpStream:
437
+ if recipients is not None and passphrase is not None:
438
+ msg = "supply recipients= or passphrase=, not both"
439
+ raise InvalidArgumentsError(msg)
440
+ if recipients is not None or passphrase is not None:
441
+ from . import encryption
442
+
443
+ if passphrase is not None:
444
+ encryption.check_passphrase(passphrase)
445
+ else:
446
+ encryption.check_recipients(recipients or ())
447
+ plan = _dump_plan(connection, credentials, options)
448
+ fields[VERDICT_KEY] = plan.verdict
449
+ if not plan.verdict.ok or plan.tool is None or plan.server is None or plan.credentials is None:
450
+ raise RefusedError(plan.verdict)
451
+ tool, server, creds = plan.tool, plan.server, plan.credentials
452
+ try:
453
+ process = _start(
454
+ tool,
455
+ _client.dump_args(
456
+ tool,
457
+ connection.database,
458
+ options,
459
+ server_family=server.family,
460
+ ),
461
+ creds,
462
+ connection,
463
+ for_load=False,
464
+ )
465
+ except OSError as exc:
466
+ msg = f"could not start {plan.tool.path}: {exc.strerror or exc}"
467
+ raise TransferError(msg, plan.verdict, None, "") from exc
468
+ try:
469
+ return DumpStream(
470
+ process,
471
+ tool=tool,
472
+ server=server,
473
+ verdict=plan.verdict,
474
+ recipients=recipients,
475
+ password=creds.password,
476
+ on_finish=finish,
477
+ passphrase=passphrase,
478
+ )
479
+ except BaseException:
480
+ _abandon(process)
481
+ raise
482
+
483
+
484
+ @dataclass(frozen=True, slots=True)
485
+ class _Partial:
486
+ """A private temporary file beside ``target``, renamed onto it on success."""
487
+
488
+ target: Path
489
+ path: Path
490
+ file: IO[bytes]
491
+
492
+ def discard(self) -> None:
493
+ self.file.close()
494
+ self.path.unlink(missing_ok=True)
495
+
496
+
497
+ def _open_partial(target: Path) -> _Partial:
498
+ path = target.with_name(f".{target.name}.{uuid.uuid4().hex}.partial")
499
+ try:
500
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
501
+ except OSError as exc:
502
+ msg = f"cannot write the dump to {str(target)!r}: {exc.strerror}"
503
+ raise InvalidArgumentsError(msg) from exc
504
+ return _Partial(target, path, os.fdopen(fd, "wb"))
505
+
506
+
507
+ def _write_failed(stream: DumpStream, where: str, exc: OSError) -> TransferError:
508
+ msg = f"writing the dump to {where} failed: {exc.strerror or exc}"
509
+ return TransferError(msg, stream._verdict, None, "")
510
+
511
+
512
+ def _write_atomically(stream: DumpStream, partial: _Partial) -> None:
513
+ """Write a stream into ``partial`` and rename it onto its target on success."""
514
+ try:
515
+ with partial.file as out:
516
+ for chunk in stream:
517
+ out.write(chunk)
518
+ out.flush()
519
+ os.fsync(out.fileno())
520
+ partial.path.replace(partial.target)
521
+ except BaseException as exc:
522
+ stream.close()
523
+ partial.discard()
524
+ if isinstance(exc, OSError):
525
+ raise _write_failed(stream, repr(str(partial.target)), exc) from exc
526
+ raise
527
+
528
+
529
+ def dump(
530
+ connection: Connection,
531
+ credentials: CredentialSource,
532
+ to: IO[bytes] | str | os.PathLike[str],
533
+ *,
534
+ recipients: Sequence[str] | None = None,
535
+ passphrase: str | None = None,
536
+ options: Sequence[str] = (),
537
+ ) -> TransferResult:
538
+ """Dump to a writable binary stream, or atomically to a path (mode 0600)."""
539
+ if isinstance(to, io.TextIOBase):
540
+ msg = "dump writes bytes: pass a binary stream (sys.stdout.buffer, a file opened 'wb')"
541
+ raise InvalidArgumentsError(msg)
542
+ if isinstance(to, (str, os.PathLike)):
543
+ partial = _open_partial(Path(to))
544
+ try:
545
+ stream = iter_dump(
546
+ connection,
547
+ credentials,
548
+ recipients=recipients,
549
+ passphrase=passphrase,
550
+ options=options,
551
+ )
552
+ except BaseException:
553
+ partial.discard()
554
+ raise
555
+ _write_atomically(stream, partial)
556
+ else:
557
+ stream = iter_dump(
558
+ connection,
559
+ credentials,
560
+ recipients=recipients,
561
+ passphrase=passphrase,
562
+ options=options,
563
+ )
564
+ with stream:
565
+ try:
566
+ for chunk in stream:
567
+ to.write(chunk)
568
+ # Unflushed, the last chunk could still be lost after success is reported.
569
+ to.flush()
570
+ except OSError as exc:
571
+ stream.close()
572
+ raise _write_failed(stream, "the output stream", exc) from exc
573
+ if stream.result is None: # pragma: no cover - iteration always sets it or raises
574
+ msg = "dump finished without a result"
575
+ raise TransferError(msg, Verdict("dump"), None, "")
576
+ return stream.result
577
+
578
+
579
+ SANDBOX_LINE = b"/*M!999999\\- enable the sandbox mode */"
580
+
581
+
582
+ @dataclass(frozen=True, slots=True)
583
+ class _Header:
584
+ """What the first lines of a source say, compared against fixed strings only."""
585
+
586
+ encrypted: bool
587
+ family: ServerFamily | None
588
+ sandboxed: bool
589
+ #: Encrypted, and no decryption secret was supplied, so the plaintext is unknown.
590
+ needs_identity: bool = False
591
+
592
+
593
+ #: Written by both families' tools a few lines below their banner, naming the source server.
594
+ SERVER_VERSION_LINE = b"-- Server version\t"
595
+
596
+
597
+ def _family_of(line: bytes) -> ServerFamily:
598
+ return ServerFamily.MARIADB if b"MariaDB" in line else ServerFamily.MYSQL
599
+
600
+
601
+ def _header(plain_head: bytes | None, *, encrypted: bool) -> _Header:
602
+ if not plain_head:
603
+ return _Header(encrypted, None, False)
604
+ lines = plain_head.split(b"\n", 8)[:8]
605
+ sandboxed = lines[0].startswith(SANDBOX_LINE)
606
+ banner: ServerFamily | None = None
607
+ server: ServerFamily | None = None
608
+ for line in lines:
609
+ if banner is None and line.startswith((b"-- MariaDB dump", b"-- MySQL dump")):
610
+ banner = _family_of(line)
611
+ elif banner is not None and line.startswith(SERVER_VERSION_LINE):
612
+ # Either family's tool can dump the other's server; the server decides.
613
+ server = _family_of(line)
614
+ break
615
+ return _Header(encrypted, server or banner, sandboxed)
616
+
617
+
618
+ def _not_bytes(block: object) -> InvalidArgumentsError:
619
+ kind = type(block).__name__
620
+ return InvalidArgumentsError(f"the dump source must yield bytes, not {kind}")
621
+
622
+
623
+ class _IterReader(io.RawIOBase):
624
+ def __init__(self, chunks: Iterable[bytes]) -> None:
625
+ self._chunks: Iterator[bytes] = iter(chunks)
626
+ # A view, so taking a piece of a large chunk never copies the rest of it.
627
+ self._pending = memoryview(b"")
628
+
629
+ def readable(self) -> bool:
630
+ return True
631
+
632
+ def readinto(self, buffer: Any) -> int:
633
+ while not self._pending:
634
+ try:
635
+ chunk = next(self._chunks)
636
+ except StopIteration:
637
+ return 0
638
+ try:
639
+ self._pending = memoryview(chunk)
640
+ except TypeError as exc:
641
+ raise _not_bytes(chunk) from exc
642
+ size = min(len(buffer), len(self._pending))
643
+ buffer[:size] = self._pending[:size]
644
+ self._pending = self._pending[size:]
645
+ return size
646
+
647
+
648
+ def _as_reader(chunks: Iterable[bytes]) -> IO[bytes]:
649
+ return io.BufferedReader(_IterReader(chunks))
650
+
651
+
652
+ def _prefixed(head: bytes, rest: IO[bytes]) -> IO[bytes]:
653
+ def chunks() -> Iterator[bytes]:
654
+ yield head
655
+ yield from iter(lambda: rest.read(CHUNK), b"")
656
+
657
+ return _as_reader(chunks())
658
+
659
+
660
+ class _Decrypting:
661
+ """Decrypts ``source`` into a pipe on a thread; ``reader`` is the plaintext end."""
662
+
663
+ def __init__(
664
+ self,
665
+ source: IO[bytes],
666
+ *,
667
+ identities: Sequence[str] | None = None,
668
+ passphrase: str | None = None,
669
+ ) -> None:
670
+ from . import encryption
671
+
672
+ read_fd, write_fd = os.pipe()
673
+ self.reader: IO[bytes] = os.fdopen(read_fd, "rb")
674
+ self.error: BaseException | None = None
675
+ writer = os.fdopen(write_fd, "wb")
676
+
677
+ def run() -> None:
678
+ try:
679
+ if passphrase is not None:
680
+ encryption.decrypt(source, writer, passphrase=passphrase)
681
+ else:
682
+ encryption.decrypt(source, writer, identities or ())
683
+ except BrokenPipeError:
684
+ pass # the consumer stopped reading
685
+ except BaseException as exc: # noqa: BLE001 - re-raised on the consumer's thread
686
+ self.error = exc
687
+ finally:
688
+ with suppress(BrokenPipeError): # flushing to a consumer that stopped reading
689
+ writer.close()
690
+
691
+ self._thread = threading.Thread(target=run, daemon=True)
692
+ self._thread.start()
693
+
694
+ def join(self) -> None:
695
+ self._thread.join()
696
+
697
+ def close(self) -> None:
698
+ """Stop reading; a thread blocked writing plaintext then fails with EPIPE and exits."""
699
+ self.reader.close()
700
+ self._thread.join(timeout=10)
701
+
702
+
703
+ def _abandon(process: subprocess.Popen[bytes]) -> None:
704
+ """Kill and reap a load program: closing its input instead would run a partial statement."""
705
+ process.kill()
706
+ process.wait()
707
+ if process.stdin is not None:
708
+ with suppress(OSError):
709
+ process.stdin.close()
710
+
711
+
712
+ def _io_failed(exc: OSError, verdict: Verdict) -> TransferError:
713
+ return TransferError(f"load failed: {exc.strerror or exc}", verdict, None, "")
714
+
715
+
716
+ def _feed(stdin: IO[bytes], head: bytes, reader: IO[bytes], decrypt: _Decrypting | None) -> int:
717
+ """Write ``head`` then ``reader`` to ``stdin`` and close it; return the bytes written."""
718
+ delivered = 0
719
+ for block in itertools.chain((head,), iter(lambda: reader.read(CHUNK), b"")):
720
+ if not block:
721
+ continue
722
+ try:
723
+ stdin.write(block)
724
+ except BrokenPipeError:
725
+ break # the program exited; its exit code says why
726
+ delivered += len(block)
727
+ if decrypt is not None:
728
+ # After an early exit the decrypting thread is blocked writing plaintext
729
+ # nobody reads; closing its reader is what lets it finish.
730
+ decrypt.close()
731
+ if decrypt.error is not None:
732
+ raise decrypt.error
733
+ with suppress(BrokenPipeError):
734
+ stdin.close()
735
+ return delivered
736
+
737
+
738
+ def _pump_load(
739
+ process: subprocess.Popen[bytes],
740
+ head: bytes,
741
+ reader: IO[bytes],
742
+ *,
743
+ decrypt: _Decrypting | None = None,
744
+ password: str,
745
+ verdict: Verdict | None = None,
746
+ ) -> int:
747
+ """Feed ``head`` then ``reader`` to the program's stdin; return the bytes delivered."""
748
+ if process.stdin is None or process.stderr is None:
749
+ msg = "a load process needs piped stdin and stderr"
750
+ raise ValueError(msg)
751
+ if verdict is None:
752
+ verdict = Verdict("load")
753
+ stderr = _Stderr(process.stderr)
754
+ # Every failure to feed and close stdin cleanly kills the program: otherwise its
755
+ # input would be closed later, on collection or exit, running a partial statement.
756
+ try:
757
+ delivered = _feed(process.stdin, head, reader, decrypt)
758
+ except BaseException as exc:
759
+ _abandon(process)
760
+ if decrypt is not None:
761
+ decrypt.close()
762
+ if isinstance(exc, OSError): # reading the source, or writing to the program
763
+ raise _io_failed(exc, verdict) from exc
764
+ raise
765
+ code = process.wait()
766
+ text = stderr.text(password)
767
+ if code != 0:
768
+ first = text.strip().splitlines()[0] if text.strip() else "no error output"
769
+ msg = f"load failed: exited {code}: {first}"
770
+ raise TransferError(msg, verdict, code, text)
771
+ return delivered
772
+
773
+
774
+ def _open_source(
775
+ source: IO[bytes] | Iterable[bytes] | str | os.PathLike[str], cleanup: ExitStack
776
+ ) -> IO[bytes]:
777
+ if isinstance(source, (str, os.PathLike)):
778
+ try:
779
+ file = open(source, "rb") # noqa: PTH123, SIM115 - closed by cleanup
780
+ except OSError as exc:
781
+ msg = f"cannot read the dump source {os.fspath(source)!r}: {exc.strerror or exc}"
782
+ raise InvalidArgumentsError(msg) from exc
783
+ return cleanup.enter_context(file)
784
+ if isinstance(source, io.TextIOBase):
785
+ msg = "load reads bytes: pass a binary stream (sys.stdin.buffer, a file opened 'rb')"
786
+ raise InvalidArgumentsError(msg)
787
+ if hasattr(source, "read"):
788
+ return source # type: ignore[return-value]
789
+ return _as_reader(source)
790
+
791
+
792
+ def _read_head(reader: IO[bytes]) -> bytes:
793
+ """Up to CHUNK bytes; an unbuffered stream may return less per read."""
794
+ head = b""
795
+ while len(head) < CHUNK:
796
+ block = reader.read(CHUNK - len(head))
797
+ if not block:
798
+ break
799
+ if not isinstance(block, (bytes, bytearray, memoryview)):
800
+ raise _not_bytes(block)
801
+ head += block
802
+ return head
803
+
804
+
805
+ def _prepare(
806
+ reader: IO[bytes],
807
+ identities: Sequence[str] | None,
808
+ cleanup: ExitStack,
809
+ *,
810
+ passphrase: str | None = None,
811
+ ) -> tuple[_Header, bytes, IO[bytes], _Decrypting | None]:
812
+ """Peek the source: (header, plaintext head, plaintext reader, decryptor)."""
813
+ from . import encryption
814
+
815
+ if identities is not None and passphrase is not None:
816
+ msg = "supply identities= or passphrase=, not both"
817
+ raise InvalidArgumentsError(msg)
818
+ try:
819
+ head = _read_head(reader)
820
+ except OSError as exc:
821
+ msg = f"cannot read the dump source: {exc.strerror or exc}"
822
+ raise InvalidArgumentsError(msg) from exc
823
+ if not head.startswith(encryption.AGE_HEADER):
824
+ return _header(head, encrypted=False), head, reader, None
825
+ if identities is None and passphrase is None:
826
+ return _Header(True, None, False, needs_identity=True), b"", reader, None
827
+ decrypting = _Decrypting(_prefixed(head, reader), identities=identities, passphrase=passphrase)
828
+ cleanup.callback(decrypting.close)
829
+ plain_head = decrypting.reader.read(CHUNK)
830
+ if not plain_head:
831
+ decrypting.join()
832
+ error = decrypting.error
833
+ if isinstance(error, OSError):
834
+ msg = f"cannot read the dump source: {error.strerror or error}"
835
+ raise InvalidArgumentsError(msg) from error
836
+ if error is not None:
837
+ raise error # wrong identity/passphrase or damage, before anything is loaded
838
+ return _header(plain_head, encrypted=True), plain_head, decrypting.reader, decrypting
839
+
840
+
841
+ def _load_plan(
842
+ connection: Connection,
843
+ credentials: CredentialSource,
844
+ options: Sequence[str],
845
+ *,
846
+ replace: str | None,
847
+ header: _Header | None,
848
+ allow_unsandboxed: bool,
849
+ ) -> _Plan:
850
+ findings = _platform_and_option_findings(options, _client.LOAD_OPTIONS)
851
+ if header is not None and header.needs_identity:
852
+ findings.append(
853
+ Finding(
854
+ Code.IDENTITY_REQUIRED,
855
+ "the source is age-encrypted; supply identities= or passphrase=",
856
+ )
857
+ )
858
+ resolved = _resolve_or_finding(credentials)
859
+ if isinstance(resolved, Finding):
860
+ return _Plan(Verdict("load", (*findings, resolved)), None, None, None)
861
+ verdict, session = _preflight_resolved(connection, resolved, "load", LOAD_PRIVILEGES)
862
+ verdict = verdict.with_findings(*findings)
863
+ if session is None:
864
+ return _Plan(verdict, resolved, None, None)
865
+ database = connection.database
866
+ try:
867
+ with session, session.cursor() as cur:
868
+ server = detect_server(cur)
869
+ cur.execute(
870
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
871
+ (database,),
872
+ )
873
+ exists = int(cur.fetchone()[0]) > 0
874
+ tables = 0
875
+ if exists:
876
+ cur.execute(
877
+ "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s",
878
+ (database,),
879
+ )
880
+ tables = int(cur.fetchone()[0])
881
+ except pymysql.err.MySQLError as exc:
882
+ raise _as_package_error(exc, DatabaseUnreachableError) from exc
883
+ extra: list[Finding] = []
884
+ if not exists:
885
+ extra.append(Finding(Code.DATABASE_ABSENT, f"{database!r} does not exist"))
886
+ elif replace is not None:
887
+ extra += _confirmation(database, replace)
888
+ elif tables:
889
+ extra.append(
890
+ Finding(
891
+ Code.DATABASE_NOT_EMPTY,
892
+ f"{database!r} holds {tables} table(s); "
893
+ f"pass replace={database!r} to load over them",
894
+ {"tables": tables},
895
+ )
896
+ )
897
+ tool, tool_findings = _tool_findings(_client.PURPOSE_LOAD, server)
898
+ extra += tool_findings
899
+ if header is not None and tool is not None:
900
+ if header.family is not None and header.family is not server.family:
901
+ extra.append(
902
+ Finding(
903
+ Code.CROSS_FAMILY_DUMP,
904
+ f"the dump comes from {header.family}; its collations may not exist on "
905
+ f"{server.family} {server.version_text}",
906
+ )
907
+ )
908
+ if header.sandboxed and not tool.understands_sandbox_line and not allow_unsandboxed:
909
+ extra.append(
910
+ Finding(
911
+ Code.INCOMPATIBLE_DUMP_SOURCE,
912
+ f"the dump begins with MariaDB's sandbox line, which {tool.label} cannot "
913
+ "read; pass allow_unsandboxed=True to strip that one line",
914
+ )
915
+ )
916
+ return _Plan(verdict.with_findings(*extra), resolved, tool, server)
917
+
918
+
919
+ def validate_table_prefix(prefix: str) -> str:
920
+ """Return ``prefix`` if it is safe to LIKE-match and drop against, else raise."""
921
+ if not isinstance(prefix, str) or not prefix:
922
+ msg = "table prefix must be a non-empty string"
923
+ raise InvalidArgumentsError(msg)
924
+ if len(prefix) > MAX_TABLE_PREFIX:
925
+ msg = f"table prefix longer than {MAX_TABLE_PREFIX} characters"
926
+ raise InvalidArgumentsError(msg)
927
+ if prefix != prefix.strip():
928
+ msg = "table prefix has leading or trailing whitespace"
929
+ raise InvalidArgumentsError(msg)
930
+ found = _PREFIX_FORBIDDEN.intersection(prefix)
931
+ if found:
932
+ chars = ", ".join(repr(c) for c in sorted(found))
933
+ msg = f"table prefix contains forbidden characters: {chars}"
934
+ raise InvalidArgumentsError(msg)
935
+ return prefix
936
+
937
+
938
+ def _prefix_like(prefix: str) -> str:
939
+ """``prefix`` as a LIKE pattern that matches only names starting with it literally."""
940
+ return grant_pattern(prefix) + "%"
941
+
942
+
943
+ def _list_prefix_objects(cur: Any, database: str, prefix: str) -> list[tuple[str, str]]:
944
+ """``(name, TABLE_TYPE)`` for base tables and views under ``prefix``, views first."""
945
+ cur.execute(
946
+ "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES "
947
+ "WHERE TABLE_SCHEMA = %s AND TABLE_TYPE IN ('BASE TABLE', 'VIEW') "
948
+ "AND TABLE_NAME LIKE %s ESCAPE %s "
949
+ "ORDER BY CASE TABLE_TYPE WHEN 'VIEW' THEN 0 ELSE 1 END, TABLE_NAME",
950
+ (database, _prefix_like(prefix), "\\"),
951
+ )
952
+ return [(str(name), str(kind)) for name, kind in cur.fetchall()]
953
+
954
+
955
+ def _drop_prefix_objects(cur: Any, database: str, prefix: str) -> tuple[str, ...]:
956
+ """Drop matching views then base tables; return the names dropped, in that order."""
957
+ rows = _list_prefix_objects(cur, database, prefix)
958
+ dropped: list[str] = []
959
+ for name, kind in rows:
960
+ statement = "VIEW" if kind == "VIEW" else "TABLE"
961
+ cur.execute(
962
+ f"DROP {statement} IF EXISTS " # noqa: S608 - quoted identifiers only
963
+ f"{quote_identifier(database)}.{quote_identifier(name)}"
964
+ )
965
+ dropped.append(name)
966
+ return tuple(dropped)
967
+
968
+
969
+ def _drop_tables_plan(
970
+ connection: Connection, credentials: CredentialSource, prefix: str
971
+ ) -> tuple[Verdict, ResolvedCredentials | None, tuple[str, ...], Any | None]:
972
+ """Preflight plus the matching table/view list. Session is open when the verdict is ok."""
973
+ prefix = validate_table_prefix(prefix)
974
+ resolved = _resolve_or_finding(credentials)
975
+ if isinstance(resolved, Finding):
976
+ return Verdict("drop_tables", (resolved,)), None, (), None
977
+ verdict, session = _preflight_resolved(
978
+ connection, resolved, "drop_tables", DROP_TABLES_PRIVILEGES
979
+ )
980
+ if session is None:
981
+ return verdict, resolved, (), None
982
+ database = connection.database
983
+ try:
984
+ with session.cursor() as cur:
985
+ cur.execute(
986
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
987
+ (database,),
988
+ )
989
+ exists = int(cur.fetchone()[0]) > 0
990
+ tables: tuple[str, ...] = ()
991
+ if exists:
992
+ tables = tuple(name for name, _ in _list_prefix_objects(cur, database, prefix))
993
+ else:
994
+ verdict = verdict.with_findings(
995
+ Finding(Code.DATABASE_ABSENT, f"{database!r} does not exist")
996
+ )
997
+ except pymysql.err.MySQLError as exc:
998
+ session.close()
999
+ raise _as_package_error(exc, DatabaseUnreachableError) from exc
1000
+ if not verdict.ok:
1001
+ session.close()
1002
+ return verdict, resolved, tables, None
1003
+ return verdict, resolved, tables, session
1004
+
1005
+
1006
+ def check_drop_tables(
1007
+ connection: Connection, credentials: CredentialSource, *, prefix: str
1008
+ ) -> DropTablesResult:
1009
+ """What :func:`drop_tables` would drop. ``tables`` is the match list."""
1010
+ with operation("check_drop_tables", connection) as fields:
1011
+ verdict, _, tables, session = _drop_tables_plan(connection, credentials, prefix)
1012
+ if session is not None:
1013
+ session.close()
1014
+ fields[VERDICT_KEY] = verdict
1015
+ fields["tables"] = len(tables)
1016
+ return DropTablesResult(tables, verdict)
1017
+
1018
+
1019
+ def drop_tables(
1020
+ connection: Connection, credentials: CredentialSource, *, prefix: str
1021
+ ) -> DropTablesResult:
1022
+ """Drop every base table and view in this database whose name starts with ``prefix``.
1023
+
1024
+ ``_`` and ``%`` in ``prefix`` are matched literally. Names that do not start
1025
+ with the prefix are never touched. An empty or invalid prefix is refused.
1026
+ """
1027
+ with operation("drop_tables", connection) as fields:
1028
+ verdict, _, _, session = _drop_tables_plan(connection, credentials, prefix)
1029
+ if session is None:
1030
+ raise RefusedError(verdict)
1031
+ try:
1032
+ with session, session.cursor() as cur:
1033
+ dropped = _drop_prefix_objects(
1034
+ cur, connection.database, validate_table_prefix(prefix)
1035
+ )
1036
+ session.commit()
1037
+ except pymysql.err.MySQLError as exc:
1038
+ msg = f"dropping tables with prefix {prefix!r} failed"
1039
+ raise _as_package_error(exc, DatabaseError, msg) from exc
1040
+ fields[VERDICT_KEY] = verdict
1041
+ fields["tables"] = len(dropped)
1042
+ return DropTablesResult(dropped, verdict)
1043
+
1044
+
1045
+ def check_load(
1046
+ connection: Connection,
1047
+ credentials: CredentialSource,
1048
+ *,
1049
+ source: str | os.PathLike[str] | None = None,
1050
+ replace: str | None = None,
1051
+ prune_prefix: str | None = None,
1052
+ identities: Sequence[str] | None = None,
1053
+ passphrase: str | None = None,
1054
+ options: Sequence[str] = (),
1055
+ allow_unsandboxed: bool = False,
1056
+ ) -> Verdict:
1057
+ """What :func:`load` would find. The source header is read only for a path."""
1058
+ if prune_prefix is not None:
1059
+ validate_table_prefix(prune_prefix)
1060
+ with operation("check_load", connection) as fields, ExitStack() as cleanup:
1061
+ if source is not None and not isinstance(source, (str, os.PathLike)):
1062
+ # Reading a stream's head here would leave load() a dump without its head.
1063
+ msg = "check_load takes source only as a path; a stream cannot be read twice"
1064
+ raise InvalidArgumentsError(msg)
1065
+ header = None
1066
+ if source is not None:
1067
+ header, *_ = _prepare(
1068
+ _open_source(source, cleanup), identities, cleanup, passphrase=passphrase
1069
+ )
1070
+ verdict = _load_plan(
1071
+ connection,
1072
+ credentials,
1073
+ options,
1074
+ replace=replace,
1075
+ header=header,
1076
+ allow_unsandboxed=allow_unsandboxed,
1077
+ ).verdict
1078
+ fields[VERDICT_KEY] = verdict
1079
+ return verdict
1080
+
1081
+
1082
+ def load(
1083
+ connection: Connection,
1084
+ credentials: CredentialSource,
1085
+ source: IO[bytes] | Iterable[bytes] | str | os.PathLike[str],
1086
+ *,
1087
+ replace: str | None = None,
1088
+ prune_prefix: str | None = None,
1089
+ identities: Sequence[str] | None = None,
1090
+ passphrase: str | None = None,
1091
+ options: Sequence[str] = (),
1092
+ allow_unsandboxed: bool = False,
1093
+ ) -> TransferResult:
1094
+ """Load a dump into this database.
1095
+
1096
+ When ``prune_prefix`` is set, every base table and view whose name starts
1097
+ with that prefix is dropped first (same rules as :func:`drop_tables`); their
1098
+ names are reported on :attr:`TransferResult.dropped`. Damage in a source is
1099
+ found mid-stream after earlier statements ran — take a safety dump first if
1100
+ the target must be recoverable.
1101
+ """
1102
+ if prune_prefix is not None:
1103
+ prune_prefix = validate_table_prefix(prune_prefix)
1104
+ started = time.monotonic()
1105
+ with operation("load", connection) as fields, ExitStack() as cleanup:
1106
+ header, head, reader, decrypting = _prepare(
1107
+ _open_source(source, cleanup), identities, cleanup, passphrase=passphrase
1108
+ )
1109
+ plan = _load_plan(
1110
+ connection,
1111
+ credentials,
1112
+ options,
1113
+ replace=replace,
1114
+ header=header,
1115
+ allow_unsandboxed=allow_unsandboxed,
1116
+ )
1117
+ fields[VERDICT_KEY] = plan.verdict
1118
+ if (
1119
+ not plan.verdict.ok
1120
+ or plan.tool is None
1121
+ or plan.server is None
1122
+ or plan.credentials is None
1123
+ ):
1124
+ raise RefusedError(plan.verdict)
1125
+ dropped: tuple[str, ...] = ()
1126
+ if prune_prefix is not None:
1127
+ try:
1128
+ session = open_connection(connection, plan.credentials, database=None)
1129
+ with session, session.cursor() as cur:
1130
+ dropped = _drop_prefix_objects(cur, connection.database, prune_prefix)
1131
+ session.commit()
1132
+ except pymysql.err.MySQLError as exc:
1133
+ msg = f"pruning tables with prefix {prune_prefix!r} before load failed"
1134
+ raise _as_package_error(exc, DatabaseError, msg) from exc
1135
+ fields["dropped"] = len(dropped)
1136
+ if header.sandboxed and not plan.tool.understands_sandbox_line:
1137
+ head = head.split(b"\n", 1)[1] if b"\n" in head else b""
1138
+ try:
1139
+ process = _start(
1140
+ plan.tool,
1141
+ _client.load_args(connection.database, options),
1142
+ plan.credentials,
1143
+ connection,
1144
+ for_load=True,
1145
+ )
1146
+ except OSError as exc:
1147
+ msg = f"could not start {plan.tool.path}: {exc.strerror or exc}"
1148
+ raise TransferError(msg, plan.verdict, None, "") from exc
1149
+ delivered = _pump_load(
1150
+ process,
1151
+ head,
1152
+ reader,
1153
+ decrypt=decrypting,
1154
+ password=plan.credentials.password,
1155
+ verdict=plan.verdict,
1156
+ )
1157
+ fields["bytes"] = delivered
1158
+ return TransferResult(
1159
+ delivered,
1160
+ round((time.monotonic() - started) * 1000, 1),
1161
+ plan.tool.label,
1162
+ plan.server,
1163
+ plan.verdict,
1164
+ dropped,
1165
+ )