triplemodel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- triplemodel/__init__.py +46 -0
- triplemodel/_config.py +72 -0
- triplemodel/_fields.py +60 -0
- triplemodel/_graph.py +191 -0
- triplemodel/_types.py +68 -0
- triplemodel/model.py +102 -0
- triplemodel/py.typed +0 -0
- triplemodel-0.1.0.dist-info/METADATA +294 -0
- triplemodel-0.1.0.dist-info/RECORD +11 -0
- triplemodel-0.1.0.dist-info/WHEEL +4 -0
- triplemodel-0.1.0.dist-info/licenses/LICENSE +21 -0
triplemodel/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""TripleModel — Pydantic models backed by RDF graphs via rdflib.
|
|
2
|
+
|
|
3
|
+
Install: ``pip install triplemodel``
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from triplemodel._config import (
|
|
7
|
+
RDF,
|
|
8
|
+
RDFS,
|
|
9
|
+
RDF_TYPE,
|
|
10
|
+
XSD,
|
|
11
|
+
RdfConfig,
|
|
12
|
+
id_from_subject_uri,
|
|
13
|
+
subject_base,
|
|
14
|
+
)
|
|
15
|
+
from triplemodel._fields import Predicate, rdf_field
|
|
16
|
+
from triplemodel._graph import (
|
|
17
|
+
OnDuplicate,
|
|
18
|
+
graph_to_model,
|
|
19
|
+
graph_to_models,
|
|
20
|
+
model_to_graph,
|
|
21
|
+
model_to_triples,
|
|
22
|
+
models_to_graph,
|
|
23
|
+
)
|
|
24
|
+
from triplemodel.model import TripleModel
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"OnDuplicate",
|
|
30
|
+
"RDF",
|
|
31
|
+
"RDFS",
|
|
32
|
+
"RDF_TYPE",
|
|
33
|
+
"XSD",
|
|
34
|
+
"Predicate",
|
|
35
|
+
"RdfConfig",
|
|
36
|
+
"TripleModel",
|
|
37
|
+
"graph_to_model",
|
|
38
|
+
"graph_to_models",
|
|
39
|
+
"id_from_subject_uri",
|
|
40
|
+
"model_to_graph",
|
|
41
|
+
"model_to_triples",
|
|
42
|
+
"models_to_graph",
|
|
43
|
+
"rdf_field",
|
|
44
|
+
"subject_base",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
triplemodel/_config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""RDF configuration attached to Pydantic models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote, unquote
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def subject_base(namespace: str) -> str:
|
|
11
|
+
"""Return the prefix used when appending an id to ``namespace``."""
|
|
12
|
+
return namespace if namespace.endswith(("/", "#")) else namespace + "/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def id_from_subject_uri(namespace: str, uri: str) -> str | None:
|
|
16
|
+
"""Extract the id segment from ``uri`` when it was built from ``namespace``."""
|
|
17
|
+
base = subject_base(namespace)
|
|
18
|
+
if not uri.startswith(base):
|
|
19
|
+
return None
|
|
20
|
+
return unquote(uri[len(base) :])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RdfConfig:
|
|
25
|
+
"""RDF metadata for an :class:`~triplemodel.TripleModel` subclass."""
|
|
26
|
+
|
|
27
|
+
namespace: str = ""
|
|
28
|
+
type_uri: str | None = None
|
|
29
|
+
id_field: str | None = None
|
|
30
|
+
"""Model field whose value is appended to ``namespace`` for the subject IRI."""
|
|
31
|
+
|
|
32
|
+
def subject_uri(self, instance: Any) -> str:
|
|
33
|
+
if not self.namespace:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"Rdf.namespace is required to derive a subject IRI; "
|
|
36
|
+
"set it on the model's Rdf class or pass uri= explicitly."
|
|
37
|
+
)
|
|
38
|
+
if not self.id_field:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Rdf.id_field is required to derive a subject IRI; "
|
|
41
|
+
"set it on the model's Rdf class or pass uri= explicitly."
|
|
42
|
+
)
|
|
43
|
+
raw = getattr(instance, self.id_field, None)
|
|
44
|
+
if raw is None or raw == "":
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Cannot build subject IRI: field {self.id_field!r} is empty."
|
|
47
|
+
)
|
|
48
|
+
base = subject_base(self.namespace)
|
|
49
|
+
segment = quote(str(raw), safe="")
|
|
50
|
+
return f"{base}{segment}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_rdf_config(model_cls: type) -> RdfConfig:
|
|
54
|
+
for cls in model_cls.__mro__:
|
|
55
|
+
if cls is object:
|
|
56
|
+
continue
|
|
57
|
+
rdf = getattr(cls, "Rdf", None)
|
|
58
|
+
if rdf is not None:
|
|
59
|
+
return RdfConfig(
|
|
60
|
+
namespace=getattr(rdf, "namespace", "") or "",
|
|
61
|
+
type_uri=getattr(rdf, "type_uri", None),
|
|
62
|
+
id_field=getattr(rdf, "id_field", None),
|
|
63
|
+
)
|
|
64
|
+
return RdfConfig()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Namespace constants used by the package
|
|
68
|
+
RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
69
|
+
RDFS = "http://www.w3.org/2000/01/rdf-schema#"
|
|
70
|
+
XSD = "http://www.w3.org/2001/XMLSchema#"
|
|
71
|
+
|
|
72
|
+
RDF_TYPE = f"{RDF}type"
|
triplemodel/_fields.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Field helpers and predicate metadata for RDF mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated, Any, cast, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
from pydantic.fields import FieldInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Predicate:
|
|
14
|
+
"""Marks a model field with its RDF predicate IRI."""
|
|
15
|
+
|
|
16
|
+
uri: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def rdf_field(
|
|
20
|
+
predicate: str,
|
|
21
|
+
*,
|
|
22
|
+
default: Any = ...,
|
|
23
|
+
**field_kwargs: Any,
|
|
24
|
+
) -> Any:
|
|
25
|
+
"""Create a Pydantic field bound to an RDF predicate.
|
|
26
|
+
|
|
27
|
+
Example::
|
|
28
|
+
|
|
29
|
+
name: str = rdf_field("http://xmlns.com/foaf/0.1/name")
|
|
30
|
+
"""
|
|
31
|
+
extra = field_kwargs.pop("json_schema_extra", None) or {}
|
|
32
|
+
if not isinstance(extra, dict):
|
|
33
|
+
extra = {}
|
|
34
|
+
extra = {**extra, "rdf_predicate": predicate}
|
|
35
|
+
return Field(default=default, json_schema_extra=extra, **field_kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def predicate_for_field(field_info: FieldInfo) -> str | None:
|
|
39
|
+
"""Resolve the RDF predicate URI for a Pydantic field, if any."""
|
|
40
|
+
extra = field_info.json_schema_extra
|
|
41
|
+
if isinstance(extra, dict):
|
|
42
|
+
predicate = cast(dict[str, Any], extra).get("rdf_predicate")
|
|
43
|
+
if predicate is not None:
|
|
44
|
+
return str(predicate)
|
|
45
|
+
|
|
46
|
+
for meta in field_info.metadata:
|
|
47
|
+
if isinstance(meta, Predicate):
|
|
48
|
+
return meta.uri
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def predicate_from_annotation(annotation: Any) -> str | None:
|
|
54
|
+
"""Read :class:`Predicate` from ``Annotated[..., Predicate(...)]``."""
|
|
55
|
+
if get_origin(annotation) is not Annotated:
|
|
56
|
+
return None
|
|
57
|
+
for meta in get_args(annotation)[1:]:
|
|
58
|
+
if isinstance(meta, Predicate):
|
|
59
|
+
return meta.uri
|
|
60
|
+
return None
|
triplemodel/_graph.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Build and parse rdflib graphs from Pydantic models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any, Literal, TypeVar, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
from rdflib import Graph, URIRef
|
|
11
|
+
|
|
12
|
+
from triplemodel._config import RDF_TYPE, RdfConfig, get_rdf_config, id_from_subject_uri
|
|
13
|
+
from triplemodel._fields import predicate_for_field, predicate_from_annotation
|
|
14
|
+
from triplemodel._types import python_to_term, term_to_python
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T", bound=BaseModel)
|
|
17
|
+
|
|
18
|
+
OnDuplicate = Literal["ignore", "warn", "error"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def model_to_triples(
|
|
22
|
+
model: BaseModel,
|
|
23
|
+
*,
|
|
24
|
+
uri: str | None = None,
|
|
25
|
+
config: RdfConfig | None = None,
|
|
26
|
+
) -> list[tuple[str, str, Any]]:
|
|
27
|
+
"""Return (subject, predicate, object) tuples for a model instance."""
|
|
28
|
+
cls = type(model)
|
|
29
|
+
cfg = config or get_rdf_config(cls)
|
|
30
|
+
subject = uri or cfg.subject_uri(model)
|
|
31
|
+
triples: list[tuple[str, str, Any]] = []
|
|
32
|
+
|
|
33
|
+
if cfg.type_uri:
|
|
34
|
+
triples.append((subject, RDF_TYPE, cfg.type_uri))
|
|
35
|
+
|
|
36
|
+
id_field = cfg.id_field
|
|
37
|
+
for name, field_info in cls.model_fields.items():
|
|
38
|
+
if id_field and name == id_field:
|
|
39
|
+
continue
|
|
40
|
+
predicate = predicate_for_field(field_info) or predicate_from_annotation(
|
|
41
|
+
field_info.annotation
|
|
42
|
+
)
|
|
43
|
+
if predicate is None:
|
|
44
|
+
continue
|
|
45
|
+
value = getattr(model, name)
|
|
46
|
+
if value is None:
|
|
47
|
+
continue
|
|
48
|
+
triples.append((subject, predicate, value))
|
|
49
|
+
|
|
50
|
+
return triples
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def model_to_graph(
|
|
54
|
+
model: BaseModel,
|
|
55
|
+
graph: Graph | None = None,
|
|
56
|
+
*,
|
|
57
|
+
uri: str | None = None,
|
|
58
|
+
config: RdfConfig | None = None,
|
|
59
|
+
) -> Graph:
|
|
60
|
+
"""Add triples for ``model`` to ``graph`` (or a new graph) and return it."""
|
|
61
|
+
# rdflib Graph() is falsy when empty — must not use `graph or Graph()`.
|
|
62
|
+
g = Graph() if graph is None else graph
|
|
63
|
+
for subj, pred, obj in model_to_triples(model, uri=uri, config=config):
|
|
64
|
+
g.add((URIRef(subj), URIRef(pred), python_to_term(obj)))
|
|
65
|
+
return g
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def models_to_graph(
|
|
69
|
+
models: Sequence[BaseModel],
|
|
70
|
+
graph: Graph | None = None,
|
|
71
|
+
) -> Graph:
|
|
72
|
+
"""Serialize multiple model instances into one graph."""
|
|
73
|
+
g = Graph() if graph is None else graph
|
|
74
|
+
for model in models:
|
|
75
|
+
model_to_graph(model, g)
|
|
76
|
+
return g
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
80
|
+
import types
|
|
81
|
+
from typing import Annotated, Union
|
|
82
|
+
|
|
83
|
+
origin = get_origin(annotation)
|
|
84
|
+
if origin is Annotated:
|
|
85
|
+
return _unwrap_optional(get_args(annotation)[0])
|
|
86
|
+
if origin is None:
|
|
87
|
+
return annotation
|
|
88
|
+
if origin in (Union, types.UnionType):
|
|
89
|
+
non_none = [a for a in get_args(annotation) if a is not type(None)]
|
|
90
|
+
return non_none[0] if len(non_none) == 1 else annotation
|
|
91
|
+
return annotation
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def graph_to_model(
|
|
95
|
+
graph: Graph,
|
|
96
|
+
model_cls: type[T],
|
|
97
|
+
uri: str,
|
|
98
|
+
*,
|
|
99
|
+
config: RdfConfig | None = None,
|
|
100
|
+
validate_type: bool = True,
|
|
101
|
+
on_duplicate: OnDuplicate = "warn",
|
|
102
|
+
) -> T:
|
|
103
|
+
"""Hydrate a single model instance from triples about ``uri``."""
|
|
104
|
+
cfg = config or get_rdf_config(model_cls)
|
|
105
|
+
subject = URIRef(uri)
|
|
106
|
+
|
|
107
|
+
if validate_type and cfg.type_uri:
|
|
108
|
+
type_ref = URIRef(cfg.type_uri)
|
|
109
|
+
if (subject, URIRef(RDF_TYPE), type_ref) not in graph:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Subject {uri!r} does not have rdf:type {cfg.type_uri!r} required by "
|
|
112
|
+
f"{model_cls.__name__}."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
data: dict[str, Any] = {}
|
|
116
|
+
|
|
117
|
+
if cfg.id_field and cfg.namespace:
|
|
118
|
+
extracted = id_from_subject_uri(cfg.namespace, uri)
|
|
119
|
+
if extracted is not None:
|
|
120
|
+
data[cfg.id_field] = extracted
|
|
121
|
+
|
|
122
|
+
for name, field_info in model_cls.model_fields.items():
|
|
123
|
+
if cfg.id_field and name == cfg.id_field:
|
|
124
|
+
continue
|
|
125
|
+
predicate = predicate_for_field(field_info) or predicate_from_annotation(
|
|
126
|
+
field_info.annotation
|
|
127
|
+
)
|
|
128
|
+
if predicate is None:
|
|
129
|
+
continue
|
|
130
|
+
pred_ref = URIRef(predicate)
|
|
131
|
+
objects = list(graph.objects(subject, pred_ref))
|
|
132
|
+
if not objects:
|
|
133
|
+
continue
|
|
134
|
+
if len(objects) > 1:
|
|
135
|
+
dup_msg = (
|
|
136
|
+
f"Multiple objects ({len(objects)}) for field {name!r} "
|
|
137
|
+
f"(predicate {predicate!r}, subject {uri!r}); using the first only "
|
|
138
|
+
f"(multi-value fields planned for 0.2.0)."
|
|
139
|
+
)
|
|
140
|
+
if on_duplicate == "error":
|
|
141
|
+
raise ValueError(dup_msg)
|
|
142
|
+
if on_duplicate == "warn":
|
|
143
|
+
warnings.warn(dup_msg, stacklevel=2)
|
|
144
|
+
# Multi-valued predicates: first object only until 0.2.0.
|
|
145
|
+
target = _unwrap_optional(field_info.annotation)
|
|
146
|
+
py_type = target if isinstance(target, type) else None
|
|
147
|
+
try:
|
|
148
|
+
data[name] = term_to_python(objects[0], py_type)
|
|
149
|
+
except (ValueError, TypeError) as exc:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Cannot convert object for field {name!r} "
|
|
152
|
+
f"(predicate {predicate!r}, subject {uri!r}): {exc}"
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
return model_cls.model_validate(data)
|
|
157
|
+
except ValidationError as exc:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"Cannot validate {model_cls.__name__} from graph for subject {uri!r}: {exc}"
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def graph_to_models(
|
|
164
|
+
graph: Graph,
|
|
165
|
+
model_cls: type[T],
|
|
166
|
+
*,
|
|
167
|
+
type_uri: str | None = None,
|
|
168
|
+
config: RdfConfig | None = None,
|
|
169
|
+
validate_type: bool = True,
|
|
170
|
+
on_duplicate: OnDuplicate = "warn",
|
|
171
|
+
) -> list[T]:
|
|
172
|
+
"""Load all resources of ``type_uri`` (or the model's configured type) as models."""
|
|
173
|
+
cfg = config or get_rdf_config(model_cls)
|
|
174
|
+
rdf_type = type_uri or cfg.type_uri
|
|
175
|
+
if not rdf_type:
|
|
176
|
+
raise ValueError("type_uri is required when the model has no Rdf.type_uri.")
|
|
177
|
+
|
|
178
|
+
instances: list[T] = []
|
|
179
|
+
for subject in graph.subjects(URIRef(RDF_TYPE), URIRef(rdf_type)):
|
|
180
|
+
if isinstance(subject, URIRef):
|
|
181
|
+
instances.append(
|
|
182
|
+
graph_to_model(
|
|
183
|
+
graph,
|
|
184
|
+
model_cls,
|
|
185
|
+
str(subject),
|
|
186
|
+
config=cfg,
|
|
187
|
+
validate_type=validate_type,
|
|
188
|
+
on_duplicate=on_duplicate,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return instances
|
triplemodel/_types.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Convert between Python values and RDF terms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rdflib import BNode, Literal, URIRef, XSD
|
|
10
|
+
from rdflib.term import Node
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def python_to_term(value: Any) -> Node:
|
|
14
|
+
"""Serialize a Python scalar to an RDF term."""
|
|
15
|
+
if isinstance(value, Node):
|
|
16
|
+
return value
|
|
17
|
+
if isinstance(value, bool):
|
|
18
|
+
return Literal(value, datatype=XSD.boolean)
|
|
19
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
20
|
+
return Literal(value, datatype=XSD.integer)
|
|
21
|
+
if isinstance(value, float):
|
|
22
|
+
return Literal(value, datatype=XSD.double)
|
|
23
|
+
if isinstance(value, datetime):
|
|
24
|
+
return Literal(value.isoformat(), datatype=XSD.dateTime)
|
|
25
|
+
if isinstance(value, date):
|
|
26
|
+
return Literal(value.isoformat(), datatype=XSD.date)
|
|
27
|
+
if isinstance(value, str):
|
|
28
|
+
if _looks_like_iri(value):
|
|
29
|
+
return URIRef(value)
|
|
30
|
+
return Literal(value, datatype=XSD.string)
|
|
31
|
+
return Literal(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def term_to_python(term: Node, target_type: type | None = None) -> Any:
|
|
35
|
+
"""Deserialize an RDF term to a Python value."""
|
|
36
|
+
if isinstance(term, URIRef):
|
|
37
|
+
return str(term)
|
|
38
|
+
|
|
39
|
+
if not isinstance(term, Literal):
|
|
40
|
+
if isinstance(term, BNode) and target_type is str:
|
|
41
|
+
raise TypeError(
|
|
42
|
+
"BNode objects cannot be assigned to str fields; "
|
|
43
|
+
"blank node support is planned for a future release."
|
|
44
|
+
)
|
|
45
|
+
return term
|
|
46
|
+
|
|
47
|
+
if target_type is bool:
|
|
48
|
+
if term.datatype == XSD.boolean:
|
|
49
|
+
return bool(term.toPython())
|
|
50
|
+
return term.value in (True, "true", "1", 1)
|
|
51
|
+
if target_type is int:
|
|
52
|
+
return int(term)
|
|
53
|
+
if target_type is float:
|
|
54
|
+
return float(term)
|
|
55
|
+
if target_type is datetime:
|
|
56
|
+
return datetime.fromisoformat(str(term))
|
|
57
|
+
if target_type is date:
|
|
58
|
+
return date.fromisoformat(str(term))
|
|
59
|
+
|
|
60
|
+
return term.toPython()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _looks_like_iri(value: str) -> bool:
|
|
67
|
+
"""True when ``value`` has an RFC 3986 scheme (e.g. http:, mailto:, file:)."""
|
|
68
|
+
return bool(_SCHEME_RE.match(value))
|
triplemodel/model.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Base Pydantic model with RDF serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing_extensions import Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
from rdflib import Graph
|
|
9
|
+
|
|
10
|
+
from triplemodel._config import RdfConfig, get_rdf_config
|
|
11
|
+
from triplemodel._graph import (
|
|
12
|
+
OnDuplicate,
|
|
13
|
+
graph_to_model,
|
|
14
|
+
graph_to_models,
|
|
15
|
+
model_to_graph,
|
|
16
|
+
model_to_triples,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TripleModel(BaseModel):
|
|
21
|
+
"""Pydantic model that can be serialized to and from an RDF graph.
|
|
22
|
+
|
|
23
|
+
Subclasses declare RDF metadata on a nested ``Rdf`` class and map fields
|
|
24
|
+
with :func:`~triplemodel.rdf_field` or ``Annotated[..., Predicate(...)]``.
|
|
25
|
+
A nested ``Rdf`` on a subclass **replaces** the parent's config entirely;
|
|
26
|
+
do not declare an empty ``class Rdf:`` on a child if you intend to inherit
|
|
27
|
+
the parent's ``namespace``, ``type_uri``, or ``id_field``.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
class Person(TripleModel):
|
|
32
|
+
class Rdf:
|
|
33
|
+
namespace = "http://example.org/people/"
|
|
34
|
+
type_uri = "http://xmlns.com/foaf/0.1/Person"
|
|
35
|
+
id_field = "slug"
|
|
36
|
+
|
|
37
|
+
slug: str
|
|
38
|
+
name: str = rdf_field("http://xmlns.com/foaf/0.1/name")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(
|
|
42
|
+
validate_assignment=True,
|
|
43
|
+
str_strip_whitespace=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def subject_uri(self, *, uri: str | None = None) -> str:
|
|
47
|
+
"""Return the RDF subject IRI for this instance."""
|
|
48
|
+
if uri is not None:
|
|
49
|
+
return uri
|
|
50
|
+
return get_rdf_config(type(self)).subject_uri(self)
|
|
51
|
+
|
|
52
|
+
def to_triples(self, *, uri: str | None = None) -> list[tuple[str, str, object]]:
|
|
53
|
+
"""Export instance data as (subject, predicate, object) tuples."""
|
|
54
|
+
return model_to_triples(self, uri=uri)
|
|
55
|
+
|
|
56
|
+
def to_graph(self, graph: Graph | None = None, *, uri: str | None = None) -> Graph:
|
|
57
|
+
"""Serialize this instance into an rdflib ``Graph``."""
|
|
58
|
+
return model_to_graph(self, graph, uri=uri)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_graph(
|
|
62
|
+
cls,
|
|
63
|
+
graph: Graph,
|
|
64
|
+
uri: str,
|
|
65
|
+
*,
|
|
66
|
+
validate_type: bool = True,
|
|
67
|
+
on_duplicate: OnDuplicate = "warn",
|
|
68
|
+
) -> Self:
|
|
69
|
+
"""Construct an instance from triples about ``uri``."""
|
|
70
|
+
return graph_to_model(
|
|
71
|
+
graph,
|
|
72
|
+
cls,
|
|
73
|
+
uri,
|
|
74
|
+
validate_type=validate_type,
|
|
75
|
+
on_duplicate=on_duplicate,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def all_from_graph(
|
|
80
|
+
cls,
|
|
81
|
+
graph: Graph,
|
|
82
|
+
*,
|
|
83
|
+
type_uri: str | None = None,
|
|
84
|
+
validate_type: bool = True,
|
|
85
|
+
on_duplicate: OnDuplicate = "warn",
|
|
86
|
+
) -> list[Self]:
|
|
87
|
+
"""Load every resource of this model's RDF type from ``graph``."""
|
|
88
|
+
return graph_to_models(
|
|
89
|
+
graph,
|
|
90
|
+
cls,
|
|
91
|
+
type_uri=type_uri,
|
|
92
|
+
validate_type=validate_type,
|
|
93
|
+
on_duplicate=on_duplicate,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def rdf_config(cls) -> RdfConfig:
|
|
98
|
+
"""Return resolved RDF configuration for this model class."""
|
|
99
|
+
return get_rdf_config(cls)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = ["TripleModel"]
|
triplemodel/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: triplemodel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pydantic TripleModel classes ↔ RDF triples via rdflib (alpha). SparqlModel ORM integration planned from 0.2.
|
|
5
|
+
Project-URL: Homepage, https://github.com/eddiethedean/triplemodel
|
|
6
|
+
Project-URL: Documentation, https://github.com/eddiethedean/triplemodel#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/eddiethedean/triplemodel
|
|
8
|
+
Project-URL: Changelog, https://github.com/eddiethedean/triplemodel/blob/main/CHANGELOG.md
|
|
9
|
+
Author: TripleModel contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: linked-data,pydantic,rdf,rdflib,semantic-web
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
25
|
+
Requires-Dist: rdflib<8,>=7.0
|
|
26
|
+
Requires-Dist: typing-extensions>=4.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
31
|
+
Requires-Dist: ty>=0.0.1; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# TripleModel
|
|
35
|
+
|
|
36
|
+
[](https://github.com/eddiethedean/triplemodel/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/eddiethedean/triplemodel)
|
|
38
|
+
[](https://github.com/eddiethedean/triplemodel/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
**Pydantic models for RDF graphs.** Map typed Python classes to [rdflib](https://github.com/RDFLib/rdflib) triples and back — without hand-writing `graph.add` for every field.
|
|
41
|
+
|
|
42
|
+
| | |
|
|
43
|
+
|--|--|
|
|
44
|
+
| PyPI / import | `triplemodel` |
|
|
45
|
+
| Base class | `TripleModel` |
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
Person(slug="alice", name="Alice") → (ex:alice, foaf:name, "Alice") → Person(...)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**TripleModel** is the **typed mapping layer** in a small ecosystem: Pydantic models ↔ RDF triples via field types and predicates. [SparqlModel](https://github.com/eddiethedean/sqarqlmodel) (session, SPARQL queries, ORM) is planned to depend on TripleModel from **0.2** — see the [ecosystem guide](https://github.com/eddiethedean/triplemodel/blob/main/docs/ECOSYSTEM.md).
|
|
52
|
+
|
|
53
|
+
> **0.1.0 is alpha.** The API may change until 1.0. See [CHANGELOG](https://github.com/eddiethedean/triplemodel/blob/main/CHANGELOG.md) and the [roadmap](https://github.com/eddiethedean/triplemodel/blob/main/docs/ROADMAP.md).
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- **Pydantic v2** models with `validate_assignment=True`
|
|
58
|
+
- **Declarative mapping** — nested `Rdf` config + `rdf_field()` or `Annotated[..., Predicate(...)]`
|
|
59
|
+
- **Subject IRIs** — build from `namespace` + `id_field`, percent-encoded segments, safe import (no prefix collisions)
|
|
60
|
+
- **XSD round-trip** — `str`, `int`, `float`, `bool`, `date`, `datetime`; IRI-like strings → `URIRef`
|
|
61
|
+
- **Stateless I/O** — `to_graph` / `from_graph` / `all_from_graph` / `models_to_graph` on in-memory `Graph`
|
|
62
|
+
- **Typed package** — `py.typed` for type checkers
|
|
63
|
+
|
|
64
|
+
**Not in 0.1.0** (on the [roadmap](https://github.com/eddiethedean/triplemodel/blob/main/docs/ROADMAP.md)): file parse/serialize, multi-valued fields, nested models, sync/remove, SPARQL helpers.
|
|
65
|
+
|
|
66
|
+
## Requirements
|
|
67
|
+
|
|
68
|
+
- Python **3.10+**
|
|
69
|
+
- [Pydantic](https://docs.pydantic.dev/) v2
|
|
70
|
+
- [rdflib](https://rdflib.readthedocs.io/) v7
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install triplemodel
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from triplemodel import TripleModel, rdf_field
|
|
82
|
+
|
|
83
|
+
FOAF = "http://xmlns.com/foaf/0.1/"
|
|
84
|
+
|
|
85
|
+
class Person(TripleModel):
|
|
86
|
+
class Rdf:
|
|
87
|
+
namespace = "http://example.org/people/"
|
|
88
|
+
type_uri = f"{FOAF}Person"
|
|
89
|
+
id_field = "slug"
|
|
90
|
+
|
|
91
|
+
slug: str
|
|
92
|
+
name: str = rdf_field(f"{FOAF}name")
|
|
93
|
+
age: int | None = rdf_field(f"{FOAF}age", default=None)
|
|
94
|
+
|
|
95
|
+
alice = Person(slug="alice", name="Alice", age=30)
|
|
96
|
+
|
|
97
|
+
graph = alice.to_graph()
|
|
98
|
+
print(alice.subject_uri()) # http://example.org/people/alice
|
|
99
|
+
|
|
100
|
+
assert Person.from_graph(graph, alice.subject_uri()) == alice
|
|
101
|
+
assert len(Person.all_from_graph(graph)) == 1
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Concepts
|
|
105
|
+
|
|
106
|
+
### RDF metadata (`class Rdf`)
|
|
107
|
+
|
|
108
|
+
| Attribute | Role |
|
|
109
|
+
|-----------|------|
|
|
110
|
+
| `namespace` | Base IRI for subject resources |
|
|
111
|
+
| `type_uri` | Emitted as `rdf:type`; used to filter `all_from_graph()` |
|
|
112
|
+
| `id_field` | Field value appended to `namespace` for the subject IRI |
|
|
113
|
+
|
|
114
|
+
Subject IRIs use `subject_base(namespace)` + percent-encoded id (`quote` / `unquote`). Override the subject IRI per call with `uri=` (round-trip works when the URI still matches `namespace`):
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
alice = Person(slug="alice", name="Alice")
|
|
118
|
+
custom_uri = "http://example.org/people/alice"
|
|
119
|
+
graph = alice.to_graph(uri=custom_uri)
|
|
120
|
+
assert Person.from_graph(graph, custom_uri) == alice
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Shared helpers (also on the package root):
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from triplemodel import id_from_subject_uri, subject_base
|
|
127
|
+
|
|
128
|
+
base = subject_base("http://example.org/people") # ensures trailing / or #
|
|
129
|
+
id_from_subject_uri("http://example.org/people", "http://example.org/people/alice") # "alice"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Field → predicate
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
name: str = rdf_field("http://xmlns.com/foaf/0.1/name")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Or with **`Annotated`**:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from typing import Annotated
|
|
142
|
+
from triplemodel import Predicate
|
|
143
|
+
|
|
144
|
+
title: Annotated[str, Predicate("http://purl.org/dc/terms/title")]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Fields **without** a predicate mapping are skipped on export and import (handy for computed or app-only fields).
|
|
148
|
+
|
|
149
|
+
Subclasses **inherit** a parent’s nested `Rdf` class when the child does not define `Rdf`. If the child declares `class Rdf:`, it **replaces** the parent’s config entirely — do not use an empty nested `Rdf` on a subclass.
|
|
150
|
+
|
|
151
|
+
### Term conversion
|
|
152
|
+
|
|
153
|
+
| Python | RDF (export) |
|
|
154
|
+
|--------|----------------|
|
|
155
|
+
| `str` (not IRI-like) | `xsd:string` literal |
|
|
156
|
+
| `str` with an RFC 3986 scheme (`http:`, `https:`, `urn:`, `mailto:`, `file:`, …) | `URIRef` |
|
|
157
|
+
| `int`, `float`, `bool`, `date`, `datetime` | XSD-typed literal |
|
|
158
|
+
|
|
159
|
+
Import uses each field’s type annotation. `BNode` objects cannot be coerced into `str` fields.
|
|
160
|
+
|
|
161
|
+
## API reference
|
|
162
|
+
|
|
163
|
+
### `TripleModel` methods
|
|
164
|
+
|
|
165
|
+
| | Method | Description |
|
|
166
|
+
|---|--------|-------------|
|
|
167
|
+
| Instance | `subject_uri(uri=None)` | Subject IRI |
|
|
168
|
+
| Instance | `to_triples(uri=None)` | `(subject, predicate, object)` tuples |
|
|
169
|
+
| Instance | `to_graph(graph=None, uri=None)` | Serialize into a `Graph` |
|
|
170
|
+
| Class | `from_graph(graph, uri, validate_type=True, on_duplicate="warn")` | Load one resource |
|
|
171
|
+
| Class | `all_from_graph(graph, type_uri=None, validate_type=True, on_duplicate="warn")` | Load all resources of this `type_uri` |
|
|
172
|
+
| Class | `rdf_config()` | Resolved `RdfConfig` |
|
|
173
|
+
|
|
174
|
+
### Module-level API
|
|
175
|
+
|
|
176
|
+
| Name | Description |
|
|
177
|
+
|------|-------------|
|
|
178
|
+
| `rdf_field`, `Predicate`, `OnDuplicate` | Predicate metadata; duplicate-import policy type |
|
|
179
|
+
| `RdfConfig`, `TripleModel` | Config dataclass and base model |
|
|
180
|
+
| `model_to_graph`, `model_to_triples`, `models_to_graph` | Export without subclassing |
|
|
181
|
+
| `graph_to_model`, `graph_to_models` | Import into a model class |
|
|
182
|
+
| `subject_base`, `id_from_subject_uri` | Subject IRI building and parsing |
|
|
183
|
+
| `RDF`, `RDFS`, `XSD`, `RDF_TYPE` | Common namespace IRIs |
|
|
184
|
+
|
|
185
|
+
## Examples
|
|
186
|
+
|
|
187
|
+
### Batch export into one graph
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from rdflib import Graph
|
|
191
|
+
from triplemodel import TripleModel, models_to_graph, rdf_field
|
|
192
|
+
|
|
193
|
+
FOAF = "http://xmlns.com/foaf/0.1/"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Person(TripleModel):
|
|
197
|
+
class Rdf:
|
|
198
|
+
namespace = "http://example.org/people/"
|
|
199
|
+
type_uri = f"{FOAF}Person"
|
|
200
|
+
id_field = "slug"
|
|
201
|
+
|
|
202
|
+
slug: str
|
|
203
|
+
name: str = rdf_field(f"{FOAF}name")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
people = [
|
|
207
|
+
Person(slug="alice", name="Alice"),
|
|
208
|
+
Person(slug="bob", name="Bob"),
|
|
209
|
+
]
|
|
210
|
+
graph = models_to_graph(people)
|
|
211
|
+
|
|
212
|
+
# Or merge into an existing graph (rdflib Graph() is falsy when empty — pass explicitly)
|
|
213
|
+
existing = Graph()
|
|
214
|
+
models_to_graph(people, existing)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Encoded subject ids
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from triplemodel import TripleModel, rdf_field
|
|
221
|
+
|
|
222
|
+
FOAF = "http://xmlns.com/foaf/0.1/"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class Person(TripleModel):
|
|
226
|
+
class Rdf:
|
|
227
|
+
namespace = "http://example.org/people/"
|
|
228
|
+
type_uri = f"{FOAF}Person"
|
|
229
|
+
id_field = "slug"
|
|
230
|
+
|
|
231
|
+
slug: str
|
|
232
|
+
name: str = rdf_field(f"{FOAF}name")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
bob = Person(slug="bob jones", name="Bob")
|
|
236
|
+
uri = bob.subject_uri() # .../bob%20jones
|
|
237
|
+
restored = Person.from_graph(bob.to_graph(), uri)
|
|
238
|
+
assert restored == bob
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## TripleModel vs SparqlModel
|
|
242
|
+
|
|
243
|
+
| Need | Use |
|
|
244
|
+
|------|-----|
|
|
245
|
+
| Turn a model instance into triples / load from a `Graph` | **TripleModel** (`pip install triplemodel`) |
|
|
246
|
+
| Turtle/JSON-LD files, namespaces, datasets (roadmap) | **TripleModel** |
|
|
247
|
+
| `session.put`, queries, cascade delete, HTTP store | **[SparqlModel](https://github.com/eddiethedean/sqarqlmodel)** |
|
|
248
|
+
|
|
249
|
+
Details: [project plan](https://github.com/eddiethedean/triplemodel/blob/main/docs/PLAN.md) · [ecosystem guide](https://github.com/eddiethedean/triplemodel/blob/main/docs/ECOSYSTEM.md).
|
|
250
|
+
|
|
251
|
+
## Limitations (0.1.x)
|
|
252
|
+
|
|
253
|
+
- **Single value per predicate** — multiple objects import only the first; a warning is emitted by default (`on_duplicate="warn"`). Full multi-value fields land in [0.2.0](https://github.com/eddiethedean/triplemodel/blob/main/docs/ROADMAP.md).
|
|
254
|
+
- **Flat models** — no nested `TripleModel` or RDF lists yet.
|
|
255
|
+
- **In-memory graphs only** — no `parse` / `serialize` until [0.4.0](https://github.com/eddiethedean/triplemodel/blob/main/docs/ROADMAP.md).
|
|
256
|
+
- **No sync/remove** — re-export does not drop triples for cleared fields until [0.2.0](https://github.com/eddiethedean/triplemodel/blob/main/docs/ROADMAP.md).
|
|
257
|
+
- **`from_graph` type check** — when `Rdf.type_uri` is set, import requires that triple unless `validate_type=False`.
|
|
258
|
+
- **`uri=` override** — `from_graph` can only derive `id_field` when the subject URI is under `Rdf.namespace`; off-namespace URIs fail validation unless you add triples another way.
|
|
259
|
+
- **Empty child `class Rdf:`** — shadows the parent and clears `namespace` / `type_uri` / `id_field`; omit `Rdf` on the child to inherit.
|
|
260
|
+
- **`type_uri=""` or other falsy config** — treated as unset (no `rdf:type` on export, no type filter on import).
|
|
261
|
+
- **`id_from_subject_uri`** — returns the URI suffix after the namespace base (may include extra `/` segments); not a single-segment validator.
|
|
262
|
+
- **`id_field` values `False` or `0`** — are valid ids (not treated as empty).
|
|
263
|
+
- **BNode subjects** — skipped in `all_from_graph()`.
|
|
264
|
+
- **Non-XSD boolean literals** — `bool` fields without `xsd:boolean` use a loose truthiness heuristic on import.
|
|
265
|
+
- **Union field types** (e.g. `str | int`) rely on rdflib `toPython()` when the annotation is not a single scalar type.
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
git clone https://github.com/eddiethedean/triplemodel.git
|
|
271
|
+
cd triplemodel
|
|
272
|
+
python -m venv .venv
|
|
273
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
274
|
+
pip install -e ".[dev]"
|
|
275
|
+
pytest
|
|
276
|
+
ruff format src tests && ruff check src tests
|
|
277
|
+
ty check src tests
|
|
278
|
+
PYTHONPATH=src python examples/readme_examples.py
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
CI runs on Python 3.10, 3.11, 3.12, and 3.13. Release steps: [RELEASING.md](https://github.com/eddiethedean/triplemodel/blob/main/RELEASING.md).
|
|
282
|
+
|
|
283
|
+
## Documentation
|
|
284
|
+
|
|
285
|
+
| Doc | Description |
|
|
286
|
+
|-----|-------------|
|
|
287
|
+
| [CHANGELOG](https://github.com/eddiethedean/triplemodel/blob/main/CHANGELOG.md) | Release notes |
|
|
288
|
+
| [Roadmap](https://github.com/eddiethedean/triplemodel/blob/main/docs/ROADMAP.md) | Versions and rdflib parity |
|
|
289
|
+
| [Plan](https://github.com/eddiethedean/triplemodel/blob/main/docs/PLAN.md) | Strategy and priorities |
|
|
290
|
+
| [Ecosystem](https://github.com/eddiethedean/triplemodel/blob/main/docs/ECOSYSTEM.md) | triplemodel ↔ SparqlModel boundaries |
|
|
291
|
+
|
|
292
|
+
## License
|
|
293
|
+
|
|
294
|
+
MIT — see [LICENSE](https://github.com/eddiethedean/triplemodel/blob/main/LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
triplemodel/__init__.py,sha256=4vlR2emen_9IUBZb3bh-czNFlDeYnyypgGIPrzy-i_4,855
|
|
2
|
+
triplemodel/_config.py,sha256=ShND_M2hbznWXBw2GwNbFCXMU3dkwy6njP0lwd91lSg,2421
|
|
3
|
+
triplemodel/_fields.py,sha256=vgQ_GwZSIDKVt-wnzhrUjMLN5jX6UYvNJZmLzUCXNTY,1665
|
|
4
|
+
triplemodel/_graph.py,sha256=3-Pwe5r9FFlpK5xasmTH7tM5Rv49v0eGcDacp4q4q78,6294
|
|
5
|
+
triplemodel/_types.py,sha256=cOFDt8ZAH0lzTbeINIMOSipZ8MyQvuyQZ9wcktAYPP0,2193
|
|
6
|
+
triplemodel/model.py,sha256=6aJ5EnIWXwRwuirPTko2SE0i94VBMMI7DIr9DsGj8zs,3060
|
|
7
|
+
triplemodel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
triplemodel-0.1.0.dist-info/METADATA,sha256=7dcGR5pFV4aVzqDDEpstPaPpQ9q6tkdz1mmiB4nL_sQ,12044
|
|
9
|
+
triplemodel-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
triplemodel-0.1.0.dist-info/licenses/LICENSE,sha256=dR0Xh1E79TnwCBAWMMOKGrPofjGpAkfiAP1jmYWQ3pg,1081
|
|
11
|
+
triplemodel-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TripleModel contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|