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,160 @@
|
|
|
1
|
+
"""Agent-facing reads (design 11): context packs and Record explanations.
|
|
2
|
+
|
|
3
|
+
`POST /context` answers in one call with the seed Records, their neighborhood, the schema
|
|
4
|
+
fragment, recent changes and lineage, trimmed to a token budget (`agents.context`). It is
|
|
5
|
+
authorized like every read (`services.reads`, Kind-scoped grants included) and cached by
|
|
6
|
+
Revision like GET reads: the `ETag` names the Revision and a digest of the request, and a
|
|
7
|
+
matching `If-None-Match` answers 304.
|
|
8
|
+
|
|
9
|
+
`GET .../records/{key}/explain` tells why a Record exists and why its values are what they
|
|
10
|
+
are (`agents.explain`), as JSON or markdown, cached like every GET read.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Annotated, Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
|
18
|
+
|
|
19
|
+
from lorebase_keeper.agents.context import JSON, MARKDOWN, PackContext, build_pack
|
|
20
|
+
from lorebase_keeper.agents.explain import ExplainOut, explain_markdown, explain_record
|
|
21
|
+
from lorebase_keeper.agents.pack import ContextPackOut, ContextRequest, dumps
|
|
22
|
+
from lorebase_keeper.agents.settings import AgentSettings, agent_settings
|
|
23
|
+
from lorebase_keeper.errors import Problem, ValidationProblem
|
|
24
|
+
from lorebase_keeper.ratelimit.enforce import charge_context_tokens, check_context_quota
|
|
25
|
+
from lorebase_keeper.reading.caching import CacheKey, cache_key, entity_tag, respond_with
|
|
26
|
+
from lorebase_keeper.reading.cursors import fingerprint
|
|
27
|
+
from lorebase_keeper.reading.points import REVISION_HEADER, open_revision, read_guard, resolve_point
|
|
28
|
+
from lorebase_keeper.reading.records import require_kind
|
|
29
|
+
from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep
|
|
30
|
+
from lorebase_keeper.reading.sensitivity import deferred_markdown
|
|
31
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
32
|
+
from lorebase_platform.settings import Settings
|
|
33
|
+
|
|
34
|
+
router = APIRouter(tags=["agents"])
|
|
35
|
+
|
|
36
|
+
BASE = "/domains/{domain_id}"
|
|
37
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
38
|
+
403: {"model": Problem, "description": "The caller lacks data.read for what is read."},
|
|
39
|
+
404: {"model": Problem, "description": "Domain, branch, Revision, Kind or Record not found."},
|
|
40
|
+
422: {"model": ValidationProblem, "description": "Invalid request."},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
DataRead = Annotated[ReadGuard, Depends(read_guard())]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_agent_settings(request: Request) -> AgentSettings:
|
|
47
|
+
return agent_settings(cast(Settings, request.app.state.settings))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
AgentSettingsDep = Annotated[AgentSettings, Depends(get_agent_settings)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post(
|
|
54
|
+
f"{BASE}/context",
|
|
55
|
+
summary="Context pack for an agent",
|
|
56
|
+
responses={
|
|
57
|
+
**_ERRORS,
|
|
58
|
+
200: {
|
|
59
|
+
"model": ContextPackOut,
|
|
60
|
+
"content": {JSON: {}, MARKDOWN: {}},
|
|
61
|
+
"description": "The pack in JSON, or markdown with `format: markdown`.",
|
|
62
|
+
},
|
|
63
|
+
304: {"description": "Not modified: If-None-Match names the current ETag."},
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
async def context_pack(
|
|
67
|
+
domain_id: str,
|
|
68
|
+
body: ContextRequest,
|
|
69
|
+
access: DataRead,
|
|
70
|
+
runtime: ReadRuntimeDep,
|
|
71
|
+
session: ReadSessionDep,
|
|
72
|
+
settings: AgentSettingsDep,
|
|
73
|
+
request: Request,
|
|
74
|
+
) -> Response:
|
|
75
|
+
"""Seed Records, their neighborhood, schema, recent changes and lineage within a budget."""
|
|
76
|
+
await check_context_quota(request, access.principal)
|
|
77
|
+
point = await resolve_point(session, runtime, access, branch=body.branch, at=body.at)
|
|
78
|
+
since: str | None = None
|
|
79
|
+
floor: int | None = None
|
|
80
|
+
if body.since is not None and body.include_recent_changes:
|
|
81
|
+
found = await open_revision(session, access, point.domain, body.since)
|
|
82
|
+
since, floor = found.id.hex(), found.generation
|
|
83
|
+
digest = fingerprint("context", body.model_dump(mode="json"), since)
|
|
84
|
+
key = CacheKey(
|
|
85
|
+
point.domain.id,
|
|
86
|
+
point.revision_hex,
|
|
87
|
+
request.url.path,
|
|
88
|
+
(("body", digest),),
|
|
89
|
+
point.variant,
|
|
90
|
+
)
|
|
91
|
+
context = PackContext(point=point, etag=entity_tag(key), since=since, floor=floor)
|
|
92
|
+
|
|
93
|
+
async def produce() -> tuple[bytes, str]:
|
|
94
|
+
return await build_pack(session, runtime, context, body, settings.context)
|
|
95
|
+
|
|
96
|
+
response = await respond_with(
|
|
97
|
+
request,
|
|
98
|
+
runtime.cache,
|
|
99
|
+
key,
|
|
100
|
+
produce,
|
|
101
|
+
time_travel=not point.materialized,
|
|
102
|
+
headers={REVISION_HEADER: point.revision_hex},
|
|
103
|
+
disclose=point.disclosure(session, runtime).send,
|
|
104
|
+
)
|
|
105
|
+
await charge_context_tokens(
|
|
106
|
+
request, access.principal, response, settings.context.chars_per_token
|
|
107
|
+
)
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get(
|
|
112
|
+
f"{BASE}/kinds/{{kind}}/records/{{key}}/explain",
|
|
113
|
+
summary="Explain a Record",
|
|
114
|
+
responses={
|
|
115
|
+
**_ERRORS,
|
|
116
|
+
200: {
|
|
117
|
+
"model": ExplainOut,
|
|
118
|
+
"content": {JSON: {}, MARKDOWN: {}},
|
|
119
|
+
"description": "The explanation in JSON, or markdown with `format=markdown`.",
|
|
120
|
+
},
|
|
121
|
+
304: {"description": "Not modified: If-None-Match names the current ETag."},
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
async def explain(
|
|
125
|
+
domain_id: str,
|
|
126
|
+
kind: str,
|
|
127
|
+
key: str,
|
|
128
|
+
access: DataRead,
|
|
129
|
+
runtime: ReadRuntimeDep,
|
|
130
|
+
session: ReadSessionDep,
|
|
131
|
+
settings: AgentSettingsDep,
|
|
132
|
+
request: Request,
|
|
133
|
+
branch: Annotated[
|
|
134
|
+
str | None, Query(description="Branch to read; default: the Domain's default branch.")
|
|
135
|
+
] = None,
|
|
136
|
+
at: Annotated[
|
|
137
|
+
str | None,
|
|
138
|
+
Query(description="Revision id or tag to read at (time travel, served from a snapshot)."),
|
|
139
|
+
] = None,
|
|
140
|
+
format: Annotated[Literal["json", "markdown"], Query()] = "json",
|
|
141
|
+
) -> Response:
|
|
142
|
+
"""Creation, per-Attribute lineage, Links with their origin and Proposals of a Record."""
|
|
143
|
+
point = await resolve_point(session, runtime, access, branch=branch, at=at)
|
|
144
|
+
found = require_kind(point, kind)
|
|
145
|
+
|
|
146
|
+
async def produce() -> tuple[bytes, str]:
|
|
147
|
+
document = await explain_record(session, runtime, point, found, key, settings.explain)
|
|
148
|
+
if format == "markdown":
|
|
149
|
+
return deferred_markdown(document, explain_markdown), MARKDOWN
|
|
150
|
+
return dumps(document).encode(), JSON
|
|
151
|
+
|
|
152
|
+
return await respond_with(
|
|
153
|
+
request,
|
|
154
|
+
runtime.cache,
|
|
155
|
+
cache_key(request, point.domain.id, point.revision_hex, point.variant),
|
|
156
|
+
produce,
|
|
157
|
+
time_travel=not point.materialized,
|
|
158
|
+
headers={REVISION_HEADER: point.revision_hex},
|
|
159
|
+
disclose=point.disclosure(session, runtime).send,
|
|
160
|
+
)
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Branches and tags (design 6.5, 11.1).
|
|
2
|
+
|
|
3
|
+
Branch names are hierarchical (`agent/bot-1/task`); in paths they are written as they are or
|
|
4
|
+
URL-encoded. Grants narrowed to a branch pattern apply to the branch named in the path or,
|
|
5
|
+
on creation, in the body.
|
|
6
|
+
|
|
7
|
+
| Route | Permission |
|
|
8
|
+
|-------|------------|
|
|
9
|
+
| list, get branches and tags | `schema.read` |
|
|
10
|
+
| create or delete a branch, create a tag | `branch.create`, plus read of the start point |
|
|
11
|
+
| protect or unprotect a branch | `domain.settings` |
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Annotated, Any
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, Depends, Request, Response
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
|
|
23
|
+
from lorebase_core.canonical import hex
|
|
24
|
+
from lorebase_keeper.auth.audit import annotate
|
|
25
|
+
from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, authorize, require
|
|
26
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
27
|
+
from lorebase_keeper.auth.principals import Principal
|
|
28
|
+
from lorebase_keeper.auth.rbac import AccessContext
|
|
29
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
30
|
+
from lorebase_keeper.context import StoreAccess, StoreDep, parse_revision_id
|
|
31
|
+
from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep
|
|
32
|
+
from lorebase_keeper.errors import Problem, ValidationProblem
|
|
33
|
+
from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
|
|
34
|
+
from lorebase_keeper.routes.domains import REPLAYED_HEADER
|
|
35
|
+
from lorebase_keeper.services import branches as service
|
|
36
|
+
from lorebase_keeper.services.reads import resolve_readable
|
|
37
|
+
from lorebase_keeper.settings import KeeperSettings
|
|
38
|
+
from lorebase_store.materialize import BranchMode
|
|
39
|
+
from lorebase_store.models.enums import Materialization, RefKind
|
|
40
|
+
from lorebase_store.repositories import (
|
|
41
|
+
MaterializationPhase,
|
|
42
|
+
MaterializationState,
|
|
43
|
+
MaterializationStatus,
|
|
44
|
+
RefRow,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
router = APIRouter(tags=["branches"])
|
|
48
|
+
|
|
49
|
+
BRANCH_PATH = "/domains/{domain_id}/branches/{branch:path}"
|
|
50
|
+
|
|
51
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
52
|
+
403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
|
|
53
|
+
404: {"model": Problem, "description": "Domain, branch, tag or Revision not found."},
|
|
54
|
+
409: {"model": Problem, "description": "Exists already, protected, or over a limit."},
|
|
55
|
+
422: {"model": ValidationProblem, "description": "Invalid request."},
|
|
56
|
+
503: {"model": Problem, "description": "The branch is locked by another writer."},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MaterializationProgress(BaseModel):
|
|
61
|
+
"""How far the background copy of a branch got."""
|
|
62
|
+
|
|
63
|
+
phase: MaterializationPhase = Field(
|
|
64
|
+
description="What the next batch copies: `records`, `links`, `history`, then `switch` "
|
|
65
|
+
"(the branch becomes a full copy); `done` at the end."
|
|
66
|
+
)
|
|
67
|
+
rows_done: int = Field(description="Rows of the source branch read so far.")
|
|
68
|
+
rows_total: int | None = Field(
|
|
69
|
+
description="Rows of the source branch to read (Records, Links and history entries "
|
|
70
|
+
"at the start of the copy); null until the job has started."
|
|
71
|
+
)
|
|
72
|
+
error: str | None = Field(
|
|
73
|
+
description="Last failure of the copy job, kept while it is retried and when it failed."
|
|
74
|
+
)
|
|
75
|
+
updated_at: datetime
|
|
76
|
+
finished_at: datetime | None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BranchOut(BaseModel):
|
|
80
|
+
"""A branch."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
head: str
|
|
84
|
+
protected: bool
|
|
85
|
+
default: bool
|
|
86
|
+
materialization: Materialization
|
|
87
|
+
version: int
|
|
88
|
+
created_at: datetime
|
|
89
|
+
created_by: str
|
|
90
|
+
materialization_status: MaterializationStatus | None = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
description="Background copy of the branch: a large copy branch is created as an "
|
|
93
|
+
"overlay of its source (`materialization` is `overlay`, reads and writes work at once) "
|
|
94
|
+
"and a job copies its rows in batches; a large copy that cannot start as an overlay "
|
|
95
|
+
"(of an overlay, from an older Revision) is `building` until a job writes its rows "
|
|
96
|
+
"from its tree (its head reads like a past Revision, commits answer 409). `pending` "
|
|
97
|
+
"(queued, or waiting for a retry), `running`, `done` (`materialization` is then "
|
|
98
|
+
"`full`) or `failed` (a build that failed for good). Null for a branch that was never "
|
|
99
|
+
"copied in the background.",
|
|
100
|
+
)
|
|
101
|
+
materialization_progress: MaterializationProgress | None = Field(
|
|
102
|
+
default=None, description="Progress of the background copy; null without one."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BranchDetail(BranchOut):
|
|
107
|
+
"""A branch with its distance to the default branch."""
|
|
108
|
+
|
|
109
|
+
ahead: int = Field(description="Revisions on this branch that the default branch lacks.")
|
|
110
|
+
behind: int = Field(description="Revisions on the default branch that this branch lacks.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BranchCreate(BaseModel):
|
|
114
|
+
"""A new branch, started at a Revision or at the head of a branch or tag."""
|
|
115
|
+
|
|
116
|
+
name: str = Field(min_length=1)
|
|
117
|
+
source: str | None = Field(
|
|
118
|
+
default=None, description="Branch or tag to start from; default: the default branch."
|
|
119
|
+
)
|
|
120
|
+
revision: str | None = Field(
|
|
121
|
+
default=None, description="Revision id to start at; default: the head of `source`."
|
|
122
|
+
)
|
|
123
|
+
mode: BranchMode | None = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
description="`copy`: the branch gets its own materialized rows. `overlay`: it stores "
|
|
126
|
+
"only the rows it changes and reads the rest from `source` as of its head (only when "
|
|
127
|
+
"starting at the head of a fully materialized branch; otherwise a copy). Default: "
|
|
128
|
+
"`store.materialize.branch_mode`, overlay for names with an overlay prefix.",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BranchUpdate(BaseModel):
|
|
133
|
+
"""Branch flags."""
|
|
134
|
+
|
|
135
|
+
protected: bool = Field(
|
|
136
|
+
description="Protected branches accept changes only through Proposals (design 6.5)."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TagOut(BaseModel):
|
|
141
|
+
"""An immutable tag."""
|
|
142
|
+
|
|
143
|
+
name: str
|
|
144
|
+
revision: str
|
|
145
|
+
created_at: datetime
|
|
146
|
+
created_by: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TagCreate(BaseModel):
|
|
150
|
+
"""A new tag at a Revision or at the head of a branch."""
|
|
151
|
+
|
|
152
|
+
name: str = Field(min_length=1)
|
|
153
|
+
source: str | None = Field(default=None, description="Branch whose head is tagged.")
|
|
154
|
+
revision: str | None = Field(default=None, description="Revision id to tag.")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _branch(
|
|
158
|
+
ref: RefRow,
|
|
159
|
+
*,
|
|
160
|
+
default: bool,
|
|
161
|
+
state: MaterializationState | None = None,
|
|
162
|
+
limited: bool = False,
|
|
163
|
+
) -> BranchOut:
|
|
164
|
+
"""A branch; `limited` (a caller limited to Kinds, `Access.kinds`) leaves out the
|
|
165
|
+
progress of its copy, whose row counts and errors tell about every Kind."""
|
|
166
|
+
progress = None
|
|
167
|
+
if state is not None and not limited:
|
|
168
|
+
progress = MaterializationProgress(
|
|
169
|
+
phase=state.phase,
|
|
170
|
+
rows_done=state.rows_done,
|
|
171
|
+
rows_total=state.rows_total,
|
|
172
|
+
error=state.error,
|
|
173
|
+
updated_at=state.updated_at,
|
|
174
|
+
finished_at=state.finished_at,
|
|
175
|
+
)
|
|
176
|
+
return BranchOut(
|
|
177
|
+
name=ref.name,
|
|
178
|
+
head=hex(ref.head),
|
|
179
|
+
protected=ref.protected,
|
|
180
|
+
default=default,
|
|
181
|
+
materialization=ref.materialization,
|
|
182
|
+
version=ref.version,
|
|
183
|
+
created_at=ref.created_at,
|
|
184
|
+
created_by=ref.created_by,
|
|
185
|
+
materialization_status=None if state is None else state.status,
|
|
186
|
+
materialization_progress=progress,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _tag(ref: RefRow) -> TagOut:
|
|
191
|
+
return TagOut(
|
|
192
|
+
name=ref.name, revision=hex(ref.head), created_at=ref.created_at, created_by=ref.created_by
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def _require_source_readable(
|
|
197
|
+
auth: AuthRuntime,
|
|
198
|
+
principal: Principal,
|
|
199
|
+
store: StoreAccess,
|
|
200
|
+
keeper: KeeperSettings,
|
|
201
|
+
domain_id: str,
|
|
202
|
+
*,
|
|
203
|
+
source: str | None,
|
|
204
|
+
revision: str | None,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""A new branch or tag starts at content the caller must be able to read: the Revision
|
|
207
|
+
given, else the source branch or tag (default: the default branch)."""
|
|
208
|
+
await resolve_readable(
|
|
209
|
+
store,
|
|
210
|
+
auth.authorizer,
|
|
211
|
+
principal,
|
|
212
|
+
(Permission.SCHEMA_READ, Permission.DATA_READ),
|
|
213
|
+
domain_id,
|
|
214
|
+
branch=None,
|
|
215
|
+
at=revision or source,
|
|
216
|
+
max_depth=keeper.reads.reachability_max_depth,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _after(params: Any) -> str | None:
|
|
221
|
+
return None if params.position is None else str(params.position.get("name", ""))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _next(rows: list[RefRow], limit: int) -> str | None:
|
|
225
|
+
return encode_cursor({"name": rows[limit - 1].name}) if len(rows) > limit else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.get("/domains/{domain_id}/branches", summary="List branches", responses=_ERRORS)
|
|
229
|
+
async def list_branches(
|
|
230
|
+
domain_id: str,
|
|
231
|
+
store: StoreDep,
|
|
232
|
+
params: PageParamsDep,
|
|
233
|
+
access: Annotated[Access, Depends(require(Permission.SCHEMA_READ, kind_limited=True))],
|
|
234
|
+
) -> Page[BranchOut]:
|
|
235
|
+
"""Branches ordered by name."""
|
|
236
|
+
found = await service.page(
|
|
237
|
+
store, domain_id, RefKind.BRANCH, limit=params.limit + 1, after=_after(params)
|
|
238
|
+
)
|
|
239
|
+
return Page[BranchOut](
|
|
240
|
+
items=[
|
|
241
|
+
_branch(
|
|
242
|
+
r,
|
|
243
|
+
default=r.name == found.default,
|
|
244
|
+
state=found.materializations.get(r.name),
|
|
245
|
+
limited=access.kinds is not None,
|
|
246
|
+
)
|
|
247
|
+
for r in found.refs[: params.limit]
|
|
248
|
+
],
|
|
249
|
+
next_cursor=_next(found.refs, params.limit),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.post(
|
|
254
|
+
"/domains/{domain_id}/branches", status_code=201, summary="Create a branch", responses=_ERRORS
|
|
255
|
+
)
|
|
256
|
+
async def create_branch(
|
|
257
|
+
domain_id: str,
|
|
258
|
+
body: BranchCreate,
|
|
259
|
+
request: Request,
|
|
260
|
+
response: Response,
|
|
261
|
+
principal: PrincipalDep,
|
|
262
|
+
auth: AuthDep,
|
|
263
|
+
store: StoreDep,
|
|
264
|
+
keeper: KeeperSettingsDep,
|
|
265
|
+
idempotency_key: IdempotencyKeyDep,
|
|
266
|
+
) -> BranchOut:
|
|
267
|
+
"""Create a materialized branch (a copy, or an overlay on its source) from a branch, tag
|
|
268
|
+
or Revision.
|
|
269
|
+
|
|
270
|
+
A copy of a large source (above `store.materialize.async_copy_min_rows` rows) is created
|
|
271
|
+
as an overlay at once and copied by a background job: `materialization_status` is then
|
|
272
|
+
`pending`, and the branch becomes `full` when it is `done`. With an `Idempotency-Key`,
|
|
273
|
+
retrying a create that succeeded returns the branch with status 200 and
|
|
274
|
+
`Idempotent-Replayed: true`.
|
|
275
|
+
"""
|
|
276
|
+
await authorize(
|
|
277
|
+
request,
|
|
278
|
+
auth,
|
|
279
|
+
principal,
|
|
280
|
+
Permission.BRANCH_CREATE,
|
|
281
|
+
domain_id,
|
|
282
|
+
AccessContext(branch=body.name),
|
|
283
|
+
)
|
|
284
|
+
revision = None if body.revision is None else parse_revision_id(body.revision, "/body/revision")
|
|
285
|
+
await _require_source_readable(
|
|
286
|
+
auth, principal, store, keeper, domain_id, source=body.source, revision=body.revision
|
|
287
|
+
)
|
|
288
|
+
created = await service.create(
|
|
289
|
+
store,
|
|
290
|
+
domain_id,
|
|
291
|
+
body.name,
|
|
292
|
+
principal,
|
|
293
|
+
keeper.branches,
|
|
294
|
+
source=body.source,
|
|
295
|
+
revision=revision,
|
|
296
|
+
idempotency_key=idempotency_key,
|
|
297
|
+
mode=body.mode,
|
|
298
|
+
)
|
|
299
|
+
annotate(
|
|
300
|
+
request,
|
|
301
|
+
action="branch.create",
|
|
302
|
+
target=body.name,
|
|
303
|
+
head=hex(created.ref.head),
|
|
304
|
+
source=body.source,
|
|
305
|
+
materialization=None if created.mode is None else created.mode.value,
|
|
306
|
+
)
|
|
307
|
+
if created.replayed:
|
|
308
|
+
response.status_code = 200
|
|
309
|
+
response.headers[REPLAYED_HEADER] = "true"
|
|
310
|
+
response.headers["Location"] = (
|
|
311
|
+
f"{keeper.api_prefix}/domains/{domain_id}/branches/{quote(body.name, safe='')}"
|
|
312
|
+
)
|
|
313
|
+
return _branch(created.ref, default=created.default, state=created.materialization)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@router.get("/domains/{domain_id}/tags", summary="List tags", responses=_ERRORS)
|
|
317
|
+
async def list_tags(
|
|
318
|
+
domain_id: str,
|
|
319
|
+
store: StoreDep,
|
|
320
|
+
params: PageParamsDep,
|
|
321
|
+
_: Annotated[Access, Depends(require(Permission.SCHEMA_READ, kind_limited=True))],
|
|
322
|
+
) -> Page[TagOut]:
|
|
323
|
+
"""Tags ordered by name."""
|
|
324
|
+
found = await service.page(
|
|
325
|
+
store, domain_id, RefKind.TAG, limit=params.limit + 1, after=_after(params)
|
|
326
|
+
)
|
|
327
|
+
return Page[TagOut](
|
|
328
|
+
items=[_tag(r) for r in found.refs[: params.limit]],
|
|
329
|
+
next_cursor=_next(found.refs, params.limit),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@router.post(
|
|
334
|
+
"/domains/{domain_id}/tags", status_code=201, summary="Create a tag", responses=_ERRORS
|
|
335
|
+
)
|
|
336
|
+
async def create_tag(
|
|
337
|
+
domain_id: str,
|
|
338
|
+
body: TagCreate,
|
|
339
|
+
request: Request,
|
|
340
|
+
response: Response,
|
|
341
|
+
principal: PrincipalDep,
|
|
342
|
+
auth: AuthDep,
|
|
343
|
+
store: StoreDep,
|
|
344
|
+
keeper: KeeperSettingsDep,
|
|
345
|
+
idempotency_key: IdempotencyKeyDep,
|
|
346
|
+
) -> TagOut:
|
|
347
|
+
"""Tag a Revision. Tags never move."""
|
|
348
|
+
await authorize(
|
|
349
|
+
request,
|
|
350
|
+
auth,
|
|
351
|
+
principal,
|
|
352
|
+
Permission.BRANCH_CREATE,
|
|
353
|
+
domain_id,
|
|
354
|
+
AccessContext(branch=body.name),
|
|
355
|
+
)
|
|
356
|
+
revision = None if body.revision is None else parse_revision_id(body.revision, "/body/revision")
|
|
357
|
+
await _require_source_readable(
|
|
358
|
+
auth, principal, store, keeper, domain_id, source=body.source, revision=body.revision
|
|
359
|
+
)
|
|
360
|
+
created = await service.create_tag(
|
|
361
|
+
store,
|
|
362
|
+
domain_id,
|
|
363
|
+
body.name,
|
|
364
|
+
principal,
|
|
365
|
+
keeper.branches,
|
|
366
|
+
source=body.source,
|
|
367
|
+
revision=revision,
|
|
368
|
+
idempotency_key=idempotency_key,
|
|
369
|
+
)
|
|
370
|
+
annotate(request, action="tag.create", target=body.name, revision=hex(created.ref.head))
|
|
371
|
+
if created.replayed:
|
|
372
|
+
response.status_code = 200
|
|
373
|
+
response.headers[REPLAYED_HEADER] = "true"
|
|
374
|
+
return _tag(created.ref)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# Routes with a hierarchical branch name come last: `{branch:path}` matches any suffix.
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@router.get(BRANCH_PATH, summary="Get a branch", responses=_ERRORS)
|
|
381
|
+
async def get_branch(
|
|
382
|
+
domain_id: str,
|
|
383
|
+
branch: str,
|
|
384
|
+
store: StoreDep,
|
|
385
|
+
access: Annotated[
|
|
386
|
+
Access,
|
|
387
|
+
Depends(require(Permission.SCHEMA_READ, branch_param="branch", kind_limited=True)),
|
|
388
|
+
],
|
|
389
|
+
) -> BranchDetail:
|
|
390
|
+
"""The branch head, protection flag and ahead/behind counts against the default branch."""
|
|
391
|
+
state = await service.get(store, domain_id, branch)
|
|
392
|
+
out = _branch(
|
|
393
|
+
state.ref,
|
|
394
|
+
default=state.default,
|
|
395
|
+
state=state.materialization,
|
|
396
|
+
limited=access.kinds is not None,
|
|
397
|
+
)
|
|
398
|
+
return BranchDetail(**out.model_dump(), ahead=state.ahead, behind=state.behind)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@router.patch(BRANCH_PATH, summary="Protect or unprotect a branch", responses=_ERRORS)
|
|
402
|
+
async def update_branch(
|
|
403
|
+
domain_id: str,
|
|
404
|
+
branch: str,
|
|
405
|
+
body: BranchUpdate,
|
|
406
|
+
request: Request,
|
|
407
|
+
store: StoreDep,
|
|
408
|
+
_: Annotated[Access, Depends(require(Permission.DOMAIN_SETTINGS, branch_param="branch"))],
|
|
409
|
+
) -> BranchOut:
|
|
410
|
+
"""Set the protection flag."""
|
|
411
|
+
state = await service.set_protected(store, domain_id, branch, body.protected)
|
|
412
|
+
annotate(
|
|
413
|
+
request,
|
|
414
|
+
action="branch.protect" if body.protected else "branch.unprotect",
|
|
415
|
+
target=branch,
|
|
416
|
+
)
|
|
417
|
+
return _branch(state.ref, default=state.default)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@router.delete(BRANCH_PATH, status_code=204, summary="Delete a branch", responses=_ERRORS)
|
|
421
|
+
async def delete_branch(
|
|
422
|
+
domain_id: str,
|
|
423
|
+
branch: str,
|
|
424
|
+
request: Request,
|
|
425
|
+
store: StoreDep,
|
|
426
|
+
keeper: KeeperSettingsDep,
|
|
427
|
+
_: Annotated[Access, Depends(require(Permission.BRANCH_CREATE, branch_param="branch"))],
|
|
428
|
+
) -> Response:
|
|
429
|
+
"""Delete a branch and its materialized rows; the default and protected branches stay.
|
|
430
|
+
|
|
431
|
+
A large branch (above `store.materialize.async_delete_min_rows` rows) disappears at once
|
|
432
|
+
and a background job deletes its rows; its name can be used again when that is done.
|
|
433
|
+
"""
|
|
434
|
+
ref, deletion = await service.delete(store, domain_id, branch, keeper.branches)
|
|
435
|
+
annotate(
|
|
436
|
+
request, action="branch.delete", target=branch, head=hex(ref.head), deletion=deletion.value
|
|
437
|
+
)
|
|
438
|
+
return Response(status_code=204)
|