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.
@@ -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
+ [![CI](https://github.com/eddiethedean/triplemodel/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/triplemodel/actions/workflows/ci.yml)
37
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://github.com/eddiethedean/triplemodel)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.