lorebase-store 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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,123 @@
1
+ """The `schema` partition: schema elements keyed by id, the Domain element keyed by Domain id.
2
+
3
+ The materialized head keeps the compiled schema (`Schema.content()`) next to the schema
4
+ partition hash, so commits read the schema with one row.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable, Mapping, Sequence
10
+ from typing import Any
11
+
12
+ from lorebase_core.canonical import ObjectType
13
+ from lorebase_core.model import (
14
+ Derivation,
15
+ Domain,
16
+ Kind,
17
+ Operation,
18
+ Relation,
19
+ Schema,
20
+ Source,
21
+ Trait,
22
+ )
23
+ from lorebase_store.commit.changes import SchemaChange, SchemaDelete, SchemaElementModel
24
+ from lorebase_store.commit.errors import SchemaChangeError
25
+
26
+ ELEMENT_MODELS: Mapping[ObjectType, type[SchemaElementModel]] = {
27
+ ObjectType.SCHEMA_DOMAIN: Domain,
28
+ ObjectType.SCHEMA_KIND: Kind,
29
+ ObjectType.SCHEMA_RELATION: Relation,
30
+ ObjectType.SCHEMA_TRAIT: Trait,
31
+ ObjectType.SCHEMA_OPERATION: Operation,
32
+ ObjectType.SCHEMA_DERIVATION: Derivation,
33
+ ObjectType.SCHEMA_SOURCE: Source,
34
+ }
35
+
36
+ _COLLECTIONS: Mapping[ObjectType, str] = {
37
+ ObjectType.SCHEMA_KIND: "kinds",
38
+ ObjectType.SCHEMA_RELATION: "relations",
39
+ ObjectType.SCHEMA_TRAIT: "traits",
40
+ ObjectType.SCHEMA_OPERATION: "operations",
41
+ ObjectType.SCHEMA_DERIVATION: "derivations",
42
+ ObjectType.SCHEMA_SOURCE: "sources",
43
+ }
44
+
45
+
46
+ def object_type_of(element: SchemaElementModel) -> ObjectType:
47
+ return ObjectType(element.object_type)
48
+
49
+
50
+ def element_key(element: SchemaElementModel) -> str:
51
+ """Key of an element in the `schema` partition."""
52
+ return element.id
53
+
54
+
55
+ def schema_elements(schema: Schema) -> dict[str, SchemaElementModel]:
56
+ """Every element of a Schema by partition key, the Domain included."""
57
+ elements: dict[str, SchemaElementModel] = {schema.domain.id: schema.domain}
58
+ for element in schema.elements():
59
+ elements[element.id] = element # type: ignore[assignment]
60
+ return elements
61
+
62
+
63
+ def schema_from_elements(elements: Iterable[SchemaElementModel]) -> Schema:
64
+ """Assemble a Schema; exactly one Domain element is required."""
65
+ domain: Domain | None = None
66
+ collections: dict[str, dict[str, Any]] = {name: {} for name in _COLLECTIONS.values()}
67
+ for element in elements:
68
+ if isinstance(element, Domain):
69
+ if domain is not None:
70
+ raise SchemaChangeError("a schema holds exactly one Domain element")
71
+ domain = element
72
+ continue
73
+ collections[_COLLECTIONS[object_type_of(element)]][element.id] = element
74
+ if domain is None:
75
+ raise SchemaChangeError("a schema needs its Domain element")
76
+ return Schema(domain=domain, **collections)
77
+
78
+
79
+ def element_from_body(object_type: ObjectType, body: Mapping[str, Any]) -> SchemaElementModel:
80
+ """Parse a stored schema object."""
81
+ return ELEMENT_MODELS[object_type].model_validate(body)
82
+
83
+
84
+ def compile_schema(schema: Schema) -> dict[str, Any]:
85
+ """Document stored in `mat_schema.compiled`."""
86
+ return schema.content()
87
+
88
+
89
+ def load_compiled(compiled: Mapping[str, Any]) -> Schema:
90
+ """Inverse of `compile_schema`."""
91
+ return Schema.model_validate(compiled)
92
+
93
+
94
+ def apply_schema_changes(
95
+ schema: Schema, changes: Sequence[SchemaChange], domain_id: str
96
+ ) -> tuple[Schema, list[SchemaElementModel], list[str]]:
97
+ """New schema plus the upserted elements and the removed element ids."""
98
+ elements = schema_elements(schema)
99
+ upserted: list[SchemaElementModel] = []
100
+ removed: list[str] = []
101
+ seen: set[str] = set()
102
+ for change in changes:
103
+ key = change.element_id if isinstance(change, SchemaDelete) else change.element.id
104
+ if key in seen:
105
+ raise SchemaChangeError(f"schema element {key} is changed more than once")
106
+ seen.add(key)
107
+ if isinstance(change, SchemaDelete):
108
+ if key == domain_id:
109
+ raise SchemaChangeError("the Domain element cannot be removed")
110
+ if key not in elements:
111
+ raise SchemaChangeError(f"unknown schema element {key}")
112
+ del elements[key]
113
+ removed.append(key)
114
+ continue
115
+ element = change.element
116
+ if isinstance(element, Domain) and element.id != domain_id:
117
+ raise SchemaChangeError(f"Domain element {element.id} in Domain {domain_id}")
118
+ current = elements.get(key)
119
+ if current is not None and object_type_of(current) is not object_type_of(element):
120
+ raise SchemaChangeError(f"schema element {key} cannot change its type")
121
+ elements[key] = element
122
+ upserted.append(element)
123
+ return schema_from_elements(elements.values()), upserted, removed
lorebase_store/db.py ADDED
@@ -0,0 +1,58 @@
1
+ """Async engine factory built from the `store` settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sqlalchemy import URL, make_url
8
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
+ from sqlalchemy.pool import NullPool
10
+
11
+ from lorebase_store.settings import DatabaseSettings
12
+
13
+ ASYNC_DRIVER = "postgresql+asyncpg"
14
+ _POSTGRES_SCHEMES = frozenset({"postgres", "postgresql", ASYNC_DRIVER})
15
+
16
+
17
+ def async_url(url: str | URL) -> URL:
18
+ """Parse a Postgres URL and switch it to the asyncpg driver."""
19
+ parsed = make_url(url)
20
+ if parsed.drivername not in _POSTGRES_SCHEMES:
21
+ raise ValueError(f"unsupported database driver {parsed.drivername!r}; use postgresql://")
22
+ return parsed.set(drivername=ASYNC_DRIVER)
23
+
24
+
25
+ def create_engine(
26
+ database: DatabaseSettings,
27
+ *,
28
+ url: str | URL | None = None,
29
+ pooled: bool = True,
30
+ statement_timeout_ms: int | None = None,
31
+ ) -> AsyncEngine:
32
+ """Engine for `database`; `url` overrides the configured DSN, `pooled=False` uses no pool."""
33
+ timeout = (
34
+ database.statement_timeout_ms if statement_timeout_ms is None else statement_timeout_ms
35
+ )
36
+ connect_args: dict[str, Any] = {
37
+ "server_settings": {
38
+ "application_name": database.application_name,
39
+ "statement_timeout": str(timeout),
40
+ # `indexes.sql_literal` writes standard literals, where a backslash is plain text.
41
+ "standard_conforming_strings": "on",
42
+ },
43
+ # Statements are prepared once per connection and SQL text, then reused.
44
+ "prepared_statement_cache_size": database.prepared_statement_cache_size,
45
+ }
46
+ pool_args: dict[str, Any] = (
47
+ {
48
+ "pool_size": database.pool_size,
49
+ "max_overflow": database.max_overflow,
50
+ "pool_timeout": database.pool_timeout_s,
51
+ "pool_recycle": database.pool_recycle_s,
52
+ "pool_pre_ping": True,
53
+ }
54
+ if pooled
55
+ else {"poolclass": NullPool}
56
+ )
57
+ target = async_url(url if url is not None else database.dsn.get_secret_value())
58
+ return create_async_engine(target, connect_args=connect_args, **pool_args)
@@ -0,0 +1,50 @@
1
+ """Grants on the security definer read functions of revisions 0005, 0010, 0018, 0035 and 0037
2
+ (store schema notes).
3
+
4
+ The migration revokes EXECUTE from PUBLIC and grants it to `database.application_role`.
5
+ `grant_read_functions` grants it to another role later: one created after the migration, or a
6
+ new application role.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sqlalchemy import text
12
+ from sqlalchemy.ext.asyncio import AsyncConnection
13
+
14
+ READ_FUNCTIONS = (
15
+ "lb_search",
16
+ "lb_links_in",
17
+ "lb_neighborhood",
18
+ "lb_query_by_attribute",
19
+ "lb_search_overlay",
20
+ "lb_links_in_overlay",
21
+ "lb_neighborhood_overlay",
22
+ "lb_query_by_attribute_overlay",
23
+ "lb_search_corrections",
24
+ "lb_search_corrected",
25
+ "lb_search_corrected_overlay",
26
+ "lb_search_bounded",
27
+ "lb_search_bounded_overlay",
28
+ )
29
+ """Functions only the store read primitives call, with a Domain already authorized."""
30
+ BM25_FUNCTIONS = ("lb_search_bm25", "lb_search_bm25_overlay")
31
+ """The BM25 search of revision 0037, called the same way; they reach the BM25 index only
32
+ where `pg_search` is installed (`lorebase_store.bm25`)."""
33
+
34
+ _SIGNATURES = text(
35
+ "SELECT CAST(CAST(p.oid AS regprocedure) AS text) FROM pg_proc p "
36
+ "WHERE p.proname = ANY(CAST(:names AS text[])) "
37
+ "AND p.pronamespace = CAST(current_schema() AS regnamespace) ORDER BY 1"
38
+ )
39
+ _QUOTED = text("SELECT quote_ident(:role)")
40
+
41
+
42
+ async def grant_read_functions(connection: AsyncConnection, role: str) -> list[str]:
43
+ """Grant EXECUTE on the read functions to `role`; returns the signatures granted."""
44
+ quoted = (await connection.execute(_QUOTED, {"role": role})).scalar_one()
45
+ names = [*READ_FUNCTIONS, *BM25_FUNCTIONS]
46
+ found = await connection.execute(_SIGNATURES, {"names": names})
47
+ signatures = list(found.scalars())
48
+ for signature in signatures:
49
+ await connection.exec_driver_sql(f"GRANT EXECUTE ON FUNCTION {signature} TO {quoted}")
50
+ return signatures
@@ -0,0 +1,99 @@
1
+ """Keyed digests of inputs that may hold personal data (design 13.5).
2
+
3
+ Lorebase stores digests of some inputs it must not keep: the parameters of Operation invocations
4
+ (idempotency, provenance) and the Record keys named by erasures in the audit log. A plain hash of
5
+ such an input confirms a guessed value, so every digest is an HMAC-SHA256 under a key of the
6
+ installation, chosen in this order:
7
+
8
+ 1. `vault.digest_key`;
9
+ 2. the vault's current token key, when the vault is configured (the digests made before this
10
+ module existed are unchanged);
11
+ 3. the random secret revision `0027` stored in `installation_secrets` (name `digest`): it keeps
12
+ the digests unconfirmable for whoever reads the API or the audit log, not for whoever reads
13
+ that table. Configure one of the keys above for that.
14
+
15
+ The key of case 3 is read from the database once per process (`ready`). In case 2 the
16
+ earlier token keys (`vault.previous_token_keys`) still recognize the digests made under them
17
+ (`all_digests`), so a token key rotation does not break idempotent replays.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from sqlalchemy import select
23
+ from sqlalchemy.ext.asyncio import AsyncSession
24
+
25
+ from lorebase_store.keyring import Keyring
26
+ from lorebase_store.models import metadata
27
+ from lorebase_store.vault.keys import NAMESPACE, VaultKeys, framed
28
+ from lorebase_store.vault.settings import VaultSettings
29
+
30
+ SECRET_NAME = "digest"
31
+ _SECRETS = metadata.tables["installation_secrets"]
32
+
33
+
34
+ class DigestKeysNotReadyError(RuntimeError):
35
+ """The installation secret has not been read yet (`DigestKeys.ready`)."""
36
+
37
+
38
+ class DigestKeys:
39
+ """Makes the keyed digests of the installation (module doc)."""
40
+
41
+ def __init__(
42
+ self,
43
+ keyring: Keyring | None = None,
44
+ *,
45
+ source: str = "installation",
46
+ earlier: tuple[Keyring, ...] = (),
47
+ ) -> None:
48
+ self._keyring = keyring
49
+ self._source = source
50
+ self._earlier = earlier
51
+
52
+ @classmethod
53
+ def from_settings(cls, settings: VaultSettings, vault: VaultKeys | None) -> DigestKeys:
54
+ if settings.digest_key is not None:
55
+ return cls(
56
+ Keyring(settings.digest_key.get_secret_value(), namespace=NAMESPACE),
57
+ source="vault.digest_key",
58
+ )
59
+ if vault is not None:
60
+ return cls(
61
+ vault.digest_keyring(),
62
+ source="vault.token_key",
63
+ earlier=vault.earlier_digest_keyrings(),
64
+ )
65
+ return cls()
66
+
67
+ @property
68
+ def source(self) -> str:
69
+ """Where the key comes from: `vault.digest_key`, `vault.token_key` or `installation`."""
70
+ return self._source
71
+
72
+ async def ready(self, session: AsyncSession) -> DigestKeys:
73
+ """These keys, after reading the installation secret if they need it."""
74
+ if self._keyring is None:
75
+ secret = (
76
+ await session.execute(
77
+ select(_SECRETS.c.secret).where(_SECRETS.c.name == SECRET_NAME)
78
+ )
79
+ ).scalar_one_or_none()
80
+ if secret is None:
81
+ raise DigestKeysNotReadyError(
82
+ "installation_secrets has no digest secret: run the migrations (revision "
83
+ "0027) or set vault.digest_key"
84
+ )
85
+ self._keyring = Keyring(secret, namespace=NAMESPACE)
86
+ return self
87
+
88
+ def digest(self, domain_id: str, purpose: str, payload: bytes) -> str:
89
+ """Keyed digest (hex) of `payload` for `purpose` in a Domain."""
90
+ if self._keyring is None:
91
+ raise DigestKeysNotReadyError("call DigestKeys.ready first")
92
+ return self._keyring.mac("digest/" + purpose, framed(domain_id, payload)).hex()
93
+
94
+ def all_digests(self, domain_id: str, purpose: str, payload: bytes) -> frozenset[str]:
95
+ """The digest under the current key and under the earlier token keys."""
96
+ found = {self.digest(domain_id, purpose, payload)}
97
+ for keyring in self._earlier:
98
+ found.add(keyring.mac("digest/" + purpose, framed(domain_id, payload)).hex())
99
+ return frozenset(found)