Python Scripts for DCAT-AP Spatial Dataset Mapping
Use rdflib to construct a DCAT-AP Spatial Profile-compliant RDF graph from a Python dictionary, serialize it to Turtle or JSON-LD, and validate it with pySHACL before publishing to any EU open data catalog.
Automating this transformation is non-trivial because the DCAT-AP Spatial Profile adds geospatial-specific constraints on top of the base W3C DCAT vocabulary: bounding boxes must follow a precise WGS84 string format, coordinate reference systems must resolve to authoritative OGC URIs, and licensing terms must be persistent URI references rather than free-text literals. Manual RDF authoring at scale introduces silent constraint violations that only surface when a national portal harvester rejects the feed. This workflow is a direct implementation of the DCAT-AP Spatial Profile Mapping patterns, and sits within the broader Automated Metadata Generation & Schema Mapping discipline that governs production spatial data infrastructures.
The diagram below shows the end-to-end data flow from source metadata to a validated catalog record:
Automated Python Implementation
Install the two core dependencies before running the script:
pip install rdflib>=6.3 pyshacl>=0.25
The script below is self-contained and runnable. It builds a compliant RDF graph, validates it against a locally-loaded SHACL shapes graph, and serializes the output to Turtle. Inline comments explain every non-obvious mapping decision.
# dcat_ap_spatial_mapper.py
"""
DCAT-AP Spatial Profile mapper.
Requires: rdflib>=6.3, pyshacl>=0.25
"""
from __future__ import annotations
import sys
from typing import Any
from rdflib import Graph, Literal, Namespace, URIRef
from rdflib.namespace import DCAT, DCTERMS, RDF, XSD
# ── Namespace declarations ────────────────────────────────────────────────────
# LOCN is not yet in rdflib's built-ins, so declare it manually.
LOCN = Namespace("http://www.w3.org/ns/locn#")
GEO = Namespace("http://www.opengis.net/ont/geosparql#")
DCATAP = Namespace("http://data.europa.eu/r5r/")
DCT = DCTERMS
def _ogc_crs_uri(epsg_code: str | int) -> URIRef:
"""Return the authoritative OGC EPSG URI for a given EPSG code.
DCAT-AP Spatial Profile v2/v3 requires this exact URI pattern.
Bare integer codes (e.g. 4326) are not acceptable as dcat:crs values.
"""
return URIRef(f"https://www.opengis.net/def/crs/EPSG/0/{epsg_code}")
def build_spatial_dataset(metadata: dict[str, Any]) -> Graph:
"""Construct a DCAT-AP Spatial Profile-compliant RDF graph.
Parameters
----------
metadata : dict
Keys documented in the example at the bottom of this file.
Returns
-------
rdflib.Graph
Populated graph with all mandatory and recommended properties.
"""
g = Graph()
# Bind prefixes explicitly — unprefixed URIs bloat serialized output and
# break cross-catalog SPARQL queries that rely on known prefix declarations.
g.bind("dcat", DCAT)
g.bind("dct", DCT)
g.bind("locn", LOCN)
g.bind("geo", GEO)
g.bind("xsd", XSD)
g.bind("dcatap", DCATAP)
ds_uri = URIRef(metadata["dataset_uri"])
g.add((ds_uri, RDF.type, DCAT.Dataset))
# ── Mandatory properties ─────────────────────────────────────────────────
g.add((ds_uri, DCT.title, Literal(metadata["title"], lang="en")))
g.add((ds_uri, DCT.identifier, Literal(metadata["identifier"])))
g.add((ds_uri, DCT.description, Literal(metadata["description"], lang="en")))
# ── Spatial coverage ─────────────────────────────────────────────────────
# dcat:bbox must be a plain string literal in "west south east north" order
# (WGS84 decimal degrees). Do NOT use a typed xsd:string or a GeoJSON dict.
if "bbox" in metadata:
w, s, e, n = metadata["bbox"]
g.add((ds_uri, DCAT.bbox, Literal(f"{w} {s} {e} {n}")))
# locn:geometry accepts WKT or GeoJSON as a string literal (optional but
# recommended for detailed footprints that exceed a bounding rectangle).
if "geometry_wkt" in metadata:
g.add((ds_uri, LOCN.geometry, Literal(metadata["geometry_wkt"])))
# ── Coordinate Reference System ──────────────────────────────────────────
# Must be an OGC URI, not a bare EPSG integer or an SRID string.
if "epsg" in metadata:
g.add((ds_uri, DCAT.crs, _ogc_crs_uri(metadata["epsg"])))
# ── Licensing and rights ─────────────────────────────────────────────────
# dct:license must be a URI reference (SPDX or Creative Commons URI).
# A free-text literal here will fail SHACL validation for DCAT-AP v2+.
if "license_uri" in metadata:
g.add((ds_uri, DCT.license, URIRef(metadata["license_uri"])))
if "rights_statement" in metadata:
g.add((ds_uri, DCT.rights, Literal(metadata["rights_statement"], lang="en")))
if "access_rights_uri" in metadata:
g.add((ds_uri, DCT.accessRights, URIRef(metadata["access_rights_uri"])))
# ── Temporal extent ──────────────────────────────────────────────────────
if "temporal_start" in metadata:
period = URIRef(metadata["dataset_uri"] + "/temporal")
g.add((ds_uri, DCT.temporal, period))
g.add((period, RDF.type, DCT.PeriodOfTime))
g.add((period, DCAT.startDate,
Literal(metadata["temporal_start"], datatype=XSD.date)))
if "temporal_end" in metadata:
g.add((period, DCAT.endDate,
Literal(metadata["temporal_end"], datatype=XSD.date)))
# ── Distributions ────────────────────────────────────────────────────────
for dist in metadata.get("distributions", []):
dist_uri = URIRef(dist["distribution_uri"])
g.add((ds_uri, DCAT.distribution, dist_uri))
g.add((dist_uri, RDF.type, DCAT.Distribution))
g.add((dist_uri, DCT.title, Literal(dist["title"], lang="en")))
# mediaType should be an IANA media type URI, not a bare string, for
# strict DCAT-AP conformance.
if "media_type" in dist:
g.add((dist_uri, DCAT.mediaType,
URIRef(f"https://www.iana.org/assignments/media-types/{dist['media_type']}")))
if "access_url" in dist:
g.add((dist_uri, DCAT.accessURL, URIRef(dist["access_url"])))
if "download_url" in dist:
g.add((dist_uri, DCAT.downloadURL, URIRef(dist["download_url"])))
if "byte_size" in dist:
g.add((dist_uri, DCAT.byteSize,
Literal(dist["byte_size"], datatype=XSD.nonNegativeInteger)))
return g
def validate_graph(data_graph: Graph, shapes_ttl_path: str) -> bool:
"""Validate *data_graph* against a local copy of DCAT-AP SHACL shapes.
Download the shapes file from the SEMIC DCAT-AP repository and pass its
path here. Returns True when all constraints are satisfied.
"""
from pyshacl import validate # type: ignore[import]
shapes_graph = Graph().parse(shapes_ttl_path, format="turtle")
conforms, _, report_text = validate(
data_graph,
shacl_graph=shapes_graph,
inference="rdfs",
abort_on_first=False,
)
if not conforms:
print(report_text, file=sys.stderr)
return conforms
# ── Example usage ────────────────────────────────────────────────────────────
if __name__ == "__main__":
import pathlib
sample_meta: dict[str, Any] = {
"dataset_uri": "https://data.example.eu/datasets/land-cover-2024",
"title": "European Land Cover 2024",
"identifier": "LC-2024-EU",
"description": (
"Annual land cover classification for Europe at 10 m resolution, "
"derived from Sentinel-2 composites."
),
"bbox": [-10.5, 35.0, 35.0, 72.0], # west south east north, WGS84
"epsg": 4326,
"license_uri": "https://creativecommons.org/licenses/by/4.0/",
"temporal_start": "2024-01-01",
"temporal_end": "2024-12-31",
"distributions": [
{
"distribution_uri": "https://data.example.eu/dist/lc-2024-geojson",
"title": "GeoJSON Export",
"media_type": "application/geo+json",
"download_url": "https://data.example.eu/files/lc-2024.geojson",
"byte_size": 94371840,
},
{
"distribution_uri": "https://data.example.eu/dist/lc-2024-turtle",
"title": "Turtle RDF Export",
"media_type": "text/turtle",
"download_url": "https://data.example.eu/files/lc-2024.ttl",
},
],
}
graph = build_spatial_dataset(sample_meta)
# Serialize to Turtle for catalog ingestion or diff review in VCS
output_path = pathlib.Path("lc-2024-dcat-ap.ttl")
graph.serialize(destination=str(output_path), format="turtle")
print(f"Serialized {len(graph)} triples to {output_path}")
# Optional SHACL validation — supply the path to a local shapes file
shapes_path = pathlib.Path("dcat-ap-shacl.ttl")
if shapes_path.exists():
ok = validate_graph(graph, str(shapes_path))
sys.exit(0 if ok else 1)
Key design decisions in the script:
_ogc_crs_uri()centralises the EPSG-to-OGC-URI conversion so bare integer codes never reach the graph.dcat:bboxis always a plain string literal. Typedxsd:stringvalues pass serialization but fail SEMIC SHACL shapes that check the literal’s datatype tag.dcat:mediaTypeis resolved to an IANA URI rather than a bare MIME string, matching the DCAT-AP v2.1 normative requirement.dct:temporalcreates adct:PeriodOfTimeblank node with typedxsd:dateliterals rather than untyped strings.
Validation and Pipeline Integration
Verify the Turtle output is parseable
# rdflib must be able to round-trip the file without parse errors
python - <<'EOF'
from rdflib import Graph
g = Graph().parse("lc-2024-dcat-ap.ttl", format="turtle")
print(f"Parsed {len(g)} triples OK")
EOF
Run mandatory-property checks with pytest
Add a fixture-driven test to your test suite. This catches regressions when the metadata dictionary evolves:
# tests/test_dcat_ap_mapper.py
import pytest
from rdflib.namespace import DCAT, DCTERMS, RDF
from rdflib import URIRef
from dcat_ap_spatial_mapper import build_spatial_dataset
SAMPLE = {
"dataset_uri": "https://example.org/ds/1",
"title": "Test Dataset",
"identifier": "TEST-001",
"description": "A test spatial dataset.",
"bbox": [-5.0, 50.0, 2.0, 59.0],
"epsg": 4326,
"license_uri": "https://creativecommons.org/licenses/by/4.0/",
"distributions": [
{
"distribution_uri": "https://example.org/ds/1/dist/1",
"title": "GeoJSON",
"media_type": "application/geo+json",
"access_url": "https://example.org/ds/1/dist/1",
}
],
}
@pytest.fixture()
def graph():
return build_spatial_dataset(SAMPLE)
def test_type(graph):
ds = URIRef(SAMPLE["dataset_uri"])
assert (ds, RDF.type, DCAT.Dataset) in graph
def test_bbox_format(graph):
ds = URIRef(SAMPLE["dataset_uri"])
bbox_vals = list(graph.objects(ds, DCAT.bbox))
assert len(bbox_vals) == 1
parts = str(bbox_vals[0]).split()
assert len(parts) == 4, "bbox must be 'west south east north'"
def test_license_is_uri(graph):
ds = URIRef(SAMPLE["dataset_uri"])
license_vals = list(graph.objects(ds, DCTERMS.license))
assert all(isinstance(v, URIRef) for v in license_vals), \
"dct:license must be a URI, not a Literal"
def test_crs_ogc_uri(graph):
ds = URIRef(SAMPLE["dataset_uri"])
crs_vals = list(graph.objects(ds, DCAT.crs))
assert len(crs_vals) == 1
assert "opengis.net/def/crs/EPSG" in str(crs_vals[0])
Run the suite with:
python -m pytest tests/test_dcat_ap_mapper.py -v
GitHub Actions CI gate
Add this step to your workflow to block merges that produce invalid DCAT-AP output:
- name: Validate DCAT-AP spatial metadata
run: |
pip install rdflib>=6.3 pyshacl>=0.25
python dcat_ap_spatial_mapper.py
python -m pytest tests/test_dcat_ap_mapper.py -v
For SHACL validation in CI, cache the DCAT-AP shapes file as a repository artifact rather than downloading it on every run. This keeps the pipeline deterministic and avoids failures caused by upstream availability.
Long-Term Compliance Best Practices
-
Pin the DCAT-AP version your catalog targets. The EU has published DCAT-AP v2.0, v2.1, and v3.0; each version changes which properties are mandatory versus recommended. Record the target version in a
COMPLIANCE.mdfile alongside your mapper and review it when upgrading catalog software. -
Store the shapes graph in version control alongside the mapper. SEMIC publishes updated SHACL shapes with each DCAT-AP release. Pinning the shapes file to a commit hash ensures that a shapes update upstream does not silently change your validation results between pipeline runs.
-
Use persistent, resolvable license URIs. Creative Commons URIs (
https://creativecommons.org/licenses/by/4.0/) and SPDX identifiers resolve reliably. Internal license registry URLs behind a corporate VPN will cause harvester failures when national portals attempt to dereferencedct:license. -
Validate coordinate order before inserting bbox values. The most common mapping failure is swapped latitude/longitude order. Add an assertion
assert -90 <= s <= n <= 90 and -180 <= w <= e <= 180before callingbuild_spatial_dataset. Silent coordinate order errors produce bounding boxes that pass SHACL but place datasets on the wrong continent in spatial search interfaces. -
Design the mapper for idempotent updates. When refreshing existing catalog records, call
graph.remove((ds_uri, None, None))before adding updated triples. This prevents duplicatedct:titleordcat:bboxstatements that accumulate in incremental update workflows and corrupt SPARQL query results. -
Automate CRS URI resolution checks in integration tests. OGC EPSG URIs follow a stable pattern, but EPSG codes for deprecated or regional CRS definitions can return non-200 responses from the OGC registry. A periodic integration test that HEAD-checks a sample of your
dcat:crsURIs catches deprecated codes before they surface in harvester logs. See Automating Metadata Extraction from PostGIS Tables for a pattern that combines SRID extraction with CRS URI construction.
Related
- DCAT-AP Spatial Profile Mapping — parent page covering the full spatial profile workflow, namespace registry setup, and concept-to-spec reference
- Automated Metadata Generation & Schema Mapping — the overarching discipline, including pipeline architecture and multi-standard comparisons
- ISO 19115 Metadata Template Generation — when your catalog requires ISO 19115 XML alongside DCAT-AP RDF
- Generating ISO 19115 Metadata from GeoTIFF Headers — sibling how-to for raster-source metadata automation using rasterio and lxml
- Validating FGDC Metadata Against XML Schemas — complementary validation approach for US federal metadata standards