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,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)