Files

1053 lines
39 KiB
Python

#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Internal XSD model and constraint validation for the Slides lint entrypoint."""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from pathlib import Path
from typing import Any
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
SML_NAMESPACE = "https://www.larkoffice.com/sml/2.0"
SML_LEGACY_HTTP_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
SML_READBACK_NAMESPACE = "/sml/2.0"
ACCEPTED_SML_NAMESPACES = frozenset(
(SML_NAMESPACE, SML_LEGACY_HTTP_NAMESPACE, SML_READBACK_NAMESPACE)
)
def local_name(value: str) -> str:
if value.startswith("{"):
return value.rsplit("}", 1)[-1]
return value.rsplit(":", 1)[-1]
def direct_children(element: ET.Element, name: str) -> list[ET.Element]:
return [child for child in element if child.tag == f"{XS_NS}{name}"]
def first_direct_child(element: ET.Element, *names: str) -> ET.Element | None:
wanted = {f"{XS_NS}{name}" for name in names}
return next((child for child in element if child.tag in wanted), None)
def occurs_value(raw: str | None, default: int) -> int | None:
if raw == "unbounded":
return None
return int(raw) if raw is not None else default
@dataclass(frozen=True)
class SimpleTypeRule:
name: str
base: str | None = None
enums: tuple[str, ...] = ()
patterns: tuple[str, ...] = ()
bounds: tuple[tuple[str, Decimal], ...] = ()
length_bounds: tuple[tuple[str, int], ...] = ()
union_members: tuple[str, ...] = ()
@dataclass(frozen=True)
class AttributeRule:
name: str
type_name: str
required: bool
@dataclass(frozen=True)
class ElementRule:
name: str
type_name: str | None
inline_complex_type: ET.Element | None
ref_name: str | None
@dataclass(frozen=True)
class ChildRule:
element: ElementRule
min_occurs: int
max_occurs: int | None
order: int | None
@dataclass(frozen=True)
class WildcardRule:
namespace: str
process_contents: str
min_occurs: int
max_occurs: int | None
order: int | None
@dataclass(frozen=True)
class ChoiceRequirement:
names: tuple[str, ...]
min_occurs: int
max_occurs: int | None
@dataclass(frozen=True)
class SchemaModel:
simple_types: dict[str, SimpleTypeRule]
complex_types: dict[str, ET.Element]
element_candidates: dict[str, tuple[ElementRule, ...]]
global_elements: dict[str, ElementRule]
def parse_simple_type(
element: ET.Element,
fallback_name: str,
simple_types: dict[str, SimpleTypeRule] | None = None,
) -> SimpleTypeRule:
name = element.attrib.get("name", fallback_name)
restriction = first_direct_child(element, "restriction")
union = first_direct_child(element, "union")
if union is not None:
union_members = [
local_name(member) for member in union.attrib.get("memberTypes", "").split()
]
for index, inline_simple in enumerate(direct_children(union, "simpleType"), start=1):
inline_name = f"__inline_union_member_{name}_{index}"
union_members.append(inline_name)
if simple_types is not None:
simple_types[inline_name] = parse_simple_type(
inline_simple,
inline_name,
simple_types,
)
return SimpleTypeRule(
name=name,
union_members=tuple(union_members),
)
if restriction is None:
return SimpleTypeRule(name=name)
facet_names = {
"minInclusive",
"minExclusive",
"maxInclusive",
"maxExclusive",
}
bounds: list[tuple[str, Decimal]] = []
length_bounds: list[tuple[str, int]] = []
for child in restriction:
facet = local_name(child.tag)
if "value" not in child.attrib:
continue
if facet in facet_names:
bounds.append((facet, Decimal(child.attrib["value"])))
elif facet in {"minLength", "maxLength"}:
length_bounds.append((facet, int(child.attrib["value"])))
return SimpleTypeRule(
name=name,
base=local_name(restriction.attrib.get("base", "string")),
enums=tuple(child.attrib["value"] for child in direct_children(restriction, "enumeration")),
patterns=tuple(child.attrib["value"] for child in direct_children(restriction, "pattern")),
bounds=tuple(bounds),
length_bounds=tuple(length_bounds),
)
def parse_element_rule(element: ET.Element) -> ElementRule | None:
raw_ref = element.attrib.get("ref")
name = element.attrib.get("name")
if name is None and raw_ref:
name = local_name(raw_ref)
if not name:
return None
return ElementRule(
name=name,
type_name=local_name(element.attrib["type"]) if element.attrib.get("type") else None,
inline_complex_type=first_direct_child(element, "complexType"),
ref_name=local_name(raw_ref) if raw_ref else None,
)
@lru_cache(maxsize=4)
def load_schema_model(schema_path: str) -> SchemaModel:
root = ET.parse(schema_path).getroot()
simple_types: dict[str, SimpleTypeRule] = {}
for element in direct_children(root, "simpleType"):
name = element.attrib.get("name")
if not name:
continue
simple_types[name] = parse_simple_type(element, name, simple_types)
for attribute in root.iter(f"{XS_NS}attribute"):
inline_simple = first_direct_child(attribute, "simpleType")
if inline_simple is None:
continue
inline_name = f"__inline_attribute_{attribute.attrib.get('name', 'anonymous')}_{id(attribute)}"
simple_types[inline_name] = parse_simple_type(inline_simple, inline_name, simple_types)
complex_types = {
element.attrib["name"]: element
for element in direct_children(root, "complexType")
if element.attrib.get("name")
}
candidates: dict[str, list[ElementRule]] = {}
for element in root.iter(f"{XS_NS}element"):
rule = parse_element_rule(element)
if rule is not None:
candidates.setdefault(rule.name, []).append(rule)
global_elements = {
rule.name: rule
for element in direct_children(root, "element")
if (rule := parse_element_rule(element)) is not None
}
return SchemaModel(
simple_types=simple_types,
complex_types=complex_types,
element_candidates={name: tuple(rules) for name, rules in candidates.items()},
global_elements=global_elements,
)
def attributes_for_complex_type(
complex_type: ET.Element,
model: SchemaModel,
resolving: set[str] | None = None,
) -> dict[str, AttributeRule]:
resolving = resolving or set()
attributes: dict[str, AttributeRule] = {}
for content_name in ("simpleContent", "complexContent"):
content = first_direct_child(complex_type, content_name)
if content is None:
continue
extension = first_direct_child(content, "extension")
if extension is None:
continue
base_name = local_name(extension.attrib.get("base", ""))
if base_name in model.complex_types and base_name not in resolving:
resolving.add(base_name)
attributes.update(attributes_for_complex_type(model.complex_types[base_name], model, resolving))
resolving.remove(base_name)
attributes.update(direct_attribute_rules(extension))
attributes.update(direct_attribute_rules(complex_type))
return attributes
def direct_attribute_rules(element: ET.Element) -> dict[str, AttributeRule]:
rules: dict[str, AttributeRule] = {}
for attribute in direct_children(element, "attribute"):
name = attribute.attrib.get("name")
if not name:
continue
type_name = local_name(attribute.attrib.get("type", "string"))
inline_simple = first_direct_child(attribute, "simpleType")
if inline_simple is not None:
type_name = f"__inline_attribute_{name}_{id(attribute)}"
rules[name] = AttributeRule(
name=name,
type_name=type_name,
required=attribute.attrib.get("use") == "required",
)
return rules
def attributes_for_element(rule: ElementRule, model: SchemaModel) -> dict[str, AttributeRule]:
complex_type = rule.inline_complex_type
if complex_type is None and rule.type_name in model.complex_types:
complex_type = model.complex_types[rule.type_name]
if complex_type is None:
return {}
return attributes_for_complex_type(complex_type, model)
def best_element_rule(element_name: str, model: SchemaModel) -> ElementRule | None:
candidates = model.element_candidates.get(element_name, ())
if not candidates:
return None
return max(
candidates,
key=lambda candidate: (
candidate.type_name is not None or candidate.inline_complex_type is not None,
len(attributes_for_element(candidate, model)),
),
)
def concrete_element_rule(rule: ElementRule, model: SchemaModel) -> ElementRule:
if rule.ref_name is not None:
return model.global_elements.get(rule.ref_name, rule)
if rule.type_name is not None or rule.inline_complex_type is not None:
return rule
candidate = best_element_rule(rule.name, model)
return candidate or rule
def complex_type_for_element(rule: ElementRule, model: SchemaModel) -> ET.Element | None:
rule = concrete_element_rule(rule, model)
if rule.inline_complex_type is not None:
return rule.inline_complex_type
if rule.type_name is not None:
return model.complex_types.get(rule.type_name)
return None
def particle_for_complex_type(complex_type: ET.Element) -> ET.Element | None:
particle = first_direct_child(complex_type, "sequence", "all", "choice")
if particle is not None:
return particle
for content_name in ("simpleContent", "complexContent"):
content = first_direct_child(complex_type, content_name)
if content is None:
continue
extension = first_direct_child(content, "extension")
if extension is not None:
return first_direct_child(extension, "sequence", "all", "choice")
return None
def multiplied_max(left: int | None, right: int | None) -> int | None:
if left is None or right is None:
return None
return left * right
def child_rules_for_complex_type(
complex_type: ET.Element,
) -> tuple[list[ChildRule], list[WildcardRule], list[ChoiceRequirement]]:
particle = particle_for_complex_type(complex_type)
if particle is None:
return [], [], []
rules: list[ChildRule] = []
wildcard_rules: list[WildcardRule] = []
requirements: list[ChoiceRequirement] = []
next_order = 0
def add_element(
element: ET.Element,
*,
order: int | None,
optional_by_choice: bool,
max_multiplier: int | None,
) -> None:
element_rule = parse_element_rule(element)
if element_rule is None:
return
minimum = occurs_value(element.attrib.get("minOccurs"), 1) or 0
maximum = occurs_value(element.attrib.get("maxOccurs"), 1)
rules.append(
ChildRule(
element=element_rule,
min_occurs=0 if optional_by_choice else minimum,
max_occurs=multiplied_max(maximum, max_multiplier),
order=order,
)
)
def add_wildcard(
wildcard: ET.Element,
*,
order: int | None,
optional_by_choice: bool,
max_multiplier: int | None,
) -> None:
minimum = occurs_value(wildcard.attrib.get("minOccurs"), 1) or 0
maximum = occurs_value(wildcard.attrib.get("maxOccurs"), 1)
wildcard_rules.append(
WildcardRule(
namespace=wildcard.attrib.get("namespace", "##any"),
process_contents=wildcard.attrib.get("processContents", "strict"),
min_occurs=0 if optional_by_choice else minimum,
max_occurs=multiplied_max(maximum, max_multiplier),
order=order,
)
)
def walk_group(
group: ET.Element,
*,
ordered: bool,
fixed_order: int | None = None,
optional_by_choice: bool = False,
max_multiplier: int | None = 1,
) -> None:
nonlocal next_order
kind = local_name(group.tag)
group_min = occurs_value(group.attrib.get("minOccurs"), 1) or 0
group_max = occurs_value(group.attrib.get("maxOccurs"), 1)
effective_max = multiplied_max(max_multiplier, group_max)
if kind == "choice":
choice_order = fixed_order
if choice_order is None and ordered:
choice_order = next_order
next_order += 1
names: list[str] = []
for child in group:
child_kind = local_name(child.tag)
if child_kind == "element":
parsed = parse_element_rule(child)
if parsed is not None:
names.append(parsed.name)
add_element(
child,
order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
elif child_kind == "any":
add_wildcard(
child,
order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
elif child_kind in {"sequence", "all", "choice"}:
walk_group(
child,
ordered=ordered,
fixed_order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
if names and (group_min > 0 or effective_max is not None):
requirements.append(ChoiceRequirement(tuple(names), group_min, effective_max))
return
group_ordered = kind == "sequence"
for child in group:
child_kind = local_name(child.tag)
if child_kind == "element":
child_order = fixed_order
if child_order is None and ordered and group_ordered:
child_order = next_order
next_order += 1
add_element(
child,
order=child_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
elif child_kind == "any":
child_order = fixed_order
if child_order is None and ordered and group_ordered:
child_order = next_order
next_order += 1
add_wildcard(
child,
order=child_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
elif child_kind in {"sequence", "all", "choice"}:
walk_group(
child,
ordered=ordered and group_ordered,
fixed_order=fixed_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
walk_group(particle, ordered=local_name(particle.tag) == "sequence")
return rules, wildcard_rules, requirements
def issue(
code: str,
path: str,
tag: str,
*,
attr: str | None,
expected: str,
actual: Any,
message: str,
hint: str,
) -> dict[str, Any]:
result: dict[str, Any] = {
"level": "error",
"code": code,
"path": path,
"tag": tag,
"expected": expected,
"actual": actual,
"message": message,
"hint": hint,
}
if attr is not None:
result["attr"] = attr
return result
def builtin_scalar_value(type_name: str, value: str) -> Decimal | str | bool:
if type_name in {"string", "anyURI"}:
return value
if type_name == "boolean":
if value not in {"true", "false", "1", "0"}:
raise ValueError("expected boolean")
return value in {"true", "1"}
if type_name in {"integer", "positiveInteger", "nonNegativeInteger"}:
if re.fullmatch(r"[+-]?\d+", value) is None:
raise ValueError("expected integer")
number = Decimal(value)
if type_name == "positiveInteger" and number <= 0:
raise ArithmeticError("expected positive integer")
if type_name == "nonNegativeInteger" and number < 0:
raise ArithmeticError("expected non-negative integer")
return number
if type_name in {"double", "decimal"}:
lexical_value = value.strip(" \t\n\r")
decimal_pattern = r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)"
double_pattern = decimal_pattern + r"(?:[eE][+-]?[0-9]+)?"
expected_pattern = double_pattern if type_name == "double" else decimal_pattern
if re.fullmatch(expected_pattern, lexical_value) is None:
raise ValueError(f"expected {type_name}")
try:
number = Decimal(lexical_value)
except InvalidOperation as error:
raise ValueError(f"expected {type_name}") from error
if not math.isfinite(float(number)):
raise ValueError(f"expected finite {type_name}")
return number
return value
def scalar_value_for_type(
type_name: str,
value: str,
model: SchemaModel,
resolving: set[str] | None = None,
) -> Decimal | str | bool:
resolving = resolving or set()
if type_name in resolving:
return value
rule = model.simple_types.get(type_name)
if rule is None or rule.base is None:
return builtin_scalar_value(type_name, value)
resolving.add(type_name)
try:
return scalar_value_for_type(rule.base, value, model, resolving)
finally:
resolving.remove(type_name)
@lru_cache(maxsize=32)
def python_pattern_for_xsd(pattern: str) -> str:
translated: list[str] = []
in_character_class = False
index = 0
while index < len(pattern):
char = pattern[index]
if char == "\\" and index + 1 < len(pattern):
escaped = pattern[index + 1]
if escaped in {"s", "S"}:
body = r" \t\n\r"
if in_character_class:
if escaped == "S":
raise ValueError(
f"unsupported complemented XSD character class \\{escaped} inside []"
)
translated.append(body)
else:
prefix = "^" if escaped == "S" else ""
translated.append(f"[{prefix}{body}]")
index += 2
continue
translated.extend((char, escaped))
index += 2
continue
if char == "[":
in_character_class = True
elif char == "]":
in_character_class = False
elif char == "." and not in_character_class:
translated.append(r"[^\n\r]")
index += 1
continue
elif char in "^$" and not in_character_class:
translated.append(f"\\{char}")
index += 1
continue
translated.append(char)
index += 1
return "".join(translated)
def xsd_pattern_matches(pattern: str, value: str) -> bool:
if pattern == r"[\w.-]+[.:]\S*":
if any(character in " \t\n\r" for character in value):
return False
for index, character in enumerate(value):
if index > 0 and character in ".:":
return True
if not (character == "_" or character.isalnum() or character in ".-"):
return False
return False
return re.fullmatch(python_pattern_for_xsd(pattern), value) is not None
def value_error_for_type(
type_name: str,
value: str,
model: SchemaModel,
resolving: set[str] | None = None,
) -> tuple[str, str] | None:
resolving = resolving or set()
if type_name in resolving:
return None
rule = model.simple_types.get(type_name)
if rule is None:
try:
builtin_scalar_value(type_name, value)
except ValueError:
return "sxsd_invalid_scalar", f"value valid for {type_name}"
except ArithmeticError:
return "sxsd_value_out_of_range", f"value in the range allowed by {type_name}"
return None
resolving.add(type_name)
try:
if rule.union_members:
member_errors = [value_error_for_type(member, value, model, resolving) for member in rule.union_members]
if any(error is None for error in member_errors):
return None
unsupported_error = next(
(error for error in member_errors if error and error[0] == "sxsd_unsupported_pattern"),
None,
)
if unsupported_error is not None:
return unsupported_error
if any(error and error[0] == "sxsd_pattern_mismatch" for error in member_errors):
return "sxsd_pattern_mismatch", f"value matching one member of {type_name}"
return member_errors[0]
if rule.enums and value not in rule.enums:
return "sxsd_invalid_enum", "one of: " + ", ".join(rule.enums)
if rule.patterns:
unsupported_patterns: list[str] = []
for pattern in rule.patterns:
try:
if xsd_pattern_matches(pattern, value):
break
except (ValueError, re.error) as error:
unsupported_patterns.append(f"{pattern!r}: {error}")
else:
if unsupported_patterns:
return (
"sxsd_unsupported_pattern",
"lint support for XSD pattern " + "; ".join(unsupported_patterns),
)
return "sxsd_pattern_mismatch", "value matching pattern " + " or ".join(rule.patterns)
base_name = rule.base or "string"
base_error = value_error_for_type(base_name, value, model, resolving)
if base_error is not None:
return base_error
for facet, bound in rule.length_bounds:
allowed = len(value) >= bound if facet == "minLength" else len(value) <= bound
if not allowed:
return "sxsd_value_out_of_range", f"{facet} {bound}"
scalar = scalar_value_for_type(base_name, value, model)
if isinstance(scalar, Decimal):
for facet, bound in rule.bounds:
allowed = {
"minInclusive": scalar >= bound,
"minExclusive": scalar > bound,
"maxInclusive": scalar <= bound,
"maxExclusive": scalar < bound,
}[facet]
if not allowed:
return "sxsd_value_out_of_range", f"{facet} {bound}"
return None
finally:
resolving.remove(type_name)
def validate_element_attributes(
element: ET.Element,
path: str,
model: SchemaModel,
element_rule: ElementRule | None = None,
) -> list[dict[str, Any]]:
tag = local_name(element.tag)
element_rule = element_rule or best_element_rule(tag, model)
if element_rule is None:
return []
attribute_rules = attributes_for_element(element_rule, model)
issues: list[dict[str, Any]] = []
for attr_rule in attribute_rules.values():
if attr_rule.required and attr_rule.name not in element.attrib:
issues.append(
issue(
"sxsd_missing_required_attr",
path,
tag,
attr=attr_rule.name,
expected=f"required attribute of type {attr_rule.type_name}",
actual=None,
message=f'missing required SXSD attribute "{attr_rule.name}" on <{tag}> at {path}',
hint=f'Add attribute "{attr_rule.name}" with a value valid for {attr_rule.type_name}.',
)
)
for raw_name, value in element.attrib.items():
attr_name = local_name(raw_name)
attr_rule = attribute_rules.get(attr_name)
if attr_rule is None:
continue
validation_error = value_error_for_type(attr_rule.type_name, value, model)
if validation_error is None:
continue
code, expected = validation_error
if code == "sxsd_unsupported_pattern":
message = (
f'unsupported SXSD pattern for attribute "{attr_name}" on <{tag}> at {path}'
)
hint = (
f"Extend the SXSD pattern interpreter for {attr_rule.type_name}; "
"do not treat this attribute value as validated."
)
else:
message = (
f'invalid SXSD value {value!r} for attribute "{attr_name}" on <{tag}> at {path}'
)
hint = f'Set attribute "{attr_name}" to a value valid for {attr_rule.type_name}.'
issues.append(
issue(
code,
path,
tag,
attr=attr_name,
expected=expected,
actual=value,
message=message,
hint=hint,
)
)
return issues
def element_namespace(tag: str) -> str | None:
if not tag.startswith("{"):
return None
return tag[1:].split("}", 1)[0]
def wildcard_matches_namespace(namespace_rule: str, namespace: str | None) -> bool:
tokens = namespace_rule.split()
if "##any" in tokens:
return True
if "##local" in tokens and namespace is None:
return True
if "##targetNamespace" in tokens and namespace == SML_NAMESPACE:
return True
if "##other" in tokens and namespace not in {None, SML_NAMESPACE}:
return True
return namespace in tokens
def wildcard_namespace_description(namespace_rule: str) -> str:
if namespace_rule == "##any":
return "any namespace"
if namespace_rule == "##local":
return "the local namespace"
if namespace_rule == "##targetNamespace":
return f"the target namespace {SML_NAMESPACE}"
if namespace_rule == "##other":
return "a namespace other than the SXSD target namespace"
return f"namespace {namespace_rule}"
def validate_element_children(
element: ET.Element,
path: str,
element_rule: ElementRule,
model: SchemaModel,
) -> tuple[list[dict[str, Any]], dict[int, ElementRule]]:
tag = local_name(element.tag)
complex_type = complex_type_for_element(element_rule, model)
child_rules, wildcard_rules, choice_requirements = (
child_rules_for_complex_type(complex_type)
if complex_type is not None
else ([], [], [])
)
rules_by_name: dict[str, list[ChildRule]] = {}
for child_rule in child_rules:
rules_by_name.setdefault(child_rule.element.name, []).append(child_rule)
issues: list[dict[str, Any]] = []
matched: dict[int, ElementRule] = {}
counts: dict[str, int] = {}
wildcard_counts: dict[int, int] = {}
latest_order = -1
for child in element:
child_name = local_name(child.tag)
child_path = f"{path}/{child_name}"
candidates = rules_by_name.get(child_name, [])
wildcard_match = next(
(
(index, wildcard_rule)
for index, wildcard_rule in enumerate(wildcard_rules)
if wildcard_matches_namespace(
wildcard_rule.namespace,
element_namespace(child.tag),
)
),
None,
)
if not candidates and wildcard_match is None:
expected_children = sorted(rules_by_name)
expected_children.extend(
wildcard_namespace_description(rule.namespace) for rule in wildcard_rules
)
issues.append(
issue(
"sxsd_unexpected_child",
child_path,
child_name,
attr=None,
expected="one of: " + ", ".join(expected_children)
if expected_children
else "no child elements",
actual=child_name,
message=f"unexpected SXSD child <{child_name}> under <{tag}> at {child_path}",
hint=f"Move or remove <{child_name}> so <{tag}> follows the SXSD child structure.",
)
)
continue
if not candidates and wildcard_match is not None:
wildcard_index, wildcard_rule = wildcard_match
if wildcard_rule.order is not None:
if wildcard_rule.order < latest_order:
issues.append(
issue(
"sxsd_invalid_child_order",
child_path,
child_name,
attr=None,
expected="children in xs:sequence order",
actual=child_name,
message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}",
hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.",
)
)
latest_order = max(latest_order, wildcard_rule.order)
wildcard_counts[wildcard_index] = wildcard_counts.get(wildcard_index, 0) + 1
actual_count = wildcard_counts[wildcard_index]
if wildcard_rule.max_occurs is not None and actual_count > wildcard_rule.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
child_path,
child_name,
attr=None,
expected=f"at most {wildcard_rule.max_occurs} child from {wildcard_namespace_description(wildcard_rule.namespace)}",
actual=actual_count,
message=f"too many wildcard SXSD children under <{tag}> at {path}",
hint=f"Keep at most {wildcard_rule.max_occurs} child from {wildcard_namespace_description(wildcard_rule.namespace)} under <{tag}>.",
)
)
continue
child_rule = candidates[0]
if child_rule.order is not None:
if child_rule.order < latest_order:
issues.append(
issue(
"sxsd_invalid_child_order",
child_path,
child_name,
attr=None,
expected="children in xs:sequence order",
actual=child_name,
message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}",
hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.",
)
)
latest_order = max(latest_order, child_rule.order)
counts[child_name] = counts.get(child_name, 0) + 1
if child_rule.max_occurs is not None and counts[child_name] > child_rule.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
child_path,
child_name,
attr=None,
expected=f"at most {child_rule.max_occurs}",
actual=counts[child_name],
message=f"too many SXSD <{child_name}> children under <{tag}> at {path}",
hint=f"Keep at most {child_rule.max_occurs} <{child_name}> children under <{tag}>.",
)
)
matched[id(child)] = concrete_element_rule(child_rule.element, model)
for child_rule in child_rules:
child_name = child_rule.element.name
actual_count = counts.get(child_name, 0)
if child_rule.min_occurs <= actual_count:
continue
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"{child_name} (at least {child_rule.min_occurs})",
actual=actual_count,
message=f"missing required SXSD child <{child_name}> under <{tag}> at {path}",
hint=f"Add at least {child_rule.min_occurs} <{child_name}> child under <{tag}>.",
)
)
for wildcard_index, wildcard_rule in enumerate(wildcard_rules):
actual_count = wildcard_counts.get(wildcard_index, 0)
if wildcard_rule.min_occurs <= actual_count:
continue
namespace_description = wildcard_namespace_description(wildcard_rule.namespace)
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"at least {wildcard_rule.min_occurs} child from {namespace_description}",
actual=actual_count,
message=f"missing required wildcard SXSD child under <{tag}> at {path}",
hint=f"Add at least {wildcard_rule.min_occurs} child from {namespace_description} under <{tag}>.",
)
)
for requirement in choice_requirements:
actual_count = sum(counts.get(name, 0) for name in requirement.names)
expected_names = ", ".join(requirement.names)
if actual_count < requirement.min_occurs:
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"one of: {expected_names} (at least {requirement.min_occurs})",
actual=actual_count,
message=f"missing required SXSD choice child under <{tag}> at {path}",
hint=f"Add at least {requirement.min_occurs} child from: {expected_names}.",
)
)
if requirement.max_occurs is not None and actual_count > requirement.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
path,
tag,
attr=None,
expected=f"at most {requirement.max_occurs} child from: {expected_names}",
actual=actual_count,
message=f"too many SXSD choice children under <{tag}> at {path}",
hint=f"Keep at most {requirement.max_occurs} child from: {expected_names}.",
)
)
return issues, matched
def validate_sxsd(root: ET.Element, schema_path: Path) -> list[dict[str, Any]]:
model = load_schema_model(str(schema_path.resolve()))
issues: list[dict[str, Any]] = []
root_name = local_name(root.tag)
document_namespace = element_namespace(root.tag)
is_bare_slide_fragment = root_name == "slide" and document_namespace is None
has_valid_document_namespace = (
document_namespace in ACCEPTED_SML_NAMESPACES or is_bare_slide_fragment
)
def visit(element: ET.Element, parent_path: str, element_rule: ElementRule) -> None:
tag = local_name(element.tag)
path = f"{parent_path}/{tag}" if parent_path else tag
namespace = element_namespace(element.tag)
invalid_root_namespace = (
not parent_path
and namespace not in ACCEPTED_SML_NAMESPACES
and not is_bare_slide_fragment
)
invalid_descendant_namespace = (
bool(parent_path)
and has_valid_document_namespace
and namespace != document_namespace
)
if invalid_root_namespace or invalid_descendant_namespace:
expected_namespace = document_namespace if parent_path else SML_NAMESPACE
namespace_hint = (
"Keep SXSD descendants without xmlns in a bare <slide> readback fragment."
if expected_namespace is None
else f'Use xmlns="{expected_namespace}" for SXSD elements.'
)
issues.append(
issue(
"sxsd_invalid_namespace",
path,
tag,
attr=None,
expected=expected_namespace,
actual=namespace,
message=f"invalid SXSD namespace on <{tag}> at {path}",
hint=namespace_hint,
)
)
issues.extend(validate_element_attributes(element, path, model, element_rule))
child_issues, matched = validate_element_children(element, path, element_rule, model)
issues.extend(child_issues)
for child in element:
child_rule = matched.get(id(child))
if child_rule is not None:
visit(child, path, child_rule)
if root_name not in {"presentation", "slide"}:
issues.append(
issue(
"sxsd_unexpected_root",
root_name,
root_name,
attr=None,
expected="presentation or slide",
actual=root_name,
message=f"unsupported SXSD root <{root_name}>",
hint="Use a <presentation> or <slide> root.",
)
)
return issues
if root_name == "presentation":
root_rule = model.global_elements.get("presentation")
elif "SlideType" in model.complex_types:
root_rule = ElementRule("slide", "SlideType", None, None)
else:
root_rule = None
if root_rule is None:
issues.append(
issue(
"sxsd_unexpected_root",
root_name,
root_name,
attr=None,
expected="presentation or slide",
actual=root_name,
message=f"unsupported SXSD root <{root_name}>",
hint="Use a <presentation> or <slide> root.",
)
)
return issues
visit(root, "", root_rule)
return issues
def load_tag_attributes(schema_path: Path) -> dict[str, set[str]]:
model = load_schema_model(str(schema_path.resolve()))
tag_attributes: dict[str, set[str]] = {}
for tag_name, candidates in model.element_candidates.items():
attrs = tag_attributes.setdefault(tag_name, set())
for candidate in candidates:
attrs.update(attributes_for_element(concrete_element_rule(candidate, model), model))
return tag_attributes