Skip to content

Commit 2ed0633

Browse files
authored
Merge pull request #302 from sneakers-the-rat/bugfix-schemaview-import-order
Fix `Schemaview.import_closure` order
2 parents e9149f8 + 1b2ac02 commit 2ed0633

File tree

18 files changed

+511
-16
lines changed

18 files changed

+511
-16
lines changed

linkml_runtime/utils/schemaview.py

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import collections
55
from functools import lru_cache
66
from copy import copy, deepcopy
7-
from collections import defaultdict
7+
from collections import defaultdict, deque
88
from pathlib import Path
99
from typing import Mapping, Tuple
10+
import warnings
1011

1112
from linkml_runtime.utils.namespaces import Namespaces
1213
from deprecated.classic import deprecated
@@ -207,34 +208,76 @@ def load_import(self, imp: str, from_schema: SchemaDefinition = None):
207208
return schema
208209

209210
@lru_cache()
210-
def imports_closure(self, imports: bool = True, traverse=True, inject_metadata=True) -> List[SchemaDefinitionName]:
211+
def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None, inject_metadata=True) -> List[SchemaDefinitionName]:
211212
"""
212213
Return all imports
213214
214-
:param traverse: if true, traverse recursively
215+
Objects in imported classes override one another in a "python-like" order -
216+
from the point of view of the importing schema, imports will override one
217+
another from first to last, recursively for each layer of imports.
218+
219+
An import tree like::
220+
221+
- main
222+
- s1
223+
- s1_1
224+
- s1_2
225+
- s1_2_1
226+
- s1_2_2
227+
- s2
228+
- s2_1
229+
- s2_2
230+
231+
will override objects with the same name, in order::
232+
233+
['s1_1', 's1_2_1', 's1_2_2', 's1_2', 's1', 's2_1', 's2_2', 's2']
234+
235+
:param imports: bool (default: ``True`` ) include imported schemas, recursively
236+
:param traverse: bool, optional (default: ``True`` ) (Deprecated, use
237+
``imports`` ). if true, traverse recursively
215238
:return: all schema names in the transitive reflexive imports closure
216239
"""
217-
if not imports:
218-
return [self.schema.name]
219240
if self.schema_map is None:
220241
self.schema_map = {self.schema.name: self.schema}
221-
closure = []
242+
243+
closure = deque()
222244
visited = set()
223245
todo = [self.schema.name]
224-
if not traverse:
246+
247+
if traverse is not None:
248+
warnings.warn(
249+
'traverse behaves identically to imports and will be removed in a future version. Use imports instead.',
250+
DeprecationWarning
251+
)
252+
253+
if not imports or (not traverse and traverse is not None):
225254
return todo
255+
226256
while len(todo) > 0:
257+
# visit item
227258
sn = todo.pop()
228-
visited.add(sn)
229259
if sn not in self.schema_map:
230260
imported_schema = self.load_import(sn)
231261
self.schema_map[sn] = imported_schema
232-
s = self.schema_map[sn]
233-
if sn not in closure:
234-
closure.append(sn)
235-
for i in s.imports:
236-
if i not in visited:
237-
todo.append(i)
262+
263+
# resolve item's imports if it has not been visited already
264+
# we will get duplicates, but not cycles this way, and
265+
# filter out dupes, preserving the first entry, at the end.
266+
if sn not in visited:
267+
for i in self.schema_map[sn].imports:
268+
# no self imports ;)
269+
if i != sn:
270+
todo.append(i)
271+
272+
# add item to closure
273+
# append + pop (above) is FILO queue, which correctly extends tree leaves,
274+
# but in backwards order.
275+
closure.appendleft(sn)
276+
visited.add(sn)
277+
278+
# filter duplicates, keeping first entry
279+
closure = list({k:None for k in closure}.keys())
280+
238281
if inject_metadata:
239282
for s in self.schema_map.values():
240283
for x in {**s.classes, **s.enums, **s.slots, **s.subsets, **s.types}.values():
@@ -434,6 +477,7 @@ def all_elements(self, imports=True) -> Dict[ElementName, Element]:
434477
def _get_dict(self, slot_name: str, imports=True) -> Dict:
435478
schemas = self.all_schema(imports)
436479
d = {}
480+
# pdb.set_trace()
437481
# iterate through all schemas and merge the list together
438482
for s in schemas:
439483
# get the value of element name from the schema, if empty, return empty dictionary.

tests/test_issues/test_linkml_issue_998.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ def test_slots_are_not_duplicated(view):
8181

8282
def test_issue_998_attribute_slot(view):
8383
enum_slots = view.get_slots_by_enum("EmploymentStatusEnum")
84-
assert len(enum_slots) == 1
85-
assert enum_slots[0].name == "employed"
84+
assert len(enum_slots) == 2
85+
assert sorted([slot.name for slot in enum_slots]) == ["employed", "past_employer"]
8686

8787

8888
def test_issue_998_schema_and_attribute_slots(view):
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
A tree of imports like:
2+
3+
```
4+
main
5+
|- linkml:types
6+
|- s1
7+
| |- s1_1
8+
| |- s1_2
9+
| |- s1_2_1
10+
| |- s1_2_1_1
11+
| |- s1_2_1_1_1
12+
| |- s1_2_1_1_2
13+
|- s2
14+
| |- s2_1
15+
| |- s2_2
16+
|- s3
17+
|- s3_1
18+
|- s3_2
19+
```
20+
21+
This is used to test SchemaView's logic for complex import hierarchies,
22+
eg.
23+
- overrides
24+
- imports closure completeness
25+
- (at some point) monotonicity
26+
- etc.
27+
28+
Currently, each schema...
29+
- Contains one `Main` class with a value whose default is overridden to indicate which module defined it, this is used to test overrides.
30+
- Contains a class like `S2` that carries the name of the module to ensure that unique classes are gotten from the whole tree
31+
- Each node in the tree outwards from `main` will carry the 'special classes' from the parents, overriding them as with `main` to
32+
get a more detailed picture of override order: eg. `s1_2_1` will also define `S1_2` and override its `ifabsent` field,
33+
which `S1_2` should override since it's the importing schema.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
id: main
2+
name: main
3+
title: main
4+
imports:
5+
- linkml:types
6+
- s1
7+
- s2
8+
- s3
9+
classes:
10+
Main:
11+
description: "The class we use to test overrides on imports!"
12+
attributes:
13+
value:
14+
range: string
15+
ifabsent: "Main"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
id: s1
2+
name: s1
3+
title: s1
4+
imports:
5+
- linkml:types
6+
- s1_1
7+
- s1_2
8+
classes:
9+
Main:
10+
description: "The class we use to test overrides on imports!"
11+
attributes:
12+
value:
13+
range: string
14+
ifabsent: "S1"
15+
S1:
16+
description: "A class from one of the imported classes!"
17+
attributes:
18+
value:
19+
range: string
20+
ifabsent: "S1"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
id: s1_1
2+
name: s1_1
3+
title: s1_1
4+
imports:
5+
- linkml:types
6+
classes:
7+
Main:
8+
description: "The class we use to test overrides on imports!"
9+
attributes:
10+
value:
11+
range: string
12+
ifabsent: "S1_1"
13+
S1:
14+
description: "A class from one of the imported classes!"
15+
attributes:
16+
value:
17+
range: string
18+
ifabsent: "S1_1"
19+
S1_1:
20+
description: "A class from one of the imported classes!"
21+
attributes:
22+
value:
23+
range: string
24+
ifabsent: "S1_1"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
id: s1_2
2+
name: s1_2
3+
title: s1_2
4+
imports:
5+
- linkml:types
6+
- s1_2_1
7+
classes:
8+
Main:
9+
description: "The class we use to test overrides on imports!"
10+
attributes:
11+
value:
12+
range: string
13+
ifabsent: "S1_2"
14+
S1:
15+
description: "A class from one of the imported classes!"
16+
attributes:
17+
value:
18+
range: string
19+
ifabsent: "S1_2"
20+
S1_2:
21+
description: "A class from one of the imported classes!"
22+
attributes:
23+
value:
24+
range: string
25+
ifabsent: "S1_2"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
id: s1_2_1
2+
name: s1_2_1
3+
title: s1_2_1
4+
imports:
5+
- linkml:types
6+
- s1_2_1_1
7+
classes:
8+
Main:
9+
description: "The class we use to test overrides on imports!"
10+
attributes:
11+
value:
12+
range: string
13+
ifabsent: "S1_2_1"
14+
S1:
15+
description: "A class from one of the imported classes!"
16+
attributes:
17+
value:
18+
range: string
19+
ifabsent: "S1_2_1"
20+
S1_2:
21+
description: "A class from one of the imported classes!"
22+
attributes:
23+
value:
24+
range: string
25+
ifabsent: "S1_2_1"
26+
S1_2_1:
27+
description: "A class from one of the imported classes!"
28+
attributes:
29+
value:
30+
range: string
31+
ifabsent: "S1_2_1"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
id: s1_2_1_1
2+
name: s1_2_1_1
3+
title: s1_2_1_1
4+
imports:
5+
- linkml:types
6+
- s1_2_1_1_1
7+
- s1_2_1_1_2
8+
classes:
9+
Main:
10+
description: "The class we use to test overrides on imports!"
11+
attributes:
12+
value:
13+
range: string
14+
ifabsent: "S1_2_1_1"
15+
S1:
16+
description: "A class from one of the imported classes!"
17+
attributes:
18+
value:
19+
range: string
20+
ifabsent: "S1_2_1_1"
21+
S1_2:
22+
description: "A class from one of the imported classes!"
23+
attributes:
24+
value:
25+
range: string
26+
ifabsent: "S1_2_1_1"
27+
S1_2_1:
28+
description: "A class from one of the imported classes!"
29+
attributes:
30+
value:
31+
range: string
32+
ifabsent: "S1_2_1_1"
33+
S1_2_1_1:
34+
description: "A class from one of the imported classes!"
35+
attributes:
36+
value:
37+
range: string
38+
ifabsent: "S1_2_1_1"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
id: s1_2_1_1_1
2+
name: s1_2_1_1_1
3+
title: s1_2_1_1_1
4+
imports:
5+
- linkml:types
6+
classes:
7+
Main:
8+
description: "The class we use to test overrides on imports!"
9+
attributes:
10+
value:
11+
range: string
12+
ifabsent: "S1_2_1_1_1"
13+
S1:
14+
description: "A class from one of the imported classes!"
15+
attributes:
16+
value:
17+
range: string
18+
ifabsent: "S1_2_1_1_1"
19+
S1_2:
20+
description: "A class from one of the imported classes!"
21+
attributes:
22+
value:
23+
range: string
24+
ifabsent: "S1_2_1_1_1"
25+
S1_2_1:
26+
description: "A class from one of the imported classes!"
27+
attributes:
28+
value:
29+
range: string
30+
ifabsent: "S1_2_1_1_1"
31+
S1_2_1_1:
32+
description: "A class from one of the imported classes!"
33+
attributes:
34+
value:
35+
range: string
36+
ifabsent: "S1_2_1_1_1"
37+
S1_2_1_1_1:
38+
description: "A class from one of the imported classes!"
39+
attributes:
40+
value:
41+
range: string
42+
ifabsent: "S1_2_1_1_1"

0 commit comments

Comments
 (0)