Skip to content

Commit d947cb0

Browse files
authored
Merge pull request #411 from linkml/schemaview_local_remote_import_tests
Fix the remote import issue in SchemaViewer and a couple of misc minor bugs
2 parents 0f72294 + d25c160 commit d947cb0

File tree

2 files changed

+201
-28
lines changed

2 files changed

+201
-28
lines changed

linkml_runtime/utils/schemaview.py

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ def is_absolute_path(path: str) -> bool:
141141
"""
142142
if path.startswith("/"):
143143
return True
144+
if "://" in path:
145+
return True
146+
144147
# windows
145148
if not os.path.isabs(path):
146149
return False
@@ -445,39 +448,20 @@ def ordered(self, elements: ElementDict, ordered_by: OrderedBy | None = None) ->
445448
def _order_lexically(self, elements: ElementDict) -> ElementDict:
446449
"""Order elements by name.
447450
451+
Note: sort is case sensitive, based on the name used in the original schema.
452+
448453
:param element: slots or class type to order
449454
:return: all classes or slots sorted lexically in schema view
450455
"""
451-
ordered_list_of_names = []
452-
ordered_elements = {}
453-
for c in elements:
454-
ordered_list_of_names.append(c)
455-
ordered_list_of_names.sort()
456-
for name in ordered_list_of_names:
457-
ordered_elements[self.get_element(name).name] = self.get_element(name)
458-
return ordered_elements
456+
return {name: elements[name] for name in sorted(elements.keys())}
459457

460458
def _order_rank(self, elements: ElementDict) -> ElementDict:
461459
"""Order elements by rank.
462460
463461
:param elements: slots or classes to order
464462
:return: all classes or slots sorted by their rank in schema view
465463
"""
466-
rank_map = {}
467-
unranked_map = {}
468-
rank_ordered_elements = {}
469-
for name, definition in elements.items():
470-
if definition.rank is None:
471-
unranked_map[self.get_element(name).name] = self.get_element(name)
472-
473-
else:
474-
rank_map[definition.rank] = name
475-
rank_ordered_map = collections.OrderedDict(sorted(rank_map.items()))
476-
for k, v in rank_ordered_map.items():
477-
rank_ordered_elements[self.get_element(v).name] = self.get_element(v)
478-
479-
rank_ordered_elements.update(unranked_map)
480-
return rank_ordered_elements
464+
return {el.name: el for el in sorted(elements.values(), key=lambda el: el.rank or float("inf"))}
481465

482466
def _order_inheritance(self, elements: DefDict) -> DefDict:
483467
"""Sort classes such that if C is a child of P then C appears after P in the list."""
@@ -697,7 +681,7 @@ def get_class(self, class_name: CLASS_NAME, imports: bool = True, strict: bool =
697681
"""
698682
c = self.all_classes(imports=imports).get(class_name, None)
699683
if strict and c is None:
700-
raise ValueError(f'No such class as "{class_name}"')
684+
raise ValueError(f'No such class: "{class_name}"')
701685
return c
702686

703687
@lru_cache(None)
@@ -723,7 +707,7 @@ def get_slot(
723707
slot.from_schema = c.from_schema
724708
slot.owner = c.name
725709
if strict and slot is None:
726-
raise ValueError(f'No such slot as "{slot_name}"')
710+
raise ValueError(f'No such slot: "{slot_name}"')
727711
return slot
728712

729713
@lru_cache(None)
@@ -736,7 +720,7 @@ def get_subset(self, subset_name: SUBSET_NAME, imports: bool = True, strict: boo
736720
"""
737721
s = self.all_subsets(imports).get(subset_name, None)
738722
if strict and s is None:
739-
raise ValueError(f'No such subset as "{subset_name}"')
723+
raise ValueError(f'No such subset: "{subset_name}"')
740724
return s
741725

742726
@lru_cache(None)
@@ -749,7 +733,7 @@ def get_enum(self, enum_name: ENUM_NAME, imports: bool = True, strict: bool = Fa
749733
"""
750734
e = self.all_enums(imports).get(enum_name, None)
751735
if strict and e is None:
752-
raise ValueError(f'No such subset as "{enum_name}"')
736+
raise ValueError(f'No such enum: "{enum_name}"')
753737
return e
754738

755739
@lru_cache(None)
@@ -762,7 +746,7 @@ def get_type(self, type_name: TYPE_NAME, imports: bool = True, strict: bool = Fa
762746
"""
763747
t = self.all_types(imports).get(type_name, None)
764748
if strict and t is None:
765-
raise ValueError(f'No such subset as "{type_name}"')
749+
raise ValueError(f'No such type: "{type_name}"')
766750
return t
767751

768752
def _parents(self, e: Element, imports: bool = True, mixins: bool = True, is_a: bool = True) -> list[ElementName]:

tests/test_utils/test_schemaview.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
SCHEMA_RELATIVE_IMPORT_TREE = Path(INPUT_DIR) / "imports_relative" / "L0_0" / "L1_0_0" / "main.yaml"
3939
SCHEMA_RELATIVE_IMPORT_TREE2 = Path(INPUT_DIR) / "imports_relative" / "L0_2" / "main.yaml"
4040

41+
CREATURE_SCHEMA = "creature_schema"
42+
CREATURE_SCHEMA_BASE_URL = "https://github.com/linkml/linkml-runtime/tests/test_utils/input/mcc"
43+
CREATURE_SCHEMA_BASE_PATH = Path(INPUT_DIR) / "mcc"
44+
4145
yaml_loader = YAMLLoader()
4246
IS_CURRENT = "is current"
4347
EMPLOYED_AT = "employed at"
@@ -81,6 +85,23 @@ def sv_attributes() -> SchemaView:
8185
return SchemaView(os.path.join(INPUT_DIR, "attribute_edge_cases.yaml"))
8286

8387

88+
@pytest.fixture(scope="session")
89+
def creature_view() -> SchemaView:
90+
return SchemaView(str(CREATURE_SCHEMA_BASE_PATH / "creature_schema.yaml"))
91+
92+
93+
@pytest.fixture(scope="session")
94+
def creature_view_remote() -> SchemaView:
95+
"""Fixture for a SchemaView for testing remote imports."""
96+
return SchemaView(str(CREATURE_SCHEMA_BASE_PATH / "creature_schema_remote.yaml"))
97+
98+
99+
@pytest.fixture(scope="session")
100+
def creature_view_local() -> SchemaView:
101+
"""Fixture for a SchemaView for testing local relative file path imports."""
102+
return SchemaView(str(CREATURE_SCHEMA_BASE_PATH / "creature_schema_local.yaml"))
103+
104+
84105
def make_schema(
85106
name: str,
86107
prefixes: list[Prefix] | None = None,
@@ -307,6 +328,35 @@ def sv_induced_slots() -> SchemaView:
307328
return SchemaView(schema_str)
308329

309330

331+
@pytest.fixture
332+
def sv_ordering_tests() -> SchemaView:
333+
"""SchemaView for testing class ordering."""
334+
schema = """
335+
id: https://example.com/ordering-tests
336+
name: ordering-tests
337+
classes:
338+
Clarinet:
339+
rank: 5
340+
is_a: wind instrument
341+
instrument:
342+
rank: 2
343+
Bassoon:
344+
is_a: wind instrument
345+
wind instrument:
346+
rank: 1
347+
is_a: instrument
348+
Abacus:
349+
is_a: counting instrument
350+
counting instrument:
351+
rank: 4
352+
is_a: instrument
353+
Didgeridoo:
354+
rank: 3
355+
is_a: wind instrument
356+
"""
357+
return SchemaView(schema)
358+
359+
310360
def test_imports(schema_view_with_imports: SchemaView) -> None:
311361
"""View should by default dynamically include imports chain."""
312362
view = schema_view_with_imports
@@ -697,6 +747,145 @@ def test_uris_without_default_prefix() -> None:
697747
assert view.get_uri("test_slot", imports=True) == "https://example.org/test#test_slot"
698748

699749

750+
CREATURE_EXPECTED = {
751+
"class": {
752+
CREATURE_SCHEMA: {"MythicalCreature", "HasMagic", "MagicalAbility", "Dragon", "Phoenix", "Unicorn"},
753+
"creature_basics": {"Entity", "Creature", "Location", "CreatureAttribute", "HasHabitat"},
754+
},
755+
"slot": {
756+
CREATURE_SCHEMA: {"creature_class", "magical_abilities", "level_of_magic"},
757+
"creature_basics": {"id", "name", "description", "species", "habitat"},
758+
},
759+
"type": {
760+
CREATURE_SCHEMA: {"MagicLevel"},
761+
"creature_types": {"string", "integer", "boolean"},
762+
},
763+
"enum": {CREATURE_SCHEMA: {"CreatureClass"}},
764+
"subset": {CREATURE_SCHEMA: set(), "creature_subsets": {"mythical_creature", "generic_creature"}},
765+
"element": {},
766+
}
767+
768+
# add in the elements
769+
for value in CREATURE_EXPECTED.values():
770+
for s_name, el in value.items():
771+
if s_name not in CREATURE_EXPECTED["element"]:
772+
CREATURE_EXPECTED["element"][s_name] = set()
773+
CREATURE_EXPECTED["element"][s_name].update(el)
774+
775+
776+
@pytest.mark.parametrize("schema", ["creature_view", "creature_view_remote", "creature_view_local"])
777+
@pytest.mark.parametrize("entity", CREATURE_EXPECTED.keys())
778+
def test_creature_schema_entities_with_without_imports(
779+
schema: str, entity: str, request: pytest.FixtureRequest
780+
) -> None:
781+
"""Test retrieval of entities from the creature schema.
782+
783+
Tests the following methods:
784+
- all_{entity}s (e.g. all_classes, all_slots, etc.) with and without imports
785+
- get_{entity} (e.g. get_class, get_slot, etc.)
786+
- the "from_schema" attribute of the retrieved entities
787+
788+
The schemas tested are:
789+
- creature_view: the main schema with all entities
790+
- creature_view_remote: imports the creature schema using a curie and a remote URL
791+
- creature_view_local: imports the creature schema using a local relative file path
792+
"""
793+
creature_view = request.getfixturevalue(schema)
794+
795+
# use the PLURAL mapping to get the correct method name to retrieve all entities of the given type
796+
get_all_fn = "all_" + PLURAL.get(entity, f"{entity}s")
797+
if schema == "creature_view":
798+
assert set(getattr(creature_view, get_all_fn)(imports=False)) == CREATURE_EXPECTED[entity][CREATURE_SCHEMA]
799+
else:
800+
assert set(getattr(creature_view, get_all_fn)(imports=False)) == set()
801+
802+
# merge the values from all the sources to get the complete set of entities
803+
all_entities = set().union(*CREATURE_EXPECTED[entity].values())
804+
assert set(getattr(creature_view, get_all_fn)(imports=True)) == all_entities
805+
806+
# run creature_view.get_{entity} for each entity and check the source
807+
for src in CREATURE_EXPECTED[entity]:
808+
for entity_name in CREATURE_EXPECTED[entity][src]:
809+
e = getattr(creature_view, f"get_{entity}")(entity_name, imports=True)
810+
assert e.from_schema == f"{CREATURE_SCHEMA_BASE_URL}/{src}"
811+
812+
813+
@pytest.mark.parametrize("entity", CREATURE_EXPECTED.keys())
814+
def test_get_entities_with_without_imports(creature_view: SchemaView, entity: str) -> None:
815+
"""Test retrieval of a specific entity from the creature schema."""
816+
get_fn = f"get_{entity}"
817+
818+
for src in CREATURE_EXPECTED[entity]:
819+
for entity_name in CREATURE_EXPECTED[entity][src]:
820+
if src == CREATURE_SCHEMA:
821+
# if the source is the main schema, we can use the method directly
822+
e = getattr(creature_view, get_fn)(entity_name, imports=False)
823+
assert e.name == entity_name
824+
# N.b. BUG: due to caching and how the `from_schema` element is generated,
825+
# we cannot know whether it will be populated.
826+
# assert e.from_schema is None
827+
else:
828+
# if the source is an imported schema, we expect None without imports
829+
assert getattr(creature_view, get_fn)(entity_name, imports=False) is None
830+
if entity != "element":
831+
# in strict mode, we expect an error if the entity does not exist
832+
with pytest.raises(ValueError, match=f'No such {entity}: "{entity_name}"'):
833+
getattr(creature_view, f"get_{entity}")(entity_name, imports=False, strict=True)
834+
835+
# turn on imports
836+
e = getattr(creature_view, f"get_{entity}")(entity_name, imports=True)
837+
assert e.from_schema == f"{CREATURE_SCHEMA_BASE_URL}/{src}"
838+
839+
840+
@pytest.mark.parametrize("entity", argvalues=[e for e in CREATURE_EXPECTED if e != "element"])
841+
def test_get_entity_does_not_exist(creature_view: SchemaView, entity: str) -> None:
842+
"""Test retrieval of a specific entity from the creature schema."""
843+
get_fn = f"get_{entity}"
844+
845+
# returns None unless the `strict` flag is passed
846+
assert getattr(creature_view, get_fn)("does_not_exist") is None
847+
848+
# raises an error with `strict` flag on
849+
with pytest.raises(ValueError, match=f'No such {entity}: "does_not_exist"'):
850+
getattr(creature_view, get_fn)("does_not_exist", strict=True)
851+
852+
853+
ORDERING_TESTS = {
854+
# Bassoon and Abacus are unranked, so appear at the end of the list.
855+
"rank": ["wind instrument", "instrument", "Didgeridoo", "counting instrument", "Clarinet", "Bassoon", "Abacus"],
856+
"preserve": [
857+
"Clarinet",
858+
"instrument",
859+
"Bassoon",
860+
"wind instrument",
861+
"Abacus",
862+
"counting instrument",
863+
"Didgeridoo",
864+
],
865+
# lexical ordering is case-sensitive, so all the capitalized words come first.
866+
"lexical": ["Abacus", "Bassoon", "Clarinet", "Didgeridoo", "counting instrument", "instrument", "wind instrument"],
867+
# TODO: this looks very dodgy
868+
"inheritance": [
869+
"instrument",
870+
"wind instrument",
871+
"Clarinet",
872+
"Bassoon",
873+
"counting instrument",
874+
"Abacus",
875+
"Didgeridoo",
876+
],
877+
}
878+
879+
880+
@pytest.mark.parametrize(
881+
("ordered_by"),
882+
ORDERING_TESTS.keys(),
883+
)
884+
def test_all_classes_ordered_by(sv_ordering_tests: SchemaView, ordered_by: str) -> None:
885+
"""Test the ordered_by method."""
886+
assert list(sv_ordering_tests.all_classes(ordered_by=ordered_by).keys()) == ORDERING_TESTS[ordered_by]
887+
888+
700889
def test_children_method(schema_view_no_imports: SchemaView) -> None:
701890
"""Test retrieval of the children of a class."""
702891
view = schema_view_no_imports

0 commit comments

Comments
 (0)