Skip to content

Commit e0c54db

Browse files
committed
feat(user-sync): add interactive preview and resolution
1 parent 54711e9 commit e0c54db

2 files changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Interactive preview and conflict resolution for the sync.
2+
3+
Rendering is pure (returns strings). All terminal IO goes through ``Prompter``,
4+
which wraps ``input`` / ``print`` so tests can script answers and capture output.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass, field
10+
from typing import Any, Callable, List, Optional, Sequence, Set
11+
12+
from .mapping import ProposedUser
13+
from .reconcile import (
14+
CONFLICT,
15+
GAP_FILL,
16+
NEW,
17+
RECONCILED_FIELDS,
18+
UNCHANGED,
19+
ReconcilePlan,
20+
UserChange,
21+
proposed_fields,
22+
)
23+
24+
_MARKERS = {NEW: "+", GAP_FILL: "~", CONFLICT: "!", UNCHANGED: "="}
25+
26+
27+
def _is_empty(value: Any) -> bool:
28+
if value is None or value == "":
29+
return True
30+
return isinstance(value, (list, set, tuple)) and len(value) == 0
31+
32+
33+
def nonempty_fields(proposed: ProposedUser) -> Set[str]:
34+
"""Reconciled field names that carry a value on the proposed user."""
35+
pf = proposed_fields(proposed)
36+
return {name for name in RECONCILED_FIELDS if not _is_empty(pf[name])}
37+
38+
39+
def render_preview(plan: ReconcilePlan) -> List[str]:
40+
"""Human-readable preview: a header line plus one row per user."""
41+
counts = plan.counts()
42+
lines = [
43+
f"Users: {len(plan.changes)} | "
44+
f"new={counts[NEW]} gap_fill={counts[GAP_FILL]} "
45+
f"conflict={counts[CONFLICT]} unchanged={counts[UNCHANGED]}"
46+
]
47+
for change in plan.changes:
48+
marker = _MARKERS[change.category]
49+
detail = ""
50+
if change.diffs:
51+
detail = " (" + ", ".join(d.name for d in change.diffs) + ")"
52+
placeholder = " [placeholder name]" if change.proposed.placeholder_name else ""
53+
lines.append(
54+
f" {marker} {change.proposed.username} {change.category}{detail}{placeholder}"
55+
)
56+
return lines
57+
58+
59+
def render_conflict(change: UserChange) -> List[str]:
60+
"""Field-by-field display of one user's differences."""
61+
lines = [f"User {change.proposed.username}:"]
62+
for diff in change.diffs:
63+
lines.append(
64+
f" {diff.name} [{diff.status}]: "
65+
f"existing={diff.existing!r} -> proposed={diff.proposed!r}"
66+
)
67+
return lines
68+
69+
70+
class Prompter:
71+
"""Thin IO seam over input/print for testable prompting."""
72+
73+
def __init__(
74+
self,
75+
input_fn: Callable[[str], str] = input,
76+
output_fn: Callable[[str], None] = print,
77+
):
78+
self._input = input_fn
79+
self._output = output_fn
80+
81+
def write(self, text: str = "") -> None:
82+
self._output(text)
83+
84+
def ask(self, prompt: str, choices: Sequence[str]) -> str:
85+
keys = "/".join(choices)
86+
while True:
87+
answer = self._input(f"{prompt} [{keys}]: ").strip().lower()
88+
if answer in choices:
89+
return answer
90+
self.write(f"Please choose one of: {keys}")
91+
92+
def confirm(self, prompt: str, default: bool = False) -> bool:
93+
suffix = "[Y/n]" if default else "[y/N]"
94+
answer = self._input(f"{prompt} {suffix}: ").strip().lower()
95+
if not answer:
96+
return default
97+
return answer in ("y", "yes")
98+
99+
100+
@dataclass
101+
class ResolvedChange:
102+
"""A user change with the decided action and fields to write."""
103+
104+
change: UserChange
105+
action: str # "create" | "update" | "skip"
106+
apply_fields: Set[str] = field(default_factory=set)
107+
108+
109+
@dataclass
110+
class Resolution:
111+
"""The outcome of resolving a plan: what to write and whether to proceed."""
112+
113+
resolved: List[ResolvedChange] = field(default_factory=list)
114+
accepted: bool = False
115+
116+
def creates(self) -> List[ResolvedChange]:
117+
return [r for r in self.resolved if r.action == "create"]
118+
119+
def updates(self) -> List[ResolvedChange]:
120+
return [r for r in self.resolved if r.action == "update"]
121+
122+
123+
def _review_user(change: UserChange, prompter: Prompter) -> Set[str]:
124+
"""Resolve one conflicting user's fields, returning accepted conflict fields."""
125+
for line in render_conflict(change):
126+
prompter.write(line)
127+
conflict_diffs = [d for d in change.diffs if d.status == CONFLICT]
128+
choice = prompter.ask(
129+
"Take all new / Keep all existing / Field-by-field?", ("a", "k", "f")
130+
)
131+
if choice == "a":
132+
return {d.name for d in conflict_diffs}
133+
if choice == "k":
134+
return set()
135+
accepted: Set[str] = set()
136+
for diff in conflict_diffs:
137+
if prompter.ask(f"{diff.name}: keep or take new?", ("k", "n")) == "n":
138+
accepted.add(diff.name)
139+
return accepted
140+
141+
142+
def resolve_plan(
143+
plan: ReconcilePlan,
144+
prompter: Prompter,
145+
dry_run: bool = False,
146+
assume_yes: bool = False,
147+
summary_lines: Optional[Sequence[str]] = None,
148+
) -> Resolution:
149+
"""Preview the plan and resolve conflicts into a write decision."""
150+
for line in render_preview(plan):
151+
prompter.write(line)
152+
for line in summary_lines or []:
153+
prompter.write(line)
154+
155+
conflict_mode: Optional[str] = None
156+
if plan.conflicts and not dry_run:
157+
if assume_yes:
158+
conflict_mode = "keep"
159+
else:
160+
choice = prompter.ask(
161+
"Conflicts found: Apply all / Keep existing / Review each / Abort?",
162+
("a", "k", "r", "x"),
163+
)
164+
if choice == "x":
165+
return Resolution(resolved=[], accepted=False)
166+
conflict_mode = {"a": "all", "k": "keep", "r": "review"}[choice]
167+
168+
resolved: List[ResolvedChange] = []
169+
for change in plan.changes:
170+
if change.category == NEW:
171+
resolved.append(
172+
ResolvedChange(change, "create", nonempty_fields(change.proposed))
173+
)
174+
elif change.category == UNCHANGED:
175+
resolved.append(ResolvedChange(change, "skip"))
176+
elif change.category == GAP_FILL:
177+
resolved.append(
178+
ResolvedChange(change, "update", {d.name for d in change.diffs})
179+
)
180+
else: # CONFLICT
181+
accepted = {d.name for d in change.diffs if d.status == GAP_FILL}
182+
if conflict_mode == "all":
183+
accepted |= {d.name for d in change.diffs if d.status == CONFLICT}
184+
elif conflict_mode == "review":
185+
accepted |= _review_user(change, prompter)
186+
action = "update" if accepted else "skip"
187+
resolved.append(ResolvedChange(change, action, accepted))
188+
189+
if dry_run:
190+
accepted_run = False
191+
elif assume_yes:
192+
accepted_run = True
193+
else:
194+
n_create = sum(1 for r in resolved if r.action == "create")
195+
n_update = sum(1 for r in resolved if r.action == "update")
196+
accepted_run = prompter.confirm(
197+
f"Proceed to create {n_create} and update {n_update} user items?"
198+
)
199+
return Resolution(resolved=resolved, accepted=accepted_run)
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Unit tests for the interactive preview and conflict resolution."""
2+
3+
import uuid
4+
5+
from osw.tools.user_sync.interactive import (
6+
Prompter,
7+
nonempty_fields,
8+
render_preview,
9+
resolve_plan,
10+
)
11+
from osw.tools.user_sync.mapping import ProposedUser
12+
from osw.tools.user_sync.reconcile import (
13+
CONFLICT,
14+
GAP_FILL,
15+
NEW,
16+
UNCHANGED,
17+
FieldDiff,
18+
ReconcilePlan,
19+
UserChange,
20+
)
21+
22+
23+
class FakeIO:
24+
def __init__(self, answers):
25+
self.answers = list(answers)
26+
self.outputs = []
27+
28+
def input(self, prompt=""):
29+
return self.answers.pop(0)
30+
31+
def output(self, text=""):
32+
self.outputs.append(text)
33+
34+
35+
def _proposed(username="u", **kw):
36+
base = dict(
37+
uuid=uuid.uuid4(),
38+
username=username,
39+
first_name="F",
40+
surname="S",
41+
label="F S",
42+
orcid=None,
43+
emails=[],
44+
websites=[],
45+
organizations=[],
46+
)
47+
base.update(kw)
48+
return ProposedUser(**base)
49+
50+
51+
def _change(category, username="u", diffs=None, placeholder=False):
52+
proposed = _proposed(username=username)
53+
proposed.placeholder_name = placeholder
54+
return UserChange(
55+
proposed=proposed,
56+
existing=None if category == NEW else object(),
57+
category=category,
58+
diffs=diffs or [],
59+
)
60+
61+
62+
def _prompter(answers):
63+
io = FakeIO(answers)
64+
return Prompter(io.input, io.output), io
65+
66+
67+
def test_render_preview():
68+
plan = ReconcilePlan(changes=[_change(NEW, "alice", placeholder=True)])
69+
lines = render_preview(plan)
70+
assert lines[0].startswith("Users: 1")
71+
assert "new=1" in lines[0]
72+
assert "+ alice new" in lines[1]
73+
assert "[placeholder name]" in lines[1]
74+
75+
76+
def test_nonempty_fields():
77+
proposed = _proposed(orcid="https://orcid.org/x", emails=["e@x.org"])
78+
assert nonempty_fields(proposed) == {
79+
"label",
80+
"first_name",
81+
"surname",
82+
"orcid",
83+
"emails",
84+
}
85+
86+
87+
def _mixed_plan():
88+
return ReconcilePlan(
89+
changes=[
90+
_change(NEW, "new1"),
91+
_change(GAP_FILL, "gap1", [FieldDiff("emails", set(), {"e"}, GAP_FILL)]),
92+
_change(CONFLICT, "con1", [FieldDiff("surname", "Old", "New", CONFLICT)]),
93+
_change(
94+
CONFLICT,
95+
"mix1",
96+
[
97+
FieldDiff("emails", set(), {"e"}, GAP_FILL),
98+
FieldDiff("surname", "Old", "New", CONFLICT),
99+
],
100+
),
101+
_change(UNCHANGED, "same1"),
102+
]
103+
)
104+
105+
106+
def test_resolve_keep_existing():
107+
prompter, _ = _prompter(["k", "y"])
108+
res = resolve_plan(_mixed_plan(), prompter)
109+
assert [r.change.proposed.username for r in res.creates()] == ["new1"]
110+
updates = {r.change.proposed.username: r.apply_fields for r in res.updates()}
111+
assert updates == {"gap1": {"emails"}, "mix1": {"emails"}}
112+
skipped = {r.change.proposed.username for r in res.resolved if r.action == "skip"}
113+
assert skipped == {"con1", "same1"}
114+
assert res.accepted is True
115+
116+
117+
def test_resolve_apply_all():
118+
prompter, _ = _prompter(["a", "y"])
119+
res = resolve_plan(_mixed_plan(), prompter)
120+
updates = {r.change.proposed.username: r.apply_fields for r in res.updates()}
121+
assert updates["con1"] == {"surname"}
122+
assert updates["mix1"] == {"emails", "surname"}
123+
124+
125+
def test_resolve_abort():
126+
prompter, _ = _prompter(["x"])
127+
res = resolve_plan(_mixed_plan(), prompter)
128+
assert res.resolved == []
129+
assert res.accepted is False
130+
131+
132+
def test_resolve_dry_run_makes_no_writes_and_no_prompts():
133+
prompter, io = _prompter([]) # no answers available
134+
res = resolve_plan(_mixed_plan(), prompter, dry_run=True)
135+
assert res.accepted is False
136+
assert io.answers == [] # nothing was asked
137+
138+
139+
def test_resolve_assume_yes_keeps_conflicts():
140+
prompter, io = _prompter([])
141+
res = resolve_plan(_mixed_plan(), prompter, assume_yes=True)
142+
assert res.accepted is True
143+
updates = {r.change.proposed.username: r.apply_fields for r in res.updates()}
144+
assert updates == {"gap1": {"emails"}, "mix1": {"emails"}}
145+
assert io.answers == []
146+
147+
148+
def test_resolve_review_field_by_field():
149+
plan = ReconcilePlan(
150+
changes=[
151+
_change(
152+
CONFLICT,
153+
"con1",
154+
[
155+
FieldDiff("surname", "Old", "New", CONFLICT),
156+
FieldDiff("label", "L1", "L2", CONFLICT),
157+
],
158+
)
159+
]
160+
)
161+
prompter, _ = _prompter(["r", "f", "n", "k", "y"])
162+
res = resolve_plan(plan, prompter)
163+
assert res.updates()[0].apply_fields == {"surname"}
164+
assert res.accepted is True
165+
166+
167+
def test_resolve_review_take_all_for_user():
168+
plan = ReconcilePlan(
169+
changes=[
170+
_change(
171+
CONFLICT,
172+
"con1",
173+
[
174+
FieldDiff("surname", "Old", "New", CONFLICT),
175+
FieldDiff("label", "L1", "L2", CONFLICT),
176+
],
177+
)
178+
]
179+
)
180+
prompter, _ = _prompter(["r", "a", "y"])
181+
res = resolve_plan(plan, prompter)
182+
assert res.updates()[0].apply_fields == {"surname", "label"}
183+
184+
185+
def test_final_confirm_no():
186+
plan = ReconcilePlan(changes=[_change(NEW, "new1")])
187+
prompter, _ = _prompter(["n"])
188+
res = resolve_plan(plan, prompter)
189+
assert res.accepted is False
190+
assert res.creates()[0].change.proposed.username == "new1"
191+
192+
193+
def test_prompter_reprompts_on_invalid():
194+
prompter, io = _prompter(["z", "k"])
195+
assert prompter.ask("q", ("k", "n")) == "k"
196+
assert any("Please choose one of" in o for o in io.outputs)

0 commit comments

Comments
 (0)