Source code for pain001.schematron.parser
# Copyright (C) 2023-2026 Pain001. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
# Licensed under either of the Apache License, Version 2.0 or the MIT
# License, at your option. You may not use this file except in
# compliance with one of those licences. Copies are provided in
# LICENSE-APACHE and LICENSE-MIT.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licences is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the applicable Licence for the specific language
# governing permissions and limitations.
"""Parser for ISO Schematron (ISO/IEC 19757-3) schema files."""
from dataclasses import dataclass, field
from pathlib import Path
from defusedxml import ElementTree as defused_et
SCHEMATRON_NS_ISO = "http://purl.oclc.org/dsdl/schematron"
SCHEMATRON_NS_15 = "http://www.ascc.net/xml/schematron"
[docs]
@dataclass
class SchematronAssertion:
"""A single assert or report statement within a Schematron rule.
Attributes:
rule_id: Unique rule identifier (from id or rule attribute).
test: The XPath test expression.
message: Human-readable error/diagnostic message.
is_report: True if this is a <report> (fails when test is true).
severity: 'ERROR' or 'WARNING'.
"""
rule_id: str
test: str
message: str
is_report: bool = False
severity: str = "ERROR"
[docs]
@dataclass
class SchematronRule:
"""A rule element specifying context and containing assertions."""
context: str
assertions: list[SchematronAssertion] = field(default_factory=list)
[docs]
@dataclass
class SchematronPattern:
"""A pattern element grouping related rules."""
pattern_id: str
title: str
rules: list[SchematronRule] = field(default_factory=list)
[docs]
@dataclass
class SchematronSchema:
"""Parsed representation of an entire Schematron document."""
title: str
namespaces: dict[str, str] = field(default_factory=dict)
patterns: list[SchematronPattern] = field(default_factory=list)
@property
def total_assertions(self) -> int:
"""Count all assertions across all patterns and rules."""
return sum(
len(rule.assertions)
for pattern in self.patterns
for rule in pattern.rules
)
def _strip_ns(tag: str) -> str:
"""Strip XML namespace prefix enclosed in braces from tag name."""
if tag.startswith("{"):
return tag.split("}", 1)[1]
return tag
[docs]
def parse_schematron(source: str | bytes | Path) -> SchematronSchema:
"""Parse a Schematron (.sch) XML document into a SchematronSchema object.
Args:
source: XML string, bytes, or Path pointing to the .sch file.
Returns:
SchematronSchema representing parsed rules and namespaces.
Raises:
ValueError: If the source does not represent a valid Schematron document.
"""
if isinstance(source, Path):
content = source.read_text(encoding="utf-8")
root = defused_et.fromstring(content)
elif isinstance(source, str):
if source.strip().startswith("<"):
root = defused_et.fromstring(source)
else:
path = Path(source)
content = path.read_text(encoding="utf-8")
root = defused_et.fromstring(content)
elif isinstance(source, bytes):
root = defused_et.fromstring(source)
else:
raise ValueError(f"Unsupported Schematron source type: {type(source)}")
root_tag = _strip_ns(root.tag)
if root_tag != "schema":
raise ValueError(f"Root element must be 'schema', got '{root.tag}'")
title = ""
namespaces: dict[str, str] = {}
patterns: list[SchematronPattern] = []
for child in root:
tag = _strip_ns(child.tag)
if tag == "title":
title = (child.text or "").strip()
elif tag == "ns":
prefix = child.attrib.get("prefix", "").strip()
uri = child.attrib.get("uri", "").strip()
if prefix and uri:
namespaces[prefix] = uri
elif tag == "pattern":
pat_id = child.attrib.get(
"id", child.attrib.get("name", "pattern")
)
pat_title = ""
pat_rules: list[SchematronRule] = []
for p_child in child:
p_tag = _strip_ns(p_child.tag)
if p_tag == "title":
pat_title = (p_child.text or "").strip()
elif p_tag == "rule":
context = p_child.attrib.get("context", ".").strip()
rule_assertions: list[SchematronAssertion] = []
for r_child in p_child:
r_tag = _strip_ns(r_child.tag)
if r_tag in ("assert", "report"):
test = r_child.attrib.get("test", "").strip()
rule_id = r_child.attrib.get(
"id",
r_child.attrib.get(
"flag", f"RULE-{len(rule_assertions) + 1}"
),
)
role = r_child.attrib.get("role", "error").lower()
severity = (
"WARNING"
if role in ("warn", "warning", "info")
else "ERROR"
)
msg = " ".join("".join(r_child.itertext()).split())
rule_assertions.append(
SchematronAssertion(
rule_id=rule_id,
test=test,
message=msg
or f"Assertion {rule_id} failed",
is_report=(r_tag == "report"),
severity=severity,
)
)
if rule_assertions:
pat_rules.append(
SchematronRule(
context=context,
assertions=rule_assertions,
)
)
patterns.append(
SchematronPattern(
pattern_id=pat_id,
title=pat_title,
rules=pat_rules,
)
)
return SchematronSchema(
title=title,
namespaces=namespaces,
patterns=patterns,
)