lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Schema inference (design 9.1, 11.1): `POST /domains/{d}/schema:infer`, permission
|
|
2
|
+
`schema.write`.
|
|
3
|
+
|
|
4
|
+
The body is either `multipart/form-data` with files in `files` (CSV or TSV text, JSON documents,
|
|
5
|
+
JSON Lines or Parquet, read by suffix; optionally an `options` field holding a JSON object of
|
|
6
|
+
`InferenceOptions` overrides and a `records_path` field, the dotted keys of the records array
|
|
7
|
+
of JSON documents that hold it under a key), or JSON naming an allowed database source:
|
|
8
|
+
`{"postgres": {"dsn": "secret://env/ERP_DSN", "schema": "public", "tables": [...]}, "options":
|
|
9
|
+
{...}}`, with `mysql` (MySQL and MariaDB) or `mssql` (SQL Server) in place of `postgres`. The
|
|
10
|
+
answer is the proposal: the schema (JSON and YAML), the evidence of every element and the
|
|
11
|
+
issues to settle; with `Accept: text/yaml` or `format=yaml`, only the YAML document. Nothing is
|
|
12
|
+
committed.
|
|
13
|
+
|
|
14
|
+
`POST /domains/{d}/schema:preview` takes the same uploads and returns their first rows as
|
|
15
|
+
records instead of a proposal (permission, size limits and nothing stored as for `schema:infer`),
|
|
16
|
+
so a client can show a Parquet file it cannot read itself.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from typing import Annotated, Any, Literal
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
|
25
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
26
|
+
from starlette.datastructures import UploadFile
|
|
27
|
+
|
|
28
|
+
from lorebase_core.infer import SchemaProposal
|
|
29
|
+
from lorebase_core.model import dump_schema
|
|
30
|
+
from lorebase_keeper.auth.audit import annotate
|
|
31
|
+
from lorebase_keeper.auth.deps import require
|
|
32
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
33
|
+
from lorebase_keeper.deps import KeeperSettingsDep, SettingsDep
|
|
34
|
+
from lorebase_keeper.errors import BAD_REQUEST, Problem, ProblemError
|
|
35
|
+
from lorebase_keeper.services import schema_inference as service
|
|
36
|
+
from lorebase_store.profiling import SqlDialect
|
|
37
|
+
from lorebase_store.settings import database_settings, schema_settings, store_settings
|
|
38
|
+
|
|
39
|
+
router = APIRouter(tags=["schema"])
|
|
40
|
+
|
|
41
|
+
YAML_MEDIA_TYPES = ("text/yaml", "application/yaml", "application/x-yaml")
|
|
42
|
+
MULTIPART = "multipart/form-data"
|
|
43
|
+
#: Form fields besides the files: `options` and `records_path`.
|
|
44
|
+
_MAX_FORM_FIELDS = 2
|
|
45
|
+
SQL_KEYS: tuple[SqlDialect, ...] = ("mysql", "mssql")
|
|
46
|
+
#: Most table names a request may list (the profiled tables are bounded by the settings).
|
|
47
|
+
MAX_TABLE_NAMES = 1000
|
|
48
|
+
|
|
49
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
50
|
+
403: {"model": Problem, "description": "The caller lacks schema.write in the Domain."},
|
|
51
|
+
413: {"model": Problem, "description": "A file or the upload is too large."},
|
|
52
|
+
422: {"model": Problem, "description": "Invalid files, source or options."},
|
|
53
|
+
503: {"model": Problem, "description": "The source database cannot be read."},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_BODY = {
|
|
57
|
+
"requestBody": {
|
|
58
|
+
"required": True,
|
|
59
|
+
"content": {
|
|
60
|
+
MULTIPART: {
|
|
61
|
+
"schema": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"files": {
|
|
65
|
+
"type": "array",
|
|
66
|
+
"items": {"type": "string", "format": "binary"},
|
|
67
|
+
"description": "CSV, TSV, JSON, JSON Lines or Parquet files, read "
|
|
68
|
+
"by suffix; the file name is the table name.",
|
|
69
|
+
},
|
|
70
|
+
"options": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "JSON object of InferenceOptions overrides.",
|
|
73
|
+
},
|
|
74
|
+
"records_path": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "Dotted keys of the records array of JSON documents "
|
|
77
|
+
"that hold it under a key, e.g. data.items.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
"required": ["files"],
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"application/json": {
|
|
84
|
+
"schema": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"postgres": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"dsn": {"type": "string", "example": "secret://env/ERP_DSN"},
|
|
91
|
+
"schema": {"type": "string", "default": "public"},
|
|
92
|
+
"tables": {"type": "array", "items": {"type": "string"}},
|
|
93
|
+
},
|
|
94
|
+
"required": ["dsn"],
|
|
95
|
+
},
|
|
96
|
+
"mysql": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"description": "A MySQL or MariaDB source, in place of postgres.",
|
|
99
|
+
"properties": {
|
|
100
|
+
"dsn": {"type": "string", "example": "secret://env/ERP_DSN"},
|
|
101
|
+
"schema": {"type": "string"},
|
|
102
|
+
"tables": {"type": "array", "items": {"type": "string"}},
|
|
103
|
+
},
|
|
104
|
+
"required": ["dsn"],
|
|
105
|
+
},
|
|
106
|
+
"mssql": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"description": "A SQL Server source, in place of postgres.",
|
|
109
|
+
"properties": {
|
|
110
|
+
"dsn": {"type": "string", "example": "secret://env/ERP_DSN"},
|
|
111
|
+
"schema": {"type": "string"},
|
|
112
|
+
"tables": {"type": "array", "items": {"type": "string"}},
|
|
113
|
+
},
|
|
114
|
+
"required": ["dsn"],
|
|
115
|
+
},
|
|
116
|
+
"options": {"type": "object", "description": "InferenceOptions overrides."},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_PREVIEW_BODY = {
|
|
125
|
+
"requestBody": {
|
|
126
|
+
"required": True,
|
|
127
|
+
"content": {
|
|
128
|
+
MULTIPART: {
|
|
129
|
+
"schema": {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {
|
|
132
|
+
"files": {
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": {"type": "string", "format": "binary"},
|
|
135
|
+
"description": "CSV, TSV, JSON, JSON Lines or Parquet files, read "
|
|
136
|
+
"by suffix.",
|
|
137
|
+
},
|
|
138
|
+
"records_path": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "Dotted keys of the records array of JSON documents "
|
|
141
|
+
"that hold it under a key, e.g. data.items.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
"required": ["files"],
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PostgresSourceIn(BaseModel):
|
|
153
|
+
"""A Postgres source named by an allowed secret reference."""
|
|
154
|
+
|
|
155
|
+
dsn: str = Field(description="Secret reference, e.g. secret://env/ERP_DSN; never a URL.")
|
|
156
|
+
schema_: str = Field(default="public", alias="schema", min_length=1, max_length=63)
|
|
157
|
+
tables: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(
|
|
158
|
+
default_factory=list,
|
|
159
|
+
max_length=MAX_TABLE_NAMES,
|
|
160
|
+
description="Tables to profile; empty: every table of the schema.",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SqlSourceIn(BaseModel):
|
|
165
|
+
"""A MySQL, MariaDB or SQL Server source named by an allowed secret reference."""
|
|
166
|
+
|
|
167
|
+
dsn: str = Field(description="Secret reference, e.g. secret://env/ERP_DSN; never a URL.")
|
|
168
|
+
schema_: str | None = Field(
|
|
169
|
+
default=None,
|
|
170
|
+
alias="schema",
|
|
171
|
+
min_length=1,
|
|
172
|
+
max_length=128,
|
|
173
|
+
description="Schema (SQL Server) or database (MySQL); default: the connection's own.",
|
|
174
|
+
)
|
|
175
|
+
tables: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(
|
|
176
|
+
default_factory=list,
|
|
177
|
+
max_length=MAX_TABLE_NAMES,
|
|
178
|
+
description="Tables to profile; empty: every table.",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class InferFromSource(BaseModel):
|
|
183
|
+
"""JSON body: one database source (`postgres`, `mysql` or `mssql`) and optional option
|
|
184
|
+
overrides."""
|
|
185
|
+
|
|
186
|
+
postgres: PostgresSourceIn | None = None
|
|
187
|
+
mysql: SqlSourceIn | None = None
|
|
188
|
+
mssql: SqlSourceIn | None = None
|
|
189
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
190
|
+
|
|
191
|
+
@model_validator(mode="after")
|
|
192
|
+
def _one_source(self) -> InferFromSource:
|
|
193
|
+
named = [key for key in ("postgres", "mysql", "mssql") if getattr(self, key) is not None]
|
|
194
|
+
if len(named) != 1:
|
|
195
|
+
raise ValueError("name exactly one of postgres, mysql and mssql")
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class FilePreviewOut(BaseModel):
|
|
200
|
+
"""The first rows of one uploaded file."""
|
|
201
|
+
|
|
202
|
+
name: str
|
|
203
|
+
columns: list[str]
|
|
204
|
+
rows: list[dict[str, Any]] = Field(
|
|
205
|
+
description="Records; dates and decimals as text, nested values as JSON."
|
|
206
|
+
)
|
|
207
|
+
row_count: int = Field(description="Rows in the file (those read, for CSV and JSON Lines).")
|
|
208
|
+
truncated: bool = Field(description="Whether the file has more rows than `rows` returned.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PreviewOut(BaseModel):
|
|
212
|
+
"""First rows of uploaded files; nothing is stored."""
|
|
213
|
+
|
|
214
|
+
domain: str
|
|
215
|
+
files: list[FilePreviewOut]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ProposalOut(BaseModel):
|
|
219
|
+
"""A proposed schema; never committed."""
|
|
220
|
+
|
|
221
|
+
domain: str
|
|
222
|
+
status: Literal["proposed"] = "proposed"
|
|
223
|
+
schema_: dict[str, Any] = Field(alias="schema")
|
|
224
|
+
yaml: str = Field(description="The proposed schema as a YAML document.")
|
|
225
|
+
evidence: list[dict[str, Any]]
|
|
226
|
+
issues: list[dict[str, Any]]
|
|
227
|
+
issue_count: int
|
|
228
|
+
tables: list[dict[str, Any]]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _wants_yaml(request: Request, format_: str | None) -> bool:
|
|
232
|
+
if format_ is not None:
|
|
233
|
+
return format_ == "yaml"
|
|
234
|
+
accept = request.headers.get("accept", "")
|
|
235
|
+
return any(media in accept for media in YAML_MEDIA_TYPES)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _options_field(raw: object) -> dict[str, Any]:
|
|
239
|
+
if raw is None:
|
|
240
|
+
return {}
|
|
241
|
+
if not isinstance(raw, str):
|
|
242
|
+
raise service.invalid("/body/options", "must be a JSON object")
|
|
243
|
+
try:
|
|
244
|
+
parsed = json.loads(raw)
|
|
245
|
+
except ValueError:
|
|
246
|
+
raise service.invalid("/body/options", "must be a JSON object") from None
|
|
247
|
+
if not isinstance(parsed, dict):
|
|
248
|
+
raise service.invalid("/body/options", "must be a JSON object")
|
|
249
|
+
return parsed
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _records_path_field(raw: object) -> str | None:
|
|
253
|
+
if raw is None or raw == "":
|
|
254
|
+
return None
|
|
255
|
+
if not isinstance(raw, str):
|
|
256
|
+
raise service.invalid("/body/records_path", "must be dotted keys such as data.items")
|
|
257
|
+
return raw
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _uploads(
|
|
261
|
+
request: Request, max_files: int, max_part_size: int
|
|
262
|
+
) -> tuple[list[service.UploadedFile], dict[str, Any], str | None]:
|
|
263
|
+
try:
|
|
264
|
+
form = await request.form(
|
|
265
|
+
max_files=max_files, max_fields=_MAX_FORM_FIELDS, max_part_size=max_part_size
|
|
266
|
+
)
|
|
267
|
+
except Exception as error: # the multipart parser reports limits and malformed bodies
|
|
268
|
+
raise ProblemError(BAD_REQUEST, f"Unreadable multipart body: {error}") from None
|
|
269
|
+
try:
|
|
270
|
+
files = []
|
|
271
|
+
for item in form.getlist("files"):
|
|
272
|
+
if not isinstance(item, UploadFile):
|
|
273
|
+
raise service.invalid("/body/files", "must be files")
|
|
274
|
+
files.append(service.UploadedFile(item.filename or "upload.csv", await item.read()))
|
|
275
|
+
return (
|
|
276
|
+
files,
|
|
277
|
+
_options_field(form.get("options")),
|
|
278
|
+
_records_path_field(form.get("records_path")),
|
|
279
|
+
)
|
|
280
|
+
finally:
|
|
281
|
+
await form.close()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _json_source(request: Request) -> InferFromSource:
|
|
285
|
+
try:
|
|
286
|
+
return InferFromSource.model_validate(await request.json())
|
|
287
|
+
except ValueError as error:
|
|
288
|
+
if isinstance(error, ValidationError):
|
|
289
|
+
first = error.errors()[0]
|
|
290
|
+
pointer = "/body" + "".join(f"/{part}" for part in first["loc"])
|
|
291
|
+
raise service.invalid(pointer, str(first["msg"])) from None
|
|
292
|
+
raise service.invalid("/body", "is not valid JSON") from None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _render(proposal: SchemaProposal, domain_id: str, yaml_only: bool, limit: int) -> Response:
|
|
296
|
+
text = dump_schema(proposal.schema)
|
|
297
|
+
if yaml_only:
|
|
298
|
+
return Response(text, media_type="text/yaml")
|
|
299
|
+
document = proposal.document()
|
|
300
|
+
body = ProposalOut(
|
|
301
|
+
domain=domain_id,
|
|
302
|
+
schema=document["schema"],
|
|
303
|
+
yaml=text,
|
|
304
|
+
evidence=document["evidence"],
|
|
305
|
+
issues=document["issues"][:limit],
|
|
306
|
+
issue_count=len(proposal.issues),
|
|
307
|
+
tables=document["tables"],
|
|
308
|
+
)
|
|
309
|
+
return Response(body.model_dump_json(by_alias=True), media_type="application/json")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@router.post(
|
|
313
|
+
"/domains/{domain_id}/schema:infer",
|
|
314
|
+
summary="Propose a schema from tables or data files",
|
|
315
|
+
responses={**_ERRORS, 200: {"model": ProposalOut, "content": {"text/yaml": {}}}},
|
|
316
|
+
response_model=ProposalOut,
|
|
317
|
+
openapi_extra=_BODY,
|
|
318
|
+
dependencies=[Depends(require(Permission.SCHEMA_WRITE))],
|
|
319
|
+
)
|
|
320
|
+
async def infer(
|
|
321
|
+
domain_id: str,
|
|
322
|
+
request: Request,
|
|
323
|
+
settings: SettingsDep,
|
|
324
|
+
keeper: KeeperSettingsDep,
|
|
325
|
+
format_: Annotated[Literal["json", "yaml"] | None, Query(alias="format")] = None,
|
|
326
|
+
) -> Response:
|
|
327
|
+
"""Profile the uploaded files (CSV, TSV, JSON, JSON Lines, Parquet) or the tables of a
|
|
328
|
+
Postgres, MySQL, MariaDB or SQL Server source and propose Kinds, typed Attributes, keys and
|
|
329
|
+
Relations with evidence. Deterministic and model-free; nothing is written."""
|
|
330
|
+
config = keeper.schema_inference
|
|
331
|
+
profiling = store_settings(settings).profiling
|
|
332
|
+
rules = schema_settings(settings).rules()
|
|
333
|
+
content_type = request.headers.get("content-type", "")
|
|
334
|
+
if content_type.startswith(MULTIPART):
|
|
335
|
+
# One file over the limit still parses, so the answer names the limit (422); the
|
|
336
|
+
# parser refuses bodies beyond that (400).
|
|
337
|
+
files, overrides, records_path = await _uploads(
|
|
338
|
+
request, config.max_files + 1, config.max_file_bytes
|
|
339
|
+
)
|
|
340
|
+
options = service.merged_options(config, overrides, "/body/options")
|
|
341
|
+
proposal = await service.infer_from_files(
|
|
342
|
+
domain_id, files, config, profiling, options, rules, records_path
|
|
343
|
+
)
|
|
344
|
+
source = ",".join(sorted({config.format_of(upload.name) for upload in files}))
|
|
345
|
+
else:
|
|
346
|
+
body = await _json_source(request)
|
|
347
|
+
options = service.merged_options(config, body.options, "/body/options")
|
|
348
|
+
sql_key = next((key for key in SQL_KEYS if getattr(body, key) is not None), None)
|
|
349
|
+
if sql_key is not None:
|
|
350
|
+
named: SqlSourceIn = getattr(body, sql_key)
|
|
351
|
+
proposal = await service.infer_from_sql(
|
|
352
|
+
domain_id,
|
|
353
|
+
sql_key,
|
|
354
|
+
service.SqlSource(named.dsn, named.schema_, tuple(named.tables)),
|
|
355
|
+
config,
|
|
356
|
+
profiling,
|
|
357
|
+
options,
|
|
358
|
+
rules,
|
|
359
|
+
)
|
|
360
|
+
source = sql_key
|
|
361
|
+
else:
|
|
362
|
+
assert body.postgres is not None # the model names exactly one source
|
|
363
|
+
proposal = await service.infer_from_postgres(
|
|
364
|
+
domain_id,
|
|
365
|
+
service.PostgresSource(
|
|
366
|
+
body.postgres.dsn, body.postgres.schema_, tuple(body.postgres.tables)
|
|
367
|
+
),
|
|
368
|
+
config,
|
|
369
|
+
database_settings(settings),
|
|
370
|
+
profiling.postgres,
|
|
371
|
+
options,
|
|
372
|
+
rules,
|
|
373
|
+
)
|
|
374
|
+
source = "postgres"
|
|
375
|
+
annotate(
|
|
376
|
+
request,
|
|
377
|
+
action="schema.infer",
|
|
378
|
+
target=domain_id,
|
|
379
|
+
source=source,
|
|
380
|
+
tables=len(proposal.tables),
|
|
381
|
+
kinds=len(proposal.schema.kinds),
|
|
382
|
+
relations=len(proposal.schema.relations),
|
|
383
|
+
issues=len(proposal.issues),
|
|
384
|
+
)
|
|
385
|
+
return _render(proposal, domain_id, _wants_yaml(request, format_), config.max_issues)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@router.post(
|
|
389
|
+
"/domains/{domain_id}/schema:preview",
|
|
390
|
+
summary="Read the first rows of uploaded data files",
|
|
391
|
+
responses={**_ERRORS, 200: {"model": PreviewOut}},
|
|
392
|
+
response_model=PreviewOut,
|
|
393
|
+
openapi_extra=_PREVIEW_BODY,
|
|
394
|
+
dependencies=[Depends(require(Permission.SCHEMA_WRITE))],
|
|
395
|
+
)
|
|
396
|
+
async def preview(
|
|
397
|
+
domain_id: str,
|
|
398
|
+
request: Request,
|
|
399
|
+
settings: SettingsDep,
|
|
400
|
+
keeper: KeeperSettingsDep,
|
|
401
|
+
rows: Annotated[
|
|
402
|
+
int | None, Query(description="Rows per file; default and cap are settings.")
|
|
403
|
+
] = None,
|
|
404
|
+
) -> PreviewOut:
|
|
405
|
+
"""Return the first rows of each uploaded file (CSV, TSV, JSON, JSON Lines, Parquet) as
|
|
406
|
+
records, under the permission and size limits of `schema:infer`. Nothing is stored; a
|
|
407
|
+
client uses it to show the rows of a format it cannot read itself."""
|
|
408
|
+
config = keeper.schema_inference
|
|
409
|
+
service.domain_of(domain_id)
|
|
410
|
+
if not request.headers.get("content-type", "").startswith(MULTIPART):
|
|
411
|
+
raise service.invalid("/body", "must be multipart/form-data with files")
|
|
412
|
+
count = service.preview_rows(rows, config)
|
|
413
|
+
files, _, records_path = await _uploads(request, config.max_files + 1, config.max_file_bytes)
|
|
414
|
+
previews = await service.preview_files(
|
|
415
|
+
files, config, store_settings(settings).profiling, count, records_path
|
|
416
|
+
)
|
|
417
|
+
annotate(
|
|
418
|
+
request,
|
|
419
|
+
action="schema.preview",
|
|
420
|
+
target=domain_id,
|
|
421
|
+
source=",".join(sorted({config.format_of(upload.name) for upload in files})),
|
|
422
|
+
files=len(previews),
|
|
423
|
+
rows=count,
|
|
424
|
+
)
|
|
425
|
+
return PreviewOut(
|
|
426
|
+
domain=domain_id,
|
|
427
|
+
files=[
|
|
428
|
+
FilePreviewOut(
|
|
429
|
+
name=item.name,
|
|
430
|
+
columns=list(item.columns),
|
|
431
|
+
rows=item.rows,
|
|
432
|
+
row_count=item.row_count,
|
|
433
|
+
truncated=item.truncated,
|
|
434
|
+
)
|
|
435
|
+
for item in previews
|
|
436
|
+
],
|
|
437
|
+
)
|