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,390 @@
1
+ """Schema inference from uploaded files or a Postgres source (design 9.1, extraction tier 0).
2
+
3
+ Uploads are read by suffix (`keeper.schema_inference.file_formats`): CSV or TSV text, JSON
4
+ documents holding an array of objects, JSON Lines, or Parquet files when pyarrow is installed
5
+ (the `lorebase-keeper[parquet]` extra; without it a Parquet upload is refused with a 422).
6
+
7
+ Nothing is committed: the proposal is returned for review, and a modeler applies it (edited)
8
+ through a schema change set on a branch. CPU-bound profiling and inference run in a worker
9
+ thread so the event loop stays free.
10
+
11
+ MySQL, MariaDB and SQL Server sources are profiled the same way (a bounded sample per table
12
+ read through `lorebase_store.profiling.sql_databases`, then the record profiler); their drivers
13
+ are the optional extras `lorebase-keeper[mysql]` and `lorebase-keeper[mssql]`.
14
+
15
+ A preview (`preview_files`) reads the same uploads under the same limits and returns their first
16
+ rows as records instead of a proposal; nothing is stored.
17
+
18
+ Database sources are named by secret reference only, and only references listed for the Domain
19
+ in `keeper.schema_inference.domain_sources` (or, for Domains not listed there, in
20
+ `keeper.schema_inference.allowed_sources`) are accepted: callers never send connection strings,
21
+ and errors never echo the resolved value nor the sources of other Domains.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from base64 import b64encode
28
+ from collections.abc import Mapping, Sequence
29
+ from dataclasses import dataclass
30
+ from datetime import date, datetime, time, timedelta
31
+ from decimal import Decimal
32
+ from typing import Any
33
+
34
+ from pydantic import ValidationError
35
+ from sqlalchemy.exc import SQLAlchemyError
36
+ from starlette.concurrency import run_in_threadpool
37
+
38
+ from lorebase_core.infer import InferenceOptions, SchemaProposal, TableProfile, infer_schema
39
+ from lorebase_core.model import SECRET_REFERENCE_PREFIX, Domain
40
+ from lorebase_core.validate import SchemaRules
41
+ from lorebase_keeper.errors import PAYLOAD_TOO_LARGE, UNAVAILABLE, VALIDATION_FAILED, ProblemError
42
+ from lorebase_keeper.services.config import SchemaInferenceSettings, UploadFormat
43
+ from lorebase_platform.secrets import SecretResolutionError, resolve_secret
44
+ from lorebase_store.db import create_engine
45
+ from lorebase_store.profiling import (
46
+ CsvProfileError,
47
+ ParquetUnavailableError,
48
+ PostgresProfileError,
49
+ RecordProfileError,
50
+ SqlDialect,
51
+ SqlDriverError,
52
+ SqlProfileError,
53
+ profile_bytes,
54
+ profile_json_bytes,
55
+ profile_jsonl_bytes,
56
+ profile_parquet_bytes,
57
+ profile_postgres,
58
+ profile_sql_database,
59
+ table_name,
60
+ )
61
+ from lorebase_store.settings import (
62
+ DatabaseSettings,
63
+ PostgresProfilingSettings,
64
+ ProfilingSettings,
65
+ )
66
+
67
+ logger = logging.getLogger("lorebase.keeper.schema_inference")
68
+
69
+ PARQUET_EXTRA = "lorebase-keeper[parquet]"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class UploadedFile:
74
+ name: str
75
+ data: bytes
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class PostgresSource:
80
+ dsn: str
81
+ schema: str
82
+ tables: tuple[str, ...]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SqlSource:
87
+ """A MySQL, MariaDB or SQL Server source; `schema` defaults to the connection's own."""
88
+
89
+ dsn: str
90
+ schema: str | None
91
+ tables: tuple[str, ...]
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class FilePreview:
96
+ """The first rows of an uploaded file, as JSON-ready records."""
97
+
98
+ name: str
99
+ columns: tuple[str, ...]
100
+ rows: list[dict[str, Any]]
101
+ row_count: int
102
+ truncated: bool
103
+
104
+
105
+ def invalid(pointer: str, message: str) -> ProblemError:
106
+ """422 naming one invalid request member."""
107
+ return ProblemError(
108
+ VALIDATION_FAILED,
109
+ "One or more request members are invalid.",
110
+ extensions={"errors": [{"pointer": pointer, "message": message, "code": "invalid"}]},
111
+ )
112
+
113
+
114
+ def domain_of(domain_id: str) -> Domain:
115
+ try:
116
+ return Domain(id=domain_id)
117
+ except ValidationError:
118
+ raise invalid("/path/domain_id", "is not a Domain id") from None
119
+
120
+
121
+ def merged_options(
122
+ settings: SchemaInferenceSettings, overrides: Mapping[str, Any] | None, pointer: str
123
+ ) -> InferenceOptions:
124
+ """The configured options with a request's overrides on top."""
125
+ if not overrides:
126
+ return settings.options
127
+ try:
128
+ return InferenceOptions.model_validate({**settings.options.model_dump(), **overrides})
129
+ except ValidationError as error:
130
+ first = error.errors()[0]
131
+ location = "/".join(str(part) for part in first["loc"])
132
+ raise invalid(f"{pointer}/{location}", str(first["msg"])) from None
133
+
134
+
135
+ def check_upload_sizes(files: Sequence[UploadedFile], settings: SchemaInferenceSettings) -> None:
136
+ """Refuse requests over the file count, size or suffix limits."""
137
+ if not files:
138
+ raise invalid("/body/files", "upload at least one file")
139
+ if len(files) > settings.max_files:
140
+ raise invalid("/body/files", f"at most {settings.max_files} files are accepted")
141
+ names: set[str] = set()
142
+ for index, upload in enumerate(files):
143
+ if not upload.name.lower().endswith(settings.file_suffixes):
144
+ raise invalid(
145
+ f"/body/files/{index}",
146
+ f"file names must end with one of {', '.join(settings.file_suffixes)}",
147
+ )
148
+ name = table_name(upload.name)
149
+ if name in names:
150
+ raise invalid(f"/body/files/{index}", f"two files are named {name!r}")
151
+ names.add(name)
152
+ if len(upload.data) > settings.max_file_bytes:
153
+ raise ProblemError(
154
+ PAYLOAD_TOO_LARGE, f"{upload.name} exceeds {settings.max_file_bytes} bytes."
155
+ )
156
+ if sum(len(f.data) for f in files) > settings.max_total_bytes:
157
+ raise ProblemError(
158
+ PAYLOAD_TOO_LARGE, f"The files exceed {settings.max_total_bytes} bytes in total."
159
+ )
160
+
161
+
162
+ def check_records_path(records_path: str | None, pointer: str = "/body/records_path") -> None:
163
+ """Refuse an empty dotted path or one with an empty key."""
164
+ if records_path is not None and not all(records_path.split(".")):
165
+ raise invalid(pointer, "must be dotted keys such as data.items")
166
+
167
+
168
+ def _profile_file(
169
+ upload: UploadedFile,
170
+ upload_format: UploadFormat,
171
+ profiling: ProfilingSettings,
172
+ records_path: str | None = None,
173
+ ) -> TableProfile:
174
+ match upload_format:
175
+ case "json":
176
+ return profile_json_bytes(upload.data, upload.name, profiling.records, records_path)
177
+ case "jsonl":
178
+ return profile_jsonl_bytes(upload.data, upload.name, profiling.records)
179
+ case "parquet":
180
+ return profile_parquet_bytes(upload.data, upload.name, profiling.records)
181
+ case _:
182
+ return profile_bytes(upload.data, upload.name, profiling.csv)
183
+
184
+
185
+ def _profile_files(
186
+ files: Sequence[UploadedFile],
187
+ settings: SchemaInferenceSettings,
188
+ profiling: ProfilingSettings,
189
+ records_path: str | None = None,
190
+ ) -> list[TableProfile]:
191
+ profiles = []
192
+ for index, upload in enumerate(files):
193
+ try:
194
+ profiles.append(
195
+ _profile_file(upload, settings.format_of(upload.name), profiling, records_path)
196
+ )
197
+ except ParquetUnavailableError as error:
198
+ raise invalid(
199
+ f"/body/files/{index}", f"{error}; this server needs {PARQUET_EXTRA}"
200
+ ) from None
201
+ except (CsvProfileError, RecordProfileError) as error:
202
+ raise invalid(f"/body/files/{index}", str(error)) from None
203
+ return profiles
204
+
205
+
206
+ async def infer_from_files(
207
+ domain_id: str,
208
+ files: Sequence[UploadedFile],
209
+ settings: SchemaInferenceSettings,
210
+ profiling: ProfilingSettings,
211
+ options: InferenceOptions,
212
+ rules: SchemaRules | None = None,
213
+ records_path: str | None = None,
214
+ ) -> SchemaProposal:
215
+ """Profile uploaded files and propose a schema (validated with `rules`). `records_path`
216
+ (dotted keys) locates the records array of JSON documents holding them under a key."""
217
+ domain = domain_of(domain_id)
218
+ check_upload_sizes(files, settings)
219
+ check_records_path(records_path)
220
+
221
+ def run() -> SchemaProposal:
222
+ profiles = _profile_files(files, settings, profiling, records_path)
223
+ return infer_schema(profiles, domain, options, rules)
224
+
225
+ return await run_in_threadpool(run)
226
+
227
+
228
+ def _json_value(value: Any, limit: int) -> Any:
229
+ """A profiled value as JSON: dates as ISO text, decimals as text (exact), bytes as base64;
230
+ text is cut to `limit` characters."""
231
+ if isinstance(value, str):
232
+ return value if len(value) <= limit else value[:limit] + "..."
233
+ if value is None or isinstance(value, bool | int):
234
+ return value
235
+ if isinstance(value, float):
236
+ return value if value == value and abs(value) != float("inf") else str(value)
237
+ if isinstance(value, datetime | date | time):
238
+ return value.isoformat()
239
+ if isinstance(value, Decimal):
240
+ return str(value)
241
+ if isinstance(value, timedelta):
242
+ return value.total_seconds()
243
+ if isinstance(value, bytes | bytearray | memoryview):
244
+ return b64encode(bytes(value)).decode("ascii")
245
+ if isinstance(value, Mapping):
246
+ return {str(key): _json_value(item, limit) for key, item in value.items()}
247
+ if isinstance(value, list | tuple):
248
+ return [_json_value(item, limit) for item in value]
249
+ return str(value)
250
+
251
+
252
+ def preview_rows(rows: int | None, settings: SchemaInferenceSettings) -> int:
253
+ """The number of preview rows: the request's, or the configured default, within the cap."""
254
+ count = settings.preview_rows if rows is None else rows
255
+ if not 1 <= count <= settings.preview_max_rows:
256
+ raise invalid("/query/rows", f"must be between 1 and {settings.preview_max_rows}")
257
+ return count
258
+
259
+
260
+ async def preview_files(
261
+ files: Sequence[UploadedFile],
262
+ settings: SchemaInferenceSettings,
263
+ profiling: ProfilingSettings,
264
+ rows: int,
265
+ records_path: str | None = None,
266
+ ) -> list[FilePreview]:
267
+ """The first `rows` rows of each uploaded file as records, under the limits of an
268
+ inference request; nothing is stored. CSV and TSV values stay text."""
269
+ check_upload_sizes(files, settings)
270
+ check_records_path(records_path)
271
+ head: dict[str, Any] = {"sample_rows": rows, "sampling": "head"}
272
+ bounded = profiling.model_copy(
273
+ update={
274
+ "csv": profiling.csv.model_copy(update=head),
275
+ "records": profiling.records.model_copy(update=head),
276
+ }
277
+ )
278
+
279
+ def run() -> list[FilePreview]:
280
+ previews = []
281
+ profiles = _profile_files(files, settings, bounded, records_path)
282
+ for upload, profile in zip(files, profiles, strict=True):
283
+ names = tuple(column.name for column in profile.columns)
284
+ values = [list(column.values) for column in profile.columns]
285
+ kept = len(values[0]) if values else 0
286
+ records = [
287
+ {
288
+ name: _json_value(column[index], settings.preview_max_cell_chars)
289
+ for name, column in zip(names, values, strict=True)
290
+ }
291
+ for index in range(kept)
292
+ ]
293
+ total = profile.row_count if profile.row_count is not None else kept
294
+ previews.append(FilePreview(upload.name, names, records, total, total > kept))
295
+ return previews
296
+
297
+ return await run_in_threadpool(run)
298
+
299
+
300
+ def _resolve_source(
301
+ domain: Domain,
302
+ source: PostgresSource | SqlSource,
303
+ settings: SchemaInferenceSettings,
304
+ key: str = "postgres",
305
+ ) -> str:
306
+ pointer = f"/body/{key}/dsn"
307
+ if not source.dsn.startswith(SECRET_REFERENCE_PREFIX):
308
+ raise invalid(pointer, "must be a secret reference such as secret://env/NAME")
309
+ if source.dsn not in settings.sources_for(domain.id):
310
+ raise invalid(pointer, "is not an allowed source for this Domain")
311
+ try:
312
+ return resolve_secret(source.dsn)
313
+ except SecretResolutionError:
314
+ raise invalid(pointer, "cannot be resolved on this server") from None
315
+
316
+
317
+ async def infer_from_postgres(
318
+ domain_id: str,
319
+ source: PostgresSource,
320
+ settings: SchemaInferenceSettings,
321
+ database: DatabaseSettings,
322
+ postgres: PostgresProfilingSettings,
323
+ options: InferenceOptions,
324
+ rules: SchemaRules | None = None,
325
+ ) -> SchemaProposal:
326
+ """Profile tables of an allowed Postgres source (read-only) and propose a schema
327
+ (validated with `rules`)."""
328
+ domain = domain_of(domain_id)
329
+ url = _resolve_source(domain, source, settings)
330
+ try:
331
+ engine = create_engine(database, url=url, pooled=False)
332
+ except Exception: # the message of a URL parse error may contain the URL
333
+ raise invalid("/body/postgres/dsn", "does not hold a Postgres URL") from None
334
+ try:
335
+ profiles = await profile_postgres(
336
+ engine, postgres, schema=source.schema, tables=source.tables or None
337
+ )
338
+ except PostgresProfileError as error:
339
+ raise invalid("/body/postgres", str(error)) from None
340
+ except (SQLAlchemyError, OSError) as error:
341
+ logger.warning(
342
+ "schema inference source unavailable",
343
+ extra={"source": source.dsn, "error": type(error).__name__},
344
+ )
345
+ raise ProblemError(UNAVAILABLE, "The source database cannot be read.") from None
346
+ finally:
347
+ await engine.dispose()
348
+ return await run_in_threadpool(infer_schema, profiles, domain, options, rules)
349
+
350
+
351
+ async def infer_from_sql(
352
+ domain_id: str,
353
+ dialect: SqlDialect,
354
+ source: SqlSource,
355
+ settings: SchemaInferenceSettings,
356
+ profiling: ProfilingSettings,
357
+ options: InferenceOptions,
358
+ rules: SchemaRules | None = None,
359
+ ) -> SchemaProposal:
360
+ """Profile tables of an allowed MySQL, MariaDB or SQL Server source (bounded sample per
361
+ table, read-only where the server allows it) and propose a schema (validated with
362
+ `rules`)."""
363
+ domain = domain_of(domain_id)
364
+ url = _resolve_source(domain, source, settings, dialect)
365
+ try:
366
+ profiles = await profile_sql_database(
367
+ dialect,
368
+ url,
369
+ profiling.sql,
370
+ profiling.records,
371
+ schema=source.schema,
372
+ tables=source.tables or None,
373
+ allowed_schemas=settings.allowed_schemas.get(source.dsn),
374
+ )
375
+ except SqlDriverError as error:
376
+ # The server's own set-up problem: logged, not described to the caller.
377
+ logger.warning(
378
+ "schema inference driver unavailable",
379
+ extra={"source": source.dsn, "detail": str(error)},
380
+ )
381
+ raise ProblemError(UNAVAILABLE, "The source database cannot be read.") from None
382
+ except SqlProfileError as error:
383
+ raise invalid(f"/body/{dialect}", str(error)) from None
384
+ except Exception as error: # driver, network and catalog failures all come from the worker
385
+ logger.warning(
386
+ "schema inference source unavailable",
387
+ extra={"source": source.dsn, "error": type(error).__name__},
388
+ )
389
+ raise ProblemError(UNAVAILABLE, "The source database cannot be read.") from None
390
+ return await run_in_threadpool(infer_schema, profiles, domain, options, rules)