Skip to content

Commit 57fddd7

Browse files
Merge branch 'main' into convert-test-reference-validator-to-pytest
2 parents b898da9 + a1381ca commit 57fddd7

File tree

4 files changed

+1024
-331
lines changed

4 files changed

+1024
-331
lines changed

linkml_runtime/utils/schemaview.py

Lines changed: 101 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import uuid
99
import warnings
1010
from collections import defaultdict, deque
11+
from collections.abc import Iterable
1112
from copy import copy, deepcopy
1213
from dataclasses import dataclass
1314
from enum import Enum
@@ -44,7 +45,7 @@
4445
from linkml_runtime.utils.pattern import PatternResolver
4546

4647
if TYPE_CHECKING:
47-
from collections.abc import Callable, Iterable, Mapping
48+
from collections.abc import Callable, Mapping
4849
from types import NotImplementedType
4950

5051
from linkml_runtime.utils.metamodelcore import URI, URIorCURIE
@@ -96,8 +97,13 @@ class OrderedBy(Enum):
9697
BLACK = 2
9798

9899

99-
def detect_cycles(f: Callable[[Any], Iterable[Any] | None], x: Any) -> None:
100-
"""Detect cycles in a graph, using function `f` to walk the graph, starting at node `x`.
100+
def detect_cycles(
101+
f: Callable[[Any], Iterable[Any] | None],
102+
node_list: Iterable[Any],
103+
) -> None:
104+
"""Detect cycles in a graph, using function `f` to walk the graph.
105+
106+
Input is supplied as a list of nodes that are used to populate the `todo` stack.
101107
102108
Uses the classic white/grey/black colour coding algorithm to track which nodes have been explored. In this
103109
case, "node" refers to any element in a schema and "neighbours" are elements that can be reached from that
@@ -107,21 +113,28 @@ def detect_cycles(f: Callable[[Any], Iterable[Any] | None], x: Any) -> None:
107113
GREY: node is being processed; processing includes exploring all neighbours reachable via f(node)
108114
BLACK: node and all of its neighbours (and their neighbours, etc.) have been processed
109115
110-
A directed cycle reachable from node `x` raises a ValueError.
116+
A directed cycle reachable from a node or its neighbours raises a ValueError.
111117
112118
:param f: function that returns an iterable of neighbouring nodes (parents or children)
113119
:type f: Callable[[Any], Iterable[Any] | None]
114-
:param x: graph node
115-
:type x: Any
116-
:raises ValueError: if a cycle is discovered through repeated calls to f(x)
120+
:param node_list: list or other iterable of values to process
121+
:type node_list: Iterable[Any]
122+
:raises ValueError: if a cycle is discovered through repeated calls to f(node)
117123
"""
124+
# ensure we have some nodes to start the analysis
125+
if not node_list or not isinstance(node_list, Iterable) or isinstance(node_list, str):
126+
err_msg = "detect_cycles requires a list of values to process"
127+
raise ValueError(err_msg)
128+
118129
# keep track of the processing state of nodes in the graph
119130
processing_state: dict[Any, int] = {}
120131

121132
# Stack entries are (node, processed_flag).
122133
# processed_flag == True means all neighbours (nodes generated by running `f(node)`)
123134
# have been added to the todo stack and the node can be marked BLACK.
124-
todo: list[tuple[Any, bool]] = [(x, False)]
135+
136+
# initialise the todo stack with entries set to False
137+
todo: list[tuple[Any, bool]] = [(node, False) for node in node_list]
125138

126139
while todo:
127140
node, processed_flag = todo.pop()
@@ -173,7 +186,7 @@ def _closure(
173186
:rtype: list[str | ElementName | ClassDefinitionName | EnumDefinitionName | SlotDefinitionName | TypeDefinitionName]
174187
"""
175188
if kwargs and kwargs.get("detect_cycles"):
176-
detect_cycles(f, x)
189+
detect_cycles(f, [x])
177190

178191
rv = [x] if reflexive else []
179192
visited = []
@@ -452,8 +465,7 @@ def imports_closure(
452465
# visit item
453466
sn = todo.pop()
454467
if sn not in self.schema_map:
455-
imported_schema = self.load_import(sn)
456-
self.schema_map[sn] = imported_schema
468+
self.schema_map[sn] = self.load_import(sn)
457469

458470
# resolve item's imports if it has not been visited already
459471
# we will get duplicates, but not cycles this way, and
@@ -529,13 +541,15 @@ def all_schema(self, imports: bool = True) -> list[SchemaDefinition]:
529541
def namespaces(self) -> Namespaces:
530542
"""Return the namespaces present in a schema.
531543
544+
Note: the output of this function will differ, depending on whether any functions that process imports have been run.
545+
532546
:return: namespaces
533547
:rtype: Namespaces
534548
"""
535549
namespaces = Namespaces()
550+
for cmap in self.schema.default_curi_maps:
551+
namespaces.add_prefixmap(cmap, include_defaults=False)
536552
for s in self.schema_map.values():
537-
for cmap in self.schema.default_curi_maps:
538-
namespaces.add_prefixmap(cmap, include_defaults=False)
539553
for prefix in s.prefixes.values():
540554
namespaces[prefix.prefix_prefix] = prefix.prefix_reference
541555
return namespaces
@@ -884,7 +898,9 @@ def _parents(self, e: Element, imports: bool = True, mixins: bool = True, is_a:
884898
def class_parents(
885899
self, class_name: CLASS_NAME, imports: bool = True, mixins: bool = True, is_a: bool = True
886900
) -> list[ClassDefinitionName]:
887-
""":param class_name: child class name
901+
"""Get the parents of a class.
902+
903+
:param class_name: child class name
888904
:param imports: include import closure
889905
:param mixins: include mixins (default is True)
890906
:return: all direct parent class names (is_a and mixins)
@@ -1137,11 +1153,24 @@ def type_ancestors(
11371153
**kwargs,
11381154
)
11391155

1156+
@lru_cache(None)
1157+
def type_roots(self, imports: bool = True) -> list[TypeDefinitionName]:
1158+
"""Return all types that have no parents.
1159+
1160+
:param imports: whether or not to include imports, defaults to True
1161+
:type imports: bool, optional
1162+
:return: list of all root types
1163+
:rtype: list[TypeDefinitionName]
1164+
"""
1165+
return [t for t in self.all_types(imports=imports) if not self.type_parents(t, imports=imports)]
1166+
11401167
@lru_cache(None)
11411168
def enum_parents(
11421169
self, enum_name: ENUM_NAME, imports: bool = False, mixins: bool = False, is_a: bool = True
11431170
) -> list[EnumDefinitionName]:
1144-
""":param enum_name: child enum name
1171+
"""Get the parents of an enum.
1172+
1173+
:param enum_name: child enum name
11451174
:param imports: include import closure (False)
11461175
:param mixins: include mixins (default is False)
11471176
:return: all direct parent enum names (is_a and mixins)
@@ -1178,12 +1207,20 @@ def enum_ancestors(
11781207
**kwargs,
11791208
)
11801209

1181-
@lru_cache(None)
1210+
@deprecated("Use `permissible_value_parents` instead")
11821211
def permissible_value_parent(
11831212
self, permissible_value: str, enum_name: ENUM_NAME
11841213
) -> list[str | PermissibleValueText]:
1185-
""":param enum_name: child enum name
1214+
return self.permissible_value_parents(permissible_value, enum_name)
1215+
1216+
@lru_cache(None)
1217+
def permissible_value_parents(
1218+
self, permissible_value: str, enum_name: ENUM_NAME
1219+
) -> list[str | PermissibleValueText]:
1220+
"""Get the parents of a permissible value.
1221+
11861222
:param permissible_value: permissible value
1223+
:param enum_name: enum for which this is a permissible value
11871224
:return: all direct parent enum names (is_a)
11881225
"""
11891226
enum = self.get_enum(enum_name, strict=True)
@@ -1197,8 +1234,10 @@ def permissible_value_parent(
11971234
def permissible_value_children(
11981235
self, permissible_value: str, enum_name: ENUM_NAME
11991236
) -> list[str | PermissibleValueText]:
1200-
""":param enum_name: parent enum name
1237+
"""Get the children of a permissible value.
1238+
12011239
:param permissible_value: permissible value
1240+
:param enum_name: enum for which this is a permissible value
12021241
:return: all direct child permissible values (is_a)
12031242
"""
12041243
enum = self.get_enum(enum_name, strict=True)
@@ -1236,7 +1275,7 @@ def permissible_value_ancestors(
12361275
:rtype: list[str]
12371276
"""
12381277
return _closure(
1239-
lambda x: self.permissible_value_parent(x, enum_name),
1278+
lambda x: self.permissible_value_parents(x, enum_name),
12401279
permissible_value_text,
12411280
reflexive=reflexive,
12421281
depth_first=depth_first,
@@ -1264,17 +1303,6 @@ def permissible_value_descendants(
12641303
**kwargs,
12651304
)
12661305

1267-
@lru_cache(None)
1268-
def type_roots(self, imports: bool = True) -> list[TypeDefinitionName]:
1269-
"""Return all types that have no parents.
1270-
1271-
:param imports: whether or not to include imports, defaults to True
1272-
:type imports: bool, optional
1273-
:return: list of all root types
1274-
:rtype: list[TypeDefinitionName]
1275-
"""
1276-
return [t for t in self.all_types(imports=imports) if not self.type_parents(t, imports=imports)]
1277-
12781306
@lru_cache(None)
12791307
def is_multivalued(self, slot_name: SlotDefinition) -> bool:
12801308
"""Return True if slot is multivalued, else returns False.
@@ -1429,13 +1457,12 @@ def get_elements_applicable_by_prefix(self, prefix: str) -> list[str]:
14291457
:return: Optional[str]
14301458
14311459
"""
1432-
applicable_elements = []
14331460
elements = self.all_elements()
1434-
for category_element in elements.values():
1435-
if hasattr(category_element, "id_prefixes") and prefix in category_element.id_prefixes:
1436-
applicable_elements.append(category_element.name)
1437-
1438-
return applicable_elements
1461+
return [
1462+
element.name
1463+
for element in elements.values()
1464+
if hasattr(element, "id_prefixes") and prefix in element.id_prefixes
1465+
]
14391466

14401467
@lru_cache(None)
14411468
def all_aliases(self) -> list[str]:
@@ -1817,6 +1844,33 @@ def get_type_designator_slot(self, cn: CLASS_NAME, imports: bool = True) -> Slot
18171844
return s
18181845
return None
18191846

1847+
@lru_cache(None)
1848+
def _get_string_type(self) -> TypeDefinition:
1849+
"""Get the type used for representing strings.
1850+
1851+
This can be used for (e.g.) retrieving the appropriate type for a slot where the range is an enum.
1852+
1853+
The method assumes that the string type will either be called "string" or
1854+
will have the URI "xsd:string", as is the case for the "string" type in linkml:types.
1855+
1856+
This method throws an error if there is anything other than one type that fits the criteria.
1857+
1858+
:return: the "string" type object
1859+
:rtype: TypeDefinition
1860+
"""
1861+
str_type = self.get_type("string")
1862+
if str_type:
1863+
return str_type
1864+
1865+
# if there isn't a type named "string", search for a type with the URI xsd:string
1866+
str_type_arr = [v for v in self.all_types().values() if v.uri == "xsd:string"]
1867+
if len(str_type_arr) == 1:
1868+
return str_type_arr[0]
1869+
1870+
# zero or more than one potential "string" type found
1871+
err_msg = f"Cannot find a suitable 'string' type: no types with name 'string' {'and more than one type with' if str_type_arr else 'or'} uri 'xsd:string'."
1872+
raise ValueError(err_msg)
1873+
18201874
def is_inlined(self, slot: SlotDefinition, imports: bool = True) -> bool:
18211875
"""Return true if slot is inferred or asserted inline.
18221876
@@ -1849,6 +1903,10 @@ def slot_applicable_range_elements(self, slot: SlotDefinition) -> list[ClassDefi
18491903
:param slot:
18501904
:return: list of element types
18511905
"""
1906+
if not slot or not isinstance(slot, SlotDefinition):
1907+
err_msg = "A SlotDefinition must be provided to generate the slot applicable range elements."
1908+
raise ValueError(err_msg)
1909+
18521910
is_any = False
18531911
range_types = []
18541912
for r in self.slot_range_as_union(slot):
@@ -1875,6 +1933,10 @@ def slot_range_as_union(self, slot: SlotDefinition) -> list[ElementName]:
18751933
:param slot:
18761934
:return: list of ranges
18771935
"""
1936+
if not slot or not isinstance(slot, SlotDefinition):
1937+
err_msg = "A SlotDefinition must be provided to generate the slot range as union."
1938+
raise ValueError(err_msg)
1939+
18781940
return list({y.range for y in [slot, *[x for x in [*slot.exactly_one_of, *slot.any_of] if x.range]]})
18791941

18801942
def induced_slot_range(self, slot: SlotDefinition, strict: bool = False) -> set[str | ElementName]: # noqa: FBT001, FBT002
@@ -1892,6 +1954,9 @@ def induced_slot_range(self, slot: SlotDefinition, strict: bool = False) -> set[
18921954
:return: set of ranges
18931955
:rtype: set[str | ElementName]
18941956
"""
1957+
if not slot or not isinstance(slot, SlotDefinition):
1958+
err_msg = "A SlotDefinition must be provided to generate the induced slot range."
1959+
raise ValueError(err_msg)
18951960

18961961
slot_range = slot.range
18971962
any_of_range = {x.range for x in slot.any_of if x.range}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ dependencies = [
4848
"requests",
4949
"prefixmaps >=0.1.4",
5050
"curies >=0.5.4",
51-
"pydantic >=1.10.2, <3.0.0",
51+
"pydantic >=2.0.0, <3.0.0",
5252
"isodate >=0.7.2, <1.0.0; python_version < '3.11'",
5353
]
5454

0 commit comments

Comments
 (0)