Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/osw/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,21 @@ def resolve(self, request: ResolveParam):
entities = osw_obj.load_entity(
OSW.LoadEntityParam(titles=request.iris)
).entities
# create a dict with request.iris as keys and the loaded entities as values
# by iterating over both lists
# load_entity() skips pages it cannot build, so the returned
# list can be shorter than request.iris. Pair by full title
# instead of by position, so a skipped page does not shift
# every following entity onto the wrong iri.
entities_by_title = {
get_full_title(entity): entity for entity in entities
}
nodes = {}
for iri, entity in zip(request.iris, entities):
for iri in request.iris:
entity = entities_by_title.get(iri)
if entity is None:
_logger.warning(f"Could not resolve iri '{iri}'")
# ResolveResult.nodes is typed Dict[str, Union[None, ...]],
# and oold indexes it by iri without checking for the key,
# so an unresolved iri has to be present and None
nodes[iri] = entity
return ResolveResult(nodes=nodes)

Expand Down Expand Up @@ -1280,8 +1291,8 @@ def __init__(self, **data):
class LoadEntityResult(BaseModel):
"""Result of load_entity()"""

entities: Union[model.OswBaseModel, List[model.OswBaseModel]]
"""The dataclass instance(s)"""
entities: List[model.OswBaseModel]
"""The list of dataclass instances"""

# fmt: off
@overload
Expand Down
2 changes: 0 additions & 2 deletions src/osw/service/ops/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ def export_entity_jsonld(
OSW.LoadEntityParam(titles=[title], autofetch_schema=True)
)
entities = result.entities
if not isinstance(entities, list):
entities = [entities]
if not entities:
raise errors.NotFound(f"Entity '{title}' not found.")
export = ctx.osw.export_jsonld(
Expand Down
104 changes: 104 additions & 0 deletions tests/test_load_entity_result_shape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Unit tests for load_entity()'s result shape and resolve()'s iri pairing.

Regression guard for #198: https://github.com/OpenSemanticLab/osw-python/issues/198
LoadEntityResult.entities was declared as Union[OswBaseModel, List[OswBaseModel]],
so pydantic v1 tried the bare OswBaseModel variant first and an empty list
validated as OswBaseModel() instead of staying an empty list. Separately,
OswDefaultBackend.resolve() zipped request.iris against load_entity()'s result
positionally, so a page load_entity() skipped shifted every later iri onto the
wrong entity.

These run fully offline: no site touches the network.
"""

from unittest.mock import MagicMock

from oold.backend.interface import ResolveParam

import osw.core
import osw.model.entity as model
from osw.core import OSW
from osw.wtsite import WtSite


def _make_entity(namespace: str, title: str) -> model.Item:
entity = model.Item(label=[model.Label(text=title)])
entity.meta = model.Meta(wiki_page=model.WikiPage(namespace=namespace, title=title))
return entity


def _build_backend(monkeypatch):
"""Constructs an OSW instance and returns its OswDefaultBackend.

set_resolver/set_backend are replaced so construction does not touch the
real global oold registry; set_backend is used to capture the backend
instance it would otherwise register.
"""
captured = {}

def _capture_set_backend(param):
captured["backend"] = param.backend

monkeypatch.setattr(osw.core, "set_resolver", lambda param: None)
monkeypatch.setattr(osw.core, "set_backend", _capture_set_backend)

site = MagicMock(spec=WtSite)
OSW(site=site)

return captured["backend"]


def test_load_entity_result_keeps_empty_list_as_list():
result = OSW.LoadEntityResult(entities=[])

assert result.entities == []
assert bool(result.entities) is False
assert len(result.entities) == 0


def test_load_entity_result_keeps_non_empty_list_and_subclass():
item = model.Item(label=[model.Label(text="x")])

result = OSW.LoadEntityResult(entities=[item])

assert isinstance(result.entities, list)
assert len(result.entities) == 1
assert isinstance(result.entities[0], model.Item)


def test_resolve_pairs_iris_by_title_not_position(monkeypatch):
backend = _build_backend(monkeypatch)
good = _make_entity("Item", "OSWAlignGood")

def _stub_load_entity(self, param):
# "Item:OSWAlignBad" could not be built and load_entity() silently
# skips it, so the returned list is shorter than request.iris.
return OSW.LoadEntityResult(entities=[good])

monkeypatch.setattr(OSW, "load_entity", _stub_load_entity)

result = backend.resolve(
ResolveParam(iris=["Item:OSWAlignBad", "Item:OSWAlignGood"])
)

# ResolveResult validates its values, which copies the entity, so compare
# by equality rather than by identity
assert result.nodes["Item:OSWAlignGood"] == good
# oold indexes nodes by iri without checking for the key, and types the
# values as Union[None, ...], so an unresolved iri maps to None
assert result.nodes["Item:OSWAlignBad"] is None


def test_resolve_returns_all_entities_when_every_iri_resolves(monkeypatch):
backend = _build_backend(monkeypatch)
first = _make_entity("Item", "OSWFirst")
second = _make_entity("Item", "OSWSecond")

def _stub_load_entity(self, param):
return OSW.LoadEntityResult(entities=[first, second])

monkeypatch.setattr(OSW, "load_entity", _stub_load_entity)

result = backend.resolve(ResolveParam(iris=["Item:OSWFirst", "Item:OSWSecond"]))

assert result.nodes == {"Item:OSWFirst": first, "Item:OSWSecond": second}
Loading