lorebase-scribe 0.2.3__tar.gz
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-0.2.3/.gitignore +79 -0
- lorebase_scribe-0.2.3/PKG-INFO +23 -0
- lorebase_scribe-0.2.3/pyproject.toml +51 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/__init__.py +3 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/__main__.py +156 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/cron.py +117 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/jobs.py +418 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/service.py +146 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/settings.py +521 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe-0.2.3/src/lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3/tests/conftest.py +34 -0
- lorebase_scribe-0.2.3/tests/integration/conftest.py +54 -0
- lorebase_scribe-0.2.3/tests/integration/extraction_support.py +103 -0
- lorebase_scribe-0.2.3/tests/integration/support.py +172 -0
- lorebase_scribe-0.2.3/tests/integration/test_audit_checkpoints.py +135 -0
- lorebase_scribe-0.2.3/tests/integration/test_embeddings.py +605 -0
- lorebase_scribe-0.2.3/tests/integration/test_extraction.py +424 -0
- lorebase_scribe-0.2.3/tests/integration/test_lineage_startup.py +60 -0
- lorebase_scribe-0.2.3/tests/integration/test_schema_suggestions.py +293 -0
- lorebase_scribe-0.2.3/tests/integration/test_source_sync.py +339 -0
- lorebase_scribe-0.2.3/tests/integration/test_sql_sources.py +247 -0
- lorebase_scribe-0.2.3/tests/integration/test_worker.py +640 -0
- lorebase_scribe-0.2.3/tests/sandbox_targets.py +54 -0
- lorebase_scribe-0.2.3/tests/test_checkpoint_sinks.py +166 -0
- lorebase_scribe-0.2.3/tests/test_connectors.py +347 -0
- lorebase_scribe-0.2.3/tests/test_cron.py +48 -0
- lorebase_scribe-0.2.3/tests/test_delta.py +182 -0
- lorebase_scribe-0.2.3/tests/test_extraction_discovery.py +332 -0
- lorebase_scribe-0.2.3/tests/test_extraction_link_values.py +200 -0
- lorebase_scribe-0.2.3/tests/test_extraction_rules.py +398 -0
- lorebase_scribe-0.2.3/tests/test_extraction_text.py +127 -0
- lorebase_scribe-0.2.3/tests/test_extraction_vault_items.py +107 -0
- lorebase_scribe-0.2.3/tests/test_http_json_connector.py +487 -0
- lorebase_scribe-0.2.3/tests/test_llm_discovery.py +312 -0
- lorebase_scribe-0.2.3/tests/test_mapping.py +290 -0
- lorebase_scribe-0.2.3/tests/test_parser_sandbox.py +107 -0
- lorebase_scribe-0.2.3/tests/test_record_connectors.py +299 -0
- lorebase_scribe-0.2.3/tests/test_service.py +46 -0
- lorebase_scribe-0.2.3/tests/test_settings.py +66 -0
- lorebase_scribe-0.2.3/tests/test_source_connections.py +66 -0
- lorebase_scribe-0.2.3/tests/test_source_profile.py +137 -0
- lorebase_scribe-0.2.3/tests/test_sql_connectors.py +527 -0
- lorebase_scribe-0.2.3/tests/test_telemetry.py +27 -0
- lorebase_scribe-0.2.3/tests/test_webhook_job.py +58 -0
- lorebase_scribe-0.2.3/tests/test_webhook_sender.py +256 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.python-eggs/
|
|
14
|
+
pip-wheel-metadata/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.pyre/
|
|
19
|
+
.pytype/
|
|
20
|
+
.hypothesis/
|
|
21
|
+
.coverage
|
|
22
|
+
.coverage.*
|
|
23
|
+
coverage.xml
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.nox/
|
|
27
|
+
*.prof
|
|
28
|
+
|
|
29
|
+
# Node
|
|
30
|
+
node_modules/
|
|
31
|
+
.pnpm-store/
|
|
32
|
+
.npm/
|
|
33
|
+
.yarn/
|
|
34
|
+
!.yarn/releases
|
|
35
|
+
.pnp.*
|
|
36
|
+
npm-debug.log*
|
|
37
|
+
yarn-debug.log*
|
|
38
|
+
yarn-error.log*
|
|
39
|
+
pnpm-debug.log*
|
|
40
|
+
.vite/
|
|
41
|
+
*.tsbuildinfo
|
|
42
|
+
apps/web/dist/
|
|
43
|
+
apps/web/.vitest/
|
|
44
|
+
|
|
45
|
+
# Environment and secrets
|
|
46
|
+
.env
|
|
47
|
+
.env.*
|
|
48
|
+
!.env.example
|
|
49
|
+
*.pem
|
|
50
|
+
*.key
|
|
51
|
+
secrets/
|
|
52
|
+
|
|
53
|
+
# Docker
|
|
54
|
+
docker-compose.override.yaml
|
|
55
|
+
docker-compose.override.yml
|
|
56
|
+
.docker/
|
|
57
|
+
|
|
58
|
+
# Data and local artifacts
|
|
59
|
+
*.sqlite
|
|
60
|
+
*.db
|
|
61
|
+
/tmp/
|
|
62
|
+
/data/
|
|
63
|
+
tests/perf/results/
|
|
64
|
+
|
|
65
|
+
# Editors and OS
|
|
66
|
+
.idea/
|
|
67
|
+
.vscode/*
|
|
68
|
+
!.vscode/extensions.json
|
|
69
|
+
!.vscode/settings.example.json
|
|
70
|
+
*.swp
|
|
71
|
+
*.swo
|
|
72
|
+
*~
|
|
73
|
+
.DS_Store
|
|
74
|
+
Thumbs.db
|
|
75
|
+
*.code-workspace
|
|
76
|
+
.history/
|
|
77
|
+
|
|
78
|
+
# Session worktrees
|
|
79
|
+
.claude/
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lorebase-scribe
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Lorebase Scribe: job worker, scheduler and Source synchronization
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: asyncpg>=0.29
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: lorebase-core<0.3,>=0.2
|
|
9
|
+
Requires-Dist: lorebase-inference<0.3,>=0.2
|
|
10
|
+
Requires-Dist: lorebase-platform<0.3,>=0.2
|
|
11
|
+
Requires-Dist: lorebase-store<0.3,>=0.2
|
|
12
|
+
Requires-Dist: opentelemetry-api>=1.25
|
|
13
|
+
Requires-Dist: pydantic>=2.7
|
|
14
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.30
|
|
15
|
+
Provides-Extra: documents
|
|
16
|
+
Requires-Dist: pypdf>=4.2; extra == 'documents'
|
|
17
|
+
Requires-Dist: python-docx>=1.1; extra == 'documents'
|
|
18
|
+
Provides-Extra: mssql
|
|
19
|
+
Requires-Dist: pymssql>=2.3; extra == 'mssql'
|
|
20
|
+
Provides-Extra: mysql
|
|
21
|
+
Requires-Dist: asyncmy>=0.2.10; extra == 'mysql'
|
|
22
|
+
Provides-Extra: parquet
|
|
23
|
+
Requires-Dist: pyarrow>=15; extra == 'parquet'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lorebase-scribe"
|
|
3
|
+
version = "0.2.3"
|
|
4
|
+
description = "Lorebase Scribe: job worker, scheduler and Source synchronization"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
# lorebase-store: job queue, commit pipeline, merges, webhooks, index jobs and GC.
|
|
7
|
+
# lorebase-inference: model interfaces for the extraction jobs to come.
|
|
8
|
+
# httpx: webhook deliveries, CSV Sources served over HTTP(S) and http_json Sources.
|
|
9
|
+
# asyncpg and sqlalchemy: reading Postgres Sources (the drivers the store already uses).
|
|
10
|
+
dependencies = [
|
|
11
|
+
"lorebase-core>=0.2,<0.3",
|
|
12
|
+
"lorebase-platform>=0.2,<0.3",
|
|
13
|
+
"lorebase-store>=0.2,<0.3",
|
|
14
|
+
"lorebase-inference>=0.2,<0.3",
|
|
15
|
+
"sqlalchemy[asyncio]>=2.0.30",
|
|
16
|
+
"asyncpg>=0.29",
|
|
17
|
+
"httpx>=0.27",
|
|
18
|
+
"pydantic>=2.7",
|
|
19
|
+
"opentelemetry-api>=1.25",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
# documents: parsers for extraction jobs (design 9.2 step 2). Markdown and plain text need
|
|
24
|
+
# nothing; pypdf reads the text layer of PDF files (BSD-3-Clause, pure Python) and python-docx
|
|
25
|
+
# reads Word documents (MIT). Without them those media types fail with a message naming this
|
|
26
|
+
# extra.
|
|
27
|
+
documents = ["pypdf>=4.2", "python-docx>=1.1"]
|
|
28
|
+
# parquet: pyarrow (Apache-2.0) reads parquet Sources batch by batch. Without it those Sources
|
|
29
|
+
# fail with a message naming this extra.
|
|
30
|
+
parquet = ["pyarrow>=15"]
|
|
31
|
+
# mysql: asyncmy (Apache-2.0, async, prebuilt wheels) reads MySQL and MariaDB Sources through
|
|
32
|
+
# SQLAlchemy. Without it those Sources fail with a message naming this extra.
|
|
33
|
+
mysql = ["asyncmy>=0.2.10"]
|
|
34
|
+
# mssql: pymssql (LGPL-2.1, FreeTDS bundled in the wheels, no ODBC driver to install) reads SQL
|
|
35
|
+
# Server Sources through SQLAlchemy in worker threads. Without it those Sources fail with a
|
|
36
|
+
# message naming this extra.
|
|
37
|
+
mssql = ["pymssql>=2.3"]
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
# Disposable MySQL and SQL Server servers for the integration tests of those Sources.
|
|
41
|
+
dev = ["testcontainers[mysql,mssql]>=4.8"]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
lorebase-scribe = "lorebase_scribe.__main__:main"
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["hatchling"]
|
|
48
|
+
build-backend = "hatchling.build"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/lorebase_scribe"]
|
|
@@ -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
|