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,179 @@
1
+ """Response shapes of the health reports. Names are api names; no Attribute value is ever
2
+ printed except the normalized value of a duplicate on an Attribute without sensitivity tags.
3
+ For Attributes whose values the caller may not read, validation issues carry the generic
4
+ `value.invalid` code and duplicate groups are only counted (`DuplicateReport.hidden`)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from lorebase_core.model.relation import RelationBacking
14
+ from lorebase_keeper.reading.views import Served
15
+
16
+
17
+ class IssueGroup(BaseModel):
18
+ """Records failing the same rule of the current schema."""
19
+
20
+ code: str = Field(
21
+ description="Validation issue code, e.g. value.required or value.pattern; value.invalid "
22
+ "for an Attribute whose values the caller may not read."
23
+ )
24
+ attribute: str | None = Field(default=None, description="Attribute api name, when one.")
25
+ count: int
26
+ examples: list[str] = Field(
27
+ description="Keys of some of the Records; none for an Attribute whose values the "
28
+ "caller may not read."
29
+ )
30
+
31
+
32
+ class ViolationReport(BaseModel):
33
+ """Result of re-validating Records with the schema of the branch head."""
34
+
35
+ checked: int = Field(description="Records re-validated.")
36
+ sampled: bool = Field(description="Only a subset of the Kind's Records was re-validated.")
37
+ invalid_records: int = Field(description="Re-validated Records with at least one issue.")
38
+ issues: list[IssueGroup]
39
+ issues_truncated: bool = False
40
+
41
+
42
+ class DuplicateGroup(BaseModel):
43
+ """Records whose value of one Attribute is the same once case and spacing are ignored."""
44
+
45
+ attribute: str
46
+ unique: bool = Field(description="The Attribute is unique: this is a violation, not a hint.")
47
+ value: str | None = Field(
48
+ description="The normalized value; null for Attributes with sensitivity tags."
49
+ )
50
+ count: int
51
+ keys: list[str]
52
+
53
+
54
+ class HiddenDuplicates(BaseModel):
55
+ """Duplicates on an Attribute whose values the caller may not read: counted, not listed
56
+ (which Records share a value would tell that their values are equal)."""
57
+
58
+ attribute: str
59
+ unique: bool = Field(description="The Attribute is unique: this is a violation, not a hint.")
60
+ groups: int = Field(description="Groups of Records sharing a value.")
61
+ records: int = Field(description="Records in those groups.")
62
+
63
+
64
+ class DuplicateReport(BaseModel):
65
+ """Normalized exact duplicates on unique and title Attributes."""
66
+
67
+ attributes: list[str] = Field(description="Attributes checked.")
68
+ groups: list[DuplicateGroup]
69
+ total_groups: int = Field(description="Groups found, listed or not (`hidden` included).")
70
+ skipped: Literal["too_many_records", "disabled"] | None = None
71
+ hidden: list[HiddenDuplicates] = Field(
72
+ default_factory=list,
73
+ description="Attributes with duplicates whose groups are not listed: the caller is "
74
+ "not cleared for their values.",
75
+ )
76
+
77
+
78
+ class DerivedReport(BaseModel):
79
+ """Derived Attributes whose materialized value is missing, so reads cannot serve it.
80
+
81
+ Only presence is checked: values are not recomputed and compared with the stored ones
82
+ (`lorebase_store.materialize.verify` with `derived=True` does that).
83
+ """
84
+
85
+ attributes: list[str]
86
+ records_missing_value: int
87
+ stale: bool
88
+
89
+
90
+ class LastChange(BaseModel):
91
+ """The latest Revision on the branch that changed a Record of the Kind."""
92
+
93
+ revision: str
94
+ at: datetime
95
+
96
+
97
+ class LinkRef(BaseModel):
98
+ from_key: str
99
+ to_key: str
100
+
101
+
102
+ class OrphanLinks(BaseModel):
103
+ """Links, or foreign keys, whose endpoint Record does not exist."""
104
+
105
+ count: int
106
+ missing_from: int = Field(description="Links whose `from` Record is missing.")
107
+ missing_to: int = Field(description="Links whose `to` Record is missing.")
108
+ examples: list[LinkRef]
109
+
110
+
111
+ class RequiredGaps(BaseModel):
112
+ """Records of the `from` Kind without the Link a required Relation demands."""
113
+
114
+ missing: int
115
+ examples: list[str] = Field(description="Keys of some of the Records.")
116
+
117
+
118
+ class RelationHealth(BaseModel):
119
+ relation: str = Field(description="Relation api name, forward direction.")
120
+ from_kind: str
121
+ to_kind: str
122
+ backing: RelationBacking
123
+ links: int
124
+ orphan_links: OrphanLinks
125
+ required: bool
126
+ required_gaps: RequiredGaps | None = Field(
127
+ default=None, description="Present for Relations with `required_from`."
128
+ )
129
+ direction: Literal["outgoing", "incoming", "self"] | None = Field(
130
+ default=None, description="In a Kind report: the side the Kind is on."
131
+ )
132
+
133
+
134
+ class KindHealth(BaseModel):
135
+ kind: str
136
+ records: int
137
+ last_change: LastChange | None = None
138
+ violations: ViolationReport
139
+ duplicates: DuplicateReport
140
+ derived: DerivedReport | None = Field(
141
+ default=None, description="Present for Kinds with derived Attributes."
142
+ )
143
+
144
+
145
+ class HealthSummary(BaseModel):
146
+ ok: bool = Field(
147
+ description="No violations, orphan Links, required-relation gaps or duplicates of unique "
148
+ "Attributes found. Duplicate candidates on title Attributes and stale derived values "
149
+ "are reported but do not make a report not ok."
150
+ )
151
+ kinds: int
152
+ records: int
153
+ links: int
154
+ invalid_records: int
155
+ orphan_links: int
156
+ required_relation_gaps: int
157
+ duplicate_groups: int
158
+ stale_kinds: int = Field(description="Kinds with derived values missing.")
159
+ sampled_kinds: int = Field(description="Kinds re-validated on a sample only.")
160
+
161
+
162
+ class DomainHealthOut(Served):
163
+ """Health of a Domain at the head of a branch, for the Kinds the caller may read."""
164
+
165
+ domain: str
166
+ branch: str
167
+ summary: HealthSummary
168
+ kinds: list[KindHealth]
169
+ relations: list[RelationHealth]
170
+
171
+
172
+ class KindHealthOut(Served):
173
+ """Health of one Kind, with the Relations it takes part in."""
174
+
175
+ domain: str
176
+ branch: str
177
+ summary: HealthSummary
178
+ health: KindHealth
179
+ relations: list[RelationHealth]
@@ -0,0 +1,154 @@
1
+ """Assembly of the health reports from the scans.
2
+
3
+ A report covers the Kinds the caller may read (`KindScope`) and the Relations whose both ends
4
+ are such Kinds, so nothing about a Kind the caller cannot see is counted or listed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from functools import partial
12
+
13
+ from sqlalchemy import func, select
14
+ from sqlalchemy.exc import DBAPIError
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from lorebase_core.model import Attribute, Kind, Relation
18
+ from lorebase_keeper.errors import UNAVAILABLE, ProblemError
19
+ from lorebase_keeper.kindhealth import scan
20
+ from lorebase_keeper.kindhealth.config import HealthSettings
21
+ from lorebase_keeper.kindhealth.models import HealthSummary, KindHealth, RelationHealth
22
+ from lorebase_keeper.reading.points import ReadPoint
23
+ from lorebase_keeper.reading.sensitivity import Masking, generalize_issues
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Findings:
28
+ """What a scan found: Kind reports, Relation reports and the totals over them."""
29
+
30
+ kinds: list[KindHealth]
31
+ relations: list[RelationHealth]
32
+ summary: HealthSummary
33
+
34
+
35
+ def visible_kinds(point: ReadPoint) -> list[Kind]:
36
+ """The Kinds the caller may read, by api name."""
37
+ kinds = [k for k in point.schema.kinds.values() if point.scope.allows(k.api_name)]
38
+ return sorted(kinds, key=lambda k: k.api_name)
39
+
40
+
41
+ def visible_relations(point: ReadPoint, kind: Kind | None = None) -> list[Relation]:
42
+ """Relations between Kinds the caller may read; only those of `kind` when given."""
43
+ schema = point.schema
44
+ found: list[Relation] = []
45
+ for relation in schema.relations.values():
46
+ ends = [schema.kinds.get(relation.from_kind), schema.kinds.get(relation.to_kind)]
47
+ if any(end is None or not point.scope.allows(end.api_name) for end in ends):
48
+ continue
49
+ if kind is None or kind.id in (relation.from_kind, relation.to_kind):
50
+ found.append(relation)
51
+ return sorted(found, key=lambda r: r.api_name)
52
+
53
+
54
+ async def scan_branch(
55
+ session: AsyncSession,
56
+ point: ReadPoint,
57
+ settings: HealthSettings,
58
+ kinds: Sequence[Kind],
59
+ relations: Sequence[Relation],
60
+ ) -> Findings:
61
+ """Run every check for `kinds` and `relations` at the head of the point's branch."""
62
+ where = scan.Branch(point.domain.id, point.layout.branch, point.layout)
63
+ schema = point.schema
64
+ masking = point.masking
65
+ await session.execute(
66
+ select(func.set_config("statement_timeout", str(settings.statement_timeout_ms), True))
67
+ )
68
+ try:
69
+ counts = await scan.record_counts(session, where)
70
+ stored_links = await scan.link_counts(session, where)
71
+ changes = await scan.last_changes(session, where, [k.id for k in kinds])
72
+ reports: list[KindHealth] = []
73
+ for kind in kinds:
74
+ total = counts.get(kind.id, 0)
75
+ reports.append(
76
+ KindHealth(
77
+ kind=kind.api_name,
78
+ records=total,
79
+ last_change=changes.get(kind.id),
80
+ violations=await scan.validate_records(
81
+ session,
82
+ where,
83
+ schema,
84
+ kind,
85
+ total,
86
+ settings,
87
+ lambda issues: generalize_issues(issues, (schema,), masking),
88
+ partial(_hidden, masking, kind),
89
+ projected=bool(point.hidden),
90
+ ),
91
+ duplicates=await scan.find_duplicates(
92
+ session,
93
+ where,
94
+ kind,
95
+ total,
96
+ settings,
97
+ masking.sensitive,
98
+ partial(_hidden, masking, kind),
99
+ ),
100
+ derived=await scan.derived_status(session, where, kind, total),
101
+ )
102
+ )
103
+ related = [
104
+ await scan.relation_health(session, where, schema, relation, stored_links, settings)
105
+ for relation in relations
106
+ ]
107
+ except DBAPIError as error:
108
+ if scan.is_timeout(error):
109
+ raise ProblemError(
110
+ UNAVAILABLE,
111
+ "The health scan took longer than read.health.statement_timeout_ms allows. "
112
+ "Ask for a single Kind, or raise the limit.",
113
+ ) from None
114
+ raise
115
+ return Findings(reports, related, summarize(reports, related))
116
+
117
+
118
+ def _hidden(masking: Masking, kind: Kind, attribute: Attribute) -> bool:
119
+ """Whether the caller may not read values of `attribute` of `kind`."""
120
+ return masking.hidden(attribute, (kind.id,))
121
+
122
+
123
+ def summarize(kinds: Sequence[KindHealth], relations: Sequence[RelationHealth]) -> HealthSummary:
124
+ """Totals over Kind and Relation reports."""
125
+ invalid = sum(k.violations.invalid_records for k in kinds)
126
+ orphans = sum(r.orphan_links.count for r in relations)
127
+ gaps = sum(r.required_gaps.missing for r in relations if r.required_gaps is not None)
128
+ duplicates = sum(k.duplicates.total_groups for k in kinds)
129
+ unique = sum(1 for k in kinds for g in k.duplicates.groups if g.unique) + sum(
130
+ h.groups for k in kinds for h in k.duplicates.hidden if h.unique
131
+ )
132
+ return HealthSummary(
133
+ ok=not (invalid or orphans or gaps or unique),
134
+ kinds=len(kinds),
135
+ records=sum(k.records for k in kinds),
136
+ links=sum(r.links for r in relations),
137
+ invalid_records=invalid,
138
+ orphan_links=orphans,
139
+ required_relation_gaps=gaps,
140
+ duplicate_groups=duplicates,
141
+ stale_kinds=sum(1 for k in kinds if k.derived is not None and k.derived.stale),
142
+ sampled_kinds=sum(1 for k in kinds if k.violations.sampled),
143
+ )
144
+
145
+
146
+ def with_direction(relations: Sequence[RelationHealth], kind: Kind) -> list[RelationHealth]:
147
+ """The Relation reports of a Kind report, each marked with the side the Kind is on."""
148
+ out: list[RelationHealth] = []
149
+ for item in relations:
150
+ outgoing = item.from_kind == kind.api_name
151
+ incoming = item.to_kind == kind.api_name
152
+ side = "self" if outgoing and incoming else "outgoing" if outgoing else "incoming"
153
+ out.append(item.model_copy(update={"direction": side}))
154
+ return out