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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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)}")
|