lorebase-keeper 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,337 @@
1
+ """Branch and tag use cases (design 6.5): create, read, delete, protect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, replace
7
+
8
+ from sqlalchemy.exc import IntegrityError
9
+
10
+ from lorebase_keeper.auth.principals import Principal
11
+ from lorebase_keeper.context import (
12
+ StoreAccess,
13
+ require_branch,
14
+ require_domain,
15
+ require_revision,
16
+ )
17
+ from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, ProblemError
18
+ from lorebase_keeper.services.config import BranchSettings
19
+ from lorebase_keeper.services.errors import (
20
+ ALREADY_EXISTS,
21
+ BRANCH_BUSY,
22
+ LIMIT_EXCEEDED,
23
+ PROTECTED_BRANCH,
24
+ )
25
+ from lorebase_store.materialize import (
26
+ BranchDeletingError,
27
+ BranchDeletion,
28
+ BranchExistsError,
29
+ BranchMode,
30
+ MaterializationMode,
31
+ create_branch,
32
+ delete_branch,
33
+ )
34
+ from lorebase_store.materialize.snapshot import SNAPSHOT_PREFIX
35
+ from lorebase_store.models.enums import Materialization, RefKind
36
+ from lorebase_store.repositories import (
37
+ BranchMaterializationRepository,
38
+ LockTimeoutError,
39
+ MaterializationState,
40
+ RefNotFoundError,
41
+ RefRepository,
42
+ RefRow,
43
+ RevisionRepository,
44
+ )
45
+
46
+ TAG_CREATE_REASON = "tag.create"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class BranchState:
51
+ """A branch with its position relative to the default branch and its background copy."""
52
+
53
+ ref: RefRow
54
+ default: bool
55
+ ahead: int
56
+ behind: int
57
+ materialization: MaterializationState | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class CreatedRef:
62
+ """A new branch or tag; `mode` tells how a branch got its rows, `replayed` that it existed,
63
+ `materialization` the background copy of a large copy branch."""
64
+
65
+ ref: RefRow
66
+ mode: MaterializationMode | None = None
67
+ replayed: bool = False
68
+ default: bool = False
69
+ materialization: MaterializationState | None = None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class RefPage:
74
+ """A page of refs with the Domain's default branch and their background copies."""
75
+
76
+ default: str
77
+ refs: list[RefRow]
78
+ materializations: dict[str, MaterializationState]
79
+
80
+
81
+ def check_ref_name(name: str, settings: BranchSettings, pointer: str = "/body/name") -> None:
82
+ """422 unless `name` is a valid branch or tag name (never a snapshot ref's name, whatever
83
+ the configured pattern)."""
84
+ if (
85
+ len(name) > settings.max_name_length
86
+ or not re.fullmatch(settings.name_pattern, name)
87
+ or name.startswith(SNAPSHOT_PREFIX)
88
+ ):
89
+ raise ProblemError(
90
+ VALIDATION_FAILED,
91
+ "One or more request members are invalid.",
92
+ extensions={
93
+ "errors": [
94
+ {
95
+ "pointer": pointer,
96
+ "message": f"must match {settings.name_pattern} and be at most "
97
+ f"{settings.max_name_length} characters",
98
+ "code": "invalid",
99
+ }
100
+ ]
101
+ },
102
+ )
103
+
104
+
105
+ def _busy(settings: BranchSettings) -> ProblemError:
106
+ return ProblemError(
107
+ BRANCH_BUSY,
108
+ "Another writer holds the branch; retry later.",
109
+ headers={"Retry-After": str(settings.lock_retry_after_s)},
110
+ )
111
+
112
+
113
+ async def page(
114
+ store: StoreAccess, domain_id: str, kind: RefKind, *, limit: int, after: str | None
115
+ ) -> RefPage:
116
+ """Refs of one kind ordered by name, with the Domain's default branch name."""
117
+ async with store.sessions() as session, session.begin():
118
+ domain = await require_domain(session, domain_id)
119
+ refs = await RefRepository(session, domain_id).page(kind, limit=limit, after=after)
120
+ states = await BranchMaterializationRepository(session, domain_id).of(
121
+ [r.name for r in refs]
122
+ )
123
+ return RefPage(domain.default_branch, refs, states)
124
+
125
+
126
+ async def get(store: StoreAccess, domain_id: str, name: str) -> BranchState:
127
+ """A branch with ahead and behind counts against the default branch head."""
128
+ async with store.sessions() as session, session.begin():
129
+ domain = await require_domain(session, domain_id)
130
+ ref = await require_branch(session, domain_id, name)
131
+ default = await RefRepository(session, domain_id).get(domain.default_branch)
132
+ ahead = behind = 0
133
+ if default is not None and default.name != ref.name:
134
+ ahead, behind = await RevisionRepository(session, domain_id).ahead_behind(
135
+ ref.head, default.head
136
+ )
137
+ state = await BranchMaterializationRepository(session, domain_id).get(name)
138
+ return BranchState(
139
+ ref=ref,
140
+ default=ref.name == domain.default_branch,
141
+ ahead=ahead,
142
+ behind=behind,
143
+ materialization=state,
144
+ )
145
+
146
+
147
+ async def create(
148
+ store: StoreAccess,
149
+ domain_id: str,
150
+ name: str,
151
+ principal: Principal,
152
+ settings: BranchSettings,
153
+ *,
154
+ source: str | None,
155
+ revision: bytes | None,
156
+ idempotency_key: str | None,
157
+ mode: BranchMode | None = None,
158
+ ) -> CreatedRef:
159
+ """Create a materialized branch at `revision` or at the head of `source`.
160
+
161
+ `source` is a branch or tag (default: the Domain's default branch). An overlay (`mode`,
162
+ default from `store.materialize`) reads the source's rows and stores only its changes;
163
+ otherwise rows are copied from the source branch when the new branch starts at its head,
164
+ rebuilt from the tree otherwise. A large copy starts as an overlay whose rows a background
165
+ job copies (`materialization` pending). Overlays do not count against
166
+ `max_materialized_per_domain`; copies in progress do.
167
+ """
168
+ check_ref_name(name, settings)
169
+ try:
170
+ async with store.sessions() as session, session.begin():
171
+ domain = await require_domain(session, domain_id)
172
+ refs = RefRepository(session, domain_id)
173
+ existing = await refs.get(name)
174
+ if existing is not None:
175
+ replayed = _replay(
176
+ existing,
177
+ principal,
178
+ idempotency_key,
179
+ RefKind.BRANCH,
180
+ default=name == domain.default_branch,
181
+ )
182
+ state = await BranchMaterializationRepository(session, domain_id).get(name)
183
+ return replace(replayed, materialization=state)
184
+ source_name = source or domain.default_branch
185
+ found_source = await refs.get(source_name)
186
+ if found_source is None or found_source.kind is RefKind.SNAPSHOT:
187
+ raise ProblemError(NOT_FOUND, f"Branch or tag {source_name!r} not found.")
188
+ if revision is not None:
189
+ await require_revision(session, domain_id, revision)
190
+ materialized = await refs.count(RefKind.BRANCH, Materialization.FULL)
191
+ materialized += await BranchMaterializationRepository(session, domain_id).count_active()
192
+ if materialized >= settings.max_materialized_per_domain:
193
+ raise ProblemError(
194
+ LIMIT_EXCEEDED,
195
+ f"Domain {domain_id!r} already has {materialized} materialized branches "
196
+ f"(limit {settings.max_materialized_per_domain}); delete one first.",
197
+ )
198
+ created = await create_branch(
199
+ session,
200
+ domain_id,
201
+ name,
202
+ created_by=principal.id,
203
+ source=source_name,
204
+ start=revision,
205
+ mode=mode,
206
+ runtime=store.runtime,
207
+ clock=store.clock,
208
+ )
209
+ state = None
210
+ if created.pending:
211
+ state = await BranchMaterializationRepository(session, domain_id).get(name)
212
+ except BranchDeletingError:
213
+ raise ProblemError(
214
+ ALREADY_EXISTS,
215
+ f"Branch {name!r} is being deleted; its name is free again once its rows are gone.",
216
+ headers={"Retry-After": str(settings.lock_retry_after_s)},
217
+ ) from None
218
+ except BranchExistsError:
219
+ async with store.sessions() as session, session.begin():
220
+ await require_domain(session, domain_id)
221
+ found = await RefRepository(session, domain_id).require(name)
222
+ return _replay(found, principal, idempotency_key, RefKind.BRANCH)
223
+ except LockTimeoutError:
224
+ raise _busy(settings) from None
225
+ return CreatedRef(ref=created.ref, mode=created.mode, materialization=state)
226
+
227
+
228
+ def _replay(
229
+ existing: RefRow,
230
+ principal: Principal,
231
+ idempotency_key: str | None,
232
+ kind: RefKind,
233
+ *,
234
+ default: bool = False,
235
+ ) -> CreatedRef:
236
+ """An existing ref returned for a retried create, or 409."""
237
+ if idempotency_key is None or existing.created_by != principal.id or existing.kind is not kind:
238
+ raise ProblemError(ALREADY_EXISTS, f"A branch or tag named {existing.name!r} exists.")
239
+ return CreatedRef(ref=existing, replayed=True, default=default)
240
+
241
+
242
+ async def delete(
243
+ store: StoreAccess, domain_id: str, name: str, settings: BranchSettings
244
+ ) -> tuple[RefRow, BranchDeletion]:
245
+ """Delete a branch and its materialized rows. The default and protected branches stay;
246
+ overlay branches based on it are promoted to full copies first. A large branch is hidden
247
+ at once and its rows are deleted by a background job (`BranchDeletion.SCHEDULED`), which
248
+ first waits for the background copies of the overlays based on it."""
249
+ try:
250
+ async with store.sessions() as session, session.begin():
251
+ domain = await require_domain(session, domain_id)
252
+ refs = RefRepository(session, domain_id)
253
+ await refs.lock(name, timeout_ms=store.runtime.settings.commit.lock_timeout_ms)
254
+ ref = await require_branch(session, domain_id, name)
255
+ if name == domain.default_branch:
256
+ raise ProblemError(
257
+ PROTECTED_BRANCH, f"{name!r} is the default branch of the Domain."
258
+ )
259
+ if ref.protected:
260
+ raise ProblemError(
261
+ PROTECTED_BRANCH, f"Branch {name!r} is protected; unprotect it first."
262
+ )
263
+ try:
264
+ deletion = await delete_branch(
265
+ session, domain_id, name, runtime=store.runtime, clock=store.clock
266
+ )
267
+ except RefNotFoundError:
268
+ raise ProblemError(NOT_FOUND, f"Branch {name!r} not found.") from None
269
+ except LockTimeoutError:
270
+ raise _busy(settings) from None
271
+ return ref, deletion
272
+
273
+
274
+ async def set_protected(
275
+ store: StoreAccess, domain_id: str, name: str, protected: bool
276
+ ) -> BranchState:
277
+ """Protect or unprotect a branch."""
278
+ async with store.sessions() as session, session.begin():
279
+ domain = await require_domain(session, domain_id)
280
+ await require_branch(session, domain_id, name)
281
+ ref = await RefRepository(session, domain_id).set_protected(name, protected)
282
+ assert ref is not None
283
+ return BranchState(ref=ref, default=name == domain.default_branch, ahead=0, behind=0)
284
+
285
+
286
+ async def create_tag(
287
+ store: StoreAccess,
288
+ domain_id: str,
289
+ name: str,
290
+ principal: Principal,
291
+ settings: BranchSettings,
292
+ *,
293
+ source: str | None,
294
+ revision: bytes | None,
295
+ idempotency_key: str | None,
296
+ ) -> CreatedRef:
297
+ """Create an immutable tag at `revision` or at the head of `source` (default branch)."""
298
+ check_ref_name(name, settings)
299
+ try:
300
+ return await _create_tag(
301
+ store, domain_id, name, principal, source, revision, idempotency_key
302
+ )
303
+ except IntegrityError:
304
+ raise ProblemError(ALREADY_EXISTS, f"A branch or tag named {name!r} exists.") from None
305
+
306
+
307
+ async def _create_tag(
308
+ store: StoreAccess,
309
+ domain_id: str,
310
+ name: str,
311
+ principal: Principal,
312
+ source: str | None,
313
+ revision: bytes | None,
314
+ idempotency_key: str | None,
315
+ ) -> CreatedRef:
316
+ async with store.sessions() as session, session.begin():
317
+ domain = await require_domain(session, domain_id)
318
+ refs = RefRepository(session, domain_id)
319
+ existing = await refs.get(name)
320
+ if existing is not None:
321
+ return _replay(existing, principal, idempotency_key, RefKind.TAG)
322
+ if revision is None:
323
+ source_ref = await refs.get(source or domain.default_branch)
324
+ if source_ref is None or source_ref.kind is RefKind.SNAPSHOT:
325
+ raise ProblemError(NOT_FOUND, f"Branch or tag {source!r} not found.")
326
+ revision = source_ref.head
327
+ await require_revision(session, domain_id, revision)
328
+ ref = await refs.create(
329
+ name,
330
+ revision,
331
+ created_at=store.clock(),
332
+ created_by=principal.id,
333
+ kind=RefKind.TAG,
334
+ materialization=Materialization.NONE,
335
+ reason=TAG_CREATE_REASON,
336
+ )
337
+ return CreatedRef(ref=ref)