Skip to content

Commit f2d011d

Browse files
committed
feat(user-sync): exclude system accounts and complete idempotent sync
1 parent a2a9d8c commit f2d011d

9 files changed

Lines changed: 146 additions & 30 deletions

File tree

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
# Public ORCID API used to enrich ORCID users.
1414
ORCID_API_BASE_DEFAULT = "https://pub.orcid.org/v3.0"
1515

16-
# MediaWiki group whose members are excluded from the sync (bots).
17-
BOT_GROUP = "bot"
16+
# MediaWiki groups whose members are treated as bot/system accounts and skipped.
17+
# All are standard MediaWiki groups, so this is not a per-instance skip-list.
18+
SYSTEM_GROUPS = ["bot", "sysop", "bureaucrat", "interface-admin"]
1819

1920

2021
@dataclass
@@ -28,10 +29,11 @@ class SyncConfig:
2829
limit: Optional[int] = None
2930
include_non_orcid: bool = True
3031
exclude_bot_group: bool = True
32+
exclude_system_usernames: bool = True
3133
create_redirects: bool = True
3234
link_organizations: bool = True
3335
orcid_api_base: str = ORCID_API_BASE_DEFAULT
34-
excluded_groups: List[str] = field(default_factory=lambda: [BOT_GROUP])
36+
excluded_groups: List[str] = field(default_factory=lambda: list(SYSTEM_GROUPS))
3537

3638

3739
def build_arg_parser() -> argparse.ArgumentParser:
@@ -86,6 +88,12 @@ def build_arg_parser() -> argparse.ArgumentParser:
8688
action="store_false",
8789
help="Sync only accounts whose username is an ORCID iD.",
8890
)
91+
parser.add_argument(
92+
"--include-system",
93+
dest="exclude_system_usernames",
94+
action="store_false",
95+
help="Do not skip MediaWiki reserved system accounts (Maintenance script etc.).",
96+
)
8997
return parser
9098

9199

@@ -99,6 +107,7 @@ def config_from_args(argv: Optional[List[str]] = None) -> SyncConfig:
99107
assume_yes=args.assume_yes,
100108
limit=args.limit,
101109
include_non_orcid=args.include_non_orcid,
110+
exclude_system_usernames=args.exclude_system_usernames,
102111
create_redirects=args.create_redirects,
103112
link_organizations=args.link_organizations,
104113
)

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ def _existing_label(entity: Any) -> Optional[str]:
5858
return None
5959

6060

61+
def _existing_orgs(entity: Any) -> Any:
62+
"""Read organization page-title refs without resolving the relation.
63+
64+
Loaded entities keep relation targets as IRIs in ``__iris__``; reading the
65+
attribute itself would make oold resolve (and fail on) the linked items.
66+
"""
67+
iris = getattr(entity, "__iris__", None)
68+
if isinstance(iris, dict):
69+
value = iris.get("organization")
70+
if value is None:
71+
return None
72+
return value if isinstance(value, (list, tuple, set)) else [value]
73+
try:
74+
return getattr(entity, "organization", None)
75+
except Exception: # pragma: no cover - defensive
76+
return None
77+
78+
6179
def existing_fields(entity: Any) -> Dict[str, Any]:
6280
"""Normalized comparable field view of a loaded User entity."""
6381
return {
@@ -67,9 +85,7 @@ def existing_fields(entity: Any) -> Dict[str, Any]:
6785
"orcid": _norm_scalar("orcid", getattr(entity, "orcid", None)),
6886
"emails": _norm_set("emails", getattr(entity, "email", None)),
6987
"websites": _norm_set("websites", getattr(entity, "website", None)),
70-
"organizations": _norm_set(
71-
"organizations", getattr(entity, "organization", None)
72-
),
88+
"organizations": _norm_set("organizations", _existing_orgs(entity)),
7389
}
7490

7591

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@ def is_redirect(content: str) -> bool:
2222
def ensure_redirect(osw: Any, username: str, item_title: str) -> Optional[str]:
2323
"""Create or refresh ``User:<username>`` -> item redirect.
2424
25-
Returns the page title if written, or None if skipped (real page exists).
25+
Returns the page title if written, or None if skipped (a real page already
26+
exists, or the redirect is already correct).
2627
"""
2728
title = f"User:{username}"
29+
target = redirect_wikitext(item_title)
2830
page = WtPage(wtSite=osw.site, title=title)
2931
if getattr(page, "exists", False):
30-
content = page.get_content() or ""
32+
content = page.get_slot_content("main") or ""
3133
if content.strip() and not is_redirect(content):
32-
return None
33-
page.set_content(redirect_wikitext(item_title))
34+
return None # do not clobber a real user page
35+
if content.strip() == target:
36+
return None # already correct
37+
page.set_slot_content("main", target)
3438
page.edit(comment="user-sync: redirect to user item")
3539
return title

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@
2020

2121
_ALLUSERS_PROPS = "registration|editcount|groups|centralids|blockinfo"
2222

23+
# MediaWiki default reserved system usernames. These ship with MediaWiki and are
24+
# the same across instances, so skipping them is not a per-instance skip-list.
25+
RESERVED_USERNAMES = frozenset({
26+
"MediaWiki default",
27+
"Maintenance script",
28+
"Conversion script",
29+
"Template namespace initialisation script",
30+
"ScriptImporter",
31+
"Delete page script",
32+
"Move page script",
33+
"Command line script",
34+
"Unknown user",
35+
"MediaWiki message delivery",
36+
"Flow talk page manager",
37+
"Abuse filter",
38+
"New user message",
39+
})
40+
2341

2442
def is_orcid_username(name: str) -> bool:
2543
"""True if a MediaWiki username is an ORCID iD (whitelisted ORCID login)."""
@@ -77,6 +95,7 @@ def _iter_allusers(site: Any, batch: Any = "max") -> Iterator[Dict[str, Any]]:
7795
def enumerate_mw_users(
7896
site: Any,
7997
excluded_groups: Sequence[str] = ("bot",),
98+
excluded_usernames: Sequence[str] = (),
8099
include_non_orcid: bool = True,
81100
limit: Optional[int] = None,
82101
batch: Any = "max",
@@ -85,15 +104,19 @@ def enumerate_mw_users(
85104
86105
Args:
87106
site: An object exposing ``api("query", ...)`` (an mwclient Site).
88-
excluded_groups: Accounts in any of these groups are skipped (bots).
107+
excluded_groups: Accounts in any of these groups are skipped (bots, admins).
108+
excluded_usernames: Exact usernames to skip (reserved system accounts).
89109
include_non_orcid: If False, keep only ORCID-username accounts.
90110
limit: Keep at most this many accounts after filtering.
91111
batch: ``aulimit`` value passed to the API.
92112
"""
93113
excluded = set(excluded_groups)
114+
excluded_names = set(excluded_usernames)
94115
users: List[MwUser] = []
95116
for raw in _iter_allusers(site, batch=batch):
96117
user = MwUser.from_api(raw)
118+
if user.name in excluded_names:
119+
continue
97120
if excluded.intersection(user.groups):
98121
continue
99122
if not include_non_orcid and not user.is_orcid:

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

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .reconcile import reconcile
2222
from .redirects import ensure_redirect
2323
from .sources import (
24+
RESERVED_USERNAMES,
2425
OrcidRateLimitError,
2526
enumerate_mw_users,
2627
fetch_orcid_record,
@@ -51,9 +52,11 @@ def summary(self) -> str:
5152
def _build_proposals(config: SyncConfig, osw: Any, session: Any):
5253
"""Enumerate MediaWiki users, enrich ORCID users and map to proposals."""
5354
excluded = tuple(config.excluded_groups) if config.exclude_bot_group else ()
55+
reserved = RESERVED_USERNAMES if config.exclude_system_usernames else ()
5456
mw_users = enumerate_mw_users(
5557
osw.mw_site,
5658
excluded_groups=excluded,
59+
excluded_usernames=reserved,
5760
include_non_orcid=config.include_non_orcid,
5861
limit=config.limit,
5962
)
@@ -98,28 +101,36 @@ def _store_users(osw: Any, resolution: Resolution, report: SyncReport) -> None:
98101
entities = []
99102
for resolved in resolution.resolved:
100103
proposed = resolved.change.proposed
101-
if resolved.action == "create":
102-
entities.append(build_user(proposed))
103-
report.created.append(proposed.full_page_title)
104-
elif resolved.action == "update":
105-
entities.append(
106-
apply_update(resolved.change.existing, proposed, resolved.apply_fields)
107-
)
108-
report.updated.append(proposed.full_page_title)
109-
else:
110-
report.skipped.append(proposed.full_page_title)
104+
try:
105+
if resolved.action == "create":
106+
entities.append(build_user(proposed))
107+
report.created.append(proposed.full_page_title)
108+
elif resolved.action == "update":
109+
entities.append(
110+
apply_update(
111+
resolved.change.existing, proposed, resolved.apply_fields
112+
)
113+
)
114+
report.updated.append(proposed.full_page_title)
115+
else:
116+
report.skipped.append(proposed.full_page_title)
117+
except Exception as exc: # pragma: no cover - build failure path
118+
report.failed[f"build:{proposed.username}"] = str(exc)
111119
if entities:
112-
osw.store_entity(
113-
OSW.StoreEntityParam(
114-
entities=entities, overwrite=True, edit_comment="user-sync"
120+
try:
121+
osw.store_entity(
122+
OSW.StoreEntityParam(
123+
entities=entities, overwrite=True, edit_comment="user-sync"
124+
)
115125
)
116-
)
126+
except Exception as exc: # pragma: no cover - network failure path
127+
report.failed["store"] = str(exc)
117128

118129

119130
def _create_redirects(osw: Any, resolution: Resolution, report: SyncReport) -> None:
131+
# Every in-scope user has an item (created or pre-existing), so ensure the
132+
# redirect for all of them; a re-run repairs any missing redirect.
120133
for resolved in resolution.resolved:
121-
if resolved.action not in ("create", "update"):
122-
continue
123134
proposed = resolved.change.proposed
124135
try:
125136
written = ensure_redirect(osw, proposed.username, proposed.full_page_title)

‎tests/tools/test_user_sync_config.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@ def test_defaults():
1515
assert cfg.include_non_orcid is True
1616
assert cfg.create_redirects is True
1717
assert cfg.link_organizations is True
18-
assert cfg.excluded_groups == ["bot"]
18+
assert cfg.exclude_system_usernames is True
19+
assert "bot" in cfg.excluded_groups
20+
assert "sysop" in cfg.excluded_groups
1921
assert USER_CATEGORY.startswith("Category:OSW")
2022
assert ORGANIZATION_CATEGORY.startswith("Category:OSW")
2123

2224

25+
def test_include_system_flag():
26+
cfg = config_from_args(["--include-system"])
27+
assert cfg.exclude_system_usernames is False
28+
29+
2330
def test_config_from_args_parses_flags():
2431
cfg = config_from_args([
2532
"--domain",

‎tests/tools/test_user_sync_reconcile.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,29 @@ def test_missing_label_is_gap_fill():
119119
assert change.diffs[0].name == "label"
120120

121121

122+
class _OrgTrap:
123+
"""A loaded-entity stand-in whose organization attribute must never be read."""
124+
125+
__iris__ = {"organization": ["Item:OSWa", "Item:OSWb"]}
126+
label = [SimpleNamespace(text="X Y")]
127+
first_name = "X"
128+
surname = "Y"
129+
orcid = None
130+
email: set = set()
131+
website: set = set()
132+
133+
@property
134+
def organization(self):
135+
raise RuntimeError("relation must not be resolved during reconcile")
136+
137+
138+
def test_existing_orgs_read_from_iris_without_resolving():
139+
from osw.tools.user_sync.reconcile import existing_fields
140+
141+
ef = existing_fields(_OrgTrap())
142+
assert ef["organizations"] == {"Item:OSWa", "Item:OSWb"}
143+
144+
122145
def test_reconcile_plan_counts():
123146
proposed = [
124147
_proposed(username="new-user"),

‎tests/tools/test_user_sync_redirects.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ def __init__(self, wtSite=None, title=None, do_init=True):
2929
self.written = None
3030
self.edited = False
3131

32-
def get_content(self):
32+
def get_slot_content(self, slot_key):
3333
return self._content
3434

35-
def set_content(self, content):
35+
def set_slot_content(self, slot_key, content):
3636
self.written = content
3737

3838
def edit(self, comment=None):
@@ -67,3 +67,10 @@ def test_ensure_redirect_skips_real_page(monkeypatch):
6767
_patch(monkeypatch, {title: True}, {title: "This is my real user page."})
6868
written = ensure_redirect(FakeOsw(), "bob", "Item:OSW1")
6969
assert written is None
70+
71+
72+
def test_ensure_redirect_skips_when_already_correct(monkeypatch):
73+
title = "User:carol"
74+
_patch(monkeypatch, {title: True}, {title: "#REDIRECT [[Item:OSW1]]"})
75+
written = ensure_redirect(FakeOsw(), "carol", "Item:OSW1")
76+
assert written is None

‎tests/tools/test_user_sync_sources.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,22 @@ def test_enumerate_orcid_only_and_limit():
9494
assert len(limited) == 1
9595

9696

97+
def test_enumerate_excludes_privileged_groups_and_reserved_names():
98+
site = FakeSite([
99+
_page([
100+
{"name": "0000-0002-6374-9831", "groups": ["user"]},
101+
{"name": "Admin", "groups": ["user", "sysop", "bureaucrat"]},
102+
{"name": "Maintenance script", "groups": ["user"]},
103+
])
104+
])
105+
users = enumerate_mw_users(
106+
site,
107+
excluded_groups=("bot", "sysop", "bureaucrat"),
108+
excluded_usernames={"Maintenance script"},
109+
)
110+
assert [u.name for u in users] == ["0000-0002-6374-9831"]
111+
112+
97113
def test_partition_by_orcid():
98114
users = [
99115
MwUser(name="0000-0002-6374-9831"),

0 commit comments

Comments
 (0)