Skip to content

Commit 54711e9

Browse files
committed
feat(user-sync): add reconciliation engine
1 parent 59b5610 commit 54711e9

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Reconcile proposed User items against the ones already stored.
2+
3+
Pure logic: given proposed users and an index of existing items, classify each
4+
into NEW, GAP_FILL, CONFLICT or UNCHANGED and record the per-field differences.
5+
The interactive layer consumes this plan; nothing here does IO.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from collections import Counter
11+
from dataclasses import dataclass, field
12+
from typing import Any, Dict, List, Optional
13+
14+
from .existing import ExistingUsers
15+
from .mapping import ProposedUser
16+
17+
NEW = "new"
18+
GAP_FILL = "gap_fill"
19+
CONFLICT = "conflict"
20+
UNCHANGED = "unchanged"
21+
22+
# username is the match key and is never reconciled as a value.
23+
SCALAR_FIELDS = ("label", "first_name", "surname", "orcid")
24+
SET_FIELDS = ("emails", "websites", "organizations")
25+
URL_FIELDS = ("orcid", "websites")
26+
RECONCILED_FIELDS = SCALAR_FIELDS + SET_FIELDS
27+
28+
29+
def _is_empty(value: Any) -> bool:
30+
if value is None or value == "":
31+
return True
32+
return isinstance(value, (list, set, tuple)) and len(value) == 0
33+
34+
35+
def _norm_url(value: Any) -> Optional[str]:
36+
return None if value is None else str(value).rstrip("/")
37+
38+
39+
def _norm_scalar(name: str, value: Any) -> Optional[str]:
40+
if value is None:
41+
return None
42+
return _norm_url(value) if name in URL_FIELDS else str(value)
43+
44+
45+
def _norm_set(name: str, value: Any) -> set:
46+
if not value:
47+
return set()
48+
if name in URL_FIELDS:
49+
return {_norm_url(x) for x in value}
50+
return {str(x) for x in value}
51+
52+
53+
def _existing_label(entity: Any) -> Optional[str]:
54+
for label in getattr(entity, "label", None) or []:
55+
text = getattr(label, "text", None)
56+
if text:
57+
return text
58+
return None
59+
60+
61+
def existing_fields(entity: Any) -> Dict[str, Any]:
62+
"""Normalized comparable field view of a loaded User entity."""
63+
return {
64+
"label": _existing_label(entity),
65+
"first_name": getattr(entity, "first_name", None),
66+
"surname": getattr(entity, "surname", None),
67+
"orcid": _norm_scalar("orcid", getattr(entity, "orcid", None)),
68+
"emails": _norm_set("emails", getattr(entity, "email", None)),
69+
"websites": _norm_set("websites", getattr(entity, "website", None)),
70+
"organizations": _norm_set(
71+
"organizations", getattr(entity, "organization", None)
72+
),
73+
}
74+
75+
76+
def proposed_fields(proposed: ProposedUser) -> Dict[str, Any]:
77+
"""Normalized comparable field view of a proposed user."""
78+
return {
79+
"label": proposed.label,
80+
"first_name": proposed.first_name,
81+
"surname": proposed.surname,
82+
"orcid": _norm_scalar("orcid", proposed.orcid),
83+
"emails": _norm_set("emails", proposed.emails),
84+
"websites": _norm_set("websites", proposed.websites),
85+
"organizations": _norm_set("organizations", proposed.organizations),
86+
}
87+
88+
89+
@dataclass
90+
class FieldDiff:
91+
"""A single field that would change on an existing item."""
92+
93+
name: str
94+
existing: Any
95+
proposed: Any
96+
status: str # GAP_FILL or CONFLICT
97+
98+
99+
@dataclass
100+
class UserChange:
101+
"""The reconciliation outcome for one proposed user."""
102+
103+
proposed: ProposedUser
104+
existing: Optional[Any]
105+
category: str
106+
diffs: List[FieldDiff] = field(default_factory=list)
107+
108+
109+
def _compare(existing_val: Any, proposed_val: Any) -> Optional[str]:
110+
"""Per-field status, or None when there is nothing to propose."""
111+
if _is_empty(proposed_val):
112+
return None
113+
if _is_empty(existing_val):
114+
return GAP_FILL
115+
return UNCHANGED if existing_val == proposed_val else CONFLICT
116+
117+
118+
def reconcile_user(proposed: ProposedUser, existing: Optional[Any]) -> UserChange:
119+
if existing is None:
120+
return UserChange(proposed=proposed, existing=None, category=NEW)
121+
ef = existing_fields(existing)
122+
pf = proposed_fields(proposed)
123+
diffs: List[FieldDiff] = []
124+
has_conflict = has_gap = False
125+
for name in RECONCILED_FIELDS:
126+
status = _compare(ef[name], pf[name])
127+
if status in (GAP_FILL, CONFLICT):
128+
diffs.append(FieldDiff(name, ef[name], pf[name], status))
129+
has_conflict = has_conflict or status == CONFLICT
130+
has_gap = has_gap or status == GAP_FILL
131+
category = CONFLICT if has_conflict else GAP_FILL if has_gap else UNCHANGED
132+
return UserChange(
133+
proposed=proposed, existing=existing, category=category, diffs=diffs
134+
)
135+
136+
137+
@dataclass
138+
class ReconcilePlan:
139+
"""All per-user reconciliation outcomes for a run."""
140+
141+
changes: List[UserChange] = field(default_factory=list)
142+
143+
def by_category(self, category: str) -> List[UserChange]:
144+
return [c for c in self.changes if c.category == category]
145+
146+
@property
147+
def conflicts(self) -> List[UserChange]:
148+
return self.by_category(CONFLICT)
149+
150+
def counts(self) -> Dict[str, int]:
151+
counter = Counter(c.category for c in self.changes)
152+
return {
153+
cat: counter.get(cat, 0) for cat in (NEW, GAP_FILL, CONFLICT, UNCHANGED)
154+
}
155+
156+
157+
def reconcile(
158+
proposed_users: List[ProposedUser], existing: ExistingUsers
159+
) -> ReconcilePlan:
160+
changes = [reconcile_user(p, existing.get(p.username)) for p in proposed_users]
161+
return ReconcilePlan(changes=changes)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Unit tests for the reconciliation engine."""
2+
3+
import uuid
4+
from types import SimpleNamespace
5+
6+
from osw.tools.user_sync.existing import ExistingUsers
7+
from osw.tools.user_sync.mapping import ProposedUser
8+
from osw.tools.user_sync.reconcile import (
9+
CONFLICT,
10+
GAP_FILL,
11+
NEW,
12+
UNCHANGED,
13+
reconcile,
14+
reconcile_user,
15+
)
16+
17+
ORCID = "0000-0002-6374-9831"
18+
19+
20+
def _proposed(**kw):
21+
base = dict(
22+
uuid=uuid.uuid4(),
23+
username=ORCID,
24+
first_name="Lukas",
25+
surname="Koschmieder",
26+
label="Lukas Koschmieder",
27+
orcid=f"https://orcid.org/{ORCID}",
28+
emails=[],
29+
websites=[],
30+
organizations=[],
31+
)
32+
base.update(kw)
33+
return ProposedUser(**base)
34+
35+
36+
def _entity(**kw):
37+
base = dict(
38+
label="Lukas Koschmieder",
39+
first_name="Lukas",
40+
surname="Koschmieder",
41+
orcid=f"https://orcid.org/{ORCID}",
42+
email=None,
43+
website=None,
44+
organization=None,
45+
)
46+
base.update(kw)
47+
return SimpleNamespace(
48+
label=[SimpleNamespace(text=base["label"])] if base["label"] else [],
49+
first_name=base["first_name"],
50+
surname=base["surname"],
51+
orcid=base["orcid"],
52+
email=set(base["email"] or []),
53+
website=set(base["website"] or []),
54+
organization=set(base["organization"] or []),
55+
)
56+
57+
58+
def test_new_when_no_existing():
59+
change = reconcile_user(_proposed(), None)
60+
assert change.category == NEW
61+
assert change.diffs == []
62+
63+
64+
def test_unchanged_when_equal():
65+
change = reconcile_user(_proposed(), _entity())
66+
assert change.category == UNCHANGED
67+
assert change.diffs == []
68+
69+
70+
def test_gap_fill_for_missing_field():
71+
proposed = _proposed(emails=["l@example.org"])
72+
change = reconcile_user(proposed, _entity(email=None))
73+
assert change.category == GAP_FILL
74+
assert [d.name for d in change.diffs] == ["emails"]
75+
assert change.diffs[0].status == GAP_FILL
76+
77+
78+
def test_conflict_on_differing_value():
79+
change = reconcile_user(_proposed(surname="Koschmieder"), _entity(surname="Kosch"))
80+
assert change.category == CONFLICT
81+
assert change.diffs[0].name == "surname"
82+
assert change.diffs[0].existing == "Kosch"
83+
assert change.diffs[0].proposed == "Koschmieder"
84+
85+
86+
def test_conflict_dominates_gap_fill():
87+
proposed = _proposed(surname="Koschmieder", emails=["l@example.org"])
88+
change = reconcile_user(proposed, _entity(surname="Kosch", email=None))
89+
assert change.category == CONFLICT
90+
names = {d.name for d in change.diffs}
91+
assert names == {"surname", "emails"}
92+
93+
94+
def test_empty_proposal_does_not_clear_existing():
95+
change = reconcile_user(_proposed(emails=[]), _entity(email=["keep@example.org"]))
96+
assert change.category == UNCHANGED
97+
assert change.diffs == []
98+
99+
100+
def test_orcid_trailing_slash_not_a_conflict():
101+
change = reconcile_user(
102+
_proposed(orcid=f"https://orcid.org/{ORCID}"),
103+
_entity(orcid=f"https://orcid.org/{ORCID}/"),
104+
)
105+
assert change.category == UNCHANGED
106+
107+
108+
def test_set_order_independent():
109+
change = reconcile_user(
110+
_proposed(organizations=["Item:OSWb", "Item:OSWa"]),
111+
_entity(organization=["Item:OSWa", "Item:OSWb"]),
112+
)
113+
assert change.category == UNCHANGED
114+
115+
116+
def test_missing_label_is_gap_fill():
117+
change = reconcile_user(_proposed(label="Lukas Koschmieder"), _entity(label=None))
118+
assert change.category == GAP_FILL
119+
assert change.diffs[0].name == "label"
120+
121+
122+
def test_reconcile_plan_counts():
123+
proposed = [
124+
_proposed(username="new-user"),
125+
_proposed(username=ORCID),
126+
_proposed(username="conflict-user", surname="New"),
127+
]
128+
existing = ExistingUsers(
129+
by_username={
130+
ORCID: _entity(),
131+
"conflict-user": _entity(surname="Old"),
132+
}
133+
)
134+
plan = reconcile(proposed, existing)
135+
counts = plan.counts()
136+
assert counts == {NEW: 1, GAP_FILL: 0, CONFLICT: 1, UNCHANGED: 1}
137+
assert [c.proposed.username for c in plan.conflicts] == ["conflict-user"]

0 commit comments

Comments
 (0)