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.
Files changed (75) hide show
  1. lorebase_scribe-0.2.3/.gitignore +79 -0
  2. lorebase_scribe-0.2.3/PKG-INFO +23 -0
  3. lorebase_scribe-0.2.3/pyproject.toml +51 -0
  4. lorebase_scribe-0.2.3/src/lorebase_scribe/__init__.py +3 -0
  5. lorebase_scribe-0.2.3/src/lorebase_scribe/__main__.py +156 -0
  6. lorebase_scribe-0.2.3/src/lorebase_scribe/checkpoints.py +324 -0
  7. lorebase_scribe-0.2.3/src/lorebase_scribe/cron.py +117 -0
  8. lorebase_scribe-0.2.3/src/lorebase_scribe/embeddings.py +570 -0
  9. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/__init__.py +17 -0
  10. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/assemble.py +339 -0
  11. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/candidates.py +136 -0
  12. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/discovery.py +567 -0
  13. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/llm_discovery.py +271 -0
  14. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/matching.py +285 -0
  15. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/ontology.py +432 -0
  16. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/parsing.py +114 -0
  17. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/pipeline.py +1091 -0
  18. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/relations.py +194 -0
  19. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/sandbox.py +269 -0
  20. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/settings.py +293 -0
  21. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/text.py +179 -0
  22. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/tier2.py +573 -0
  23. lorebase_scribe-0.2.3/src/lorebase_scribe/extraction/values.py +282 -0
  24. lorebase_scribe-0.2.3/src/lorebase_scribe/jobs.py +418 -0
  25. lorebase_scribe-0.2.3/src/lorebase_scribe/scheduler.py +318 -0
  26. lorebase_scribe-0.2.3/src/lorebase_scribe/service.py +146 -0
  27. lorebase_scribe-0.2.3/src/lorebase_scribe/settings.py +521 -0
  28. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/__init__.py +60 -0
  29. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/access.py +241 -0
  30. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/connectors.py +320 -0
  31. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/delta.py +175 -0
  32. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/http_json.py +283 -0
  33. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/mapping.py +429 -0
  34. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/profile.py +36 -0
  35. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/records.py +298 -0
  36. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/sql.py +332 -0
  37. lorebase_scribe-0.2.3/src/lorebase_scribe/sources/sync.py +730 -0
  38. lorebase_scribe-0.2.3/src/lorebase_scribe/telemetry.py +113 -0
  39. lorebase_scribe-0.2.3/src/lorebase_scribe/webhooks.py +99 -0
  40. lorebase_scribe-0.2.3/src/lorebase_scribe/worker.py +254 -0
  41. lorebase_scribe-0.2.3/tests/conftest.py +34 -0
  42. lorebase_scribe-0.2.3/tests/integration/conftest.py +54 -0
  43. lorebase_scribe-0.2.3/tests/integration/extraction_support.py +103 -0
  44. lorebase_scribe-0.2.3/tests/integration/support.py +172 -0
  45. lorebase_scribe-0.2.3/tests/integration/test_audit_checkpoints.py +135 -0
  46. lorebase_scribe-0.2.3/tests/integration/test_embeddings.py +605 -0
  47. lorebase_scribe-0.2.3/tests/integration/test_extraction.py +424 -0
  48. lorebase_scribe-0.2.3/tests/integration/test_lineage_startup.py +60 -0
  49. lorebase_scribe-0.2.3/tests/integration/test_schema_suggestions.py +293 -0
  50. lorebase_scribe-0.2.3/tests/integration/test_source_sync.py +339 -0
  51. lorebase_scribe-0.2.3/tests/integration/test_sql_sources.py +247 -0
  52. lorebase_scribe-0.2.3/tests/integration/test_worker.py +640 -0
  53. lorebase_scribe-0.2.3/tests/sandbox_targets.py +54 -0
  54. lorebase_scribe-0.2.3/tests/test_checkpoint_sinks.py +166 -0
  55. lorebase_scribe-0.2.3/tests/test_connectors.py +347 -0
  56. lorebase_scribe-0.2.3/tests/test_cron.py +48 -0
  57. lorebase_scribe-0.2.3/tests/test_delta.py +182 -0
  58. lorebase_scribe-0.2.3/tests/test_extraction_discovery.py +332 -0
  59. lorebase_scribe-0.2.3/tests/test_extraction_link_values.py +200 -0
  60. lorebase_scribe-0.2.3/tests/test_extraction_rules.py +398 -0
  61. lorebase_scribe-0.2.3/tests/test_extraction_text.py +127 -0
  62. lorebase_scribe-0.2.3/tests/test_extraction_vault_items.py +107 -0
  63. lorebase_scribe-0.2.3/tests/test_http_json_connector.py +487 -0
  64. lorebase_scribe-0.2.3/tests/test_llm_discovery.py +312 -0
  65. lorebase_scribe-0.2.3/tests/test_mapping.py +290 -0
  66. lorebase_scribe-0.2.3/tests/test_parser_sandbox.py +107 -0
  67. lorebase_scribe-0.2.3/tests/test_record_connectors.py +299 -0
  68. lorebase_scribe-0.2.3/tests/test_service.py +46 -0
  69. lorebase_scribe-0.2.3/tests/test_settings.py +66 -0
  70. lorebase_scribe-0.2.3/tests/test_source_connections.py +66 -0
  71. lorebase_scribe-0.2.3/tests/test_source_profile.py +137 -0
  72. lorebase_scribe-0.2.3/tests/test_sql_connectors.py +527 -0
  73. lorebase_scribe-0.2.3/tests/test_telemetry.py +27 -0
  74. lorebase_scribe-0.2.3/tests/test_webhook_job.py +58 -0
  75. 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,3 @@
1
+ """Lorebase Scribe: job worker, scheduler and Source synchronization."""
2
+
3
+ __version__ = "0.2.3"
@@ -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