Skip to content

Commit 8f2d867

Browse files
authored
Merge pull request #338 from candleindark/add-class-improv
BF: Fix bug and other improvement in `SchemaBuilder.add_class()`
2 parents f749489 + 90adb0b commit 8f2d867

File tree

2 files changed

+183
-20
lines changed

2 files changed

+183
-20
lines changed

linkml_runtime/utils/schema_builder.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from dataclasses import dataclass, field
1+
from dataclasses import dataclass, fields
22
from typing import Dict, List, Union, Optional
33

44
from linkml_runtime.linkml_model import (ClassDefinition, EnumDefinition,
@@ -16,7 +16,7 @@ class SchemaBuilder:
1616
1717
Example:
1818
19-
>>> from linkml.utils.schema_builder import SchemaBuilder
19+
>>> from linkml_runtime.utils.schema_builder import SchemaBuilder
2020
>>> sb = SchemaBuilder('test-schema')
2121
>>> sb.add_class('Person', slots=['name', 'age'])
2222
>>> sb.add_class('Organization', slots=['name', 'employees'])
@@ -57,26 +57,47 @@ def add_class(
5757
cls: Union[ClassDefinition, Dict, str],
5858
slots: List[Union[str, SlotDefinition]] = None,
5959
slot_usage: Dict[str, SlotDefinition] = None,
60-
replace_if_present=False,
61-
use_attributes=False,
60+
replace_if_present: bool = False,
61+
use_attributes: bool = False,
6262
**kwargs,
6363
) -> "SchemaBuilder":
6464
"""
6565
Adds a class to the schema.
6666
6767
:param cls: name, dict object, or ClassDefinition object to add
68-
:param slots: slot of slot names or slot objects.
69-
:param slot_usage: slots keyed by slot name
68+
:param slots: list of slot names or slot objects. This must be a list of
69+
`SlotDefinition` objects if `use_attributes=True`
70+
:param slot_usage: slots keyed by slot name (ignored if `use_attributes=True`)
7071
:param replace_if_present: if True, replace existing class if present
72+
:param use_attributes: Whether to specify the given slots as an inline
73+
definition of slots, attributes, in the class definition
7174
:param kwargs: additional ClassDefinition properties
7275
:return: builder
7376
:raises ValueError: if class already exists and replace_if_present=False
7477
"""
78+
if slots is None:
79+
slots = []
80+
if slot_usage is None:
81+
slot_usage = {}
82+
7583
if isinstance(cls, str):
7684
cls = ClassDefinition(cls, **kwargs)
77-
if isinstance(cls, dict):
85+
elif isinstance(cls, dict):
7886
cls = ClassDefinition(**{**cls, **kwargs})
79-
if cls.name is self.schema.classes and not replace_if_present:
87+
else:
88+
# Ensure that `cls` is a `ClassDefinition` object
89+
if not isinstance(cls, ClassDefinition):
90+
msg = (
91+
f"cls must be a string, dict, or ClassDefinition, "
92+
f"not {type(cls)!r}"
93+
)
94+
raise TypeError(msg)
95+
96+
cls_as_dict = {f.name: getattr(cls, f.name) for f in fields(cls)}
97+
98+
cls = ClassDefinition(**{**cls_as_dict, **kwargs})
99+
100+
if cls.name in self.schema.classes and not replace_if_present:
80101
raise ValueError(f"Class {cls.name} already exists")
81102
self.schema.classes[cls.name] = cls
82103
if use_attributes:
@@ -88,18 +109,14 @@ def add_class(
88109
f"If use_attributes=True then slots must be SlotDefinitions"
89110
)
90111
else:
91-
if slots is not None:
92-
for s in slots:
93-
cls.slots.append(s.name if isinstance(s, SlotDefinition) else s)
94-
if isinstance(s, str) and s in self.schema.slots:
95-
# top-level slot already exists
96-
continue
97-
self.add_slot(s, replace_if_present=replace_if_present)
98-
if slot_usage:
99-
for k, v in slot_usage.items():
100-
cls.slot_usage[k] = v
101-
for k, v in kwargs.items():
102-
setattr(cls, k, v)
112+
for s in slots:
113+
cls.slots.append(s.name if isinstance(s, SlotDefinition) else s)
114+
if isinstance(s, str) and s in self.schema.slots:
115+
# top-level slot already exists
116+
continue
117+
self.add_slot(s, replace_if_present=replace_if_present)
118+
for k, v in slot_usage.items():
119+
cls.slot_usage[k] = v
103120
return self
104121

105122
def add_slot(
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from typing import Optional, List, Union, Any, Dict
2+
from dataclasses import fields
3+
4+
import pytest
5+
6+
from linkml_runtime.utils.schema_builder import SchemaBuilder
7+
from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition
8+
9+
10+
@pytest.mark.parametrize("replace_if_present", [True, False])
11+
def test_add_existing_class(replace_if_present):
12+
"""
13+
Test the case of adding a class with a name that is the same as a class that already
14+
exists in the schema
15+
"""
16+
builder = SchemaBuilder()
17+
18+
cls_name = "Person"
19+
20+
# Add a class to the schema
21+
cls = ClassDefinition(name=cls_name, slots=["name"])
22+
builder.add_class(cls)
23+
24+
# Add another class with the same name to the schema
25+
cls_repeat = ClassDefinition(name=cls_name, slots=["age"])
26+
27+
if replace_if_present:
28+
builder.add_class(cls_repeat, replace_if_present=True)
29+
assert builder.schema.classes[cls_name].slots == ["age"]
30+
else:
31+
with pytest.raises(ValueError, match=f"Class {cls_name} already exists"):
32+
builder.add_class(cls_repeat)
33+
assert builder.schema.classes[cls_name].slots == ["name"]
34+
35+
36+
@pytest.mark.parametrize(
37+
"slots",
38+
[
39+
None,
40+
["name", "age"],
41+
["name", SlotDefinition(name="age")],
42+
[SlotDefinition(name="name"), SlotDefinition(name="age")],
43+
],
44+
)
45+
@pytest.mark.parametrize("use_attributes", [True, False])
46+
def test_add_class_with_slot_additions(
47+
slots: Optional[List[Union[str, SlotDefinition]]], use_attributes: bool
48+
):
49+
"""
50+
Test adding a class with separate additional slots specification
51+
"""
52+
# If `slots` is None, it should be treated as if it were an empty list
53+
if slots is None:
54+
slots = []
55+
56+
slot_names = {s if isinstance(s, str) else s.name for s in slots}
57+
58+
builder = SchemaBuilder()
59+
60+
cls_name = "Person"
61+
62+
# Add a class to the schema
63+
cls = ClassDefinition(name=cls_name)
64+
65+
if use_attributes:
66+
# === The case where the slots specified should be added to the `attributes`
67+
# meta slot of the class ===
68+
if any(not isinstance(s, SlotDefinition) for s in slots):
69+
with pytest.raises(
70+
ValueError,
71+
match="If use_attributes=True then slots must be SlotDefinitions",
72+
):
73+
builder.add_class(cls, slots=slots, use_attributes=use_attributes)
74+
else:
75+
builder.add_class(cls, slots=slots, use_attributes=use_attributes)
76+
assert builder.schema.classes[cls_name].attributes.keys() == slot_names
77+
else:
78+
# === The case where the slots specified should be added to the `slots`
79+
# meta slot of the schema ===
80+
builder.add_class(cls, slots=slots, use_attributes=use_attributes)
81+
assert builder.schema.slots.keys() == slot_names
82+
83+
84+
@pytest.mark.parametrize(
85+
("cls", "extra_kwargs", "expected_added_class"),
86+
[
87+
("Person", {}, ClassDefinition(name="Person")),
88+
(2, {}, None), # Invalid type for `cls`
89+
("Person", {"tree_root": True}, ClassDefinition(name="Person", tree_root=True)),
90+
("Person", {"ijk": True}, None), # Invalid extra kwarg
91+
(
92+
{"name": "Person", "tree_root": False},
93+
{"tree_root": True},
94+
ClassDefinition(name="Person", tree_root=True),
95+
),
96+
(
97+
{"name": "Person", "tree_root": False},
98+
{"ijk": True}, # Invalid extra kwarg
99+
None,
100+
),
101+
(
102+
ClassDefinition(name="Person", tree_root=False),
103+
{"tree_root": True},
104+
ClassDefinition(name="Person", tree_root=True),
105+
),
106+
(
107+
ClassDefinition(name="Person", tree_root=False),
108+
{"ijk": True}, # Invalid extra kwarg
109+
ClassDefinition(name="Person", tree_root=True),
110+
),
111+
],
112+
)
113+
def test_add_class_with_extra_kwargs(
114+
cls: Union[ClassDefinition, Dict, str],
115+
extra_kwargs: Dict[str, Any],
116+
expected_added_class: Optional[ClassDefinition],
117+
):
118+
"""
119+
Test adding a class with extra kwargs
120+
"""
121+
# The meta slots or fields of `ClassDefinition`
122+
class_meta_slots = {f.name for f in fields(ClassDefinition)}
123+
124+
builder = SchemaBuilder()
125+
126+
if not isinstance(cls, (str, dict, ClassDefinition)):
127+
with pytest.raises(TypeError, match="cls must be"):
128+
builder.add_class(cls, **extra_kwargs)
129+
elif extra_kwargs.keys() - class_meta_slots:
130+
# Handle the case of extra kwargs include a key that is not a meta slot of
131+
# `ClassDefinition`
132+
with pytest.raises(ValueError):
133+
builder.add_class(cls, **extra_kwargs)
134+
else:
135+
builder.add_class(cls, **extra_kwargs)
136+
137+
if isinstance(cls, str):
138+
class_name = cls
139+
elif isinstance(cls, dict):
140+
class_name = cls["name"]
141+
else:
142+
class_name = cls.name
143+
144+
added_class = builder.schema.classes[class_name]
145+
146+
assert added_class == expected_added_class

0 commit comments

Comments
 (0)