Skip to content

Commit e9341f9

Browse files
authored
fix(core): report dropped entities and restore the cache on error
Merge pull request #185 from OpenSemanticLab/fix/183-silent-drop-and-cache-restore
2 parents 33c6532 + 7297ca6 commit e9341f9

3 files changed

Lines changed: 350 additions & 84 deletions

File tree

‎src/osw/core.py‎

Lines changed: 98 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,93 +1227,101 @@ def load_entity(
12271227
# enable cache to speed up loading
12281228
self.site.enable_cache()
12291229

1230-
entities = []
1231-
pages = self.site.get_page(
1232-
WtSite.GetPageParam(titles=param.titles, offline_pages=param.offline_pages)
1233-
).pages
1234-
for page in pages:
1235-
entity = None
1236-
schemas = []
1237-
schemas_fetched = True
1238-
jsondata = page.get_slot_content("jsondata")
1239-
if param.remove_empty:
1240-
remove_empty(jsondata)
1241-
if jsondata:
1242-
for category in jsondata["type"]:
1243-
schema = (
1244-
self.site
1245-
.get_page(
1246-
WtSite.GetPageParam(
1247-
titles=[category], offline_pages=param.offline_pages
1230+
# the restore runs in a finally block so that an exception from any of the
1231+
# calls below cannot leave the cache enabled for the rest of the process
1232+
try:
1233+
entities = []
1234+
pages = self.site.get_page(
1235+
WtSite.GetPageParam(
1236+
titles=param.titles, offline_pages=param.offline_pages
1237+
)
1238+
).pages
1239+
for page in pages:
1240+
entity = None
1241+
schemas = []
1242+
schemas_fetched = True
1243+
jsondata = page.get_slot_content("jsondata")
1244+
if param.remove_empty:
1245+
remove_empty(jsondata)
1246+
if jsondata:
1247+
for category in jsondata["type"]:
1248+
schema = (
1249+
self.site
1250+
.get_page(
1251+
WtSite.GetPageParam(
1252+
titles=[category], offline_pages=param.offline_pages
1253+
)
12481254
)
1255+
.pages[0]
1256+
.get_slot_content("jsonschema")
12491257
)
1250-
.pages[0]
1251-
.get_slot_content("jsonschema")
1252-
)
1253-
schemas.append(schema)
1254-
# generate model if not already exists
1255-
cls_name: str = schema["title"]
1256-
# If a schema_to_use is provided, we do not need to check if the
1257-
# model exists
1258-
if not param.model_to_use:
1259-
if not hasattr(model, cls_name):
1260-
if param.autofetch_schema:
1261-
self.fetch_schema(
1262-
OSW.FetchSchemaParam(
1263-
schema_title=category,
1264-
mode="append",
1265-
offline_pages=param.offline_pages,
1258+
schemas.append(schema)
1259+
# generate model if not already exists
1260+
cls_name: str = schema["title"]
1261+
# If a schema_to_use is provided, we do not need to check if
1262+
# the model exists
1263+
if not param.model_to_use:
1264+
if not hasattr(model, cls_name):
1265+
if param.autofetch_schema:
1266+
self.fetch_schema(
1267+
OSW.FetchSchemaParam(
1268+
schema_title=category,
1269+
mode="append",
1270+
offline_pages=param.offline_pages,
1271+
)
12661272
)
1273+
if not hasattr(model, cls_name):
1274+
schemas_fetched = False
1275+
print(
1276+
f"Error: Model {cls_name} not found. Schema {category} "
1277+
f"needs to be fetched first."
12671278
)
1268-
if not hasattr(model, cls_name):
1269-
schemas_fetched = False
1270-
print(
1271-
f"Error: Model {cls_name} not found. Schema {category} "
1272-
f"needs to be fetched first."
1273-
)
1274-
if not schemas_fetched:
1275-
continue
1276-
1277-
try:
1278-
if param.model_to_use:
1279-
entity: model.OswBaseModel = param.model_to_use(**jsondata)
1279+
if not schemas_fetched:
1280+
continue
12801281

1281-
elif len(schemas) == 0:
1282-
_logger.error("Error: no schema defined")
1282+
try:
1283+
if param.model_to_use:
1284+
entity: model.OswBaseModel = param.model_to_use(**jsondata)
12831285

1284-
elif len(schemas) == 1:
1285-
cls: Type[model.Entity] = getattr(model, schemas[0]["title"])
1286-
entity: model.Entity = cls(**jsondata)
1286+
elif len(schemas) == 0:
1287+
_logger.error("Error: no schema defined")
12871288

1288-
else:
1289-
bases = []
1290-
for schema in schemas:
1291-
bases.append(getattr(model, schema["title"]))
1292-
cls = create_model("Test", __base__=tuple(bases))
1293-
entity: model.Entity = cls(**jsondata)
1294-
except Exception as e:
1295-
_logger.error(f"Error creating entity from page {page.title}: {e}")
1296-
# legacy: `entity` is annotated as OswBaseModel and Entity above
1297-
entity = None # ty: ignore[conflicting-declarations]
1289+
elif len(schemas) == 1:
1290+
cls: Type[model.Entity] = getattr(model, schemas[0]["title"])
1291+
entity: model.Entity = cls(**jsondata)
12981292

1299-
if entity is not None:
1300-
# make sure we do not override existing metadata
1301-
if not hasattr(entity, "meta") or entity.meta is None:
1302-
entity.meta = model.Meta()
1303-
if (
1304-
not hasattr(entity.meta, "wiki_page")
1305-
or entity.meta.wiki_page is None
1306-
):
1307-
entity.meta.wiki_page = model.WikiPage()
1308-
entity.meta.wiki_page.namespace = namespace_from_full_title(page.title)
1309-
entity.meta.wiki_page.title = title_from_full_title(page.title)
1293+
else:
1294+
bases = []
1295+
for schema in schemas:
1296+
bases.append(getattr(model, schema["title"]))
1297+
cls = create_model("Test", __base__=tuple(bases))
1298+
entity: model.Entity = cls(**jsondata)
1299+
except Exception as e:
1300+
_logger.error(f"Error creating entity from page {page.title}: {e}")
1301+
# legacy: `entity` is annotated as OswBaseModel and Entity above
1302+
entity = None # ty: ignore[conflicting-declarations]
1303+
1304+
if entity is not None:
1305+
# make sure we do not override existing metadata
1306+
if not hasattr(entity, "meta") or entity.meta is None:
1307+
entity.meta = model.Meta()
1308+
if (
1309+
not hasattr(entity.meta, "wiki_page")
1310+
or entity.meta.wiki_page is None
1311+
):
1312+
entity.meta.wiki_page = model.WikiPage()
1313+
entity.meta.wiki_page.namespace = namespace_from_full_title(
1314+
page.title
1315+
)
1316+
entity.meta.wiki_page.title = title_from_full_title(page.title)
13101317

1311-
entities.append(entity)
1312-
# restore original cache state
1313-
if cache_state:
1314-
self.site.enable_cache()
1315-
else:
1316-
self.site.disable_cache()
1318+
entities.append(entity)
1319+
finally:
1320+
# restore original cache state
1321+
if cache_state:
1322+
self.site.enable_cache()
1323+
else:
1324+
self.site.disable_cache()
13171325

13181326
if isinstance(entity_title, str): # single title
13191327
if len(entities) >= 1:
@@ -1701,8 +1709,11 @@ class StoreEntityResult(OswBaseModel):
17011709
"""The pages that have been successfully stored, keyed by full page title.
17021710
On partial failure this contains only the successfully-stored pages."""
17031711
failed: Dict[str, Exception] = {}
1704-
"""Entities that could not be stored, keyed by full page title and mapped to
1705-
the exception that caused the failure. Empty on full success."""
1712+
"""Entities that could not be stored, mapped to the exception that caused the
1713+
failure. Empty on full success. The key is the full page title where one could
1714+
be determined. For an entity whose title or namespace could not be resolved it
1715+
falls back to the entity name, then to its uuid, then to 'unknown', so do not
1716+
parse this key as 'namespace:title'."""
17061717

17071718
class Config:
17081719
arbitrary_types_allowed = True
@@ -1859,20 +1870,23 @@ def store_entity_(
18591870
entity_name = getattr(entity_, "name", None) or getattr(
18601871
entity_, "uuid", "unknown"
18611872
)
1862-
_logger.error(f"Error getting title for entity '{entity_name}': {e}")
1863-
return
1873+
# raise instead of returning: a plain return is not an exception,
1874+
# so the collector loop below would record the entity in neither
1875+
# created_pages nor failed and store_entity would report success
1876+
raise ValueError(
1877+
f"Error getting title for entity '{entity_name}': {e}"
1878+
) from e
18641879
if namespace_ is None:
18651880
namespace_ = get_namespace(entity_)
18661881
if namespace_ is None or title_ is None:
18671882
entity_name = getattr(entity_, "name", None) or getattr(
18681883
entity_, "uuid", "unknown"
18691884
)
1870-
_logger.error(
1885+
raise TypeError(
18711886
f"Unsupported entity type: namespace={namespace_}, "
18721887
f"title={title_}, entity name='{entity_name}', "
18731888
f"type={type(entity_).__name__}"
18741889
)
1875-
return
18761890
if overwrite_class_param is None:
18771891
raise TypeError("'overwrite_class_param' must not be None!")
18781892
entity_title = namespace_ + ":" + title_
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

0 commit comments

Comments
 (0)