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.
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 aspatial_rules.yamlthat 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
.jsonfile 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 againstpackage.json, CI configuration, and other JSON artefacts. - Separate warning from blocking thresholds. Map field-completeness gaps to
W9xxx(informational) and CRS/license violations toE9xxx(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.yamlonce inopen()at the class level rather than insideprocess_module, and usepylint --jobs=4to 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.jsonin CI and assert the expected message codes appear; this catches regressions when PyLint upgrades change checker lifecycle behavior.
Related
- Spatial Data Schema Linting in CI — parent topic covering the full schema linting pipeline for geospatial assets
- Setting up GitHub Actions for ISO 19115 validation — XML-based companion workflow for ISO 19115 record validation
- Policy Enforcement Gates for Data PRs — how to turn PyLint exit codes into merge-blocking status checks
- Metadata Artifact Retention Strategies — managing and archiving the validation reports this checker produces