Skip to content

Commit da82eb6

Browse files
Merge branch 'main' into schemaview_namespaces
2 parents 7090046 + 3c42fc7 commit da82eb6

File tree

2 files changed

+91
-14
lines changed

2 files changed

+91
-14
lines changed

linkml_runtime/utils/schemaview.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,12 +1876,61 @@ def slot_range_as_union(self, slot: SlotDefinition) -> list[ElementName]:
18761876
:param slot:
18771877
:return: list of ranges
18781878
"""
1879-
r = slot.range
1880-
range_union_of = [r]
1881-
for x in slot.exactly_one_of + slot.any_of:
1882-
if x.range:
1883-
range_union_of.append(x.range)
1884-
return range_union_of
1879+
return list({y.range for y in [slot, *[x for x in [*slot.exactly_one_of, *slot.any_of] if x.range]]})
1880+
1881+
def induced_slot_range(self, slot: SlotDefinition, strict: bool = False) -> set[str | ElementName]: # noqa: FBT001, FBT002
1882+
"""Retrieve all applicable ranges for a slot, falling back to the default if necessary.
1883+
1884+
Performs several validation checks if `strict` is True:
1885+
- ensures that the slot has a range specified
1886+
- requires the slot range to be set to a class with CURIE `linkml:Any` if one of the boolean specifiers is used for the range
1887+
- ensures that only one of `any_of` and `exactly_one_of` is used if the range is `linkml:Any`
1888+
1889+
:param slot: the slot to be investigated
1890+
:type slot: SlotDefinition
1891+
:param strict: whether or not to throw errors if there are validation issues with the schema, defaults to False
1892+
:type strict: bool, optional
1893+
:return: set of ranges
1894+
:rtype: set[str | ElementName]
1895+
"""
1896+
1897+
slot_range = slot.range
1898+
any_of_range = {x.range for x in slot.any_of if x.range}
1899+
exactly_one_of_range = {x.range for x in slot.exactly_one_of if x.range}
1900+
1901+
is_any = False
1902+
if slot_range:
1903+
range_class = self.get_class(slot_range)
1904+
if range_class and range_class.class_uri == "linkml:Any":
1905+
is_any = True
1906+
1907+
if strict:
1908+
# no range specified and no schema default
1909+
if not (any_of_range or exactly_one_of_range or slot_range):
1910+
err_msg = f"{slot.owner} slot {slot.name} has no range specified"
1911+
raise ValueError(err_msg)
1912+
1913+
# ensure that only one of any_of and exactly_one_of is specified
1914+
if any_of_range and exactly_one_of_range:
1915+
err_msg = f"{slot.owner} slot {slot.name} has range specified in both `exactly_one_of` and `any_of`"
1916+
raise ValueError(err_msg)
1917+
1918+
# if any_of or exactly_one_of is set, the slot range should be linkml:Any
1919+
if (any_of_range or exactly_one_of_range) and not (slot_range and is_any):
1920+
err_msg = f"{slot.owner} slot {slot.name} has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any"
1921+
raise ValueError(err_msg)
1922+
1923+
# if the range is linkml:Any and one/both of these ranges is set, return them
1924+
if is_any and (any_of_range or exactly_one_of_range):
1925+
return {*any_of_range, *exactly_one_of_range}
1926+
1927+
# return the slot range (if set)
1928+
if slot_range:
1929+
return {slot_range}
1930+
1931+
# return empty set (not {None})
1932+
# note: this is only returned when strict mode is False
1933+
return set()
18851934

18861935
def get_classes_by_slot(self, slot: SlotDefinition, include_induced: bool = False) -> list[ClassDefinitionName]:
18871936
"""Get all classes that use a given slot, either as a direct or induced slot.

tests/test_utils/test_schemaview.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,13 @@ def sv_range_riid_gen(request: pytest.FixtureRequest) -> tuple[SchemaView, tuple
18261826
for key, value in ranges_replaced_by_defaults["none_range"].items()
18271827
}
18281828

1829+
induced_range_strict_errors = {
1830+
"any_of_and_exactly_one_of_range": "ClassWithRanges slot any_of_and_exactly_one_of_range has range specified in both `exactly_one_of` and `any_of`",
1831+
"invalid_any_range_no_linkml_any": "ClassWithRanges slot invalid_any_range_no_linkml_any has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any",
1832+
"invalid_any_range_enum": "ClassWithRanges slot invalid_any_range_enum has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any",
1833+
"invalid_any_range_class": "ClassWithRanges slot invalid_any_range_class has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any",
1834+
}
1835+
18291836

18301837
def test_generated_range_schema(sv_range_riid_gen: tuple[SchemaView, tuple[str, str | None, str | None]]) -> None:
18311838
"""Tests for generation of range schemas.
@@ -1842,8 +1849,17 @@ def test_generated_range_schema(sv_range_riid_gen: tuple[SchemaView, tuple[str,
18421849
assert isinstance(sv_range, SchemaView)
18431850

18441851

1845-
@pytest.mark.parametrize("range_function", ["slot_range", "slot_range_as_union", "slot_applicable_range_elements"])
18461852
@pytest.mark.parametrize("slot_name", ranges_no_defaults.keys())
1853+
@pytest.mark.parametrize(
1854+
"range_function",
1855+
[
1856+
"slot_range",
1857+
"slot_range_as_union",
1858+
"induced_slot_range",
1859+
"induced_range_strict",
1860+
"slot_applicable_range_elements",
1861+
],
1862+
)
18471863
def test_slot_range(
18481864
range_function: str,
18491865
slot_name: str,
@@ -1859,25 +1875,37 @@ def test_slot_range(
18591875
:type sv_range_riid_gen: tuple[SchemaView, tuple[str, str | None, str | None]]
18601876
"""
18611877
(sv_range, range_tuple) = sv_range_riid_gen
1862-
1863-
slots_by_name = {s.name: s for s in sv_range.class_induced_slots("ClassWithRanges")}
1878+
slot_object = sv_range.induced_slot(slot_name, "ClassWithRanges")
18641879
expected = ranges_no_defaults[slot_name]
1880+
18651881
if slot_name in ranges_replaced_by_defaults:
18661882
expected = ranges_replaced_by_defaults[slot_name][range_tuple]
1883+
18671884
if range_function == "slot_range":
1868-
assert slots_by_name[slot_name].range == expected[0]
1885+
assert slot_object.range == expected[0]
18691886
elif range_function == "slot_range_as_union":
1870-
assert set(sv_range.slot_range_as_union(slots_by_name[slot_name])) == expected[1]
1887+
assert set(sv_range.slot_range_as_union(slot_object)) == expected[1]
18711888
elif range_function == "induced_slot_range":
1872-
assert sv_range.induced_slot_range(slots_by_name[slot_name]) == expected[2]
1889+
assert sv_range.induced_slot_range(slot_object) == expected[2]
1890+
elif range_function == "induced_range_strict":
1891+
# err_msg will be None if there is no error in the slot range specification
1892+
err_msg = induced_range_strict_errors.get(slot_name)
1893+
if not err_msg and expected[2] == set():
1894+
err_msg = f"ClassWithRanges slot {slot_name} has no range specified"
1895+
1896+
if err_msg:
1897+
with pytest.raises(ValueError, match=err_msg):
1898+
sv_range.induced_slot_range(slot_object, strict=True)
1899+
else:
1900+
assert sv_range.induced_slot_range(slot_object, strict=True) == expected[2]
18731901
elif range_function == "slot_applicable_range_elements":
18741902
if slot_name in ranges_replaced_by_defaults and len(expected) < 4:
18751903
expected = ranges_no_defaults[slot_name]
18761904
if isinstance(expected[3], set):
1877-
assert set(sv_range.slot_applicable_range_elements(slots_by_name[slot_name])) == expected[3]
1905+
assert set(sv_range.slot_applicable_range_elements(slot_object)) == expected[3]
18781906
else:
18791907
with pytest.raises(expected[3], match="Unrecognized range: None"):
1880-
sv_range.slot_applicable_range_elements(slots_by_name[slot_name])
1908+
sv_range.slot_applicable_range_elements(slot_object)
18811909
else:
18821910
pytest.fail(f"Unexpected range_function value: {range_function}")
18831911

0 commit comments

Comments
 (0)