lorebase-core 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 (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,291 @@
1
+ """Data validation of single Records and Links against a Schema (invariant I1).
2
+
3
+ These rules need no access to other data: types, canonical form, constraints, required
4
+ Attributes, keys and Trait conformance. Rules that need existing data (uniqueness,
5
+ cardinality, referential integrity) live in ``changeset``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Protocol
12
+
13
+ from pydantic import JsonValue
14
+
15
+ from lorebase_core.model.attribute import Attribute
16
+ from lorebase_core.model.ids import is_ulid
17
+ from lorebase_core.model.kind import Kind
18
+ from lorebase_core.model.record import Link, Record, record_key
19
+ from lorebase_core.model.relation import RelationBacking
20
+ from lorebase_core.model.schema import Schema
21
+ from lorebase_core.model.vault import VAULT_KEY, looks_like_reference, vault_token
22
+ from lorebase_core.types import (
23
+ CastError,
24
+ ConstraintName,
25
+ EncodingError,
26
+ cast,
27
+ check_constraints,
28
+ encode_value,
29
+ is_canonical,
30
+ )
31
+ from lorebase_core.validate.issues import Issue, IssueCode, PathPart
32
+
33
+ CONSTRAINT_CODES: dict[ConstraintName, IssueCode] = {
34
+ ConstraintName.MIN: IssueCode.VALUE_MIN,
35
+ ConstraintName.MAX: IssueCode.VALUE_MAX,
36
+ ConstraintName.MIN_LENGTH: IssueCode.VALUE_MIN_LENGTH,
37
+ ConstraintName.MAX_LENGTH: IssueCode.VALUE_MAX_LENGTH,
38
+ ConstraintName.PATTERN: IssueCode.VALUE_PATTERN,
39
+ ConstraintName.ENUM_VALUES: IssueCode.VALUE_ENUM_VALUES,
40
+ }
41
+
42
+
43
+ def holds_reference(value: object) -> bool:
44
+ """Whether a vault reference (or anything shaped like one) is nested inside `value`, at
45
+ any depth of its objects and arrays."""
46
+ stack: list[object] = [value]
47
+ while stack:
48
+ current = stack.pop()
49
+ if isinstance(current, dict):
50
+ if VAULT_KEY in current:
51
+ return True
52
+ stack.extend(current.values())
53
+ elif isinstance(current, list):
54
+ stack.extend(current)
55
+ return False
56
+
57
+
58
+ class RecordExistence(Protocol):
59
+ """The part of ``IndexView`` needed to check Link endpoints."""
60
+
61
+ def record_exists(self, kind_id: str, key: str) -> bool: ...
62
+
63
+
64
+ def check_values(
65
+ attributes: Mapping[str, Attribute],
66
+ values: Mapping[str, JsonValue],
67
+ path: tuple[PathPart, ...],
68
+ *,
69
+ kind: str | None = None,
70
+ relation: str | None = None,
71
+ key: str | None = None,
72
+ ) -> list[Issue]:
73
+ """Type, canonical form, constraint and required checks of a value map."""
74
+ issues: list[Issue] = []
75
+
76
+ def report(code: IssueCode, attribute_id: str, message: str, **details: JsonValue) -> None:
77
+ issues.append(
78
+ Issue(
79
+ code=code,
80
+ path=(*path, "values", attribute_id),
81
+ message=message,
82
+ kind=kind,
83
+ relation=relation,
84
+ key=key,
85
+ attribute=attribute_id,
86
+ details=details,
87
+ )
88
+ )
89
+
90
+ for attribute_id, value in values.items():
91
+ attribute = attributes.get(attribute_id)
92
+ if attribute is None:
93
+ report(
94
+ IssueCode.VALUE_ATTRIBUTE_UNKNOWN, attribute_id, f"unknown attribute {attribute_id}"
95
+ )
96
+ continue
97
+ name = attribute.api_name
98
+ if attribute.is_derived:
99
+ report(
100
+ IssueCode.VALUE_DERIVED,
101
+ attribute_id,
102
+ f"{name!r} is derived; derived values are never stored",
103
+ )
104
+ continue
105
+ if looks_like_reference(value):
106
+ # The store mints references and checks they belong to the Record (13.5); the
107
+ # value behind one was validated when it was written.
108
+ if not attribute.in_vault or vault_token(value) is None:
109
+ report(
110
+ IssueCode.VALUE_VAULT_REFERENCE,
111
+ attribute_id,
112
+ f"{name!r} does not take a vault reference",
113
+ )
114
+ continue
115
+ if isinstance(value, dict | list) and holds_reference(value):
116
+ # Only a vault Attribute holds a reference, and only as its whole value: one
117
+ # nested in another value would be stored as it is and read back as a token.
118
+ report(
119
+ IssueCode.VALUE_VAULT_REFERENCE,
120
+ attribute_id,
121
+ f"{name!r} holds a vault reference inside its value",
122
+ )
123
+ continue
124
+ try:
125
+ canonical = encode_value(attribute.type, value)
126
+ except EncodingError as exc:
127
+ report(IssueCode.VALUE_TYPE, attribute_id, f"{name!r}: {exc}", reason=exc.reason.value)
128
+ continue
129
+ if canonical != value:
130
+ report(
131
+ IssueCode.VALUE_NOT_CANONICAL,
132
+ attribute_id,
133
+ f"{name!r} is not in canonical form; expected {canonical!r}",
134
+ canonical=canonical,
135
+ )
136
+ continue
137
+ for violation in check_constraints(attribute.type, attribute.constraints, canonical):
138
+ details: dict[str, JsonValue] = {"constraint": violation.constraint.value}
139
+ if violation.index is not None:
140
+ details["index"] = violation.index
141
+ report(
142
+ CONSTRAINT_CODES[violation.constraint],
143
+ attribute_id,
144
+ f"{name!r}: {violation.message}",
145
+ **details,
146
+ )
147
+
148
+ for attribute in attributes.values():
149
+ if attribute.required and not attribute.is_derived and values.get(attribute.id) is None:
150
+ report(IssueCode.VALUE_REQUIRED, attribute.id, f"{attribute.api_name!r} is required")
151
+ return issues
152
+
153
+
154
+ def _check_key(kind: Kind, record: Record, path: tuple[PathPart, ...]) -> list[Issue]:
155
+ if not kind.has_natural_key:
156
+ if is_ulid(record.key):
157
+ return []
158
+ return [
159
+ Issue(
160
+ code=IssueCode.RECORD_KEY_INVALID,
161
+ path=(*path, "key"),
162
+ message=f"Kind {kind.api_name!r} has no primary key; Record keys are minted ULIDs",
163
+ kind=kind.id,
164
+ key=record.key,
165
+ )
166
+ ]
167
+ for attribute_id in kind.primary_key:
168
+ attribute = kind.attributes.get(attribute_id)
169
+ value = record.values.get(attribute_id)
170
+ if attribute is None or value is None or not is_canonical(attribute.type, value):
171
+ return []
172
+ expected = record_key(kind, record.values)
173
+ if expected is None or expected == record.key:
174
+ return []
175
+ return [
176
+ Issue(
177
+ code=IssueCode.RECORD_KEY_MISMATCH,
178
+ path=(*path, "key"),
179
+ message=f"key {record.key!r} does not match the primary key values ({expected!r})",
180
+ kind=kind.id,
181
+ key=record.key,
182
+ details={"expected": expected},
183
+ )
184
+ ]
185
+
186
+
187
+ def _check_traits(
188
+ schema: Schema, kind: Kind, record: Record, path: tuple[PathPart, ...]
189
+ ) -> list[Issue]:
190
+ issues: list[Issue] = []
191
+ for implementation in kind.traits:
192
+ trait = schema.traits.get(implementation.trait_id)
193
+ if trait is None:
194
+ continue
195
+ for trait_attribute in trait.attributes.values():
196
+ attribute_id = implementation.attribute_map.get(trait_attribute.id)
197
+ attribute = kind.attributes.get(attribute_id) if attribute_id is not None else None
198
+ label = f"{trait.api_name}.{trait_attribute.api_name}"
199
+ if attribute is None:
200
+ continue
201
+ value = record.values.get(attribute.id)
202
+ message: str | None = None
203
+ if value is None:
204
+ if trait_attribute.required:
205
+ message = f"{label} is required by Trait {trait.api_name!r}"
206
+ elif is_canonical(attribute.type, value):
207
+ try:
208
+ cast(attribute.type, trait_attribute.type, value)
209
+ except CastError as exc:
210
+ message = f"{label}: {exc.message}"
211
+ if message is not None:
212
+ issues.append(
213
+ Issue(
214
+ code=IssueCode.RECORD_TRAIT_CONFORMANCE,
215
+ path=(*path, "values", attribute.id),
216
+ message=message,
217
+ kind=kind.id,
218
+ key=record.key,
219
+ attribute=attribute.id,
220
+ details={"trait": trait.id, "trait_attribute": trait_attribute.id},
221
+ )
222
+ )
223
+ return issues
224
+
225
+
226
+ def validate_record(
227
+ schema: Schema, record: Record, *, path: tuple[PathPart, ...] = ()
228
+ ) -> list[Issue]:
229
+ """Validate one Record against its Kind: key, types, constraints, required, Traits."""
230
+ kind = schema.kinds.get(record.kind)
231
+ if kind is None:
232
+ return [
233
+ Issue(
234
+ code=IssueCode.RECORD_KIND_UNKNOWN,
235
+ path=(*path, "kind"),
236
+ message=f"unknown Kind {record.kind}",
237
+ kind=record.kind,
238
+ key=record.key,
239
+ )
240
+ ]
241
+ issues = _check_key(kind, record, path)
242
+ issues.extend(check_values(kind.attributes, record.values, path, kind=kind.id, key=record.key))
243
+ issues.extend(_check_traits(schema, kind, record, path))
244
+ return issues
245
+
246
+
247
+ def validate_link(
248
+ schema: Schema,
249
+ link: Link,
250
+ index_view: RecordExistence | None = None,
251
+ *,
252
+ path: tuple[PathPart, ...] = (),
253
+ ) -> list[Issue]:
254
+ """Validate one Link against its Relation; with ``index_view``, also check its endpoints."""
255
+ relation = schema.relations.get(link.relation)
256
+ if relation is None:
257
+ return [
258
+ Issue(
259
+ code=IssueCode.LINK_RELATION_UNKNOWN,
260
+ path=(*path, "relation"),
261
+ message=f"unknown Relation {link.relation}",
262
+ relation=link.relation,
263
+ )
264
+ ]
265
+ if relation.backing is RelationBacking.FOREIGN_KEY:
266
+ return [
267
+ Issue(
268
+ code=IssueCode.LINK_FOREIGN_KEY_BACKED,
269
+ path=(*path, "relation"),
270
+ message=f"Links of {relation.api_name!r} derive from a foreign key attribute",
271
+ relation=relation.id,
272
+ )
273
+ ]
274
+ issues = check_values(relation.attributes, link.values, path, relation=relation.id)
275
+ if index_view is not None:
276
+ for end, kind_id, key in (
277
+ ("from", relation.from_kind, link.from_key),
278
+ ("to", relation.to_kind, link.to_key),
279
+ ):
280
+ if not index_view.record_exists(kind_id, key):
281
+ issues.append(
282
+ Issue(
283
+ code=IssueCode.LINK_ENDPOINT_MISSING,
284
+ path=(*path, end),
285
+ message=f"{end} Record {key!r} does not exist",
286
+ kind=kind_id,
287
+ relation=relation.id,
288
+ key=key,
289
+ )
290
+ )
291
+ return issues
@@ -0,0 +1,123 @@
1
+ """Validation issues: structured findings of schema and data validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from enum import StrEnum
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
9
+
10
+
11
+ class Severity(StrEnum):
12
+ ERROR = "error"
13
+ WARNING = "warning"
14
+
15
+
16
+ class IssueCode(StrEnum):
17
+ """Stable issue codes. ``schema.*`` come from static validation, the others from data."""
18
+
19
+ # Schema: names and ids
20
+ SCHEMA_API_NAME_INVALID = "schema.api_name_invalid"
21
+ SCHEMA_API_NAME_DUPLICATE = "schema.api_name_duplicate"
22
+ SCHEMA_API_NAME_RESERVED = "schema.api_name_reserved"
23
+ SCHEMA_ID_DUPLICATE = "schema.id_duplicate"
24
+ SCHEMA_DESCRIPTION_MISSING = "schema.description_missing"
25
+ # Schema: Kinds and Attributes
26
+ SCHEMA_PRIMARY_KEY_UNKNOWN = "schema.primary_key_unknown"
27
+ SCHEMA_PRIMARY_KEY_INVALID = "schema.primary_key_invalid"
28
+ SCHEMA_TITLE_ATTRIBUTE_MISSING = "schema.title_attribute_missing"
29
+ SCHEMA_TITLE_ATTRIBUTE_UNKNOWN = "schema.title_attribute_unknown"
30
+ SCHEMA_TYPE_REFERENCE_UNKNOWN = "schema.type_reference_unknown"
31
+ SCHEMA_CONSTRAINT_INVALID = "schema.constraint_invalid"
32
+ SCHEMA_DEFAULT_INVALID = "schema.default_invalid"
33
+ SCHEMA_DEFAULT_CONFLICT = "schema.default_conflict"
34
+ SCHEMA_ATTRIBUTE_FLAG_INVALID = "schema.attribute_flag_invalid"
35
+ SCHEMA_AUTHORITY_UNKNOWN = "schema.authority_unknown"
36
+ SCHEMA_EMBEDDING_TEMPLATE_INVALID = "schema.embedding_template_invalid"
37
+ SCHEMA_VAULT_INVALID = "schema.vault_invalid"
38
+ SCHEMA_SENSITIVE_ATTRIBUTE = "schema.sensitive_attribute"
39
+ SCHEMA_SENSITIVITY_INHERITED = "schema.sensitivity_inherited"
40
+ # Schema: Relations
41
+ SCHEMA_RELATION_ENDPOINT_UNKNOWN = "schema.relation_endpoint_unknown"
42
+ SCHEMA_FOREIGN_KEY_INVALID = "schema.foreign_key_invalid"
43
+ # Schema: Traits
44
+ SCHEMA_TRAIT_UNKNOWN = "schema.trait_unknown"
45
+ SCHEMA_TRAIT_DUPLICATE = "schema.trait_duplicate"
46
+ SCHEMA_TRAIT_MAPPING_MISSING = "schema.trait_mapping_missing"
47
+ SCHEMA_TRAIT_MAPPING_INVALID = "schema.trait_mapping_invalid"
48
+ SCHEMA_TRAIT_MAPPING_TYPE = "schema.trait_mapping_type"
49
+ SCHEMA_TRAIT_MAPPING_REQUIRED = "schema.trait_mapping_required"
50
+ # Schema: Operations, Derivations, Sources
51
+ SCHEMA_EXPRESSIONS_DISABLED = "schema.expressions_disabled"
52
+ SCHEMA_EXPRESSION_INVALID = "schema.expression_invalid"
53
+ SCHEMA_OPERATION_REFERENCE_UNKNOWN = "schema.operation_reference_unknown"
54
+ SCHEMA_OPERATION_INVALID = "schema.operation_invalid"
55
+ SCHEMA_DERIVATION_REFERENCE_UNKNOWN = "schema.derivation_reference_unknown"
56
+ SCHEMA_DERIVATION_INVALID = "schema.derivation_invalid"
57
+ SCHEMA_DERIVATION_CYCLE = "schema.derivation_cycle"
58
+ SCHEMA_SOURCE_REFERENCE_UNKNOWN = "schema.source_reference_unknown"
59
+ SCHEMA_SOURCE_INVALID = "schema.source_invalid"
60
+
61
+ # Data: values (Records and Links)
62
+ VALUE_ATTRIBUTE_UNKNOWN = "value.attribute_unknown"
63
+ VALUE_DERIVED = "value.derived"
64
+ VALUE_TYPE = "value.type"
65
+ VALUE_NOT_CANONICAL = "value.not_canonical"
66
+ VALUE_REQUIRED = "value.required"
67
+ VALUE_MIN = "value.min"
68
+ VALUE_MAX = "value.max"
69
+ VALUE_MIN_LENGTH = "value.min_length"
70
+ VALUE_MAX_LENGTH = "value.max_length"
71
+ VALUE_PATTERN = "value.pattern"
72
+ VALUE_ENUM_VALUES = "value.enum_values"
73
+ VALUE_UNIQUE = "value.unique"
74
+ VALUE_REFERENCE_MISSING = "value.reference_missing"
75
+ VALUE_VAULT_REFERENCE = "value.vault_reference"
76
+ VALUE_VAULT_UNAVAILABLE = "value.vault_unavailable"
77
+ VALUE_VAULT_ERASED = "value.vault_erased"
78
+ VALUE_INVALID = "value.invalid"
79
+ """A value of a sensitive Attribute is invalid; how is not told to callers without the
80
+ clearance (Keeper replaces the specific code with this one)."""
81
+ # Data: Records
82
+ RECORD_KIND_UNKNOWN = "record.kind_unknown"
83
+ RECORD_KEY_INVALID = "record.key_invalid"
84
+ RECORD_KEY_MISMATCH = "record.key_mismatch"
85
+ RECORD_TRAIT_CONFORMANCE = "record.trait_conformance"
86
+ RECORD_NOT_FOUND = "record.not_found"
87
+ RECORD_DELETE_RESTRICTED = "record.delete_restricted"
88
+ RECORD_REQUIRED_RELATION = "record.required_relation"
89
+ # Data: Links
90
+ LINK_RELATION_UNKNOWN = "link.relation_unknown"
91
+ LINK_FOREIGN_KEY_BACKED = "link.foreign_key_backed"
92
+ LINK_ENDPOINT_MISSING = "link.endpoint_missing"
93
+ LINK_CARDINALITY = "link.cardinality"
94
+ LINK_NOT_FOUND = "link.not_found"
95
+ # Data: change sets
96
+ CHANGESET_DUPLICATE_CHANGE = "changeset.duplicate_change"
97
+
98
+
99
+ PathPart = str | int
100
+
101
+
102
+ class Issue(BaseModel):
103
+ """One finding. ``path`` locates it inside the schema or the change set."""
104
+
105
+ model_config = ConfigDict(frozen=True, extra="forbid")
106
+
107
+ code: IssueCode
108
+ severity: Severity = Severity.ERROR
109
+ path: tuple[PathPart, ...] = ()
110
+ message: str
111
+ kind: str | None = None
112
+ relation: str | None = None
113
+ key: str | None = None
114
+ attribute: str | None = None
115
+ details: dict[str, JsonValue] = Field(default_factory=dict)
116
+
117
+ @property
118
+ def is_error(self) -> bool:
119
+ return self.severity is Severity.ERROR
120
+
121
+
122
+ def has_errors(issues: Iterable[Issue]) -> bool:
123
+ return any(issue.is_error for issue in issues)