Skip to main content

kapps-ogm

An object-graph mapper for RDF. It maps between instance data in a triplestore and pydantic models, in both directions.

What it does

The pydantic model's shape is derived from the ontology, not written by hand. You give it a class IRI; it reads that class's declared properties out of the store — the rdfs:range declarations and OWL restrictions attached to them — and builds the model from what it finds.

That is the whole point. The alternative is a pydantic model written in Python that mirrors an ontology, and mirrors it correctly only until somebody edits one of the two. Nothing detects the drift: the model keeps validating, against a shape the ontology no longer describes. Here there is one source of truth, and it is the store.

Installation

pip install kapps-ogm

Python 3.12 or later. You also need a reachable GraphDB triplestore — this library maps to and from one, it does not embed one. The client is kapps-triplestore-interface, which installs as a dependency; you do not need to install it yourself.

A round-trip example

Write an instance, read it back, and compare. Nothing here names a host: the connection comes from the environment.

import os

from kapps_triplestore_interface import IRI, GraphDB, GraphDBCredentials

from kapps_ogm.ogm import OGM
from kapps_ogm.utils.class_scope import ClassScope

ONTOLOGY = "https://example.org/ontology#"

credentials = GraphDBCredentials(
    base_url=os.environ["GRAPHDB_URL"],
    username=os.environ["GRAPHDB_USERNAME"],
    password=os.environ["GRAPHDB_PASSWORD"],
    repository=os.environ["GRAPHDB_REPOSITORY"],
)
ogm = OGM(db=GraphDB(credentials=credentials))

# How deep to go. An RDF graph has no natural boundary, so the caller states which
# paths to follow out from the root class; everything else is left in the store.
#
# Each chain runs from the root class down to a property you want, so a nested node
# needs its own properties named. `[[hasComponent]]` alone would put an empty scope
# under hasComponent, and a nested class with an empty scope is not hydrated at all --
# hasName and isActive would be unknown properties, dropped with a warning.
class_scope = ClassScope.from_property_chains(
    [
        [IRI(f"{ONTOLOGY}hasComponent"), IRI(f"{ONTOLOGY}hasName")],
        [IRI(f"{ONTOLOGY}hasComponent"), IRI(f"{ONTOLOGY}isActive")],
    ]
)

data = {
    f"{ONTOLOGY}hasComponent": [
        {
            f"{ONTOLOGY}hasName": ["Component A"],
            f"{ONTOLOGY}isActive": [True],
        },
    ],
}

node = ogm.create(
    class_iri=IRI(f"{ONTOLOGY}Assembly"),
    class_scope=class_scope,
    data=data,
    persist=True,
)

fetched = ogm.fetch(
    instance_iri=node.id,
    class_scope=class_scope,
    materialize=True,
)

assert fetched.to_json_ld() == node.to_json_ld()

create returns a Node — the mapped instance, carrying .id and .data. To get the pydantic model itself, call .materialize():

model = fetched.materialize()   # a pydantic BaseModel, validated against the derived shape
print(model.model_dump())

Changing several instances in one write

commit updates one instance. To change several instances together, give it changes: a list of Create and Update entries, each for a different instance. They are written as one DELETE/INSERT, so the store admits all of them or none of them. If the store refuses one change, for example on a SHACL shape, the call raises the store's error and writes nothing.

Here a new inspection record is created, and an existing workpiece points at it, in one write:

from kapps_ogm import Create, Update

inspection = IRI(f"{ONTOLOGY}inspection_7")

ogm.commit(
    changes=[
        Create(class_iri=IRI(f"{ONTOLOGY}Inspection"), instance_iri=inspection, data={}),
        Update(
            instance_iri=IRI(f"{ONTOLOGY}workpiece_1"),
            data={f"{ONTOLOGY}hasLastInspection": [{"id": str(inspection)}]},
        ),
    ]
)

An Update means what a commit of one instance means: each property in data replaces the stored values of that property, and the other properties keep theirs. So an Update lists every value that a property keeps, and a value it does not list is removed. To add a value to a property that already has values, read the stored values and list them too. A value that another writer adds between that read and the commit is then removed, and nothing reports it.

Each instance gets at most one change in a call. This includes a named instance that a change nests with properties in its data, because the change writes about it too. Two changes that write one instance would each apply their own diff to it, so the call raises ValueError before it writes. Types do not count, so a Create can link another new instance of the same call.

A Create refers to a new instance only, as create does: see below. Give a Create its instance_iri when another change refers to it.

What create and commit promise

A normal return of create or commit means that the store admitted the write: it found the precondition of the write true, and it committed the change. If the precondition is false, the call raises PreconditionFailedError from kapps_triplestore_interface.exceptions, and nothing of the call is written. The error comes unchanged from the triple store interface. The store checks the precondition in the same update request that makes the write.

A new instance only. create, and a Create in commit, write only new instances. The store refuses the write if the target graph holds an explicit triple whose subject is an instance that the call creates. The target graph is named_graph if you give one. An IRI whose triples are only in another graph counts as new there.

Links. The data decides what a call creates. A nested value with properties is an instance of its own, and the call creates it with its parent. A nested value given by its IRI alone, as {"id": iri} with no other key or as an IRI, is a link. The call writes only the triple that points at it, and nothing about the linked instance, even when the class scope hydrates its class. So a link can point at an instance that exists. A SHACL shape decides whether the store admits it.

# m2 is new. obj2 exists, and the call writes nothing about it.
ogm.create(
    class_iri=IRI(f"{ONTOLOGY}Measurement"),
    instance_iri=IRI(f"{ONTOLOGY}m2"),
    data={
        f"{ONTOLOGY}value": [1.5],
        f"{ONTOLOGY}measuredOn": [{"id": f"{ONTOLOGY}obj2"}],
    },
)

What commit removes. commit reads the stored instance first, inferred triples included. The triples that the new data no longer holds are the triples to remove. A delete removes only explicit triples, so the store checks each triple to remove against the explicit triples of the target graph. The call raises if one is not there. Examples are a value that the store only infers, a value that another writer changed after the call read it, and a named_graph that does not hold the instance. If the data changes nothing, the call sends nothing, because there is nothing to admit.

Concurrent writers. The store isolates commits at the level "read committed". Another write can commit between the check of a write and its own commit. If an invariant must hold while several writers change the store, state it as a SHACL shape. The store checks the shapes when it commits each write.

Known limit. A triple that is both explicit and inferred passes the check. After the commit it is still visible, because a delete clears only the explicit triple, and the inferred one stays.

What the ontology has to declare

The mapper reads shapes out of the store, so a class whose properties are undeclared maps to an empty shape — it does not guess. Each property a model should carry needs an rdfs:range, an OWL restriction, or both.

Two rules are worth knowing before you write the TBox:

  • Several rdfs:range assertions on one property are read conjunctively — as their intersection, which is what RDFS entails. A value must satisfy all of them. Declaring two is not an error.
  • Ranges are inherited through rdfs:subPropertyOf. A range declared on a superproperty reaches every subproperty, and the chain is walked transitively. This is not something a reasoner materialises for you; the mapper resolves it explicitly.

Where two declarations genuinely conflict — incompatible datatypes, or a datatype target against a class target — that raises at specify time and names the property, rather than failing later inside pydantic where the ontology cannot be named.

Anonymous nodes

A nested node with no IRI of its own still needs an identity, or a write followed by a read returns a different node than the one written. Such nodes are given a Skolem IRI, minted under a /.well-known/genid/ path as RDF 1.1 Concepts §3.5 provides for, so that a third party reading the graph can tell the IRI stands in for a blank node.

The minting authority is an ontology-governance decision rather than this library's, so the namespace is configurable per instance and the default is a documented placeholder:

ogm = OGM(db=db, skolem_namespace="https://example.org/.well-known/genid/")

The /.well-known/genid/ path itself is not configurable. A namespace without it would mint addresses this library could no longer recognise as Skolem IRIs, which would silently disable the guard rather than fail, so it is rejected at construction.

Contributing

This repository is a publish target. It receives one commit per release, cut from a private development repository, so a pull request opened here has no shared history to merge into and cannot be merged. Please open an issue instead.

License

The package is licensed under the MIT license.

Acknowledgements

This package is developed as part of the INF subproject of the CRC 1574: Circular Factory for the Perpetual Product. This work is therefore supported by the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) [grant-number: SFB-1574-471687386]

Metadata

Release files for kapps-ogm 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for kapps-ogm 0.3.0
File Size Uploaded
kapps_ogm-0.3.0.tar.gz 154.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for kapps-ogm 0.3.0
File Interpreter ABI Platform
kapps_ogm-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 208.4 kB

Release files / kapps_ogm-0.3.0.tar.gz

Download URL kapps_ogm-0.3.0.tar.gz
Size 154.7 kB
Tags Source
SHA-256 checksum
How to use checksums
0877fa55be4a92340650d5485f0ac572a9bf9535fe867185a0b67402b65f50e9
BLAKE2b-256 checksum
How to use checksums
9cc13317504955e1f37c32ae5ed831737efaa3394800685f117eb79877944309
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release files / kapps_ogm-0.3.0-py3-none-any.whl

Download URL kapps_ogm-0.3.0-py3-none-any.whl
Size 53.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
73238d65ebbb1cc4e4e6c139b3a995c59392e8b69c6c226b7541ddc3965cabeb
BLAKE2b-256 checksum
How to use checksums
a8683b7c04acbe2c1c6b1a6f518c9459f67de93def452e5194572fac5a7f0617
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page