diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3e4b08b6..9114320d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "osl-tasks", "displayName": "OSL task management", "description": "Task and project management for OpenSemanticLab, driven by the osw CLI and MCP server.", - "version": "2.9.0", + "version": "2.9.1", "author": { "name": "OpenSemanticLab" }, diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index bfe85cbb..4b86eec0 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -2,16 +2,17 @@ # INTEGR_TEST_* secrets; where they are unavailable (e.g. forks), the # credential fixtures make the tests skip instead of fail. # -# Runs on every pull request into main, so breaking changes surface before the -# release job (gated separately by required review on the pypi environment) -# ever starts. No push trigger: a push to main is already covered by the PR -# that preceded it, so a second run would add nothing. +# Runs on every pull request into main or stage, so breaking changes surface +# before the release job (gated separately by required review on the pypi +# environment) ever starts. stage collects reviewed pull requests before they +# go to main together. No push trigger: a push to main is already covered by +# the PR that preceded it, so a second run would add nothing. name: Integration on: pull_request: - branches: [main] + branches: [main, stage] workflow_dispatch: concurrency: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index da26db27..2548d777 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Main on: push: - branches: [main] + branches: [main, stage] pull_request: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 902fcb3e..19297525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ +## v2.9.1 (2026-11-06) + +### Bug Fixes + +- **core**: Resolve bare $refs before dropping schema definitions + ([#164](https://github.com/OpenSemanticLab/osw-python/pull/164), + [`d86ad31`](https://github.com/OpenSemanticLab/osw-python/commit/d86ad31b9531b027117707bfa159a36f8b5d0694)) + + ## v2.9.0 (2026-11-05) ### Features diff --git a/CITATION.cff b/CITATION.cff index d0881772..0139075d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,7 +3,7 @@ message: "If you use this software, please cite it as below." type: software title: "OpenSemanticWorld Python Package (osw-python)" # Managed by python-semantic-release (version_variables). Do not edit by hand. -version: 2.9.0 +version: 2.9.1 abstract: "Python toolset for data processing, queries, wikicode generation and page manipulation within OpenSemanticLab: work with OpenSemanticLab instances as typed pydantic entities, query with semantic search, generate models from wiki schemas, and write changes back." repository-code: "https://github.com/OpenSemanticLab/osw-python" url: "https://opensemanticlab.github.io/osw-python/" diff --git a/docs/dev.md b/docs/dev.md index 82625fce..0d7e0540 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -66,8 +66,8 @@ excluded by default: Integration tests run against a live wiki and need credentials; tests whose credentials are missing skip instead of fail. CI runs them on every -pull request into `main` (workflow `integration.yml`), so they gate the -release rather than run after it. +pull request into `main` or `stage` (workflow `integration.yml`), so they +gate the release rather than run after it. === "make" diff --git a/pyproject.toml b/pyproject.toml index c567f0e0..51f2e253 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license-files = ["LICENSE.txt"] requires-python = ">=3.10,<3.14" # Managed by python-semantic-release (see [tool.semantic_release]). Do not # edit by hand; it is bumped automatically on merge to main. -version = "2.9.0" +version = "2.9.1" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", diff --git a/src/osw/core.py b/src/osw/core.py index ed6b0a85..03594f6b 100644 --- a/src/osw/core.py +++ b/src/osw/core.py @@ -485,7 +485,29 @@ def register_schema(self, schema_registration: SchemaRegistration): else: result_array.append(subschema) match.full_path.update_or_create(schema, result_array) - if "definitions" in schema: + + all_refs_resolved = True + bare_ref_jsonpath_expr = parse("$..dollarref") + # pydantic only wraps a $ref in allOf when the field carries extra + # metadata (e.g. Field(description=...)). A plain nested-model + # field produces a bare {"$ref": ...} that is not reachable via + # the "$..allOf" jsonpath above, so replace those local + # definitions (#/definitions/...) with embedded definitions too, + # to prevent a dangling ref once "definitions" is removed below + for match in bare_ref_jsonpath_expr.find(schema): + value = match.value + if value.startswith("#"): + definition_jsonpath_expr = parse( + value.replace("#", "$").replace("/", ".") + ) + def_matches = definition_jsonpath_expr.find(schema) + for def_match in def_matches: + match.context.full_path.update_or_create( + schema, def_match.value + ) + if not def_matches: + all_refs_resolved = False + if "definitions" in schema and all_refs_resolved: del schema["definitions"] if "allOf" not in schema: diff --git a/src/osw/skills/osl-tasks/SKILL.md b/src/osw/skills/osl-tasks/SKILL.md index 093de3bb..7296e8df 100644 --- a/src/osw/skills/osl-tasks/SKILL.md +++ b/src/osw/skills/osl-tasks/SKILL.md @@ -2,7 +2,7 @@ name: osl-tasks description: Use when importing local todo or note files into an OpenSemanticLab (OSL) wiki as Task entities, or when listing, filtering and updating tasks that already live in OSL. Requires the `osw` CLI or the osw MCP server. metadata: - version: "2.9.0" + version: "2.9.1" --- # OSL task management diff --git a/tests/test_register_schema_nested_refs.py b/tests/test_register_schema_nested_refs.py new file mode 100644 index 00000000..d48ee6a7 --- /dev/null +++ b/tests/test_register_schema_nested_refs.py @@ -0,0 +1,109 @@ +"""Unit tests for register_schema()'s $ref post-processing (issue #101). + +Regression guard: pydantic v1 only wraps a nested-model $ref in "allOf" when +the field carries extra metadata (e.g. Field(description=...)). A plain +nested-model field produces a bare {"$ref": ...} that the old code never +rewrote before deleting "definitions", leaving a dangling ref behind. + +These run fully offline: WtPage.init and WtPage.edit are stubbed so no +network is required, following the pattern in test_store_entity_failure.py. +""" + +import uuid + +import pytest +from pydantic.v1 import Field + +import osw.model.entity as model +from osw.core import OSW +from osw.wtsite import WtPage + + +class Inner(model.OswBaseModel): + a: str = "x" + + +class OuterBare(model.OswBaseModel): + """Nested field without Field(...) metadata: pydantic emits a bare $ref.""" + + inner: Inner + + +class OuterAllOf(model.OswBaseModel): + """Nested field with Field(description=...): pydantic wraps the $ref in allOf.""" + + inner: Inner = Field(description="the inner thing") + + +class _FakeMwSite: + host = "example.org" + + +class _FakeSite: + mw_site = _FakeMwSite() + + +@pytest.fixture +def offline_osw(monkeypatch): + # no network when a WtPage is constructed with do_init=True; mimic the + # do_init=False branch of WtPage.__init__ which sets .exists + monkeypatch.setattr(WtPage, "init", lambda self: setattr(self, "exists", False)) + captured_schemas = [] + + def fake_edit(self, *args, **kwargs): + captured_schemas.append(self._slots.get("jsonschema")) + + monkeypatch.setattr(WtPage, "edit", fake_edit) + return OSW.construct(site=_FakeSite()), captured_schemas + + +def _register_and_get_schema(offline_osw, model_cls, name): + osw, captured_schemas = offline_osw + osw.register_schema( + OSW.SchemaRegistration( + model_cls=model_cls, + schema_uuid=str(uuid.uuid4()), + schema_name=name, + ) + ) + return captured_schemas[-1] + + +def _has_dangling_ref(node): + """Recursively look for a "dollarref" (register_schema's stand-in for + "$ref") anywhere in the schema. Since register_schema only ever emits + local (#/definitions/...) refs, any leftover one is dangling once + "definitions" is removed. + """ + if isinstance(node, dict): + if "dollarref" in node: + return True + return any(_has_dangling_ref(v) for v in node.values()) + if isinstance(node, list): + return any(_has_dangling_ref(v) for v in node) + return False + + +def test_bare_nested_ref_is_resolved_like_allof(offline_osw): + """A nested field with no Field(...) metadata must not leave a dangling + ref behind, and must be embedded the same way an allOf-wrapped ref is. + """ + schema = _register_and_get_schema(offline_osw, OuterBare, "OuterBare") + + assert not _has_dangling_ref(schema) + assert "definitions" not in schema + assert schema["properties"]["inner"]["title"] == "Inner" + assert schema["properties"]["inner"]["properties"]["a"]["type"] == "string" + + +def test_allof_wrapped_ref_is_still_resolved(offline_osw): + """Existing behaviour for allOf-wrapped refs must be unchanged.""" + schema = _register_and_get_schema(offline_osw, OuterAllOf, "OuterAllOf") + + assert not _has_dangling_ref(schema) + assert "definitions" not in schema + assert schema["properties"]["inner"]["description"] == "the inner thing" + assert schema["properties"]["inner"]["allOf"][0]["title"] == "Inner" + assert ( + schema["properties"]["inner"]["allOf"][0]["properties"]["a"]["type"] == "string" + ) diff --git a/uv.lock b/uv.lock index b2e2c0af..e851018e 100644 --- a/uv.lock +++ b/uv.lock @@ -2066,7 +2066,7 @@ wheels = [ [[package]] name = "osw" -version = "2.9.0" +version = "2.9.1" source = { editable = "." } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" },