Skip to content

Commit 10e8d65

Browse files
authored
Merge pull request #227 from linkml/issue-659-pydantic-loader-dumper
Support Pydantic BaseModel in loaders and dumpers
2 parents 4e1c751 + 5139426 commit 10e8d65

27 files changed

+1861
-424
lines changed

linkml_runtime/dumpers/csv_dumper.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import io
22
import yaml
33
import json
4-
from typing import Dict, List, Any
4+
from typing import Union
5+
from pydantic import BaseModel
56

67
from linkml_runtime.dumpers.dumper_root import Dumper
78
from linkml_runtime.dumpers.json_dumper import JSONDumper
@@ -15,7 +16,7 @@
1516

1617
class CSVDumper(Dumper):
1718

18-
def dumps(self, element: YAMLRoot,
19+
def dumps(self, element: Union[BaseModel, YAMLRoot],
1920
index_slot: SlotDefinitionName = None,
2021
schema: SchemaDefinition = None,
2122
schemaview: SchemaView = None,

linkml_runtime/dumpers/dumper_root.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from abc import ABC, abstractmethod
2+
from typing import Union
23

34
from linkml_runtime.utils.yamlutils import YAMLRoot
5+
from pydantic import BaseModel
46

57

68
class Dumper(ABC):
79
""" Abstract base class for all dumpers """
810

9-
def dump(self, element: YAMLRoot, to_file: str, **_) -> None:
11+
def dump(self, element: Union[BaseModel, YAMLRoot], to_file: str, **_) -> None:
1012
"""
1113
Write element to to_file
1214
:param element: LinkML object to be dumped
@@ -17,10 +19,10 @@ def dump(self, element: YAMLRoot, to_file: str, **_) -> None:
1719
output_file.write(self.dumps(element, **_))
1820

1921
@abstractmethod
20-
def dumps(self, element: YAMLRoot, **_) -> str:
22+
def dumps(self, element: Union[BaseModel, YAMLRoot], **_) -> str:
2123
"""
2224
Convert element to a string
23-
@param element: YAMLRoot object to be rendered
25+
@param element: Union[BaseModel, YAMLRoot] object to be rendered
2426
@param _: method specific arguments
2527
@return: stringified representation of element
2628
"""

linkml_runtime/dumpers/json_dumper.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from decimal import Decimal
3-
from typing import Dict
3+
from typing import Dict, Union
4+
from pydantic import BaseModel
45

56
from deprecated.classic import deprecated
67

@@ -11,9 +12,11 @@
1112
from linkml_runtime.utils.yamlutils import YAMLRoot, as_json_object
1213
from jsonasobj2 import JsonObj
1314

15+
1416
class JSONDumper(Dumper):
1517

16-
def dump(self, element: YAMLRoot, to_file: str, contexts: CONTEXTS_PARAM_TYPE = None, **kwargs) -> None:
18+
def dump(self, element: Union[BaseModel, YAMLRoot], to_file: str, contexts: CONTEXTS_PARAM_TYPE = None,
19+
**kwargs) -> None:
1720
"""
1821
Write element as json to to_file
1922
:param element: LinkML object to be serialized as YAML
@@ -26,9 +29,11 @@ def dump(self, element: YAMLRoot, to_file: str, contexts: CONTEXTS_PARAM_TYPE =
2629
* JSON Object
2730
* A list containing elements of any type named above
2831
"""
32+
if isinstance(element, BaseModel):
33+
element = element.dict()
2934
super().dump(element, to_file, contexts=contexts, **kwargs)
3035

31-
def dumps(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE = None, inject_type=True) -> str:
36+
def dumps(self, element: Union[BaseModel, YAMLRoot], contexts: CONTEXTS_PARAM_TYPE = None, inject_type=True) -> str:
3237
"""
3338
Return element as a JSON or a JSON-LD string
3439
:param element: LinkML object to be emitted
@@ -42,20 +47,24 @@ def dumps(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE = None, inject_
4247
:param inject_type: if True (default), add a @type at the top level
4348
:return: JSON Object representing the element
4449
"""
50+
4551
def default(o):
52+
if isinstance(o, BaseModel):
53+
return remove_empty_items(o.dict(), hide_protected_keys=True)
4654
if isinstance(o, YAMLRoot):
4755
return remove_empty_items(o, hide_protected_keys=True)
4856
elif isinstance(o, Decimal):
4957
# https://stackoverflow.com/questions/1960516/python-json-serialize-a-decimal-object
5058
return str(o)
5159
else:
5260
return json.JSONDecoder().decode(o)
61+
if isinstance(element, BaseModel):
62+
element = element.dict()
5363
return json.dumps(as_json_object(element, contexts, inject_type=inject_type),
5464
default=default,
5565
ensure_ascii=False,
5666
indent=' ')
5767

58-
5968
@staticmethod
6069
@deprecated("Use `utils/formatutils/remove_empty_items` instead")
6170
def remove_empty_items(obj: Dict) -> Dict:
@@ -66,7 +75,8 @@ def remove_empty_items(obj: Dict) -> Dict:
6675
"""
6776
return formatutils.remove_empty_items(obj, hide_protected_keys=True)
6877

69-
def to_json_object(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE = None, inject_type=True) -> JsonObj:
78+
def to_json_object(self, element: Union[BaseModel, YAMLRoot], contexts: CONTEXTS_PARAM_TYPE = None,
79+
inject_type=True) -> JsonObj:
7080
"""
7181
As dumps(), except returns a JsonObj, not a string
7282
@@ -83,7 +93,7 @@ def to_json_object(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE = None
8393
"""
8494
return as_json_object(element, contexts, inject_type=inject_type)
8595

86-
def to_dict(self, element: YAMLRoot, **kwargs) -> JsonObj:
96+
def to_dict(self, element: Union[BaseModel, YAMLRoot], **kwargs) -> JsonObj:
8797
"""
8898
As dumps(), except returns a JsonObj, not a string
8999
@@ -98,4 +108,4 @@ def to_dict(self, element: YAMLRoot, **kwargs) -> JsonObj:
98108
:param inject_type: if True (default), add a @type at the top level
99109
:return: JSON Object representing the element
100110
"""
101-
return json.loads(self.dumps(element, inject_type=False, **kwargs))
111+
return json.loads(self.dumps(element, inject_type=False, **kwargs))

linkml_runtime/dumpers/rdf_dumper.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
2-
from typing import Optional
2+
from typing import Optional, Union
3+
from pydantic import BaseModel
34

45
from hbreader import hbread
56
from rdflib import Graph
@@ -12,7 +13,7 @@
1213

1314

1415
class RDFDumper(Dumper):
15-
def as_rdf_graph(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE, namespaces: CONTEXT_TYPE = None) -> Graph:
16+
def as_rdf_graph(self, element: Union[BaseModel, YAMLRoot], contexts: CONTEXTS_PARAM_TYPE, namespaces: CONTEXT_TYPE = None) -> Graph:
1617
"""
1718
Convert element into an RDF graph guided by the context(s) in contexts
1819
:param element: element to represent in RDF
@@ -63,7 +64,7 @@ def as_rdf_graph(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE, namespa
6364

6465
return g
6566

66-
def dump(self, element: YAMLRoot, to_file: str, contexts: CONTEXTS_PARAM_TYPE = None, fmt: str = 'turtle') -> None:
67+
def dump(self, element: Union[BaseModel, YAMLRoot], to_file: str, contexts: CONTEXTS_PARAM_TYPE = None, fmt: str = 'turtle') -> None:
6768
"""
6869
Write element as rdf to to_file
6970
:param element: LinkML object to be emitted
@@ -77,15 +78,19 @@ def dump(self, element: YAMLRoot, to_file: str, contexts: CONTEXTS_PARAM_TYPE =
7778
* A list containing elements of any type named above
7879
:param fmt: RDF format
7980
"""
81+
if isinstance(element, BaseModel):
82+
element = element.dict()
8083
super().dump(element, to_file, contexts=contexts, fmt=fmt)
8184

82-
def dumps(self, element: YAMLRoot, contexts: CONTEXTS_PARAM_TYPE = None, fmt: Optional[str] = 'turtle') -> str:
85+
def dumps(self, element: Union[BaseModel, YAMLRoot], contexts: CONTEXTS_PARAM_TYPE = None, fmt: Optional[str] = 'turtle') -> str:
8386
"""
8487
Convert element into an RDF graph guided by the context(s) in contexts
8588
:param element: element to represent in RDF
8689
:param contexts: JSON-LD context(s) in the form of a file or URL, a json string or a json obj
8790
:param fmt: rdf format
8891
:return: rdflib Graph containing element
8992
"""
93+
if isinstance(element, BaseModel):
94+
element = element.dict()
9095
return self.as_rdf_graph(remove_empty_items(element, hide_protected_keys=True), contexts).\
9196
serialize(format=fmt)

linkml_runtime/dumpers/rdflib_dumper.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
22
import urllib
33
from abc import abstractmethod
4-
from typing import Optional, Any, Dict
4+
from typing import Optional, Any, Dict, Union
5+
from pydantic import BaseModel
56

67
from rdflib import Graph, URIRef, XSD
78
from rdflib.term import Node, BNode, Literal
@@ -23,7 +24,7 @@ class RDFLibDumper(Dumper):
2324
This requires a SchemaView object
2425
2526
"""
26-
def as_rdf_graph(self, element: YAMLRoot, schemaview: SchemaView, prefix_map: Dict[str, str] = None) -> Graph:
27+
def as_rdf_graph(self, element: Union[BaseModel, YAMLRoot], schemaview: SchemaView, prefix_map: Dict[str, str] = None) -> Graph:
2728
"""
2829
Dumps from element to an rdflib Graph,
2930
following a schema
@@ -136,7 +137,7 @@ def inject_triples(self, element: Any, schemaview: SchemaView, graph: Graph, tar
136137
graph.add((element_uri, RDF.type, URIRef(schemaview.get_uri(cn, expand=True))))
137138
return element_uri
138139

139-
def dump(self, element: YAMLRoot,
140+
def dump(self, element: Union[BaseModel, YAMLRoot],
140141
to_file: str,
141142
schemaview: SchemaView = None,
142143
fmt: str = 'turtle', prefix_map: Dict[str, str] = None, **args) -> None:
@@ -152,7 +153,7 @@ def dump(self, element: YAMLRoot,
152153
"""
153154
super().dump(element, to_file, schemaview=schemaview, fmt=fmt, prefix_map=prefix_map)
154155

155-
def dumps(self, element: YAMLRoot, schemaview: SchemaView = None,
156+
def dumps(self, element: Union[BaseModel, YAMLRoot], schemaview: SchemaView = None,
156157
fmt: Optional[str] = 'turtle', prefix_map: Dict[str, str] = None) -> str:
157158
"""
158159
Convert element into an RDF graph guided by the schema
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
from decimal import Decimal
2-
31
import yaml
2+
from typing import Union
3+
from pydantic import BaseModel
44

55
from linkml_runtime.dumpers.dumper_root import Dumper
66
from linkml_runtime.utils.formatutils import remove_empty_items
77
from linkml_runtime.utils.yamlutils import YAMLRoot
88

99
class YAMLDumper(Dumper):
1010

11-
def dumps(self, element: YAMLRoot, **kwargs) -> str:
11+
def dumps(self, element: Union[BaseModel, YAMLRoot], **kwargs) -> str:
1212
""" Return element formatted as a YAML string """
1313
# Internal note: remove_empty_items will also convert Decimals to int/float;
1414
# this is necessary until https://github.com/yaml/pyyaml/pull/372 is merged
15-
return yaml.dump(remove_empty_items(element, hide_protected_keys=True),
15+
16+
dumper_safe_element = element.dict() if isinstance(element, BaseModel) else element
17+
return yaml.dump(remove_empty_items(dumper_safe_element, hide_protected_keys=True),
1618
Dumper=yaml.SafeDumper, sort_keys=False,
1719
allow_unicode=True,
1820
**kwargs)

linkml_runtime/loaders/csv_loader.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
from typing import Type, Union, List
44
from linkml_runtime.utils.yamlutils import YAMLRoot
5+
from pydantic import BaseModel
56

67
from linkml_runtime.loaders.loader_root import Loader
78
from linkml_runtime.loaders.json_loader import JSONLoader
@@ -17,7 +18,7 @@ def load_any(self, *args, **kwargs) -> Union[YAMLRoot, List[YAMLRoot]]:
1718

1819

1920
def loads(self, input,
20-
target_class: Type[YAMLRoot],
21+
target_class: Type[Union[BaseModel, YAMLRoot]],
2122
index_slot: SlotDefinitionName = None,
2223
schema: SchemaDefinition = None,
2324
schemaview: SchemaView = None,
@@ -30,7 +31,7 @@ def loads(self, input,
3031
return JSONLoader().loads(json.dumps({index_slot: objs}), target_class=target_class)
3132

3233
def load(self, source: str,
33-
target_class: Type[YAMLRoot],
34+
target_class: Type[Union[BaseModel, YAMLRoot]],
3435
index_slot: SlotDefinitionName = None,
3536
schema: SchemaDefinition = None,
3637
schemaview: SchemaView = None,

linkml_runtime/loaders/json_loader.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
from linkml_runtime.loaders.loader_root import Loader
88
from linkml_runtime.utils.yamlutils import YAMLRoot
9-
9+
from pydantic import BaseModel
1010

1111
class JSONLoader(Loader):
1212

13-
def load_any(self, source: Union[str, dict, TextIO], target_class: Type[YAMLRoot], *, base_dir: Optional[str] = None,
14-
metadata: Optional[FileInfo] = None, **_) -> Union[YAMLRoot, List[YAMLRoot]]:
13+
def load_any(self, source: Union[str, dict, TextIO], target_class: Type[Union[BaseModel, YAMLRoot]], *, base_dir: Optional[str] = None,
14+
metadata: Optional[FileInfo] = None, **_) -> Union[BaseModel, YAMLRoot, List[BaseModel], List[YAMLRoot]]:
1515
def loader(data: Union[str, dict], _: FileInfo) -> Optional[Dict]:
1616
data_as_dict = json.loads(data) if isinstance(data, str) else data
1717
if isinstance(data_as_dict, list):

linkml_runtime/loaders/loader_root.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from abc import ABC, abstractmethod
22
from typing import TextIO, Union, Optional, Callable, Dict, Type, Any, List
33

4+
from pydantic import BaseModel
45
from hbreader import FileInfo, hbread
56
from jsonasobj2 import as_dict, JsonObj
67

@@ -36,9 +37,9 @@ def _is_empty(o) -> bool:
3637
def load_source(self,
3738
source: Union[str, dict, TextIO],
3839
loader: Callable[[Union[str, Dict], FileInfo], Optional[Union[Dict, List]]],
39-
target_class: Type[YAMLRoot],
40+
target_class: Union[Type[YAMLRoot], Type[BaseModel]],
4041
accept_header: Optional[str] = "text/plain, application/yaml;q=0.9",
41-
metadata: Optional[FileInfo] = None) -> Optional[Union[YAMLRoot, List[YAMLRoot]]]:
42+
metadata: Optional[FileInfo] = None) -> Optional[Union[BaseModel, YAMLRoot, List[BaseModel], List[YAMLRoot]]]:
4243
""" Base loader - convert a file, url, string, open file handle or dictionary into an instance
4344
of target_class
4445
@@ -59,19 +60,28 @@ def load_source(self,
5960
else:
6061
data = source
6162
data_as_dict = loader(data, metadata)
63+
6264
if data_as_dict:
6365
if isinstance(data_as_dict, list):
64-
return [target_class(**as_dict(x)) for x in data_as_dict]
66+
if issubclass(target_class, YAMLRoot):
67+
return [target_class(**as_dict(x)) for x in data_as_dict]
68+
elif issubclass(target_class, BaseModel):
69+
return [target_class.parse_obj(**as_dict(x)) for x in data_as_dict]
70+
else:
71+
raise ValueError(f'Cannot load list of {target_class}')
6572
elif isinstance(data_as_dict, dict):
66-
return target_class(**data_as_dict)
73+
if issubclass(target_class, BaseModel):
74+
return target_class.parse_obj(data_as_dict)
75+
else:
76+
return target_class(**data_as_dict)
6777
elif isinstance(data_as_dict, JsonObj):
6878
return [target_class(**as_dict(x)) for x in data_as_dict]
6979
else:
7080
raise ValueError(f'Unexpected type {data_as_dict}')
7181
else:
7282
return None
7383

74-
def load(self, *args, **kwargs) -> YAMLRoot:
84+
def load(self, *args, **kwargs) -> Union[BaseModel, YAMLRoot]:
7585
"""
7686
Load source as an instance of target_class
7787
@@ -83,14 +93,14 @@ def load(self, *args, **kwargs) -> YAMLRoot:
8393
:return: instance of target_class
8494
"""
8595
results = self.load_any(*args, **kwargs)
86-
if isinstance(results, YAMLRoot):
96+
if isinstance(results, BaseModel) or isinstance(results, YAMLRoot):
8797
return results
8898
else:
89-
raise ValueError(f'Result is not an instance of YAMLRoot: {type(results)}')
99+
raise ValueError(f'Result is not an instance of BaseModel or YAMLRoot: {type(results)}')
90100

91101
@abstractmethod
92-
def load_any(self, source: Union[str, dict, TextIO], target_class: Type[YAMLRoot], *, base_dir: Optional[str] = None,
93-
metadata: Optional[FileInfo] = None, **_) -> Union[YAMLRoot, List[YAMLRoot]]:
102+
def load_any(self, source: Union[str, dict, TextIO], target_class: Type[Union[BaseModel, YAMLRoot]], *, base_dir: Optional[str] = None,
103+
metadata: Optional[FileInfo] = None, **_) -> Union[BaseModel, YAMLRoot, List[BaseModel], List[YAMLRoot]]:
94104
"""
95105
Load source as an instance of target_class, or list of instances of target_class
96106
@@ -103,7 +113,7 @@ def load_any(self, source: Union[str, dict, TextIO], target_class: Type[YAMLRoot
103113
"""
104114
raise NotImplementedError()
105115

106-
def loads_any(self, source: str, target_class: Type[YAMLRoot], *, metadata: Optional[FileInfo] = None, **_) -> Union[YAMLRoot, List[YAMLRoot]]:
116+
def loads_any(self, source: str, target_class: Type[Union[BaseModel, YAMLRoot]], *, metadata: Optional[FileInfo] = None, **_) -> Union[BaseModel, YAMLRoot, List[BaseModel], List[YAMLRoot]]:
107117
"""
108118
Load source as a string as an instance of target_class, or list of instances of target_class
109119
@param source: source
@@ -114,7 +124,7 @@ def loads_any(self, source: str, target_class: Type[YAMLRoot], *, metadata: Opti
114124
"""
115125
return self.load_any(source, target_class, metadata=metadata)
116126

117-
def loads(self, source: str, target_class: Type[YAMLRoot], *, metadata: Optional[FileInfo] = None, **_) -> YAMLRoot:
127+
def loads(self, source: str, target_class: Type[Union[BaseModel, YAMLRoot]], *, metadata: Optional[FileInfo] = None, **_) -> Union[BaseModel, YAMLRoot]:
118128
"""
119129
Load source as a string
120130
:param source: source

linkml_runtime/loaders/rdf_loader.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from linkml_runtime.loaders.loader_root import Loader
66
from linkml_runtime.utils.context_utils import CONTEXTS_PARAM_TYPE
77
from linkml_runtime.utils.yamlutils import YAMLRoot
8+
from pydantic import BaseModel
89
from rdflib import Graph
910

1011
from linkml_runtime.loaders.requests_ssl_patch import no_ssl_verification
@@ -15,12 +16,13 @@
1516

1617
class RDFLoader(Loader):
1718

18-
def load_any(self, *args, **kwargs) -> Union[YAMLRoot, List[YAMLRoot]]:
19+
def load_any(self, *args, **kwargs) -> Union[BaseModel, YAMLRoot, List[BaseModel], List[YAMLRoot]]:
1920
return self.load(*args, **kwargs)
2021

21-
def load(self, source: Union[str, TextIO, Graph], target_class: Type[YAMLRoot], *, base_dir: Optional[str] = None,
22+
23+
def load(self, source: Union[str, TextIO, Graph], target_class: Type[Union[BaseModel, YAMLRoot]], *, base_dir: Optional[str] = None,
2224
contexts: CONTEXTS_PARAM_TYPE = None, fmt: Optional[str] = 'turtle',
23-
metadata: Optional[FileInfo] = None) -> YAMLRoot:
25+
metadata: Optional[FileInfo] = None) -> Union[BaseModel, YAMLRoot]:
2426
"""
2527
Load the RDF in source into the python target_class structure
2628
:param source: RDF data source. Can be a URL, a file name, an RDF string, an open handle or an existing graph

0 commit comments

Comments
 (0)