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,682 @@
|
|
|
1
|
+
"""Schema reads and schema change sets (design 11.1).
|
|
2
|
+
|
|
3
|
+
| Route | Permission |
|
|
4
|
+
|-------|------------|
|
|
5
|
+
| `GET /domains/{d}/schema`, `GET /domains/{d}/schema/{section}/{name}` | `schema.read` |
|
|
6
|
+
| `POST /domains/{d}/schema:validate` | `schema.read` |
|
|
7
|
+
| `POST /domains/{d}/branches/{b}/schema/changes` | `schema.write` on the branch |
|
|
8
|
+
| `POST /domains/{d}/branches/{b}/rebase-tags` | `schema.write` on the branch |
|
|
9
|
+
|
|
10
|
+
`schema.read` is checked against the Revision that `branch`/`at` resolve to, not the name given
|
|
11
|
+
(`services/reads.py`).
|
|
12
|
+
|
|
13
|
+
The schema is served as JSON (elements keyed by id, with api names and descriptions) or, with
|
|
14
|
+
`Accept: text/yaml` or `format=yaml`, as the YAML document of `lorebase_core.model.yaml_schema`.
|
|
15
|
+
A change set is sent as JSON (`yaml`: a full document, or `patch` and `remove`) or as a raw
|
|
16
|
+
YAML document (`Content-Type: text/yaml` or `application/yaml`, options as query
|
|
17
|
+
parameters). Protected branches refuse direct changes: the change goes to another branch and
|
|
18
|
+
then through a Proposal.
|
|
19
|
+
|
|
20
|
+
`rebase-tags` brings a branch forked before the default branch tagged an Attribute level with
|
|
21
|
+
it: one schema change adds the default branch's sensitivity tags (never removes one) and
|
|
22
|
+
recomputes the titles and search vectors of the Kinds it changes. With `rows_only`, the schema
|
|
23
|
+
is left alone and a `store.sensitivity_refresh` job rewrites those rows in batches.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Annotated, Any, Literal, Self
|
|
29
|
+
|
|
30
|
+
from fastapi import APIRouter, Body, Depends, Query, Request, Response
|
|
31
|
+
from pydantic import BaseModel, Field, JsonValue, ValidationError, model_validator
|
|
32
|
+
|
|
33
|
+
from lorebase_core.canonical import hex
|
|
34
|
+
from lorebase_core.model import Schema, Sensitivity, dump_schema
|
|
35
|
+
from lorebase_keeper.auth.audit import annotate
|
|
36
|
+
from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, require
|
|
37
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
38
|
+
from lorebase_keeper.auth.principals import Principal
|
|
39
|
+
from lorebase_keeper.auth.rbac import AccessContext
|
|
40
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
41
|
+
from lorebase_keeper.context import StoreAccess, StoreDep, parse_revision_id
|
|
42
|
+
from lorebase_keeper.deps import KeeperSettingsDep
|
|
43
|
+
from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, Problem, ProblemError
|
|
44
|
+
from lorebase_keeper.reading.runtime import ReadRuntime, ReadRuntimeDep
|
|
45
|
+
from lorebase_keeper.reading.sensitivity import clearance_of, default_schema
|
|
46
|
+
from lorebase_keeper.reading.visibility import visible
|
|
47
|
+
from lorebase_keeper.services import schema as service
|
|
48
|
+
from lorebase_keeper.services.errors import (
|
|
49
|
+
ProtectedBranchError,
|
|
50
|
+
commit_problem,
|
|
51
|
+
finding_documents,
|
|
52
|
+
protected_branch_problem,
|
|
53
|
+
violations_problem,
|
|
54
|
+
)
|
|
55
|
+
from lorebase_keeper.services.operation_catalog import builtin_definition
|
|
56
|
+
from lorebase_keeper.services.pii_import import require_schema_change
|
|
57
|
+
from lorebase_keeper.services.reads import (
|
|
58
|
+
ReadGuard,
|
|
59
|
+
ReadTarget,
|
|
60
|
+
resolve_kind_limited,
|
|
61
|
+
resolve_readable,
|
|
62
|
+
)
|
|
63
|
+
from lorebase_keeper.settings import KeeperSettings
|
|
64
|
+
from lorebase_store.commit import CommitError
|
|
65
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
66
|
+
|
|
67
|
+
router = APIRouter(tags=["schema"])
|
|
68
|
+
|
|
69
|
+
YAML_MEDIA_TYPES = ("text/yaml", "application/yaml", "application/x-yaml")
|
|
70
|
+
REVISION_HEADER = "Lorebase-Revision"
|
|
71
|
+
Section = Literal["kinds", "relations", "traits", "operations", "derivations", "sources"]
|
|
72
|
+
|
|
73
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
74
|
+
403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
|
|
75
|
+
404: {"model": Problem, "description": "Domain, branch, Revision or element not found."},
|
|
76
|
+
409: {"model": Problem, "description": "Protected branch or moved head."},
|
|
77
|
+
422: {"model": Problem, "description": "The change set is invalid; `issues` locate why."},
|
|
78
|
+
503: {"model": Problem, "description": "The branch is locked by another writer."},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_YAML_BODY = {
|
|
82
|
+
"requestBody": {
|
|
83
|
+
"content": {
|
|
84
|
+
"text/yaml": {"schema": {"type": "string", "description": "Full schema document."}}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_BODY_DOC = (
|
|
91
|
+
"JSON change set, or the full schema document as text/yaml (options as query parameters)."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ElementRemoval(BaseModel):
|
|
96
|
+
"""An element to remove."""
|
|
97
|
+
|
|
98
|
+
section: Section
|
|
99
|
+
name: str = Field(min_length=1, description="Api name or id of the element.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ChangeSetBody(BaseModel):
|
|
103
|
+
"""A full schema document, or a patch of elements."""
|
|
104
|
+
|
|
105
|
+
yaml: str | None = Field(
|
|
106
|
+
default=None,
|
|
107
|
+
description="Full schema document; elements it leaves out are removed.",
|
|
108
|
+
)
|
|
109
|
+
patch: dict[str, JsonValue] | None = Field(
|
|
110
|
+
default=None,
|
|
111
|
+
description="Partial document (same layout as the YAML document): each element "
|
|
112
|
+
"replaces the element with the same api name, or is added.",
|
|
113
|
+
)
|
|
114
|
+
remove: list[ElementRemoval] = Field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
@model_validator(mode="after")
|
|
117
|
+
def _one_form(self) -> Self:
|
|
118
|
+
if (self.yaml is not None) == (self.patch is not None or bool(self.remove)):
|
|
119
|
+
raise ValueError("give either `yaml`, or `patch` and/or `remove`")
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def to_input(self) -> service.ChangeSetInput:
|
|
123
|
+
return service.ChangeSetInput(
|
|
124
|
+
yaml=self.yaml,
|
|
125
|
+
patch=self.patch,
|
|
126
|
+
remove=[service.ElementRemoval(r.section, r.name) for r in self.remove],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SchemaChangeRequest(ChangeSetBody):
|
|
131
|
+
"""A schema change set for one branch."""
|
|
132
|
+
|
|
133
|
+
message: str | None = Field(default=None, max_length=2000)
|
|
134
|
+
expected_head: str | None = Field(
|
|
135
|
+
default=None, description="Fail with 409 unless the branch head is this Revision."
|
|
136
|
+
)
|
|
137
|
+
dry_run: bool = Field(default=False, description="Validate and plan without committing.")
|
|
138
|
+
cascade: bool = Field(
|
|
139
|
+
default=False,
|
|
140
|
+
description="Confirm that removing Kinds or Relations deletes their Records or Links.",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class SchemaValidateRequest(ChangeSetBody):
|
|
145
|
+
"""A change set to validate against the schema of a branch or Revision."""
|
|
146
|
+
|
|
147
|
+
branch: str | None = None
|
|
148
|
+
at: str | None = Field(default=None, description="Revision id, tag or branch name.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ElementChangeOut(BaseModel):
|
|
152
|
+
"""One element written or removed."""
|
|
153
|
+
|
|
154
|
+
op: Literal["create", "update", "delete"]
|
|
155
|
+
section: str
|
|
156
|
+
id: str
|
|
157
|
+
api_name: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class EmbeddingOut(BaseModel):
|
|
161
|
+
"""The embedding model of a Domain: what a caller embeds `query_vector` with."""
|
|
162
|
+
|
|
163
|
+
model: str = Field(description="Name the stored vectors are keyed by.")
|
|
164
|
+
dims: int = Field(description="Length of a query vector.")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SchemaSearchOut(BaseModel):
|
|
168
|
+
"""How the Domain can be searched at this Revision."""
|
|
169
|
+
|
|
170
|
+
modes: list[Literal["lexical", "semantic", "hybrid"]]
|
|
171
|
+
embedding: EmbeddingOut | None = Field(
|
|
172
|
+
description="Set when some Kind has `search.embedding_template`: semantic and hybrid "
|
|
173
|
+
"search need a `query_vector` made by this model; null otherwise."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SchemaOut(BaseModel):
|
|
178
|
+
"""The schema of a Revision."""
|
|
179
|
+
|
|
180
|
+
domain: str
|
|
181
|
+
branch: str | None
|
|
182
|
+
revision: str
|
|
183
|
+
schema_: dict[str, Any] = Field(alias="schema")
|
|
184
|
+
search: SchemaSearchOut
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SchemaChangeOut(BaseModel):
|
|
188
|
+
"""Outcome of a schema change set."""
|
|
189
|
+
|
|
190
|
+
status: Literal["committed", "dry_run", "no_op"]
|
|
191
|
+
branch: str
|
|
192
|
+
head: str = Field(description="Branch head the change set was applied to.")
|
|
193
|
+
revision: str | None = Field(description="New Revision; null for dry runs.")
|
|
194
|
+
changes: list[ElementChangeOut]
|
|
195
|
+
change_count: int
|
|
196
|
+
data_changes: int = Field(description="Record and Link changes made by data migrations.")
|
|
197
|
+
issues: list[dict[str, Any]]
|
|
198
|
+
issue_count: int
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class RebaseTagsRequest(BaseModel):
|
|
202
|
+
"""A tag rebase of one branch."""
|
|
203
|
+
|
|
204
|
+
message: str | None = Field(default=None, max_length=2000)
|
|
205
|
+
expected_head: str | None = Field(
|
|
206
|
+
default=None, description="Fail with 409 unless the branch head is this Revision."
|
|
207
|
+
)
|
|
208
|
+
dry_run: bool = Field(default=False, description="Plan without committing or queueing.")
|
|
209
|
+
rows_only: bool = Field(
|
|
210
|
+
default=False,
|
|
211
|
+
description="Leave the branch's schema as it is: queue the batched rewrite of the "
|
|
212
|
+
"titles and search vectors it materialized before the default branch's tags.",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TagChangeOut(BaseModel):
|
|
217
|
+
"""Sensitivity tags added to one Attribute."""
|
|
218
|
+
|
|
219
|
+
section: Literal["kinds", "relations"]
|
|
220
|
+
owner: str = Field(description="Api name of the Kind or Relation.")
|
|
221
|
+
attribute: str = Field(description="Api name of the Attribute.")
|
|
222
|
+
tags: list[Sensitivity]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class RebaseTagsOut(BaseModel):
|
|
226
|
+
"""Outcome of a tag rebase."""
|
|
227
|
+
|
|
228
|
+
status: Literal["committed", "dry_run", "no_op", "queued"]
|
|
229
|
+
branch: str
|
|
230
|
+
head: str = Field(description="Branch head the rebase was planned against.")
|
|
231
|
+
revision: str | None = Field(description="New Revision; null for dry runs and rows_only.")
|
|
232
|
+
tags: list[TagChangeOut] = Field(description="Tags the schema change adds.")
|
|
233
|
+
changes: list[ElementChangeOut]
|
|
234
|
+
change_count: int
|
|
235
|
+
issues: list[dict[str, Any]]
|
|
236
|
+
issue_count: int
|
|
237
|
+
job: int | None = Field(
|
|
238
|
+
default=None, description="The queued store.sensitivity_refresh job (rows_only)."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class SchemaValidationOut(BaseModel):
|
|
243
|
+
"""Static validation of a change set."""
|
|
244
|
+
|
|
245
|
+
valid: bool
|
|
246
|
+
issues: list[dict[str, Any]]
|
|
247
|
+
issue_count: int
|
|
248
|
+
changes: list[ElementChangeOut]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _invalid_body(error: ValidationError) -> ProblemError:
|
|
252
|
+
errors = [
|
|
253
|
+
{
|
|
254
|
+
"pointer": "/body/" + "/".join(str(p) for p in e["loc"]),
|
|
255
|
+
"message": str(e["msg"]),
|
|
256
|
+
"code": str(e["type"]),
|
|
257
|
+
}
|
|
258
|
+
for e in error.errors()
|
|
259
|
+
]
|
|
260
|
+
return ProblemError(
|
|
261
|
+
VALIDATION_FAILED,
|
|
262
|
+
"One or more request members are invalid.",
|
|
263
|
+
extensions={"errors": errors},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse[M: BaseModel](body: M | str, model: type[M], options: dict[str, Any]) -> M:
|
|
268
|
+
"""The request model; a YAML document body takes its options from the query."""
|
|
269
|
+
if not isinstance(body, str):
|
|
270
|
+
return body
|
|
271
|
+
try:
|
|
272
|
+
return model.model_validate(
|
|
273
|
+
{"yaml": body, **{k: v for k, v in options.items() if v is not None}}
|
|
274
|
+
)
|
|
275
|
+
except ValidationError as error:
|
|
276
|
+
raise _invalid_body(error) from None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _wants_yaml(request: Request, format_: str | None) -> bool:
|
|
280
|
+
if format_ is not None:
|
|
281
|
+
return format_ == "yaml"
|
|
282
|
+
accept = request.headers.get("accept", "")
|
|
283
|
+
return any(media in accept for media in YAML_MEDIA_TYPES)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _readable(
|
|
287
|
+
request: Request,
|
|
288
|
+
auth: AuthRuntime,
|
|
289
|
+
principal: Principal,
|
|
290
|
+
store: StoreAccess,
|
|
291
|
+
keeper: KeeperSettings,
|
|
292
|
+
domain_id: str,
|
|
293
|
+
branch: str | None,
|
|
294
|
+
at: str | None,
|
|
295
|
+
) -> ReadTarget:
|
|
296
|
+
"""The Revision a read names, once the caller may read it (not just the named branch)."""
|
|
297
|
+
annotate(request, domain_id=domain_id, permission=Permission.SCHEMA_READ.value)
|
|
298
|
+
return await resolve_readable(
|
|
299
|
+
store,
|
|
300
|
+
auth.authorizer,
|
|
301
|
+
principal,
|
|
302
|
+
(Permission.SCHEMA_READ,),
|
|
303
|
+
domain_id,
|
|
304
|
+
branch=branch,
|
|
305
|
+
at=at,
|
|
306
|
+
max_depth=keeper.reads.reachability_max_depth,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def _visible_read(
|
|
311
|
+
request: Request,
|
|
312
|
+
auth: AuthRuntime,
|
|
313
|
+
principal: Principal,
|
|
314
|
+
store: StoreAccess,
|
|
315
|
+
keeper: KeeperSettings,
|
|
316
|
+
domain_id: str,
|
|
317
|
+
branch: str | None,
|
|
318
|
+
at: str | None,
|
|
319
|
+
) -> tuple[ReadTarget, Schema]:
|
|
320
|
+
"""The Revision a schema read names and its schema as the caller sees it: in full, or,
|
|
321
|
+
for a caller whose grants are narrowed to Kinds that are invisible to it, projected to
|
|
322
|
+
those Kinds (design 13.3)."""
|
|
323
|
+
try:
|
|
324
|
+
target = await _readable(request, auth, principal, store, keeper, domain_id, branch, at)
|
|
325
|
+
except ProblemError as error:
|
|
326
|
+
guard = ReadGuard(
|
|
327
|
+
auth.authorizer,
|
|
328
|
+
principal,
|
|
329
|
+
(Permission.SCHEMA_READ,),
|
|
330
|
+
domain_id,
|
|
331
|
+
keeper.reads.reachability_max_depth,
|
|
332
|
+
)
|
|
333
|
+
kinds = (
|
|
334
|
+
await guard.granted_kinds()
|
|
335
|
+
if error.status_code == 403 and auth.authorizer.invisible_kinds
|
|
336
|
+
else frozenset()
|
|
337
|
+
)
|
|
338
|
+
if not kinds:
|
|
339
|
+
raise
|
|
340
|
+
target = await resolve_kind_limited(store, guard, branch=branch, at=at)
|
|
341
|
+
return target, visible(await service.read(store, domain_id, target), kinds)
|
|
342
|
+
return target, await service.read(store, domain_id, target)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _changes(changes: list[service.ElementChange], limit: int) -> list[ElementChangeOut]:
|
|
346
|
+
return [
|
|
347
|
+
ElementChangeOut(op=c.op, section=c.section, id=c.id, api_name=c.api_name)
|
|
348
|
+
for c in changes[:limit]
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@router.get(
|
|
353
|
+
"/domains/{domain_id}/schema",
|
|
354
|
+
summary="Get the schema",
|
|
355
|
+
responses={**_ERRORS, 200: {"content": {"text/yaml": {}}}},
|
|
356
|
+
response_model=SchemaOut,
|
|
357
|
+
)
|
|
358
|
+
async def get_schema(
|
|
359
|
+
domain_id: str,
|
|
360
|
+
request: Request,
|
|
361
|
+
principal: PrincipalDep,
|
|
362
|
+
auth: AuthDep,
|
|
363
|
+
store: StoreDep,
|
|
364
|
+
keeper: KeeperSettingsDep,
|
|
365
|
+
runtime: ReadRuntimeDep,
|
|
366
|
+
branch: Annotated[str | None, Query(description="Default: the default branch.")] = None,
|
|
367
|
+
at: Annotated[str | None, Query(description="Revision id, tag or branch name.")] = None,
|
|
368
|
+
format_: Annotated[Literal["json", "yaml"] | None, Query(alias="format")] = None,
|
|
369
|
+
) -> Response:
|
|
370
|
+
"""The schema at a branch head or at a Revision, as JSON or YAML."""
|
|
371
|
+
target, schema = await _visible_read(
|
|
372
|
+
request, auth, principal, store, keeper, domain_id, branch, at
|
|
373
|
+
)
|
|
374
|
+
revision = hex(target.revision)
|
|
375
|
+
headers = {REVISION_HEADER: revision}
|
|
376
|
+
if _wants_yaml(request, format_):
|
|
377
|
+
return Response(dump_schema(schema), media_type="text/yaml", headers=headers)
|
|
378
|
+
body = SchemaOut(
|
|
379
|
+
domain=domain_id,
|
|
380
|
+
branch=target.ref,
|
|
381
|
+
revision=revision,
|
|
382
|
+
schema=schema.content(),
|
|
383
|
+
search=_search(schema, runtime),
|
|
384
|
+
)
|
|
385
|
+
return Response(
|
|
386
|
+
body.model_dump_json(by_alias=True), media_type="application/json", headers=headers
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _search(schema: Schema, runtime: ReadRuntime) -> SchemaSearchOut:
|
|
391
|
+
"""Search modes of the schema; the embedding model comes from the settings (vectors are
|
|
392
|
+
keyed by its name), never from the inference package."""
|
|
393
|
+
if not any(kind.search.embedding_template for kind in schema.kinds.values()):
|
|
394
|
+
return SchemaSearchOut(modes=["lexical"], embedding=None)
|
|
395
|
+
embedding = EmbeddingOut(
|
|
396
|
+
model=runtime.search.semantic.model, dims=runtime.store_settings.embeddings.dims
|
|
397
|
+
)
|
|
398
|
+
return SchemaSearchOut(modes=["lexical", "semantic", "hybrid"], embedding=embedding)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _element(schema: Schema, section: Section, name: str) -> dict[str, Any] | None:
|
|
402
|
+
elements: dict[str, Any] = getattr(schema, section)
|
|
403
|
+
found = elements.get(name) or next((e for e in elements.values() if e.api_name == name), None)
|
|
404
|
+
if found is not None:
|
|
405
|
+
return dict(found.content())
|
|
406
|
+
if section == "operations":
|
|
407
|
+
builtin = builtin_definition(schema, name)
|
|
408
|
+
return None if builtin is None else builtin.document()
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@router.get(
|
|
413
|
+
"/domains/{domain_id}/schema/{section}/{name}",
|
|
414
|
+
summary="Get a schema element",
|
|
415
|
+
responses=_ERRORS,
|
|
416
|
+
)
|
|
417
|
+
async def get_element(
|
|
418
|
+
domain_id: str,
|
|
419
|
+
section: Section,
|
|
420
|
+
name: str,
|
|
421
|
+
request: Request,
|
|
422
|
+
principal: PrincipalDep,
|
|
423
|
+
auth: AuthDep,
|
|
424
|
+
store: StoreDep,
|
|
425
|
+
keeper: KeeperSettingsDep,
|
|
426
|
+
response: Response,
|
|
427
|
+
branch: Annotated[str | None, Query(description="Default: the default branch.")] = None,
|
|
428
|
+
at: Annotated[str | None, Query(description="Revision id, tag or branch name.")] = None,
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""A Kind, Relation, Trait, Operation, Derivation or Source by api name or id.
|
|
431
|
+
|
|
432
|
+
Built-in Operations (`create_supplier`, `link_supplied_by`, `import_records`, ...) are
|
|
433
|
+
described with the parameters derived from the schema.
|
|
434
|
+
"""
|
|
435
|
+
target, schema = await _visible_read(
|
|
436
|
+
request, auth, principal, store, keeper, domain_id, branch, at
|
|
437
|
+
)
|
|
438
|
+
element = _element(schema, section, name)
|
|
439
|
+
if element is None:
|
|
440
|
+
raise ProblemError(NOT_FOUND, f"No element {name!r} in {section}.")
|
|
441
|
+
response.headers[REVISION_HEADER] = hex(target.revision)
|
|
442
|
+
return element
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@router.post(
|
|
446
|
+
"/domains/{domain_id}/schema:validate",
|
|
447
|
+
summary="Validate a schema change set",
|
|
448
|
+
responses=_ERRORS,
|
|
449
|
+
openapi_extra=_YAML_BODY,
|
|
450
|
+
)
|
|
451
|
+
async def validate_schema(
|
|
452
|
+
domain_id: str,
|
|
453
|
+
payload: Annotated[SchemaValidateRequest | str, Body(description=_BODY_DOC)],
|
|
454
|
+
request: Request,
|
|
455
|
+
principal: PrincipalDep,
|
|
456
|
+
auth: AuthDep,
|
|
457
|
+
store: StoreDep,
|
|
458
|
+
keeper: KeeperSettingsDep,
|
|
459
|
+
branch: Annotated[str | None, Query(description="For YAML bodies.")] = None,
|
|
460
|
+
at: Annotated[str | None, Query(description="For YAML bodies.")] = None,
|
|
461
|
+
) -> SchemaValidationOut:
|
|
462
|
+
"""Static validation of a document or patch against the schema of a branch or Revision.
|
|
463
|
+
|
|
464
|
+
Issue paths point into the schema document (api names). Nothing is written.
|
|
465
|
+
"""
|
|
466
|
+
body = _parse(payload, SchemaValidateRequest, {"branch": branch, "at": at})
|
|
467
|
+
target = await _readable(
|
|
468
|
+
request, auth, principal, store, keeper, domain_id, body.branch, body.at
|
|
469
|
+
)
|
|
470
|
+
limits = keeper.schema_changes
|
|
471
|
+
issues, changes = await service.validate(store, domain_id, body.to_input(), target)
|
|
472
|
+
return SchemaValidationOut(
|
|
473
|
+
valid=not issues,
|
|
474
|
+
issues=finding_documents(issues, limits.max_issues),
|
|
475
|
+
issue_count=len(issues),
|
|
476
|
+
changes=_changes(changes, limits.max_listed_changes),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@router.post(
|
|
481
|
+
"/domains/{domain_id}/branches/{branch:path}/schema/changes",
|
|
482
|
+
summary="Apply a schema change set",
|
|
483
|
+
responses=_ERRORS,
|
|
484
|
+
openapi_extra=_YAML_BODY,
|
|
485
|
+
)
|
|
486
|
+
async def apply_schema_changes(
|
|
487
|
+
domain_id: str,
|
|
488
|
+
branch: str,
|
|
489
|
+
payload: Annotated[SchemaChangeRequest | str, Body(description=_BODY_DOC)],
|
|
490
|
+
request: Request,
|
|
491
|
+
store: StoreDep,
|
|
492
|
+
keeper: KeeperSettingsDep,
|
|
493
|
+
access: Annotated[Access, Depends(require(Permission.SCHEMA_WRITE, branch_param="branch"))],
|
|
494
|
+
auth: AuthDep,
|
|
495
|
+
dry_run: Annotated[bool | None, Query(description="For YAML bodies.")] = None,
|
|
496
|
+
cascade: Annotated[bool | None, Query(description="For YAML bodies.")] = None,
|
|
497
|
+
message: Annotated[str | None, Query(description="For YAML bodies.")] = None,
|
|
498
|
+
expected_head: Annotated[str | None, Query(description="For YAML bodies.")] = None,
|
|
499
|
+
) -> SchemaChangeOut:
|
|
500
|
+
"""Apply a change set as one Revision (data migrations included), or dry-run it.
|
|
501
|
+
|
|
502
|
+
A dry run returns the element changes, the number of data changes and the validation
|
|
503
|
+
issues without committing. Protected branches refuse direct changes (409).
|
|
504
|
+
"""
|
|
505
|
+
body = _parse(
|
|
506
|
+
payload,
|
|
507
|
+
SchemaChangeRequest,
|
|
508
|
+
{
|
|
509
|
+
"dry_run": dry_run,
|
|
510
|
+
"cascade": cascade,
|
|
511
|
+
"message": message,
|
|
512
|
+
"expected_head": expected_head,
|
|
513
|
+
},
|
|
514
|
+
)
|
|
515
|
+
limits = keeper.schema_changes
|
|
516
|
+
expected = (
|
|
517
|
+
None
|
|
518
|
+
if body.expected_head is None
|
|
519
|
+
else parse_revision_id(body.expected_head, "/body/expected_head")
|
|
520
|
+
)
|
|
521
|
+
async with store.sessions() as session, session.begin():
|
|
522
|
+
await scope_to_domain(session, domain_id)
|
|
523
|
+
reference = await default_schema(session, domain_id, store.runtime)
|
|
524
|
+
# Kind-narrowed grants count for what issues show; removing tags needs Domain-wide ones.
|
|
525
|
+
clearance = await clearance_of(auth.authorizer, access.principal, domain_id, reference)
|
|
526
|
+
try:
|
|
527
|
+
outcome = await service.apply(
|
|
528
|
+
store,
|
|
529
|
+
domain_id,
|
|
530
|
+
branch,
|
|
531
|
+
body.to_input(),
|
|
532
|
+
access.principal,
|
|
533
|
+
message=body.message,
|
|
534
|
+
expected_head=expected,
|
|
535
|
+
dry_run=body.dry_run,
|
|
536
|
+
cascade=body.cascade,
|
|
537
|
+
guard=lambda old, new: require_schema_change(
|
|
538
|
+
auth.authorizer, access.principal, domain_id, old, new
|
|
539
|
+
),
|
|
540
|
+
cleared=clearance.everywhere,
|
|
541
|
+
clearance=clearance,
|
|
542
|
+
)
|
|
543
|
+
except (service.ChangeSetInvalidError, service.RemovalBlockedError) as invalid:
|
|
544
|
+
raise violations_problem(invalid.issues, limit=limits.max_issues) from None
|
|
545
|
+
except ProtectedBranchError:
|
|
546
|
+
raise protected_branch_problem(
|
|
547
|
+
domain_id, branch, f"{keeper.api_prefix}/domains/{domain_id}/proposals"
|
|
548
|
+
) from None
|
|
549
|
+
except CommitError as error:
|
|
550
|
+
raise commit_problem(
|
|
551
|
+
error,
|
|
552
|
+
max_issues=limits.max_issues,
|
|
553
|
+
retry_after_s=keeper.branches.lock_retry_after_s,
|
|
554
|
+
) from None
|
|
555
|
+
revision = None if outcome.revision is None else hex(outcome.revision)
|
|
556
|
+
annotate(
|
|
557
|
+
request,
|
|
558
|
+
action="schema.dry_run" if body.dry_run else "schema.change",
|
|
559
|
+
target=branch,
|
|
560
|
+
status=outcome.status.value,
|
|
561
|
+
revision=revision,
|
|
562
|
+
changes=len(outcome.changes),
|
|
563
|
+
)
|
|
564
|
+
return SchemaChangeOut(
|
|
565
|
+
status=outcome.status.value,
|
|
566
|
+
branch=branch,
|
|
567
|
+
head=hex(outcome.head),
|
|
568
|
+
revision=revision,
|
|
569
|
+
changes=_changes(outcome.changes, limits.max_listed_changes),
|
|
570
|
+
change_count=len(outcome.changes),
|
|
571
|
+
data_changes=outcome.data_changes,
|
|
572
|
+
issues=finding_documents(outcome.issues, limits.max_issues),
|
|
573
|
+
issue_count=len(outcome.issues),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@router.post(
|
|
578
|
+
"/domains/{domain_id}/branches/{branch:path}/rebase-tags",
|
|
579
|
+
summary="Take the default branch's sensitivity tags",
|
|
580
|
+
responses=_ERRORS,
|
|
581
|
+
)
|
|
582
|
+
async def rebase_tags(
|
|
583
|
+
domain_id: str,
|
|
584
|
+
branch: str,
|
|
585
|
+
body: RebaseTagsRequest,
|
|
586
|
+
request: Request,
|
|
587
|
+
store: StoreDep,
|
|
588
|
+
keeper: KeeperSettingsDep,
|
|
589
|
+
access: Annotated[Access, Depends(require(Permission.SCHEMA_WRITE, branch_param="branch"))],
|
|
590
|
+
auth: AuthDep,
|
|
591
|
+
) -> RebaseTagsOut:
|
|
592
|
+
"""Give a branch forked before the default branch tagged an Attribute the tags it lacks.
|
|
593
|
+
|
|
594
|
+
One schema change adds, to every Attribute the branch shares with the default branch, the
|
|
595
|
+
tags the default branch gives it, and to derived Attributes the tags of what they read;
|
|
596
|
+
no tag is removed. It recomputes the titles and search vectors of the Kinds it changes,
|
|
597
|
+
so verification no longer reports them as `sensitivity_stale`. A branch that already has
|
|
598
|
+
the tags gives `no_op`. Protected branches refuse the change (409) unless `rows_only`:
|
|
599
|
+
then only a `store.sensitivity_refresh` job is queued, which rewrites those rows in
|
|
600
|
+
batches and leaves the schema as it is. `tags` lists the tags added only to callers
|
|
601
|
+
who may read the default branch's schema (empty otherwise).
|
|
602
|
+
"""
|
|
603
|
+
limits = keeper.schema_changes
|
|
604
|
+
if body.rows_only:
|
|
605
|
+
head, job = await service.queue_tag_refresh(store, domain_id, branch, dry_run=body.dry_run)
|
|
606
|
+
annotate(request, action="schema.rebase_tags", target=branch, job=job)
|
|
607
|
+
return RebaseTagsOut(
|
|
608
|
+
status="dry_run" if body.dry_run else "queued",
|
|
609
|
+
branch=branch,
|
|
610
|
+
head=hex(head),
|
|
611
|
+
revision=None,
|
|
612
|
+
tags=[],
|
|
613
|
+
changes=[],
|
|
614
|
+
change_count=0,
|
|
615
|
+
issues=[],
|
|
616
|
+
issue_count=0,
|
|
617
|
+
job=job,
|
|
618
|
+
)
|
|
619
|
+
expected = (
|
|
620
|
+
None
|
|
621
|
+
if body.expected_head is None
|
|
622
|
+
else parse_revision_id(body.expected_head, "/body/expected_head")
|
|
623
|
+
)
|
|
624
|
+
try:
|
|
625
|
+
rebase = await service.rebase_tags(
|
|
626
|
+
store,
|
|
627
|
+
domain_id,
|
|
628
|
+
branch,
|
|
629
|
+
access.principal,
|
|
630
|
+
message=body.message,
|
|
631
|
+
expected_head=expected,
|
|
632
|
+
dry_run=body.dry_run,
|
|
633
|
+
)
|
|
634
|
+
except service.ChangeSetInvalidError as invalid:
|
|
635
|
+
raise violations_problem(invalid.issues, limit=limits.max_issues) from None
|
|
636
|
+
except ProtectedBranchError:
|
|
637
|
+
raise protected_branch_problem(
|
|
638
|
+
domain_id, branch, f"{keeper.api_prefix}/domains/{domain_id}/proposals"
|
|
639
|
+
) from None
|
|
640
|
+
except CommitError as error:
|
|
641
|
+
raise commit_problem(
|
|
642
|
+
error,
|
|
643
|
+
max_issues=limits.max_issues,
|
|
644
|
+
retry_after_s=keeper.branches.lock_retry_after_s,
|
|
645
|
+
) from None
|
|
646
|
+
outcome = rebase.outcome
|
|
647
|
+
revision = None if outcome.revision is None else hex(outcome.revision)
|
|
648
|
+
shown = rebase.tags
|
|
649
|
+
if shown and not await auth.authorizer.allows(
|
|
650
|
+
access.principal,
|
|
651
|
+
Permission.SCHEMA_READ,
|
|
652
|
+
domain_id,
|
|
653
|
+
AccessContext(branch=rebase.default_branch),
|
|
654
|
+
):
|
|
655
|
+
shown = [] # the names of the tagged Attributes come from the default branch
|
|
656
|
+
annotate(
|
|
657
|
+
request,
|
|
658
|
+
action="schema.rebase_tags",
|
|
659
|
+
target=branch,
|
|
660
|
+
status=outcome.status.value,
|
|
661
|
+
revision=revision,
|
|
662
|
+
changes=len(outcome.changes),
|
|
663
|
+
)
|
|
664
|
+
return RebaseTagsOut(
|
|
665
|
+
status=outcome.status.value,
|
|
666
|
+
branch=branch,
|
|
667
|
+
head=hex(outcome.head),
|
|
668
|
+
revision=revision,
|
|
669
|
+
tags=[
|
|
670
|
+
TagChangeOut(
|
|
671
|
+
section="kinds" if t.section == "kinds" else "relations",
|
|
672
|
+
owner=t.owner,
|
|
673
|
+
attribute=t.attribute,
|
|
674
|
+
tags=t.tags,
|
|
675
|
+
)
|
|
676
|
+
for t in shown
|
|
677
|
+
],
|
|
678
|
+
changes=_changes(outcome.changes, limits.max_listed_changes),
|
|
679
|
+
change_count=len(outcome.changes),
|
|
680
|
+
issues=finding_documents(outcome.issues, limits.max_issues),
|
|
681
|
+
issue_count=len(outcome.issues),
|
|
682
|
+
)
|