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,211 @@
|
|
|
1
|
+
"""Postgres profiler: catalog reflection (SQLAlchemy) plus a sampled, read-only SELECT per table.
|
|
2
|
+
|
|
3
|
+
Declared facts come from the catalog: column types (mapped to Attribute types with a fixed
|
|
4
|
+
table, design 9.1), nullability, primary keys, single-column unique constraints, foreign keys
|
|
5
|
+
and comments. Values come from ``SELECT ... LIMIT sample_rows`` in a read-only transaction
|
|
6
|
+
with a statement timeout. Row counts are the planner estimate unless ``exact_row_count``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import Connection, column, func, inspect, select, table, text
|
|
16
|
+
from sqlalchemy.dialects import postgresql
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
|
|
18
|
+
from sqlalchemy.types import (
|
|
19
|
+
ARRAY,
|
|
20
|
+
JSON,
|
|
21
|
+
Boolean,
|
|
22
|
+
Date,
|
|
23
|
+
DateTime,
|
|
24
|
+
Enum,
|
|
25
|
+
Float,
|
|
26
|
+
Integer,
|
|
27
|
+
Interval,
|
|
28
|
+
Numeric,
|
|
29
|
+
String,
|
|
30
|
+
Text,
|
|
31
|
+
TypeEngine,
|
|
32
|
+
Uuid,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from lorebase_core.infer import ColumnProfile, DeclaredForeignKey, TableProfile
|
|
36
|
+
from lorebase_core.types import AttributeType, TypeSpec
|
|
37
|
+
from lorebase_store.settings import PostgresProfilingSettings
|
|
38
|
+
|
|
39
|
+
DEFAULT_SCHEMA = "public"
|
|
40
|
+
_ESTIMATE = text(
|
|
41
|
+
"SELECT c.reltuples::bigint FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
42
|
+
"WHERE n.nspname = :schema AND c.relname = :table"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PostgresProfileError(ValueError):
|
|
47
|
+
"""The requested tables cannot be profiled."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _Column:
|
|
52
|
+
name: str
|
|
53
|
+
spec: TypeSpec | None
|
|
54
|
+
max_length: int | None
|
|
55
|
+
nullable: bool
|
|
56
|
+
unique: bool
|
|
57
|
+
comment: str | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class _Table:
|
|
62
|
+
name: str
|
|
63
|
+
columns: tuple[_Column, ...]
|
|
64
|
+
primary_key: tuple[str, ...]
|
|
65
|
+
foreign_keys: tuple[DeclaredForeignKey, ...]
|
|
66
|
+
comment: str | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def type_spec(sql_type: TypeEngine[Any]) -> tuple[TypeSpec | None, int | None]:
|
|
70
|
+
"""Attribute type and declared max length of a catalog type; ``(None, None)`` when the type
|
|
71
|
+
has no fixed mapping (inference then detects it from values)."""
|
|
72
|
+
if isinstance(sql_type, Boolean):
|
|
73
|
+
return TypeSpec.of(AttributeType.BOOLEAN), None
|
|
74
|
+
if isinstance(sql_type, Integer):
|
|
75
|
+
return TypeSpec.of(AttributeType.INTEGER), None
|
|
76
|
+
if isinstance(sql_type, Float):
|
|
77
|
+
return TypeSpec.of(AttributeType.FLOAT), None
|
|
78
|
+
if isinstance(sql_type, Numeric):
|
|
79
|
+
precision, scale = sql_type.precision, sql_type.scale
|
|
80
|
+
if precision is not None and scale is not None and 0 <= scale <= precision:
|
|
81
|
+
return TypeSpec.decimal(precision, scale), None
|
|
82
|
+
return None, None
|
|
83
|
+
if isinstance(sql_type, DateTime):
|
|
84
|
+
return TypeSpec.of(AttributeType.TIMESTAMP), None
|
|
85
|
+
if isinstance(sql_type, Date):
|
|
86
|
+
return TypeSpec.of(AttributeType.DATE), None
|
|
87
|
+
if isinstance(sql_type, Interval):
|
|
88
|
+
return TypeSpec.of(AttributeType.DURATION), None
|
|
89
|
+
if isinstance(sql_type, JSON | postgresql.JSONB):
|
|
90
|
+
return TypeSpec.of(AttributeType.JSON), None
|
|
91
|
+
if isinstance(sql_type, Uuid | postgresql.UUID):
|
|
92
|
+
return TypeSpec.of(AttributeType.STRING), None
|
|
93
|
+
if isinstance(sql_type, Enum):
|
|
94
|
+
values = tuple(str(v) for v in sql_type.enums)
|
|
95
|
+
return (TypeSpec.enum(*values), None) if values else (None, None)
|
|
96
|
+
if isinstance(sql_type, Text):
|
|
97
|
+
return TypeSpec.of(AttributeType.TEXT), None
|
|
98
|
+
if isinstance(sql_type, String):
|
|
99
|
+
return TypeSpec.of(AttributeType.STRING), sql_type.length
|
|
100
|
+
if isinstance(sql_type, ARRAY):
|
|
101
|
+
element, _ = type_spec(sql_type.item_type)
|
|
102
|
+
if element is not None and element.name not in (AttributeType.ARRAY, AttributeType.JSON):
|
|
103
|
+
return TypeSpec.array(element), None
|
|
104
|
+
return None, None
|
|
105
|
+
return None, None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _reflect(
|
|
109
|
+
connection: Connection, schema: str, names: Sequence[str] | None, max_tables: int
|
|
110
|
+
) -> list[_Table]:
|
|
111
|
+
inspector = inspect(connection)
|
|
112
|
+
available = sorted(inspector.get_table_names(schema=schema))
|
|
113
|
+
if names:
|
|
114
|
+
missing = sorted(set(names) - set(available))
|
|
115
|
+
if missing:
|
|
116
|
+
raise PostgresProfileError(f"unknown tables in schema {schema!r}: {', '.join(missing)}")
|
|
117
|
+
chosen = [n for n in available if n in set(names)]
|
|
118
|
+
else:
|
|
119
|
+
chosen = available
|
|
120
|
+
if len(chosen) > max_tables:
|
|
121
|
+
raise PostgresProfileError(
|
|
122
|
+
f"{len(chosen)} tables requested, more than the {max_tables} allowed"
|
|
123
|
+
)
|
|
124
|
+
tables = []
|
|
125
|
+
for name in chosen:
|
|
126
|
+
primary = tuple(
|
|
127
|
+
inspector.get_pk_constraint(name, schema=schema).get("constrained_columns") or ()
|
|
128
|
+
)
|
|
129
|
+
unique = {
|
|
130
|
+
u["column_names"][0]
|
|
131
|
+
for u in inspector.get_unique_constraints(name, schema=schema)
|
|
132
|
+
if len(u["column_names"]) == 1
|
|
133
|
+
}
|
|
134
|
+
columns = []
|
|
135
|
+
for info in inspector.get_columns(name, schema=schema):
|
|
136
|
+
spec, max_length = type_spec(info["type"])
|
|
137
|
+
columns.append(
|
|
138
|
+
_Column(
|
|
139
|
+
name=info["name"],
|
|
140
|
+
spec=spec,
|
|
141
|
+
max_length=max_length,
|
|
142
|
+
nullable=bool(info.get("nullable", True)) and info["name"] not in primary,
|
|
143
|
+
unique=info["name"] in unique,
|
|
144
|
+
comment=info.get("comment"),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
foreign_keys = tuple(
|
|
148
|
+
DeclaredForeignKey(
|
|
149
|
+
tuple(fk["constrained_columns"]),
|
|
150
|
+
fk["referred_table"],
|
|
151
|
+
tuple(fk["referred_columns"]),
|
|
152
|
+
)
|
|
153
|
+
for fk in inspector.get_foreign_keys(name, schema=schema)
|
|
154
|
+
if fk.get("referred_schema") in (None, schema)
|
|
155
|
+
)
|
|
156
|
+
comment = inspector.get_table_comment(name, schema=schema).get("text")
|
|
157
|
+
tables.append(_Table(name, tuple(columns), primary, foreign_keys, comment))
|
|
158
|
+
return tables
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _sample(
|
|
162
|
+
connection: AsyncConnection, schema: str, found: _Table, settings: PostgresProfilingSettings
|
|
163
|
+
) -> TableProfile:
|
|
164
|
+
source = table(found.name, *(column(c.name) for c in found.columns), schema=schema)
|
|
165
|
+
query = select(*source.columns).limit(settings.sample_rows)
|
|
166
|
+
rows = (await connection.execute(query)).all()
|
|
167
|
+
count: int | None
|
|
168
|
+
if settings.exact_row_count:
|
|
169
|
+
count = (await connection.execute(select(func.count()).select_from(source))).scalar_one()
|
|
170
|
+
else:
|
|
171
|
+
estimate = (
|
|
172
|
+
await connection.execute(_ESTIMATE, {"schema": schema, "table": found.name})
|
|
173
|
+
).scalar()
|
|
174
|
+
count = int(estimate) if estimate is not None and estimate >= 0 else None
|
|
175
|
+
columns = tuple(
|
|
176
|
+
ColumnProfile(
|
|
177
|
+
name=c.name,
|
|
178
|
+
values=[row[index] for row in rows],
|
|
179
|
+
declared_type=c.spec,
|
|
180
|
+
declared_max_length=c.max_length,
|
|
181
|
+
nullable=c.nullable,
|
|
182
|
+
unique=c.unique or None,
|
|
183
|
+
comment=c.comment,
|
|
184
|
+
)
|
|
185
|
+
for index, c in enumerate(found.columns)
|
|
186
|
+
)
|
|
187
|
+
return TableProfile(
|
|
188
|
+
name=found.name,
|
|
189
|
+
columns=columns,
|
|
190
|
+
row_count=max(count or 0, len(rows)),
|
|
191
|
+
primary_key=found.primary_key or None,
|
|
192
|
+
foreign_keys=found.foreign_keys,
|
|
193
|
+
comment=found.comment,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def profile_postgres(
|
|
198
|
+
engine: AsyncEngine,
|
|
199
|
+
settings: PostgresProfilingSettings,
|
|
200
|
+
*,
|
|
201
|
+
schema: str = DEFAULT_SCHEMA,
|
|
202
|
+
tables: Sequence[str] | None = None,
|
|
203
|
+
) -> list[TableProfile]:
|
|
204
|
+
"""Profiles of ``tables`` (default: every table) of ``schema``, read-only."""
|
|
205
|
+
async with engine.connect() as connection, connection.begin():
|
|
206
|
+
await connection.execute(text("SET TRANSACTION READ ONLY"))
|
|
207
|
+
await connection.execute(
|
|
208
|
+
text(f"SET LOCAL statement_timeout = {int(settings.statement_timeout_ms)}")
|
|
209
|
+
)
|
|
210
|
+
found = await connection.run_sync(_reflect, schema, tables, settings.max_tables)
|
|
211
|
+
return [await _sample(connection, schema, t, settings) for t in found]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Profilers of uploaded record files: JSON documents, JSON Lines and Parquet files.
|
|
2
|
+
|
|
3
|
+
The bytes are already bounded by the caller (an upload). A JSON document is an array of
|
|
4
|
+
objects, or holds one at ``records_path`` (dotted keys); JSON Lines hold one object per line
|
|
5
|
+
(blank lines skipped); both are UTF-8, with or without a byte order mark. Parquet needs
|
|
6
|
+
pyarrow (an optional extra of the installing package; ``ParquetUnavailableError`` without it)
|
|
7
|
+
and is decoded ``batch_rows`` rows at a time. Values keep their JSON or Arrow types and nested
|
|
8
|
+
objects stay JSON values; the table is named after the file (``delimited.table_name``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Iterator, Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from lorebase_core.infer import TableProfile
|
|
18
|
+
from lorebase_store.profiling.delimited import table_name
|
|
19
|
+
from lorebase_store.profiling.records import RecordProfileError, RecordProfiler
|
|
20
|
+
from lorebase_store.settings import RecordProfilingSettings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ParquetUnavailableError(RecordProfileError):
|
|
24
|
+
"""Parquet files cannot be read: pyarrow is not installed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _text(data: bytes, name: str) -> str:
|
|
28
|
+
try:
|
|
29
|
+
return data.decode("utf-8-sig")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
raise RecordProfileError(f"{name}: not UTF-8 text") from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _add(profiler: RecordProfiler, row: Mapping[str, Any], name: str) -> None:
|
|
35
|
+
try:
|
|
36
|
+
profiler.add(row)
|
|
37
|
+
except RecordProfileError as error:
|
|
38
|
+
raise RecordProfileError(f"{name}: {error}") from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _profile(
|
|
42
|
+
rows: Iterator[Mapping[str, Any]], name: str, settings: RecordProfilingSettings
|
|
43
|
+
) -> TableProfile:
|
|
44
|
+
profiler = RecordProfiler(settings)
|
|
45
|
+
for row in rows:
|
|
46
|
+
_add(profiler, row, name)
|
|
47
|
+
return profiler.profile(table_name(name))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _records_at(document: Any, path: str | None, name: str) -> list[Any]:
|
|
51
|
+
"""The array of records of a JSON document: the document itself, or the array at the dotted
|
|
52
|
+
key ``path`` (``data.items``); null there is no records."""
|
|
53
|
+
node = document
|
|
54
|
+
if path is not None and not isinstance(document, list):
|
|
55
|
+
for part in path.split("."):
|
|
56
|
+
if not isinstance(node, dict) or part not in node:
|
|
57
|
+
raise RecordProfileError(f"{name}: no {path!r} in the document")
|
|
58
|
+
node = node[part]
|
|
59
|
+
if node is None:
|
|
60
|
+
return []
|
|
61
|
+
if not isinstance(node, list):
|
|
62
|
+
what = "the document" if path is None else repr(path)
|
|
63
|
+
hint = "" if path is not None else " (give records_path)"
|
|
64
|
+
raise RecordProfileError(f"{name}: {what} is not an array of records{hint}")
|
|
65
|
+
return node
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def profile_json_bytes(
|
|
69
|
+
data: bytes, name: str, settings: RecordProfilingSettings, records_path: str | None = None
|
|
70
|
+
) -> TableProfile:
|
|
71
|
+
"""Profile a JSON document holding an array of objects, or the array at ``records_path``
|
|
72
|
+
(dotted keys) of an object."""
|
|
73
|
+
try:
|
|
74
|
+
document = json.loads(_text(data, name))
|
|
75
|
+
except ValueError as error:
|
|
76
|
+
raise RecordProfileError(f"{name}: not valid JSON ({error})") from None
|
|
77
|
+
document = _records_at(document, records_path, name)
|
|
78
|
+
|
|
79
|
+
def rows() -> Iterator[Mapping[str, Any]]:
|
|
80
|
+
for number, record in enumerate(document, start=1):
|
|
81
|
+
if not isinstance(record, dict):
|
|
82
|
+
raise RecordProfileError(f"{name}: record {number} is not an object")
|
|
83
|
+
yield record
|
|
84
|
+
|
|
85
|
+
return _profile(rows(), name, settings)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def profile_jsonl_bytes(data: bytes, name: str, settings: RecordProfilingSettings) -> TableProfile:
|
|
89
|
+
"""Profile JSON Lines: one object per line."""
|
|
90
|
+
text = _text(data, name)
|
|
91
|
+
|
|
92
|
+
def rows() -> Iterator[Mapping[str, Any]]:
|
|
93
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
94
|
+
if not line.strip():
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
record = json.loads(line)
|
|
98
|
+
except ValueError:
|
|
99
|
+
raise RecordProfileError(f"{name}: line {number} is not valid JSON") from None
|
|
100
|
+
if not isinstance(record, dict):
|
|
101
|
+
raise RecordProfileError(f"{name}: line {number} is not an object")
|
|
102
|
+
yield record
|
|
103
|
+
|
|
104
|
+
return _profile(rows(), name, settings)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def profile_parquet_bytes(
|
|
108
|
+
data: bytes, name: str, settings: RecordProfilingSettings
|
|
109
|
+
) -> TableProfile:
|
|
110
|
+
"""Profile a Parquet file; ``ParquetUnavailableError`` without pyarrow."""
|
|
111
|
+
try:
|
|
112
|
+
import pyarrow # type: ignore[import-untyped]
|
|
113
|
+
import pyarrow.parquet as parquet # type: ignore[import-untyped]
|
|
114
|
+
except ImportError:
|
|
115
|
+
raise ParquetUnavailableError(f"{name}: Parquet files need pyarrow") from None
|
|
116
|
+
profiler = RecordProfiler(settings)
|
|
117
|
+
try:
|
|
118
|
+
file = parquet.ParquetFile(pyarrow.BufferReader(data))
|
|
119
|
+
columns = tuple(str(field.name) for field in file.schema_arrow)
|
|
120
|
+
for batch in file.iter_batches(batch_size=settings.batch_rows):
|
|
121
|
+
for row in batch.to_pylist():
|
|
122
|
+
_add(profiler, row, name)
|
|
123
|
+
except RecordProfileError:
|
|
124
|
+
raise
|
|
125
|
+
except (ValueError, OSError) as error: # pyarrow.ArrowInvalid, truncated files
|
|
126
|
+
raise RecordProfileError(f"{name}: not a readable Parquet file ({error})") from None
|
|
127
|
+
return profiler.profile(table_name(name), columns)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Profiler of rows read as records: JSON documents, JSON Lines, Parquet files, JSON APIs.
|
|
2
|
+
|
|
3
|
+
Rows are mappings from column name to native value (``str``, ``int``, ``float``, ``Decimal``,
|
|
4
|
+
``date``, ``datetime``, ``bool``, nested ``dict`` or ``list``, ``None``). Columns are the keys
|
|
5
|
+
seen in any row, in first-seen order; a row without a column holds null there. Every row is
|
|
6
|
+
counted and ``sample_rows`` of them are kept, the first ones (``head``) or a uniform seeded
|
|
7
|
+
sample (``reservoir``, kept in read order). Rows can be pushed one at a time
|
|
8
|
+
(``RecordProfiler``), so an asynchronous reader never holds more than the sample.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import random
|
|
14
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from lorebase_core.infer import ColumnProfile, TableProfile
|
|
18
|
+
from lorebase_store.settings import RecordProfilingSettings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RecordProfileError(ValueError):
|
|
22
|
+
"""The rows cannot be profiled as one table."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RowSampler[T]:
|
|
26
|
+
"""Counts rows and keeps ``size`` of them: the first ones, or a seeded uniform sample."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, size: int, sampling: Literal["head", "reservoir"], seed: int) -> None:
|
|
29
|
+
self._size = size
|
|
30
|
+
self._head = sampling == "head"
|
|
31
|
+
self._random = random.Random(seed)
|
|
32
|
+
self._kept: list[tuple[int, T]] = []
|
|
33
|
+
self.total = 0
|
|
34
|
+
|
|
35
|
+
def add(self, row: T) -> None:
|
|
36
|
+
index = self.total
|
|
37
|
+
self.total += 1
|
|
38
|
+
if len(self._kept) < self._size:
|
|
39
|
+
self._kept.append((index, row))
|
|
40
|
+
elif not self._head:
|
|
41
|
+
slot = self._random.randint(0, index)
|
|
42
|
+
if slot < self._size:
|
|
43
|
+
self._kept[slot] = (index, row)
|
|
44
|
+
|
|
45
|
+
def kept(self) -> list[T]:
|
|
46
|
+
"""The kept rows, in read order."""
|
|
47
|
+
return [row for _, row in sorted(self._kept, key=lambda item: item[0])]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RecordProfiler:
|
|
51
|
+
"""Builds the ``TableProfile`` of rows pushed with ``add``."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, settings: RecordProfilingSettings) -> None:
|
|
54
|
+
self._settings = settings
|
|
55
|
+
self._sampler: RowSampler[Mapping[str, object]] = RowSampler(
|
|
56
|
+
settings.sample_rows, settings.sampling, settings.seed
|
|
57
|
+
)
|
|
58
|
+
self._columns: dict[str, None] = {}
|
|
59
|
+
|
|
60
|
+
def add(self, row: Mapping[str, object]) -> None:
|
|
61
|
+
for column in row:
|
|
62
|
+
if column not in self._columns:
|
|
63
|
+
if len(self._columns) >= self._settings.max_columns:
|
|
64
|
+
raise RecordProfileError(
|
|
65
|
+
f"more than the {self._settings.max_columns} columns allowed"
|
|
66
|
+
)
|
|
67
|
+
self._columns[column] = None
|
|
68
|
+
self._sampler.add(row)
|
|
69
|
+
|
|
70
|
+
def profile(self, name: str, columns: Sequence[str] = ()) -> TableProfile:
|
|
71
|
+
"""The profile of table ``name``; ``columns`` (a declared header) come first."""
|
|
72
|
+
names = list(dict.fromkeys([*columns, *self._columns]))
|
|
73
|
+
if len(names) > self._settings.max_columns:
|
|
74
|
+
raise RecordProfileError(
|
|
75
|
+
f"{name}: more than the {self._settings.max_columns} columns allowed"
|
|
76
|
+
)
|
|
77
|
+
kept = self._sampler.kept()
|
|
78
|
+
return TableProfile(
|
|
79
|
+
name,
|
|
80
|
+
tuple(ColumnProfile(column, [row.get(column) for row in kept]) for column in names),
|
|
81
|
+
row_count=self._sampler.total,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def profile_records(
|
|
86
|
+
rows: Iterable[Mapping[str, object]], name: str, settings: RecordProfilingSettings
|
|
87
|
+
) -> TableProfile:
|
|
88
|
+
"""Profile rows as table ``name``."""
|
|
89
|
+
profiler = RecordProfiler(settings)
|
|
90
|
+
for row in rows:
|
|
91
|
+
profiler.add(row)
|
|
92
|
+
return profiler.profile(name)
|