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,162 @@
1
+ """Content-addressed objects in Postgres (`objects`), scoped to one Domain (design 6.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from types import MappingProxyType
8
+ from typing import Any
9
+
10
+ from pydantic import JsonValue
11
+ from sqlalchemy import Table, select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ from sqlalchemy.orm import Session
14
+
15
+ from lorebase_core.canonical import ObjectType, canonical_bytes, normalize, object_hash
16
+ from lorebase_store.bulk import bulk_write, chunks
17
+ from lorebase_store.models import metadata
18
+ from lorebase_store.settings import WriteSettings
19
+
20
+ OBJECTS: Table = metadata.tables["objects"]
21
+
22
+ OBJECT_TYPE_CODES: Mapping[ObjectType, int] = MappingProxyType(
23
+ {
24
+ ObjectType.SCHEMA_DOMAIN: 1,
25
+ ObjectType.SCHEMA_KIND: 2,
26
+ ObjectType.SCHEMA_RELATION: 3,
27
+ ObjectType.SCHEMA_TRAIT: 4,
28
+ ObjectType.SCHEMA_OPERATION: 5,
29
+ ObjectType.SCHEMA_DERIVATION: 6,
30
+ ObjectType.SCHEMA_SOURCE: 7,
31
+ ObjectType.RECORD: 8,
32
+ ObjectType.LINK: 9,
33
+ }
34
+ )
35
+ """`objects.object_type` codes. Stored data depends on them: never renumber."""
36
+
37
+ OBJECT_TYPES_BY_CODE: Mapping[int, ObjectType] = MappingProxyType(
38
+ {code: object_type for object_type, code in OBJECT_TYPE_CODES.items()}
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class EncodedObject:
44
+ """An object ready to store: hash, type, normalized body and canonical size in bytes."""
45
+
46
+ hash: bytes
47
+ object_type: ObjectType
48
+ body: dict[str, Any]
49
+ size: int
50
+
51
+
52
+ def encode_object(object_type: ObjectType | str, content: Mapping[str, Any]) -> EncodedObject:
53
+ """Normalize `content` and compute its object hash (design 6.1)."""
54
+ kind = ObjectType(object_type)
55
+ body = normalize(content)
56
+ if not isinstance(body, dict):
57
+ raise TypeError("object content must be a JSON object")
58
+ return EncodedObject(
59
+ hash=object_hash(kind, body),
60
+ object_type=kind,
61
+ body=body,
62
+ size=len(canonical_bytes(body)),
63
+ )
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class StoredBody:
68
+ """Type and body of a stored object."""
69
+
70
+ object_type: ObjectType
71
+ body: dict[str, Any]
72
+
73
+
74
+ class PgObjectStore:
75
+ """Async batched reads and bulk writes of one Domain's objects."""
76
+
77
+ def __init__(
78
+ self, session: AsyncSession, domain_id: str, *, writes: WriteSettings, batch: int = 1000
79
+ ) -> None:
80
+ self._session = session
81
+ self._domain = domain_id
82
+ self._writes = writes
83
+ self._batch = batch
84
+
85
+ async def get_many(self, hashes: Iterable[bytes]) -> dict[bytes, StoredBody]:
86
+ """Stored objects among `hashes`; absent hashes are left out."""
87
+ found: dict[bytes, StoredBody] = {}
88
+ for chunk in chunks(list(dict.fromkeys(hashes)), self._batch):
89
+ rows = await self._session.execute(
90
+ select(OBJECTS.c.hash, OBJECTS.c.object_type, OBJECTS.c.body).where(
91
+ OBJECTS.c.domain_id == self._domain, OBJECTS.c.hash.in_(chunk)
92
+ )
93
+ )
94
+ for digest, code, body in rows:
95
+ found[digest] = StoredBody(OBJECT_TYPES_BY_CODE[code], body)
96
+ return found
97
+
98
+ async def put_many(self, objects: Iterable[EncodedObject]) -> None:
99
+ """Persist objects; objects already stored are left unchanged."""
100
+ unique = {item.hash: item for item in objects}
101
+ rows = [
102
+ {
103
+ "domain_id": self._domain,
104
+ "hash": item.hash,
105
+ "object_type": OBJECT_TYPE_CODES[item.object_type],
106
+ "body": item.body,
107
+ "size": item.size,
108
+ }
109
+ for item in unique.values()
110
+ ]
111
+ await bulk_write(self._session, OBJECTS, rows, writes=self._writes)
112
+
113
+
114
+ class SessionObjectStore:
115
+ """Synchronous core `ObjectStore` of one Domain over a synchronous `Session`.
116
+
117
+ Like `PgNodeStore` it runs inside `AsyncSession.run_sync`: reads query `objects` (and are
118
+ cached for the life of the store), writes are buffered and handed to
119
+ `PgObjectStore.put_many` with `take_pending`.
120
+ """
121
+
122
+ def __init__(self, session: Session, domain_id: str, *, batch: int = 1000) -> None:
123
+ self._session = session
124
+ self._domain = domain_id
125
+ self._batch = batch
126
+ self._known: dict[bytes, dict[str, Any]] = {}
127
+ self._pending: dict[bytes, EncodedObject] = {}
128
+
129
+ def get(self, object_hash: bytes) -> JsonValue | None:
130
+ pending = self._pending.get(object_hash)
131
+ if pending is not None:
132
+ return pending.body
133
+ if object_hash not in self._known:
134
+ self.prefetch([object_hash])
135
+ return self._known.get(object_hash)
136
+
137
+ def put(self, object_type: str, payload: JsonValue) -> bytes:
138
+ if not isinstance(payload, dict):
139
+ raise TypeError("object content must be a JSON object")
140
+ encoded = encode_object(object_type, payload)
141
+ self._pending.setdefault(encoded.hash, encoded)
142
+ return encoded.hash
143
+
144
+ def prefetch(self, hashes: Iterable[bytes]) -> None:
145
+ """Load the given objects in batched queries; known ones are skipped."""
146
+ missing = [
147
+ h for h in dict.fromkeys(hashes) if h not in self._known and h not in self._pending
148
+ ]
149
+ for chunk in chunks(missing, self._batch):
150
+ rows = self._session.execute(
151
+ select(OBJECTS.c.hash, OBJECTS.c.body).where(
152
+ OBJECTS.c.domain_id == self._domain, OBJECTS.c.hash.in_(chunk)
153
+ )
154
+ )
155
+ for digest, body in rows:
156
+ self._known[digest] = body
157
+
158
+ def take_pending(self) -> list[EncodedObject]:
159
+ """Return and forget the buffered writes."""
160
+ pending, self._pending = self._pending, {}
161
+ self._known.update({digest: item.body for digest, item in pending.items()})
162
+ return list(pending.values())
@@ -0,0 +1,166 @@
1
+ """Event types of the outbox and the sensitive values their payloads carry (design 10.2, 10.5).
2
+
3
+ **Types.** A commit writes `revision.committed`; an Operation that declares side effects writes
4
+ one `operation.<name>` event per name, in its commit transaction. Webhooks subscribe to exact
5
+ types or to `operation.*`, every Operation event.
6
+
7
+ **Sensitive values.** A payload never holds a value that needs a clearance as such: the writer
8
+ wraps it as `{"$sensitive": {"tags": [...], "value": ...}}`, the value of a vault Attribute
9
+ being its vault reference (`{"$vault": token}`), never the personal data. `disclose` renders a
10
+ payload for one subscriber when it is sent: a wrapped value whose tags the subscriber is
11
+ cleared for is shown (a vault reference opened; an erased one shows `erased`), any other is
12
+ replaced by `masked`, and so is a vault reference found outside a wrapper. A wrapper without
13
+ tags is never cleared, and writers wrap with every tag any value they did not produce whose
14
+ JSON holds `$sensitive` or `$vault` keys (`carries_markers`), so a value written by a caller
15
+ cannot pose as a wrapper or a reference. Stored payloads and deliveries therefore keep no vault
16
+ value, and an erasure reaches deliveries not sent yet. Values of sensitive Attributes outside
17
+ the vault are kept as committed, like the Revision objects that hold them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections.abc import Collection, Iterable, Iterator, Mapping
24
+ from typing import Any
25
+
26
+ from lorebase_core.model import VAULT_KEY, Sensitivity, vault_token
27
+ from lorebase_core.model.operation import SIDE_EFFECT_MAX_LENGTH
28
+ from lorebase_store.commit.pipeline import COMMITTED_EVENT
29
+ from lorebase_store.vault import VaultState, VaultValue
30
+
31
+ OPERATION_EVENT_PREFIX = "operation."
32
+ OPERATION_EVENTS = "operation.*"
33
+ """Subscribes to the events of every Operation side effect."""
34
+ EVENT_TYPES: tuple[str, ...] = (COMMITTED_EVENT, OPERATION_EVENTS)
35
+ """Event types a webhook subscribes to when it names none."""
36
+ SENSITIVE_KEY = "$sensitive"
37
+
38
+ _OPERATION_EVENT = re.compile(r"operation\.[a-z][a-z0-9]*(?:_[a-z0-9]+)*")
39
+
40
+
41
+ def operation_event_type(side_effect: str) -> str:
42
+ """The outbox event type of an Operation side effect."""
43
+ return f"{OPERATION_EVENT_PREFIX}{side_effect}"
44
+
45
+
46
+ def event_type_problem(name: str) -> str | None:
47
+ """Why `name` is not an event type a webhook may subscribe to; None when it is."""
48
+ if name in EVENT_TYPES:
49
+ return None
50
+ if (
51
+ _OPERATION_EVENT.fullmatch(name)
52
+ and len(name) - len(OPERATION_EVENT_PREFIX) <= SIDE_EFFECT_MAX_LENGTH
53
+ ):
54
+ return None
55
+ return (
56
+ f"unknown event type {name!r}: expected {COMMITTED_EVENT}, {OPERATION_EVENTS} or "
57
+ "operation.<side effect>"
58
+ )
59
+
60
+
61
+ def subscribed(event_type: str, subscriptions: Collection[str]) -> bool:
62
+ """Whether a subscription to `subscriptions` receives events of `event_type`."""
63
+ if event_type in subscriptions:
64
+ return True
65
+ return event_type.startswith(OPERATION_EVENT_PREFIX) and OPERATION_EVENTS in subscriptions
66
+
67
+
68
+ def sensitive_value(tags: Iterable[Sensitivity], value: Any) -> dict[str, Any]:
69
+ """`value` wrapped with the tags that clear it (see module doc)."""
70
+ return {SENSITIVE_KEY: {"tags": sorted(tag.value for tag in tags), "value": value}}
71
+
72
+
73
+ def carries_markers(value: Any) -> bool:
74
+ """Whether `value` holds, at any depth, an object with a `$sensitive` or `$vault` key."""
75
+ if isinstance(value, dict):
76
+ return (
77
+ SENSITIVE_KEY in value
78
+ or VAULT_KEY in value
79
+ or any(map(carries_markers, value.values()))
80
+ )
81
+ if isinstance(value, list):
82
+ return any(map(carries_markers, value))
83
+ return False
84
+
85
+
86
+ def _wrapped(node: Any) -> tuple[frozenset[str], Any] | None:
87
+ if isinstance(node, dict) and len(node) == 1 and SENSITIVE_KEY in node:
88
+ body = node[SENSITIVE_KEY]
89
+ if isinstance(body, dict) and isinstance(body.get("tags"), list):
90
+ return frozenset(str(t) for t in body["tags"]), body.get("value")
91
+ return None
92
+
93
+
94
+ def _cleared(tags: frozenset[str], clearances: Collection[str]) -> bool:
95
+ return bool(tags) and all(tag in clearances for tag in tags)
96
+
97
+
98
+ def cleared_tokens(payload: Any, clearances: Collection[str]) -> set[str]:
99
+ """Vault tokens that `disclose` opens for a subscriber with `clearances`."""
100
+ return set(_tokens(payload, clearances, inside=False))
101
+
102
+
103
+ def _tokens(node: Any, clearances: Collection[str], *, inside: bool) -> Iterator[str]:
104
+ wrapped = _wrapped(node)
105
+ if wrapped is not None:
106
+ tags, value = wrapped
107
+ if _cleared(tags, clearances):
108
+ yield from _tokens(value, clearances, inside=True)
109
+ return
110
+ if isinstance(node, dict):
111
+ token = vault_token(node) if VAULT_KEY in node else None
112
+ if token is not None:
113
+ if inside:
114
+ yield token
115
+ return
116
+ for item in node.values():
117
+ yield from _tokens(item, clearances, inside=inside)
118
+ elif isinstance(node, list):
119
+ for item in node:
120
+ yield from _tokens(item, clearances, inside=inside)
121
+
122
+
123
+ def disclose(
124
+ payload: Any,
125
+ clearances: Collection[str],
126
+ opened: Mapping[str, VaultValue],
127
+ *,
128
+ masked: str,
129
+ erased: str,
130
+ ) -> Any:
131
+ """`payload` as a subscriber with `clearances` receives it (see module doc). `opened`
132
+ holds the vault values of `cleared_tokens`."""
133
+ return _disclose(payload, frozenset(clearances), opened, masked, erased, inside=False)
134
+
135
+
136
+ def _disclose(
137
+ node: Any,
138
+ clearances: frozenset[str],
139
+ opened: Mapping[str, VaultValue],
140
+ masked: str,
141
+ erased: str,
142
+ *,
143
+ inside: bool,
144
+ ) -> Any:
145
+ wrapped = _wrapped(node)
146
+ if wrapped is not None:
147
+ tags, value = wrapped
148
+ if not _cleared(tags, clearances):
149
+ return None if value is None else masked
150
+ return _disclose(value, clearances, opened, masked, erased, inside=True)
151
+ if isinstance(node, dict):
152
+ if VAULT_KEY in node:
153
+ token = vault_token(node)
154
+ if token is None or not inside:
155
+ return masked
156
+ found = opened.get(token)
157
+ if found is None or found.state is VaultState.UNAVAILABLE:
158
+ return masked
159
+ return erased if found.state is VaultState.ERASED else found.value
160
+ return {
161
+ key: _disclose(item, clearances, opened, masked, erased, inside=inside)
162
+ for key, item in node.items()
163
+ }
164
+ if isinstance(node, list):
165
+ return [_disclose(item, clearances, opened, masked, erased, inside=inside) for item in node]
166
+ return node
@@ -0,0 +1,58 @@
1
+ """Profilers of structured sources for schema inference (``lorebase_core.infer``).
2
+
3
+ They read CSV and TSV files (``delimited``), Postgres tables (``postgres``), MySQL, MariaDB
4
+ and SQL Server tables (``sql_databases``), rows read as records (``records``: JSON, JSON Lines,
5
+ Parquet and API Sources, whose readers live in Scribe) and uploaded JSON, JSON Lines and
6
+ Parquet files (``record_files``) into ``TableProfile`` objects.
7
+ Limits come from the ``store.profiling`` settings.
8
+ """
9
+
10
+ from lorebase_store.profiling.delimited import (
11
+ CsvProfileError,
12
+ profile_bytes,
13
+ profile_file,
14
+ profile_text,
15
+ table_name,
16
+ )
17
+ from lorebase_store.profiling.postgres import PostgresProfileError, profile_postgres, type_spec
18
+ from lorebase_store.profiling.record_files import (
19
+ ParquetUnavailableError,
20
+ profile_json_bytes,
21
+ profile_jsonl_bytes,
22
+ profile_parquet_bytes,
23
+ )
24
+ from lorebase_store.profiling.records import (
25
+ RecordProfileError,
26
+ RecordProfiler,
27
+ RowSampler,
28
+ profile_records,
29
+ )
30
+ from lorebase_store.profiling.sql_databases import (
31
+ SqlDialect,
32
+ SqlDriverError,
33
+ SqlProfileError,
34
+ profile_sql_database,
35
+ )
36
+
37
+ __all__ = [
38
+ "CsvProfileError",
39
+ "ParquetUnavailableError",
40
+ "PostgresProfileError",
41
+ "RecordProfileError",
42
+ "RecordProfiler",
43
+ "RowSampler",
44
+ "SqlDialect",
45
+ "SqlDriverError",
46
+ "SqlProfileError",
47
+ "profile_bytes",
48
+ "profile_file",
49
+ "profile_json_bytes",
50
+ "profile_jsonl_bytes",
51
+ "profile_parquet_bytes",
52
+ "profile_postgres",
53
+ "profile_records",
54
+ "profile_sql_database",
55
+ "profile_text",
56
+ "table_name",
57
+ "type_spec",
58
+ ]
@@ -0,0 +1,109 @@
1
+ """CSV and TSV profiler: delimiter sniffing, encoding fallback and row sampling (stdlib ``csv``).
2
+
3
+ The first row is the header. Every row is counted; ``sample_rows`` of them are kept, either the
4
+ first ones (``head``) or a uniform seeded sample (``reservoir``, rows kept in file order). Short
5
+ rows are padded with nulls and extra cells are dropped. All values stay text: inference
6
+ detects their types.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import io
13
+ from collections.abc import Iterable, Iterator
14
+ from itertools import chain
15
+ from pathlib import Path
16
+ from typing import TextIO
17
+
18
+ from lorebase_core.infer import ColumnProfile, TableProfile
19
+ from lorebase_store.profiling.records import RowSampler
20
+ from lorebase_store.settings import CsvProfilingSettings
21
+
22
+ TSV_SUFFIXES = (".tsv", ".tab")
23
+ DEFAULT_DELIMITER = ","
24
+
25
+
26
+ class CsvProfileError(ValueError):
27
+ """The file cannot be read as delimited text."""
28
+
29
+
30
+ def _delimiter(sample: str, name: str, settings: CsvProfilingSettings) -> str:
31
+ if name.lower().endswith(TSV_SUFFIXES):
32
+ return "\t"
33
+ try:
34
+ return csv.Sniffer().sniff(sample, delimiters=settings.delimiters).delimiter
35
+ except csv.Error:
36
+ return DEFAULT_DELIMITER
37
+
38
+
39
+ def _sample(
40
+ rows: Iterator[list[str]], settings: CsvProfilingSettings
41
+ ) -> tuple[list[list[str]], int]:
42
+ """Kept rows (in file order) and the number of rows read."""
43
+ sampler: RowSampler[list[str]] = RowSampler(
44
+ settings.sample_rows, settings.sampling, settings.seed
45
+ )
46
+ for row in rows:
47
+ sampler.add(row)
48
+ return sampler.kept(), sampler.total
49
+
50
+
51
+ def _header(cells: list[str]) -> list[str]:
52
+ return [cell.strip() or f"column_{index + 1}" for index, cell in enumerate(cells)]
53
+
54
+
55
+ def profile_text(stream: TextIO, name: str, settings: CsvProfilingSettings) -> TableProfile:
56
+ """Profile delimited text; ``name`` is the table name (a file name keeps its suffix rules)."""
57
+ head = stream.read(settings.sniff_bytes)
58
+ head += stream.readline()
59
+ delimiter = _delimiter(head, name, settings)
60
+ lines: Iterable[str] = chain(io.StringIO(head), stream)
61
+ reader = csv.reader(lines, delimiter=delimiter)
62
+ try:
63
+ first = next(reader, None)
64
+ if first is None:
65
+ raise CsvProfileError(f"{name}: the file is empty")
66
+ header = _header(first)
67
+ if len(header) > settings.max_columns:
68
+ raise CsvProfileError(
69
+ f"{name}: {len(header)} columns, more than the {settings.max_columns} allowed"
70
+ )
71
+ rows = (row for row in reader if any(cell.strip() for cell in row))
72
+ kept, total = _sample(rows, settings)
73
+ except csv.Error as error:
74
+ raise CsvProfileError(f"{name}: not valid delimited text ({error})") from None
75
+ columns = tuple(
76
+ ColumnProfile(
77
+ column,
78
+ [row[index] if index < len(row) else None for row in kept],
79
+ )
80
+ for index, column in enumerate(header)
81
+ )
82
+ return TableProfile(table_name(name), columns, row_count=total)
83
+
84
+
85
+ def table_name(file_name: str) -> str:
86
+ """Table name of a file: the base name without directories and suffix."""
87
+ return Path(file_name.replace("\\", "/")).stem or file_name
88
+
89
+
90
+ def profile_bytes(data: bytes, name: str, settings: CsvProfilingSettings) -> TableProfile:
91
+ """Profile an uploaded file, trying ``settings.encodings`` in order."""
92
+ for encoding in settings.encodings:
93
+ try:
94
+ text = data.decode(encoding)
95
+ except (UnicodeDecodeError, LookupError):
96
+ continue
97
+ return profile_text(io.StringIO(text, newline=""), name, settings)
98
+ raise CsvProfileError(f"{name}: not decodable as {', '.join(settings.encodings)}")
99
+
100
+
101
+ def profile_file(path: Path, settings: CsvProfilingSettings) -> TableProfile:
102
+ """Profile a file on disk without loading it whole, trying ``settings.encodings``."""
103
+ for encoding in settings.encodings:
104
+ try:
105
+ with path.open(encoding=encoding, newline="") as stream:
106
+ return profile_text(stream, path.name, settings)
107
+ except (UnicodeDecodeError, LookupError):
108
+ continue
109
+ raise CsvProfileError(f"{path.name}: not decodable as {', '.join(settings.encodings)}")