Skip to content

feat(user-sync): sync OSW User items from MediaWiki and ORCID - #174

Merged
LukasGold merged 22 commits into
stagefrom
user-item-upd
Nov 12, 2026
Merged

LukasGold merged 22 commits into
stagefrom
user-item-upd

Conversation

@raederan

@raederan raederan commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds osw.tools.user_sync: a maintenance tool that creates and updates OSW User items from an OSL instance's MediaWiki accounts, enriching ORCID users from the public ORCID API. Idempotent, with an interactive preview and confirm before any write.

  • Enumerates MediaWiki accounts; excludes bots, privileged groups, reserved system accounts and, by default, blocked accounts (--include-blocked keeps them).
  • Core identity is always written (username, name, orcid, email); website and organization are opt-in (--with-websites / --with-organizations); employment_contract_status is never written.
  • Finds existing items by username across all User items (paging through the results with the offset the wiki reports), and updates an item whose deterministic page already exists instead of creating it again.
  • Placeholder names (MediaWiki users without name data) are written only for new items and never proposed for an existing item.
  • --prune removes employment_contract_status from the stored jsondata of existing items, and overwrites 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. The removal of employment_contract_status is decided from the stored jsondata, so a second --prune run proposes no removal for an item whose key is already gone.
  • --dry-run, --auto-apply, --orcid-only / --mw-only.
  • Creates User:<name> redirects; warns (never fails) on users with no ORCID email.
  • Unit tests with mocked MediaWiki and ORCID, examples/user_sync.py, and a docs page listed in the existing "Tools" nav group next to CLI and MCP.

Changes after review

  • Merged stage into the branch. With oold 2.3.0, entity.__iris__ returns a new dict on each read, so the earlier in-place edits had no effect; relations are now set by attribute assignment, and --prune clears stored values through the jsondata edit.
  • A User built or loaded without employment_contract_status serializes the model default; the sync now unsets it before every store, so a normal run never writes it.
  • docs/tools/index.md now says the Tools section also covers standalone scripts; the second "Tools" nav group is removed.
  • New tests for the store, redirect, verify and prune paths, including one that runs the overwrite merge from src/osw/core.py on a built User.
  • A failed store is no longer counted as created, and redirects are written only for stored or pre-existing items (user sync: a failed store is reported as created and redirects are still written #224).
  • --prune clears a key by its field type in the User model: a list or set field gets [], a single-valued field (employment_contract_status) is deleted, also when it holds an empty value such as []. Before, every key got [].

Limitations

  • MediaWiki's API exposes no real name or email for other users, so MediaWiki-native (non-ORCID) users get username-based placeholder names and no email.
  • After --prune, a loaded User shows the model's default employment_contract_status again, because the model re-applies its default when the key is missing; the sync never writes it back.
  • The prune edit sends no base revision, so an edit by someone else between the store and the prune edit can be lost.
  • Unlinking organizations leaves the Organization items orphaned (not deleted).
  • Live test on a test wiki: the --prune dry run proposed removing websites, organizations and employment_contract_status from a test user, the applied run reported updated=1 pruned=1 failed=0, and a second dry run showed the user as unchanged. The wiki's edit form was not opened on the pruned item.

@raederan raederan self-assigned this Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Release preview

Merging this PR would release v2.10.0 (current: v2.9.1).

Changelog preview (truncated)
## v2.10.0 (2026-11-12)

### Bug Fixes

- Follow properties and items when collecting category templates
  ([#220](https://github.com/OpenSemanticLab/osw-python/pull/220),
  [`55e500e`](https://github.com/OpenSemanticLab/osw-python/commit/55e500efb896f0a3d6bde98409b3a6f43a652bdc))

- Keep the stored jsonschema when store_entity regenerates it
  ([`9b76a8f`](https://github.com/OpenSemanticLab/osw-python/commit/9b76a8fbeb813b34c4881b54d329e9e837b08f95))

- Select category templates from the schema allOf like the wiki
  ([#220](https://github.com/OpenSemanticLab/osw-python/pull/220),
  [`9f606f4`](https://github.com/OpenSemanticLab/osw-python/commit/9f606f48fefd640cca48e6718f1a92eca118da6b))

- **user-sync**: Align with stage and the tools docs
  ([`bc6b0d6`](https://github.com/OpenSemanticLab/osw-python/commit/bc6b0d642f5697ca8048b4be05fd26c9ad2162f6))

- **user-sync**: Count and redirect only stored items
  ([#224](https://github.com/OpenSemanticLab/osw-python/pull/224),
  [`0b8dc97`](https://github.com/OpenSemanticLab/osw-python/commit/0b8dc974e972e8adaa7a2bb03bd3ae4396589f40))

- **user-sync**: Prune a single-valued key stored with an empty value
  ([`ebda514`](https://github.com/OpenSemanticLab/osw-python/commit/ebda514c731718ab20a084b93288491b3b944627))

- **user-sync**: Prune by field type and decide from stored jsondata
  ([`9b36e4e`](https://github.com/OpenSemanticLab/osw-python/commit/9b36e4ef8417b8847ba304535a95c0eedaf628b9))

### Continuous Integration

- Run Main on pushes to stage and Integration on PRs into stage
  ([`1b55f02`](https://github.com/OpenSemanticLab/osw-python/commit/1b55f02c90cae646d690f83d6208c01f0e26e62b))

### Documentation

- State which schema references the template walk follows
  ([#220](https://github.com/OpenSemanticLab/osw-python/pull/220),
  [`799bc07`](https://github.com/OpenSemanticLab/osw-python/commit/799bc07952aac7e6ab579bc127c5cb3000710491))

### Testing

- Drop the FileController type check, it is no longer a model
  ([`1eddbe8`](https://github.com/OpenSemanticLab/osw-python/commit/1eddbe867559e14806ad72eb076172cd41c7767e))

- Set subclass_of for oold 2 in the jsonschema tests
  ([`bd4ffdb`](https://github.com/OpenSemanticLab/osw-python/commit/bd4ffdb3e950f43f4eea0151d19d07f621eb14e0))

Preview via python-semantic-release and conventional commits.

@simontaurus

Copy link
Copy Markdown
Contributor

user properties may be available via https://www.mediawiki.org/wiki/API:Userinfo

@raederan

Copy link
Copy Markdown
Contributor Author

user properties may be available via https://www.mediawiki.org/wiki/API:Userinfo

only works for logged in users, not even admins can read real name or email
name mapping can only work if username is the realname on MW side created accounts for user items.
email must be provided by each user themselves

LukasGold added a commit that referenced this pull request Sep 21, 2026
* feat(mcp): add osw-mcp server exposing a live OSL instance

Add an in-repo `osw[mcp]` extra and an `osw-mcp` stdio console script that
wraps OswExpress and serves it over the Model Context Protocol for clients
such as Claude Code.

Tools: semantic/SPARQL/full-text search, category schema introspection,
entity read + JSON-LD export, create/update/delete, full page-slot access,
and file up/download.

- Delete is provenance-guarded: a local JSON ledger records pages the server
  created/modified; deleting anything untracked requires
  confirm_external_delete=true.
- Credentials resolve from env/.env and are validated up front (fail fast,
  never prompts, so stdio is never corrupted by an input() call).
- osw stdout is redirected to stderr so it never leaks onto the JSON-RPC channel.
- OSW_MCP_READ_ONLY hides all mutating tools.

* feat(mcp): port server to mcp 2.x and isolate the extra

- FastMCP replaced by MCPServer, extra now requires mcp>=2
- mcp dropped from the all extra and the dev group: it needs
  anyio>=4.9, workflow pins anyio<4.7 (#139)
- uv conflicts declare mcp exclusive with workflow and with dev
- pytest stack moved to its own test group, so an environment with
  both pytest and mcp exists
- src/osw/mcp excluded from ty, mcp tests guarded by importorskip

* feat(mcp): authenticate from an osw credential file

- OSW_MCP_CRED_FILEPATH configures it, OSL_CRED_FILEPATH is a fallback
- an alternative to OSW_USERNAME/OSW_PASSWORD, so the password is not
  duplicated into a second plaintext file
- existence and a matching domain entry are validated at startup
- lookups use CredentialFallback.none, so osw never prompts and never
  blocks the stdio transport

* feat(mcp): select between multiple OSL instances at runtime

- list_instances and select_instance tools, returning iris only and
  never any credential value
- OSW_DOMAIN becomes optional when a credential file supplies the iris
- auto-selects when OSW_DOMAIN is set or the file holds exactly one iri
- switching rebuilds the connection and the per-domain provenance ledger
- tools resolve the active domain and credentials at call time

* refactor: extract SDK-free osw.service core from osw.mcp

- move config/ledger/serialization from osw.mcp to osw.service
- add errors.py (OpError), context.py (Context+Policy), registry.py
- Operation validator rejects path-like params on the mcp surface
- canonical OSW_* env names, OSW_MCP_*/OSL_* kept as aliases
- config/ledger/serialization tests now run without the mcp extra

* refactor(service): lift search tools into osw.service.ops

- add osw/service/ops/ with the search group as @operation functions
- mcp/tools/search.py becomes a registry loop over bind()
- add transitional legacy_context() so existing tests keep passing
- bind() resolves annotations against the op module, not registry.py

* refactor(service): lift remaining tool bodies into osw.service.ops

- schema, status, entities and slots lifted as @operation functions
- mcp/tools/*.py collapse to registry loops over bind()
- error dicts become raised errors.*; ledger calls become records= hooks
- normalize cli_name so every group reads as `osw <group> <verb>`
- 172 passed in the dev env, 29 in the mcp extra env

* feat(cli): add typer CLI assembled from the operation registry

- typer as a base dependency; osw = "osw.cli.main:app" console script
- commands built from iter_operations(surface="cli"); Context built lazily
- OpError.exit_code becomes the process exit status, no traceback
- json_value parser lives in osw.service.params, so core never imports cli
- set_slot coerces content per the sibling slot's content model

* feat: remove filesystem paths from the MCP surface

- add path-free get_file_info/read_file_text/write_file_text built on
  WikiFileController.get()/.put(), never touching the local filesystem
- move file download/upload and ledger path to osw.cli.ops, the only
  module allowed to name a path (surfaces={"cli"}); drop status's
  ledger_path
- guard tests: no MCP-surfaced op names a path, and osw.mcp.server
  never imports osw.cli
- read_file_text decodes incrementally so a byte cap splitting a
  multi-byte character is not misreported as binary content

* feat(mcp): pin one instance per server, wire annotations and meta

- delete list_instances/select_instance; each server process is pinned to
  one OSL instance and refuses to start if none resolves
- map Operation's hints onto ToolAnnotations and _meta by explicit
  keyword, since the SDK silently absorbs a misspelled field name
- set server instructions and version; make mcp.run(transport="stdio")
  explicit
- add CLI --instance and osw instance list, returning iris only
- retarget the "no instance selected" message at OSW_DOMAIN and
  --instance instead of the removed tool

* refactor(mcp): drop tool closures, fold registration into server

- delete osw/mcp/tools/ and connection.py; bodies live in osw.service.ops
- fold registration.py into server.py, its sole consumer
- replace test_mcp_tools.py with test_service_ops.py + test_mcp_server.py
- rebuild the integration fixture on bind()/iter_operations
- test_mcp_instances.py -> test_service_instances.py, no longer SDK-gated

* build: type-check src/osw/mcp instead of excluding it

- drop src/osw/mcp from [tool.ty.src] exclude
- silence only the two unresolvable SDK imports inline (issue #139)

* docs: document the osw CLI and unify the config reference

- add a Command line section: command tree, global options, exit behaviour
- move credentials into a shared Configuration section with an alias table
- state that no MCP tool takes a path, and where the path-based commands live
- add stdio type, multi-instance and per-instance permission examples
- give each CLI command group a one-line help string

* fix(config): find .env from the CWD, report config sources

- CLI now searches upward from the working directory, not from the
  installed package's directory (dotenv's default walks the call stack)
- MCP server searches nowhere: its CWD is chosen by the client
- both print the resolved .env and credential file to stderr at startup
- a missing credential file whose path holds a control character now
  explains .env double-quote escape decoding

* docs: move CLI and MCP sections out of the README

- new docs page "CLI and MCP tools", added to the zensical nav
- README keeps a short pointer section, otherwise back to its old shape
- get-started extras table gains the osw[mcp] row

* docs: default MCP examples to a credential file in env

- server entries now set OSW_CRED_FILEPATH plus OSW_DOMAIN, no .env needed
- multi-instance example shares one credential file, one domain per server
- register via claude mcp add-json, which takes the entry verbatim

* feat(mcp): require an explicitly configured OSW_DOMAIN

- server no longer auto-selects a single-iri credential file; the CLI still does
- docs present the env block and the .env file as two supported styles
- multi-instance example shows one server of each style
- drop the "OSW_DOMAIN may be omitted" note

* docs: restructure the CLI and MCP guide

- add a Setup section with the uv/pip installs up front
- move Configuration below the MCP section, both adapters share it
- state where the .env is looked for first, drop the always-loaded claim
- note that --instance is optional and when it is required
- collect the rationale in a Design notes section at the end

* docs: simplify the CLI and MCP setup section

- lead with uv tool install, the mcp extra includes the base package
- fold the pip, uv add and uvx variants into a details element
- move the editable-install caveat to a Notes for developers section
- document running the server from a local checkout via uvx --from

* added local folders to .gitignore

* docs: correct why the CLI may infer an instance

- --instance is optional, so it is not what makes inference acceptable
- the CLI resolves per invocation and reports the instance it resolved
- state the --instance condition as OSW_DOMAIN unset, not .env-specific

* refactor: de-isolate the mcp extra from the dev environment

- drop the ty: ignore on the mcp SDK imports in server.py
- run the MCP tests unconditionally instead of importorskip-ing them
- update the config.py env-file hint (no more `test` group)
- docs: mcp is part of osw[all]; replace the anyio design note

* docs: drop remaining references to the separate MCP environment

- test docstrings no longer contrast against a plain dev env
- deptry comment no longer claims extras are absent from dev
- README lists osw[mcp] among the extras

* refactor(service): validate Settings with pydantic

- convert Settings from a frozen dataclass to a frozen pydantic model
- add validators for domain, sparql_endpoint, state_dir, cred_filepath
- constrain max_results/max_chars to positive integers via Field(gt=0)
- drop _int_env in favour of one ValidationError -> RuntimeError site
  that still names the exact alias that was set

Closes #143

* test: stop test_init_from_env_vars leaking OSW_CRED_FILEPATH

- use monkeypatch.setenv so OSW_CRED_FILEPATH and OSW_DOMAIN are restored
- the test unlinks its credential file, so the leaked path pointed every
  later test at a missing file
- surfaced by the mcp de-isolation: tests/integration/test_mcp_server.py
  no longer skips for a missing SDK, so it hit the polluted environment

* test: do not assume the first ask-query hit carries jsondata

- SMW ask results have no defined order and Category:Item can hold pages without a jsondata slot

- scan all returned titles, require at least one with the slot

* fix(service): validate read_only via pydantic instead of truthy set

- route OSW_READ_ONLY through Settings so an unparseable value raises
- a typo like "ture" previously yielded False, silently enabling writes
- "y"/"t" now parse as true; all documented spellings keep working
- blank/whitespace-only still falls back to the default, as for the ints
- drop the now-unused _TRUTHY set

Follow-up to #143.

* fix(deps): make python-dotenv a base dependency

- the osw CLI is a base console script, but .env loading required osw[mcp]
- without dotenv the implicit .env search returned silently, so the CLI
  reported missing credentials instead of the missing package
- drop the now redundant entry from the mcp extra
- update the docstrings and error message that named osw[mcp]

* feat(cli): find accounts.pwd.yaml in the working directory

- look for accounts.pwd.yaml in the CWD when no credential file is set
- skip the lookup when OSW_USERNAME or OSW_PASSWORD is configured
- ignore a discovered file with no entry for the configured domain
- report the credential file and where its path came from
- no parent-directory walk, and the MCP server never searches

* feat(cli): print one configuration line by default

- reduce the CLI banner to the credential source unless --verbose
- print the env-file line too when a command fails
- omit the MCP stdio hint from CLI error messages
- flush the banner so it survives a buffered stderr

* docs(mcp): add a quick install section for claude mcp add

- one claude mcp add command with the env block inline
- note on quoting osw[mcp] and on Windows paths
- scope flags, and how to list, inspect and remove the entry

* docs(cli): make the search help explain OSW-ID titles

- rename full_text_search to search_titles: it searches titles only
- drop "full-text" from the group help, module docstring and docs
- state that OSW pages are titled by OSW-ID, so names are not in titles
- add a wildcard label example to `search ask`, with the caveat that the
  property name depends on the instance schema (not run against a wiki)
- document the --limit default and the {titles, count, truncated} shape

* feat(search): add a page-content search

- wiki_tools.content_search wraps the MediaWiki list=search API
- WtSite.content_search delegates to it, like prefix_search
- new operation search_content, CLI `osw search content`, MCP tool
- returns page titles in the same shape as the sibling searches

* docs(cli): rename `search text` to `search titles`

- `text` next to the new `content` implied it searched page text
- ground the name-property advice in Entity.json's @context mapping
- name the jsondata slot in the content-search caveat
- list `titles` and `content` in the command table

* docs(mcp): describe the credential lookup without the rationale

- replace the env-file prose with a CLI/server table
- state the credential-file order as steps, drop the reasons
- merge the duplicated credential setup into one section
- fix quick install: osw-mcp is the console script, not the server name
- correct the default `claude mcp add` scope to local

* feat(mcp): report config sources only when OSW_VERBOSE is set

- add Settings.verbose, read from OSW_VERBOSE or OSW_MCP_VERBOSE
- MCP server buffers the credential and env-file lines instead of
  writing them to stderr at every start
- a failed start still prints both lines, verbose or not
- CLI output is unchanged

* test(mcp): cover main() on a successful start, quiet and verbose

* fix(service): flag truncation at the limit that reached the wiki

- cap_list only reports a cut it made itself, but all four search
  operations pass the same limit to the wiki, which never returns more,
  so `truncated` was always False
- flag when the result set is as large as the effective limit
- search_entities: a `limit=` inside the ask query wins over the limit
  argument, so compare against that one
- sparql_query unchanged; its limit applies to the response, not the query

* docs(service): say what search_entities actually counts

* docs: reorganize CLI and MCP pages into a Tools section

- replace the "CLI and MCP tools" tab with a "Tools" tab
- split the single page into Overview, CLI, MCP and Configuration
- leave room for a User Sync page from PR #174
- repoint the links in README.md and get-started.md

* feat(cli): add instances status, fix the username status reports

- status reports the username from a credential file, not only from
  OSW_USERNAME, and uses the precedence the login path uses
- rename the "instance" group to "instances", unconfusable with --instance
- add "osw instances status" to check every configured instance at once
- name the correct form when a root option is typed after the command
- extract config.get_credentials_for and Context.osw_for

* fix(service): print the running adapter's name in shared messages

- shared code hardcoded [osw] or [osw-mcp], so each adapter printed the
  other one's name about half the time
- config.set_log_prefix/log_prefix; CLI sets osw, MCP server sets osw-mcp
- covers the default delete and set_slot wiki edit comments, which are
  written permanently into page histories
- add flush=True to the status connection-failure print, which CliRunner
  otherwise never captures because it flushes only stdout

* refactor(cli): rename osw search instances to osw search entities

- "instances" read as OSL instances, which is what osw instances lists
- MCP tool name is the function name, so list_instances_of_category
  becomes search_entities and the ask query becomes search_ask
- fix the help text, which documented [[HasType::Category:<category>]]
  while the real query for Category:Item is [[HasType::Category:Item]]
- set the MCP prefix in the integration fixture that stands in for the
  server, so a future write test cannot tag a live wiki edit as [osw]

* test(logging): reset the osw logger in its fixture

The tests assumed osw was imported after something had configured logging,
which decides whether osw attaches its own handler on import. tests/conftest.py
now imports osw, so that assumption no longer held and the hand-over test read
the leftover import-time handler.

* refactor(service): log shared diagnostics instead of printing them

- six stderr prints in the shared code move to the osw logger
- removes messages lost to an unflushed stderr write
- the configuration source report stays a print, gated by --verbose
- the MCP startup failure stays a print, so OSW_LOG_LEVEL=OFF cannot hide it

* build: declare click as a direct dependency

osw.cli.main subclasses click's Command and Group to report a misplaced root
option, so click is imported directly. deptry flagged it as DEP003 and failed
the quality job.

* docs(service): correct the reason the config report flushes

sys.stderr is line-buffered on every supported Python (>=3.10). The real
reason is the caller-chosen stream: the MCP server passes an io.StringIO and
click's CliRunner never flushes its stderr replacement.

* test(cli): normalize typer's rich output before asserting

- typer forces colour on when GITHUB_ACTIONS is set
- rich styles an option's leading dash separately, so "--jsondata"
  is no longer a plain substring
- rich wraps at the real terminal width, 80 in CI, 79 on Windows
- strip the escape sequences and panel borders, then join the lines

* fix(cli): write stdout and stderr as UTF-8, not the locale encoding

- a redirected stream used cp1252 on Windows, so a German label reached
  the consumer as bytes no JSON parser could read, with exit code 0
- a character cp1252 cannot represent raised UnicodeEncodeError instead
- the app callback reconfigures both streams before any output
- errors= is passed too, or reconfigure would reset stderr to strict
- --help and an unknown command name still bypass it; neither carries
  wiki content

Closes #187

* feat(cli): suppress osw's import notice on the console scripts

- osw and osw-mcp now start through the new src/osw_entry.py shim
- the shim sets OSW_LOG_LEVEL before osw is imported, which is the
  only place that can, since importing any submodule imports the
  package first
- setdefault leaves a value the caller already chose alone
- a plain "import osw" still writes the notice, library behaviour
  is deliberately unchanged
- the wheel ships osw_entry.py via only-include plus sources

* test(cli): cover the osw-mcp script and the shim's level choice

- the osw-mcp console script had no test of its own, only osw did
- it now runs as a subprocess and must leave stdout empty, which is
  the assertion that matters for a JSON-RPC transport
- the level the shim sets moved into _DEFAULT_LEVEL, and a test ties
  it to osw.DEFAULT_LOG_LEVEL so the two cannot drift apart

* fix(config): validate domain and state_dir (#195)

* fix(config): reject a hostless domain and a relative state_dir

- domain accepts a bare host or a full URL, so the check is on what
  _derive_domain yields rather than on the form of the value
- a hostless domain used to fail later in OswExpress.validate_domain,
  quoting a regex instead of naming OSW_DOMAIN
- state_dir "~/osw" created a directory literally named "~", because
  Ledger builds its path with Path() and never expands it
- a relative state_dir resolves against a working directory the MCP
  client chooses, so the ledger landed in an unpredictable place
- last open item from #143

* test(config): cover drive-relative state_dir and an unknown home

- reject "\osw-state" and "C:osw-state", non-absolute on both platforms
- assert the expanduser RuntimeError becomes a named config error
- state in the docs that a domain without a host is rejected

* test: decode subprocess output as UTF-8, not the locale codepage

- text=True alone decodes with locale.getencoding(), which ignores
  Python's UTF-8 mode, so a child writing UTF-8 broke the reader
  thread with UnicodeDecodeError and returned the stream as None
- reproduced on Windows: byte 0x90 at position 1028 of osw --help
- the osw_entry tests now share a _DECODE mapping
- test_no_paths_on_mcp_surface gets the same kwargs as protection
- 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
- count a title as created or updated only after store_entity stored it
- record each failed title of StoreEntityPartialError as store:<title>
- write redirects only for stored or pre-existing items
- drop new links to organizations that failed to store
- prune only updates that were stored
@LukasGold

Copy link
Copy Markdown
Contributor

Fix for #224 pushed as 0b8dc97 (fix(user-sync): count and redirect only stored items).

Changes in src/osw/tools/user_sync/sync.py:

  • _store_users counts a title as created or updated only after store_entity stored it. Before, titles were counted while the entities were built.
  • On OSW.StoreEntityPartialError, only the titles in stored count, and each failed title is recorded as report.failed["store:<title>"]. Same for _store_organizations. Any other exception still sets report.failed["store"] and counts nothing.
  • _create_redirects skips a create whose item was not stored. update and skip keep their redirect, because resolve_plan assigns them only to users with an existing item.
  • New links to an organization that failed to store are dropped before the user is stored. A link the existing item already has is kept. A re-run proposes the dropped links again; if the item has other organizations, the re-run shows a conflict instead of a gap fill.
  • _prune_users skips updates that were not stored.

Tests: new tests/tools/test_user_sync_store_failures.py covers a partial store, a failed store, redirects after a failed create, a failed and a partially failed organization store, a kept existing organization link, prune after a partial store, and an accepted successful sync.

Not handled: an existing item that is deleted between the load and the store still gets its redirect. This was already the case before the change.

@LukasGold

Copy link
Copy Markdown
Contributor

Note for anyone with a local checkout of user-item-upd: the branch moved from e97daa4 to 0b8dc97 (the fix for #224, see #174 (comment)). Run git pull before you commit or push more work on this branch, so the next push does not fail as a non-fast-forward.

@LukasGold

Copy link
Copy Markdown
Contributor

Live test of --prune on a test wiki (stacktest), run from a local merge of stage into this branch.

Result: website and organization are cleared. employment_contract_status is stored as [], but the item still reads as Active, both in osw-python and in SMW.

--prune writes values that do not match the schema

clear_jsondata_keys() in src/osw/tools/user_sync/prune.py sets every pruned key to [], independent of its schema type. The User schema (Category:OSWd9aa0bca9b0040d8af6f5c091bf9eec7, defined in world.opensemantic.base) defines the field as a single string:

"employment_contract_status": {
  "type": "string",
  "format": "autocomplete",
  "range": "Category:OSW781ad17c7eef4161ade5a7b690aca6b5",
  "default": "Item:OSW62770459451644f3841c502df8a5cb1d"
}

The jsondata written by the prune must be valid against the schema of the category. [] is valid for website and organization (arrays), but not for employment_contract_status.

The default applies when the value is empty

An empty value does not clear the status. Both osw-python and the wiki replace it with the default Item:OSW62770459451644f3841c502df8a5cb1d (Active).

osw-python:

  • load_entity() runs remove_empty() on the stored jsondata (src/osw/core.py, LoadEntityParam.remove_empty defaults to True). remove_empty() (src/osw/utils/wiki.py) deletes keys whose value is "", [], {} or an empty set.
  • The model is then built without the key, and the packaged model fills it with its default_factory, which returns Active (opensemantic/base/_model.py, field employment_contract_status).
  • store_entity() serializes with exclude_none=True and also runs remove_empty(). It keeps an empty value only when the caller set it explicitly (_explicitly_set_empty()). So None, "" and [] all become an absent key, and an absent key loads as Active.
  • Live result: after the prune, the raw jsondata slot holds [], and load_entity() returns Item:OSW62770459451644f3841c502df8a5cb1d for the same page. A second --prune --dry-run therefore proposes -employment_contract_status again.

Wiki (SMW):

  • The User header template sets the property with a wikitext parameter default (slot_header_template.wikitext:36):
    {{#set: HasEmploymentContractStatus={{{employment_contract_status|Item:OSW62770459451644f3841c502df8a5cb1d}}} }}
  • Before it renders the template, MwJson removes every table value from the jsondata, so [] is removed and the parameter is undefined (MwJson.slot_main.lua:513-516).
  • The template default then sets Active. A missing key gives the same result.
  • This is a template parameter default, not the JSON Schema default, but the value is the same.
  • Live result: after the prune, an ask query on the page returns HasEmploymentContractStatus = Item:OSW62770459451644f3841c502df8a5cb1d, while HasWebsite and HasOrganization are empty.
  • Limit: I read the template and the Lua module in the page packages. I could not compare them with the versions installed on stacktest, because the wiki was unreachable afterwards. The live result matches the code.

Consequence

With the current schema and templates, an empty or absent employment_contract_status means Active, in osw-python and in the wiki. --prune cannot express "not employed" by clearing the field.

A schema-valid alternative is to write one of the other values of the range, Item:OSWacf5f4db53264d29ae8744d03796be0b (Inactive) or Item:OSWa09571ab688e4395aa1a845234e3093b (Unknown). Which value is correct for a pruned user is a design decision for this PR.

- 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
- a stored [] on employment_contract_status is now removed by --prune
@LukasGold

Copy link
Copy Markdown
Contributor

@raederan please view the changes that I made based on AI assisted review

@raederan

Copy link
Copy Markdown
Contributor Author

@LukasGold is fine for me

@raederan
raederan removed the request for review from simontaurus November 12, 2026 15:35

@LukasGold LukasGold left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-depth review with code changes performed on this PR, good to merge

@LukasGold
LukasGold merged commit 8a059eb into stage Nov 12, 2026
12 checks passed
@LukasGold
LukasGold deleted the user-item-upd branch November 12, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants