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,219 @@
1
+ """The ``Schema`` aggregate: every schema element of one Revision, with lookup helpers.
2
+
3
+ Elements are keyed by id. Api name lookups return ``None`` for unknown names; when a schema
4
+ is invalid (duplicate api names) they return one of the candidates, and ``validate_schema``
5
+ reports the duplicates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator, Mapping
11
+ from functools import cached_property
12
+ from typing import Any, Self
13
+
14
+ from pydantic import Field, model_validator
15
+
16
+ from lorebase_core.model.attribute import Attribute
17
+ from lorebase_core.model.base import Model, SchemaElement
18
+ from lorebase_core.model.derivation import Derivation
19
+ from lorebase_core.model.domain import Domain
20
+ from lorebase_core.model.ids import (
21
+ AttributeId,
22
+ DerivationId,
23
+ KindId,
24
+ OperationId,
25
+ RelationId,
26
+ SourceId,
27
+ TraitId,
28
+ )
29
+ from lorebase_core.model.kind import Kind
30
+ from lorebase_core.model.operation import Operation
31
+ from lorebase_core.model.paths import PathRelation, path_relations
32
+ from lorebase_core.model.record import parse_ref
33
+ from lorebase_core.model.relation import LinkDirection, Relation
34
+ from lorebase_core.model.source import Source
35
+ from lorebase_core.model.trait import Trait
36
+
37
+ #: Names of the lazily built lookup indexes (cached properties).
38
+ _INDEXES = (
39
+ "_kinds_by_name",
40
+ "_traits_by_name",
41
+ "_relations_by_name",
42
+ "_operations_by_name",
43
+ "_derivations_by_name",
44
+ "_sources_by_name",
45
+ "_paths",
46
+ "_paths_by_name",
47
+ )
48
+
49
+
50
+ class Schema(Model):
51
+ domain: Domain
52
+ kinds: dict[KindId, Kind] = Field(default_factory=dict)
53
+ relations: dict[RelationId, Relation] = Field(default_factory=dict)
54
+ traits: dict[TraitId, Trait] = Field(default_factory=dict)
55
+ operations: dict[OperationId, Operation] = Field(default_factory=dict)
56
+ derivations: dict[DerivationId, Derivation] = Field(default_factory=dict)
57
+ sources: dict[SourceId, Source] = Field(default_factory=dict)
58
+
59
+ @model_validator(mode="after")
60
+ def _check_keys(self) -> Self:
61
+ collections: tuple[tuple[str, dict[str, SchemaElement]], ...] = (
62
+ ("kinds", dict(self.kinds)),
63
+ ("relations", dict(self.relations)),
64
+ ("traits", dict(self.traits)),
65
+ ("operations", dict(self.operations)),
66
+ ("derivations", dict(self.derivations)),
67
+ ("sources", dict(self.sources)),
68
+ )
69
+ for name, elements in collections:
70
+ for key, element in elements.items():
71
+ if key != element.id:
72
+ raise ValueError(f"{name}: key {key} does not match element id {element.id}")
73
+ return self
74
+
75
+ def model_copy(self, *, update: Mapping[str, Any] | None = None, deep: bool = False) -> Self:
76
+ """Copy the Schema; lookup indexes are rebuilt for the copy."""
77
+ copied = super().model_copy(update=update, deep=deep)
78
+ for name in _INDEXES:
79
+ copied.__dict__.pop(name, None)
80
+ return copied
81
+
82
+ # Iteration -----------------------------------------------------------------
83
+
84
+ def elements(self) -> Iterator[SchemaElement]:
85
+ """Every schema element except the Domain."""
86
+ yield from self.kinds.values()
87
+ yield from self.relations.values()
88
+ yield from self.traits.values()
89
+ yield from self.operations.values()
90
+ yield from self.derivations.values()
91
+ yield from self.sources.values()
92
+
93
+ # Api name lookups ------------------------------------------------------------
94
+
95
+ @cached_property
96
+ def _kinds_by_name(self) -> dict[str, Kind]:
97
+ return {kind.api_name: kind for kind in self.kinds.values()}
98
+
99
+ @cached_property
100
+ def _traits_by_name(self) -> dict[str, Trait]:
101
+ return {trait.api_name: trait for trait in self.traits.values()}
102
+
103
+ @cached_property
104
+ def _relations_by_name(self) -> dict[str, tuple[Relation, LinkDirection]]:
105
+ index: dict[str, tuple[Relation, LinkDirection]] = {}
106
+ for relation in self.relations.values():
107
+ index[relation.inverse_api_name] = (relation, LinkDirection.INVERSE)
108
+ index[relation.api_name] = (relation, LinkDirection.FORWARD)
109
+ return index
110
+
111
+ @cached_property
112
+ def _operations_by_name(self) -> dict[str, Operation]:
113
+ return {operation.api_name: operation for operation in self.operations.values()}
114
+
115
+ @cached_property
116
+ def _derivations_by_name(self) -> dict[str, Derivation]:
117
+ return {derivation.api_name: derivation for derivation in self.derivations.values()}
118
+
119
+ @cached_property
120
+ def _sources_by_name(self) -> dict[str, Source]:
121
+ return {source.api_name: source for source in self.sources.values()}
122
+
123
+ @cached_property
124
+ def _paths(self) -> dict[str, PathRelation]:
125
+ return path_relations(self.derivations.values(), self.relations)
126
+
127
+ @cached_property
128
+ def _paths_by_name(self) -> dict[str, tuple[PathRelation, LinkDirection]]:
129
+ index: dict[str, tuple[PathRelation, LinkDirection]] = {}
130
+ for path in self._paths.values():
131
+ inverse = path.relation.inverse_api_name
132
+ if inverse:
133
+ index[inverse] = (path, LinkDirection.INVERSE)
134
+ index[path.relation.api_name] = (path, LinkDirection.FORWARD)
135
+ return index
136
+
137
+ def kind_by_api_name(self, api_name: str) -> Kind | None:
138
+ return self._kinds_by_name.get(api_name)
139
+
140
+ def trait_by_api_name(self, api_name: str) -> Trait | None:
141
+ return self._traits_by_name.get(api_name)
142
+
143
+ def relation_by_api_name(self, api_name: str) -> tuple[Relation, LinkDirection] | None:
144
+ """Resolve a forward or inverse Relation name to the Relation and the direction."""
145
+ return self._relations_by_name.get(api_name)
146
+
147
+ def path_by_api_name(self, api_name: str) -> tuple[PathRelation, LinkDirection] | None:
148
+ """Resolve the forward or inverse name of a path Derivation (a virtual Relation)."""
149
+ return self._paths_by_name.get(api_name)
150
+
151
+ def path_relations(self) -> dict[str, PathRelation]:
152
+ """The valid path Derivations as virtual Relations, by Derivation id."""
153
+ return self._paths
154
+
155
+ def link_by_api_name(self, api_name: str) -> tuple[Relation, LinkDirection] | None:
156
+ """Resolve a Relation name or a path Derivation name (its virtual Relation, whose id
157
+ is the Derivation id) to the Relation and the direction."""
158
+ found = self._relations_by_name.get(api_name)
159
+ if found is not None:
160
+ return found
161
+ path = self._paths_by_name.get(api_name)
162
+ return None if path is None else (path[0].relation, path[1])
163
+
164
+ def link_relation(self, relation_id: str) -> Relation | None:
165
+ """A Relation by id, or the virtual Relation of a valid path Derivation."""
166
+ relation = self.relations.get(relation_id)
167
+ if relation is not None:
168
+ return relation
169
+ path = self._paths.get(relation_id)
170
+ return None if path is None else path.relation
171
+
172
+ def operation_by_api_name(self, api_name: str) -> Operation | None:
173
+ return self._operations_by_name.get(api_name)
174
+
175
+ def derivation_by_api_name(self, api_name: str) -> Derivation | None:
176
+ return self._derivations_by_name.get(api_name)
177
+
178
+ def source_by_api_name(self, api_name: str) -> Source | None:
179
+ return self._sources_by_name.get(api_name)
180
+
181
+ def attribute_by_api_name(self, kind_id: str, api_name: str) -> Attribute | None:
182
+ kind = self.kinds.get(kind_id)
183
+ return kind.attribute_by_api_name(api_name) if kind is not None else None
184
+
185
+ # Structural lookups ----------------------------------------------------------
186
+
187
+ def attribute(self, kind_id: str, attribute_id: str) -> Attribute | None:
188
+ kind = self.kinds.get(kind_id)
189
+ return kind.attributes.get(attribute_id) if kind is not None else None
190
+
191
+ def relations_of(self, kind_id: str) -> list[Relation]:
192
+ """Relations with ``kind_id`` at either end."""
193
+ return [r for r in self.relations.values() if kind_id in (r.from_kind, r.to_kind)]
194
+
195
+ def kinds_implementing(self, trait_id: str) -> list[Kind]:
196
+ return [k for k in self.kinds.values() if any(t.trait_id == trait_id for t in k.traits)]
197
+
198
+ def resolve_ref(self, ref: str) -> tuple[KindId, str]:
199
+ """Resolve ``Supplier/S-1001`` to ``(kind_id, key)``.
200
+
201
+ Raises ``KeyError`` for unknown Kinds.
202
+ """
203
+ kind_api_name, key = parse_ref(ref)
204
+ kind = self.kind_by_api_name(kind_api_name)
205
+ if kind is None:
206
+ raise KeyError(f"unknown Kind {kind_api_name!r}")
207
+ return kind.id, key
208
+
209
+ def attribute_owner(self, attribute_id: AttributeId) -> Kind | Relation | Trait | None:
210
+ """The Kind, Relation or Trait declaring ``attribute_id``."""
211
+ owners: list[Kind | Relation | Trait] = [
212
+ *self.kinds.values(),
213
+ *self.relations.values(),
214
+ *self.traits.values(),
215
+ ]
216
+ for owner in owners:
217
+ if attribute_id in owner.attributes:
218
+ return owner
219
+ return None
@@ -0,0 +1,204 @@
1
+ """Source (design 5.9): external backing data mapped into a Kind. Model only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import ClassVar, Self
7
+
8
+ from pydantic import Field, JsonValue, model_validator
9
+
10
+ from lorebase_core.model.base import ElementStatus, Model, SchemaElement
11
+ from lorebase_core.model.ids import AttributeId, KindId, RelationId, SourceId
12
+
13
+ #: Prefix of secret references; connections are never literals (design 5.9, 13.4).
14
+ SECRET_REFERENCE_PREFIX = "secret://"
15
+
16
+
17
+ class SourceType(StrEnum):
18
+ POSTGRES_TABLE = "postgres_table"
19
+ POSTGRES_QUERY = "postgres_query"
20
+ MYSQL_TABLE = "mysql_table"
21
+ MYSQL_QUERY = "mysql_query"
22
+ MSSQL_TABLE = "mssql_table"
23
+ MSSQL_QUERY = "mssql_query"
24
+ CSV = "csv"
25
+ JSON = "json"
26
+ JSONL = "jsonl"
27
+ PARQUET = "parquet"
28
+ HTTP_JSON = "http_json"
29
+
30
+
31
+ class SyncMode(StrEnum):
32
+ SNAPSHOT = "snapshot"
33
+ INCREMENTAL = "incremental"
34
+
35
+
36
+ class MergePolicy(StrEnum):
37
+ AUTO_WHEN_CLEAN = "auto_when_clean"
38
+ PROPOSAL = "proposal"
39
+ AUTO_WITH_AUTHORITY = "auto_with_authority"
40
+
41
+
42
+ class TransformName(StrEnum):
43
+ CAST = "cast"
44
+ TRIM = "trim"
45
+ LOWER = "lower"
46
+ UPPER = "upper"
47
+ PARSE_DATE = "parse_date"
48
+ CONCAT = "concat"
49
+ CONSTANT = "constant"
50
+ MAP = "map"
51
+ NULL_IF = "null_if"
52
+
53
+
54
+ class Transform(Model):
55
+ name: TransformName
56
+ args: dict[str, JsonValue] = Field(default_factory=dict)
57
+
58
+
59
+ class AttributeMapping(Model):
60
+ """Maps one column or one expression, through transforms, to an Attribute."""
61
+
62
+ column: str | None = None
63
+ expression: str | None = None
64
+ transforms: tuple[Transform, ...] = ()
65
+
66
+ @model_validator(mode="after")
67
+ def _check_input(self) -> Self:
68
+ if (self.column is None) == (self.expression is None):
69
+ raise ValueError("attribute mapping needs exactly one of column or expression")
70
+ return self
71
+
72
+
73
+ class RelationMapping(Model):
74
+ """Foreign key columns of the Source row that identify the ``to`` Record of ``relation``."""
75
+
76
+ relation: RelationId
77
+ columns: tuple[str, ...] = Field(min_length=1)
78
+
79
+
80
+ class TargetBranchPolicy(Model):
81
+ branch_template: str = "source/{source}"
82
+ merge_policy: MergePolicy = MergePolicy.PROPOSAL
83
+
84
+
85
+ class PaginationType(StrEnum):
86
+ NONE = "none"
87
+ CURSOR = "cursor"
88
+ PAGE = "page"
89
+ OFFSET = "offset"
90
+ LINK = "link"
91
+
92
+
93
+ class HttpPagination(Model):
94
+ """How an ``http_json`` Source asks for the next page.
95
+
96
+ ``cursor`` sends the value found at ``cursor_path`` of a response as ``cursor_param`` of the
97
+ next request; ``page`` counts ``page_param`` up from ``start_page``; ``offset`` moves
98
+ ``offset_param`` by ``page_size``; ``link`` follows the ``rel="next"`` URL of the ``Link``
99
+ header. ``size_param`` (with ``page_size``) asks for the page size.
100
+ """
101
+
102
+ type: PaginationType = PaginationType.NONE
103
+ cursor_path: str | None = Field(default=None, min_length=1)
104
+ cursor_param: str = Field(default="cursor", min_length=1)
105
+ page_param: str = Field(default="page", min_length=1)
106
+ start_page: int = Field(default=1, ge=0)
107
+ offset_param: str = Field(default="offset", min_length=1)
108
+ size_param: str | None = Field(default=None, min_length=1)
109
+ page_size: int | None = Field(default=None, ge=1)
110
+
111
+ @model_validator(mode="after")
112
+ def _check_type(self) -> Self:
113
+ if self.type is PaginationType.CURSOR and self.cursor_path is None:
114
+ raise ValueError("cursor pagination needs cursor_path")
115
+ if self.type is PaginationType.OFFSET and self.page_size is None:
116
+ raise ValueError("offset pagination needs page_size")
117
+ if self.size_param is not None and self.page_size is None:
118
+ raise ValueError("size_param needs page_size")
119
+ return self
120
+
121
+
122
+ #: Request headers an ``http_json`` Source may not set: Scribe owns the target and the framing.
123
+ RESERVED_HEADERS = frozenset(
124
+ {"host", "content-length", "transfer-encoding", "connection", "te", "upgrade"}
125
+ )
126
+ #: Header names containing one of these carry credentials: their values must be secret
127
+ #: references, so no credential is ever stored in a schema.
128
+ CREDENTIAL_HEADER_HINTS = (
129
+ "auth",
130
+ "token",
131
+ "secret",
132
+ "key",
133
+ "password",
134
+ "cookie",
135
+ "session",
136
+ "signature",
137
+ )
138
+
139
+
140
+ def credential_header(name: str) -> bool:
141
+ """Whether the value of header ``name`` must be a secret reference."""
142
+ lowered = name.lower()
143
+ return any(hint in lowered for hint in CREDENTIAL_HEADER_HINTS)
144
+
145
+
146
+ class HttpOptions(Model):
147
+ """Requests of an ``http_json`` Source (the URL is the value of its ``connection``)."""
148
+
149
+ #: Header values; values of credential headers (``credential_header``) must be secret
150
+ #: references, resolved by Scribe like the connection.
151
+ headers: dict[str, str] = Field(default_factory=dict)
152
+ pagination: HttpPagination = Field(default_factory=HttpPagination)
153
+ #: Query parameter carrying the watermark of the previous run on incremental reads, so the
154
+ #: server can filter; rows are filtered by the watermark column either way.
155
+ since_param: str | None = Field(default=None, min_length=1)
156
+ #: Pages read at most by one run, below ``scribe.sources.http_json.max_pages``.
157
+ max_pages: int | None = Field(default=None, ge=1)
158
+
159
+ @model_validator(mode="after")
160
+ def _check_headers(self) -> Self:
161
+ for name, value in self.headers.items():
162
+ if not name.strip() or name.lower() in RESERVED_HEADERS:
163
+ raise ValueError(f"header {name!r} cannot be set by a Source")
164
+ if credential_header(name) and not value.startswith(SECRET_REFERENCE_PREFIX):
165
+ raise ValueError(
166
+ f"header {name!r} carries credentials: its value must be a secret "
167
+ f"reference ({SECRET_REFERENCE_PREFIX}...), never a literal"
168
+ )
169
+ return self
170
+
171
+
172
+ class Source(SchemaElement):
173
+ object_type: ClassVar[str] = "schema.source"
174
+
175
+ id: SourceId
176
+ type: SourceType
177
+ connection: str
178
+ #: Table of a ``postgres_table``, ``mysql_table`` or ``mssql_table`` Source, optionally
179
+ #: schema-qualified (``erp.orders``); ``None`` reads the table named by the last segment
180
+ #: of the api name.
181
+ table: str | None = None
182
+ #: Read-only ``SELECT`` of a ``postgres_query``, ``mysql_query`` or ``mssql_query`` Source.
183
+ query: str | None = None
184
+ #: ``json`` and ``http_json``: dotted path of the array of records in the document
185
+ #: (``data.items``); ``None``: the document is the array.
186
+ records_path: str | None = Field(default=None, min_length=1)
187
+ #: ``json``, ``jsonl``, ``http_json`` and ``parquet``: nested objects become columns named
188
+ #: ``parent<separator>child``; ``None`` keeps them as JSON values.
189
+ flatten_separator: str | None = Field(default=None, min_length=1)
190
+ #: Requests of an ``http_json`` Source.
191
+ http: HttpOptions | None = None
192
+ target_kind: KindId
193
+ #: Source columns producing the primary key, in primary key order.
194
+ key_mapping: tuple[str, ...] = ()
195
+ attribute_mapping: dict[AttributeId, AttributeMapping] = Field(default_factory=dict)
196
+ relation_mapping: tuple[RelationMapping, ...] = ()
197
+ scope_filter: str | None = None
198
+ sync_mode: SyncMode = SyncMode.SNAPSHOT
199
+ watermark_column: str | None = None
200
+ tombstone_column: str | None = None
201
+ #: Cron expression; ``None`` means manual runs only.
202
+ schedule: str | None = None
203
+ target_branch_policy: TargetBranchPolicy = Field(default_factory=TargetBranchPolicy)
204
+ status: ElementStatus = ElementStatus.ACTIVE
@@ -0,0 +1,37 @@
1
+ """Trait (design 5.6): a shared set of Attributes that Kinds implement by mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar, Self
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from lorebase_core.model.base import ElementStatus, Model, SchemaElement
10
+ from lorebase_core.model.ids import AttributeId, TraitId
11
+ from lorebase_core.types import TypeSpec
12
+
13
+
14
+ class TraitAttribute(Model):
15
+ id: AttributeId
16
+ api_name: str
17
+ display_name: str = ""
18
+ description: str = ""
19
+ type: TypeSpec
20
+ required: bool = False
21
+
22
+
23
+ class Trait(SchemaElement):
24
+ object_type: ClassVar[str] = "schema.trait"
25
+
26
+ id: TraitId
27
+ attributes: dict[AttributeId, TraitAttribute] = Field(default_factory=dict)
28
+ status: ElementStatus = ElementStatus.ACTIVE
29
+
30
+ @model_validator(mode="after")
31
+ def _check_keys(self) -> Self:
32
+ for key, attribute in self.attributes.items():
33
+ if key != attribute.id:
34
+ raise ValueError(
35
+ f"Trait {self.id}: attribute map key {key} does not match id {attribute.id}"
36
+ )
37
+ return self
@@ -0,0 +1,43 @@
1
+ """Vault references (design 6.1, 13.5).
2
+
3
+ The value of an Attribute stored in the vault is replaced, in Record objects, by a reference
4
+ ``{"$vault": "<token>"}``. Objects, hashes, diffs and merges see the reference only; the value
5
+ lives encrypted in the store's ``vault`` table, and erasing it leaves every Revision intact
6
+ while reads of the reference answer that the value was erased.
7
+
8
+ Tokens are opaque here: the store derives them (``lorebase_store.vault``) so that the same
9
+ value of the same Attribute of the same Record gives the same token, which keeps unchanged
10
+ values unchanged across writes, branches and merges.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from pydantic import JsonValue
18
+
19
+ VAULT_KEY = "$vault"
20
+ """The only member of a vault reference."""
21
+
22
+ TOKEN_PREFIX = "vlt_"
23
+ TOKEN_PATTERN = re.compile(r"vlt_[0-9a-f]{32}")
24
+ """Tokens minted by the store: the prefix and 128 bits in lowercase hex."""
25
+
26
+
27
+ def vault_reference(token: str) -> dict[str, JsonValue]:
28
+ """The canonical value that stands for a vaulted value."""
29
+ return {VAULT_KEY: token}
30
+
31
+
32
+ def vault_token(value: object) -> str | None:
33
+ """The token of a well-formed vault reference; None for any other value."""
34
+ if isinstance(value, dict) and len(value) == 1:
35
+ token = value.get(VAULT_KEY)
36
+ if isinstance(token, str) and TOKEN_PATTERN.fullmatch(token):
37
+ return token
38
+ return None
39
+
40
+
41
+ def looks_like_reference(value: object) -> bool:
42
+ """Whether `value` is shaped like a vault reference, well formed or not."""
43
+ return isinstance(value, dict) and VAULT_KEY in value