Validate INSPIRE GML Application Schemas Jump to heading

Publishing into a pan-European download service means every GML 3.2.1 feature collection must satisfy the INSPIRE application schema for its theme — not just be well-formed XML. A dataset that parses cleanly can still violate the schema: a missing gml:id, an nilReason used where the schema forbids nilling, a coordinate reference declared as a bare EPSG integer instead of the INSPIRE URN form, or a code-list value that no controlled vocabulary contains. This procedure sits inside INSPIRE Directive Schema Compliance and owns the runnable validation step: it takes an already-transformed GML file and proves, deterministically and offline, that it conforms to the published XSD and the ISO Schematron business rules that the directive layers on top. It assumes the upstream stage already produced Annex-shaped features; here we verify them and route failures with actionable diagnostics.

The walkthrough maps 1:1 to the phases this site standardizes on — configure (Step 1, mirror the schema tree and build a catalog), execute (Steps 2–3, XSD then Schematron), validate (Step 4, interpret and classify errors), and log (Step 5, report and CI gate). The single hardest part is not the validation call itself but making remote schema imports resolve reliably: the INSPIRE XSDs import dozens of GML, ISO 19139, and cross-theme schemas by absolute URL, and validating against the live network is slow, non-reproducible, and fails the moment a schema host is unreachable. A local catalog fixes that.

INSPIRE GML Validation Pipeline A GML 3.2.1 feature collection is validated in two ordered stages against a locally cached schema tree resolved through an XML catalog. The first stage is lxml XSD structural validation; the second is ISO Schematron business-rule evaluation. Conforming files produce a machine-readable validation report; structural and semantic failures route to a classified error log for triage. CONFIGURE EXECUTE / VALIDATE LOG Cached schema tree XML catalog resolves imports GML 3.2.1 feature collection XSD validate structure · types Schematron business rules Report pass / fail Classified error log structural · semantic · reference fail

Prerequisites checklist Jump to heading

Confirm the toolchain and the schema mirror before running any validation pass. The single most common cause of a validator that “works on my machine” but fails in CI is an implicit network fetch of a remote schema import, so pin the schema cache explicitly.

Step 1: Mirror the schema tree and build a catalog Jump to heading

INSPIRE application schemas do not stand alone. The Land Cover XSD imports the GML 3.2.1 schema, which imports XLink and the SMIL animation schema; the base types import ISO 19139 metadata schemas. Validating against live URLs makes every run depend on remote availability and silently changes behaviour when a host updates a schema. Mirror the tree once, then resolve all imports through an XML catalog so validation is offline and byte-reproducible.

python
# requires: python >=3.10, PyYAML >=6.0, lxml >=5.1
import os
from pathlib import Path
from lxml import etree

# Point libxml2 at the local catalog BEFORE any parser is created.
CATALOG = Path("schemas/catalog.xml").resolve()
os.environ["XML_CATALOG_FILES"] = str(CATALOG)


def load_schema(xsd_path: Path) -> etree.XMLSchema:
    """Compile the theme XSD; imports resolve through the catalog, never the network."""
    # no_network=True turns any un-cataloged remote import into a hard error
    parser = etree.XMLParser(no_network=True, resolve_entities=False)
    doc = etree.parse(str(xsd_path), parser=parser)
    return etree.XMLSchema(doc)

Configuration rules:

  • Set XML_CATALOG_FILES before the first parser is instantiated; libxml2 reads it at load time.
  • Pass no_network=True so a missing catalog entry fails loudly instead of fetching a schema.
  • Pin the mirror to a specific INSPIRE schema release and record that version in the report lineage.
  • Keep one catalog.xml per schema release so upgrades are an explicit, reviewable change.

Step 2: Run structural XSD validation Jump to heading

XSD validation checks structure and datatypes: element order, required attributes such as gml:id, geometry type membership, and the coordinate-tuple encoding. Compile the theme schema once and reuse it across the batch — recompilation dominates runtime otherwise. Collect every error rather than stopping at the first, so an operator fixes a file in one pass instead of re-running per defect.

python
# requires: lxml >=5.1  (Python 3.10+)
from dataclasses import dataclass, field
from pathlib import Path
from lxml import etree


@dataclass
class ValidationResult:
    source: str
    xsd_errors: list[str] = field(default_factory=list)
    schematron_errors: list[str] = field(default_factory=list)

    @property
    def conforms(self) -> bool:
        return not self.xsd_errors and not self.schematron_errors


def validate_xsd(gml_path: Path, schema: etree.XMLSchema) -> ValidationResult:
    result = ValidationResult(source=gml_path.name)
    parser = etree.XMLParser(no_network=True, resolve_entities=False, huge_tree=True)
    tree = etree.parse(str(gml_path), parser=parser)

    if not schema.validate(tree):
        for err in schema.error_log:  # full log, not just the first failure
            result.xsd_errors.append(
                f"line {err.line}: {err.message} ({err.type_name})"
            )
    return result

Execution rules:

  • Reuse one compiled XMLSchema for the whole batch; do not recompile per file.
  • Enable huge_tree=True for national feature collections that exceed libxml2’s default node limits.
  • Preserve err.line and err.type_name — they are the difference between a fixable report and noise.
  • Treat a passing XSD run as necessary but not sufficient; business rules run next.

Step 3: Apply Schematron business rules Jump to heading

XSD cannot express many INSPIRE constraints: that a nilReason is only permitted for a specific set of voidable properties, that a code-list value belongs to a controlled vocabulary, or that a beginLifespanVersion precedes an endLifespanVersion. INSPIRE ships these as ISO Schematron. lxml runs them directly through isoschematron, which compiles the rules to XSLT and reports each failed assertion with its human-readable message.

python
# requires: lxml >=5.1  (Python 3.10+)
from pathlib import Path
from lxml import etree
from lxml.isoschematron import Schematron


def validate_schematron(gml_path: Path, sch_path: Path, result: ValidationResult) -> ValidationResult:
    parser = etree.XMLParser(no_network=True, resolve_entities=False)
    rules = Schematron(etree.parse(str(sch_path), parser=parser), store_report=True)
    tree = etree.parse(str(gml_path), parser=parser)

    if not rules.validate(tree):
        # Each failed-assert carries the rule's own diagnostic text.
        ns = {"svrl": "http://purl.oclc.org/dsdl/svrl"}
        for failed in rules.validation_report.iterfind(".//svrl:failed-assert", ns):
            location = failed.get("location", "")
            text = failed.findtext("svrl:text", default="", namespaces=ns).strip()
            result.schematron_errors.append(f"{location}: {text}")
    return result

Business-rule guidance:

  • Run Schematron only after XSD passes; an ill-formed document produces misleading assertion output.
  • Keep the SVRL report (store_report=True) so the exact rule and location survive into the log.
  • Version the Schematron ruleset alongside the XSD mirror; the two must come from the same INSPIRE release.
  • Do not hand-edit the shipped rules; extend them in a separate overlay file if a national profile adds constraints.

Step 4: Interpret and classify failures Jump to heading

Raw parser messages overwhelm operators. Classify each failure so remediation is routed to the right owner: structural errors (missing element, wrong order) are a transform bug; semantic errors (bad code-list value, cardinality) are a data-content problem; reference errors (unresolvable xlink:href, wrong CRS URN) are usually an upstream identifier or projection issue. The CRS-URN class in particular routes back to CRS Normalization & Sync, which owns the canonical urn:ogc:def:crs forms INSPIRE mandates.

python
# requires: python >=3.10
def classify(message: str) -> str:
    m = message.lower()
    if "not expected" in m or "missing child" in m or "content" in m:
        return "structural"
    if "urn:ogc:def:crs" in m or "xlink:href" in m or "unresolved" in m:
        return "reference"
    return "semantic"


def summarize(result: ValidationResult) -> dict:
    buckets: dict[str, list[str]] = {"structural": [], "semantic": [], "reference": []}
    for msg in (*result.xsd_errors, *result.schematron_errors):
        buckets[classify(msg)].append(msg)
    return {"source": result.source, "conforms": result.conforms, "by_class": buckets}

Classification rules:

  • Route structural failures to the transform stage; they mean the producer emitted the wrong shape.
  • Route reference failures to identifier and CRS normalization; a bad xlink:href or non-URN CRS is not a schema bug.
  • Route semantic failures to data stewards; a code-list miss needs a content correction, not code.
  • Never silently drop a failing file — every error class contributes a row to the report.

Step 5: Emit a report and gate in CI Jump to heading

Validation is only defensible if the verdict is machine-readable and reproducible. Write one report per batch and fail the build on any structural or mandatory Schematron violation. Publishing that report as a conformance scorecard is owned by the compliance discipline — feed this output into CI validation scorecard publishing so reviewers see a trend, not a single red X.

yaml
# .github/workflows/inspire-gml-validate.yml
name: INSPIRE GML Schema Validation
on: [pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: pip install "lxml>=5.1" "PyYAML>=6.0"
      - name: Restore schema mirror cache
        uses: actions/cache@v4
        with:
          path: schemas/
          key: inspire-schemas-4.0
      - name: Validate GML against XSD and Schematron
        run: python -m pipeline.validate_gml data/gml/ --report reports/gml_report.json
      - name: Upload validation report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: inspire-gml-report
          path: reports/gml_report.json

CI rules:

  • Cache the schema mirror so validation never touches the network on the runner.
  • Exit non-zero on any structural error or failed mandatory assertion; block the merge.
  • Emit the report even on failure (if: always()) so the scorecard always has data.
  • Record the INSPIRE schema release in the report so a verdict is reproducible against the exact rules that produced it.

Verification Jump to heading

Confirm the validator actually distinguishes conformant from non-conformant input — a validator that passes everything is worse than none. Assert against a known-good and a known-bad fixture, and confirm imports resolved offline:

python
schema = load_schema(Path("schemas/inspire/LandCover.xsd"))

good = validate_xsd(Path("tests/fixtures/landcover_valid.gml"), schema)
assert good.conforms, f"valid fixture unexpectedly failed: {good.xsd_errors}"

bad = validate_xsd(Path("tests/fixtures/landcover_missing_gmlid.gml"), schema)
assert not bad.conforms, "invalid fixture passed — schema not actually applied"
assert any("gml:id" in e for e in bad.xsd_errors), "expected missing gml:id error"

A healthy run prints no network-fetch lines and no I/O warning : failed to load external entity; that warning is the signature of a catalog miss. On the CLI, xmllint --noout --schema schemas/inspire/LandCover.xsd data/gml/sample.gml with XML_CATALOG_FILES set should agree with the Python verdict, and xmlcatalog schemas/catalog.xml "http://inspire.ec.europa.eu/schemas/gml/3.2.1/gml.xsd" should resolve to a local path.

Troubleshooting Jump to heading

Symptom Likely cause Fix
failed to load external entity during validation A remote xs:import is not in the catalog, so libxml2 tried the network Add the URI to catalog.xml and re-run; with no_network=True this becomes a hard, visible error.
Validation is slow and inconsistent between runs Schemas are being fetched live instead of from the mirror Confirm XML_CATALOG_FILES is set before any parser is created; verify with xmlcatalog.
XSD passes but the INSPIRE validator rejects the file Business rules only expressible in Schematron were skipped Run Step 3; XSD cannot check code-list membership or voidable cardinality.
XMLSchemaParseError on xs:import chains libxml2 too old to resolve nested imports Upgrade to libxml2 >= 2.9.12; rebuild lxml against it.
Every file reports a CRS reference error Geometry declares a bare EPSG:xxxx instead of the INSPIRE URN form Normalize to urn:ogc:def:crs:EPSG::xxxx upstream in CRS normalization, then re-validate.