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,204 @@
1
+ """Object access for the merge engine: the object store protocol and the schema partition codec.
2
+
3
+ The tree maps element keys to object hashes; objects themselves (canonical JSON payloads of
4
+ schema elements, Records and Links) live in an ``ObjectStore``. The ``schema`` partition is
5
+ keyed by element id; the Domain document is the only key that is not an element id.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from pydantic import JsonValue
14
+
15
+ from lorebase_core.canonical import ObjectType, object_hash
16
+ from lorebase_core.model import (
17
+ Derivation,
18
+ Domain,
19
+ IdPrefix,
20
+ Kind,
21
+ Model,
22
+ Operation,
23
+ Relation,
24
+ Schema,
25
+ Source,
26
+ Trait,
27
+ id_prefix,
28
+ )
29
+ from lorebase_core.tree import (
30
+ PARTITION_SCHEMA,
31
+ NodeStore,
32
+ TreeChange,
33
+ kind_partition,
34
+ relation_partition,
35
+ tree_entries,
36
+ )
37
+
38
+ __all__ = [
39
+ "DOMAIN_KEY",
40
+ "InMemoryObjectStore",
41
+ "MissingObjectError",
42
+ "ObjectStore",
43
+ "SchemaLoader",
44
+ "data_object_type",
45
+ "element_model",
46
+ "load_object",
47
+ "load_schema",
48
+ "partition_element",
49
+ "put_schema",
50
+ "schema_from_documents",
51
+ "schema_object_type",
52
+ "tree_schema_loader",
53
+ ]
54
+
55
+ #: Key of the Domain document in the ``schema`` partition.
56
+ DOMAIN_KEY = "domain"
57
+
58
+ _KIND_PREFIX = kind_partition("")
59
+ _RELATION_PREFIX = relation_partition("")
60
+
61
+ _ELEMENT_TYPES: dict[IdPrefix, tuple[ObjectType, type[Model], str]] = {
62
+ IdPrefix.KIND: (ObjectType.SCHEMA_KIND, Kind, "kinds"),
63
+ IdPrefix.RELATION: (ObjectType.SCHEMA_RELATION, Relation, "relations"),
64
+ IdPrefix.TRAIT: (ObjectType.SCHEMA_TRAIT, Trait, "traits"),
65
+ IdPrefix.OPERATION: (ObjectType.SCHEMA_OPERATION, Operation, "operations"),
66
+ IdPrefix.DERIVATION: (ObjectType.SCHEMA_DERIVATION, Derivation, "derivations"),
67
+ IdPrefix.SOURCE: (ObjectType.SCHEMA_SOURCE, Source, "sources"),
68
+ }
69
+
70
+ SchemaLoader = Callable[[bytes], Schema]
71
+ """Loads the Schema of a Revision from its tree root."""
72
+
73
+
74
+ class MissingObjectError(KeyError):
75
+ """Raised when a tree references an object the store does not hold."""
76
+
77
+
78
+ @runtime_checkable
79
+ class ObjectStore(Protocol):
80
+ """Content-addressed objects: canonical JSON payloads keyed by object hash."""
81
+
82
+ def get(self, object_hash: bytes) -> JsonValue | None: ...
83
+
84
+ def put(self, object_type: str, payload: JsonValue) -> bytes:
85
+ """Store ``payload`` and return its object hash."""
86
+ ...
87
+
88
+
89
+ class InMemoryObjectStore:
90
+ """Dictionary-backed :class:`ObjectStore`."""
91
+
92
+ def __init__(self) -> None:
93
+ self._objects: dict[bytes, JsonValue] = {}
94
+
95
+ def get(self, object_hash: bytes) -> JsonValue | None:
96
+ return self._objects.get(object_hash)
97
+
98
+ def put(self, object_type: str, payload: JsonValue) -> bytes:
99
+ digest = object_hash(object_type, payload)
100
+ self._objects.setdefault(digest, payload)
101
+ return digest
102
+
103
+ def __len__(self) -> int:
104
+ return len(self._objects)
105
+
106
+
107
+ def load_object(objects: ObjectStore, digest: bytes) -> dict[str, JsonValue]:
108
+ """Fetch an object payload, which must be a JSON object."""
109
+ payload = objects.get(digest)
110
+ if payload is None:
111
+ raise MissingObjectError(digest.hex())
112
+ if not isinstance(payload, dict):
113
+ raise TypeError(f"object {digest.hex()} is not a JSON object")
114
+ return payload
115
+
116
+
117
+ def schema_object_type(key: str) -> ObjectType:
118
+ """Object type of the ``schema`` partition entry under ``key``."""
119
+ prefix = id_prefix(key)
120
+ if prefix is None or prefix not in _ELEMENT_TYPES:
121
+ return ObjectType.SCHEMA_DOMAIN
122
+ return _ELEMENT_TYPES[prefix][0]
123
+
124
+
125
+ def element_model(key: str) -> type[Model]:
126
+ """Model class of the ``schema`` partition entry under ``key``."""
127
+ prefix = id_prefix(key)
128
+ if prefix is None or prefix not in _ELEMENT_TYPES:
129
+ return Domain
130
+ return _ELEMENT_TYPES[prefix][1]
131
+
132
+
133
+ def data_object_type(partition: str) -> ObjectType:
134
+ """Object type stored in a data partition: Records for Kinds, Links for Relations."""
135
+ if partition.startswith(_RELATION_PREFIX):
136
+ return ObjectType.LINK
137
+ return ObjectType.RECORD
138
+
139
+
140
+ def partition_element(partition: str) -> str | None:
141
+ """Kind or Relation id owning a data partition; ``None`` for the ``schema`` partition."""
142
+ for prefix in (_KIND_PREFIX, _RELATION_PREFIX):
143
+ if partition.startswith(prefix):
144
+ return partition[len(prefix) :]
145
+ return None
146
+
147
+
148
+ def put_schema(objects: ObjectStore, schema: Schema) -> list[TreeChange]:
149
+ """Store every element of ``schema`` and return the ``schema`` partition entries."""
150
+ changes = [
151
+ TreeChange(
152
+ PARTITION_SCHEMA,
153
+ DOMAIN_KEY,
154
+ objects.put(ObjectType.SCHEMA_DOMAIN, schema.domain.content()),
155
+ )
156
+ ]
157
+ for element in schema.elements():
158
+ changes.append(
159
+ TreeChange(
160
+ PARTITION_SCHEMA,
161
+ element.id,
162
+ objects.put(schema_object_type(element.id), element.content()),
163
+ )
164
+ )
165
+ return changes
166
+
167
+
168
+ def schema_from_documents(documents: Mapping[str, Mapping[str, JsonValue]]) -> Schema:
169
+ """Build a Schema from ``schema`` partition documents keyed as in the tree."""
170
+ domain: JsonValue = None
171
+ collections: dict[str, dict[str, JsonValue]] = {
172
+ name: {} for _, _, name in _ELEMENT_TYPES.values()
173
+ }
174
+ for key, document in documents.items():
175
+ prefix = id_prefix(key)
176
+ if prefix is None or prefix not in _ELEMENT_TYPES:
177
+ domain = dict(document)
178
+ continue
179
+ collections[_ELEMENT_TYPES[prefix][2]][key] = dict(document)
180
+ if domain is None:
181
+ raise ValueError("the schema partition holds no Domain document")
182
+ return Schema.model_validate({"domain": domain, **collections})
183
+
184
+
185
+ def load_schema(store: NodeStore, objects: ObjectStore, root: bytes) -> Schema:
186
+ """Load the Schema of a tree from its ``schema`` partition."""
187
+ documents = {
188
+ key: load_object(objects, digest)
189
+ for key, digest in tree_entries(store, root, PARTITION_SCHEMA)
190
+ }
191
+ return schema_from_documents(documents)
192
+
193
+
194
+ def tree_schema_loader(store: NodeStore, objects: ObjectStore) -> SchemaLoader:
195
+ """A :data:`SchemaLoader` reading the ``schema`` partition, with a per-root cache."""
196
+ cache: dict[bytes, Schema] = {}
197
+
198
+ def loader(root: bytes) -> Schema:
199
+ schema = cache.get(root)
200
+ if schema is None:
201
+ schema = cache[root] = load_schema(store, objects, root)
202
+ return schema
203
+
204
+ return loader
@@ -0,0 +1,322 @@
1
+ """Merge policies (design 6.8, 6.10): options applied before any person sees a Conflict.
2
+
3
+ Three policies resolve value Conflicts without a person:
4
+
5
+ - ``prefer``: one side wins, globally, per Kind or Relation, or per Attribute.
6
+ - ``authority``: a ranked list of origins (Sources, principals, actors, origin types); the
7
+ side whose last change of the Attribute comes from the higher ranked origin wins.
8
+ - ``newest_wins``: the side whose last change of the Attribute is the more recent wins.
9
+
10
+ ``authority`` and ``newest_wins`` read the Lineage of each side (the provenance entry of the
11
+ last change of every Attribute) through an explicit loader; they only apply to Attribute
12
+ Conflicts (``DV1``, ``DV3``) and leave the Conflict to the next rule, and finally to a person,
13
+ when they cannot decide.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable, Mapping
19
+ from dataclasses import dataclass, field
20
+ from datetime import UTC, datetime
21
+ from enum import StrEnum
22
+ from typing import Any, Final
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, field_validator
25
+
26
+ from lorebase_core.merge.conflicts import Side
27
+ from lorebase_core.model import OperationOrigin, ProvenanceEntry, SourceSyncOrigin
28
+
29
+ __all__ = [
30
+ "DEFAULT_ATOMIC_PROPERTIES",
31
+ "ORIGIN_LABEL_PREFIXES",
32
+ "ChangeStamp",
33
+ "MergePolicy",
34
+ "PolicyDecision",
35
+ "PolicyName",
36
+ "PolicyRule",
37
+ "decide_authority",
38
+ "decide_newest",
39
+ "origin_labels",
40
+ ]
41
+
42
+ #: Schema element properties merged as whole values, never entry by entry.
43
+ DEFAULT_ATOMIC_PROPERTIES = frozenset({"type", "default", "definition"})
44
+
45
+ #: Prefixes of origin labels: ``source:{source id}``, ``principal:{principal}`` (the principal
46
+ #: of an Operation), ``actor:{actor}`` (who wrote the provenance entry), ``operation:{api
47
+ #: name}`` and ``origin:{origin type}`` (``source_sync``, ``operation``, ``extraction``, ...).
48
+ ORIGIN_LABEL_PREFIXES: Final = frozenset({"source", "principal", "actor", "operation", "origin"})
49
+
50
+
51
+ class PolicyName(StrEnum):
52
+ PREFER = "prefer"
53
+ AUTHORITY = "authority"
54
+ NEWEST_WINS = "newest_wins"
55
+
56
+ @property
57
+ def resolver(self) -> str:
58
+ """The resolver recorded on the resolutions of this policy."""
59
+ return f"policy:{self.value}"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class PolicyRule:
64
+ """One step of the rule chain of a Conflict: ``side`` for ``prefer``, ``ranking`` for
65
+ ``authority``, nothing more for ``newest_wins``."""
66
+
67
+ name: PolicyName
68
+ side: Side | None = None
69
+ ranking: tuple[str, ...] = ()
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PolicyDecision:
74
+ """The side a rule picked and why, recorded with the automatic resolution."""
75
+
76
+ name: PolicyName
77
+ side: Side
78
+ details: dict[str, JsonValue] = field(default_factory=dict)
79
+
80
+
81
+ def _check_label(label: str) -> str:
82
+ prefix, sep, rest = label.partition(":")
83
+ if not sep or not rest or prefix not in ORIGIN_LABEL_PREFIXES:
84
+ allowed = ", ".join(sorted(ORIGIN_LABEL_PREFIXES))
85
+ raise ValueError(f"origin label {label!r} must be '<prefix>:<value>' with prefix {allowed}")
86
+ return label
87
+
88
+
89
+ def _check_ranking(ranking: tuple[str, ...]) -> tuple[str, ...]:
90
+ for label in ranking:
91
+ _check_label(label)
92
+ if len(set(ranking)) != len(ranking):
93
+ raise ValueError("an authority ranking lists every origin once")
94
+ return ranking
95
+
96
+
97
+ class MergePolicy(BaseModel):
98
+ """Options of one merge. Every default keeps the behavior of design 6.8.
99
+
100
+ - ``fast_forward``: when ours is an ancestor of theirs, move to theirs instead of
101
+ producing a merge Revision (default ``True``).
102
+ - ``allow_checked_casts``: values written on one side are cast to a type changed on the
103
+ other side with ``checked`` casts; failures are type coercion Conflicts. When ``False``
104
+ every such value is a type coercion Conflict (default ``True``).
105
+ - ``cascade_dangling``: a Link added on one side to a Record deleted on the other is
106
+ dropped when its Relation has ``on_delete = cascade`` (default ``True``).
107
+ - ``apply_defaults``: a missing value of a newly required Attribute takes the Attribute
108
+ default when it has one (default ``True``).
109
+ - ``prefer``, ``prefer_elements``, ``prefer_attributes``: resolve value and schema
110
+ Conflicts (``DV1`` to ``DV3``, ``SC1``, ``SC2``) for one side, globally, per Kind or
111
+ Relation id, or per Attribute id (default: none).
112
+ - ``authority``, ``authority_attributes``: ranked origin labels (highest first, see
113
+ ``ORIGIN_LABEL_PREFIXES``), globally or per Attribute id. On an Attribute Conflict the
114
+ side whose last change comes from the higher ranked origin wins; an origin missing from
115
+ the ranking ranks below every listed one. No decision when both sides rank the same,
116
+ when neither is listed, or when the Lineage of a side is unknown (default: none).
117
+ - ``schema_authority``: an Attribute's schema ``authority`` (a Source) ranks first for that
118
+ Attribute (default ``False``).
119
+ - ``newest_wins``, ``newest_wins_attributes``: on an Attribute Conflict, globally or for
120
+ the listed Attribute ids, the side whose last change has the later provenance timestamp
121
+ wins. Equal timestamps are decided by the greater provenance entry id, so the outcome
122
+ does not depend on which branch is ours; no decision when the ids are equal too or the
123
+ Lineage of a side is unknown (default: off).
124
+ - ``sample_size``: member keys listed in grouped Conflicts (default 10).
125
+ - ``atomic_properties``: schema element properties merged as whole values.
126
+
127
+ Rules are tried from the most specific to the least: per Attribute (``prefer``, then
128
+ ``authority``, then ``newest_wins``), per Kind or Relation (``prefer``), then global
129
+ (``authority``, ``newest_wins``, ``prefer``). The first rule that decides resolves the
130
+ Conflict; when none does, the Conflict is left to a person.
131
+ """
132
+
133
+ model_config = ConfigDict(frozen=True, extra="forbid")
134
+
135
+ fast_forward: bool = True
136
+ allow_checked_casts: bool = True
137
+ cascade_dangling: bool = True
138
+ apply_defaults: bool = True
139
+ prefer: Side | None = None
140
+ prefer_elements: dict[str, Side] = Field(default_factory=dict)
141
+ prefer_attributes: dict[str, Side] = Field(default_factory=dict)
142
+ authority: tuple[str, ...] = ()
143
+ authority_attributes: dict[str, tuple[str, ...]] = Field(default_factory=dict)
144
+ schema_authority: bool = False
145
+ newest_wins: bool = False
146
+ newest_wins_attributes: frozenset[str] = frozenset()
147
+ sample_size: int = Field(default=10, ge=1)
148
+ atomic_properties: frozenset[str] = DEFAULT_ATOMIC_PROPERTIES
149
+
150
+ @field_validator("prefer")
151
+ @classmethod
152
+ def _side(cls, side: Side | None) -> Side | None:
153
+ if side is Side.BASE:
154
+ raise ValueError("prefer is ours or theirs")
155
+ return side
156
+
157
+ @field_validator("prefer_elements", "prefer_attributes")
158
+ @classmethod
159
+ def _sides(cls, sides: dict[str, Side]) -> dict[str, Side]:
160
+ if Side.BASE in sides.values():
161
+ raise ValueError("prefer is ours or theirs")
162
+ return sides
163
+
164
+ @field_validator("authority")
165
+ @classmethod
166
+ def _ranking(cls, ranking: tuple[str, ...]) -> tuple[str, ...]:
167
+ return _check_ranking(ranking)
168
+
169
+ @field_validator("authority_attributes")
170
+ @classmethod
171
+ def _rankings(cls, rankings: dict[str, tuple[str, ...]]) -> dict[str, tuple[str, ...]]:
172
+ for ranking in rankings.values():
173
+ if not ranking:
174
+ raise ValueError("an authority ranking lists at least one origin")
175
+ _check_ranking(ranking)
176
+ return rankings
177
+
178
+ def preferred(self, element: str | None, attribute: str | None) -> Side | None:
179
+ """The side ``prefer`` picks for a Conflict on ``element`` and ``attribute``."""
180
+ if attribute is not None and attribute in self.prefer_attributes:
181
+ return self.prefer_attributes[attribute]
182
+ if element is not None and element in self.prefer_elements:
183
+ return self.prefer_elements[element]
184
+ return self.prefer
185
+
186
+ def rules(
187
+ self,
188
+ element: str | None,
189
+ attribute: str | None,
190
+ *,
191
+ attribute_authority: str | None = None,
192
+ ) -> tuple[PolicyRule, ...]:
193
+ """The rules tried, in order, on a Conflict of ``element`` and ``attribute``.
194
+
195
+ ``attribute`` is set for Attribute Conflicts only; ``attribute_authority`` is the
196
+ schema ``authority`` (a Source id) of that Attribute.
197
+ """
198
+ rules: list[PolicyRule] = []
199
+ if attribute is not None:
200
+ if attribute in self.prefer_attributes:
201
+ rules.append(PolicyRule(PolicyName.PREFER, side=self.prefer_attributes[attribute]))
202
+ ranking = self._attribute_ranking(attribute, attribute_authority)
203
+ if ranking:
204
+ rules.append(PolicyRule(PolicyName.AUTHORITY, ranking=ranking))
205
+ if attribute in self.newest_wins_attributes:
206
+ rules.append(PolicyRule(PolicyName.NEWEST_WINS))
207
+ if element is not None and element in self.prefer_elements:
208
+ rules.append(PolicyRule(PolicyName.PREFER, side=self.prefer_elements[element]))
209
+ if attribute is not None:
210
+ if self.authority:
211
+ rules.append(PolicyRule(PolicyName.AUTHORITY, ranking=self.authority))
212
+ if self.newest_wins:
213
+ rules.append(PolicyRule(PolicyName.NEWEST_WINS))
214
+ if self.prefer is not None:
215
+ rules.append(PolicyRule(PolicyName.PREFER, side=self.prefer))
216
+ return tuple(rules)
217
+
218
+ def _attribute_ranking(self, attribute: str, authority: str | None) -> tuple[str, ...]:
219
+ head = (f"source:{authority}",) if self.schema_authority and authority else ()
220
+ return tuple(dict.fromkeys((*head, *self.authority_attributes.get(attribute, ()))))
221
+
222
+
223
+ # Lineage-based decisions -------------------------------------------------------------------
224
+
225
+
226
+ def origin_labels(entry: ProvenanceEntry) -> frozenset[str]:
227
+ """The labels an authority ranking can match for one provenance entry."""
228
+ origin = entry.origin
229
+ labels = {f"origin:{origin.type}", f"actor:{entry.actor}"}
230
+ if isinstance(origin, SourceSyncOrigin):
231
+ labels.add(f"source:{origin.source_id}")
232
+ elif isinstance(origin, OperationOrigin):
233
+ labels.add(f"principal:{origin.principal}")
234
+ labels.add(f"operation:{origin.operation}")
235
+ return frozenset(labels)
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class ChangeStamp:
240
+ """Who and when of the last change of an Attribute on one side: the provenance entry id,
241
+ its time (timezone aware) and the origin labels an authority ranking can match."""
242
+
243
+ id: str
244
+ at: datetime
245
+ labels: frozenset[str]
246
+
247
+ @classmethod
248
+ def of(cls, entry: ProvenanceEntry) -> ChangeStamp:
249
+ return cls(entry.id, _aware(datetime.fromisoformat(entry.at)), origin_labels(entry))
250
+
251
+ @classmethod
252
+ def from_stored(
253
+ cls, id_: str, origin_type: str, origin: Mapping[str, Any], actor: str, at: datetime
254
+ ) -> ChangeStamp:
255
+ """A stamp of a stored provenance entry. Entries whose origin is not a Lineage origin
256
+ (the entry of a whole commit or merge) only carry ``origin:`` and ``actor:`` labels."""
257
+ try:
258
+ entry = ProvenanceEntry.model_validate(
259
+ {"id": id_, "origin": dict(origin), "actor": actor, "at": _aware(at).isoformat()}
260
+ )
261
+ except ValidationError:
262
+ labels = frozenset({f"origin:{origin_type}", f"actor:{actor}"})
263
+ return cls(id_, _aware(at), labels)
264
+ return cls.of(entry)
265
+
266
+
267
+ def _aware(at: datetime) -> datetime:
268
+ return at if at.tzinfo is not None else at.replace(tzinfo=UTC)
269
+
270
+
271
+ def _rank(stamp: ChangeStamp, ranking: tuple[str, ...]) -> tuple[int | None, str | None]:
272
+ for index, label in enumerate(ranking):
273
+ if label in stamp.labels:
274
+ return index, label
275
+ return None, None
276
+
277
+
278
+ def decide_authority(
279
+ ranking: Iterable[str], ours: ChangeStamp | None, theirs: ChangeStamp | None
280
+ ) -> PolicyDecision | None:
281
+ """The side whose change comes from the higher ranked origin, if one does."""
282
+ if ours is None or theirs is None:
283
+ return None
284
+ ranked = tuple(ranking)
285
+ ours_rank, ours_label = _rank(ours, ranked)
286
+ theirs_rank, theirs_label = _rank(theirs, ranked)
287
+ if ours_rank == theirs_rank:
288
+ return None
289
+ if theirs_rank is None or (ours_rank is not None and ours_rank < theirs_rank):
290
+ side = Side.OURS
291
+ else:
292
+ side = Side.THEIRS
293
+ return PolicyDecision(
294
+ PolicyName.AUTHORITY,
295
+ side,
296
+ {
297
+ "ours_origin": ours_label,
298
+ "theirs_origin": theirs_label,
299
+ "ours_provenance": ours.id,
300
+ "theirs_provenance": theirs.id,
301
+ },
302
+ )
303
+
304
+
305
+ def decide_newest(ours: ChangeStamp | None, theirs: ChangeStamp | None) -> PolicyDecision | None:
306
+ """The side whose change is the more recent; equal times fall to the greater entry id."""
307
+ if ours is None or theirs is None:
308
+ return None
309
+ details: dict[str, JsonValue] = {
310
+ "ours_at": ours.at.isoformat(),
311
+ "theirs_at": theirs.at.isoformat(),
312
+ "ours_provenance": ours.id,
313
+ "theirs_provenance": theirs.id,
314
+ }
315
+ if ours.at != theirs.at:
316
+ side = Side.OURS if ours.at > theirs.at else Side.THEIRS
317
+ elif ours.id != theirs.id:
318
+ side = Side.OURS if ours.id > theirs.id else Side.THEIRS
319
+ details["tie_break"] = "provenance_id"
320
+ else:
321
+ return None
322
+ return PolicyDecision(PolicyName.NEWEST_WINS, side, details)