contextbase-shared-plugins 0.0.0a1__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.
- contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
- shared_plugins/__init__.py +8 -0
- shared_plugins/automation.py +11 -0
- shared_plugins/base_platform.py +105 -0
- shared_plugins/bindings.py +260 -0
- shared_plugins/dlt.py +89 -0
- shared_plugins/env.py +104 -0
- shared_plugins/exceptions.py +10 -0
- shared_plugins/google_client/__init__.py +1 -0
- shared_plugins/google_client/auth.py +108 -0
- shared_plugins/google_client/batch_retry.py +308 -0
- shared_plugins/google_client/http_errors.py +27 -0
- shared_plugins/machine_token.py +419 -0
- shared_plugins/microsoft_dataverse/__init__.py +27 -0
- shared_plugins/microsoft_dataverse/annotations.py +61 -0
- shared_plugins/microsoft_dataverse/auth.py +26 -0
- shared_plugins/microsoft_dataverse/binding_config.py +35 -0
- shared_plugins/microsoft_dataverse/client.py +468 -0
- shared_plugins/microsoft_dataverse/ctx.py +21 -0
- shared_plugins/microsoft_dataverse/identifiers.py +62 -0
- shared_plugins/microsoft_dataverse/ingress.py +53 -0
- shared_plugins/microsoft_dataverse/metadata.py +106 -0
- shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
- shared_plugins/microsoft_dataverse/source.py +299 -0
- shared_plugins/microsoft_dataverse/tables.py +34 -0
- shared_plugins/microsoft_dataverse/translators.py +133 -0
- shared_plugins/microsoft_dataverse/types.py +355 -0
- shared_plugins/microsoft_graph.py +250 -0
- shared_plugins/models.py +91 -0
- shared_plugins/naming.py +83 -0
- shared_plugins/pg_column_comments.py +59 -0
- shared_plugins/provider_token.py +238 -0
- shared_plugins/pyairbyte.py +485 -0
- shared_plugins/resources.py +179 -0
- shared_plugins/scratch.py +127 -0
- shared_plugins/sentry.py +117 -0
- shared_plugins/sqlalchemy_types.py +225 -0
- shared_plugins/sqlite.py +123 -0
- shared_plugins/values.py +117 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from .env import CTXB_SCRATCH_DIR_ENV_VAR, load_shared_python_settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _validate_safe_segment(value: str, *, label: str) -> str:
|
|
12
|
+
normalized = value.strip()
|
|
13
|
+
if not normalized:
|
|
14
|
+
raise ValueError(f"{label} cannot be blank.")
|
|
15
|
+
if normalized in {".", ".."}:
|
|
16
|
+
raise ValueError(f"{label} cannot be '.' or '..'.")
|
|
17
|
+
if "/" in normalized or "\\" in normalized or "\x00" in normalized:
|
|
18
|
+
raise ValueError(f"{label} cannot contain path separators or null bytes.")
|
|
19
|
+
return normalized
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_scratch_root() -> Path:
|
|
23
|
+
scratch_root = load_shared_python_settings().ctx_scratch_dir
|
|
24
|
+
if scratch_root is None:
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
f"{CTXB_SCRATCH_DIR_ENV_VAR} is not set; run via ctxb so scratch paths are configured."
|
|
27
|
+
)
|
|
28
|
+
return scratch_root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_relative_dir(relative_dir: str) -> Path:
|
|
32
|
+
normalized = relative_dir.strip()
|
|
33
|
+
if not normalized:
|
|
34
|
+
raise ValueError("relative_dir cannot be blank.")
|
|
35
|
+
|
|
36
|
+
relative_path = Path(normalized)
|
|
37
|
+
if relative_path.is_absolute():
|
|
38
|
+
raise ValueError("relative_dir must be relative, not absolute.")
|
|
39
|
+
|
|
40
|
+
safe_parts = [
|
|
41
|
+
_validate_safe_segment(part, label="relative_dir segment")
|
|
42
|
+
for part in relative_path.parts
|
|
43
|
+
]
|
|
44
|
+
return Path(*safe_parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_binding_target_paths(
|
|
48
|
+
*,
|
|
49
|
+
binding_id: str,
|
|
50
|
+
relative_dir: str,
|
|
51
|
+
) -> tuple[Path, Path]:
|
|
52
|
+
scratch_root = _resolve_scratch_root()
|
|
53
|
+
safe_binding_id = _validate_safe_segment(binding_id, label="binding_id")
|
|
54
|
+
safe_relative_dir = _parse_relative_dir(relative_dir)
|
|
55
|
+
|
|
56
|
+
binding_root = (scratch_root / safe_binding_id).absolute()
|
|
57
|
+
target_dir = (binding_root / safe_relative_dir).absolute()
|
|
58
|
+
if not target_dir.is_relative_to(binding_root):
|
|
59
|
+
raise ValueError("relative_dir escapes the binding scratch root.")
|
|
60
|
+
return binding_root, target_dir
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _remove_path(path: Path) -> None:
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return
|
|
66
|
+
if path.is_dir():
|
|
67
|
+
shutil.rmtree(path)
|
|
68
|
+
else:
|
|
69
|
+
path.unlink()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_temp_path(binding_root: Path, *, prefix: str) -> Path:
|
|
73
|
+
return binding_root / f".{prefix}-{uuid4().hex}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def replace_scratch_dir_files(
|
|
77
|
+
*,
|
|
78
|
+
binding_id: str,
|
|
79
|
+
relative_dir: str,
|
|
80
|
+
files: Mapping[str, bytes],
|
|
81
|
+
) -> dict[str, str]:
|
|
82
|
+
binding_root, target_dir = _resolve_binding_target_paths(
|
|
83
|
+
binding_id=binding_id,
|
|
84
|
+
relative_dir=relative_dir,
|
|
85
|
+
)
|
|
86
|
+
binding_root.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
staging_dir = _build_temp_path(binding_root, prefix="ctx-staging")
|
|
89
|
+
staging_dir.mkdir(parents=False, exist_ok=False)
|
|
90
|
+
|
|
91
|
+
safe_name_by_input_name: dict[str, str] = {}
|
|
92
|
+
normalized_safe_names: set[str] = set()
|
|
93
|
+
for file_name, content in files.items():
|
|
94
|
+
safe_file_name = _validate_safe_segment(file_name, label="file name")
|
|
95
|
+
if not isinstance(content, bytes):
|
|
96
|
+
raise TypeError("files values must be bytes.")
|
|
97
|
+
|
|
98
|
+
if safe_file_name in normalized_safe_names:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"files contains duplicate normalized file names: '{safe_file_name}'."
|
|
101
|
+
)
|
|
102
|
+
normalized_safe_names.add(safe_file_name)
|
|
103
|
+
safe_name_by_input_name[file_name] = safe_file_name
|
|
104
|
+
|
|
105
|
+
file_path = staging_dir / safe_file_name
|
|
106
|
+
file_path.write_bytes(content)
|
|
107
|
+
|
|
108
|
+
backup_path: Path | None = None
|
|
109
|
+
try:
|
|
110
|
+
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
if target_dir.exists():
|
|
112
|
+
backup_path = _build_temp_path(binding_root, prefix="ctx-backup")
|
|
113
|
+
target_dir.rename(backup_path)
|
|
114
|
+
staging_dir.rename(target_dir)
|
|
115
|
+
except Exception:
|
|
116
|
+
if backup_path is not None and backup_path.exists() and not target_dir.exists():
|
|
117
|
+
backup_path.rename(target_dir)
|
|
118
|
+
raise
|
|
119
|
+
finally:
|
|
120
|
+
_remove_path(staging_dir)
|
|
121
|
+
if backup_path is not None:
|
|
122
|
+
_remove_path(backup_path)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
file_name: str((target_dir / safe_name).absolute())
|
|
126
|
+
for file_name, safe_name in safe_name_by_input_name.items()
|
|
127
|
+
}
|
shared_plugins/sentry.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
|
|
8
|
+
import sentry_sdk
|
|
9
|
+
from sentry_sdk.types import Event, Hint
|
|
10
|
+
|
|
11
|
+
from shared_plugins.values import require_non_negative_int
|
|
12
|
+
|
|
13
|
+
# Hardcoded write-only ingest key (safe in shipped code). Prod-gated: ctxb (the
|
|
14
|
+
# Node spawn parent) forwards SENTRY_ENABLED=true to the dagster child only from
|
|
15
|
+
# its compiled binary, so the SDK initializes only in shipped runs — never local
|
|
16
|
+
# or CI Dagster, which run from source and never set the flag. Errors only: no
|
|
17
|
+
# tracing, profiling, or replay (internal Sentry spec, pass 1).
|
|
18
|
+
_DSN = "https://96f72b28675ce397ec6cb39a0702e747@o4510556311519232.ingest.us.sentry.io/4511513003229184"
|
|
19
|
+
|
|
20
|
+
# A UUID contains digit runs, so it must be collapsed BEFORE they are — the
|
|
21
|
+
# reverse order shreds it into fragments and every event keys differently. This
|
|
22
|
+
# mirrors @usecontextlayer/shared/sentry-event-cap on the TypeScript side; the two
|
|
23
|
+
# are duplicated because there is no cross-language home for 30 lines, and they
|
|
24
|
+
# must stay in step.
|
|
25
|
+
_UUID_PATTERN = re.compile(
|
|
26
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
# Six, not three — the rationale lives with the canonical TS copy
|
|
30
|
+
# (@usecontextlayer/shared/sentry-event-cap).
|
|
31
|
+
_LONG_DIGIT_RUN_PATTERN = re.compile(r"\d{6,}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_event_cap(raw: str | None) -> int | None:
|
|
35
|
+
"""Read the SENTRY_CAP_EVENTS contract: a positive integer enables capping and
|
|
36
|
+
IS the cap. Absent, blank, or unparseable means capping off."""
|
|
37
|
+
try:
|
|
38
|
+
cap = require_non_negative_int(raw)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
# Positivity is this module's policy, not the shared coercer's: zero is a valid
|
|
42
|
+
# non-negative integer but cannot express a cap.
|
|
43
|
+
return cap if cap > 0 else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalized_key(event: Event) -> str:
|
|
47
|
+
# Dagster reports through the logging integration, which writes BOTH the
|
|
48
|
+
# template (`message`) and the interpolated text (`formatted`). Key on
|
|
49
|
+
# `formatted`: it is the realized string in either case, so this works whether
|
|
50
|
+
# or not the caller passed logging params. Fall back to a plain message, then
|
|
51
|
+
# to the LAST exception value — the same precedence the JS side uses.
|
|
52
|
+
logentry = event.get("logentry") or {}
|
|
53
|
+
raw = logentry.get("formatted") or logentry.get("message") or event.get("message")
|
|
54
|
+
if raw is None:
|
|
55
|
+
values = (event.get("exception") or {}).get("values") or []
|
|
56
|
+
raw = values[-1].get("value") if values else ""
|
|
57
|
+
collapsed = _UUID_PATTERN.sub("<uuid>", raw or "")
|
|
58
|
+
return _LONG_DIGIT_RUN_PATTERN.sub("<n>", collapsed)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _event_cap_before_send(
|
|
62
|
+
raw: str | None,
|
|
63
|
+
) -> Callable[[Event, Hint], Event | None] | None:
|
|
64
|
+
"""The composed form init uses: parse the raw env value and build the hook in
|
|
65
|
+
one step — None (capping off) when the value doesn't parse."""
|
|
66
|
+
cap = _parse_event_cap(raw)
|
|
67
|
+
return None if cap is None else _create_capped_before_send(cap)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _create_capped_before_send(cap: int) -> Callable[[Event, Hint], Event | None]:
|
|
71
|
+
"""Admit at most `cap` events per normalized message. The first event of a key
|
|
72
|
+
never seen before is always admitted, so this understates how often something
|
|
73
|
+
recurs but can never hide that it happened.
|
|
74
|
+
|
|
75
|
+
NOTE the process boundary: core/definitions.py calls init_sentry() at module
|
|
76
|
+
scope, and that module is loaded by EVERY Dagster process — daemon, per-run
|
|
77
|
+
subprocess, per-step subprocess. Each therefore gets its own counter. This
|
|
78
|
+
bounds a burst inside one step; it does NOT bound a run-failure storm across
|
|
79
|
+
many subprocesses, which is what the July 2026 microsoft_mail_attachment_content
|
|
80
|
+
cluster (~1058 failed runs, one event each) actually was.
|
|
81
|
+
"""
|
|
82
|
+
sent_per_key: dict[str, int] = {}
|
|
83
|
+
|
|
84
|
+
def before_send(event: Event, hint: Hint) -> Event | None:
|
|
85
|
+
key = _normalized_key(event)
|
|
86
|
+
sent = sent_per_key.get(key, 0)
|
|
87
|
+
if sent >= cap:
|
|
88
|
+
return None
|
|
89
|
+
sent_per_key[key] = sent + 1
|
|
90
|
+
return event
|
|
91
|
+
|
|
92
|
+
return before_send
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def init_sentry() -> None:
|
|
96
|
+
# Off unless the compiled ctxb forwarded the baked prod-gate (see module note).
|
|
97
|
+
if os.environ.get("SENTRY_ENABLED") != "true":
|
|
98
|
+
return
|
|
99
|
+
# Derive the release from the installed "contextbase-shared-plugins" dist
|
|
100
|
+
# version (release.ts bumps it in lockstep with the pyproject version),
|
|
101
|
+
# labeled "contextbase-core@<version>". Computed here, after the gate, so a
|
|
102
|
+
# disabled/dev/CI run does no metadata lookup, and the dist is guaranteed
|
|
103
|
+
# installed whenever the gate is on. No literal to keep in sync.
|
|
104
|
+
release = f"contextbase-core@{version('contextbase-shared-plugins')}"
|
|
105
|
+
# SENTRY_CAP_EVENTS needs no forwarding of its own: buildDagsterEnv spreads the
|
|
106
|
+
# raw process.env into the child, so a real env var arrives for free (unlike
|
|
107
|
+
# SENTRY_ENABLED, which is build-folded out of the compiled ctxb's environment
|
|
108
|
+
# and therefore has to be re-added explicitly there).
|
|
109
|
+
sentry_sdk.init(
|
|
110
|
+
before_send=_event_cap_before_send(os.environ.get("SENTRY_CAP_EVENTS")),
|
|
111
|
+
dsn=_DSN,
|
|
112
|
+
environment="production",
|
|
113
|
+
release=release,
|
|
114
|
+
send_default_pii=False,
|
|
115
|
+
# Errors only — performance tracing is deferred to a later pass.
|
|
116
|
+
traces_sample_rate=0,
|
|
117
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.types import Float, Integer, TypeDecorator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _timedelta_total_microseconds(delta: timedelta) -> int:
|
|
11
|
+
return ((delta.days * 86_400) + delta.seconds) * 1_000_000 + delta.microseconds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _timedelta_total_milliseconds(delta: timedelta) -> int:
|
|
15
|
+
total_microseconds = _timedelta_total_microseconds(delta)
|
|
16
|
+
if total_microseconds >= 0:
|
|
17
|
+
return total_microseconds // 1_000
|
|
18
|
+
return -((-total_microseconds) // 1_000)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
APPLE_CORE_DATA_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AppleCoreDataTimestamp(TypeDecorator[datetime | None]):
|
|
25
|
+
"""Store Apple Core Data timestamps as aware UTC datetimes."""
|
|
26
|
+
|
|
27
|
+
impl = Float
|
|
28
|
+
cache_ok = True
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, null_sentinels: Iterable[float] = ()) -> None:
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.null_sentinels = tuple(
|
|
33
|
+
sorted({float(sentinel) for sentinel in null_sentinels})
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def process_bind_param(
|
|
37
|
+
self,
|
|
38
|
+
value: object,
|
|
39
|
+
dialect: Any,
|
|
40
|
+
) -> float | None:
|
|
41
|
+
del dialect
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if not isinstance(value, datetime):
|
|
45
|
+
raise TypeError(
|
|
46
|
+
"AppleCoreDataTimestamp values must be datetime instances or None."
|
|
47
|
+
)
|
|
48
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
49
|
+
raise TypeError("AppleCoreDataTimestamp requires an aware datetime.")
|
|
50
|
+
|
|
51
|
+
normalized = value.astimezone(timezone.utc)
|
|
52
|
+
return (normalized - APPLE_CORE_DATA_EPOCH).total_seconds()
|
|
53
|
+
|
|
54
|
+
def process_result_value(
|
|
55
|
+
self,
|
|
56
|
+
value: object,
|
|
57
|
+
dialect: Any,
|
|
58
|
+
) -> datetime | None:
|
|
59
|
+
del dialect
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
63
|
+
raise TypeError(
|
|
64
|
+
"AppleCoreDataTimestamp database values must be numeric or None."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
numeric_value = float(value)
|
|
68
|
+
if numeric_value in self.null_sentinels:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
return APPLE_CORE_DATA_EPOCH + timedelta(seconds=numeric_value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AppleMessageTimestamp(TypeDecorator[datetime | None]):
|
|
75
|
+
"""Store iMessage timestamps as aware UTC datetimes.
|
|
76
|
+
|
|
77
|
+
iMessage's ``chat.db`` stores timestamps as **nanoseconds** since
|
|
78
|
+
2001-01-01 UTC starting with iOS 12 / macOS Mojave; pre-iOS 11 databases
|
|
79
|
+
stored the same columns as **seconds** since the same epoch. Rows from a
|
|
80
|
+
restored backup can mix both formats in the same column, so each value is
|
|
81
|
+
classified by magnitude: anything below 10**12 is treated as seconds and
|
|
82
|
+
everything else as nanoseconds. Sub-microsecond precision is lost on read
|
|
83
|
+
since ``datetime`` cannot represent it.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
impl = Integer
|
|
87
|
+
cache_ok = True
|
|
88
|
+
|
|
89
|
+
def __init__(self, *, null_sentinels: Iterable[int] = ()) -> None:
|
|
90
|
+
super().__init__()
|
|
91
|
+
self.null_sentinels = tuple(
|
|
92
|
+
sorted({int(sentinel) for sentinel in null_sentinels})
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def process_bind_param(
|
|
96
|
+
self,
|
|
97
|
+
value: object,
|
|
98
|
+
dialect: Any,
|
|
99
|
+
) -> int | None:
|
|
100
|
+
del dialect
|
|
101
|
+
if value is None:
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(value, datetime):
|
|
104
|
+
raise TypeError(
|
|
105
|
+
"AppleMessageTimestamp values must be datetime instances or None."
|
|
106
|
+
)
|
|
107
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
108
|
+
raise TypeError("AppleMessageTimestamp requires an aware datetime.")
|
|
109
|
+
|
|
110
|
+
normalized = value.astimezone(timezone.utc)
|
|
111
|
+
delta = normalized - APPLE_CORE_DATA_EPOCH
|
|
112
|
+
return _timedelta_total_microseconds(delta) * 1_000
|
|
113
|
+
|
|
114
|
+
def process_result_value(
|
|
115
|
+
self,
|
|
116
|
+
value: object,
|
|
117
|
+
dialect: Any,
|
|
118
|
+
) -> datetime | None:
|
|
119
|
+
del dialect
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
123
|
+
raise TypeError(
|
|
124
|
+
"AppleMessageTimestamp database values must be int or None."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if value in self.null_sentinels:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if value >= 10**12:
|
|
131
|
+
microseconds = value // 1_000
|
|
132
|
+
return APPLE_CORE_DATA_EPOCH + timedelta(microseconds=microseconds)
|
|
133
|
+
|
|
134
|
+
return APPLE_CORE_DATA_EPOCH + timedelta(seconds=value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
CHROMIUM_EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ChromiumTimestamp(TypeDecorator[datetime | None]):
|
|
141
|
+
"""Store Chromium timestamps as aware UTC datetimes.
|
|
142
|
+
|
|
143
|
+
Chromium stores timestamps as microseconds since 1601-01-01 00:00:00 UTC.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
impl = Integer
|
|
147
|
+
cache_ok = True
|
|
148
|
+
|
|
149
|
+
def process_bind_param(
|
|
150
|
+
self,
|
|
151
|
+
value: object,
|
|
152
|
+
dialect: Any,
|
|
153
|
+
) -> int | None:
|
|
154
|
+
del dialect
|
|
155
|
+
if value is None:
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(value, datetime):
|
|
158
|
+
raise TypeError(
|
|
159
|
+
"ChromiumTimestamp values must be datetime instances or None."
|
|
160
|
+
)
|
|
161
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
162
|
+
raise TypeError("ChromiumTimestamp requires an aware datetime.")
|
|
163
|
+
|
|
164
|
+
normalized = value.astimezone(timezone.utc)
|
|
165
|
+
delta = normalized - CHROMIUM_EPOCH
|
|
166
|
+
return _timedelta_total_microseconds(delta)
|
|
167
|
+
|
|
168
|
+
def process_result_value(
|
|
169
|
+
self,
|
|
170
|
+
value: object,
|
|
171
|
+
dialect: Any,
|
|
172
|
+
) -> datetime | None:
|
|
173
|
+
del dialect
|
|
174
|
+
if value is None:
|
|
175
|
+
return None
|
|
176
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
177
|
+
raise TypeError(
|
|
178
|
+
"ChromiumTimestamp database values must be numeric or None."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return CHROMIUM_EPOCH + timedelta(microseconds=int(value))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class UnixMillisTimestamp(TypeDecorator[datetime | None]):
|
|
188
|
+
"""Store Unix-millisecond timestamps as aware UTC datetimes."""
|
|
189
|
+
|
|
190
|
+
impl = Integer
|
|
191
|
+
cache_ok = True
|
|
192
|
+
|
|
193
|
+
def process_bind_param(
|
|
194
|
+
self,
|
|
195
|
+
value: object,
|
|
196
|
+
dialect: Any,
|
|
197
|
+
) -> int | None:
|
|
198
|
+
del dialect
|
|
199
|
+
if value is None:
|
|
200
|
+
return None
|
|
201
|
+
if not isinstance(value, datetime):
|
|
202
|
+
raise TypeError(
|
|
203
|
+
"UnixMillisTimestamp values must be datetime instances or None."
|
|
204
|
+
)
|
|
205
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
206
|
+
raise TypeError("UnixMillisTimestamp requires an aware datetime.")
|
|
207
|
+
|
|
208
|
+
normalized = value.astimezone(timezone.utc)
|
|
209
|
+
delta = normalized - UNIX_EPOCH
|
|
210
|
+
return _timedelta_total_milliseconds(delta)
|
|
211
|
+
|
|
212
|
+
def process_result_value(
|
|
213
|
+
self,
|
|
214
|
+
value: object,
|
|
215
|
+
dialect: Any,
|
|
216
|
+
) -> datetime | None:
|
|
217
|
+
del dialect
|
|
218
|
+
if value is None:
|
|
219
|
+
return None
|
|
220
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
221
|
+
raise TypeError(
|
|
222
|
+
"UnixMillisTimestamp database values must be numeric or None."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return UNIX_EPOCH + timedelta(milliseconds=int(value))
|
shared_plugins/sqlite.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
import sqlite3
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
BACKUP_TIMEOUT_SECONDS = 5.0
|
|
16
|
+
BACKUP_PAGES_PER_STEP = 100
|
|
17
|
+
SQLITE_COPY_SIBLING_SUFFIXES = ("-journal", "-wal", "-shm")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _BackupTimeout(Exception):
|
|
21
|
+
"""Internal sentinel raised from the backup progress callback."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def sqlite_snapshot(
|
|
26
|
+
db_path: Path,
|
|
27
|
+
*,
|
|
28
|
+
backup_timeout: float = BACKUP_TIMEOUT_SECONDS,
|
|
29
|
+
) -> Iterator[Path]:
|
|
30
|
+
"""Create a consistent snapshot of a potentially-locked SQLite database.
|
|
31
|
+
|
|
32
|
+
Yields a path to a temporary copy that the caller can open with any
|
|
33
|
+
library (raw sqlite3, SQLAlchemy, etc.). The temporary directory and
|
|
34
|
+
all files are deleted when the context manager exits.
|
|
35
|
+
|
|
36
|
+
Strategy:
|
|
37
|
+
|
|
38
|
+
1. Try the SQLite Online Backup API with cooperative timeout. A
|
|
39
|
+
`progress` callback fires after every backup_step iteration —
|
|
40
|
+
including SQLITE_BUSY / SQLITE_LOCKED — and aborts the backup
|
|
41
|
+
by raising when the wall-clock deadline is exceeded. The source
|
|
42
|
+
connection is opened with `timeout=0` so SQLite's internal busy
|
|
43
|
+
retry doesn't loop in C; control returns to Python between every
|
|
44
|
+
step, which is where we check the deadline.
|
|
45
|
+
|
|
46
|
+
2. If the backup deadline is exceeded, fall back to copying the
|
|
47
|
+
database file and any rollback-journal or WAL siblings. Best-
|
|
48
|
+
effort: not transactionally guaranteed, but works across SQLite
|
|
49
|
+
journal modes.
|
|
50
|
+
|
|
51
|
+
Non-timeout errors (missing file, corrupt DB, etc.) propagate to
|
|
52
|
+
the caller — they are not part of the "should we fall back?"
|
|
53
|
+
decision.
|
|
54
|
+
"""
|
|
55
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
56
|
+
snapshot_dir = Path(tmp_dir)
|
|
57
|
+
snapshot_path = snapshot_dir / db_path.name
|
|
58
|
+
if _try_backup(db_path, snapshot_path, backup_timeout):
|
|
59
|
+
LOGGER.debug("sqlite_snapshot.backup_ok path=%s", db_path)
|
|
60
|
+
else:
|
|
61
|
+
LOGGER.info(
|
|
62
|
+
"sqlite_snapshot.backup_timeout path=%s timeout=%.1fs, falling back to file copy",
|
|
63
|
+
db_path,
|
|
64
|
+
backup_timeout,
|
|
65
|
+
)
|
|
66
|
+
_copy_with_siblings(db_path, snapshot_dir)
|
|
67
|
+
|
|
68
|
+
yield snapshot_path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _try_backup(db_path: Path, dest_path: Path, timeout: float) -> bool:
|
|
72
|
+
"""Attempt a backup-API snapshot. Returns True on success, False on timeout."""
|
|
73
|
+
deadline = time.monotonic() + timeout
|
|
74
|
+
|
|
75
|
+
def abort_if_past_deadline(rc: int, remaining: int, total: int) -> None:
|
|
76
|
+
if time.monotonic() > deadline:
|
|
77
|
+
raise _BackupTimeout()
|
|
78
|
+
|
|
79
|
+
# timeout=0 disables SQLite's internal busy retry so the progress
|
|
80
|
+
# callback fires on every BUSY/LOCKED step (otherwise SQLite's C
|
|
81
|
+
# layer would loop on BUSY for up to busy_timeout before returning
|
|
82
|
+
# to Python, and we'd lose our cooperative cancellation point).
|
|
83
|
+
source = sqlite3.connect(_sqlite_readonly_uri(db_path), uri=True, timeout=0)
|
|
84
|
+
try:
|
|
85
|
+
dest = sqlite3.connect(dest_path)
|
|
86
|
+
try:
|
|
87
|
+
source.backup(
|
|
88
|
+
dest,
|
|
89
|
+
pages=BACKUP_PAGES_PER_STEP,
|
|
90
|
+
progress=abort_if_past_deadline,
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
except _BackupTimeout:
|
|
94
|
+
_cleanup_partial_backup(dest_path)
|
|
95
|
+
return False
|
|
96
|
+
finally:
|
|
97
|
+
dest.close()
|
|
98
|
+
finally:
|
|
99
|
+
source.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _copy_with_siblings(db_path: Path, dest_dir: Path) -> None:
|
|
103
|
+
"""Copy a database file and any rollback-journal or WAL siblings."""
|
|
104
|
+
for artifact in _iter_sqlite_artifacts(db_path):
|
|
105
|
+
if artifact.is_file():
|
|
106
|
+
shutil.copy2(artifact, dest_dir / artifact.name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cleanup_partial_backup(dest_path: Path) -> None:
|
|
110
|
+
for artifact in _iter_sqlite_artifacts(dest_path):
|
|
111
|
+
if artifact.exists():
|
|
112
|
+
artifact.unlink()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _iter_sqlite_artifacts(db_path: Path) -> Iterator[Path]:
|
|
116
|
+
yield db_path
|
|
117
|
+
for suffix in SQLITE_COPY_SIBLING_SUFFIXES:
|
|
118
|
+
yield db_path.parent / f"{db_path.name}{suffix}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _sqlite_readonly_uri(db_path: Path) -> str:
|
|
122
|
+
base_uri = db_path.resolve(strict=False).as_uri()
|
|
123
|
+
return f"{base_uri}?{urlencode({'mode': 'ro'})}"
|