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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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
+ )