Skip to content

Commit 65cd85e

Browse files
Merge pull request #477 from linkml/schemaview_minor_file_reorg
schemaview.py: minor reorg and test reorganisation
2 parents add14ac + f346240 commit 65cd85e

File tree

2 files changed

+165
-165
lines changed

2 files changed

+165
-165
lines changed

linkml_runtime/utils/schemaview.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,17 @@ def type_ancestors(
11521152
**kwargs,
11531153
)
11541154

1155+
@lru_cache(None)
1156+
def type_roots(self, imports: bool = True) -> list[TypeDefinitionName]:
1157+
"""Return all types that have no parents.
1158+
1159+
:param imports: whether or not to include imports, defaults to True
1160+
:type imports: bool, optional
1161+
:return: list of all root types
1162+
:rtype: list[TypeDefinitionName]
1163+
"""
1164+
return [t for t in self.all_types(imports=imports) if not self.type_parents(t, imports=imports)]
1165+
11551166
@lru_cache(None)
11561167
def enum_parents(
11571168
self, enum_name: ENUM_NAME, imports: bool = False, mixins: bool = False, is_a: bool = True
@@ -1279,17 +1290,6 @@ def permissible_value_descendants(
12791290
**kwargs,
12801291
)
12811292

1282-
@lru_cache(None)
1283-
def type_roots(self, imports: bool = True) -> list[TypeDefinitionName]:
1284-
"""Return all types that have no parents.
1285-
1286-
:param imports: whether or not to include imports, defaults to True
1287-
:type imports: bool, optional
1288-
:return: list of all root types
1289-
:rtype: list[TypeDefinitionName]
1290-
"""
1291-
return [t for t in self.all_types(imports=imports) if not self.type_parents(t, imports=imports)]
1292-
12931293
@lru_cache(None)
12941294
def is_multivalued(self, slot_name: SlotDefinition) -> bool:
12951295
"""Return True if slot is multivalued, else returns False.

tests/test_utils/test_schemaview.py

Lines changed: 154 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,160 @@ def test_class_parents_ancestors(creature_view: SchemaView) -> None:
12711271
}
12721272

12731273

1274+
"""
1275+
Tests of the detect_cycles function, which can identify cyclic relationships between classes, types, and other schema elements.
1276+
"""
1277+
1278+
1279+
@pytest.mark.parametrize("dodgy_input", [None, [], set(), {}, 12345, 123.45, "some string", ()])
1280+
def test_detect_cycles_input_error(dodgy_input: Any) -> None:
1281+
"""Ensure that `detect_cycles` throws an error if input is not supplied in the appropriate form."""
1282+
with pytest.raises(ValueError, match="detect_cycles requires a list of values to process"):
1283+
detect_cycles(lambda x: x, dodgy_input)
1284+
1285+
1286+
@pytest.fixture(scope="module")
1287+
def sv_cycles_schema() -> SchemaView:
1288+
"""A schema containing cycles!"""
1289+
return SchemaView(INPUT_DIR_PATH / "cycles.yaml")
1290+
1291+
1292+
# metadata for elements in the `sv_cycles_schema`
1293+
CYCLES = {
1294+
TYPES: {
1295+
# types in cycles, either directly or via ancestors
1296+
# key: type, value: node where the cycle starts
1297+
0: {
1298+
"curve_type": "circular_type",
1299+
"semi_circular_type": "circular_type",
1300+
"circular_type": "circular_type",
1301+
"type_circular": "type_circular",
1302+
"circle": "circle",
1303+
"circle_of_life": "circle",
1304+
},
1305+
# types not involved in cycles
1306+
# key: the type, value: ancestors of the type
1307+
1: {
1308+
"supreme_string": {"supreme_string", "super_string", "string"},
1309+
"super_string": {"super_string", "string"},
1310+
"string": {"string"},
1311+
"integer": {"integer"},
1312+
"boolean": {"boolean"},
1313+
},
1314+
},
1315+
CLASSES: {
1316+
# classes involved in cycles
1317+
# key: class name, value: node where the cycle starts
1318+
0: {
1319+
"ClassA": "ClassA",
1320+
"ClassB": "ClassB",
1321+
"ClassC": "ClassC",
1322+
"ClassD": "ClassA",
1323+
"ClassE": "ClassA",
1324+
"ClassF": "ClassF",
1325+
"ClassG": "ClassF",
1326+
"Mixin1": "Mixin1",
1327+
"Mixin2": "Mixin2",
1328+
"MixedClass": "Mixin2",
1329+
},
1330+
# class ancestors for classes not in cycles
1331+
# key: class name, value: class ancestors
1332+
1: {
1333+
"BaseClass": {"BaseClass"},
1334+
"MixinA": {"MixinA"}, # no ID slot
1335+
"MixinB": {"MixinB"}, # no ID slot
1336+
"NonCycleClassA": {"NonCycleClassA", "BaseClass"},
1337+
"NonCycleClassB": {"MixinA", "NonCycleClassB", "NonCycleClassA", "BaseClass"},
1338+
"NonCycleClassC": {"MixinB", "NonCycleClassC", "NonCycleClassA", "BaseClass"},
1339+
"IdentifierCycleClassA": {"IdentifierCycleClassA"},
1340+
"IdentifierCycleClassB": {"IdentifierCycleClassB"},
1341+
"IdentifierCycleClassC": {"IdentifierCycleClassC"},
1342+
"IdentifierCycleClassD": {"IdentifierCycleClassD"},
1343+
},
1344+
},
1345+
}
1346+
1347+
1348+
@pytest.mark.parametrize(("target", "cycle_start_node"), list(CYCLES[TYPES][0].items()))
1349+
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "type_ancestors"])
1350+
def test_detect_type_cycles_error(sv_cycles_schema: SchemaView, target: str, cycle_start_node: str, fn: str) -> None:
1351+
"""Test detection of cycles in the types segment of the cycles schema."""
1352+
if fn == "detect_cycles":
1353+
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
1354+
detect_cycles(sv_cycles_schema.type_parents, [target])
1355+
elif fn == "graph_closure":
1356+
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
1357+
graph_closure(sv_cycles_schema.type_parents, target, detect_cycles=True)
1358+
else:
1359+
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
1360+
sv_cycles_schema.type_ancestors(type_name=target, detect_cycles=True)
1361+
1362+
1363+
@pytest.mark.parametrize(("target", "expected"), list(CYCLES[TYPES][1].items()))
1364+
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "type_ancestors"])
1365+
def test_detect_type_cycles_no_cycles(sv_cycles_schema: SchemaView, target: str, expected: set[str], fn: str) -> None:
1366+
"""Ensure that types without cycles in their ancestry do not throw an error."""
1367+
if fn == "detect_cycles":
1368+
detect_cycles(sv_cycles_schema.type_parents, [target])
1369+
elif fn == "graph_closure":
1370+
got = graph_closure(sv_cycles_schema.type_parents, target, detect_cycles=True)
1371+
assert set(got) == expected
1372+
else:
1373+
got = sv_cycles_schema.type_ancestors(target, detect_cycles=True)
1374+
assert set(got) == expected
1375+
1376+
1377+
@pytest.mark.parametrize(("target", "cycle_start_node"), list(CYCLES[CLASSES][0].items()))
1378+
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "class_ancestors"])
1379+
def test_detect_class_cycles_error(sv_cycles_schema: SchemaView, target: str, cycle_start_node: str, fn: str) -> None:
1380+
"""Test detection of class cycles in the cycles schema."""
1381+
if fn == "detect_cycles":
1382+
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
1383+
detect_cycles(sv_cycles_schema.class_parents, [target])
1384+
1385+
elif fn == "graph_closure":
1386+
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
1387+
graph_closure(sv_cycles_schema.class_parents, target, detect_cycles=True)
1388+
else:
1389+
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
1390+
sv_cycles_schema.class_ancestors(target, detect_cycles=True)
1391+
1392+
1393+
@pytest.mark.parametrize(("target", "expected"), list(CYCLES[CLASSES][1].items()))
1394+
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "class_ancestors"])
1395+
def test_detect_class_cycles_no_cycles(sv_cycles_schema: SchemaView, target: str, expected: set[str], fn: str) -> None:
1396+
"""Ensure that classes without cycles in their ancestry do not throw an error."""
1397+
if fn == "detect_cycles":
1398+
detect_cycles(sv_cycles_schema.class_parents, [target])
1399+
elif fn == "graph_closure":
1400+
got = graph_closure(sv_cycles_schema.class_parents, target, detect_cycles=True)
1401+
assert set(got) == expected
1402+
else:
1403+
got = sv_cycles_schema.class_ancestors(target, detect_cycles=True)
1404+
assert set(got) == expected
1405+
1406+
1407+
@pytest.mark.parametrize("target", CYCLES[CLASSES][1].keys())
1408+
def test_detect_class_as_range_cycles(sv_cycles_schema: SchemaView, target: str) -> None:
1409+
"""Test cycle detection in cases where a class is used as a range."""
1410+
1411+
def check_recursive_id_slots(class_name: str) -> list[str]:
1412+
"""Given a class, retrieve any classes used as the range for the class identifier slot."""
1413+
id_slot = sv_cycles_schema.get_identifier_slot(class_name, use_key=True)
1414+
if not id_slot:
1415+
return []
1416+
ind_range = sv_cycles_schema.slot_range_as_union(id_slot)
1417+
return [sv_cycles_schema.get_class(x).name for x in ind_range if sv_cycles_schema.get_class(x)] or []
1418+
1419+
# classes with a cycle in the class identifier slot range are cunningly named
1420+
if "IdentifierCycle" in target:
1421+
with pytest.raises(ValueError, match="Cycle detected at node "):
1422+
detect_cycles(check_recursive_id_slots, [target])
1423+
1424+
else:
1425+
detect_cycles(check_recursive_id_slots, [target])
1426+
1427+
12741428
ORDERING_TESTS = {
12751429
# Bassoon and Abacus are unranked, so appear at the end of the list.
12761430
"rank": ["wind instrument", "instrument", "Didgeridoo", "counting instrument", "Clarinet", "Bassoon", "Abacus"],
@@ -2981,160 +3135,6 @@ def test_class_name_mappings() -> None:
29813135
assert {snm_def.name: snm for snm, snm_def in view.slot_name_mappings().items()} == slot_names
29823136

29833137

2984-
"""
2985-
Tests of the detect_cycles function, which can identify cyclic relationships between classes, types, and other schema elements.
2986-
"""
2987-
2988-
2989-
@pytest.mark.parametrize("dodgy_input", [None, [], set(), {}, 12345, 123.45, "some string", ()])
2990-
def test_detect_cycles_input_error(dodgy_input: Any) -> None:
2991-
"""Ensure that `detect_cycles` throws an error if input is not supplied in the appropriate form."""
2992-
with pytest.raises(ValueError, match="detect_cycles requires a list of values to process"):
2993-
detect_cycles(lambda x: x, dodgy_input)
2994-
2995-
2996-
@pytest.fixture(scope="module")
2997-
def sv_cycles_schema() -> SchemaView:
2998-
"""A schema containing cycles!"""
2999-
return SchemaView(INPUT_DIR_PATH / "cycles.yaml")
3000-
3001-
3002-
# metadata for elements in the `sv_cycles_schema`
3003-
CYCLES = {
3004-
TYPES: {
3005-
# types in cycles, either directly or via ancestors
3006-
# key: type, value: node where the cycle starts
3007-
0: {
3008-
"curve_type": "circular_type",
3009-
"semi_circular_type": "circular_type",
3010-
"circular_type": "circular_type",
3011-
"type_circular": "type_circular",
3012-
"circle": "circle",
3013-
"circle_of_life": "circle",
3014-
},
3015-
# types not involved in cycles
3016-
# key: the type, value: ancestors of the type
3017-
1: {
3018-
"supreme_string": {"supreme_string", "super_string", "string"},
3019-
"super_string": {"super_string", "string"},
3020-
"string": {"string"},
3021-
"integer": {"integer"},
3022-
"boolean": {"boolean"},
3023-
},
3024-
},
3025-
CLASSES: {
3026-
# classes involved in cycles
3027-
# key: class name, value: node where the cycle starts
3028-
0: {
3029-
"ClassA": "ClassA",
3030-
"ClassB": "ClassB",
3031-
"ClassC": "ClassC",
3032-
"ClassD": "ClassA",
3033-
"ClassE": "ClassA",
3034-
"ClassF": "ClassF",
3035-
"ClassG": "ClassF",
3036-
"Mixin1": "Mixin1",
3037-
"Mixin2": "Mixin2",
3038-
"MixedClass": "Mixin2",
3039-
},
3040-
# class ancestors for classes not in cycles
3041-
# key: class name, value: class ancestors
3042-
1: {
3043-
"BaseClass": {"BaseClass"},
3044-
"MixinA": {"MixinA"}, # no ID slot
3045-
"MixinB": {"MixinB"}, # no ID slot
3046-
"NonCycleClassA": {"NonCycleClassA", "BaseClass"},
3047-
"NonCycleClassB": {"MixinA", "NonCycleClassB", "NonCycleClassA", "BaseClass"},
3048-
"NonCycleClassC": {"MixinB", "NonCycleClassC", "NonCycleClassA", "BaseClass"},
3049-
"IdentifierCycleClassA": {"IdentifierCycleClassA"},
3050-
"IdentifierCycleClassB": {"IdentifierCycleClassB"},
3051-
"IdentifierCycleClassC": {"IdentifierCycleClassC"},
3052-
"IdentifierCycleClassD": {"IdentifierCycleClassD"},
3053-
},
3054-
},
3055-
}
3056-
3057-
3058-
@pytest.mark.parametrize(("target", "cycle_start_node"), list(CYCLES[TYPES][0].items()))
3059-
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "type_ancestors"])
3060-
def test_detect_type_cycles_error(sv_cycles_schema: SchemaView, target: str, cycle_start_node: str, fn: str) -> None:
3061-
"""Test detection of cycles in the types segment of the cycles schema."""
3062-
if fn == "detect_cycles":
3063-
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
3064-
detect_cycles(sv_cycles_schema.type_parents, [target])
3065-
elif fn == "graph_closure":
3066-
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
3067-
graph_closure(sv_cycles_schema.type_parents, target, detect_cycles=True)
3068-
else:
3069-
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
3070-
sv_cycles_schema.type_ancestors(type_name=target, detect_cycles=True)
3071-
3072-
3073-
@pytest.mark.parametrize(("target", "expected"), list(CYCLES[TYPES][1].items()))
3074-
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "type_ancestors"])
3075-
def test_detect_type_cycles_no_cycles(sv_cycles_schema: SchemaView, target: str, expected: set[str], fn: str) -> None:
3076-
"""Ensure that types without cycles in their ancestry do not throw an error."""
3077-
if fn == "detect_cycles":
3078-
detect_cycles(sv_cycles_schema.type_parents, [target])
3079-
elif fn == "graph_closure":
3080-
got = graph_closure(sv_cycles_schema.type_parents, target, detect_cycles=True)
3081-
assert set(got) == expected
3082-
else:
3083-
got = sv_cycles_schema.type_ancestors(target, detect_cycles=True)
3084-
assert set(got) == expected
3085-
3086-
3087-
@pytest.mark.parametrize(("target", "cycle_start_node"), list(CYCLES[CLASSES][0].items()))
3088-
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "class_ancestors"])
3089-
def test_detect_class_cycles_error(sv_cycles_schema: SchemaView, target: str, cycle_start_node: str, fn: str) -> None:
3090-
"""Test detection of class cycles in the cycles schema."""
3091-
if fn == "detect_cycles":
3092-
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
3093-
detect_cycles(sv_cycles_schema.class_parents, [target])
3094-
3095-
elif fn == "graph_closure":
3096-
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
3097-
graph_closure(sv_cycles_schema.class_parents, target, detect_cycles=True)
3098-
else:
3099-
with pytest.raises(ValueError, match=f"Cycle detected at node '{cycle_start_node}'"):
3100-
sv_cycles_schema.class_ancestors(target, detect_cycles=True)
3101-
3102-
3103-
@pytest.mark.parametrize(("target", "expected"), list(CYCLES[CLASSES][1].items()))
3104-
@pytest.mark.parametrize("fn", ["detect_cycles", "graph_closure", "class_ancestors"])
3105-
def test_detect_class_cycles_no_cycles(sv_cycles_schema: SchemaView, target: str, expected: set[str], fn: str) -> None:
3106-
"""Ensure that classes without cycles in their ancestry do not throw an error."""
3107-
if fn == "detect_cycles":
3108-
detect_cycles(sv_cycles_schema.class_parents, [target])
3109-
elif fn == "graph_closure":
3110-
got = graph_closure(sv_cycles_schema.class_parents, target, detect_cycles=True)
3111-
assert set(got) == expected
3112-
else:
3113-
got = sv_cycles_schema.class_ancestors(target, detect_cycles=True)
3114-
assert set(got) == expected
3115-
3116-
3117-
@pytest.mark.parametrize("target", CYCLES[CLASSES][1].keys())
3118-
def test_detect_class_as_range_cycles(sv_cycles_schema: SchemaView, target: str) -> None:
3119-
"""Test cycle detection in cases where a class is used as a range."""
3120-
3121-
def check_recursive_id_slots(class_name: str) -> list[str]:
3122-
"""Given a class, retrieve any classes used as the range for the class identifier slot."""
3123-
id_slot = sv_cycles_schema.get_identifier_slot(class_name, use_key=True)
3124-
if not id_slot:
3125-
return []
3126-
ind_range = sv_cycles_schema.slot_range_as_union(id_slot)
3127-
return [sv_cycles_schema.get_class(x).name for x in ind_range if sv_cycles_schema.get_class(x)] or []
3128-
3129-
# classes with a cycle in the class identifier slot range are cunningly named
3130-
if "IdentifierCycle" in target:
3131-
with pytest.raises(ValueError, match="Cycle detected at node "):
3132-
detect_cycles(check_recursive_id_slots, [target])
3133-
3134-
else:
3135-
detect_cycles(check_recursive_id_slots, [target])
3136-
3137-
31383138
@pytest.mark.parametrize(
31393139
("entity_type", "entity_name", "type_for_methods", "get_all_method"),
31403140
[

0 commit comments

Comments
 (0)