orchestrata_ultimate.domaingen.reader
Reference › orchestrata_ultimate › domaingen › reader
reader reads an OWL or Turtle ontology into the domain-model IR (SPEC-105 FR-001) using rdflib. parse_ontology accepts either a file path or raw text and returns a DomainModel from models. _parse_graph loads the source into a graph. _build_entities creates an Entity per class IRI, capturing its documentation and parent (REQ-105-019). _attach_datatype_properties and _attach_object_properties attach OWL datatype properties as fields and object properties as relationships on their domain class. _apply_restrictions marks fields and relationships as required where an OWL restriction sets a minimum cardinality (REQ-105-017), using _restriction_required_prop. _namespace_of derives the common namespace of the class IRIs. Entities, fields and relationships are sorted by name so the IR, and everything generated from it, is stable. An ontology that cannot be parsed raises OntologyParseError (ERR-105-001). The package imports it, and its output feeds generator and ttl.
At a glance
| Source | src/orchestrata_ultimate/domaingen/reader.py |
| Lines of code | 213 |
| Classes | 1 |
| Functions | 9 |
| Imports (internal) | orchestrata_ultimate.core.base.error, orchestrata_ultimate.core.traceability, orchestrata_ultimate.domaingen.models |
| Imported by | orchestrata_ultimate.domaingen |
| Tests that import it | none |
Class diagram
Dependency diagram
API
Classes
OntologyParseError
Bases: BaseError · Decorators: traces(spec='105', story='US-105-001', reqs=['REQ-105-024']) · Line 30
Raised when an ontology cannot be parsed (ERR-105-001).
Functions
| Function | Signature | Summary |
|---|---|---|
_local traces |
_local(iri: object) -> str |
|
_namespace_of traces |
_namespace_of(iris: list[str]) -> str |
Common namespace prefix (up to '#' or final '/') of the class IRIs. |
_restriction_required_prop traces |
_restriction_required_prop(graph: Graph, restr: Node) -> str | None |
Return the property local-name a required owl:Restriction targets, else None. |
_apply_restrictions traces |
_apply_restrictions(graph: Graph, by_iri: dict[URIRef, Entity]) -> None |
Mark fields/relationships required from owl:Restriction minCardinality (REQ-105-017). |
_parse_graph traces |
_parse_graph(source: str | Path) -> Graph |
Parse an OWL/Turtle source (file path or raw text) into an rdflib Graph. |
_build_entities traces |
_build_entities(graph: Graph, classes: set[URIRef]) -> dict[URIRef, Entity] |
Build an Entity per class IRI, capturing doc and parent (REQ-105-019). |
_attach_datatype_properties traces |
_attach_datatype_properties(graph: Graph, by_iri: dict[URIRef, Entity]) -> None |
Attach owl:DatatypeProperty declarations as fields on their domain entity. |
_attach_object_properties traces |
_attach_object_properties(graph: Graph, by_iri: dict[URIRef, Entity]) -> None |
Attach owl:ObjectProperty declarations as relationships on their domain. |
parse_ontology traces |
parse_ontology(source: str | Path) -> DomainModel |
Parse an OWL/Turtle ontology (file path or raw text) into a DomainModel. |
Source
"""Ontology (OWL / Turtle) -> DomainModel IR via rdflib (SPEC-105 FR-001).
Deterministic: entities, fields and relationships are sorted by name so the IR
(and everything generated from it) is stable.
"""
from __future__ import annotations
import logging
from pathlib import Path
from rdflib import OWL, RDF, RDFS, Graph, URIRef
from rdflib.term import Node
from orchestrata_ultimate.core.base.error import BaseError
from orchestrata_ultimate.core.traceability import traces
from orchestrata_ultimate.domaingen.models import (
XSD_TO_PY,
DomainModel,
Entity,
Field,
Module,
Relationship,
)
logger = logging.getLogger(__name__)
@traces(spec="105", story="US-105-001", reqs=["REQ-105-024"])
class OntologyParseError(BaseError):
"""Raised when an ontology cannot be parsed (ERR-105-001)."""
@traces(spec="105", story="US-105-001", reqs=["REQ-105-020"])
def _local(iri: object) -> str:
s = str(iri)
if "#" in s:
return s.rsplit("#", 1)[1]
return s.rstrip("/").rsplit("/", 1)[-1]
@traces(spec="105", story="US-105-001", reqs=["REQ-105-023"])
def _namespace_of(iris: list[str]) -> str:
"""Common namespace prefix (up to '#' or final '/') of the class IRIs."""
for s in iris:
if "#" in s:
return s.rsplit("#", 1)[0] + "#"
if iris:
return iris[0].rstrip("/").rsplit("/", 1)[0] + "/"
return "https://example.org/domain#"
@traces(spec="105", story="US-105-001", reqs=["REQ-105-017"])
def _restriction_required_prop(graph: Graph, restr: Node) -> str | None:
"""Return the property local-name a required owl:Restriction targets, else None.
A restriction marks a property required when it declares minCardinality (or
cardinality) >= 1 on an onProperty. Returns ``None`` for anything else
(not a Restriction, missing onProperty/cardinality, non-numeric, or < 1).
"""
if (restr, RDF.type, OWL.Restriction) not in graph:
return None
on_prop = graph.value(restr, OWL.onProperty)
card = graph.value(restr, OWL.minCardinality) or graph.value(restr, OWL.cardinality)
if on_prop is None or card is None:
return None
try:
# card is a Node (typically an rdflib Literal, which is a str
# subclass at runtime); str() gets its lexical form for int().
required = int(str(card)) >= 1
except TypeError, ValueError:
return None
return _local(on_prop) if required else None
@traces(spec="105", story="US-105-001", reqs=["REQ-105-017"])
def _apply_restrictions(graph: Graph, by_iri: dict[URIRef, Entity]) -> None:
"""Mark fields/relationships required from owl:Restriction minCardinality (REQ-105-017)."""
for cls, ent in by_iri.items():
for restr in graph.objects(cls, RDFS.subClassOf):
pname = _restriction_required_prop(graph, restr)
if pname is None:
continue
for fld in ent.fields:
if fld.name == pname:
fld.required = True
for rel in ent.relationships:
if rel.name == pname:
rel.required = True
@traces(
spec="105", story="US-105-001", reqs=["REQ-105-012", "REQ-105-013", "REQ-105-024"]
)
def _parse_graph(source: str | Path) -> Graph:
"""Parse an OWL/Turtle source (file path or raw text) into an rdflib Graph."""
graph = Graph()
src = str(source)
try:
if isinstance(source, Path) or (len(src) < 4096 and Path(src).exists()):
graph.parse(str(source), format="turtle")
else:
graph.parse(data=src, format="turtle")
except OntologyParseError:
raise
except Exception as exc: # rdflib raises a variety of parse errors
raise OntologyParseError(f"failed to parse ontology: {exc}") from exc
return graph
@traces(
spec="105", story="US-105-001", reqs=["REQ-105-001", "REQ-105-016", "REQ-105-019"]
)
def _build_entities(graph: Graph, classes: set[URIRef]) -> dict[URIRef, Entity]:
"""Build an Entity per class IRI, capturing doc and parent (REQ-105-019)."""
by_iri: dict[URIRef, Entity] = {}
for cls in classes:
ent = Entity(name=_local(cls), iri=str(cls))
# doc: prefer rdfs:comment, fall back to rdfs:label (REQ-105-019). The
# class identifier stays the IRI local name (identifier-safe).
comment = graph.value(cls, RDFS.comment)
label = graph.value(cls, RDFS.label)
if comment is not None:
ent.doc = str(comment)
elif label is not None:
ent.doc = str(label)
parent = graph.value(cls, RDFS.subClassOf)
if isinstance(parent, URIRef) and parent in classes:
ent.parent = _local(parent)
by_iri[cls] = ent
return by_iri
@traces(spec="105", story="US-105-001", reqs=["REQ-105-014", "REQ-105-018"])
def _attach_datatype_properties(graph: Graph, by_iri: dict[URIRef, Entity]) -> None:
"""Attach owl:DatatypeProperty declarations as fields on their domain entity."""
for prop in graph.subjects(RDF.type, OWL.DatatypeProperty):
domain = graph.value(prop, RDFS.domain)
if not isinstance(domain, URIRef) or domain not in by_iri:
continue
rng = graph.value(prop, RDFS.range)
pytype = XSD_TO_PY.get(_local(rng), "str") if rng is not None else "str"
if rng is not None and _local(rng) not in XSD_TO_PY:
logger.warning("Unknown datatype %s -> str", rng)
by_iri[domain].fields.append(
Field(name=_local(prop), type=pytype, iri=str(prop))
)
@traces(spec="105", story="US-105-001", reqs=["REQ-105-015", "REQ-105-025"])
def _attach_object_properties(graph: Graph, by_iri: dict[URIRef, Entity]) -> None:
"""Attach owl:ObjectProperty declarations as relationships on their domain."""
for prop in graph.subjects(RDF.type, OWL.ObjectProperty):
domain = graph.value(prop, RDFS.domain)
if not isinstance(domain, URIRef) or domain not in by_iri:
continue
rng = graph.value(prop, RDFS.range)
if not isinstance(rng, URIRef): # ERR-105-002
logger.warning("Object property %s has no range — skipped", prop)
continue
functional = (prop, RDF.type, OWL.FunctionalProperty) in graph
inverse = graph.value(prop, OWL.inverseOf)
by_iri[domain].relationships.append(
Relationship(
name=_local(prop),
target=_local(rng),
iri=str(prop),
many=not functional,
inverse=_local(inverse) if inverse is not None else None,
)
)
@traces(
spec="105",
story="US-105-001",
reqs=[
"REQ-105-001",
"REQ-105-004",
"REQ-105-012",
"REQ-105-013",
"REQ-105-015",
"REQ-105-016",
"REQ-105-017",
"REQ-105-021",
"REQ-105-023",
"REQ-105-025",
],
)
def parse_ontology(source: str | Path) -> DomainModel:
"""Parse an OWL/Turtle ontology (file path or raw text) into a DomainModel."""
graph = _parse_graph(source)
classes = {
c
for c in set(graph.subjects(RDF.type, OWL.Class))
| set(graph.subjects(RDF.type, RDFS.Class))
if isinstance(c, URIRef)
}
by_iri = _build_entities(graph, classes)
_attach_datatype_properties(graph, by_iri)
_attach_object_properties(graph, by_iri)
_apply_restrictions(graph, by_iri)
namespace = _namespace_of(sorted(str(c) for c in classes))
entities = sorted(by_iri.values(), key=lambda e: e.name)
for ent in entities:
ent.fields.sort(key=lambda f: f.name)
ent.relationships.sort(key=lambda r: r.name)
name = namespace.rstrip("#/").rsplit("/", 1)[-1] or "domain"
return DomainModel(
name=name, namespace=namespace, modules=[Module(name=name, entities=entities)]
)