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,81 @@
1
+ """Per-table analysis: normalized names, column analyses and the key choice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from lorebase_core.infer.columns import ColumnAnalysis, analyze_column
8
+ from lorebase_core.infer.keys import KeyChoice, choose_key, is_minted
9
+ from lorebase_core.infer.names import normalize, singular, strip_prefixes, strip_suffix
10
+ from lorebase_core.infer.options import InferenceOptions
11
+ from lorebase_core.infer.profile import DeclaredForeignKey, TableProfile
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TableAnalysis:
16
+ """What inference knows about one table."""
17
+
18
+ name: str
19
+ snake: str
20
+ singular: str
21
+ columns: tuple[ColumnAnalysis, ...]
22
+ rows: int
23
+ values_read: int
24
+ key: KeyChoice | None
25
+ foreign_keys: tuple[DeclaredForeignKey, ...]
26
+ comment: str | None
27
+
28
+ @property
29
+ def sampled(self) -> bool:
30
+ return self.rows > self.values_read
31
+
32
+ @property
33
+ def key_column(self) -> ColumnAnalysis | None:
34
+ """The single key column (also for minted keys), or ``None``."""
35
+ if self.key is None or self.key.single is None:
36
+ return None
37
+ return self.columns[self.key.single]
38
+
39
+ def column(self, name: str) -> ColumnAnalysis | None:
40
+ return next((c for c in self.columns if c.name == name), None)
41
+
42
+
43
+ def analyze_table(profile: TableProfile, options: InferenceOptions) -> TableAnalysis:
44
+ """Analyze every column of ``profile`` and choose its key."""
45
+ snake = strip_prefixes(normalize(profile.name), options.strip_table_prefixes)
46
+ single = singular(snake) if options.singular_kind_names else snake
47
+ columns = tuple(
48
+ analyze_column(column, index, options) for index, column in enumerate(profile.columns)
49
+ )
50
+ values_read = max((c.count for c in columns), default=0)
51
+ rows = max(profile.row_count or 0, values_read)
52
+ sampled = rows > values_read
53
+ reference_like = frozenset(
54
+ c.index for c in columns if strip_suffix(c.snake, options.foreign_key_suffixes) is not None
55
+ )
56
+ names = list(dict.fromkeys((snake, single)))
57
+ key = choose_key(
58
+ names, columns, profile.primary_key, options, sampled=sampled, reference_like=reference_like
59
+ )
60
+ if key is not None and not key.declared and is_minted(key, columns, options):
61
+ key = KeyChoice(
62
+ columns=key.columns,
63
+ confidence=key.confidence,
64
+ reasons=(
65
+ *key.reasons,
66
+ f"{columns[key.columns[0]].name!r} holds generated ids: Records use minted keys",
67
+ ),
68
+ candidates=key.candidates,
69
+ minted=True,
70
+ )
71
+ return TableAnalysis(
72
+ name=profile.name,
73
+ snake=snake,
74
+ singular=single,
75
+ columns=columns,
76
+ rows=rows,
77
+ values_read=values_read,
78
+ key=key,
79
+ foreign_keys=profile.foreign_keys,
80
+ comment=profile.comment,
81
+ )
@@ -0,0 +1,89 @@
1
+ """Three-way merge of Revisions (design 6.8 to 6.10): schema first, then data, then consistency."""
2
+
3
+ from lorebase_core.merge.conflicts import (
4
+ CHOICES,
5
+ GROUPED_KINDS,
6
+ MACHINE_RESOLVABLE,
7
+ AutoResolution,
8
+ Conflict,
9
+ ConflictKind,
10
+ Resolution,
11
+ ResolutionChoice,
12
+ ResolutionError,
13
+ Side,
14
+ UnresolvedConflictsError,
15
+ conflict_fingerprint,
16
+ )
17
+ from lorebase_core.merge.engine import (
18
+ IndexViewFactory,
19
+ LineageLoader,
20
+ MergeAttempt,
21
+ MergedTree,
22
+ MergeInput,
23
+ MergeOutcome,
24
+ MergeResult,
25
+ ProvenanceLoader,
26
+ apply_resolutions,
27
+ merge,
28
+ )
29
+ from lorebase_core.merge.index import TreeIndexView
30
+ from lorebase_core.merge.objects import (
31
+ DOMAIN_KEY,
32
+ InMemoryObjectStore,
33
+ MissingObjectError,
34
+ ObjectStore,
35
+ SchemaLoader,
36
+ load_schema,
37
+ put_schema,
38
+ schema_from_documents,
39
+ tree_schema_loader,
40
+ )
41
+ from lorebase_core.merge.policies import (
42
+ DEFAULT_ATOMIC_PROPERTIES,
43
+ ORIGIN_LABEL_PREFIXES,
44
+ ChangeStamp,
45
+ MergePolicy,
46
+ PolicyName,
47
+ origin_labels,
48
+ )
49
+
50
+ __all__ = [
51
+ "CHOICES",
52
+ "DEFAULT_ATOMIC_PROPERTIES",
53
+ "DOMAIN_KEY",
54
+ "GROUPED_KINDS",
55
+ "MACHINE_RESOLVABLE",
56
+ "ORIGIN_LABEL_PREFIXES",
57
+ "AutoResolution",
58
+ "ChangeStamp",
59
+ "Conflict",
60
+ "ConflictKind",
61
+ "InMemoryObjectStore",
62
+ "IndexViewFactory",
63
+ "LineageLoader",
64
+ "MergeAttempt",
65
+ "MergeInput",
66
+ "MergeOutcome",
67
+ "MergePolicy",
68
+ "MergeResult",
69
+ "MergedTree",
70
+ "MissingObjectError",
71
+ "ObjectStore",
72
+ "PolicyName",
73
+ "ProvenanceLoader",
74
+ "Resolution",
75
+ "ResolutionChoice",
76
+ "ResolutionError",
77
+ "SchemaLoader",
78
+ "Side",
79
+ "TreeIndexView",
80
+ "UnresolvedConflictsError",
81
+ "apply_resolutions",
82
+ "conflict_fingerprint",
83
+ "load_schema",
84
+ "merge",
85
+ "origin_labels",
86
+ "put_schema",
87
+ "schema_from_documents",
88
+ "tree_schema_loader",
89
+ ]
@@ -0,0 +1,262 @@
1
+ """Conflict taxonomy, fingerprints and resolutions (design 6.9, 6.10).
2
+
3
+ Every Conflict has a stable code (``DV1`` to ``XS11``) and a fingerprint computed from its
4
+ code, location and the hashes of the three sides. Resolutions are matched to Conflicts by
5
+ fingerprint, so a merge recomputed against a moved target re-applies the resolutions of the
6
+ Conflicts that did not change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from collections.abc import Iterable
13
+ from enum import StrEnum
14
+ from typing import Self
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
17
+
18
+ from lorebase_core.canonical import canonical_bytes
19
+
20
+ __all__ = [
21
+ "CHOICES",
22
+ "GROUPED_KINDS",
23
+ "MACHINE_RESOLVABLE",
24
+ "AutoResolution",
25
+ "Conflict",
26
+ "ConflictKind",
27
+ "Resolution",
28
+ "ResolutionChoice",
29
+ "ResolutionError",
30
+ "Side",
31
+ "UnresolvedConflictsError",
32
+ "conflict_fingerprint",
33
+ ]
34
+
35
+
36
+ class ConflictKind(StrEnum):
37
+ """Stable Conflict codes (design 6.9). ``DV`` data, ``SC`` schema, ``XS`` cross-cutting."""
38
+
39
+ ATTRIBUTE_VALUE = "DV1"
40
+ DELETE_MODIFY = "DV2"
41
+ ADD_ADD = "DV3"
42
+ SCHEMA_PROPERTY = "SC1"
43
+ SCHEMA_DELETE_MODIFY = "SC2"
44
+ NAMING = "SC3"
45
+ ORPHANED_DATA = "XS1"
46
+ ORPHANED_VALUE = "XS2"
47
+ TYPE_COERCION = "XS3"
48
+ MISSING_VALUE = "XS4"
49
+ CONSTRAINT = "XS5"
50
+ CARDINALITY = "XS6"
51
+ REQUIRED_RELATION = "XS7"
52
+ UNIQUENESS = "XS8"
53
+ DANGLING_LINK = "XS9"
54
+ TRAIT_CONFORMANCE = "XS10"
55
+ BROKEN_REFERENCE = "XS11"
56
+
57
+
58
+ class Side(StrEnum):
59
+ BASE = "base"
60
+ OURS = "ours"
61
+ THEIRS = "theirs"
62
+
63
+
64
+ class ResolutionChoice(StrEnum):
65
+ """How a Conflict is resolved; the meaning per Conflict kind is listed in ``CHOICES``.
66
+
67
+ - ``ours``, ``theirs``, ``base``: take that side's value, element or state.
68
+ - ``custom``: a value given by the resolver (``Resolution.value`` or, for grouped
69
+ Conflicts, ``Resolution.values`` keyed by member key).
70
+ - ``drop``: drop the data that breaks the merged schema (orphaned data or values, Records
71
+ with uncastable values, Records without a required Link, dangling Links).
72
+ - ``restore``: restore what the other side removed (a Kind, Relation or Attribute, a type
73
+ before its change, a deleted Record, a referenced schema element).
74
+ - ``relax``: relax the schema rule that the data breaks (required, constraint,
75
+ cardinality, ``required_from``, uniqueness, Trait implementation).
76
+ """
77
+
78
+ OURS = "ours"
79
+ THEIRS = "theirs"
80
+ BASE = "base"
81
+ CUSTOM = "custom"
82
+ DROP = "drop"
83
+ RESTORE = "restore"
84
+ RELAX = "relax"
85
+
86
+
87
+ _C = ResolutionChoice
88
+ _PICK = (_C.OURS, _C.THEIRS, _C.BASE, _C.CUSTOM)
89
+
90
+ #: Choices accepted per Conflict kind.
91
+ CHOICES: dict[ConflictKind, tuple[ResolutionChoice, ...]] = {
92
+ ConflictKind.ATTRIBUTE_VALUE: _PICK,
93
+ ConflictKind.DELETE_MODIFY: _PICK,
94
+ ConflictKind.ADD_ADD: _PICK,
95
+ ConflictKind.SCHEMA_PROPERTY: _PICK,
96
+ ConflictKind.SCHEMA_DELETE_MODIFY: _PICK,
97
+ ConflictKind.NAMING: (_C.CUSTOM,),
98
+ ConflictKind.ORPHANED_DATA: (_C.DROP, _C.RESTORE),
99
+ ConflictKind.ORPHANED_VALUE: (_C.DROP, _C.RESTORE),
100
+ ConflictKind.TYPE_COERCION: (_C.CUSTOM, _C.DROP, _C.RESTORE),
101
+ ConflictKind.MISSING_VALUE: (_C.CUSTOM, _C.RELAX),
102
+ ConflictKind.CONSTRAINT: (_C.CUSTOM, _C.RELAX),
103
+ ConflictKind.CARDINALITY: (_C.CUSTOM, _C.RELAX),
104
+ ConflictKind.REQUIRED_RELATION: (_C.CUSTOM, _C.DROP, _C.RELAX),
105
+ ConflictKind.UNIQUENESS: (_C.CUSTOM, _C.RELAX),
106
+ ConflictKind.DANGLING_LINK: (_C.DROP, _C.RESTORE),
107
+ ConflictKind.TRAIT_CONFORMANCE: (_C.CUSTOM, _C.RELAX),
108
+ ConflictKind.BROKEN_REFERENCE: (_C.CUSTOM, _C.RESTORE),
109
+ }
110
+
111
+ #: Kinds a ``MergePolicy`` can resolve without a person (``prefer`` and ``cascade_dangling``;
112
+ #: ``authority`` and ``newest_wins`` for the Attribute Conflicts ``DV1`` and ``DV3``).
113
+ MACHINE_RESOLVABLE = frozenset(
114
+ {
115
+ ConflictKind.ATTRIBUTE_VALUE,
116
+ ConflictKind.DELETE_MODIFY,
117
+ ConflictKind.ADD_ADD,
118
+ ConflictKind.SCHEMA_PROPERTY,
119
+ ConflictKind.SCHEMA_DELETE_MODIFY,
120
+ ConflictKind.DANGLING_LINK,
121
+ }
122
+ )
123
+
124
+ #: Kinds reported as one Conflict over many members, with a count and a sample of keys.
125
+ GROUPED_KINDS = frozenset(
126
+ {
127
+ ConflictKind.ORPHANED_DATA,
128
+ ConflictKind.ORPHANED_VALUE,
129
+ ConflictKind.TYPE_COERCION,
130
+ ConflictKind.MISSING_VALUE,
131
+ ConflictKind.CONSTRAINT,
132
+ ConflictKind.CARDINALITY,
133
+ ConflictKind.REQUIRED_RELATION,
134
+ ConflictKind.UNIQUENESS,
135
+ ConflictKind.DANGLING_LINK,
136
+ }
137
+ )
138
+
139
+
140
+ def conflict_fingerprint(
141
+ kind: ConflictKind,
142
+ partition: str,
143
+ key: str | None,
144
+ attribute: str | None,
145
+ hashes: tuple[bytes | None, bytes | None, bytes | None],
146
+ members: Iterable[str] = (),
147
+ ) -> str:
148
+ """``sha256(code, partition, key, attribute, base_hash, ours_hash, theirs_hash)``.
149
+
150
+ Grouped Conflicts also hash their sorted member keys, so a group that gains or loses
151
+ members is a different Conflict.
152
+ """
153
+ digest = hashlib.sha256()
154
+ head: list[JsonValue] = [
155
+ kind.value,
156
+ partition,
157
+ key,
158
+ attribute,
159
+ *(None if h is None else h.hex() for h in hashes),
160
+ ]
161
+ digest.update(canonical_bytes(head))
162
+ for member in sorted(members):
163
+ digest.update(b"\x00")
164
+ digest.update(member.encode("utf-8"))
165
+ return digest.hexdigest()
166
+
167
+
168
+ class _Frozen(BaseModel):
169
+ model_config = ConfigDict(frozen=True, extra="forbid")
170
+
171
+
172
+ class Conflict(_Frozen):
173
+ """One Conflict of a merge attempt.
174
+
175
+ ``partition`` and ``key`` locate the element (for schema Conflicts the partition is
176
+ ``schema`` and the key the element id); ``attribute`` is an Attribute id for data
177
+ Conflicts and a dotted property path for schema Conflicts. ``base``, ``ours`` and
178
+ ``theirs`` hold the competing values (``None`` means absent) and the ``*_hash`` fields the
179
+ hashes of the elements they come from. Grouped Conflicts carry ``count`` members and a
180
+ ``sample`` of their keys.
181
+ """
182
+
183
+ kind: ConflictKind
184
+ fingerprint: str
185
+ partition: str
186
+ key: str | None = None
187
+ attribute: str | None = None
188
+ base: JsonValue = None
189
+ ours: JsonValue = None
190
+ theirs: JsonValue = None
191
+ base_hash: str | None = None
192
+ ours_hash: str | None = None
193
+ theirs_hash: str | None = None
194
+ count: int = Field(default=1, ge=1)
195
+ sample: tuple[str, ...] = ()
196
+ message: str = ""
197
+ details: dict[str, JsonValue] = Field(default_factory=dict)
198
+ choices: tuple[ResolutionChoice, ...] = ()
199
+ machine_resolvable: bool = False
200
+
201
+ @property
202
+ def grouped(self) -> bool:
203
+ return self.kind in GROUPED_KINDS
204
+
205
+
206
+ class Resolution(_Frozen):
207
+ """A resolver's decision for the Conflict with ``fingerprint``.
208
+
209
+ ``custom`` takes ``value`` (``None`` means absent), or ``values`` keyed by member key for
210
+ grouped Conflicts.
211
+ """
212
+
213
+ fingerprint: str
214
+ choice: ResolutionChoice
215
+ value: JsonValue = None
216
+ values: dict[str, JsonValue] | None = None
217
+ resolver: str = ""
218
+
219
+ @model_validator(mode="after")
220
+ def _check_custom(self) -> Self:
221
+ if self.choice is not ResolutionChoice.CUSTOM and (
222
+ self.value is not None or self.values is not None
223
+ ):
224
+ raise ValueError("only custom resolutions carry a value")
225
+ return self
226
+
227
+ def value_for(self, member: str | None) -> JsonValue:
228
+ """The custom value for ``member``: its entry in ``values``, else ``value``."""
229
+ if self.values is not None and member is not None and member in self.values:
230
+ return self.values[member]
231
+ return self.value
232
+
233
+
234
+ class AutoResolution(_Frozen):
235
+ """A Conflict resolved by the engine or by a policy, recorded for Lineage and review.
236
+
237
+ ``details`` say why a Lineage-based policy decided: the origins matched by ``authority``,
238
+ the timestamps compared by ``newest_wins``, and the provenance entries they come from.
239
+ """
240
+
241
+ kind: ConflictKind
242
+ fingerprint: str
243
+ partition: str
244
+ key: str | None = None
245
+ attribute: str | None = None
246
+ choice: ResolutionChoice
247
+ resolver: str
248
+ count: int = Field(default=1, ge=1)
249
+ sample: tuple[str, ...] = ()
250
+ details: dict[str, JsonValue] = Field(default_factory=dict)
251
+
252
+
253
+ class ResolutionError(ValueError):
254
+ """A resolution that does not fit the merge attempt."""
255
+
256
+
257
+ class UnresolvedConflictsError(ResolutionError):
258
+ """Raised when resolutions leave Conflicts of the attempt open."""
259
+
260
+ def __init__(self, fingerprints: Iterable[str]) -> None:
261
+ self.fingerprints = tuple(sorted(fingerprints))
262
+ super().__init__(f"{len(self.fingerprints)} conflict(s) left unresolved")