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,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
|