Skip to content

Commit 4e38b29

Browse files
committed
feat(user-sync): gate optional fields and opt-in prune removals
1 parent 31c2f82 commit 4e38b29

9 files changed

Lines changed: 179 additions & 18 deletions

File tree

‎src/osw/tools/user_sync/config.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class SyncConfig:
4141
# reported as warnings rather than failing the run.
4242
include_websites: bool = False
4343
link_organizations: bool = False
44+
# Remove disabled optional fields and protected fields from existing items.
45+
prune: bool = False
4446
orcid_api_base: str = ORCID_API_BASE_DEFAULT
4547
excluded_groups: List[str] = field(default_factory=lambda: list(SYSTEM_GROUPS))
4648

@@ -105,6 +107,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
105107
action="store_true",
106108
help="Enable all optional data: websites and organizations.",
107109
)
110+
parser.add_argument(
111+
"--prune",
112+
dest="prune",
113+
action="store_true",
114+
help="Remove disabled optional fields and employment_contract_status from "
115+
"existing items (data cleanup). Off by default; a normal run only adds.",
116+
)
108117
account_scope = parser.add_mutually_exclusive_group()
109118
account_scope.add_argument(
110119
"--orcid-only",
@@ -142,4 +151,5 @@ def config_from_args(argv: Optional[List[str]] = None) -> SyncConfig:
142151
create_redirects=args.create_redirects,
143152
include_websites=args.include_websites or args.with_extras,
144153
link_organizations=args.link_organizations or args.with_extras,
154+
prune=args.prune,
145155
)

‎src/osw/tools/user_sync/interactive.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
GAP_FILL,
1616
NEW,
1717
RECONCILED_FIELDS,
18+
REMOVE,
1819
UNCHANGED,
1920
ReconcilePlan,
2021
UserChange,
@@ -48,7 +49,10 @@ def render_preview(plan: ReconcilePlan) -> List[str]:
4849
marker = _MARKERS[change.category]
4950
detail = ""
5051
if change.diffs:
51-
detail = " (" + ", ".join(d.name for d in change.diffs) + ")"
52+
names = [
53+
("-" + d.name if d.status == REMOVE else d.name) for d in change.diffs
54+
]
55+
detail = " (" + ", ".join(names) + ")"
5256
placeholder = " [placeholder name]" if change.proposed.placeholder_name else ""
5357
lines.append(
5458
f" {marker} {change.proposed.username} {change.category}{detail}{placeholder}"
@@ -178,7 +182,7 @@ def resolve_plan(
178182
ResolvedChange(change, "update", {d.name for d in change.diffs})
179183
)
180184
else: # CONFLICT
181-
accepted = {d.name for d in change.diffs if d.status == GAP_FILL}
185+
accepted = {d.name for d in change.diffs if d.status in (GAP_FILL, REMOVE)}
182186
if conflict_mode == "all":
183187
accepted |= {d.name for d in change.diffs if d.status == CONFLICT}
184188
elif conflict_mode == "review":

‎src/osw/tools/user_sync/mapping.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,14 @@ def _derive_names(
113113
def map_user(
114114
mw_user: MwUser,
115115
profile: Optional[OrcidProfile] = None,
116-
link_organizations: bool = True,
116+
include_websites: bool = False,
117+
link_organizations: bool = False,
117118
) -> Tuple[ProposedUser, List[ProposedOrganization]]:
118-
"""Build a ProposedUser (and any linked organizations) from the sources."""
119+
"""Build a ProposedUser (and any linked organizations) from the sources.
120+
121+
Email and names are always taken from the profile (core). Websites and
122+
organizations are populated only when their flag is enabled.
123+
"""
119124
orcid_uri = f"https://orcid.org/{mw_user.orcid_id}" if mw_user.orcid_id else None
120125
first, surname, label, placeholder = _derive_names(profile, mw_user.name)
121126

@@ -139,7 +144,7 @@ def map_user(
139144
label=label,
140145
orcid=orcid_uri,
141146
emails=list(profile.emails) if profile else [],
142-
websites=list(profile.urls) if profile else [],
147+
websites=list(profile.urls) if (profile and include_websites) else [],
143148
organizations=org_titles,
144149
placeholder_name=placeholder,
145150
)

‎src/osw/tools/user_sync/reconcile.py‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
"""Reconcile proposed User items against the ones already stored.
22
33
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.
4+
into NEW, GAP_FILL, CONFLICT or UNCHANGED and record the per-field differences
5+
(including REMOVE diffs for disabled optional fields and protected fields).
56
The interactive layer consumes this plan; nothing here does IO.
67
"""
78

89
from __future__ import annotations
910

1011
from collections import Counter
1112
from dataclasses import dataclass, field
12-
from typing import Any, Dict, List, Optional
13+
from typing import Any, Dict, FrozenSet, List, Optional
1314

15+
from .config import PROTECTED_FIELDS
1416
from .existing import ExistingUsers
1517
from .mapping import ProposedUser
1618

1719
NEW = "new"
1820
GAP_FILL = "gap_fill"
1921
CONFLICT = "conflict"
22+
REMOVE = "remove"
2023
UNCHANGED = "unchanged"
2124

2225
# username is the match key and is never reconciled as a value.
2326
SCALAR_FIELDS = ("label", "first_name", "surname", "orcid")
2427
SET_FIELDS = ("emails", "websites", "organizations")
2528
URL_FIELDS = ("orcid", "websites")
2629
RECONCILED_FIELDS = SCALAR_FIELDS + SET_FIELDS
30+
# Fields that are only written when opted in; removed from existing when disabled.
31+
OPTIONAL_FIELDS = ("websites", "organizations")
2732

2833

2934
def _is_empty(value: Any) -> bool:
@@ -76,6 +81,12 @@ def _existing_orgs(entity: Any) -> Any:
7681
return None
7782

7883

84+
def _existing_has_iri(entity: Any, name: str) -> bool:
85+
"""True if the loaded entity carries a relation IRI for ``name``."""
86+
iris = getattr(entity, "__iris__", None)
87+
return isinstance(iris, dict) and bool(iris.get(name))
88+
89+
7990
def existing_fields(entity: Any) -> Dict[str, Any]:
8091
"""Normalized comparable field view of a loaded User entity."""
8192
return {
@@ -109,7 +120,7 @@ class FieldDiff:
109120
name: str
110121
existing: Any
111122
proposed: Any
112-
status: str # GAP_FILL or CONFLICT
123+
status: str # GAP_FILL, CONFLICT or REMOVE
113124

114125

115126
@dataclass
@@ -131,20 +142,37 @@ def _compare(existing_val: Any, proposed_val: Any) -> Optional[str]:
131142
return UNCHANGED if existing_val == proposed_val else CONFLICT
132143

133144

134-
def reconcile_user(proposed: ProposedUser, existing: Optional[Any]) -> UserChange:
145+
def reconcile_user(
146+
proposed: ProposedUser,
147+
existing: Optional[Any],
148+
enabled_fields: FrozenSet[str] = frozenset(),
149+
prune: bool = False,
150+
) -> UserChange:
135151
if existing is None:
136152
return UserChange(proposed=proposed, existing=None, category=NEW)
137153
ef = existing_fields(existing)
138154
pf = proposed_fields(proposed)
139155
diffs: List[FieldDiff] = []
140-
has_conflict = has_gap = False
156+
has_conflict = has_change = False
141157
for name in RECONCILED_FIELDS:
158+
if name in OPTIONAL_FIELDS and name not in enabled_fields:
159+
# Disabled optional field: remove any existing value only when pruning.
160+
if prune and not _is_empty(ef[name]):
161+
diffs.append(FieldDiff(name, ef[name], None, REMOVE))
162+
has_change = True
163+
continue
142164
status = _compare(ef[name], pf[name])
143165
if status in (GAP_FILL, CONFLICT):
144166
diffs.append(FieldDiff(name, ef[name], pf[name], status))
145167
has_conflict = has_conflict or status == CONFLICT
146-
has_gap = has_gap or status == GAP_FILL
147-
category = CONFLICT if has_conflict else GAP_FILL if has_gap else UNCHANGED
168+
has_change = has_change or status == GAP_FILL
169+
# Protected fields are stripped from existing items only when pruning.
170+
if prune:
171+
for name in PROTECTED_FIELDS:
172+
if _existing_has_iri(existing, name):
173+
diffs.append(FieldDiff(name, "set", None, REMOVE))
174+
has_change = True
175+
category = CONFLICT if has_conflict else GAP_FILL if has_change else UNCHANGED
148176
return UserChange(
149177
proposed=proposed, existing=existing, category=category, diffs=diffs
150178
)
@@ -171,7 +199,13 @@ def counts(self) -> Dict[str, int]:
171199

172200

173201
def reconcile(
174-
proposed_users: List[ProposedUser], existing: ExistingUsers
202+
proposed_users: List[ProposedUser],
203+
existing: ExistingUsers,
204+
enabled_fields: FrozenSet[str] = frozenset(),
205+
prune: bool = False,
175206
) -> ReconcilePlan:
176-
changes = [reconcile_user(p, existing.get(p.username)) for p in proposed_users]
207+
changes = [
208+
reconcile_user(p, existing.get(p.username), enabled_fields, prune)
209+
for p in proposed_users
210+
]
177211
return ReconcilePlan(changes=changes)

‎src/osw/tools/user_sync/sync.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ def _build_proposals(config: SyncConfig, osw: Any, session: Any):
7777
except OrcidRateLimitError:
7878
profile = None
7979
proposed, orgs = map_user(
80-
mw_user, profile, link_organizations=config.link_organizations
80+
mw_user,
81+
profile,
82+
include_websites=config.include_websites,
83+
link_organizations=config.link_organizations,
8184
)
8285
proposed_users.append(proposed)
8386
for org in orgs:
@@ -173,7 +176,12 @@ def run_user_sync(
173176

174177
proposed_users, org_map = _build_proposals(config, osw, session)
175178
existing = load_existing_users(osw)
176-
plan = reconcile(proposed_users, existing)
179+
plan = reconcile(
180+
proposed_users,
181+
existing,
182+
enabled_fields=frozenset(config.enabled_optional_fields()),
183+
prune=config.prune,
184+
)
177185

178186
summary_lines = []
179187
if config.link_organizations and org_map:

‎tests/tools/test_user_sync_config.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def test_defaults():
1717
# Websites and organizations are opt-in (off by default); email is standard.
1818
assert cfg.include_websites is False
1919
assert cfg.link_organizations is False
20+
assert cfg.prune is False # removals are opt-in
2021
assert cfg.enabled_optional_fields() == set()
2122
assert cfg.exclude_system_usernames is True
2223
assert "bot" in cfg.excluded_groups
@@ -42,6 +43,11 @@ def test_with_extras_enables_all():
4243
assert cfg.enabled_optional_fields() == {"websites", "organizations"}
4344

4445

46+
def test_prune_flag():
47+
assert config_from_args([]).prune is False
48+
assert config_from_args(["--prune"]).prune is True
49+
50+
4551
def test_config_from_args_parses_flags():
4652
cfg = config_from_args([
4753
"--domain",

‎tests/tools/test_user_sync_interactive.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
CONFLICT,
1414
GAP_FILL,
1515
NEW,
16+
REMOVE,
1617
UNCHANGED,
1718
FieldDiff,
1819
ReconcilePlan,
@@ -190,6 +191,41 @@ def test_final_confirm_no():
190191
assert res.creates()[0].change.proposed.username == "new1"
191192

192193

194+
def test_removals_auto_apply_and_marked_in_preview():
195+
plan = ReconcilePlan(
196+
changes=[
197+
_change(
198+
GAP_FILL,
199+
"u1",
200+
[FieldDiff("organizations", {"x"}, None, REMOVE)],
201+
)
202+
]
203+
)
204+
assert any("-organizations" in ln for ln in render_preview(plan))
205+
prompter, _ = _prompter(["y"]) # only the final confirm
206+
res = resolve_plan(plan, prompter)
207+
assert res.updates()[0].apply_fields == {"organizations"}
208+
assert res.accepted is True
209+
210+
211+
def test_conflict_user_still_auto_applies_removal():
212+
plan = ReconcilePlan(
213+
changes=[
214+
_change(
215+
CONFLICT,
216+
"u1",
217+
[
218+
FieldDiff("surname", "Old", "New", CONFLICT),
219+
FieldDiff("organizations", {"x"}, None, REMOVE),
220+
],
221+
)
222+
]
223+
)
224+
prompter, _ = _prompter(["k", "y"]) # keep existing on conflict, final yes
225+
res = resolve_plan(plan, prompter)
226+
assert res.updates()[0].apply_fields == {"organizations"}
227+
228+
193229
def test_prompter_reprompts_on_invalid():
194230
prompter, io = _prompter(["z", "k"])
195231
assert prompter.ask("q", ("k", "n")) == "k"

‎tests/tools/test_user_sync_mapping.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ def test_map_orcid_user_full_profile():
2929
urls=["https://example.org"],
3030
affiliations=[OrcidAffiliation(organization="Example University")],
3131
)
32-
proposed, orgs = map_user(mw, profile)
32+
proposed, orgs = map_user(
33+
mw, profile, include_websites=True, link_organizations=True
34+
)
3335
assert isinstance(proposed, ProposedUser)
3436
assert proposed.username == ORCID
3537
assert proposed.orcid == f"https://orcid.org/{ORCID}"
@@ -99,7 +101,7 @@ def test_duplicate_affiliations_deduped():
99101
OrcidAffiliation(organization="example university"),
100102
]
101103
)
102-
proposed, orgs = map_user(mw, profile)
104+
proposed, orgs = map_user(mw, profile, link_organizations=True)
103105
assert len(orgs) == 1
104106
assert len(proposed.organizations) == 1
105107

@@ -110,3 +112,17 @@ def test_link_organizations_disabled():
110112
proposed, orgs = map_user(mw, profile, link_organizations=False)
111113
assert orgs == []
112114
assert proposed.organizations == []
115+
116+
117+
def test_email_always_included_but_extras_gated_by_default():
118+
mw = MwUser(name=ORCID)
119+
profile = _profile(
120+
emails=["l@example.org"],
121+
urls=["https://example.org"],
122+
affiliations=[OrcidAffiliation(organization="Example U")],
123+
)
124+
proposed, orgs = map_user(mw, profile) # defaults: websites/orgs off
125+
assert proposed.emails == ["l@example.org"]
126+
assert proposed.websites == []
127+
assert proposed.organizations == []
128+
assert orgs == []

‎tests/tools/test_user_sync_reconcile.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
CONFLICT,
1010
GAP_FILL,
1111
NEW,
12+
REMOVE,
1213
UNCHANGED,
1314
reconcile,
1415
reconcile_user,
@@ -109,10 +110,51 @@ def test_set_order_independent():
109110
change = reconcile_user(
110111
_proposed(organizations=["Item:OSWb", "Item:OSWa"]),
111112
_entity(organization=["Item:OSWa", "Item:OSWb"]),
113+
enabled_fields=frozenset({"organizations"}),
112114
)
113115
assert change.category == UNCHANGED
114116

115117

118+
def test_disabled_optional_field_removed_when_pruning():
119+
# organizations disabled + existing value + prune -> REMOVE
120+
change = reconcile_user(
121+
_proposed(), _entity(organization=["Item:OSWa"]), prune=True
122+
)
123+
assert change.category == GAP_FILL
124+
removes = [d for d in change.diffs if d.status == REMOVE]
125+
assert [d.name for d in removes] == ["organizations"]
126+
assert removes[0].proposed is None
127+
128+
129+
def test_prune_off_keeps_disabled_and_protected():
130+
entity = _entity()
131+
entity.__iris__ = {
132+
"organization": ["Item:OSWa"],
133+
"employment_contract_status": "Item:OSWx",
134+
}
135+
change = reconcile_user(_proposed(), entity) # prune off (default)
136+
assert change.category == UNCHANGED
137+
assert change.diffs == []
138+
139+
140+
def test_enabled_optional_field_not_removed():
141+
change = reconcile_user(
142+
_proposed(organizations=["Item:OSWa"]),
143+
_entity(organization=["Item:OSWa"]),
144+
enabled_fields=frozenset({"organizations"}),
145+
)
146+
assert change.category == UNCHANGED
147+
148+
149+
def test_protected_field_removed_when_pruning():
150+
entity = _entity()
151+
entity.__iris__ = {"employment_contract_status": "Item:OSWx"}
152+
change = reconcile_user(_proposed(), entity, prune=True)
153+
removes = [d.name for d in change.diffs if d.status == REMOVE]
154+
assert "employment_contract_status" in removes
155+
assert change.category == GAP_FILL
156+
157+
116158
def test_missing_label_is_gap_fill():
117159
change = reconcile_user(_proposed(label="Lukas Koschmieder"), _entity(label=None))
118160
assert change.category == GAP_FILL

0 commit comments

Comments
 (0)