Integrating PyLint with spatial metadata validators

Integrating PyLint with spatial metadata validators requires building a custom PyLint checker plugin that executes geospatial validation logic and maps failures directly to PyLint’s message reporting system. Because PyLint natively analyzes Python abstract syntax trees (AST), it does not parse XML, JSON, or YAML by default. You bridge this gap by subclassing pylint.checkers.BaseRawFileChecker, intercepting raw file paths, and routing them to dedicated spatial parsers — the result is a unified linting pass that enforces ISO 19115/19139 completeness, INSPIRE licensing declarations, and coordinate reference system (CRS) validation rules while returning standardized exit codes for automated CI pipelines.

This page is part of Spatial Data Schema Linting in CI, which sits within the broader CI/CD Validation & Policy Enforcement for Spatial Data framework. Read both for the pipeline context before implementing the checker below.

Automated Python implementation

The architecture relies on PyLint’s raw-file checker API. The plugin intercepts every file PyLint processes, filters by extension, parses the spatial metadata, and emits structured diagnostics. Message codes in the W9000–E9999 range are reserved for user-defined checkers and will not collide with PyLint’s built-in rules.

PyLint spatial metadata validation flow A left-to-right flowchart showing a CI trigger sending files to PyLint, which routes Python files through the AST checker and JSON/XML metadata files through the custom BaseRawFileChecker plugin. The plugin checks required fields, CRS format, and license validity, then emits W/E messages that feed into a final pass/fail gate. CI trigger push / PR pylint dispatcher .py files AST checker .json/.xml BaseRawFileChecker required fields CRS format license URI bounding box exit 0 clean exit 2 block

The complete, self-contained checker module below handles JSON and GeoJSON metadata files. Extend the _check_xml_metadata stub shown in the comments for ISO 19115 XML coverage.

# linters/spatial_metadata_checker.py
"""
Custom PyLint checker for spatial metadata files (JSON, GeoJSON).
Validates ISO 19115 completeness, CRS format, and licensing compliance.

Install: pip install pylint>=3.0
Usage:   pylint --rcfile=.pylintrc metadata.json
"""
import json
import re
from pylint.checkers import BaseRawFileChecker

# ---------------------------------------------------------------------------
# Message registry — W9xxx = warnings, E9xxx = errors
# ---------------------------------------------------------------------------
MSGS = {
    "W9001": (
        "Missing required spatial metadata field: %s",
        "missing-spatial-field",
        "A mandatory spatial metadata key is absent from this file.",
    ),
    "E9002": (
        "Invalid or missing license identifier: %s",
        "invalid-license-id",
        "License field must contain a valid SPDX identifier or CC/OGC URI.",
    ),
    "E9003": (
        "CRS declaration does not match EPSG or OGC URN format: %s",
        "invalid-crs-format",
        "CRS must be 'EPSG:XXXX' or 'urn:ogc:def:crs:EPSG::XXXX'.",
    ),
    "E9004": (
        "Cannot read or parse metadata file: %s",
        "unparseable-metadata-file",
        "The metadata file could not be opened or is not valid JSON.",
    ),
    "W9005": (
        "Bounding box is invalid: %s",
        "invalid-bounding-box",
        "extent must be [minX, minY, maxX, maxY] with min < max values.",
    ),
    "W9006": (
        "Temporal coverage value is not ISO 8601: %s",
        "invalid-temporal-coverage",
        "temporal.start and temporal.end must be ISO 8601 date strings.",
    ),
}

# Fields required by ISO 19115 identification and distribution info
REQUIRED_FIELDS = ["title", "description", "extent", "crs", "license"]

# CRS must be EPSG:NNNN or the OGC URN form
VALID_CRS = re.compile(
    r"^(EPSG:\d{4,}|urn:ogc:def:crs:EPSG::\d{4,})$"
)

# ISO 8601 date (YYYY-MM-DD) — extend the pattern for full datetimes if needed
ISO_8601_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z?)?$")

# A sentinel key that differentiates spatial metadata JSON from other JSON
SENTINEL_KEY = "spatial_metadata_version"


class SpatialMetadataChecker(BaseRawFileChecker):
    """PyLint checker that enforces spatial metadata compliance rules."""

    name = "spatial-metadata"
    msgs = MSGS
    priority = -1  # run after core checkers

    # ------------------------------------------------------------------ #
    # Entry point — called by PyLint for every file in scope              #
    # ------------------------------------------------------------------ #
    def process_module(self, node):
        file_path = getattr(node, "file", "") or ""

        if file_path.endswith((".json", ".geojson")):
            self._check_json_metadata(file_path)
        # XML branch: uncomment and implement _check_xml_metadata for ISO 19115 XML
        # elif file_path.endswith(".xml"):
        #     self._check_xml_metadata(file_path)

    # ------------------------------------------------------------------ #
    # JSON / GeoJSON validation                                           #
    # ------------------------------------------------------------------ #
    def _check_json_metadata(self, file_path: str) -> None:
        try:
            with open(file_path, "r", encoding="utf-8") as fh:
                metadata = json.load(fh)
        except (json.JSONDecodeError, OSError) as exc:
            self.add_message("E9004", args=str(exc), line=1)
            return

        # Skip non-spatial JSON artefacts (package.json, pyproject.toml, etc.)
        if SENTINEL_KEY not in metadata:
            return

        # 1. Required field completeness
        for field in REQUIRED_FIELDS:
            if field not in metadata:
                self.add_message("W9001", args=field, line=1)

        # 2. License compliance — SPDX id, CC URI, or OGC URI
        license_val = str(metadata.get("license", ""))
        valid_license_prefixes = (
            "https://",
            "http://",
            "SPDX:",
            "CC-BY",
            "CC0",
            "OGL",
        )
        if not license_val or not license_val.startswith(valid_license_prefixes):
            self.add_message("E9002", args=license_val or "<empty>", line=1)

        # 3. CRS format
        crs_val = str(metadata.get("crs", ""))
        if crs_val and not VALID_CRS.match(crs_val):
            self.add_message("E9003", args=crs_val, line=1)

        # 4. Bounding box geometry
        extent = metadata.get("extent")
        if extent is not None:
            self._validate_extent(extent)

        # 5. Temporal coverage
        temporal = metadata.get("temporal")
        if temporal is not None:
            self._validate_temporal(temporal)

    def _validate_extent(self, extent) -> None:
        """Check [minX, minY, maxX, maxY] ordering and numeric types."""
        try:
            if len(extent) != 4:
                raise ValueError("must have exactly 4 elements")
            min_x, min_y, max_x, max_y = (float(v) for v in extent)
            if min_x >= max_x or min_y >= max_y:
                raise ValueError(f"min >= max: {extent}")
        except (TypeError, ValueError) as exc:
            self.add_message("W9005", args=str(exc), line=1)

    def _validate_temporal(self, temporal) -> None:
        """Validate ISO 8601 dates in temporal.start and temporal.end."""
        for key in ("start", "end"):
            val = str(temporal.get(key, ""))
            if val and not ISO_8601_DATE.match(val):
                self.add_message(
                    "W9006", args=f"{key}={val!r}", line=1
                )


def register(linter):
    """Registration hook — PyLint calls this when loading the plugin."""
    linter.register_checker(SpatialMetadataChecker(linter))

Validation and pipeline integration

.pylintrc configuration

[MASTER]
load-plugins=linters.spatial_metadata_checker

[MESSAGES CONTROL]
# Disable all built-in messages; enable only spatial metadata rules.
disable=all
enable=W9001,E9002,E9003,E9004,W9005,W9006

Keeping disable=all prevents PyLint from reporting Python-style issues against JSON and GeoJSON content, which would generate noise and obscure the spatial diagnostics you care about.

Verify plugin registration before pushing to CI

# Confirm the checker is visible
pylint --list-plugins | grep spatial

# Dry-run against a single metadata file
pylint --rcfile=.pylintrc data/metadata/sample.json

# Machine-readable output (parses cleanly in GitHub Actions annotations)
pylint --rcfile=.pylintrc --output-format=json data/metadata/*.json \
    > pylint-spatial-report.json
echo "pylint exit code: $?"

PyLint exit codes are additive bitmasks: 0 = clean, 1 = fatal, 2 = error, 4 = warning, 8 = refactor, 16 = convention, 32 = usage error. For policy enforcement gates for data PRs, gate on exit_code & 3 (fatal + error) to block merges that contain CRS or licensing errors while still allowing merges with informational warnings.

GitHub Actions integration

# .github/workflows/spatial-metadata-lint.yml
name: Spatial Metadata Lint

on:
  pull_request:
    paths:
      - "data/metadata/**/*.json"
      - "data/metadata/**/*.geojson"
      - "linters/**"
      - ".pylintrc"

jobs:
  metadata-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip

      - name: Install dependencies
        run: pip install pylint>=3.0

      - name: Lint spatial metadata
        run: |
          pylint --rcfile=.pylintrc --output-format=json \
            data/metadata/**/*.json data/metadata/**/*.geojson \
            > pylint-report.json || true
          # Exit non-zero only for errors (E9xxx), not warnings (W9xxx)
          python - <<'EOF'
          import json, sys
          report = json.load(open("pylint-report.json"))
          errors = [m for m in report if m["type"] in ("error", "fatal")]
          if errors:
              for e in errors:
                  print(f"::error file={e['path']},line={e['line']}::{e['message']}")
              sys.exit(1)
          EOF

      - name: Upload lint report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: pylint-spatial-report
          path: pylint-report.json

For XML-backed ISO 19115 records, pair this checker with setting up GitHub Actions for ISO 19115 validation to cover both JSON sidecar files and full ISO 19115 XML records in the same pipeline run.

Pre-commit hook (local enforcement)

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: spatial-metadata-lint
        name: Spatial metadata PyLint
        entry: pylint --rcfile=.pylintrc --output-format=colorized
        language: python
        additional_dependencies: [pylint>=3.0]
        types: [json]
        files: ^data/metadata/

Long-term compliance best practices

  • Version your rule set alongside your data. Store REQUIRED_FIELDS, CRS patterns, and license allow-lists in a spatial_rules.yaml that the checker loads at startup. When compliance requirements change (new mandatory ISO 19115 elements, updated SPDX identifiers), update the YAML and cut a semver tag — your audit trail shows exactly which rule version applied to each dataset.
  • Guard against extension collisions. Not every .json file in a repository is spatial metadata. Always require a sentinel key (spatial_metadata_version) and return early without emitting messages when it is absent. This prevents false positives against package.json, CI configuration, and other JSON artefacts.
  • Separate warning from blocking thresholds. Map field-completeness gaps to W9xxx (informational) and CRS/license violations to E9xxx (blocking). Gate your metadata artifact retention strategies pipeline on errors only; surface warnings as PR annotations for human review.
  • Cache parsed rule definitions in CI. PyLint processes files sequentially. For catalogs with hundreds of metadata records, load spatial_rules.yaml once in open() at the class level rather than inside process_module, and use pylint --jobs=4 to parallelize file dispatch across available CPU cores.
  • Align message IDs with your organization’s compliance register. Prefix codes with department or project identifiers (e.g., W9001-INSPIRE, E9002-SPDX) in the message symbolic name and store the mapping in a machine-readable registry. This makes automated compliance reports — where each finding links back to a specific regulatory clause — straightforward to generate.
  • Track rule drift with integration tests. Maintain a small fixture directory of known-good and known-bad metadata files. Run pylint --rcfile=.pylintrc fixtures/bad/missing_crs.json in CI and assert the expected message codes appear; this catches regressions when PyLint upgrades change checker lifecycle behavior.