Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7793def268 | |||
| 45ca9ffb68 |
+1237
-394
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,7 @@ NEW_CONCEPTS = [
|
|||||||
|
|
||||||
# ------------------------------------------------------------------- new object
|
# ------------------------------------------------------------------- new object
|
||||||
NEW_OBJECTS = [
|
NEW_OBJECTS = [
|
||||||
("ex:BO_04_01_002", "BO-04.01-002", "Outlet", "ex:DD_04", "ex:ST_DD_04", "ex:SD_04_01",
|
("ex:BO_04_02_001", "BO-04.02-001", "Outlet", "ex:DD_04", "ex:ST_DD_04", "ex:SD_04_02",
|
||||||
"ex:BC_04_01_002", [], "MDM Customer -> SODH [split from BO-04.01-001, proposed to DD-04]"),
|
"ex:BC_04_01_002", [], "MDM Customer -> SODH [split from BO-04.01-001, proposed to DD-04]"),
|
||||||
("ex:BO_16_01_002", "BO-16.01-002", "Exchange Rate", "ex:DD_16", "ex:ST_DD_16", "ex:SD_16_01",
|
("ex:BO_16_01_002", "BO-16.01-002", "Exchange Rate", "ex:DD_16", "ex:ST_DD_16", "ex:SD_16_01",
|
||||||
"ex:BC_16_01_002", [], "Group Finance -> SODH [split from BO-16.01-001, proposed to DD-16]"),
|
"ex:BC_16_01_002", [], "Group Finance -> SODH [split from BO-16.01-001, proposed to DD-16]"),
|
||||||
@@ -176,7 +176,13 @@ def main():
|
|||||||
continue
|
continue
|
||||||
block = m.group(1)
|
block = m.group(1)
|
||||||
|
|
||||||
|
# strip what a previous run may have written, so re-running is a no-op.
|
||||||
|
# Duplicated clauses carrying the SAME value merge under RDF set
|
||||||
|
# semantics: the graph stays valid and SHACL sees nothing, while the
|
||||||
|
# file quietly grows a redundant line on every pass.
|
||||||
new_block = re.sub(r'[ \t]*pr:composedOf\s+[^";]*;[ \t]*\n', '', block)
|
new_block = re.sub(r'[ \t]*pr:composedOf\s+[^";]*;[ \t]*\n', '', block)
|
||||||
|
new_block = re.sub(r'[ \t]*pr:(aboutConcept|usesConcept)\s+[^";]*;[ \t]*\n',
|
||||||
|
'', new_block)
|
||||||
clause = ' pr:aboutConcept %s ;\n' % about
|
clause = ' pr:aboutConcept %s ;\n' % about
|
||||||
if uses:
|
if uses:
|
||||||
clause += ' pr:usesConcept %s ;\n' % " , ".join(uses)
|
clause += ' pr:usesConcept %s ;\n' % " , ".join(uses)
|
||||||
@@ -204,27 +210,38 @@ def main():
|
|||||||
" renamed=%s" % newname if newname else ""))
|
" renamed=%s" % newname if newname else ""))
|
||||||
|
|
||||||
# ---- 3. insert the missing concepts --------------------------------------
|
# ---- 3. insert the missing concepts --------------------------------------
|
||||||
|
# idempotent: identical duplicated blocks merge in RDF, so SHACL would
|
||||||
|
# never flag them -- the file grows while the graph stays valid.
|
||||||
|
todo_c = [c for c in NEW_CONCEPTS
|
||||||
|
if not re.search(r'^%s a ' % re.escape(c[0]), text, re.M)]
|
||||||
anchor = re.search(r'\n(?=ex:BO_\w+ a pr:BusinessObject ;)', text)
|
anchor = re.search(r'\n(?=ex:BO_\w+ a pr:BusinessObject ;)', text)
|
||||||
if anchor:
|
if anchor and todo_c:
|
||||||
blocks = "\n".join(concept_block(*c) for c in NEW_CONCEPTS)
|
blocks = "\n".join(concept_block(*c) for c in todo_c)
|
||||||
text = (text[:anchor.start()] + "\n\n" +
|
text = (text[:anchor.start()] + "\n\n" +
|
||||||
"# --- concepts added in v1.1 migration -------------------------------\n" +
|
"# --- concepts added in v1.1 migration -------------------------------\n" +
|
||||||
"# Six of these belong to other domains: DRAFT definitions proposed by the\n" +
|
"# Six of these belong to other domains: DRAFT definitions proposed by the\n" +
|
||||||
"# DGO, flagged TO_ARBITRATE. OW-007 blocks publication until ratified.\n" +
|
"# DGO, flagged TO_ARBITRATE. OW-007 blocks publication until ratified.\n" +
|
||||||
blocks + "\n" + text[anchor.start():])
|
blocks + "\n" + text[anchor.start():])
|
||||||
report.append("inserted %d Business Concepts (%d TO_ARBITRATE)"
|
report.append("inserted %d Business Concepts (%d TO_ARBITRATE)"
|
||||||
% (len(NEW_CONCEPTS), sum(1 for c in NEW_CONCEPTS if c[6])))
|
% (len(todo_c), sum(1 for c in todo_c if c[6])))
|
||||||
|
elif not todo_c:
|
||||||
|
report.append("all %d Business Concepts already present, skipped"
|
||||||
|
% len(NEW_CONCEPTS))
|
||||||
|
|
||||||
# ---- 4. append the objects produced by the splits ------------------------
|
# ---- 4. append the objects produced by the splits ------------------------
|
||||||
|
todo_o = [o for o in NEW_OBJECTS
|
||||||
|
if not re.search(r'^%s a ' % re.escape(o[0]), text, re.M)]
|
||||||
tail = re.search(r'\n(?=ex:M_\w+ a pr:Metric ;|ex:DO_\w+ a pr:DataObject ;)', text)
|
tail = re.search(r'\n(?=ex:M_\w+ a pr:Metric ;|ex:DO_\w+ a pr:DataObject ;)', text)
|
||||||
if tail:
|
if tail and todo_o:
|
||||||
blocks = "\n".join(object_block(*o) for o in NEW_OBJECTS)
|
blocks = "\n".join(object_block(*o) for o in todo_o)
|
||||||
text = (text[:tail.start()] + "\n\n" +
|
text = (text[:tail.start()] + "\n\n" +
|
||||||
"# --- Business Objects from the v1.1 splits ---------------------------\n" +
|
"# --- Business Objects from the v1.1 splits ---------------------------\n" +
|
||||||
"# 'Customer & Outlet' and 'Currency & Exchange Rates' had no single\n" +
|
"# 'Customer & Outlet' and 'Currency & Exchange Rates' had no single\n" +
|
||||||
"# subject. Split proposed to DD-04 and DD-16, hence TO_ARBITRATE.\n" +
|
"# subject. Split proposed to DD-04 and DD-16, hence TO_ARBITRATE.\n" +
|
||||||
blocks + "\n" + text[tail.start():])
|
blocks + "\n" + text[tail.start():])
|
||||||
report.append("inserted %d Business Objects from splits" % len(NEW_OBJECTS))
|
report.append("inserted %d Business Objects from splits" % len(todo_o))
|
||||||
|
elif not todo_o:
|
||||||
|
report.append("Business Objects from splits already present, skipped")
|
||||||
|
|
||||||
# ---- report --------------------------------------------------------------
|
# ---- report --------------------------------------------------------------
|
||||||
print()
|
print()
|
||||||
@@ -233,6 +250,13 @@ def main():
|
|||||||
for line in report:
|
for line in report:
|
||||||
print(" " + line)
|
print(" " + line)
|
||||||
print("=" * W)
|
print("=" * W)
|
||||||
|
doubled = []
|
||||||
|
for m in re.finditer(r'^(ex:\w+) a pr:\w+ ;(.*?)\.\s*\n', text, re.S | re.M):
|
||||||
|
for prop in ("pr:hasName", "pr:aboutConcept", "pr:belongsTo"):
|
||||||
|
if len(re.findall(r'(?<![\w:])%s\s' % re.escape(prop), m.group(2))) > 1:
|
||||||
|
doubled.append("%s/%s" % (m.group(1), prop))
|
||||||
|
print(" duplicated single-valued properties: %s"
|
||||||
|
% (", ".join(doubled) if doubled else "none"))
|
||||||
bo = len(re.findall(r'a pr:BusinessObject', text))
|
bo = len(re.findall(r'a pr:BusinessObject', text))
|
||||||
bc = len(re.findall(r'a pr:BusinessConcept', text))
|
bc = len(re.findall(r'a pr:BusinessConcept', text))
|
||||||
print(" Business Objects %d | Business Concepts %d | composedOf left %d"
|
print(" Business Objects %d | Business Concepts %d | composedOf left %d"
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SODH - BR-013 alignment, last step of the v1.1 migration
|
||||||
|
=========================================================
|
||||||
|
Regenerates the Metric and Data Element sections of instances/sodh.ttl from the
|
||||||
|
v0.7 back-doc, which has been ahead of the TTL since the divergence found at
|
||||||
|
audit time. This is the migration that puts the flow back the right way round:
|
||||||
|
after it, the TTL is the source and the workbook becomes a generated artefact.
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
python3 scripts/apply_br013_sodh.py # dry run
|
||||||
|
python3 scripts/apply_br013_sodh.py --apply # rewrite, .bak kept
|
||||||
|
|
||||||
|
WHAT IT FIXES
|
||||||
|
37 BLOCKING DataElementMeaningShape -- no element had a route to meaning
|
||||||
|
15 BLOCKING physicalName on a Metric -- layer leak
|
||||||
|
15 WARNING hasGranularity on a Metric
|
||||||
|
6 WARNING monitoredBy on a Data Object
|
||||||
|
|
||||||
|
WHAT IT DOES
|
||||||
|
1. 15 metrics -> 10 mother metrics carrying the harmonized calculation
|
||||||
|
rule, each with a formula, a unit and a measured Concept. No physical
|
||||||
|
name, no granularity: both belong elsewhere now.
|
||||||
|
2. 95 measure Data Elements generated, each computedBy its mother metric,
|
||||||
|
each carrying the physical name the metric used to hold.
|
||||||
|
3. 37 dimensional Data Elements given a represents towards their Concept.
|
||||||
|
With 2, this closes the XOR: every element reaches meaning by exactly
|
||||||
|
one route.
|
||||||
|
4. Three concepts created because the XOR forces them to be named --
|
||||||
|
Country, Marketing Entity, Fiscal Period had elements but no notion.
|
||||||
|
TO_ARBITRATE like the others.
|
||||||
|
5. Steward removed from the 6 Data Objects: it is inherited now.
|
||||||
|
6. hasGrainElement on the 4 DIMENSION Data Objects only.
|
||||||
|
|
||||||
|
WHY FACT TABLES GET NO GRAIN ELEMENTS
|
||||||
|
A fact table's grain is carried by its foreign keys, and OW-006 says a
|
||||||
|
foreign key is not a Data Element -- so a fact object has no element of its
|
||||||
|
own to point at. Its grain is already expressed, by references towards the
|
||||||
|
dimension objects it joins. Forcing hasGrainElement onto facts would mean
|
||||||
|
either breaking OW-006 or pointing at another object's elements, which
|
||||||
|
GrainConsistencyShape rejects. Nothing is lost: the information is there.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
TTL = os.path.join(REPO, "instances", "sodh.ttl")
|
||||||
|
BACKDOC = os.path.join(REPO, "instances", "SODH_data.xlsx")
|
||||||
|
W = 78
|
||||||
|
|
||||||
|
# ---- mother metric -> the Concept it measures ------------------------------
|
||||||
|
METRIC_CONCEPT = {
|
||||||
|
"M-06.01-001": "ex:BC_06_01_001", # Sell Out Volume -> Sell Out
|
||||||
|
"M-06.01-002": "ex:BC_06_01_001", # Sell Out Value -> Sell Out
|
||||||
|
"M-06.01-003": "ex:BC_06_01_004", # Numeric Distribution -> Retail Distribution
|
||||||
|
"M-06.01-004": "ex:BC_06_01_004", # Weighted Distribution -> Retail Distribution
|
||||||
|
"M-06.01-005": "ex:BC_06_01_004", # Total Distribution Pts -> Retail Distribution
|
||||||
|
"M-06.01-006": "ex:BC_06_01_002", # Forward Stock Volume -> Retailer Stock
|
||||||
|
"M-06.01-007": "ex:BC_06_01_002", # Days of Coverage -> Retailer Stock
|
||||||
|
"M-06.01-008": "ex:BC_06_01_002", # Stock Share -> Retailer Stock
|
||||||
|
"M-06.01-009": "ex:BC_06_01_005", # Baseline Volume -> Sell Out Baseline
|
||||||
|
"M-06.01-010": "ex:BC_06_01_005", # Incremental Volume -> Sell Out Baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- dimensional element -> the Concept it represents ----------------------
|
||||||
|
# Exact ids first, then id prefixes. Every dimensional element must land
|
||||||
|
# somewhere: that is what the XOR is for.
|
||||||
|
DE_CONCEPT_EXACT = {
|
||||||
|
"DE-04.01-0006": "ex:BC_04_01_002", # Outlet Type -> Outlet
|
||||||
|
"DE-04.01-0007": "ex:BC_04_01_002", # Channel -> Outlet
|
||||||
|
"DE-04.01-0010": "ex:BC_04_01_002", # Point of Sale Count -> Outlet
|
||||||
|
"DE-04.01-0008": "ex:BC_04_03_001", # Country Code -> Country
|
||||||
|
"DE-04.01-0009": "ex:BC_04_03_001", # Country Name -> Country
|
||||||
|
"DE-16.01-0001": "ex:BC_16_01_001", # Currency Code -> Currency
|
||||||
|
"DE-16.01-0002": "ex:BC_16_01_002", # FX Rate to-Euro -> Exchange Rate
|
||||||
|
"DE-16.01-0003": "ex:BC_16_01_002", # FX Rate to-USD -> Exchange Rate
|
||||||
|
"DE-16.02-0001": "ex:BC_16_03_001", # Marketing Entity -> Marketing Entity
|
||||||
|
"DE-16.02-0002": "ex:BC_16_02_001", # Fiscal Period Code -> Fiscal Period
|
||||||
|
"DE-16.02-0003": "ex:BC_16_02_001", # Fiscal Quarter Code -> Fiscal Period
|
||||||
|
}
|
||||||
|
DE_CONCEPT_PREFIX = [
|
||||||
|
("DE-10.01", "ex:BC_10_01_001"), # product hierarchy -> Product
|
||||||
|
("DE-04.01", "ex:BC_04_01_001"), # trade hierarchy -> Customer
|
||||||
|
("DE-21.01", "ex:BC_21_01_001"), # calendar -> Calendar Date
|
||||||
|
("DE-05.01", "ex:BC_05_01_001"), # promotion flags -> Promotion
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- concepts the XOR forces us to name ------------------------------------
|
||||||
|
NEW_CONCEPTS = [
|
||||||
|
# Final identifiers from the outset. Creating them under one id and
|
||||||
|
# renumbering them later broke idempotence: the second run no longer found
|
||||||
|
# the original id and recreated the concept alongside the renamed one.
|
||||||
|
("ex:BC_04_03_001", "BC-04.03-001", "Country", "ex:DD_04", "ex:ST_DD_04",
|
||||||
|
"A sovereign territory used as the geographic frame for retail measurement, "
|
||||||
|
"identified by its ISO 3166 code. Distinct from Customer and Outlet: it is where "
|
||||||
|
"they operate, not what they are."),
|
||||||
|
("ex:BC_16_03_001", "BC-16.03-001", "Marketing Entity", "ex:DD_16", "ex:ST_DD_16",
|
||||||
|
"An organisational unit of the group holding commercial responsibility for a market, "
|
||||||
|
"and the level at which financial results are consolidated."),
|
||||||
|
("ex:BC_16_02_001", "BC-16.02-001", "Fiscal Period", "ex:DD_16", "ex:ST_DD_16",
|
||||||
|
"A reporting interval of the Pernod Ricard fiscal year, which runs July to June. "
|
||||||
|
"Distinct from Calendar Date: the fiscal frame does not align with the Gregorian one."),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- grain of the DIMENSION objects (fact grain lives in references) -------
|
||||||
|
GRAIN = {
|
||||||
|
"ex:DO_10_01_001": ["ex:DE_10_01_0005"], # Product Dimension -> SKU Code
|
||||||
|
"ex:DO_04_01_001": ["ex:DE_04_01_0005"], # Customer Dimension -> Customer Tier-2 Code
|
||||||
|
"ex:DO_16_01_001": ["ex:DE_16_01_0001"], # Currency Dimension -> Currency Code
|
||||||
|
"ex:DO_21_01_001": ["ex:DE_21_01_0001"], # Calendar Dimension -> Calendar Date
|
||||||
|
}
|
||||||
|
|
||||||
|
UNIT_FIX = {"9L / L": "9L", "Currency EUR/USD/LC": "EUR", "%": "%",
|
||||||
|
"Index": "Index", "Days": "Days"}
|
||||||
|
|
||||||
|
|
||||||
|
def iri(ident):
|
||||||
|
"""BO-06.01-001 -> ex:BO_06_01_001"""
|
||||||
|
return "ex:" + ident.replace("-", "_", 1).replace(".", "_").replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def esc(text):
|
||||||
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx_rows(path):
|
||||||
|
"""
|
||||||
|
Minimal .xlsx reader: zipfile + ElementTree, no third-party dependency.
|
||||||
|
|
||||||
|
openpyxl would do this in three lines, but the validation venv is already
|
||||||
|
pinned tightly (pyshacl 0.26.0 for Python 3.9) and adding a dependency to a
|
||||||
|
migration script that runs once is a poor trade. Handles what a back-doc
|
||||||
|
needs: shared strings, inline strings, numbers, and sheet names.
|
||||||
|
"""
|
||||||
|
import zipfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
||||||
|
REL = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
||||||
|
PKG = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
shared = []
|
||||||
|
if "xl/sharedStrings.xml" in z.namelist():
|
||||||
|
for si in ET.fromstring(z.read("xl/sharedStrings.xml")):
|
||||||
|
shared.append("".join(t.text or "" for t in si.iter(NS + "t")))
|
||||||
|
|
||||||
|
rels = {}
|
||||||
|
for rel in ET.fromstring(z.read("xl/_rels/workbook.xml.rels")):
|
||||||
|
rels[rel.get("Id")] = rel.get("Target").lstrip("/")
|
||||||
|
|
||||||
|
sheets = []
|
||||||
|
for sh in ET.fromstring(z.read("xl/workbook.xml")).iter(NS + "sheet"):
|
||||||
|
target = rels.get(sh.get(REL + "id"), "")
|
||||||
|
if not target.startswith("xl/"):
|
||||||
|
target = "xl/" + target
|
||||||
|
sheets.append((sh.get("name"), target))
|
||||||
|
|
||||||
|
for name, target in sheets:
|
||||||
|
if target not in z.namelist():
|
||||||
|
continue
|
||||||
|
rows = []
|
||||||
|
for row in ET.fromstring(z.read(target)).iter(NS + "row"):
|
||||||
|
cells = []
|
||||||
|
for c in row.iter(NS + "c"):
|
||||||
|
v = c.find(NS + "v")
|
||||||
|
if c.get("t") == "s" and v is not None:
|
||||||
|
cells.append(shared[int(v.text)])
|
||||||
|
elif c.get("t") == "inlineStr":
|
||||||
|
cells.append("".join(t.text or "" for t in c.iter(NS + "t")))
|
||||||
|
else:
|
||||||
|
cells.append(v.text if v is not None else "")
|
||||||
|
rows.append([(x or "").strip() for x in cells])
|
||||||
|
yield name, rows
|
||||||
|
|
||||||
|
|
||||||
|
def _text_rows(path):
|
||||||
|
"""Tab-separated fallback, for back-docs exported as plain text."""
|
||||||
|
sheet, rows = None, []
|
||||||
|
for raw in open(path, encoding="utf-8", errors="replace"):
|
||||||
|
if raw.startswith("## Sheet:"):
|
||||||
|
if sheet:
|
||||||
|
yield sheet, rows
|
||||||
|
sheet, rows = raw.split(":", 1)[1].strip(), []
|
||||||
|
elif sheet is not None:
|
||||||
|
rows.append([c.strip() for c in raw.rstrip("\n").split("\t")])
|
||||||
|
if sheet:
|
||||||
|
yield sheet, rows
|
||||||
|
|
||||||
|
|
||||||
|
def parse_backdoc(path):
|
||||||
|
"""
|
||||||
|
Read the back-doc, whichever form it takes.
|
||||||
|
|
||||||
|
A real .xlsx is a ZIP (magic PK\x03\x04); some pipelines hand over a
|
||||||
|
tab-separated text export of the same content. Sniff rather than assume:
|
||||||
|
guessing from the extension is what made this script fail the first time.
|
||||||
|
"""
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
is_zip = fh.read(4) == b"PK\x03\x04"
|
||||||
|
reader = _xlsx_rows if is_zip else _text_rows
|
||||||
|
|
||||||
|
out = {"metrics": [], "elements": []}
|
||||||
|
for name, rows in reader(path):
|
||||||
|
key = None
|
||||||
|
if name.strip().startswith("6"):
|
||||||
|
key = "metrics"
|
||||||
|
elif name.strip().startswith("7"):
|
||||||
|
key = "elements"
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
for cells in rows:
|
||||||
|
if len(cells) < 5 or not cells[0]:
|
||||||
|
continue
|
||||||
|
if key == "metrics" and cells[0].startswith("M-"):
|
||||||
|
out[key].append(cells)
|
||||||
|
elif key == "elements" and cells[0].startswith("DE-"):
|
||||||
|
out[key].append(cells)
|
||||||
|
if not out["metrics"] or not out["elements"]:
|
||||||
|
raise SystemExit(
|
||||||
|
"Back-doc read but empty: %d metrics, %d elements.\n"
|
||||||
|
"Expected sheets starting with '6.' (Mother Metrics) and '7.' "
|
||||||
|
"(Data Elements) in %s" % (len(out["metrics"]), len(out["elements"]), path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def concept_for(de_id):
|
||||||
|
if de_id in DE_CONCEPT_EXACT:
|
||||||
|
return DE_CONCEPT_EXACT[de_id]
|
||||||
|
for prefix, bc in DE_CONCEPT_PREFIX:
|
||||||
|
if de_id.startswith(prefix):
|
||||||
|
return bc
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_metrics(metrics):
|
||||||
|
out = ["# --- mother metrics, BR-013 -----------------------------------------",
|
||||||
|
"# One harmonized calculation rule each. The granular variants are Data",
|
||||||
|
"# Elements linked by computedBy. No physical name (layer leak) and no",
|
||||||
|
"# granularity (a calculation rule has no rows).", ""]
|
||||||
|
for r in metrics:
|
||||||
|
mid, name, bo, formula, unit = r[0], r[1], r[2], r[3], r[4]
|
||||||
|
unit = UNIT_FIX.get(unit.split(" (")[0], unit.split(" (")[0])
|
||||||
|
out.append("%s a pr:Metric ;" % iri(mid))
|
||||||
|
out.append(' pr:hasIdentifier "%s" ; pr:hasName "%s" ;' % (mid, esc(name)))
|
||||||
|
out.append(" pr:owningDomain ex:DD_06 ; pr:ownedBy ex:ST_DD_06 ;")
|
||||||
|
out.append(' pr:hasFormula "%s" ;' % esc(formula))
|
||||||
|
out.append(' pr:hasUnit "%s" ;' % esc(unit))
|
||||||
|
out.append(" pr:measures %s ;" % METRIC_CONCEPT[mid])
|
||||||
|
out.append(' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; '
|
||||||
|
'pr:hasSource "SODH back-doc v0.7" .')
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_elements(elements):
|
||||||
|
measure, dimensional = [], []
|
||||||
|
for r in elements:
|
||||||
|
(deid, name, domain, computed, fmt, unit, source, phys, do) = r[:9]
|
||||||
|
dom = "ex:DD_" + deid.split("-")[1].split(".")[0]
|
||||||
|
block = ["%s a pr:DataElement ;" % iri(deid),
|
||||||
|
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;' % (deid, esc(name)),
|
||||||
|
" pr:owningDomain %s ;" % dom,
|
||||||
|
' pr:hasFormat "%s" ;' % esc(fmt)]
|
||||||
|
if unit and unit != "-":
|
||||||
|
block.append(' pr:hasUnit "%s" ;' % esc(unit))
|
||||||
|
if phys and phys != "-":
|
||||||
|
block.append(' pr:physicalName "%s" ;' % esc(phys))
|
||||||
|
if computed == "(dimensional)":
|
||||||
|
bc = concept_for(deid)
|
||||||
|
if not bc:
|
||||||
|
raise SystemExit("No concept mapped for %s -- the XOR would fail." % deid)
|
||||||
|
block.append(" pr:represents %s ;" % bc)
|
||||||
|
block.append(' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; '
|
||||||
|
'pr:hasSource "%s" .' % esc(source))
|
||||||
|
dimensional.append("\n".join(block))
|
||||||
|
else:
|
||||||
|
block.append(' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; '
|
||||||
|
'pr:hasSource "%s" .' % esc(source))
|
||||||
|
measure.append("\n".join(block))
|
||||||
|
return measure, dimensional
|
||||||
|
|
||||||
|
|
||||||
|
def build_computed_by(elements):
|
||||||
|
"""Metric -> its granular elements. Asserted from the metric side."""
|
||||||
|
by_metric = {}
|
||||||
|
name_to_id = {r[1]: r[0] for r in parse_backdoc(BACKDOC)["metrics"]}
|
||||||
|
for r in elements:
|
||||||
|
if r[3] == "(dimensional)":
|
||||||
|
continue
|
||||||
|
mid = name_to_id.get(r[3])
|
||||||
|
if mid:
|
||||||
|
by_metric.setdefault(mid, []).append(iri(r[0]))
|
||||||
|
out = ["# --- BR-013 wiring: each mother metric and the variants that implement it",
|
||||||
|
""]
|
||||||
|
for mid, des in by_metric.items():
|
||||||
|
chunks = [des[i:i + 4] for i in range(0, len(des), 4)]
|
||||||
|
lines = [" , ".join(c) for c in chunks]
|
||||||
|
out.append("%s pr:computedBy %s ." % (iri(mid), " ,\n ".join(lines)))
|
||||||
|
return "\n".join(out), sum(len(v) for v in by_metric.values())
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_has_element(text, elements):
|
||||||
|
"""
|
||||||
|
Re-attach every Data Element to its Data Object, from the back-doc column.
|
||||||
|
|
||||||
|
Without this the 95 generated measure elements would be orphans: no path to
|
||||||
|
the physical layer, and no steward, since stewardship is inherited through
|
||||||
|
the object. Not caught by any shape -- which is why it is worth doing here
|
||||||
|
rather than waiting for the validator to complain.
|
||||||
|
"""
|
||||||
|
by_do = {}
|
||||||
|
for r in elements:
|
||||||
|
do = r[8].strip()
|
||||||
|
if do and do != "-":
|
||||||
|
by_do.setdefault(iri(do), []).append(iri(r[0]))
|
||||||
|
n = 0
|
||||||
|
for do, des in by_do.items():
|
||||||
|
pat = re.compile(r'(^%s a pr:DataObject ;.*?)\n\s*pr:hasElement[^;]*;' % re.escape(do),
|
||||||
|
re.S | re.M)
|
||||||
|
chunks = [des[i:i + 4] for i in range(0, len(des), 4)]
|
||||||
|
clause = "\n pr:hasElement " + " ,\n ".join(" , ".join(c) for c in chunks) + " ;"
|
||||||
|
if pat.search(text):
|
||||||
|
text = pat.sub(lambda m: m.group(1) + clause, text, count=1)
|
||||||
|
n += 1
|
||||||
|
return text, n, {k: len(v) for k, v in by_do.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_has_metric(text, metrics):
|
||||||
|
"""
|
||||||
|
Re-point hasMetric on the Business Objects at the mother metrics.
|
||||||
|
|
||||||
|
Removing the old metric definitions is not enough: the Business Objects
|
||||||
|
still name them, and because hasMetric has rdfs:range pr:Metric, RDFS
|
||||||
|
entailment types those dangling IRIs as metrics. They then become focus
|
||||||
|
nodes carrying no identifier, no name, no formula -- ten ghosts, six
|
||||||
|
violations each. Deleting a subject means deleting what points at it.
|
||||||
|
"""
|
||||||
|
by_bo = {}
|
||||||
|
for r in metrics:
|
||||||
|
by_bo.setdefault(iri(r[2]), []).append(iri(r[0]))
|
||||||
|
|
||||||
|
text = re.sub(r'\n\s*pr:hasMetric[^;]*;', '', text)
|
||||||
|
n = 0
|
||||||
|
for bo, ms in by_bo.items():
|
||||||
|
pat = re.compile(r'(^%s a pr:BusinessObject ;.*?)(\n\s*pr:hasStatus)' % re.escape(bo),
|
||||||
|
re.S | re.M)
|
||||||
|
if pat.search(text):
|
||||||
|
text = pat.sub(r'\1\n pr:hasMetric %s ;\2' % " , ".join(ms), text, count=1)
|
||||||
|
n += 1
|
||||||
|
return text, n, by_bo
|
||||||
|
|
||||||
|
|
||||||
|
def concept_block(c):
|
||||||
|
i, ident, name, dom, st, definition = c
|
||||||
|
return ("%s a pr:BusinessConcept ;\n"
|
||||||
|
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;\n'
|
||||||
|
" pr:owningDomain %s ; pr:ownedBy %s ;\n"
|
||||||
|
' pr:hasBusinessDefinition "%s" ;\n'
|
||||||
|
' pr:arbitrationStatus "TO_ARBITRATE" ;\n'
|
||||||
|
' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; '
|
||||||
|
'pr:hasSource "DGO proposal, pending ratification by the owning domain" .'
|
||||||
|
% (i, ident, esc(name), dom, st, esc(definition)))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
apply_changes = "--apply" in sys.argv
|
||||||
|
for f in (TTL, BACKDOC):
|
||||||
|
if not os.path.exists(f):
|
||||||
|
print("Not found: %s" % f)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
doc = parse_backdoc(BACKDOC)
|
||||||
|
text = original = open(TTL, encoding="utf-8").read()
|
||||||
|
report = []
|
||||||
|
|
||||||
|
# 1. drop the old metric and data element blocks
|
||||||
|
n_old_m = len(re.findall(r'^ex:M_\w+ a pr:Metric ;', text, re.M))
|
||||||
|
n_old_de = len(re.findall(r'^ex:DE_\w+ a pr:DataElement ;', text, re.M))
|
||||||
|
text = re.sub(r'^ex:M_\w+ a pr:Metric ;.*?\.\s*\n(?=^ex:|\Z)', '', text, flags=re.S | re.M)
|
||||||
|
text = re.sub(r'^ex:DE_\w+ a pr:DataElement ;.*?\.\s*\n(?=^ex:|\Z)', '', text, flags=re.S | re.M)
|
||||||
|
report.append("removed %d old metrics and %d old data elements" % (n_old_m, n_old_de))
|
||||||
|
|
||||||
|
# 2. steward is inherited, not declared on a Data Object
|
||||||
|
n_st = 0
|
||||||
|
for m in re.finditer(r'^ex:DO_\w+ a pr:DataObject ;.*?\.\s*\n', text, re.S | re.M):
|
||||||
|
block = m.group(0)
|
||||||
|
new = re.sub(r'\s*pr:monitoredBy\s+ex:\w+\s*;', ' ;', block)
|
||||||
|
new = re.sub(r';\s*;', ' ;', new)
|
||||||
|
if new != block:
|
||||||
|
n_st += 1
|
||||||
|
text = text.replace(block, new, 1)
|
||||||
|
report.append("removed monitoredBy from %d Data Objects (inherited now)" % n_st)
|
||||||
|
|
||||||
|
# 3. grain, dimension objects only
|
||||||
|
n_g = 0
|
||||||
|
for do, grain in GRAIN.items():
|
||||||
|
pat = re.compile(r'(^%s a pr:DataObject ;.*?)(\n\s*pr:hasStatus)' % re.escape(do),
|
||||||
|
re.S | re.M)
|
||||||
|
if pat.search(text):
|
||||||
|
text = pat.sub(r'\1\n pr:hasGrainElement %s ;\2' % " , ".join(grain), text, count=1)
|
||||||
|
n_g += 1
|
||||||
|
report.append("grain declared on %d dimension Data Objects "
|
||||||
|
"(fact grain stays in references)" % n_g)
|
||||||
|
|
||||||
|
# 4. the three concepts the XOR forces us to name
|
||||||
|
# idempotent: a concept already in the file is left alone. Duplicated
|
||||||
|
# blocks would be INVISIBLE to SHACL -- identical triples merge under RDF
|
||||||
|
# set semantics, so the graph validates while the file carries redundant
|
||||||
|
# text. A script that can be re-run must check before it inserts.
|
||||||
|
todo = [c for c in NEW_CONCEPTS
|
||||||
|
if not re.search(r'^%s a ' % re.escape(c[0]), text, re.M)]
|
||||||
|
if len(todo) < len(NEW_CONCEPTS):
|
||||||
|
report.append("skipped %d concept(s) already present"
|
||||||
|
% (len(NEW_CONCEPTS) - len(todo)))
|
||||||
|
anchor = re.search(r'\n(?=ex:BO_\w+ a pr:BusinessObject ;)', text)
|
||||||
|
blocks = "\n".join(concept_block(c) for c in todo)
|
||||||
|
if todo:
|
||||||
|
text = (text[:anchor.start()] + "\n\n"
|
||||||
|
+ "# --- concepts required by the meaning XOR ---------------------------\n"
|
||||||
|
+ "# Country, Marketing Entity and Fiscal Period had Data Elements but no\n"
|
||||||
|
+ "# notion behind them. The rule forced them to be named.\n"
|
||||||
|
+ blocks + "\n" + text[anchor.start():])
|
||||||
|
report.append("added %d concepts required by the XOR (TO_ARBITRATE)" % len(todo))
|
||||||
|
|
||||||
|
# 5. metrics, elements, wiring
|
||||||
|
measure, dimensional = build_elements(doc["elements"])
|
||||||
|
wiring, n_wired = build_computed_by(doc["elements"])
|
||||||
|
text = text.rstrip() + "\n\n\n" + build_metrics(doc["metrics"]) + "\n\n"
|
||||||
|
text += ("# --- dimensional data elements --------------------------------------\n"
|
||||||
|
"# Each represents the Concept it carries: the first of the two routes\n"
|
||||||
|
"# to business meaning.\n\n" + "\n".join(dimensional) + "\n\n")
|
||||||
|
text += ("# --- granular measure data elements ---------------------------------\n"
|
||||||
|
"# Same calculation rule as their mother metric, different analysis\n"
|
||||||
|
"# context. They reach meaning through computedBy, never directly.\n\n"
|
||||||
|
+ "\n".join(measure) + "\n\n" + wiring + "\n")
|
||||||
|
report.append("wrote %d mother metrics, %d dimensional and %d measure elements"
|
||||||
|
% (len(doc["metrics"]), len(dimensional), len(measure)))
|
||||||
|
report.append("wired %d computedBy links" % n_wired)
|
||||||
|
|
||||||
|
text, n_bo, per_bo = rebuild_has_metric(text, doc["metrics"])
|
||||||
|
report.append("re-pointed hasMetric on %d Business Objects (%s)"
|
||||||
|
% (n_bo, ", ".join("%s=%d" % (k.replace("ex:BO_", "BO-"), len(v))
|
||||||
|
for k, v in sorted(per_bo.items()))))
|
||||||
|
|
||||||
|
text, n_do, counts = rebuild_has_element(text, doc["elements"])
|
||||||
|
report.append("re-attached elements to %d Data Objects (%s)"
|
||||||
|
% (n_do, ", ".join("%s=%d" % (k.replace("ex:DO_", "DO-"), v)
|
||||||
|
for k, v in sorted(counts.items()))))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("SODH BR-013 ALIGNMENT %s" % ("APPLY" if apply_changes else "DRY RUN"))
|
||||||
|
print("=" * W)
|
||||||
|
for line in report:
|
||||||
|
print(" " + line)
|
||||||
|
print("=" * W)
|
||||||
|
defined = set(re.findall(r'^(ex:M_\w+) a pr:Metric ;', text, re.M))
|
||||||
|
referenced = set(re.findall(r'ex:M_\w+', text))
|
||||||
|
dangling = referenced - defined
|
||||||
|
print(" dangling metric references: %s"
|
||||||
|
% (", ".join(sorted(dangling)) if dangling else "none"))
|
||||||
|
print(" Metrics %d | Data Elements %d | Concepts %d | computedBy %d | "
|
||||||
|
"physicalName on Metric %d"
|
||||||
|
% (len(re.findall(r'a pr:Metric', text)),
|
||||||
|
len(re.findall(r'a pr:DataElement', text)),
|
||||||
|
len(re.findall(r'a pr:BusinessConcept', text)),
|
||||||
|
n_wired,
|
||||||
|
len(re.findall(r'a pr:Metric ;[^.]*?physicalName', text, re.S))))
|
||||||
|
print("=" * W)
|
||||||
|
|
||||||
|
if text != original and apply_changes:
|
||||||
|
shutil.copy2(TTL, TTL + ".bak")
|
||||||
|
open(TTL, "w", encoding="utf-8").write(text)
|
||||||
|
print(" written, backup at %s.bak" % os.path.basename(TTL))
|
||||||
|
elif text != original:
|
||||||
|
print(" dry run -- re-run with --apply to write")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SODH - structure proposed for the borrowed domains
|
||||||
|
===================================================
|
||||||
|
Gives the nine TO_ARBITRATE concepts a home: a Business Object and a
|
||||||
|
Sub-Domain each, per the structure arbitrated with Bastien on 2026-07-27.
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
python3 scripts/apply_structure_sodh.py # dry run
|
||||||
|
python3 scripts/apply_structure_sodh.py --apply # rewrite, .bak kept
|
||||||
|
|
||||||
|
STILL A PROPOSAL
|
||||||
|
Everything created here stays TO_ARBITRATE. DD-04, DD-10, DD-16 and DD-21
|
||||||
|
own these perimeters; the DGO is proposing a coherent structure, not
|
||||||
|
ratifying one. Replacing padding with an equally unilateral structure
|
||||||
|
would trade one problem for a tidier version of the same problem. The flag
|
||||||
|
comes off when Helene Puchot, Anas El Kesri and Gaelle Seret say so, and
|
||||||
|
OW-007 blocks publication until then.
|
||||||
|
|
||||||
|
IDENTIFIERS ARE FINAL FROM CREATION
|
||||||
|
Identifiers encode the sub-domain (BO-04.02-001 lives in SD-04.02), so the
|
||||||
|
upstream scripts now mint them correctly rather than having this one
|
||||||
|
renumber afterwards. Renaming after creation broke idempotence: the script
|
||||||
|
that created an object stopped recognising its own output and made a second
|
||||||
|
copy on the next run.
|
||||||
|
|
||||||
|
COMPOUND SUB-DOMAINS SPLIT TOO
|
||||||
|
'Customer & Outlet' and 'Marketing Entity & Fiscal Calendar' have the same
|
||||||
|
defect as the Business Objects that were split earlier: a name joined by
|
||||||
|
'&' has no single subject. Same rule, one level up.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
TTL = os.path.join(REPO, "instances", "sodh.ttl")
|
||||||
|
W = 78
|
||||||
|
|
||||||
|
# No IRI renaming here. Objects are created with their final identifiers by
|
||||||
|
# the upstream scripts: renumbering after the fact broke idempotence, because
|
||||||
|
# the creating script then no longer recognised its own output.
|
||||||
|
# ---- sub-domains ----------------------------------------------------------
|
||||||
|
SD_RENAME = {
|
||||||
|
"ex:SD_04_01": "Customer Structure",
|
||||||
|
"ex:SD_16_01": "Currency & Exchange Rate",
|
||||||
|
"ex:SD_16_02": "Fiscal Calendar",
|
||||||
|
"ex:SD_21_01": "Standard Calendar",
|
||||||
|
}
|
||||||
|
SD_NEW = [
|
||||||
|
("ex:SD_04_02", "SD-04.02", "Outlet & Points of Sale", "ex:DD_04"),
|
||||||
|
("ex:SD_04_03", "SD-04.03", "Geography", "ex:DD_04"),
|
||||||
|
("ex:SD_16_03", "SD-16.03", "Organisation Structure", "ex:DD_16"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- business objects -----------------------------------------------------
|
||||||
|
BO_RENAME = {
|
||||||
|
"ex:BO_10_01_001": "Product Hierarchy",
|
||||||
|
"ex:BO_04_01_001": "Customer Hierarchy",
|
||||||
|
"ex:BO_21_01_001": "Analysis Period",
|
||||||
|
}
|
||||||
|
BO_NEW = [
|
||||||
|
("ex:BO_04_03_001", "BO-04.03-001", "Country", "ex:DD_04", "ex:ST_DD_04",
|
||||||
|
"ex:SD_04_03", "ex:BC_04_03_001"),
|
||||||
|
("ex:BO_16_02_001", "BO-16.02-001", "Fiscal Period", "ex:DD_16", "ex:ST_DD_16",
|
||||||
|
"ex:SD_16_02", "ex:BC_16_02_001"),
|
||||||
|
("ex:BO_16_03_001", "BO-16.03-001", "Marketing Entity", "ex:DD_16", "ex:ST_DD_16",
|
||||||
|
"ex:SD_16_03", "ex:BC_16_03_001"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- where each object finally sits ---------------------------------------
|
||||||
|
BELONGS = {
|
||||||
|
"ex:BO_10_01_001": "ex:SD_10_01",
|
||||||
|
"ex:BO_04_01_001": "ex:SD_04_01",
|
||||||
|
"ex:BO_04_02_001": "ex:SD_04_02",
|
||||||
|
"ex:BO_04_03_001": "ex:SD_04_03",
|
||||||
|
"ex:BO_16_01_001": "ex:SD_16_01",
|
||||||
|
"ex:BO_16_01_002": "ex:SD_16_01",
|
||||||
|
"ex:BO_16_02_001": "ex:SD_16_02",
|
||||||
|
"ex:BO_16_03_001": "ex:SD_16_03",
|
||||||
|
"ex:BO_21_01_001": "ex:SD_21_01",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- subject of each borrowed object --------------------------------------
|
||||||
|
ABOUT = {
|
||||||
|
"ex:BO_10_01_001": "ex:BC_10_01_001", # Product Hierarchy -> Product
|
||||||
|
"ex:BO_04_01_001": "ex:BC_04_01_001", # Customer Hierarchy -> Customer
|
||||||
|
"ex:BO_04_02_001": "ex:BC_04_01_002", # Outlet -> Outlet
|
||||||
|
"ex:BO_04_03_001": "ex:BC_04_03_001", # Country -> Country
|
||||||
|
"ex:BO_16_01_001": "ex:BC_16_01_001", # Currency -> Currency
|
||||||
|
"ex:BO_16_01_002": "ex:BC_16_01_002", # Exchange Rate -> Exchange Rate
|
||||||
|
"ex:BO_16_02_001": "ex:BC_16_02_001", # Fiscal Period -> Fiscal Period
|
||||||
|
"ex:BO_16_03_001": "ex:BC_16_03_001", # Marketing Entity -> Marketing Entity
|
||||||
|
"ex:BO_21_01_001": "ex:BC_21_01_001", # Analysis Period -> Calendar Date
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKETING_NOTE = ("Named Marketing Entity, not Management Entity: the element is "
|
||||||
|
"Marketing Entity Code and the column is MARKETING_ENTITY_CD. "
|
||||||
|
"To be confirmed by DD-16.")
|
||||||
|
|
||||||
|
|
||||||
|
def sd_block(i, ident, name, domain):
|
||||||
|
return ('%s a pr:SubDomain ;\n'
|
||||||
|
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;\n'
|
||||||
|
' pr:belongsTo %s ; pr:owningDomain %s ;\n'
|
||||||
|
' pr:arbitrationStatus "TO_ARBITRATE" ;\n'
|
||||||
|
' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; '
|
||||||
|
'pr:hasSource "DGO proposal, split of a compound sub-domain" .'
|
||||||
|
% (i, ident, name, domain, domain))
|
||||||
|
|
||||||
|
|
||||||
|
def bo_block(i, ident, name, domain, steward, sd, bc):
|
||||||
|
return ('%s a pr:BusinessObject ;\n'
|
||||||
|
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;\n'
|
||||||
|
' pr:owningDomain %s ; pr:ownedBy %s ;\n'
|
||||||
|
' pr:belongsTo %s ; pr:monitoredBy %s ;\n'
|
||||||
|
' pr:aboutConcept %s ;\n'
|
||||||
|
' pr:arbitrationStatus "TO_ARBITRATE" ;\n'
|
||||||
|
' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; '
|
||||||
|
'pr:hasSource "DGO proposal, pending ratification by the owning domain" .'
|
||||||
|
% (i, ident, name, domain, steward, sd, steward, bc))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_clauses(body):
|
||||||
|
"""Split a Turtle predicate list on ';' that sit outside string literals."""
|
||||||
|
out, buf, i, n = [], [], 0, len(body)
|
||||||
|
while i < n:
|
||||||
|
c = body[i]
|
||||||
|
if c == '"':
|
||||||
|
j = i + 1
|
||||||
|
while j < n and body[j] != '"':
|
||||||
|
j += 2 if body[j] == "\\" else 1
|
||||||
|
j = min(j + 1, n)
|
||||||
|
buf.append(body[i:j]); i = j; continue
|
||||||
|
if c == ";":
|
||||||
|
out.append("".join(buf)); buf = []; i += 1; continue
|
||||||
|
buf.append(c); i += 1
|
||||||
|
out.append("".join(buf))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_prop(text, subject, prop, value):
|
||||||
|
"""
|
||||||
|
Set or replace a single-valued property inside a subject block.
|
||||||
|
|
||||||
|
Clause-level, not regex-level: a property can sit mid-line
|
||||||
|
(pr:hasIdentifier "X" ; pr:hasName "Y" ;) and its value can contain a
|
||||||
|
semicolon inside the literal. A line-anchored pattern silently misses both
|
||||||
|
and APPENDS instead of replacing, which produces a second value and a
|
||||||
|
cardinality violation -- exactly what happened on the first run here.
|
||||||
|
"""
|
||||||
|
pat = re.compile(r'(^%s a pr:\w+ ;)(.*?)(\.\s*\n)' % re.escape(subject), re.S | re.M)
|
||||||
|
m = pat.search(text)
|
||||||
|
if not m:
|
||||||
|
return text, False
|
||||||
|
kept = [c for c in _split_clauses(m.group(2))
|
||||||
|
if c.strip() and not re.match(r'\s*%s\s' % re.escape(prop), c)]
|
||||||
|
kept.insert(0, "\n %s %s " % (prop, value))
|
||||||
|
body = " ;".join(kept)
|
||||||
|
if not body.endswith("\n"):
|
||||||
|
body = body.rstrip() + "\n "
|
||||||
|
return text[:m.start()] + m.group(1) + body + m.group(3) + text[m.end():], True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
apply_changes = "--apply" in sys.argv
|
||||||
|
if not os.path.exists(TTL):
|
||||||
|
print("Not found: %s" % TTL)
|
||||||
|
sys.exit(2)
|
||||||
|
text = original = open(TTL, encoding="utf-8").read()
|
||||||
|
report = []
|
||||||
|
|
||||||
|
# 2. sub-domains
|
||||||
|
for sd, name in SD_RENAME.items():
|
||||||
|
text, ok = set_prop(text, sd, "pr:hasName", '"%s"' % name)
|
||||||
|
if ok:
|
||||||
|
report.append("renamed %s -> %s" % (sd.replace("ex:SD_", "SD-"), name))
|
||||||
|
|
||||||
|
todo_sd = [s for s in SD_NEW if not re.search(r'^%s a ' % re.escape(s[0]), text, re.M)]
|
||||||
|
if todo_sd:
|
||||||
|
anchor = re.search(r'\n(?=ex:BC_\w+ a pr:BusinessConcept ;|ex:BO_\w+ a pr:BusinessObject ;)',
|
||||||
|
text)
|
||||||
|
blocks = "\n".join(sd_block(*s) for s in todo_sd)
|
||||||
|
text = (text[:anchor.start()] + "\n\n"
|
||||||
|
+ "# --- sub-domains from the v1.1 structure proposal --------------------\n"
|
||||||
|
+ "# 'Customer & Outlet' and 'Marketing Entity & Fiscal Calendar' had the\n"
|
||||||
|
+ "# same defect as the compound Business Objects: a name joined by '&'\n"
|
||||||
|
+ "# has no single subject. Same rule, one level up.\n"
|
||||||
|
+ blocks + "\n" + text[anchor.start():])
|
||||||
|
report.append("created %d sub-domains%s"
|
||||||
|
% (len(todo_sd), "" if todo_sd else " (already present)"))
|
||||||
|
|
||||||
|
# 3. new business objects
|
||||||
|
todo_bo = [b for b in BO_NEW if not re.search(r'^%s a ' % re.escape(b[0]), text, re.M)]
|
||||||
|
if todo_bo:
|
||||||
|
anchor = re.search(r'\n(?=ex:DO_\w+ a pr:DataObject ;|ex:M_\w+ a pr:Metric ;)', text)
|
||||||
|
blocks = "\n".join(bo_block(*b) for b in todo_bo)
|
||||||
|
text = (text[:anchor.start()] + "\n\n"
|
||||||
|
+ "# --- business objects for the borrowed concepts -----------------------\n"
|
||||||
|
+ "# Country, Fiscal Period and Marketing Entity had a concept but nothing\n"
|
||||||
|
+ "# to hold it. Proposed to DD-04 and DD-16, hence TO_ARBITRATE.\n"
|
||||||
|
+ blocks + "\n" + text[anchor.start():])
|
||||||
|
report.append("created %d business objects%s"
|
||||||
|
% (len(todo_bo), "" if todo_bo else " (already present)"))
|
||||||
|
|
||||||
|
# 4. renames, placement, subject
|
||||||
|
for bo, name in BO_RENAME.items():
|
||||||
|
text, _ = set_prop(text, bo, "pr:hasName", '"%s"' % name)
|
||||||
|
report.append("renamed %d business objects to the agreed nomenclature" % len(BO_RENAME))
|
||||||
|
|
||||||
|
n_b = n_a = 0
|
||||||
|
for bo, sd in BELONGS.items():
|
||||||
|
text, ok = set_prop(text, bo, "pr:belongsTo", sd)
|
||||||
|
n_b += ok
|
||||||
|
for bo, bc in ABOUT.items():
|
||||||
|
text, ok = set_prop(text, bo, "pr:aboutConcept", bc)
|
||||||
|
n_a += ok
|
||||||
|
report.append("placed %d objects in their sub-domain, %d subjects confirmed" % (n_b, n_a))
|
||||||
|
|
||||||
|
# 5. the naming caveat, recorded on the object rather than in a side note
|
||||||
|
text, ok = set_prop(text, "ex:BC_16_03_001", "pr:hasBusinessRule", '"%s"' % MARKETING_NOTE)
|
||||||
|
if ok:
|
||||||
|
report.append("recorded the Marketing/Management naming caveat on the concept")
|
||||||
|
|
||||||
|
# ---- report
|
||||||
|
print()
|
||||||
|
print("SODH STRUCTURE PROPOSAL %s" % ("APPLY" if apply_changes else "DRY RUN"))
|
||||||
|
print("=" * W)
|
||||||
|
for line in report:
|
||||||
|
print(" " + line)
|
||||||
|
print("=" * W)
|
||||||
|
defined = set(re.findall(r'^(ex:\w+) a ', text, re.M))
|
||||||
|
dangling = sorted(set(re.findall(r'\bex:\w+', text)) - defined)
|
||||||
|
print(" dangling references: %s" % (", ".join(dangling) if dangling else "none"))
|
||||||
|
for cls in ("SubDomain", "BusinessObject", "BusinessConcept"):
|
||||||
|
ids = re.findall(r'^(ex:\w+) a pr:%s\b' % cls, text, re.M)
|
||||||
|
dup = [i for i in set(ids) if ids.count(i) > 1]
|
||||||
|
print(" %-16s %3d duplicates %d" % (cls, len(ids), len(dup)))
|
||||||
|
doubled = []
|
||||||
|
for m in re.finditer(r'^(ex:\w+) a pr:\w+ ;(.*?)\.\s*\n', text, re.S | re.M):
|
||||||
|
for prop in ("pr:hasName", "pr:hasIdentifier", "pr:belongsTo", "pr:aboutConcept"):
|
||||||
|
if len(re.findall(r'%s\s' % re.escape(prop), m.group(2))) > 1:
|
||||||
|
doubled.append("%s/%s" % (m.group(1), prop))
|
||||||
|
print(" duplicated single-valued properties: %s"
|
||||||
|
% (", ".join(doubled) if doubled else "none"))
|
||||||
|
arb = len(re.findall(r'pr:arbitrationStatus "TO_ARBITRATE"', text))
|
||||||
|
print(" TO_ARBITRATE %3d (nothing publishes until the domains ratify)" % arb)
|
||||||
|
print("=" * W)
|
||||||
|
|
||||||
|
if text != original and apply_changes:
|
||||||
|
shutil.copy2(TTL, TTL + ".bak")
|
||||||
|
open(TTL, "w", encoding="utf-8").write(text)
|
||||||
|
print(" written, backup at %s.bak" % os.path.basename(TTL))
|
||||||
|
elif text != original:
|
||||||
|
print(" dry run -- re-run with --apply to write")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -236,13 +236,41 @@ def main():
|
|||||||
meta_shacl=False,
|
meta_shacl=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def shape_label(g, res, SH):
|
||||||
|
"""
|
||||||
|
A readable name for the shape that fired.
|
||||||
|
|
||||||
|
sh:sourceShape on a property constraint is a blank node, which prints as
|
||||||
|
'ne775aabb...' and tells the reader nothing. Fall back, in order, to the
|
||||||
|
constrained path, then to the named NodeShape that owns the blank node,
|
||||||
|
then to the constraint component.
|
||||||
|
"""
|
||||||
|
shape = g.value(res, SH.sourceShape)
|
||||||
|
if shape is not None and not str(shape).startswith("n"):
|
||||||
|
name = str(shape).rsplit("/", 1)[-1]
|
||||||
|
if name and not name.startswith("N"):
|
||||||
|
return name
|
||||||
|
path = g.value(res, SH.resultPath)
|
||||||
|
if path is not None:
|
||||||
|
owner = None
|
||||||
|
for s_, p_, o_ in g.triples((None, SH.property, shape)):
|
||||||
|
owner = s_
|
||||||
|
break
|
||||||
|
base = str(path).rsplit("/", 1)[-1].rsplit("#", 1)[-1]
|
||||||
|
if owner is not None and not str(owner).startswith("n"):
|
||||||
|
return "%s / %s" % (str(owner).rsplit("/", 1)[-1], base)
|
||||||
|
return "path %s" % base
|
||||||
|
comp = g.value(res, SH.sourceConstraintComponent)
|
||||||
|
if comp is not None:
|
||||||
|
return str(comp).rsplit("#", 1)[-1]
|
||||||
|
return "(unnamed shape)"
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for res in results.subjects(RDF.type, SH.ValidationResult):
|
for res in results.subjects(RDF.type, SH.ValidationResult):
|
||||||
sev = str(results.value(res, SH.resultSeverity)).rsplit("#", 1)[-1]
|
sev = str(results.value(res, SH.resultSeverity)).rsplit("#", 1)[-1]
|
||||||
focus = str(results.value(res, SH.focusNode)).rsplit("/", 1)[-1]
|
focus = str(results.value(res, SH.focusNode)).rsplit("/", 1)[-1]
|
||||||
msg = str(results.value(res, SH.resultMessage) or "")
|
msg = str(results.value(res, SH.resultMessage) or "")
|
||||||
src = str(results.value(res, SH.sourceShape) or "").rsplit("/", 1)[-1]
|
rows.append((sev, shape_label(results, res, SH), focus, msg))
|
||||||
rows.append((sev, src, focus, msg))
|
|
||||||
|
|
||||||
violations = sum(1 for r in rows if r[0] == "Violation")
|
violations = sum(1 for r in rows if r[0] == "Violation")
|
||||||
warnings = len(rows) - violations
|
warnings = len(rows) - violations
|
||||||
|
|||||||
Reference in New Issue
Block a user