lorebase-scribe 0.2.3__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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Run Scribe: `lorebase-scribe` (or `python -m lorebase_scribe`).
|
|
2
|
+
|
|
3
|
+
lorebase-scribe work and schedule until SIGTERM or SIGINT
|
|
4
|
+
lorebase-scribe once [--limit N] run the due jobs, then exit
|
|
5
|
+
lorebase-scribe sync DOMAIN SOURCE [--dry-run]
|
|
6
|
+
synchronize one Source now and print the report
|
|
7
|
+
lorebase-scribe embeddings DOMAIN [--branch B]... [--reindex] [--prune]
|
|
8
|
+
compute missing embeddings now (backfill) and print
|
|
9
|
+
the report
|
|
10
|
+
lorebase-scribe modes clear forget the structured output modes remembered per chat
|
|
11
|
+
endpoint
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
from lorebase_platform.logging import configure_logging
|
|
24
|
+
from lorebase_platform.settings import ConfigError, Settings, load_settings
|
|
25
|
+
from lorebase_platform.telemetry import setup_telemetry
|
|
26
|
+
from lorebase_scribe.service import open_scribe
|
|
27
|
+
from lorebase_scribe.sources import SourceSync
|
|
28
|
+
from lorebase_store.endpoint_modes import clear_modes
|
|
29
|
+
|
|
30
|
+
SERVICE_NAME = "scribe"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(prog="lorebase-scribe", description=__doc__.split("\n")[0])
|
|
35
|
+
commands = parser.add_subparsers(dest="command")
|
|
36
|
+
commands.add_parser("run", help="Work and schedule until stopped (default).")
|
|
37
|
+
once = commands.add_parser("once", help="Run the due jobs, then exit.")
|
|
38
|
+
once.add_argument("--limit", type=int, default=None, help="Stop after this many jobs.")
|
|
39
|
+
sync = commands.add_parser("sync", help="Synchronize one Source now.")
|
|
40
|
+
sync.add_argument("domain")
|
|
41
|
+
sync.add_argument("source", help="Source id or api name.")
|
|
42
|
+
sync.add_argument("--dry-run", action="store_true", help="Compute the delta only.")
|
|
43
|
+
embed = commands.add_parser(
|
|
44
|
+
"embeddings", help="Embed the Records that lack a vector of the configured model now."
|
|
45
|
+
)
|
|
46
|
+
embed.add_argument("domain")
|
|
47
|
+
embed.add_argument(
|
|
48
|
+
"--branch",
|
|
49
|
+
action="append",
|
|
50
|
+
dest="branches",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Only this branch (repeatable); default: every materialized branch.",
|
|
53
|
+
)
|
|
54
|
+
embed.add_argument(
|
|
55
|
+
"--reindex",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Render every text again and re-embed those that changed.",
|
|
58
|
+
)
|
|
59
|
+
embed.add_argument(
|
|
60
|
+
"--prune",
|
|
61
|
+
action="store_true",
|
|
62
|
+
help="After a complete pass, delete the vectors of every other model.",
|
|
63
|
+
)
|
|
64
|
+
modes = commands.add_parser("modes", help="Structured output modes of the chat endpoints.")
|
|
65
|
+
modes.add_argument("action", choices=["clear"], help="Forget every remembered mode.")
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _modes_clear(settings: Settings) -> dict[str, object]:
|
|
70
|
+
async with open_scribe(settings) as scribe:
|
|
71
|
+
return {"cleared": await clear_modes(scribe.context.sessions)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _serve(settings: Settings) -> None:
|
|
75
|
+
stop = asyncio.Event()
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
78
|
+
loop.add_signal_handler(signum, stop.set)
|
|
79
|
+
async with open_scribe(settings) as scribe:
|
|
80
|
+
await scribe.serve(stop)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _once(settings: Settings, limit: int | None) -> int:
|
|
84
|
+
async with open_scribe(settings) as scribe:
|
|
85
|
+
return await scribe.worker.run_once(limit=limit)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _sync(settings: Settings, domain: str, source: str, dry_run: bool) -> dict[str, object]:
|
|
89
|
+
async with open_scribe(settings) as scribe:
|
|
90
|
+
context = scribe.context
|
|
91
|
+
sync = SourceSync(
|
|
92
|
+
context.sessions,
|
|
93
|
+
context.runtime,
|
|
94
|
+
context.settings.sources,
|
|
95
|
+
principal=context.settings.worker.principal,
|
|
96
|
+
connectors=context.connectors,
|
|
97
|
+
)
|
|
98
|
+
return (await sync.run(domain, source, dry_run=dry_run)).as_dict()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _embeddings(
|
|
102
|
+
settings: Settings,
|
|
103
|
+
domain: str,
|
|
104
|
+
branches: list[str] | None,
|
|
105
|
+
reindex: bool,
|
|
106
|
+
prune: bool,
|
|
107
|
+
) -> dict[str, object]:
|
|
108
|
+
"""Run passes until the Domain is complete (each pass bounded like a job)."""
|
|
109
|
+
async with open_scribe(settings) as scribe:
|
|
110
|
+
embedding = scribe.context.embedding_pass()
|
|
111
|
+
reports: list[dict[str, object]] = []
|
|
112
|
+
resume: tuple[str, str, str] | None = None
|
|
113
|
+
while True:
|
|
114
|
+
report = await embedding.run(
|
|
115
|
+
domain, branches=branches, reindex=reindex, resume=resume, prune=prune
|
|
116
|
+
)
|
|
117
|
+
reports.append(report.as_dict())
|
|
118
|
+
if report.busy or report.resume is None:
|
|
119
|
+
break
|
|
120
|
+
resume = report.resume
|
|
121
|
+
return {"passes": reports, "complete": report.complete}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
125
|
+
"""Load settings, configure logging and telemetry, then run the command."""
|
|
126
|
+
args = _parser().parse_args(argv)
|
|
127
|
+
try:
|
|
128
|
+
settings = load_settings()
|
|
129
|
+
except ConfigError as exc:
|
|
130
|
+
sys.exit(str(exc))
|
|
131
|
+
configure_logging(settings.logging, service=SERVICE_NAME)
|
|
132
|
+
telemetry = setup_telemetry(settings.telemetry)
|
|
133
|
+
try:
|
|
134
|
+
if args.command == "once":
|
|
135
|
+
ran = asyncio.run(_once(settings, args.limit))
|
|
136
|
+
sys.stdout.write(json.dumps({"jobs_run": ran}) + "\n")
|
|
137
|
+
elif args.command == "embeddings":
|
|
138
|
+
result = asyncio.run(
|
|
139
|
+
_embeddings(settings, args.domain, args.branches, args.reindex, args.prune)
|
|
140
|
+
)
|
|
141
|
+
sys.stdout.write(json.dumps(result, default=str) + "\n")
|
|
142
|
+
return 0 if result["complete"] else 1
|
|
143
|
+
elif args.command == "modes":
|
|
144
|
+
sys.stdout.write(json.dumps(asyncio.run(_modes_clear(settings))) + "\n")
|
|
145
|
+
elif args.command == "sync":
|
|
146
|
+
report = asyncio.run(_sync(settings, args.domain, args.source, args.dry_run))
|
|
147
|
+
sys.stdout.write(json.dumps(report, default=str) + "\n")
|
|
148
|
+
else:
|
|
149
|
+
asyncio.run(_serve(settings))
|
|
150
|
+
finally:
|
|
151
|
+
telemetry.shutdown()
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Publication of signed audit checkpoints outside the database (`audit_checkpoint` jobs).
|
|
2
|
+
|
|
3
|
+
Each job reads the head of every chain of the configured streams, signs one checkpoint per
|
|
4
|
+
stream (`lorebase_store.auth.checkpoints`) and writes it to the sink of
|
|
5
|
+
`scribe.audit_checkpoints`:
|
|
6
|
+
|
|
7
|
+
- `file`: a new file under `directory` (never replacing one), written to a temporary name,
|
|
8
|
+
synced and linked into place;
|
|
9
|
+
- `http`: a PUT or POST of the document to `http.url`;
|
|
10
|
+
- `s3`: a PUT to an S3-compatible bucket, signed with Signature Version 4.
|
|
11
|
+
|
|
12
|
+
HTTP targets get the checks of webhook deliveries: allowed schemes, every resolved address
|
|
13
|
+
global unicast unless `allow_private_targets`, connections pinned to the checked addresses
|
|
14
|
+
with the host name kept for TLS, no redirects, no proxy from the environment (the shared
|
|
15
|
+
client is built with `trust_env=False`), and the exchange bounded as a whole.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import os
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Callable, Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Protocol
|
|
30
|
+
from urllib.parse import quote
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
34
|
+
|
|
35
|
+
from lorebase_scribe.settings import (
|
|
36
|
+
AuditCheckpointSettings,
|
|
37
|
+
CheckpointHttpSettings,
|
|
38
|
+
CheckpointS3Settings,
|
|
39
|
+
)
|
|
40
|
+
from lorebase_scribe.webhooks import pin
|
|
41
|
+
from lorebase_store.auth import AuditRepository, AuditStream
|
|
42
|
+
from lorebase_store.auth.checkpoints import Checkpoint, load_private_key, signed_document
|
|
43
|
+
from lorebase_store.webhooks.delivery import Resolver, TargetPolicy, check_target, resolve_host
|
|
44
|
+
|
|
45
|
+
MEDIA_TYPE = "application/json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CheckpointSink(Protocol):
|
|
49
|
+
"""Stores one checkpoint document under its name; returns where it went."""
|
|
50
|
+
|
|
51
|
+
async def put(self, name: str, document: bytes) -> str: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SinkError(RuntimeError):
|
|
55
|
+
"""A checkpoint could not be stored."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FileSink:
|
|
59
|
+
"""Checkpoints as files under a directory; an existing file is never replaced."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, directory: str) -> None:
|
|
62
|
+
self._root = Path(directory)
|
|
63
|
+
|
|
64
|
+
def _write(self, name: str, document: bytes) -> str:
|
|
65
|
+
root = self._root.resolve()
|
|
66
|
+
target = (root / name).resolve()
|
|
67
|
+
if root not in target.parents:
|
|
68
|
+
raise SinkError(f"checkpoint name {name!r} leaves the directory")
|
|
69
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
|
71
|
+
try:
|
|
72
|
+
with temporary.open("xb") as handle:
|
|
73
|
+
handle.write(document)
|
|
74
|
+
handle.flush()
|
|
75
|
+
os.fsync(handle.fileno())
|
|
76
|
+
os.link(temporary, target) # fails rather than replacing an existing checkpoint
|
|
77
|
+
except FileExistsError:
|
|
78
|
+
raise SinkError(f"checkpoint {name} exists already") from None
|
|
79
|
+
finally:
|
|
80
|
+
temporary.unlink(missing_ok=True)
|
|
81
|
+
return str(target)
|
|
82
|
+
|
|
83
|
+
async def put(self, name: str, document: bytes) -> str:
|
|
84
|
+
return await asyncio.to_thread(self._write, name, document)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def send_checked(
|
|
88
|
+
client: httpx.AsyncClient,
|
|
89
|
+
method: str,
|
|
90
|
+
url: str,
|
|
91
|
+
*,
|
|
92
|
+
body: bytes,
|
|
93
|
+
headers: Mapping[str, str],
|
|
94
|
+
policy: TargetPolicy,
|
|
95
|
+
timeout_s: float,
|
|
96
|
+
resolve: Resolver = resolve_host,
|
|
97
|
+
) -> int:
|
|
98
|
+
"""Send to `url` after the target checks, connecting only to the checked addresses;
|
|
99
|
+
the status code. `SinkError` when the target is refused or answers other than 2xx."""
|
|
100
|
+
target = await check_target(url, policy, resolve)
|
|
101
|
+
if target.problem is not None:
|
|
102
|
+
raise SinkError(f"checkpoint target refused: {target.problem}")
|
|
103
|
+
parsed = httpx.URL(url)
|
|
104
|
+
attempts = [pin(parsed, address) for address in target.addresses] or [(parsed, {}, {})]
|
|
105
|
+
failure: Exception | None = None
|
|
106
|
+
async with asyncio.timeout(timeout_s):
|
|
107
|
+
for pinned, host, extensions in attempts:
|
|
108
|
+
try:
|
|
109
|
+
# The response body is never read: a target that streams cannot hold the job.
|
|
110
|
+
async with client.stream(
|
|
111
|
+
method,
|
|
112
|
+
pinned,
|
|
113
|
+
content=body,
|
|
114
|
+
headers={**headers, **host},
|
|
115
|
+
timeout=httpx.Timeout(timeout_s),
|
|
116
|
+
follow_redirects=False,
|
|
117
|
+
extensions=extensions,
|
|
118
|
+
) as response:
|
|
119
|
+
status = response.status_code
|
|
120
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as error:
|
|
121
|
+
failure = error # the next checked address, never a new lookup
|
|
122
|
+
continue
|
|
123
|
+
if not 200 <= status < 300:
|
|
124
|
+
raise SinkError(f"checkpoint target answered {status}")
|
|
125
|
+
return status
|
|
126
|
+
raise SinkError(f"cannot connect to the checkpoint target: {failure}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class HttpSink:
|
|
130
|
+
"""Checkpoints sent to an HTTP endpoint (`http.url`, `{name}` replaced)."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
client: httpx.AsyncClient,
|
|
135
|
+
settings: CheckpointHttpSettings,
|
|
136
|
+
*,
|
|
137
|
+
resolve: Resolver = resolve_host,
|
|
138
|
+
) -> None:
|
|
139
|
+
if settings.url is None:
|
|
140
|
+
raise ValueError("the http sink needs http.url")
|
|
141
|
+
self._client = client
|
|
142
|
+
self._settings = settings
|
|
143
|
+
self._url = settings.url
|
|
144
|
+
self._resolve = resolve
|
|
145
|
+
|
|
146
|
+
async def put(self, name: str, document: bytes) -> str:
|
|
147
|
+
url = self._url.replace("{name}", quote(name, safe="/-_.~"))
|
|
148
|
+
headers = {k: v.get_secret_value() for k, v in self._settings.headers.items()}
|
|
149
|
+
await send_checked(
|
|
150
|
+
self._client,
|
|
151
|
+
self._settings.method,
|
|
152
|
+
url,
|
|
153
|
+
body=document,
|
|
154
|
+
headers={"Content-Type": MEDIA_TYPE, **headers},
|
|
155
|
+
policy=self._settings,
|
|
156
|
+
timeout_s=self._settings.timeout_s,
|
|
157
|
+
resolve=self._resolve,
|
|
158
|
+
)
|
|
159
|
+
return url
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _hmac(key: bytes, message: str) -> bytes:
|
|
163
|
+
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sigv4_headers(
|
|
167
|
+
method: str,
|
|
168
|
+
url: httpx.URL,
|
|
169
|
+
*,
|
|
170
|
+
region: str,
|
|
171
|
+
access_key: str,
|
|
172
|
+
secret_key: str,
|
|
173
|
+
body: bytes,
|
|
174
|
+
now: datetime,
|
|
175
|
+
session_token: str | None = None,
|
|
176
|
+
signed: Mapping[str, str] | None = None,
|
|
177
|
+
) -> dict[str, str]:
|
|
178
|
+
"""Headers of an S3 request signed with AWS Signature Version 4 (header form, single
|
|
179
|
+
chunk, no query string). `signed` are further headers to sign and send. The path of
|
|
180
|
+
`url` must already be percent-encoded as S3 expects (each segment once)."""
|
|
181
|
+
stamp = now.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
182
|
+
day = stamp[:8]
|
|
183
|
+
headers = {
|
|
184
|
+
"host": url.netloc.decode("ascii"),
|
|
185
|
+
"x-amz-content-sha256": hashlib.sha256(body).hexdigest(),
|
|
186
|
+
"x-amz-date": stamp,
|
|
187
|
+
**{k.lower(): v.strip() for k, v in (signed or {}).items()},
|
|
188
|
+
}
|
|
189
|
+
if session_token:
|
|
190
|
+
headers["x-amz-security-token"] = session_token
|
|
191
|
+
names = sorted(headers)
|
|
192
|
+
canonical = "\n".join(
|
|
193
|
+
[
|
|
194
|
+
method,
|
|
195
|
+
url.raw_path.decode("ascii").split("?", 1)[0] or "/",
|
|
196
|
+
"",
|
|
197
|
+
"".join(f"{name}:{headers[name]}\n" for name in names),
|
|
198
|
+
";".join(names),
|
|
199
|
+
headers["x-amz-content-sha256"],
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
scope = f"{day}/{region}/s3/aws4_request"
|
|
203
|
+
to_sign = "\n".join(
|
|
204
|
+
["AWS4-HMAC-SHA256", stamp, scope, hashlib.sha256(canonical.encode()).hexdigest()]
|
|
205
|
+
)
|
|
206
|
+
key = _hmac(("AWS4" + secret_key).encode("utf-8"), day)
|
|
207
|
+
for part in (region, "s3", "aws4_request"):
|
|
208
|
+
key = _hmac(key, part)
|
|
209
|
+
signature = hmac.new(key, to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
210
|
+
headers["authorization"] = (
|
|
211
|
+
f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
|
|
212
|
+
f"SignedHeaders={';'.join(names)}, Signature={signature}"
|
|
213
|
+
)
|
|
214
|
+
del headers["host"] # sent by the client (kept on pinned connections by `pin`)
|
|
215
|
+
return headers
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class S3Sink:
|
|
219
|
+
"""Checkpoints PUT into an S3-compatible bucket."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
client: httpx.AsyncClient,
|
|
224
|
+
settings: CheckpointS3Settings,
|
|
225
|
+
*,
|
|
226
|
+
resolve: Resolver = resolve_host,
|
|
227
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
228
|
+
) -> None:
|
|
229
|
+
if settings.endpoint is None or settings.bucket is None:
|
|
230
|
+
raise ValueError("the s3 sink needs s3.endpoint and s3.bucket")
|
|
231
|
+
if settings.access_key_id is None or settings.secret_access_key is None:
|
|
232
|
+
raise ValueError("the s3 sink needs s3.access_key_id and s3.secret_access_key")
|
|
233
|
+
self._client = client
|
|
234
|
+
self._settings = settings
|
|
235
|
+
self._endpoint = httpx.URL(settings.endpoint)
|
|
236
|
+
self._bucket = settings.bucket
|
|
237
|
+
self._access_key = settings.access_key_id
|
|
238
|
+
self._secret_key = settings.secret_access_key
|
|
239
|
+
self._resolve = resolve
|
|
240
|
+
self._clock = clock
|
|
241
|
+
|
|
242
|
+
def url(self, name: str) -> httpx.URL:
|
|
243
|
+
"""URL of an object."""
|
|
244
|
+
key = "/".join(quote(part, safe="-_.~") for part in name.split("/"))
|
|
245
|
+
base = self._endpoint.raw_path.decode("ascii").rstrip("/")
|
|
246
|
+
if self._settings.path_style:
|
|
247
|
+
return self._endpoint.copy_with(raw_path=f"{base}/{self._bucket}/{key}".encode())
|
|
248
|
+
host = f"{self._bucket}.{self._endpoint.host}"
|
|
249
|
+
return self._endpoint.copy_with(host=host, raw_path=f"{base}/{key}".encode())
|
|
250
|
+
|
|
251
|
+
async def put(self, name: str, document: bytes) -> str:
|
|
252
|
+
settings = self._settings
|
|
253
|
+
url = self.url(name)
|
|
254
|
+
headers = sigv4_headers(
|
|
255
|
+
"PUT",
|
|
256
|
+
url,
|
|
257
|
+
region=settings.region,
|
|
258
|
+
access_key=self._access_key.get_secret_value(),
|
|
259
|
+
secret_key=self._secret_key.get_secret_value(),
|
|
260
|
+
session_token=(
|
|
261
|
+
settings.session_token.get_secret_value() if settings.session_token else None
|
|
262
|
+
),
|
|
263
|
+
body=document,
|
|
264
|
+
now=self._clock(),
|
|
265
|
+
signed={"content-type": MEDIA_TYPE},
|
|
266
|
+
)
|
|
267
|
+
await send_checked(
|
|
268
|
+
self._client,
|
|
269
|
+
"PUT",
|
|
270
|
+
str(url),
|
|
271
|
+
body=document,
|
|
272
|
+
headers=headers,
|
|
273
|
+
policy=settings,
|
|
274
|
+
timeout_s=settings.timeout_s,
|
|
275
|
+
resolve=self._resolve,
|
|
276
|
+
)
|
|
277
|
+
return str(url)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_sink(settings: AuditCheckpointSettings, client: httpx.AsyncClient) -> CheckpointSink:
|
|
281
|
+
"""The sink the settings name."""
|
|
282
|
+
if settings.sink == "http":
|
|
283
|
+
return HttpSink(client, settings.http)
|
|
284
|
+
if settings.sink == "s3":
|
|
285
|
+
return S3Sink(client, settings.s3)
|
|
286
|
+
if settings.directory is None:
|
|
287
|
+
raise ValueError("the file sink needs a directory")
|
|
288
|
+
return FileSink(settings.directory)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class Published:
|
|
293
|
+
"""One checkpoint written."""
|
|
294
|
+
|
|
295
|
+
stream: str
|
|
296
|
+
chains: int
|
|
297
|
+
location: str
|
|
298
|
+
|
|
299
|
+
def as_dict(self) -> dict[str, Any]:
|
|
300
|
+
return {"stream": self.stream, "chains": self.chains, "location": self.location}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def publish_checkpoints(
|
|
304
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
305
|
+
settings: AuditCheckpointSettings,
|
|
306
|
+
sink: CheckpointSink,
|
|
307
|
+
*,
|
|
308
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
309
|
+
) -> list[Published]:
|
|
310
|
+
"""Sign and store one checkpoint per configured stream."""
|
|
311
|
+
if settings.signing_key is None:
|
|
312
|
+
raise ValueError("scribe.audit_checkpoints.signing_key is not set")
|
|
313
|
+
key = load_private_key(settings.signing_key.get_secret_value())
|
|
314
|
+
published = []
|
|
315
|
+
for name in settings.streams:
|
|
316
|
+
stream = AuditStream(name)
|
|
317
|
+
async with sessions() as session, session.begin():
|
|
318
|
+
heads = await AuditRepository(session, stream).heads()
|
|
319
|
+
checkpoint = Checkpoint(stream, clock(), settings.key_id, tuple(heads))
|
|
320
|
+
location = await sink.put(
|
|
321
|
+
checkpoint.name(settings.prefix), signed_document(checkpoint, key)
|
|
322
|
+
)
|
|
323
|
+
published.append(Published(stream.value, len(heads), location))
|
|
324
|
+
return published
|
lorebase_scribe/cron.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Five-field cron expressions (minute hour day-of-month month day-of-week), evaluated in UTC.
|
|
2
|
+
|
|
3
|
+
Fields accept `*`, numbers, ranges `a-b`, lists `a,b` and steps `*/n` or `a-b/n`. Day of week
|
|
4
|
+
runs from 0 (Sunday) to 6, with 7 also meaning Sunday. As in classic cron, when both day of
|
|
5
|
+
month and day of week are restricted a day matching either one matches.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
|
|
13
|
+
_BOUNDS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
|
|
14
|
+
_NAMES = ("minute", "hour", "day of month", "month", "day of week")
|
|
15
|
+
_SEARCH_LIMIT = timedelta(days=366 * 5)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CronError(ValueError):
|
|
19
|
+
"""The expression is not a valid five-field cron expression."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _field(text: str, index: int) -> frozenset[int]:
|
|
23
|
+
low, high = _BOUNDS[index]
|
|
24
|
+
values: set[int] = set()
|
|
25
|
+
for part in text.split(","):
|
|
26
|
+
body, _, step_text = part.partition("/")
|
|
27
|
+
step = 1
|
|
28
|
+
if step_text:
|
|
29
|
+
if not step_text.isdigit() or int(step_text) < 1:
|
|
30
|
+
raise CronError(f"bad step {step_text!r} in the {_NAMES[index]} field")
|
|
31
|
+
step = int(step_text)
|
|
32
|
+
if body == "*":
|
|
33
|
+
start, end = low, high
|
|
34
|
+
elif "-" in body:
|
|
35
|
+
first, _, last = body.partition("-")
|
|
36
|
+
if not (first.isdigit() and last.isdigit()):
|
|
37
|
+
raise CronError(f"bad range {body!r} in the {_NAMES[index]} field")
|
|
38
|
+
start, end = int(first), int(last)
|
|
39
|
+
elif body.isdigit():
|
|
40
|
+
start = end = int(body)
|
|
41
|
+
if step_text:
|
|
42
|
+
end = high
|
|
43
|
+
else:
|
|
44
|
+
raise CronError(f"bad value {body!r} in the {_NAMES[index]} field")
|
|
45
|
+
if not (low <= start <= end <= high):
|
|
46
|
+
raise CronError(f"{body!r} is outside {low}-{high} in the {_NAMES[index]} field")
|
|
47
|
+
values.update(range(start, end + 1, step))
|
|
48
|
+
return frozenset(values)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Cron:
|
|
53
|
+
"""A parsed expression; `matches` and `previous` work on UTC datetimes."""
|
|
54
|
+
|
|
55
|
+
expression: str
|
|
56
|
+
minutes: frozenset[int]
|
|
57
|
+
hours: frozenset[int]
|
|
58
|
+
days: frozenset[int]
|
|
59
|
+
months: frozenset[int]
|
|
60
|
+
weekdays: frozenset[int]
|
|
61
|
+
any_day: bool
|
|
62
|
+
any_weekday: bool
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def parse(cls, expression: str) -> Cron:
|
|
66
|
+
parts = expression.split()
|
|
67
|
+
if len(parts) != len(_BOUNDS):
|
|
68
|
+
raise CronError(f"expected 5 fields, got {len(parts)}: {expression!r}")
|
|
69
|
+
fields = [_field(text, index) for index, text in enumerate(parts)]
|
|
70
|
+
weekdays = frozenset(0 if day == 7 else day for day in fields[4])
|
|
71
|
+
return cls(
|
|
72
|
+
expression=expression,
|
|
73
|
+
minutes=fields[0],
|
|
74
|
+
hours=fields[1],
|
|
75
|
+
days=fields[2],
|
|
76
|
+
months=fields[3],
|
|
77
|
+
weekdays=weekdays,
|
|
78
|
+
any_day=parts[2] == "*",
|
|
79
|
+
any_weekday=parts[4] == "*",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _day_matches(self, moment: datetime) -> bool:
|
|
83
|
+
if moment.month not in self.months:
|
|
84
|
+
return False
|
|
85
|
+
by_day = moment.day in self.days
|
|
86
|
+
by_weekday = (moment.isoweekday() % 7) in self.weekdays
|
|
87
|
+
if self.any_day or self.any_weekday:
|
|
88
|
+
return by_day and by_weekday
|
|
89
|
+
return by_day or by_weekday
|
|
90
|
+
|
|
91
|
+
def matches(self, moment: datetime) -> bool:
|
|
92
|
+
return (
|
|
93
|
+
moment.minute in self.minutes
|
|
94
|
+
and moment.hour in self.hours
|
|
95
|
+
and self._day_matches(moment)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def previous(self, moment: datetime) -> datetime:
|
|
99
|
+
"""The latest firing time at or before `moment` (whole minutes)."""
|
|
100
|
+
current = moment.replace(second=0, microsecond=0)
|
|
101
|
+
limit = current - _SEARCH_LIMIT
|
|
102
|
+
while current >= limit:
|
|
103
|
+
if not self._day_matches(current):
|
|
104
|
+
current = current.replace(hour=23, minute=59) - timedelta(days=1)
|
|
105
|
+
continue
|
|
106
|
+
if current.hour not in self.hours:
|
|
107
|
+
current = current.replace(minute=59) - timedelta(hours=1)
|
|
108
|
+
continue
|
|
109
|
+
if current.minute in self.minutes:
|
|
110
|
+
return current
|
|
111
|
+
current -= timedelta(minutes=1)
|
|
112
|
+
raise CronError(f"{self.expression!r} never fires")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_cron(expression: str) -> Cron:
|
|
116
|
+
"""Parse a cron expression; raises `CronError` with the offending field."""
|
|
117
|
+
return Cron.parse(expression)
|