#!/usr/bin/env python3
"""
PR Data Meta Model - T-Box viewer generator
===========================================
Reads the T-Box Turtle file and emits a self-contained, zero-dependency HTML
viewer. The TTL is the source of truth; the viewer is a build artefact and must
never be edited by hand.
USAGE
python generate_tbox_viewer.py [tbox.ttl] [out.html]
Defaults: pr_datametamodel_v1_0.ttl -> pr_metamodel_viewer_v1_0.html
Uses rdflib when available; otherwise falls back to a restricted Turtle reader
that handles the subset of syntax used by this T-Box. Run it in CI on every
merge so the viewer can never drift from the model.
"""
import json
import re
import sys
from collections import OrderedDict
PR = "https://ontology.pernod-ricard.com/metamodel/"
WANTED = {
"a": "type", "rdf:type": "type",
"rdfs:label": "label", "rdfs:comment": "comment",
"rdfs:subClassOf": "subClassOf", "rdfs:subPropertyOf": "subPropertyOf",
"rdfs:domain": "domain", "rdfs:range": "range",
"owl:inverseOf": "inverseOf", "owl:deprecated": "deprecated",
"dcterms:isReplacedBy": "isReplacedBy",
"pr:harvestSource": "harvestSource", "pr:curationMode": "curationMode",
}
# --------------------------------------------------------------------------
# Turtle reading
# --------------------------------------------------------------------------
def strip_comments(text):
"""Remove # comments that sit outside string literals."""
out, i, n = [], 0, len(text)
while i < n:
c = text[i]
if text.startswith('"""', i):
j = text.find('"""', i + 3)
j = n if j == -1 else j + 3
out.append(text[i:j]); i = j; continue
if c == '"':
j = i + 1
while j < n and text[j] != '"':
j += 2 if text[j] == "\\" else 1
j = min(j + 1, n)
out.append(text[i:j]); i = j; continue
if c == "#":
j = text.find("\n", i)
i = n if j == -1 else j
continue
out.append(c); i += 1
return "".join(out)
def split_top(text, sep):
"""Split on `sep` at nesting depth 0, ignoring string literals."""
parts, buf, depth, i, n = [], [], 0, 0, len(text)
while i < n:
c = text[i]
if text.startswith('"""', i):
j = text.find('"""', i + 3)
j = n if j == -1 else j + 3
buf.append(text[i:j]); i = j; continue
if c == '"':
j = i + 1
while j < n and text[j] != '"':
j += 2 if text[j] == "\\" else 1
j = min(j + 1, n)
buf.append(text[i:j]); i = j; continue
if c in "([":
depth += 1
elif c in ")]":
depth -= 1
if c == sep and depth == 0:
parts.append("".join(buf)); buf = []; i += 1; continue
buf.append(c); i += 1
if "".join(buf).strip():
parts.append("".join(buf))
return parts
def tokens(text):
"""Split an object list into individual terms."""
return [t.strip() for t in split_top(text, ",") if t.strip()]
def unquote(tok):
tok = tok.strip()
if tok.startswith('"""'):
return tok[3:tok.rindex('"""')] if tok.count('"""') > 1 else tok[3:]
if tok.startswith('"'):
end = tok.rindex('"')
return tok[1:end] if end > 0 else tok[1:]
return tok
def parse_ttl_fallback(text):
text = strip_comments(text)
subjects = OrderedDict()
for stmt in split_top(text, "."):
stmt = stmt.strip()
if not stmt:
continue
chunks = split_top(stmt, ";")
head = chunks[0].strip()
m = re.match(r"^(\S+)\s+(.*)$", head, re.S)
if not m:
continue
subj, rest = m.group(1), m.group(2)
if not subj.startswith("pr:"):
continue
rec = subjects.setdefault(subj, {})
for chunk in [rest] + [c.strip() for c in chunks[1:]]:
chunk = chunk.strip()
if not chunk:
continue
pm = re.match(r"^(\S+)\s+(.*)$", chunk, re.S)
if not pm:
continue
pred, objs = pm.group(1), pm.group(2)
key = WANTED.get(pred)
if not key:
continue
rec.setdefault(key, []).extend(unquote(t) for t in tokens(objs))
return subjects
def parse_ttl_rdflib(path):
from rdflib import Graph, RDF, RDFS, OWL, URIRef, Namespace
DCT = Namespace("http://purl.org/dc/terms/")
g = Graph(); g.parse(path, format="turtle")
inv = {
RDF.type: "type", RDFS.label: "label", RDFS.comment: "comment",
RDFS.subClassOf: "subClassOf", RDFS.subPropertyOf: "subPropertyOf",
RDFS.domain: "domain", RDFS.range: "range",
OWL.inverseOf: "inverseOf", OWL.deprecated: "deprecated",
DCT.isReplacedBy: "isReplacedBy",
URIRef(PR + "harvestSource"): "harvestSource",
URIRef(PR + "curationMode"): "curationMode",
}
def short(term):
s = str(term)
for pfx, ns in (("pr:", PR),
("owl:", "http://www.w3.org/2002/07/owl#"),
("rdfs:", "http://www.w3.org/2000/01/rdf-schema#"),
("xsd:", "http://www.w3.org/2001/XMLSchema#"),
("prov:", "http://www.w3.org/ns/prov#")):
if s.startswith(ns):
return pfx + s[len(ns):]
return s
subjects = OrderedDict()
for s, p, o in g:
key = inv.get(p)
if key is None or not str(s).startswith(PR):
continue
rec = subjects.setdefault(short(s), {})
rec.setdefault(key, []).append(str(o) if key in ("label", "comment", "harvestSource", "curationMode", "deprecated") else short(o))
return subjects
def read_tbox(path):
try:
return parse_ttl_rdflib(path), "rdflib"
except ImportError:
with open(path, encoding="utf-8") as fh:
return parse_ttl_fallback(fh.read()), "built-in reader"
# --------------------------------------------------------------------------
# Model assembly
# --------------------------------------------------------------------------
BANDS = [
("roots", "Model roots", "How a term got here", "#0A1F44"),
("ownership", "Ownership & Categorization", "Who is accountable", "#B8935A"),
("business", "Business", "What the business means", "#8B3A3A"),
("logical", "Logical", "How the data is structured", "#7A4E8B"),
("delivery", "Delivery", "What we promise, to whom", "#B8935A"),
("physical", "Physical", "What actually runs", "#1E4B9B"),
("consumption", "Consumption", "What people open in the morning", "#1E7A5F"),
("context", "Actors & vocabularies", "Outside the object stack", "#6B6B6B"),
]
LAYER_ROOT = {
"pr:OwnershipLayerObject": "ownership",
"pr:BusinessLayerObject": "business",
"pr:LogicalLayerObject": "logical",
"pr:DeliveryLayerObject": "delivery",
"pr:PhysicalLayerObject": "physical",
"pr:ConsumptionLayerObject": "consumption",
}
def first(rec, key, default=""):
v = rec.get(key)
return v[0] if v else default
def build(subjects):
classes, objprops, dataprops, annprops, individuals = {}, {}, {}, {}, {}
for term, rec in subjects.items():
types = rec.get("type", [])
if "owl:Class" in types:
classes[term] = rec
elif "owl:ObjectProperty" in types:
objprops[term] = rec
elif "owl:DatatypeProperty" in types:
dataprops[term] = rec
elif "owl:AnnotationProperty" in types:
annprops[term] = rec
elif types and types[0].startswith("pr:"):
individuals.setdefault(types[0], []).append(term)
# layer + provenance by walking subClassOf upward
def ancestry(term, seen=None):
seen = seen or set()
if term in seen:
return []
seen.add(term)
out = [term]
for parent in classes.get(term, {}).get("subClassOf", []):
out += ancestry(parent, seen)
return out
nodes = []
for term, rec in classes.items():
anc = ancestry(term)
band = next((LAYER_ROOT[a] for a in anc if a in LAYER_ROOT), None)
if band is None:
if "pr:Actor" in anc or term in ("pr:ActivationStatus", "pr:Environment", "pr:SystemType"):
band = "context"
else:
band = "roots"
provenance = ("observed" if "pr:ObservedObject" in anc
else "curated" if "pr:CuratedObject" in anc else "neutral")
nodes.append({
"id": term,
"label": first(rec, "label", term.split(":")[1]),
"band": band,
"prov": provenance,
"parent": first(rec, "subClassOf"),
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"curation": first(rec, "curationMode"),
"deprecated": first(rec, "deprecated") in ("true", "True"),
"replacedBy": first(rec, "isReplacedBy"),
"members": [
{"id": m, "label": first(subjects.get(m, {}), "label", m.split(":")[1])}
for m in individuals.get(term, [])
],
})
known = {n["id"] for n in nodes}
edges, universal = [], []
for term, rec in objprops.items():
dom, rng = first(rec, "domain"), first(rec, "range")
item = {
"id": term,
"label": first(rec, "label", term.split(":")[1]),
"s": dom, "t": rng,
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"curation": first(rec, "curationMode"),
"inverseOf": first(rec, "inverseOf"),
"subPropertyOf": first(rec, "subPropertyOf"),
"functional": "owl:FunctionalProperty" in rec.get("type", []),
"transitive": "owl:TransitiveProperty" in rec.get("type", []),
"deprecated": first(rec, "deprecated") in ("true", "True"),
"replacedBy": first(rec, "isReplacedBy"),
}
if dom in known and rng in known:
edges.append(item)
else:
item["s"] = dom or "pr:MetaModelObject"
universal.append(item)
datatypes = [{
"id": t, "label": first(r, "label", t.split(":")[1]),
"range": first(r, "range"), "comment": first(r, "comment"),
"harvest": first(r, "harvestSource"),
"functional": "owl:FunctionalProperty" in r.get("type", []),
"deprecated": first(r, "deprecated") in ("true", "True"),
"replacedBy": first(r, "isReplacedBy"),
} for t, r in dataprops.items()]
bridges = [{
"id": t, "label": first(r, "label", t.split(":")[1]),
"comment": first(r, "comment"),
} for t, r in annprops.items() if t.startswith("pr:denotes")]
onto = subjects.get("