Skip to content

Commit 9b36e4e

Browse files
committed
fix(user-sync): prune by field type and decide from stored jsondata
- set list fields to [] and delete single-valued keys on --prune - read stored jsondata via offline_pages, without an extra request - decide protected-field removal from the stored jsondata - a second --prune run no longer proposes the same removal
1 parent 0b8dc97 commit 9b36e4e

10 files changed

Lines changed: 234 additions & 34 deletions

File tree

‎docs/tools/user-sync.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ prompt.
3030

3131
To also clean up existing items, add `--prune`. Without it, the sync never
3232
writes `employment_contract_status` and never removes anything. With it,
33-
`employment_contract_status` on existing items is overwritten with an empty
34-
value. So are `website` and `organization`, unless `--with-websites` or
35-
`--with-organizations` enables them. This happens in a second edit of the
36-
page's jsondata after the store:
33+
`employment_contract_status` is removed from the stored jsondata of existing
34+
items. `website` and `organization` are overwritten with an empty list, unless
35+
`--with-websites` or `--with-organizations` enables them. This happens in a
36+
second edit of the page's jsondata after the store. The sync decides from the
37+
stored jsondata, not from the loaded item, which shows the model default for
38+
`employment_contract_status`. A second `--prune` run therefore changes nothing:
3739

3840
```bash
3941
uv run python examples/user_sync.py --domain your-instance.example.org --prune --dry-run
@@ -62,7 +64,7 @@ only core identity plus email.
6264
| `--with-organizations` | Also create and link Organization items from ORCID affiliations (opt-in). |
6365
| `--with-extras` | Enable both websites and organizations. |
6466
| `--include-blocked` | Do not skip blocked MediaWiki accounts. |
65-
| `--prune` | Overwrite `employment_contract_status`, and `website` or `organization` when their opt-in flag is not given, on existing items with an empty value, in a second edit of the page's jsondata after the store (default off; a normal run never writes `employment_contract_status` and never removes anything). |
67+
| `--prune` | Remove `employment_contract_status` from the stored jsondata of existing items, and overwrite `website` or `organization` with an empty list when their opt-in flag is not given, in a second edit of the page's jsondata after the store (default off; a normal run never writes `employment_contract_status` and never removes anything). |
6668

6769
## What it does
6870

@@ -95,9 +97,9 @@ only core identity plus email.
9597
| --- | --- |
9698
| `username`, `first_name`, `surname`, `label`, `orcid` | Always (core identity). |
9799
| `email` | Standard: always attempted; missing email is warned, not fatal; never removed. |
98-
| `website` | Opt-in (`--with-websites`); without that flag, on existing items overwritten with an empty value only with `--prune`, in a second jsondata edit after the store. |
99-
| `organization` | Opt-in (`--with-organizations`); without that flag, on existing items overwritten with an empty value only with `--prune`, in a second jsondata edit after the store. |
100-
| `employment_contract_status` | Never written without `--prune`; with `--prune`, overwritten with an empty value on existing items in a second jsondata edit after the store. |
100+
| `website` | Opt-in (`--with-websites`); without that flag, on existing items overwritten with an empty list only with `--prune`, in a second jsondata edit after the store. |
101+
| `organization` | Opt-in (`--with-organizations`); without that flag, on existing items overwritten with an empty list only with `--prune`, in a second jsondata edit after the store. |
102+
| `employment_contract_status` | Never written without `--prune`; with `--prune`, removed from the stored jsondata of existing items in a second jsondata edit after the store. |
101103

102104
## Notes
103105

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
113113
"--prune",
114114
dest="prune",
115115
action="store_true",
116-
help="Remove disabled optional fields from existing items and overwrite "
117-
"employment_contract_status with an empty value (data cleanup). Off by "
116+
help="Overwrite disabled optional fields of existing items with an empty "
117+
"list and remove employment_contract_status (data cleanup). Off by "
118118
"default; a normal run only adds.",
119119
)
120120
account_scope = parser.add_mutually_exclusive_group()

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

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
from __future__ import annotations
88

9+
import copy
910
from dataclasses import dataclass, field
10-
from typing import Any, Dict, List, Optional
11+
from typing import Any, Dict, List, Optional, Tuple
1112

13+
from osw.core import OSW
1214
from osw.wiki_tools import SearchParam
15+
from osw.wtsite import WtSite
1316

1417
from .config import USER_CATEGORY
1518

@@ -22,10 +25,16 @@ class ExistingUsers:
2225
"""Existing User items indexed by their MediaWiki username."""
2326

2427
by_username: Dict[str, Any] = field(default_factory=dict)
28+
# The jsondata as stored on the page, per username. The loaded entity cannot
29+
# tell a stored value from a model default.
30+
stored_jsondata: Dict[str, dict] = field(default_factory=dict)
2531

2632
def get(self, username: str) -> Optional[Any]:
2733
return self.by_username.get(username)
2834

35+
def get_stored(self, username: str) -> Optional[dict]:
36+
return self.stored_jsondata.get(username)
37+
2938

3039
def _as_entity_list(result: Any) -> List[Any]:
3140
"""Normalize load_entity output (single, list, or LoadEntityResult)."""
@@ -39,6 +48,29 @@ def _as_entity_list(result: Any) -> List[Any]:
3948
return [result]
4049

4150

51+
def _load_with_stored(osw: Any, titles: List[str]) -> Tuple[List[Any], Dict[str, dict]]:
52+
"""Load the entities and read the stored jsondata of the same pages.
53+
54+
The pages are fetched once and handed to load_entity as offline pages, so the
55+
jsondata is read without a second request. A copy is taken first, because
56+
load_entity removes empty keys from the slot content in place.
57+
58+
Returns:
59+
The entities and the stored jsondata keyed by the uuid in it.
60+
"""
61+
pages = osw.site.get_page(WtSite.GetPageParam(titles=titles)).pages
62+
offline_pages = {page.title: page for page in pages}
63+
stored: Dict[str, dict] = {}
64+
for page in pages:
65+
jsondata = page.get_slot_content("jsondata")
66+
if isinstance(jsondata, dict):
67+
stored[str(jsondata.get("uuid"))] = copy.deepcopy(jsondata)
68+
result = osw.load_entity(
69+
OSW.LoadEntityParam(titles=titles, offline_pages=offline_pages)
70+
)
71+
return _as_entity_list(result), stored
72+
73+
4274
def _query_user_titles(
4375
osw: Any, category: str, limit: Optional[int] = None
4476
) -> List[str]:
@@ -88,13 +120,16 @@ def load_existing_users(
88120
titles = _query_user_titles(osw, category, limit)
89121
if not titles:
90122
return ExistingUsers()
91-
entities = _as_entity_list(osw.load_entity(titles))
92-
by_username: Dict[str, Any] = {}
123+
entities, stored = _load_with_stored(osw, titles)
124+
existing = ExistingUsers()
93125
for entity in entities:
94126
username = getattr(entity, "username", None)
95127
if username:
96-
by_username[username] = entity
97-
return ExistingUsers(by_username=by_username)
128+
existing.by_username[username] = entity
129+
jsondata = stored.get(str(getattr(entity, "uuid", None)))
130+
if jsondata is not None:
131+
existing.stored_jsondata[username] = jsondata
132+
return existing
98133

99134

100135
def adopt_existing_pages(
@@ -118,12 +153,15 @@ def adopt_existing_pages(
118153
present = [p for p in candidates if p.full_page_title not in missing]
119154
if not present:
120155
return []
121-
entities = _as_entity_list(osw.load_entity([p.full_page_title for p in present]))
156+
entities, stored = _load_with_stored(osw, [p.full_page_title for p in present])
122157
by_uuid = {str(getattr(e, "uuid", None)): e for e in entities if e is not None}
123158
adopted: List[str] = []
124159
for proposed in present:
125160
entity = by_uuid.get(str(proposed.uuid))
126161
if entity is not None:
127162
existing.by_username[proposed.username] = entity
163+
jsondata = stored.get(str(proposed.uuid))
164+
if jsondata is not None:
165+
existing.stored_jsondata[proposed.username] = jsondata
128166
adopted.append(proposed.full_page_title)
129167
return adopted

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
"""Clear stored relations of existing user items (``--prune``).
22
33
The store merges with the stored page, so an absent key keeps the stored value.
4-
The keys are therefore overwritten with an empty list in a second edit of the
5-
page's jsondata slot.
4+
The keys are therefore changed in a second edit of the page's jsondata slot.
5+
A key of a list or set field is overwritten with an empty list. A key of a
6+
single-valued field is deleted, because an empty list is not a valid value for
7+
it. A key that is not a field of User is deleted as well.
68
"""
79

810
from __future__ import annotations
911

1012
import json
1113
from typing import Any, Iterable
1214

15+
from opensemantic.base.v1 import User
16+
from pydantic.v1.fields import SHAPE_SINGLETON
17+
1318
from osw.wtsite import WtPage
1419

1520

21+
def _is_multi_valued(key: str) -> bool:
22+
"""True if the User field is a list or a set, False for any other key."""
23+
model_field = User.__fields__.get(key)
24+
return model_field is not None and model_field.shape != SHAPE_SINGLETON
25+
26+
1627
def clear_jsondata_keys(osw: Any, title: str, keys: Iterable[str]) -> bool:
17-
"""Set each present, non-empty key in the page's jsondata slot to ``[]``.
28+
"""Clear each present, non-empty key in the page's jsondata slot.
1829
19-
The page is read with a new WtPage, which downloads the current revision and
20-
bypasses the WtSite page cache. Other keys are written back unchanged.
30+
A list or set field becomes ``[]``. Any other key is deleted. The page is
31+
read with a new WtPage, which downloads the current revision and bypasses the
32+
WtSite page cache. Other keys are written back unchanged.
2133
Returns True if the page was written, False if nothing changed.
2234
"""
2335
page = WtPage(wtSite=osw.site, title=title)
@@ -29,7 +41,10 @@ def clear_jsondata_keys(osw: Any, title: str, keys: Iterable[str]) -> bool:
2941
changed = False
3042
for key in keys:
3143
if key in content and content[key] not in (None, [], "", {}):
32-
content[key] = []
44+
if _is_multi_valued(key):
45+
content[key] = []
46+
else:
47+
del content[key]
3348
changed = True
3449
if not changed:
3550
return False

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ def _existing_has_iri(entity: Any, name: str) -> bool:
9191
return isinstance(iris, dict) and bool(iris.get(name))
9292

9393

94+
def _has_protected_value(
95+
entity: Any, name: str, stored_jsondata: Optional[Dict[str, Any]]
96+
) -> bool:
97+
"""True if the protected field holds a value that pruning would remove.
98+
99+
Without stored jsondata the loaded entity is the only source.
100+
"""
101+
if stored_jsondata is None:
102+
return _existing_has_iri(entity, name)
103+
return not _is_empty(stored_jsondata.get(name))
104+
105+
94106
def existing_fields(entity: Any) -> Dict[str, Any]:
95107
"""Normalized comparable field view of a loaded User entity."""
96108
return {
@@ -151,6 +163,7 @@ def reconcile_user(
151163
existing: Optional[Any],
152164
enabled_fields: FrozenSet[str] = frozenset(),
153165
prune: bool = False,
166+
stored_jsondata: Optional[Dict[str, Any]] = None,
154167
) -> UserChange:
155168
if existing is None:
156169
return UserChange(proposed=proposed, existing=None, category=NEW)
@@ -178,10 +191,11 @@ def reconcile_user(
178191
if not getattr(existing, "username", None):
179192
diffs.append(FieldDiff("username", None, proposed.username, GAP_FILL))
180193
has_change = True
181-
# Protected fields are stripped from existing items only when pruning.
194+
# Protected fields are stripped from existing items only when pruning. The
195+
# stored jsondata decides, because a loaded entity also shows model defaults.
182196
if prune:
183197
for name in PROTECTED_FIELDS:
184-
if _existing_has_iri(existing, name):
198+
if _has_protected_value(existing, name, stored_jsondata):
185199
diffs.append(FieldDiff(name, "set", None, REMOVE))
186200
has_change = True
187201
category = CONFLICT if has_conflict else GAP_FILL if has_change else UNCHANGED
@@ -217,7 +231,13 @@ def reconcile(
217231
prune: bool = False,
218232
) -> ReconcilePlan:
219233
changes = [
220-
reconcile_user(p, existing.get(p.username), enabled_fields, prune)
234+
reconcile_user(
235+
p,
236+
existing.get(p.username),
237+
enabled_fields,
238+
prune,
239+
existing.get_stored(p.username),
240+
)
221241
for p in proposed_users
222242
]
223243
return ReconcilePlan(changes=changes)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def _store_users(
191191

192192

193193
def _prune_users(osw: Any, resolution: Resolution, report: SyncReport) -> None:
194-
"""Overwrite removed fields of existing items with an empty value."""
194+
"""Clear removed fields of stored existing items (see clear_jsondata_keys)."""
195195
updated = set(report.updated)
196196
for resolved in resolution.resolved:
197197
if resolved.action != "update":

‎tests/tools/test_user_sync_existing.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@
1010
adopt_existing_pages,
1111
load_existing_users,
1212
)
13+
from osw.wtsite import WtPage
14+
15+
16+
class FakeWtPage(WtPage):
17+
"""A WtPage that serves a fixed jsondata slot without a wiki."""
18+
19+
def __init__(self, title, jsondata=None):
20+
self.title = title
21+
self._jsondata = jsondata or {}
22+
23+
def get_slot_content(self, slot, *args, **kwargs):
24+
return self._jsondata
1325

1426

1527
def _ask_result(titles, continue_offset=None):
@@ -33,6 +45,11 @@ class FakeSite:
3345
def __init__(self, responses):
3446
self._responses = list(responses)
3547
self.queries = []
48+
self.stored = {}
49+
50+
def get_page(self, param):
51+
pages = [FakeWtPage(t, self.stored.get(t, {"uuid": t})) for t in param.titles]
52+
return SimpleNamespace(pages=pages, errors=[])
3653

3754
def api(self, action, **params):
3855
assert action == "ask"
@@ -54,8 +71,10 @@ def _get_missing_page_titles(self, titles, confirm_delay_s=5):
5471
assert confirm_delay_s == 0
5572
return [t for t in titles if t in self.missing]
5673

57-
def load_entity(self, titles):
74+
def load_entity(self, param):
75+
titles = param.titles
5876
self.loaded = list(titles)
77+
assert set(param.offline_pages) == set(titles)
5978
return [self._entities[t] for t in titles]
6079

6180

@@ -157,3 +176,28 @@ def test_adopt_skips_users_already_matched_by_username():
157176
existing = ExistingUsers(by_username={"Alice": SimpleNamespace(username="Alice")})
158177
assert adopt_existing_pages(osw, existing, [_proposed("Alice", "1")]) == []
159178
assert osw.loaded is None
179+
180+
181+
def test_load_records_the_stored_jsondata_per_username():
182+
entities = {
183+
"Item:OSW1": SimpleNamespace(username="Alice", uuid="u1"),
184+
"Item:OSW2": SimpleNamespace(username="Bob", uuid="u2"),
185+
}
186+
osw = FakeOsw(["Item:OSW1", "Item:OSW2"], entities)
187+
osw.site.stored = {
188+
"Item:OSW1": {"uuid": "u1", "username": "Alice", "website": []},
189+
"Item:OSW2": {"uuid": "u2", "username": "Bob"},
190+
}
191+
existing = load_existing_users(osw)
192+
assert existing.get_stored("Alice") == osw.site.stored["Item:OSW1"]
193+
assert "employment_contract_status" not in existing.get_stored("Bob")
194+
assert existing.get_stored("missing") is None
195+
196+
197+
def test_adopt_records_the_stored_jsondata():
198+
found = SimpleNamespace(username=None, uuid="1")
199+
osw = FakeOsw([], {"Item:OSW1": found}, pages=[_ask_result([])])
200+
osw.site.stored = {"Item:OSW1": {"uuid": "1", "label": [{"text": "A"}]}}
201+
existing = ExistingUsers()
202+
adopt_existing_pages(osw, existing, [_proposed("Alice", "1")])
203+
assert existing.get_stored("Alice") == osw.site.stored["Item:OSW1"]

‎tests/tools/test_user_sync_reconcile.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,40 @@ def test_protected_field_removed_when_pruning():
157157
assert change.category == GAP_FILL
158158

159159

160+
def test_protected_default_not_stored_is_not_removed():
161+
# The loaded entity shows the model default IRI, the stored page has none.
162+
entity = _entity()
163+
entity.__iris__ = {"employment_contract_status": "Item:OSWdefault"}
164+
change = reconcile_user(
165+
_proposed(), entity, prune=True, stored_jsondata={"username": ORCID}
166+
)
167+
assert [d for d in change.diffs if d.status == REMOVE] == []
168+
assert change.category == UNCHANGED
169+
170+
171+
def test_protected_field_stored_is_removed_when_pruning():
172+
entity = _entity()
173+
entity.__iris__ = {"employment_contract_status": "Item:OSWx"}
174+
change = reconcile_user(
175+
_proposed(),
176+
entity,
177+
prune=True,
178+
stored_jsondata={"employment_contract_status": "Item:OSWx"},
179+
)
180+
removes = [d.name for d in change.diffs if d.status == REMOVE]
181+
assert removes == ["employment_contract_status"]
182+
183+
184+
def test_reconcile_uses_the_stored_jsondata_of_the_index():
185+
entity = _entity()
186+
entity.__iris__ = {"employment_contract_status": "Item:OSWdefault"}
187+
existing = ExistingUsers(
188+
by_username={ORCID: entity}, stored_jsondata={ORCID: {"username": ORCID}}
189+
)
190+
plan = reconcile([_proposed()], existing, prune=True)
191+
assert plan.changes[0].category == UNCHANGED
192+
193+
160194
def test_missing_label_is_gap_fill():
161195
change = reconcile_user(_proposed(label="Lukas Koschmieder"), _entity(label=None))
162196
assert change.category == GAP_FILL

0 commit comments

Comments
 (0)