Skip to content

Commit bc6b0d6

Browse files
committed
fix(user-sync): align with stage and the tools docs
- list the page in the existing Tools nav group, describe scripts there - page through all User items and adopt pages at the deterministic title - skip blocked accounts by default, never propose placeholder names - prune via a second jsondata edit; oold 2.3 ignores __iris__ edits - never write employment_contract_status; add store and prune tests
1 parent ebfbadb commit bc6b0d6

17 files changed

Lines changed: 878 additions & 94 deletions

‎docs/tools/index.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
Besides the Python API, osw ships two adapters that talk to a live instance:
44
the `osw` command line client, and an MCP server for agent clients such as
5-
Claude Code. Both run the same operations from one shared, SDK-free core
6-
(`osw.service`), so a command and its matching tool behave identically. They
5+
Claude Code. This section also covers standalone scripts built on the Python
6+
API. The current one is the [user sync](user-sync.md). The two adapters run
7+
the same operations from one shared, SDK-free core (`osw.service`), so a
8+
command and its matching tool behave identically. They
79
differ in exactly one way: only the CLI accepts filesystem paths.
810

911
## Setup
@@ -49,4 +51,5 @@ extras are listed in the [Get Started guide](../get-started.md#optional-extras).
4951
| ---- | -------- |
5052
| [CLI](cli.md) | The `.env` quick start, the full command reference, and the global flags |
5153
| [MCP server](mcp.md) | The tool surface, the no-filesystem-access and one-server-per-instance rules, and how to register the server with a client such as Claude Code |
54+
| [User Sync (script)](user-sync.md) | A Python script that creates or updates `User` items from the MediaWiki accounts of an instance, with the options, the data it writes and how it reconciles existing items |
5255
| [Configuration](configuration.md) | What both adapters share: where credentials and settings come from, and the full environment-variable reference |

‎docs/tools/user-sync.md‎

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Create or update OSW `User` items from the MediaWiki accounts of an OSL
44
instance, enriching ORCID users from the public ORCID API. The run is
55
idempotent and previews every change before writing.
66

7+
This is a Python script built on the osw library. It is not a command of the
8+
`osw` CLI and not an MCP tool, see [Tools](index.md). It runs from a source
9+
checkout, because `examples/` is not part of the installed package.
10+
711
## When to use it
812

913
OSL instances gain accounts from manual creation and from whitelisted ORCID
@@ -16,12 +20,20 @@ semantic `User` item until this tool reconciles them.
1620
uv run python examples/user_sync.py --domain your-instance.example.org --dry-run
1721
```
1822

19-
Credentials are read from `accounts.pwd.yaml` (or `--cred-filepath`), the same
20-
mechanism as `OswExpress`. Start with `--dry-run` to preview, then run without
21-
it to write after confirming at the prompt.
22-
23-
To also remove data no longer wanted (disabled optional fields and
24-
`employment_contract_status`) from existing items, add `--prune`:
23+
The script reads the credential file given by `--cred-filepath`, otherwise
24+
`accounts.pwd.yaml` in the working directory, otherwise the file named by the
25+
`OSW_CRED_FILEPATH` environment variable. It does not read a `.env` file.
26+
`--domain` falls back to the `OSW_DOMAIN` environment variable. See
27+
[Credentials](configuration.md#credentials) for the file format. Start with
28+
`--dry-run` to preview, then run without it to write after confirming at the
29+
prompt.
30+
31+
To also clean up existing items, add `--prune`. Without it, the sync never
32+
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:
2537

2638
```bash
2739
uv run python examples/user_sync.py --domain your-instance.example.org --prune --dry-run
@@ -49,11 +61,14 @@ only core identity plus email.
4961
| `--with-websites` | Also store ORCID researcher URLs (opt-in). |
5062
| `--with-organizations` | Also create and link Organization items from ORCID affiliations (opt-in). |
5163
| `--with-extras` | Enable both websites and organizations. |
52-
| `--prune` | Remove disabled optional fields and `employment_contract_status` from existing items (default off; a normal run only adds). |
64+
| `--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). |
5366

5467
## What it does
5568

56-
1. Enumerate MediaWiki accounts, skipping bots and privileged groups
69+
1. Enumerate MediaWiki accounts, skipping blocked accounts (unless
70+
`--include-blocked` is set; the User item of a skipped blocked account is
71+
left unchanged), bots and privileged groups
5772
(`bot`, `sysop`, `bureaucrat`, `interface-admin`) and MediaWiki reserved
5873
system usernames. Accounts whose username is an exact ORCID iD are exempt
5974
from the group filter (so a person's own admin account is synced); other
@@ -62,10 +77,14 @@ only core identity plus email.
6277
always; researcher URLs and affiliations only when their flag is enabled.
6378
3. Map each account to a proposed `User` item with a deterministic id
6479
(uuid5 on the ORCID iD, else the username), so re-runs are idempotent.
65-
4. Reconcile against existing items by `username` into NEW, GAP_FILL, CONFLICT,
66-
REMOVE or UNCHANGED, then preview; gap-fills apply automatically and conflicts
67-
are resolved interactively. Removals only appear with `--prune`; a normal run
68-
never removes anything.
80+
4. Reconcile against existing items. Existing items are found by `username`
81+
across all `User` items. An item whose deterministic page already exists is
82+
updated and not created again. Each user gets one of four categories: NEW,
83+
GAP_FILL, CONFLICT or UNCHANGED. Each changed field has one of three
84+
statuses: GAP_FILL, CONFLICT or REMOVE. The preview shows a removed field as
85+
`-field`. A user with only gap-fills or removals is GAP_FILL. Gap-fills apply
86+
automatically and conflicts are resolved interactively. Removals appear only
87+
with `--prune`; a normal run never removes anything.
6988
5. Store items, create `User:<username>` redirects to each item, ensure any
7089
linked Organization items, verify by reloading, and warn about users with no
7190
email.
@@ -76,14 +95,16 @@ only core identity plus email.
7695
| --- | --- |
7796
| `username`, `first_name`, `surname`, `label`, `orcid` | Always (core identity). |
7897
| `email` | Standard: always attempted; missing email is warned, not fatal; never removed. |
79-
| `website` | Opt-in (`--with-websites`); removed from existing only with `--prune`. |
80-
| `organization` | Opt-in (`--with-organizations`); removed from existing only with `--prune`. |
81-
| `employment_contract_status` | Never written; removed from existing only with `--prune`. |
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. |
82101

83102
## Notes
84103

85104
- MediaWiki does not expose other users' email or real name, so ORCID is the
86105
only source of rich data. Non-ORCID accounts get username-based placeholder
87-
names, flagged in the preview.
88-
- On a first accepted run the library autofetches schemas and may regenerate
89-
`src/osw/model/entity.py`; restore it if you do not want that change.
106+
names, flagged in the preview. Placeholder names are written only for new
107+
items and are never proposed for an existing item.
108+
- In a source checkout (for developers), the first accepted run autofetches
109+
schemas and may regenerate `src/osw/model/entity.py`. Restore it if you do not
110+
want that change.

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

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Build pydantic User/Organization entities from proposals.
22
33
Must run within an open OSW connection (the models resolve linked entities on
4-
construction). Organization references are passed as a list of page-title IRIs,
5-
which oold stores in the entity's ``__iris__`` map; the reference attribute is
6-
never read back here, as that would trigger a network resolve.
4+
construction). Organization references are passed as a list of page-title IRIs;
5+
the reference attribute is never read back here, as that would trigger a
6+
network resolve.
7+
8+
An empty field is set to None, so its key is absent from the serialized item.
9+
The store merges with the stored page, so an absent key keeps the stored value.
10+
Clearing a stored value is done by ``prune.clear_jsondata_keys`` instead.
711
"""
812

913
from __future__ import annotations
@@ -24,17 +28,13 @@ def _labels(text: str) -> List[Any]:
2428

2529

2630
def _strip_protected(entity: Any) -> None:
27-
"""Clear protected relations (e.g. employment_contract_status).
31+
"""Unset protected relations (e.g. employment_contract_status).
2832
29-
Set to an empty list rather than removed: the model re-applies its default
30-
when the field is absent, and the store only overwrites a relation that is
31-
present as an explicit empty value (with ``remove_empty=False``).
33+
The attribute is set to None, so the key is absent from the serialized item
34+
and the model default is not written. A stored value is left unchanged.
3235
"""
33-
iris = getattr(entity, "__iris__", None)
34-
if isinstance(iris, dict):
35-
for name in PROTECTED_FIELDS:
36-
if name in iris:
37-
iris[name] = []
36+
for name in PROTECTED_FIELDS:
37+
setattr(entity, name, None)
3838

3939

4040
def build_organization(proposed: ProposedOrganization) -> Organization:
@@ -66,9 +66,11 @@ def build_user(proposed: ProposedUser) -> User:
6666
def apply_update(entity: Any, proposed: ProposedUser, apply_fields: Set[str]) -> Any:
6767
"""Apply the accepted fields onto a loaded existing entity, in place.
6868
69-
Fields whose proposal is empty (REMOVE) are cleared; protected fields in
70-
``apply_fields`` are stripped from the entity's ``__iris__`` map.
69+
An empty organization or website value is set to None, which leaves the
70+
stored value unchanged. Protected fields are always unset.
7171
"""
72+
if "username" in apply_fields:
73+
entity.username = proposed.username
7274
if "label" in apply_fields:
7375
entity.label = _labels(proposed.label)
7476
if "first_name" in apply_fields:
@@ -80,10 +82,10 @@ def apply_update(entity: Any, proposed: ProposedUser, apply_fields: Set[str]) ->
8082
if "emails" in apply_fields:
8183
entity.email = set(proposed.emails)
8284
if "websites" in apply_fields:
83-
entity.website = set(proposed.websites)
85+
entity.website = set(proposed.websites) if proposed.websites else None
8486
if "organizations" in apply_fields:
85-
# Empty list both links (non-empty) and clears (empty, on removal).
86-
entity.__iris__["organization"] = list(proposed.organizations)
87-
if any(name in apply_fields for name in PROTECTED_FIELDS):
88-
_strip_protected(entity)
87+
entity.organization = (
88+
list(proposed.organizations) if proposed.organizations else None
89+
)
90+
_strip_protected(entity)
8991
return entity

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class SyncConfig:
4343
link_organizations: bool = False
4444
# Remove disabled optional fields and protected fields from existing items.
4545
prune: bool = False
46+
# Blocked MediaWiki accounts are skipped unless this is set.
47+
include_blocked: bool = False
4648
orcid_api_base: str = ORCID_API_BASE_DEFAULT
4749
excluded_groups: List[str] = field(default_factory=lambda: list(SYSTEM_GROUPS))
4850

@@ -111,8 +113,9 @@ def build_arg_parser() -> argparse.ArgumentParser:
111113
"--prune",
112114
dest="prune",
113115
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+
help="Remove disabled optional fields from existing items and overwrite "
117+
"employment_contract_status with an empty value (data cleanup). Off by "
118+
"default; a normal run only adds.",
116119
)
117120
account_scope = parser.add_mutually_exclusive_group()
118121
account_scope.add_argument(
@@ -133,6 +136,12 @@ def build_arg_parser() -> argparse.ArgumentParser:
133136
action="store_false",
134137
help="Do not skip MediaWiki reserved system accounts (Maintenance script etc.).",
135138
)
139+
parser.add_argument(
140+
"--include-blocked",
141+
dest="include_blocked",
142+
action="store_true",
143+
help="Do not skip blocked MediaWiki accounts.",
144+
)
136145
return parser
137146

138147

@@ -152,4 +161,5 @@ def config_from_args(argv: Optional[List[str]] = None) -> SyncConfig:
152161
include_websites=args.include_websites or args.with_extras,
153162
link_organizations=args.link_organizations or args.with_extras,
154163
prune=args.prune,
164+
include_blocked=args.include_blocked,
155165
)

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

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@
99
from dataclasses import dataclass, field
1010
from typing import Any, Dict, List, Optional
1111

12+
from osw.wiki_tools import SearchParam
13+
1214
from .config import USER_CATEGORY
1315

16+
# Results per semantic search request.
17+
PAGE_SIZE = 1000
18+
1419

1520
@dataclass
1621
class ExistingUsers:
@@ -34,6 +39,36 @@ def _as_entity_list(result: Any) -> List[Any]:
3439
return [result]
3540

3641

42+
def _query_user_titles(
43+
osw: Any, category: str, limit: Optional[int] = None
44+
) -> List[str]:
45+
"""Return the titles of all items of a category, paging through the results.
46+
47+
The next offset is the one the wiki reports (``query-continue-offset``), not
48+
the offset plus the page size, because the wiki can cut a page shorter.
49+
"""
50+
titles: List[str] = []
51+
offset = 0
52+
while True:
53+
query = SearchParam(
54+
query=f"[[HasType::{category}]]|offset={offset}",
55+
limit=PAGE_SIZE,
56+
return_meta=True,
57+
)
58+
result = osw.site.semantic_search(query)[0]
59+
titles.extend(result.titles)
60+
if limit is not None and len(titles) >= limit:
61+
return titles[:limit]
62+
if not result.truncated:
63+
return titles
64+
if result.next_offset is None or result.next_offset <= offset:
65+
raise RuntimeError(
66+
f"The wiki reported the next offset {result.next_offset} after "
67+
f"offset {offset} for '{category}'. Paging cannot continue."
68+
)
69+
offset = result.next_offset
70+
71+
3772
def load_existing_users(
3873
osw: Any,
3974
category: str = USER_CATEGORY,
@@ -42,14 +77,15 @@ def load_existing_users(
4277
"""Query and load all existing User items, indexed by username.
4378
4479
Args:
45-
osw: An authenticated OSW connection exposing ``query_instances`` and
46-
``load_entity``.
80+
osw: An authenticated OSW connection exposing ``site.semantic_search``
81+
and ``load_entity``.
4782
category: The User category page title.
4883
limit: Load at most this many items (for testing).
84+
85+
Raises:
86+
RuntimeError: If the wiki reports a next offset that does not advance.
4987
"""
50-
titles = osw.query_instances(category=category)
51-
if limit is not None:
52-
titles = titles[:limit]
88+
titles = _query_user_titles(osw, category, limit)
5389
if not titles:
5490
return ExistingUsers()
5591
entities = _as_entity_list(osw.load_entity(titles))
@@ -59,3 +95,35 @@ def load_existing_users(
5995
if username:
6096
by_username[username] = entity
6197
return ExistingUsers(by_username=by_username)
98+
99+
100+
def adopt_existing_pages(
101+
osw: Any, existing: ExistingUsers, proposed_users: List[Any]
102+
) -> List[str]:
103+
"""Add items that exist at a proposed user's deterministic page title.
104+
105+
A user without a match by ``username`` would be created at its deterministic
106+
page title. If that page already exists (for example, it has no username or
107+
is not typed as a User), it is loaded and indexed under the proposed
108+
username, so the sync reconciles against it instead of creating it again.
109+
110+
Returns:
111+
The page titles that were adopted.
112+
"""
113+
candidates = [p for p in proposed_users if existing.get(p.username) is None]
114+
if not candidates:
115+
return []
116+
titles = [p.full_page_title for p in candidates]
117+
missing = set(osw._get_missing_page_titles(titles, confirm_delay_s=0))
118+
present = [p for p in candidates if p.full_page_title not in missing]
119+
if not present:
120+
return []
121+
entities = _as_entity_list(osw.load_entity([p.full_page_title for p in present]))
122+
by_uuid = {str(getattr(e, "uuid", None)): e for e in entities if e is not None}
123+
adopted: List[str] = []
124+
for proposed in present:
125+
entity = by_uuid.get(str(proposed.uuid))
126+
if entity is not None:
127+
existing.by_username[proposed.username] = entity
128+
adopted.append(proposed.full_page_title)
129+
return adopted

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Clear stored relations of existing user items (``--prune``).
2+
3+
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.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from typing import Any, Iterable
12+
13+
from osw.wtsite import WtPage
14+
15+
16+
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 ``[]``.
18+
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.
21+
Returns True if the page was written, False if nothing changed.
22+
"""
23+
page = WtPage(wtSite=osw.site, title=title)
24+
content = page.get_slot_content("jsondata")
25+
if isinstance(content, str):
26+
content = json.loads(content)
27+
if not isinstance(content, dict):
28+
return False
29+
changed = False
30+
for key in keys:
31+
if key in content and content[key] not in (None, [], "", {}):
32+
content[key] = []
33+
changed = True
34+
if not changed:
35+
return False
36+
page.set_slot_content("jsondata", content)
37+
page.edit(comment="user-sync: prune")
38+
return True

0 commit comments

Comments
 (0)