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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- 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
|