Skip to content

Commit a10790e

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/logging
# Conflicts: # src/osw/core.py
2 parents 2f78559 + f361065 commit a10790e

10 files changed

Lines changed: 710 additions & 88 deletions

‎CHANGELOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,40 @@
1010
1111
<!-- version list -->
1212

13+
## v2.3.5 (2026-09-21)
14+
15+
### Bug Fixes
16+
17+
- **core**: Report dropped entities and restore the cache on error
18+
([`870adb8`](https://github.com/OpenSemanticLab/osw-python/commit/870adb876b890c8df97d6545fe2ff113940d4b5b))
19+
20+
### Documentation
21+
22+
- **core**: State that a failed key is not always a page title
23+
([`7297ca6`](https://github.com/OpenSemanticLab/osw-python/commit/7297ca6250292493697cbf8746b492f9a97e188e))
24+
25+
### Testing
26+
27+
- **core**: Cover a raise inside the load_entity page loop
28+
([`88be481`](https://github.com/OpenSemanticLab/osw-python/commit/88be4810807db103af965d9f812952875c4589c7))
29+
30+
31+
## v2.3.4 (2026-09-21)
32+
33+
### Bug Fixes
34+
35+
- **core**: Confirm a missing page before reporting a failed store
36+
([`5756436`](https://github.com/OpenSemanticLab/osw-python/commit/5756436e08ba95be3e177c6a78c424bfdba756c8))
37+
38+
- **core**: Verify that store_entity actually created the page
39+
([`f84a529`](https://github.com/OpenSemanticLab/osw-python/commit/f84a5297c605fc458783e5497edeb1f97113053b))
40+
41+
### Chores
42+
43+
- Relicense to Apache-2.0 ([#173](https://github.com/OpenSemanticLab/osw-python/pull/173),
44+
[`848615c`](https://github.com/OpenSemanticLab/osw-python/commit/848615cbd1e06401922eda88231a51028440a242))
45+
46+
1347
## v2.3.3 (2026-09-08)
1448

1549
### Bug Fixes

‎CITATION.cff‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ message: "If you use this software, please cite it as below."
33
type: software
44
title: "OpenSemanticWorld Python Package (osw-python)"
55
# Managed by python-semantic-release (version_variables). Do not edit by hand.
6-
version: 2.3.3
6+
version: 2.3.5
77
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."
88
repository-code: "https://github.com/OpenSemanticLab/osw-python"
99
url: "https://opensemanticlab.github.io/osw-python/"

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ license-files = ["LICENSE.txt"]
88
requires-python = ">=3.10,<3.14"
99
# Managed by python-semantic-release (see [tool.semantic_release]). Do not
1010
# edit by hand; it is bumped automatically on merge to main.
11-
version = "2.3.3"
11+
version = "2.3.5"
1212
classifiers = [
1313
"Development Status :: 3 - Alpha",
1414
"Intended Audience :: Developers",

‎src/osw/core.py‎

Lines changed: 205 additions & 85 deletions
Large diffs are not rendered by default.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Unit tests for the cache-state restore in load_entity().
2+
3+
Regression guard for #183: https://github.com/OpenSemanticLab/osw-python/issues/183
4+
load_entity() saved the original cache state before fetching pages and restored
5+
it afterwards, but the restore was not in a finally block. An exception raised
6+
while fetching or parsing a page left the cache enabled (or disabled) for the
7+
rest of the process instead of restoring the state the caller had before the
8+
call.
9+
10+
These run fully offline: the fake site never touches the network.
11+
"""
12+
13+
from types import SimpleNamespace
14+
15+
import pytest
16+
17+
from osw.core import OSW
18+
19+
20+
class _FakeSite:
21+
def __init__(self, cache_enabled, pages=None, fails=False):
22+
self.cache_enabled = cache_enabled
23+
self._pages = pages if pages is not None else []
24+
self.fails = fails
25+
26+
def get_cache_enabled(self):
27+
return self.cache_enabled
28+
29+
def enable_cache(self):
30+
self.cache_enabled = True
31+
32+
def disable_cache(self):
33+
self.cache_enabled = False
34+
35+
def get_page(self, param):
36+
if self.fails:
37+
raise RuntimeError("the wiki is not reachable")
38+
return SimpleNamespace(pages=self._pages)
39+
40+
41+
@pytest.mark.parametrize("disable_cache", [False, True])
42+
def test_cache_stays_disabled_when_get_page_raises(disable_cache):
43+
site = _FakeSite(cache_enabled=False, fails=True)
44+
osw_obj = OSW.construct(site=site)
45+
46+
with pytest.raises(RuntimeError):
47+
osw_obj.load_entity(
48+
OSW.LoadEntityParam(titles=["Item:Foo"], disable_cache=disable_cache)
49+
)
50+
51+
assert site.get_cache_enabled() is False
52+
53+
54+
@pytest.mark.parametrize("disable_cache", [False, True])
55+
def test_cache_stays_enabled_when_get_page_raises(disable_cache):
56+
site = _FakeSite(cache_enabled=True, fails=True)
57+
osw_obj = OSW.construct(site=site)
58+
59+
with pytest.raises(RuntimeError):
60+
osw_obj.load_entity(
61+
OSW.LoadEntityParam(titles=["Item:Foo"], disable_cache=disable_cache)
62+
)
63+
64+
assert site.get_cache_enabled() is True
65+
66+
67+
class _FailingPage:
68+
"""A page whose slot content cannot be read.
69+
70+
Raises from inside the per-page loop rather than from get_page, so that the
71+
try block is shown to cover the whole body and not only its first call.
72+
"""
73+
74+
title = "Item:Foo"
75+
76+
def get_slot_content(self, slot):
77+
raise RuntimeError("the slot content is not readable")
78+
79+
80+
@pytest.mark.parametrize("cache_enabled", [False, True])
81+
def test_cache_is_restored_when_the_page_loop_raises(cache_enabled):
82+
site = _FakeSite(cache_enabled=cache_enabled, pages=[_FailingPage()])
83+
osw_obj = OSW.construct(site=site)
84+
85+
with pytest.raises(RuntimeError):
86+
osw_obj.load_entity(
87+
OSW.LoadEntityParam(titles=["Item:Foo"], disable_cache=True)
88+
)
89+
90+
assert site.get_cache_enabled() is cache_enabled
91+
92+
93+
def test_cache_state_is_restored_after_normal_path_disabled():
94+
site = _FakeSite(cache_enabled=False)
95+
osw_obj = OSW.construct(site=site)
96+
97+
osw_obj.load_entity(OSW.LoadEntityParam(titles=["Item:Foo"]))
98+
99+
assert site.get_cache_enabled() is False
100+
101+
102+
def test_cache_state_is_restored_after_normal_path_enabled():
103+
site = _FakeSite(cache_enabled=True)
104+
osw_obj = OSW.construct(site=site)
105+
106+
osw_obj.load_entity(OSW.LoadEntityParam(titles=["Item:Foo"]))
107+
108+
assert site.get_cache_enabled() is True

‎tests/test_overwrite_policy.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ def _stub_page_io(monkeypatch, exists: bool):
355355
)
356356
edited = []
357357
monkeypatch.setattr(WtPage, "edit", lambda self, *a, **k: edited.append(self.title))
358+
# the write verification of store_entity() would query the wiki. These tests
359+
# cover the overwrite policy, so report every edited page as existing. The
360+
# verification itself is covered by test_store_entity_verify.py
361+
monkeypatch.setattr(OSW, "_get_missing_page_titles", lambda self, titles: [])
358362
return edited
359363

360364

‎tests/test_store_entity_failure.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ def offline_osw(monkeypatch):
2626
monkeypatch.setattr(
2727
OSW, "_apply_overwrite_policy", staticmethod(lambda param: param.page)
2828
)
29+
# the write verification would query the wiki. These tests cover the reporting
30+
# of a failed edit, so report every edited page as existing. The verification
31+
# itself is covered by test_store_entity_verify.py
32+
monkeypatch.setattr(OSW, "_get_missing_page_titles", lambda self, titles: [])
2933
return OSW.construct(site=object())
3034

3135

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Unit tests for store_entity() dropping entities without recording a failure.
2+
3+
Regression guard for #183: https://github.com/OpenSemanticLab/osw-python/issues/183
4+
store_entity_() returned plainly (instead of raising) when it could not determine
5+
an entity's title or namespace. A plain return is not an exception, so the
6+
collector loop in store_entity() never recorded the entity in failed, and it was
7+
never in created_pages either: the entity was silently dropped without being
8+
reported to the caller. store_entity_() must now raise in both cases so the
9+
existing collector loop records them.
10+
11+
These run fully offline: WtPage.init, the overwrite policy, WtPage.edit and the
12+
write verification are stubbed, so no network is required.
13+
"""
14+
15+
import pytest
16+
17+
import osw.core as core_mod
18+
import osw.model.entity as model
19+
from osw.core import OSW
20+
from osw.utils.wiki import get_namespace, get_title
21+
from osw.wtsite import WtPage
22+
23+
24+
def _title(entity):
25+
return f"{get_namespace(entity)}:{get_title(entity)}"
26+
27+
28+
@pytest.fixture
29+
def offline_osw(monkeypatch):
30+
# no network when a WtPage is constructed with do_init=True; mimic the
31+
# do_init=False branch of WtPage.__init__ which sets .exists
32+
monkeypatch.setattr(WtPage, "init", lambda self: setattr(self, "exists", False))
33+
# bypass the overwrite policy: return the page that was built for the entity
34+
monkeypatch.setattr(
35+
OSW, "_apply_overwrite_policy", staticmethod(lambda param: param.page)
36+
)
37+
monkeypatch.setattr(WtPage, "edit", lambda self, *a, **kw: None)
38+
# the write verification would query the wiki. These tests are not about
39+
# verification, so report every edited page as existing. See
40+
# tests/test_store_entity_verify.py for the write verification tests.
41+
monkeypatch.setattr(OSW, "_get_missing_page_titles", lambda self, titles: [])
42+
return OSW.construct(site=object())
43+
44+
45+
def test_title_failure_is_reported_serial(offline_osw, monkeypatch):
46+
def _raise_get_title(entity):
47+
raise RuntimeError("boom")
48+
49+
monkeypatch.setattr(core_mod, "get_title", _raise_get_title)
50+
item = model.Item(label=[model.Label(text="Solo")])
51+
52+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
53+
offline_osw.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
54+
55+
err = exc_info.value
56+
assert len(err.failed) == 1
57+
(exc,) = err.failed.values()
58+
assert isinstance(exc, ValueError)
59+
assert "Error getting title for entity" in str(exc)
60+
61+
62+
def test_title_failure_is_reported_parallel(offline_osw, monkeypatch):
63+
def _raise_get_title(entity):
64+
raise RuntimeError("boom")
65+
66+
monkeypatch.setattr(core_mod, "get_title", _raise_get_title)
67+
item = model.Item(label=[model.Label(text="Solo")])
68+
69+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
70+
offline_osw.store_entity(OSW.StoreEntityParam(entities=[item], parallel=True))
71+
72+
err = exc_info.value
73+
assert len(err.failed) == 1
74+
(exc,) = err.failed.values()
75+
assert isinstance(exc, ValueError)
76+
assert "Error getting title for entity" in str(exc)
77+
78+
79+
def test_missing_namespace_is_reported(offline_osw, monkeypatch):
80+
monkeypatch.setattr(core_mod, "get_namespace", lambda entity: None)
81+
item = model.Item(label=[model.Label(text="NoNamespace")])
82+
83+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
84+
offline_osw.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
85+
86+
err = exc_info.value
87+
assert len(err.failed) == 1
88+
(exc,) = err.failed.values()
89+
assert isinstance(exc, TypeError)
90+
assert "Unsupported entity type" in str(exc)
91+
92+
93+
def test_missing_title_is_reported(offline_osw, monkeypatch):
94+
monkeypatch.setattr(core_mod, "get_title", lambda entity: None)
95+
item = model.Item(label=[model.Label(text="NoTitle")])
96+
97+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
98+
offline_osw.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
99+
100+
err = exc_info.value
101+
assert len(err.failed) == 1
102+
(exc,) = err.failed.values()
103+
assert isinstance(exc, TypeError)
104+
assert "Unsupported entity type" in str(exc)
105+
106+
107+
def test_only_the_failing_entity_of_a_batch_is_reported(offline_osw, monkeypatch):
108+
real_get_title = get_title
109+
good_item = model.Item(label=[model.Label(text="Good")])
110+
bad_item = model.Item(label=[model.Label(text="Bad")])
111+
good_title = _title(good_item)
112+
113+
# store_entity() re-validates its 'entities' param, so the object identity
114+
# of good_item/bad_item is not preserved past that point. Recognize the
115+
# failing entity by its label instead.
116+
def _get_title(entity):
117+
if entity.label and entity.label[0].text == "Bad":
118+
raise RuntimeError("boom")
119+
return real_get_title(entity)
120+
121+
monkeypatch.setattr(core_mod, "get_title", _get_title)
122+
123+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
124+
offline_osw.store_entity(
125+
OSW.StoreEntityParam(entities=[good_item, bad_item], parallel=False)
126+
)
127+
128+
err = exc_info.value
129+
assert len(err.result.pages) == 1
130+
assert len(err.failed) == 1
131+
assert good_title in err.result.pages
132+
assert err.stored == [good_title]
133+
134+
135+
def test_normal_path_is_unaffected(offline_osw):
136+
item = model.Item(label=[model.Label(text="Fine")])
137+
title = _title(item)
138+
139+
result = offline_osw.store_entity(
140+
OSW.StoreEntityParam(entities=[item], parallel=False)
141+
)
142+
143+
assert set(result.pages.keys()) == {title}
144+
assert result.failed == {}

0 commit comments

Comments
 (0)