From b07e8faf2ecbcceb0dce734a050686528adae2b0 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Fri, 31 Jul 2026 10:56:26 -0600 Subject: [PATCH 1/2] mdparsing: Allow indented list items to span multiple lines Markdown allows list items to span multiple lines, as long as as each subsequent non-blank line are indented at least the same amount as the first line. Support this syntax when parsing nested list sections by aggregating the value across multiple lines that follow this rule. The key must still be on the same line as the "-" For example, this is now allowed: - foo - bar: Still part of bar - baz: Part of baz --- spec_parser/mdparsing.py | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/spec_parser/mdparsing.py b/spec_parser/mdparsing.py index 1dbd18d..925a015 100644 --- a/spec_parser/mdparsing.py +++ b/spec_parser/mdparsing.py @@ -4,6 +4,7 @@ import logging import re +import textwrap logger = logging.getLogger(__name__) @@ -81,24 +82,53 @@ def load(self, content): class NestedListSection(Section): RE_EXTRACT_TOP_LEVEL = re.compile(r"-\s+((\w|/)+)") - RE_EXTRACT_KEY_VALUE = re.compile(r"\s+-\s+(\w+):\s+(.+)") + RE_EXTRACT_KEY_VALUE = re.compile(r"(\s+-\s+)(\w+):((\s+.+)?)") def load(self, content): self.content = content self.ikv = dict() + item = None + key = None + value = None + indent = None + + def add_value(): + nonlocal item + nonlocal key + nonlocal value + if not key: + return + + self.ikv[item][key] = textwrap.dedent(value) + key = None + for i,l in enumerate(content.splitlines()): + if not l.rstrip(): + continue + if l.startswith("-"): m = re.fullmatch(self.RE_EXTRACT_TOP_LEVEL, l) if m is None: logger.error(self._fmt_err_msg("Top-level nested list parsing error", i+1, l)) else: + add_value() item = m.group(1) self.ikv[item] = dict() + elif item is None: + logger.error(self._fmt_err_msg("Top-level nested list parsing error", i+1, l)) else: m = re.fullmatch(self.RE_EXTRACT_KEY_VALUE, l) if m is None: - logger.error(self._fmt_err_msg("Nested list parsing error", i+1, l)) + if indent and l.startswith(" " * len(indent)): + if value: + value += "\n" + value += l + else: + logger.error(self._fmt_err_msg("Nested list parsing error", i+1, l)) else: - key = m.group(1) - val = m.group(2).strip() - self.ikv[item][key] = val + add_value() + indent = m.group(1) + key = m.group(2) + value = m.group(3).strip() + + add_value() From 589d9567feca68d63b55eabcd8ae3d0a64fdca30 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Thu, 30 Jul 2026 16:38:45 -0600 Subject: [PATCH 2/2] model: Add SPARQL sections Adds a section of SPARQL queries to classes, vocabularies, and properties. If any of these are present, they will be attached as validation queries to the class in which they are bound. --- spec_parser/model.py | 18 ++++++++++++++++++ spec_parser/rdf.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/spec_parser/model.py b/spec_parser/model.py index bebef4b..1063fff 100644 --- a/spec_parser/model.py +++ b/spec_parser/model.py @@ -264,6 +264,12 @@ def __init__(self, fname, ns): else: self.ext_prop_restrs = dict() + if "SPARQL" in sf.sections: + s = NestedListSection(sf.sections["SPARQL"], filename=self.fqname, context="sparql") + self.sparql = s.ikv + else: + self.sparql = dict() + # checks assert self.name == self.metadata["name"], f"Class name {self.name} does not match metadata {self.metadata['name']}" for p in self.metadata: @@ -320,6 +326,12 @@ def __init__(self, fname, ns): s = SingleListSection(sf.sections["Metadata"], filename=self.fqname, context="metadata") self.metadata = s.kv + if "SPARQL" in sf.sections: + s = NestedListSection(sf.sections["SPARQL"], filename=self.fqname, context="sparql") + self.sparql = s.ikv + else: + self.sparql = dict() + # checks assert self.name == self.metadata["name"], f"Property name {self.name} does not match metadata {self.metadata['name']}" for p in self.metadata: @@ -355,6 +367,12 @@ def __init__(self, fname, ns): s = SingleListSection(sf.sections["Entries"], filename=self.fqname, context="entries") self.entries = s.kv + if "SPARQL" in sf.sections: + s = NestedListSection(sf.sections["SPARQL"], filename=self.fqname, context="sparql") + self.sparql = s.ikv + else: + self.sparql = dict() + # checks assert self.name == self.metadata["name"], f"Vocabulary name {self.name} does not match metadata {self.metadata['name']}" for p in self.metadata: diff --git a/spec_parser/rdf.py b/spec_parser/rdf.py index 70a3745..1212b03 100644 --- a/spec_parser/rdf.py +++ b/spec_parser/rdf.py @@ -83,6 +83,16 @@ def get_parent(model, c): return None +def add_sparql(g, target, sparql): + snode = BNode() + g.add((snode, RDF.type, SH.SPARQLConstraint)) + g.add((snode, SH.message, Literal(sparql["message"]))) + + query = f"BASE <{URI_BASE}>\n" + sparql["query"] + g.add((snode, SH.select, Literal(query))) + + g.add((target, SH.sparql, snode)) + def gen_rdf_classes(model, g): for c in model.classes.values(): node = URIRef(c.iri) @@ -110,6 +120,9 @@ def gen_rdf_classes(model, g): else: g.add((node, SH.nodeKind, SH.BlankNodeOrIRI)) + for s in c.sparql.values(): + add_sparql(g, node, s) + if c.properties: g.add((node, RDF.type, SH.NodeShape)) for p in c.properties: @@ -119,6 +132,10 @@ def gen_rdf_classes(model, g): bnode = BNode() g.add((node, SH.property, bnode)) prop = model.properties[fqprop] + + for s in prop.sparql.values(): + add_sparql(g, node, s) + g.add((bnode, SH.path, URIRef(prop.iri))) prop_rng = prop.metadata["Range"] if ":" not in prop_rng: @@ -169,6 +186,8 @@ def gen_rdf_classes(model, g): for e in dt.entries: lst.append(URIRef(dt.iri + "/" + e)) g.add((bnode, SH["in"], lst.uri)) + for s in dt.sparql.values(): + add_sparql(g, node, s) elif typename in model.datatypes: dt = model.datatypes[typename] if "pattern" in dt.format: