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,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)
|