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,232 @@
|
|
|
1
|
+
"""Preview of the expression mappings of a candidate Source (design 5.9, 11.1).
|
|
2
|
+
|
|
3
|
+
| Route | Permission |
|
|
4
|
+
|-------|------------|
|
|
5
|
+
| `POST /domains/{d}/sources:preview` | `schema.read` on the branch or the Source's target Kind |
|
|
6
|
+
|
|
7
|
+
The body is a schema change set, as for `POST /schema:validate` (`yaml`, or `patch` and
|
|
8
|
+
`remove`; an empty `patch` previews the schema as saved), `source`, the api name of the Source in
|
|
9
|
+
the schema it produces, and `rows`, sample rows of the Source as the caller read them (column
|
|
10
|
+
name to value). The schema is validated like a schema change (`schema.expression_limits`
|
|
11
|
+
included), then every Attribute mapped by `expression` is evaluated on each row, as Scribe
|
|
12
|
+
evaluates it when it maps rows, and the value before the mapping's transforms is returned.
|
|
13
|
+
Expressions are compiled once over every column the rows have: a column a row lacks is null.
|
|
14
|
+
|
|
15
|
+
Rows come from the caller and nothing else is read: no Record, no connection, no secret. At
|
|
16
|
+
most `read.source_preview_max_rows` rows of at most `read.source_preview_max_columns` columns
|
|
17
|
+
are accepted, and at most `read.source_preview_max_cells` expressions (rows times Attributes
|
|
18
|
+
mapped by an expression) are evaluated, off the event loop. A grant of `schema.read` narrowed
|
|
19
|
+
to Kinds is enough when it names the Source's target Kind (in the candidate and, when the Kind
|
|
20
|
+
exists there, in the schema read); otherwise the preview is refused (403) before the
|
|
21
|
+
candidate is validated.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from functools import partial
|
|
28
|
+
from typing import Annotated, Any
|
|
29
|
+
|
|
30
|
+
import anyio
|
|
31
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
32
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
33
|
+
|
|
34
|
+
from lorebase_core.model import Schema, Source
|
|
35
|
+
from lorebase_keeper.auth.audit import annotate
|
|
36
|
+
from lorebase_keeper.auth.deps import AuthDep, PrincipalDep
|
|
37
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
38
|
+
from lorebase_keeper.context import StoreDep
|
|
39
|
+
from lorebase_keeper.deps import KeeperSettingsDep
|
|
40
|
+
from lorebase_keeper.errors import Problem, ValidationProblem
|
|
41
|
+
from lorebase_keeper.reading.points import ReadPoint, resolve_point
|
|
42
|
+
from lorebase_keeper.reading.problems import invalid
|
|
43
|
+
from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep
|
|
44
|
+
from lorebase_keeper.reading.settings import ReadSettings
|
|
45
|
+
from lorebase_keeper.routes.derivations import PreviewErrorOut
|
|
46
|
+
from lorebase_keeper.routes.schema import ChangeSetBody
|
|
47
|
+
from lorebase_keeper.services import source_preview as service
|
|
48
|
+
from lorebase_keeper.services.derivation_preview import CandidateInvalidError
|
|
49
|
+
from lorebase_keeper.services.errors import violations_problem
|
|
50
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
51
|
+
|
|
52
|
+
router = APIRouter(tags=["sources"])
|
|
53
|
+
|
|
54
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
55
|
+
403: {
|
|
56
|
+
"model": Problem,
|
|
57
|
+
"description": "The caller lacks schema.read for the branch and for the Source's "
|
|
58
|
+
"target Kind.",
|
|
59
|
+
},
|
|
60
|
+
404: {"model": Problem, "description": "Domain, branch or Revision not found."},
|
|
61
|
+
422: {
|
|
62
|
+
"model": ValidationProblem,
|
|
63
|
+
"description": "Invalid request, or a candidate schema that fails validation (`issues` "
|
|
64
|
+
"locate why).",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SourcePreviewRequest(ChangeSetBody):
|
|
70
|
+
"""A candidate schema change set, the Source to preview in it and sample rows."""
|
|
71
|
+
|
|
72
|
+
source: str = Field(
|
|
73
|
+
min_length=1, description="Api name of the Source in the schema the change set produces."
|
|
74
|
+
)
|
|
75
|
+
rows: list[dict[str, JsonValue]] = Field(
|
|
76
|
+
min_length=1,
|
|
77
|
+
description="Sample rows: column name to value (text for CSV, native values for JSON). "
|
|
78
|
+
"At most `read.source_preview_max_rows`, each with at most "
|
|
79
|
+
"`read.source_preview_max_columns` columns.",
|
|
80
|
+
)
|
|
81
|
+
branch: str | None = Field(default=None, description="Default: the default branch.")
|
|
82
|
+
at: str | None = Field(default=None, description="Revision id, tag or branch name.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ExpressionCellOut(BaseModel):
|
|
86
|
+
"""The value of one expression mapping for one row, before the transforms."""
|
|
87
|
+
|
|
88
|
+
value: JsonValue = Field(description="Null when there is none or `error` is set.")
|
|
89
|
+
error: PreviewErrorOut | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SourcePreviewOut(BaseModel):
|
|
93
|
+
"""Values of the expression mappings of a candidate Source on sample rows."""
|
|
94
|
+
|
|
95
|
+
domain: str
|
|
96
|
+
branch: str | None
|
|
97
|
+
revision: str = Field(description="Revision whose schema the change set was applied to.")
|
|
98
|
+
source: str
|
|
99
|
+
target_kind: str
|
|
100
|
+
attributes: list[str] = Field(description="Attributes mapped by an expression, by api name.")
|
|
101
|
+
rows: list[dict[str, ExpressionCellOut]] = Field(
|
|
102
|
+
description="One entry per row sent: the cell of each Attribute in `attributes`."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def schema_reader() -> Callable[..., Awaitable[ReadGuard]]:
|
|
107
|
+
"""Dependency: the authenticated reader of the Domain named by the path; `schema.read` is
|
|
108
|
+
checked once the read point is resolved."""
|
|
109
|
+
|
|
110
|
+
async def dependency(
|
|
111
|
+
request: Request, principal: PrincipalDep, auth: AuthDep, keeper: KeeperSettingsDep
|
|
112
|
+
) -> ReadGuard:
|
|
113
|
+
domain_id = str(request.path_params["domain_id"])
|
|
114
|
+
annotate(request, domain_id=domain_id, permission=Permission.SCHEMA_READ.value)
|
|
115
|
+
return ReadGuard(
|
|
116
|
+
authorizer=auth.authorizer,
|
|
117
|
+
principal=principal,
|
|
118
|
+
permissions=(Permission.SCHEMA_READ,),
|
|
119
|
+
domain_id=domain_id,
|
|
120
|
+
max_depth=keeper.reads.reachability_max_depth,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return dependency
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
SchemaReader = Annotated[ReadGuard, Depends(schema_reader())]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _bound_rows(rows: list[dict[str, JsonValue]], expressions: int, read: ReadSettings) -> None:
|
|
130
|
+
"""422 for more rows, columns or cells than the settings allow."""
|
|
131
|
+
if len(rows) > read.source_preview_max_rows:
|
|
132
|
+
raise invalid("/rows", f"must not exceed {read.source_preview_max_rows}", "limit_exceeded")
|
|
133
|
+
for index, row in enumerate(rows):
|
|
134
|
+
if len(row) > read.source_preview_max_columns:
|
|
135
|
+
raise invalid(
|
|
136
|
+
f"/rows/{index}",
|
|
137
|
+
f"must not have more than {read.source_preview_max_columns} columns",
|
|
138
|
+
"limit_exceeded",
|
|
139
|
+
)
|
|
140
|
+
if len(rows) * expressions > read.source_preview_max_cells:
|
|
141
|
+
raise invalid(
|
|
142
|
+
"/rows",
|
|
143
|
+
f"{len(rows)} rows times {expressions} expression mappings exceed "
|
|
144
|
+
f"{read.source_preview_max_cells} cells",
|
|
145
|
+
"limit_exceeded",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _target_kind_check(point: ReadPoint) -> Callable[[Schema, Source | None], None]:
|
|
150
|
+
"""403 unless the caller may read the Source's target Kind: always with grants that are
|
|
151
|
+
not narrowed to Kinds; with Kind-narrowed grants, when they name it in the candidate and,
|
|
152
|
+
if the Kind exists there, in the schema read (a rename in the change set moves nothing)."""
|
|
153
|
+
|
|
154
|
+
def check(candidate: Schema, source: Source | None) -> None:
|
|
155
|
+
if point.scope.kinds is None:
|
|
156
|
+
return
|
|
157
|
+
kind = None if source is None else candidate.kinds.get(source.target_kind)
|
|
158
|
+
if kind is None:
|
|
159
|
+
raise point.scope.guard.denied()
|
|
160
|
+
names = {kind.api_name}
|
|
161
|
+
stored = point.schema.kinds.get(kind.id)
|
|
162
|
+
if stored is not None:
|
|
163
|
+
names.add(stored.api_name)
|
|
164
|
+
if point.scope.invisible and not all(point.scope.allows(n) for n in names):
|
|
165
|
+
# Like a Source that does not exist: nothing tells which Kind is missing.
|
|
166
|
+
raise point.scope.guard.denied()
|
|
167
|
+
point.scope.require(names)
|
|
168
|
+
|
|
169
|
+
return check
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.post(
|
|
173
|
+
"/domains/{domain_id}/sources:preview",
|
|
174
|
+
summary="Preview the expression mappings of a candidate Source",
|
|
175
|
+
response_model=SourcePreviewOut,
|
|
176
|
+
responses=_ERRORS,
|
|
177
|
+
)
|
|
178
|
+
async def preview_source(
|
|
179
|
+
domain_id: str,
|
|
180
|
+
payload: Annotated[SourcePreviewRequest, Body()],
|
|
181
|
+
access: SchemaReader,
|
|
182
|
+
store: StoreDep,
|
|
183
|
+
keeper: KeeperSettingsDep,
|
|
184
|
+
runtime: ReadRuntimeDep,
|
|
185
|
+
session: ReadSessionDep,
|
|
186
|
+
) -> SourcePreviewOut:
|
|
187
|
+
"""Evaluate the `expression` mappings of a Source, as edited and not yet saved, on sample
|
|
188
|
+
rows. Nothing is read from the Source and nothing is written."""
|
|
189
|
+
_bound_rows(payload.rows, 0, runtime.read)
|
|
190
|
+
point = await resolve_point(session, runtime, access, branch=payload.branch, at=payload.at)
|
|
191
|
+
try:
|
|
192
|
+
candidate = service.candidate_of(
|
|
193
|
+
point.schema,
|
|
194
|
+
payload.to_input(),
|
|
195
|
+
payload.source,
|
|
196
|
+
store.runtime.schema.rules(),
|
|
197
|
+
authorize=_target_kind_check(point),
|
|
198
|
+
)
|
|
199
|
+
except CandidateInvalidError as error:
|
|
200
|
+
raise violations_problem(error.issues, limit=keeper.schema_changes.max_issues) from None
|
|
201
|
+
attributes = service.expression_attributes(candidate)
|
|
202
|
+
_bound_rows(payload.rows, len(attributes), runtime.read)
|
|
203
|
+
cells = await anyio.to_thread.run_sync(
|
|
204
|
+
partial(
|
|
205
|
+
service.evaluate,
|
|
206
|
+
candidate,
|
|
207
|
+
payload.rows,
|
|
208
|
+
limits=store.runtime.schema.expression_limits,
|
|
209
|
+
now=store.clock(),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
kind = candidate.schema.kinds[candidate.source.target_kind]
|
|
213
|
+
return SourcePreviewOut(
|
|
214
|
+
domain=domain_id,
|
|
215
|
+
branch=point.branch,
|
|
216
|
+
revision=point.revision_hex,
|
|
217
|
+
source=payload.source,
|
|
218
|
+
target_kind=kind.api_name,
|
|
219
|
+
attributes=list(attributes),
|
|
220
|
+
rows=[
|
|
221
|
+
{
|
|
222
|
+
attribute: ExpressionCellOut(
|
|
223
|
+
value=cell.value,
|
|
224
|
+
error=None
|
|
225
|
+
if cell.error_code is None
|
|
226
|
+
else PreviewErrorOut(code=cell.error_code, message=cell.error_message),
|
|
227
|
+
)
|
|
228
|
+
for attribute, cell in row.items()
|
|
229
|
+
}
|
|
230
|
+
for row in cells
|
|
231
|
+
],
|
|
232
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Structured Sources of a Domain (design 9.1): trigger a synchronization, list its runs.
|
|
2
|
+
|
|
3
|
+
`POST /domains/{d}/sources/{s}/sync` queues a `source_sync` job that Scribe runs (permission
|
|
4
|
+
`source.sync`: `editor`, `maintainer`, `admin`) and answers 202 with the job id; while an
|
|
5
|
+
identical job is queued or running, that job is returned instead of a new one. The Source is
|
|
6
|
+
named by id or api name and looked up in the schema of the Domain's default branch. Every
|
|
7
|
+
trigger is audited (`source.sync`). Triggering a Source that maps vault Attributes also needs
|
|
8
|
+
`pii.import` on the whole Domain (`services.pii_import`): a caller who could feed or re-run
|
|
9
|
+
such a Source would learn whether a value was erased.
|
|
10
|
+
|
|
11
|
+
`GET /domains/{d}/sources/{s}/runs` lists the runs recorded by Scribe, newest first, with their
|
|
12
|
+
counts, Revision, Proposal and error. It needs `data.read` through a Domain-wide grant: run
|
|
13
|
+
stats cover every Kind the Source writes and errors quote row values, so callers whose grants
|
|
14
|
+
are narrowed to a Kind or a branch get 403, as for unknown Domains they may not be told about.
|
|
15
|
+
Errors are shown only to callers cleared Domain-wide for every sensitivity tag (they may
|
|
16
|
+
quote sensitive values of any branch's schema); others get `{"redacted": true}`.
|
|
17
|
+
The trigger needs a Domain-wide `source.sync` grant and answers with the job only, never run
|
|
18
|
+
data.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Annotated, Any
|
|
25
|
+
|
|
26
|
+
from fastapi import APIRouter, Depends, Request, Response
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
29
|
+
|
|
30
|
+
from lorebase_core.model import Schema, Sensitivity, Source
|
|
31
|
+
from lorebase_keeper.auth.audit import annotate
|
|
32
|
+
from lorebase_keeper.auth.deps import Access, AuthDep, require
|
|
33
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
34
|
+
from lorebase_keeper.context import StoreDep, require_domain
|
|
35
|
+
from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, Problem, ProblemError
|
|
36
|
+
from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
|
|
37
|
+
from lorebase_keeper.reading.points import open_domain, read_guard
|
|
38
|
+
from lorebase_keeper.reading.sensitivity import clearance_of
|
|
39
|
+
from lorebase_keeper.services.pii_import import require_pii_import, source_vault_targets
|
|
40
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
41
|
+
from lorebase_store.commit.schema import load_compiled
|
|
42
|
+
from lorebase_store.jobs import SOURCE_SYNC_JOB, enqueue_job, source_sync_payload
|
|
43
|
+
from lorebase_store.repositories import (
|
|
44
|
+
MaterializedRepository,
|
|
45
|
+
SourceRunRepository,
|
|
46
|
+
SourceRunRow,
|
|
47
|
+
)
|
|
48
|
+
from lorebase_store.runtime import StoreRuntime
|
|
49
|
+
|
|
50
|
+
router = APIRouter(tags=["sources"])
|
|
51
|
+
|
|
52
|
+
BASE = "/domains/{domain_id}/sources/{source}"
|
|
53
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
54
|
+
403: {"model": Problem, "description": "The caller lacks the permission."},
|
|
55
|
+
404: {"model": Problem, "description": "Domain or Source not found."},
|
|
56
|
+
}
|
|
57
|
+
_SYNC_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
58
|
+
**_ERRORS,
|
|
59
|
+
403: {
|
|
60
|
+
"model": Problem,
|
|
61
|
+
"description": "The caller lacks source.sync, or pii.import for a Source mapping "
|
|
62
|
+
"vault Attributes.",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Sync = Annotated[Access, Depends(require(Permission.SOURCE_SYNC))]
|
|
67
|
+
ReadRuns = Annotated[ReadGuard, Depends(read_guard())]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SyncRequest(BaseModel):
|
|
71
|
+
model_config = ConfigDict(extra="forbid")
|
|
72
|
+
|
|
73
|
+
dry_run: bool = Field(
|
|
74
|
+
default=False, description="Compute and record the delta without writing to any branch."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SyncQueued(BaseModel):
|
|
79
|
+
job_id: int = Field(description="Id of the queued (or already pending) job.")
|
|
80
|
+
source: str = Field(description="Source id.")
|
|
81
|
+
api_name: str
|
|
82
|
+
dry_run: bool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SourceRunOut(BaseModel):
|
|
86
|
+
id: str
|
|
87
|
+
source: str
|
|
88
|
+
status: str
|
|
89
|
+
started_at: datetime | None = None
|
|
90
|
+
finished_at: datetime | None = None
|
|
91
|
+
stats: dict[str, Any]
|
|
92
|
+
revision: str | None = None
|
|
93
|
+
proposal_id: str | None = None
|
|
94
|
+
error: dict[str, Any] | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _source(
|
|
98
|
+
session: AsyncSession, domain_id: str, branch: str, reference: str, runtime: StoreRuntime
|
|
99
|
+
) -> tuple[Schema, Source]:
|
|
100
|
+
"""The Source named by id or api name in the schema of `branch`, with that schema."""
|
|
101
|
+
compiled = await MaterializedRepository(
|
|
102
|
+
session, domain_id, branch, writes=runtime.settings.writes
|
|
103
|
+
).schema()
|
|
104
|
+
schema = None if compiled is None else load_compiled(compiled.compiled)
|
|
105
|
+
found = None
|
|
106
|
+
if schema is not None:
|
|
107
|
+
found = schema.sources.get(reference) or schema.source_by_api_name(reference)
|
|
108
|
+
if schema is None or found is None:
|
|
109
|
+
raise ProblemError(NOT_FOUND, f"Source {reference!r} not found.")
|
|
110
|
+
return schema, found
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
REDACTED_ERROR: dict[str, Any] = {"redacted": True}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run(row: SourceRunRow, *, errors: bool) -> SourceRunOut:
|
|
117
|
+
return SourceRunOut(
|
|
118
|
+
id=row.id,
|
|
119
|
+
source=row.source_id,
|
|
120
|
+
status=row.status.value,
|
|
121
|
+
started_at=row.started_at,
|
|
122
|
+
finished_at=row.finished_at,
|
|
123
|
+
stats=row.stats,
|
|
124
|
+
revision=None if row.revision_id is None else row.revision_id.hex(),
|
|
125
|
+
proposal_id=row.proposal_id,
|
|
126
|
+
error=row.error if errors or row.error is None else REDACTED_ERROR,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.post(
|
|
131
|
+
f"{BASE}/sync",
|
|
132
|
+
status_code=202,
|
|
133
|
+
summary="Synchronize a Source",
|
|
134
|
+
response_model=SyncQueued,
|
|
135
|
+
responses=_SYNC_ERRORS,
|
|
136
|
+
)
|
|
137
|
+
async def sync_source(
|
|
138
|
+
domain_id: str,
|
|
139
|
+
source: str,
|
|
140
|
+
access: Sync,
|
|
141
|
+
auth: AuthDep,
|
|
142
|
+
store: StoreDep,
|
|
143
|
+
request: Request,
|
|
144
|
+
response: Response,
|
|
145
|
+
body: SyncRequest | None = None,
|
|
146
|
+
) -> SyncQueued:
|
|
147
|
+
"""Queue a synchronization of the Source for Scribe."""
|
|
148
|
+
dry_run = body is not None and body.dry_run
|
|
149
|
+
async with store.sessions() as session, session.begin():
|
|
150
|
+
domain = await require_domain(session, domain_id)
|
|
151
|
+
schema, found = await _source(
|
|
152
|
+
session, domain_id, domain.default_branch, source, store.runtime
|
|
153
|
+
)
|
|
154
|
+
if source_vault_targets(schema, found):
|
|
155
|
+
await require_pii_import(auth.authorizer, access.principal, domain_id)
|
|
156
|
+
job_id = await enqueue_job(
|
|
157
|
+
session,
|
|
158
|
+
SOURCE_SYNC_JOB,
|
|
159
|
+
domain_id=domain_id,
|
|
160
|
+
payload=source_sync_payload(found.id, dry_run=dry_run),
|
|
161
|
+
unique=True,
|
|
162
|
+
)
|
|
163
|
+
annotate(request, action="source.sync", target=found.id, job=job_id, dry_run=dry_run)
|
|
164
|
+
response.headers["Location"] = f"{request.url.path.rsplit('/', 1)[0]}/runs"
|
|
165
|
+
return SyncQueued(job_id=job_id, source=found.id, api_name=found.api_name, dry_run=dry_run)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.get(f"{BASE}/runs", summary="List Source runs", responses=_ERRORS)
|
|
169
|
+
async def list_source_runs(
|
|
170
|
+
domain_id: str, source: str, access: ReadRuns, store: StoreDep, params: PageParamsDep
|
|
171
|
+
) -> Page[SourceRunOut]:
|
|
172
|
+
"""Runs of the Source, newest first."""
|
|
173
|
+
after = None
|
|
174
|
+
if params.position is not None:
|
|
175
|
+
try:
|
|
176
|
+
after = (
|
|
177
|
+
datetime.fromisoformat(str(params.position["at"])),
|
|
178
|
+
str(params.position["id"]),
|
|
179
|
+
)
|
|
180
|
+
except (KeyError, ValueError):
|
|
181
|
+
raise ProblemError(VALIDATION_FAILED, "The cursor is not a run cursor.") from None
|
|
182
|
+
async with store.sessions() as session, session.begin():
|
|
183
|
+
domain = await open_domain(session, access)
|
|
184
|
+
await access.require_wide()
|
|
185
|
+
_, found = await _source(session, domain.id, domain.default_branch, source, store.runtime)
|
|
186
|
+
rows = await SourceRunRepository(session, domain_id).page(
|
|
187
|
+
found.id, limit=params.limit + 1, after=after
|
|
188
|
+
)
|
|
189
|
+
clearance = await clearance_of(access.authorizer, access.principal, domain_id, None)
|
|
190
|
+
errors = clearance.covers(Sensitivity, ())
|
|
191
|
+
more = len(rows) > params.limit
|
|
192
|
+
rows = rows[: params.limit]
|
|
193
|
+
last = rows[-1] if rows else None
|
|
194
|
+
next_cursor = (
|
|
195
|
+
encode_cursor({"at": last.started_at.isoformat(), "id": last.id})
|
|
196
|
+
if more and last is not None and last.started_at is not None
|
|
197
|
+
else None
|
|
198
|
+
)
|
|
199
|
+
items = [_run(r, errors=errors) for r in rows]
|
|
200
|
+
return Page[SourceRunOut](items=items, next_cursor=next_cursor)
|