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,418 @@
1
+ """Review of schema suggestions (design 9.2 step 10).
2
+
3
+ Extraction jobs aggregate what they saw outside the schema into `schema_suggestions`
4
+ (`lorebase_scribe.extraction.discovery`). Reviewers list them, reject them, or accept a set of
5
+ them: the accepted suggestions become one schema change set (`suggestion_patch`) committed on a
6
+ new branch (`keeper.schema_suggestions.branch_prefix` and the first suggestion's id) and
7
+ proposed into the default branch as a Proposal. Nothing reaches the default branch until that
8
+ Proposal merges; new Kinds and Relations are drafts for a modeler to describe and activate.
9
+
10
+ Listings compare each suggestion with the default branch's schema of now: one that duplicates
11
+ an element meanwhile is flagged (`duplicate_of`) and cannot be accepted, and one that
12
+ duplicates a sensitive Attribute the caller may not read is listed without examples and
13
+ samples.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+ from lorebase_core.infer.suggestions import (
23
+ PROPOSED,
24
+ AttributeGuess,
25
+ ExistingMatch,
26
+ Suggestion,
27
+ SuggestionError,
28
+ SuggestionOptions,
29
+ SuggestionType,
30
+ existing_match,
31
+ infer_attribute,
32
+ sensitive_attributes,
33
+ suggestion_patch,
34
+ )
35
+ from lorebase_core.model import Kind, Schema
36
+ from lorebase_keeper.auth.principals import Principal
37
+ from lorebase_keeper.context import StoreAccess, require_domain
38
+ from lorebase_keeper.errors import CONFLICT, NOT_FOUND, VALIDATION_FAILED, ProblemError
39
+ from lorebase_keeper.reading.sensitivity import Masking
40
+ from lorebase_keeper.services import branches as branch_service
41
+ from lorebase_keeper.services import proposals as proposal_service
42
+ from lorebase_keeper.services import schema as schema_service
43
+ from lorebase_keeper.services.config import (
44
+ BranchSettings,
45
+ ProposalSettings,
46
+ SchemaSuggestionSettings,
47
+ )
48
+ from lorebase_keeper.services.errors import Finding, violations_problem
49
+ from lorebase_store.commit.schema import load_compiled
50
+ from lorebase_store.repositories import (
51
+ MaterializedRepository,
52
+ MergeOutcome,
53
+ SchemaSuggestionRepository,
54
+ SuggestionRow,
55
+ )
56
+ from lorebase_store.repositories.suggestions import ACCEPTED, PENDING, REJECTED
57
+
58
+ DECISIONS = (PENDING, ACCEPTED, REJECTED)
59
+ ISSUES_LISTED = 50
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SuggestionView:
64
+ """A stored suggestion as the caller may see it, against the current schema."""
65
+
66
+ row: SuggestionRow
67
+ kind: str | None
68
+ target: str | None
69
+ guess: AttributeGuess | None
70
+ duplicate: ExistingMatch | None
71
+ withheld: bool
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class SuggestionPage:
76
+ views: list[SuggestionView]
77
+ next_position: dict[str, Any] | None
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DecisionInput:
82
+ suggestion: str
83
+ decision: str
84
+ name: str | None = None
85
+
86
+
87
+ @dataclass
88
+ class DecisionOutcome:
89
+ views: list[SuggestionView]
90
+ proposal_id: str | None = None
91
+ proposal_title: str | None = None
92
+ branch: str | None = None
93
+ changes: list[schema_service.ElementChange] = field(default_factory=list)
94
+
95
+
96
+ def invalid(pointer: str, message: str, **extra: Any) -> ProblemError:
97
+ return ProblemError(
98
+ VALIDATION_FAILED,
99
+ "One or more request members are invalid.",
100
+ extensions={"errors": [{"pointer": pointer, "message": message, **extra}]},
101
+ )
102
+
103
+
104
+ async def default_schema(store: StoreAccess, domain_id: str) -> tuple[str, Schema]:
105
+ """The default branch's name and schema."""
106
+ async with store.sessions() as session, session.begin():
107
+ domain = await require_domain(session, domain_id)
108
+ compiled = await MaterializedRepository(
109
+ session, domain_id, domain.default_branch, writes=store.runtime.settings.writes
110
+ ).schema()
111
+ if compiled is None:
112
+ raise ProblemError(NOT_FOUND, f"Branch {domain.default_branch!r} is not materialized.")
113
+ return domain.default_branch, load_compiled(compiled.compiled)
114
+
115
+
116
+ def _kind_label(schema: Schema, ref: str | None) -> str | None:
117
+ if ref is None:
118
+ return None
119
+ if ref.startswith(PROPOSED):
120
+ return ref
121
+ kind = schema.kinds.get(ref)
122
+ return ref if kind is None else kind.api_name
123
+
124
+
125
+ def view_of(
126
+ row: SuggestionRow,
127
+ schema: Schema,
128
+ options: SuggestionOptions,
129
+ masking: Masking | None,
130
+ ) -> SuggestionView:
131
+ """What a listing shows of `row` against `schema` for a caller with `masking`."""
132
+ type_ = SuggestionType(row.suggestion_type)
133
+ duplicate = existing_match(
134
+ schema,
135
+ type_,
136
+ row.name,
137
+ kind=row.kind_ref,
138
+ target=row.target_ref,
139
+ options=options,
140
+ tags=sensitive_attributes(schema),
141
+ )
142
+ withheld = False
143
+ if duplicate is not None and duplicate.sensitive:
144
+ owner = schema.attribute_owner(duplicate.id)
145
+ attribute = owner.attributes.get(duplicate.id) if isinstance(owner, Kind) else None
146
+ kinds = (owner.id,) if isinstance(owner, Kind) else ()
147
+ withheld = masking is None or attribute is None or masking.hidden(attribute, kinds)
148
+ guess = None
149
+ if type_ is SuggestionType.ATTRIBUTE and row.samples and not withheld:
150
+ guess = infer_attribute(row.name, row.samples, options)
151
+ return SuggestionView(
152
+ row=row,
153
+ kind=_kind_label(schema, row.kind_ref),
154
+ target=_kind_label(schema, row.target_ref),
155
+ guess=guess,
156
+ duplicate=duplicate,
157
+ withheld=withheld,
158
+ )
159
+
160
+
161
+ async def page(
162
+ store: StoreAccess,
163
+ domain_id: str,
164
+ masking: Masking | None,
165
+ *,
166
+ statuses: Sequence[str],
167
+ types: Sequence[str],
168
+ all_support: bool,
169
+ limit: int,
170
+ position: dict[str, Any] | None,
171
+ ) -> SuggestionPage:
172
+ """Suggestions by support, the most supported first: pending ones need
173
+ `schema.suggestions.min_support` (pending Kinds also `kind_min_attributes`), unless
174
+ `all_support`, and at most `max_suggestions` are listed in all."""
175
+ options = store.runtime.schema.suggestions
176
+ _, schema = await default_schema(store, domain_id)
177
+ listed = 0
178
+ after = None
179
+ if position is not None:
180
+ try:
181
+ after = (int(position["support"]), str(position["id"]))
182
+ listed = int(position["listed"])
183
+ except (KeyError, TypeError, ValueError):
184
+ raise ProblemError(
185
+ VALIDATION_FAILED, "The cursor is not a suggestion cursor."
186
+ ) from None
187
+ room = options.max_suggestions - listed
188
+ if room <= 0:
189
+ return SuggestionPage([], None)
190
+ take = min(limit, room)
191
+ async with store.sessions() as session, session.begin():
192
+ await require_domain(session, domain_id)
193
+ rows = await SchemaSuggestionRepository(session, domain_id).page(
194
+ statuses=statuses,
195
+ types=types,
196
+ min_support=1 if all_support else options.min_support,
197
+ kind_min_attributes=0 if all_support else options.kind_min_attributes,
198
+ limit=take + 1,
199
+ after=after,
200
+ )
201
+ more = len(rows) > take
202
+ rows = rows[:take]
203
+ following = None
204
+ if more and rows and listed + len(rows) < options.max_suggestions:
205
+ last = rows[-1]
206
+ following = {"support": last.support, "id": last.id, "listed": listed + len(rows)}
207
+ return SuggestionPage([view_of(r, schema, options, masking) for r in rows], following)
208
+
209
+
210
+ async def merge_variants(store: StoreAccess, domain_id: str) -> MergeOutcome:
211
+ """Fold the pending Attribute suggestions already stored that are variants of one another
212
+ (`schema.suggestions.merge_similarity`) into the best supported one of each set."""
213
+ async with store.sessions() as session, session.begin():
214
+ await require_domain(session, domain_id)
215
+ return await SchemaSuggestionRepository(session, domain_id).merge_variants(
216
+ options=store.runtime.schema.suggestions, now=store.clock()
217
+ )
218
+
219
+
220
+ async def decide(
221
+ store: StoreAccess,
222
+ domain_id: str,
223
+ decisions: Sequence[DecisionInput],
224
+ principal: Principal,
225
+ *,
226
+ settings: SchemaSuggestionSettings,
227
+ branches: BranchSettings,
228
+ proposals: ProposalSettings,
229
+ masking: Masking | None,
230
+ ) -> DecisionOutcome:
231
+ """Reject, reset or accept suggestions. Accepted ones are applied together on a new branch
232
+ and proposed into the default branch; nothing is decided when that fails."""
233
+ if len(decisions) > settings.max_decisions:
234
+ raise invalid("/body/decisions", f"at most {settings.max_decisions} per request")
235
+ ids = [d.suggestion for d in decisions]
236
+ if len(set(ids)) != len(ids):
237
+ raise invalid("/body/decisions", "a suggestion is named twice")
238
+ options = store.runtime.schema.suggestions
239
+ default_branch, schema = await default_schema(store, domain_id)
240
+ async with store.sessions() as session, session.begin():
241
+ await require_domain(session, domain_id)
242
+ rows = await SchemaSuggestionRepository(session, domain_id).get_many(ids)
243
+ for index, decision in enumerate(decisions):
244
+ row = rows.get(decision.suggestion)
245
+ if row is None:
246
+ raise ProblemError(NOT_FOUND, f"Suggestion {decision.suggestion!r} not found.")
247
+ if row.status == ACCEPTED:
248
+ raise ProblemError(
249
+ CONFLICT,
250
+ f"Suggestion {row.id!r} was accepted into Proposal {row.proposal_id!r}.",
251
+ extensions={"proposal": row.proposal_id},
252
+ )
253
+ if decision.name is not None and not decision.name.strip():
254
+ raise invalid(f"/body/decisions/{index}/name", "must not be empty")
255
+ accepted = [d for d in decisions if d.decision == ACCEPTED]
256
+ outcome = DecisionOutcome(views=[])
257
+ now = store.clock()
258
+ if accepted:
259
+ await _accept(
260
+ store,
261
+ domain_id,
262
+ [(d, rows[d.suggestion]) for d in accepted],
263
+ schema,
264
+ default_branch,
265
+ principal,
266
+ options,
267
+ settings,
268
+ branches,
269
+ proposals,
270
+ outcome,
271
+ masking,
272
+ )
273
+ async with store.sessions() as session, session.begin():
274
+ await require_domain(session, domain_id)
275
+ repository = SchemaSuggestionRepository(session, domain_id)
276
+ if accepted:
277
+ await repository.decide(
278
+ [d.suggestion for d in accepted],
279
+ status=ACCEPTED,
280
+ decided_by=principal.id,
281
+ decided_at=now,
282
+ proposal_id=outcome.proposal_id,
283
+ branch=outcome.branch,
284
+ )
285
+ for status in (REJECTED, PENDING):
286
+ chosen = [d.suggestion for d in decisions if d.decision == status]
287
+ await repository.decide(
288
+ chosen,
289
+ status=status,
290
+ decided_by=principal.id if status == REJECTED else None,
291
+ decided_at=now if status == REJECTED else None,
292
+ )
293
+ stored = await repository.get_many(ids)
294
+ outcome.views = [view_of(stored[i], schema, options, masking) for i in ids]
295
+ return outcome
296
+
297
+
298
+ def _patch(
299
+ rows: Sequence[tuple[DecisionInput, SuggestionRow]],
300
+ schema: Schema,
301
+ options: SuggestionOptions,
302
+ ) -> dict[str, Any]:
303
+ suggestions: list[Suggestion] = []
304
+ for index, (decision, row) in enumerate(rows):
305
+ type_ = SuggestionType(row.suggestion_type)
306
+ duplicate = existing_match(
307
+ schema, type_, row.name, kind=row.kind_ref, target=row.target_ref, options=options
308
+ )
309
+ if duplicate is not None:
310
+ raise invalid(
311
+ f"/body/decisions/{index}",
312
+ f"the schema has {duplicate.api_name!r} ({duplicate.section}) already",
313
+ suggestion=row.id,
314
+ )
315
+ suggestions.append(
316
+ Suggestion(
317
+ id=row.id,
318
+ type=type_,
319
+ name=decision.name.strip() if decision.name else row.name,
320
+ kind=row.kind_ref,
321
+ target=row.target_ref,
322
+ samples=tuple(row.samples),
323
+ labels=tuple(row.labels),
324
+ )
325
+ )
326
+ try:
327
+ return suggestion_patch(schema, suggestions, options)
328
+ except SuggestionError as error:
329
+ position = next(i for i, (_, row) in enumerate(rows) if row.id == error.suggestion)
330
+ raise invalid(
331
+ f"/body/decisions/{position}", str(error), suggestion=error.suggestion
332
+ ) from None
333
+
334
+
335
+ def _issues_problem(issues: Sequence[Finding]) -> ProblemError:
336
+ return violations_problem(issues, limit=ISSUES_LISTED)
337
+
338
+
339
+ async def _accept(
340
+ store: StoreAccess,
341
+ domain_id: str,
342
+ rows: Sequence[tuple[DecisionInput, SuggestionRow]],
343
+ schema: Schema,
344
+ default_branch: str,
345
+ principal: Principal,
346
+ options: SuggestionOptions,
347
+ settings: SchemaSuggestionSettings,
348
+ branches: BranchSettings,
349
+ proposals: ProposalSettings,
350
+ outcome: DecisionOutcome,
351
+ masking: Masking | None,
352
+ ) -> None:
353
+ change = schema_service.ChangeSetInput(patch=_patch(rows, schema, options))
354
+ try:
355
+ target = schema_service.target_schema(schema, change)
356
+ except schema_service.ChangeSetInvalidError as error:
357
+ raise _issues_problem(error.issues) from None
358
+ issues = schema_service.static_issues(target, store.runtime.schema.rules())
359
+ if issues:
360
+ raise _issues_problem(issues)
361
+ branch = f"{settings.branch_prefix}{rows[0][1].id}"
362
+ branch_service.check_ref_name(branch, branches, "/body/decisions")
363
+ await branch_service.create(
364
+ store,
365
+ domain_id,
366
+ branch,
367
+ principal,
368
+ branches,
369
+ source=default_branch,
370
+ revision=None,
371
+ idempotency_key=None,
372
+ )
373
+ names = ", ".join(row.name for _, row in rows)
374
+ try:
375
+ applied = await schema_service.apply(
376
+ store,
377
+ domain_id,
378
+ branch,
379
+ change,
380
+ principal,
381
+ message=f"Schema suggestions: {names}",
382
+ expected_head=None,
383
+ dry_run=False,
384
+ cascade=False,
385
+ clearance=None if masking is None else masking.clearance,
386
+ )
387
+ created = await proposal_service.create(
388
+ store,
389
+ domain_id,
390
+ proposal_service.NewProposal(
391
+ source_ref=branch,
392
+ target_ref=default_branch,
393
+ title=f"Schema suggestions: {names}"[:200],
394
+ description="Suggested by document extraction: "
395
+ + ", ".join(f"{row.suggestion_type} {row.name} ({row.id})" for _, row in rows),
396
+ ),
397
+ principal,
398
+ proposals,
399
+ idempotency_key=None,
400
+ )
401
+ except schema_service.ChangeSetInvalidError as error:
402
+ await _drop(store, domain_id, branch, branches)
403
+ raise _issues_problem(error.issues) from None
404
+ except Exception:
405
+ await _drop(store, domain_id, branch, branches)
406
+ raise
407
+ outcome.branch = branch
408
+ outcome.changes = applied.changes
409
+ outcome.proposal_id = created.view.proposal.id
410
+ outcome.proposal_title = created.view.proposal.title
411
+
412
+
413
+ async def _drop(store: StoreAccess, domain_id: str, branch: str, settings: BranchSettings) -> None:
414
+ """Delete the branch of a change set that could not be proposed (best effort)."""
415
+ try:
416
+ await branch_service.delete(store, domain_id, branch, settings)
417
+ except ProblemError:
418
+ return
@@ -0,0 +1,127 @@
1
+ """Preview of the expression mappings of a candidate Source (design 5.9) on sample rows.
2
+
3
+ The candidate is a schema change set naming the Source: the schema it produces is validated
4
+ like a schema change, then the `expression` of every Attribute mapping of the Source is
5
+ evaluated on each row the caller sends, with the engine Scribe maps rows with
6
+ (`lorebase_core.expr.rows`, within `schema.expression_limits`). The answer holds the value of
7
+ each expression before the mapping's transforms; transforms and the coercion to the Attribute
8
+ type are the mapper's (`lorebase_scribe.sources.mapping`, which Keeper does not import).
9
+
10
+ The rows are the caller's own input and the answer is made of them: no Record is read, nothing is
11
+ stored and no connection is opened, so reading the schema is the only permission involved (a
12
+ grant narrowed to a Kind is enough when it names the Source's target Kind). Expressions are
13
+ compiled once over every column the rows have; a column a row lacks is null, as in Scribe.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable, Mapping, Sequence
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+
22
+ from pydantic import JsonValue
23
+
24
+ from lorebase_core.expr import ExprError, ExprLimits, RowExpressions, row_value
25
+ from lorebase_core.model import Schema, Source
26
+ from lorebase_core.validate import SchemaRules
27
+ from lorebase_keeper.services.derivation_preview import CandidateInvalidError
28
+ from lorebase_keeper.services.errors import Finding
29
+ from lorebase_keeper.services.schema import (
30
+ ChangeSetInput,
31
+ ChangeSetInvalidError,
32
+ static_issues,
33
+ target_schema,
34
+ )
35
+
36
+ SOURCE_NOT_FOUND = "source.not_found"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ExpressionCell:
41
+ """What one expression gives for one row: the value, or why there is none."""
42
+
43
+ value: JsonValue
44
+ error_code: str | None = None
45
+ error_message: str | None = None
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SourceCandidate:
50
+ """A validated candidate: the schema the change set asks for and the Source in it."""
51
+
52
+ schema: Schema
53
+ source: Source
54
+
55
+
56
+ def candidate_of(
57
+ current: Schema,
58
+ change: ChangeSetInput,
59
+ name: str,
60
+ rules: SchemaRules,
61
+ *,
62
+ authorize: Callable[[Schema, Source | None], None] | None = None,
63
+ ) -> SourceCandidate:
64
+ """The Source `name` of the schema `change` asks for, once the schema is valid.
65
+
66
+ `authorize` is called with the schema the change set produces and the Source in it (None
67
+ when there is none) before the schema is validated, so a caller who may not read the
68
+ Source's target Kind is refused before any issue is reported. Raises
69
+ `CandidateInvalidError` when the change set cannot be read, fails static validation or
70
+ `name` is not a Source of it.
71
+ """
72
+ try:
73
+ schema = target_schema(current, change)
74
+ except ChangeSetInvalidError as invalid:
75
+ raise CandidateInvalidError(invalid.issues) from None
76
+ source = schema.source_by_api_name(name)
77
+ if authorize is not None:
78
+ authorize(schema, source)
79
+ issues = static_issues(schema, rules)
80
+ if issues:
81
+ raise CandidateInvalidError(issues)
82
+ if source is None:
83
+ raise CandidateInvalidError(
84
+ [Finding(SOURCE_NOT_FOUND, f"No Source {name!r} in the schema.", ("sources",))]
85
+ )
86
+ return SourceCandidate(schema, source)
87
+
88
+
89
+ def expression_attributes(candidate: SourceCandidate) -> dict[str, str]:
90
+ """Api name of each Attribute mapped by an expression, with the expression."""
91
+ schema, source = candidate.schema, candidate.source
92
+ kind = schema.kinds[source.target_kind]
93
+ return {
94
+ kind.attributes[attribute_id].api_name: mapping.expression
95
+ for attribute_id, mapping in source.attribute_mapping.items()
96
+ if mapping.expression is not None and attribute_id in kind.attributes
97
+ }
98
+
99
+
100
+ def evaluate(
101
+ candidate: SourceCandidate,
102
+ rows: Sequence[Mapping[str, JsonValue]],
103
+ *,
104
+ limits: ExprLimits,
105
+ now: datetime,
106
+ ) -> list[dict[str, ExpressionCell]]:
107
+ """The value of every expression mapping of the Source for each row, in row order.
108
+
109
+ Expressions are compiled once over every column the rows have, as Scribe compiles them
110
+ over the columns of the Source: a column a row lacks is null."""
111
+ expressions = expression_attributes(candidate)
112
+ columns: dict[str, None] = {}
113
+ for row in rows:
114
+ columns.update(dict.fromkeys(row))
115
+ programs = RowExpressions(candidate.schema, columns, limits)
116
+ cells: list[dict[str, ExpressionCell]] = []
117
+ for row in rows:
118
+ found: dict[str, ExpressionCell] = {}
119
+ for attribute, expression in expressions.items():
120
+ try:
121
+ found[attribute] = ExpressionCell(
122
+ row_value(programs.evaluate(expression, row, now=now))
123
+ )
124
+ except ExprError as error:
125
+ found[attribute] = ExpressionCell(None, error.code.value, error.describe())
126
+ cells.append(found)
127
+ return cells