Validating Code List Values Against INSPIRE Registers Jump to heading
An INSPIRE code list is not a Python set. It is a resource in a registry, owned by an authority, identified by a URI, versioned, and amended on a schedule you do not control. Treating it as a set that happens to live in your codebase produces a validator that is confidently wrong the first time the register changes; fetching it live at validation time produces a validator whose verdict depends on the network and on whether the authority deployed something last night. This procedure takes the third path — pin, cache, validate offline, and review amendments as code — and it implements the register case of attribute domain and code list validation, inside Spatial Data Quality Validation & Geometry Integrity.
The steps map to configure (Steps 1–2, identify and sync), execute (Step 3, offline validation), validate (Step 4, hierarchy and deprecation policy), and log (Step 5, the amendment diff). The same pattern applies to any governed vocabulary — a national land-use classification, a utility asset taxonomy, an ISO 19115 code list — and the INSPIRE registry is simply the best-documented example of the shape.
Prerequisites checklist Jump to heading
Step 1: Identify the register resource and its version Jump to heading
An INSPIRE code list URI such as http://inspire.ec.europa.eu/codelist/HILUCSValue identifies the concept, and content negotiation yields a machine-readable representation of its members. What matters for reproducibility is the version identifier the registry publishes alongside them.
# sync/registers.py — requests >= 2.31 — Python 3.10+ (BUILD TIME ONLY)
import requests
TIMEOUT = 30
def fetch_register(uri: str) -> dict:
"""Fetch the machine-readable representation of a code list."""
response = requests.get(
uri + ".en.json",
headers={"Accept": "application/json"},
timeout=TIMEOUT,
)
response.raise_for_status()
payload = response.json()
# The registry reports the version of the register itself, not of each member.
version = payload["register"]["versionInfo"]
return {"uri": uri, "version": version, "raw": payload}
Two habits are worth adopting here. Always request a specific representation rather than relying on the default, so a registry that changes its default format does not silently change your cache’s shape. And always fail on a non-200: a sync job that writes an HTML error page into the cache produces a validator that rejects every value with a message nobody can read.
Step 2: Normalize into a committed cache with a content hash Jump to heading
# sync/registers.py — Python 3.10+ (BUILD TIME ONLY)
import hashlib
import json
from pathlib import Path
def normalize(payload: dict) -> dict:
"""Flatten the registry representation into the stable shape validation reads."""
members = []
for item in payload["raw"]["register"]["containeditems"]:
value = item["value"]
members.append({
"id": value["id"].rsplit("/", 1)[-1], # the code as it appears in data
"uri": value["id"],
"label": value["label"]["text"],
"status": value["status"]["label"]["text"].lower(), # valid | superseded | retired
"parent": (value.get("parent") or {}).get("id", "").rsplit("/", 1)[-1] or None,
})
members.sort(key=lambda m: m["id"]) # stable order → stable diff
body = {
"uri": payload["uri"],
"version": payload["version"],
"members": members,
}
body["content_hash"] = hashlib.sha256(
json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return body
def write_cache(body: dict, path: Path) -> None:
path.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8")
Sorting the members before writing is the detail that makes this workflow usable. An unsorted cache produces a 900-line diff every time the registry reorders its response, and a reviewer confronted with that diff will approve it without reading. Sorted, the diff for a real amendment is three lines, and a reviewer can see exactly which codes appeared and which were retired.
Step 3: Validate offline, asserting the pin first Jump to heading
# quality/registers.py — Python 3.10+ (RUNTIME — no network imports)
import json
from pathlib import Path
def load_pinned(spec: dict) -> tuple[frozenset[str], frozenset[str], dict[str, str | None]]:
path = Path(spec["cache_path"])
if not path.is_file():
raise FileNotFoundError(
f"{spec['name']}: register cache {path} is missing. Run the sync job; "
"validation never fetches."
)
body = json.loads(path.read_text(encoding="utf-8"))
if body["version"] != spec["register_version"]:
raise ValueError(
f"{spec['name']}: cache holds {body['version']}, manifest pins "
f"{spec['register_version']} — refusing to validate."
)
if body["uri"] != spec["register_uri"]:
raise ValueError(f"{spec['name']}: cache is for {body['uri']}, not {spec['register_uri']}")
active = frozenset(m["id"] for m in body["members"] if m["status"] == "valid")
retired = frozenset(m["id"] for m in body["members"] if m["status"] != "valid")
parents = {m["id"]: m["parent"] for m in body["members"]}
return active, retired, parents
The version assertion is the single most valuable line in this procedure. Without it, a sync job that failed last week leaves an older cache in place, the validation run passes against a stale vocabulary, and the conformance report says everything is fine — the exact failure mode the whole pinning strategy exists to prevent. With it, the mismatch is a hard stop naming both versions, and the fix is obvious to whoever reads the log.
Step 4: Decide hierarchy and deprecation explicitly Jump to heading
INSPIRE code lists are frequently hierarchical: 1_1_1 (permanent crops) is a narrower term under 1_1 (agriculture). Whether a dataset may use a narrower value than the specification names is a policy decision, and so is whether a retired code is a rejection or a warning during a migration window.
# quality/registers.py — Python 3.10+
def resolve(value: str, active: frozenset[str], retired: frozenset[str],
parents: dict[str, str | None], spec: dict) -> tuple[bool, str]:
if value in active:
return True, ""
if value in retired:
if spec.get("allow_deprecated"):
return True, "ATTR_DEPRECATED_CODE_ACCEPTED"
return False, "ATTR_DEPRECATED_CODE"
if spec.get("allow_narrower"):
# Walk up the hierarchy: a narrower term is acceptable if an ancestor is active.
seen: set[str] = set()
cursor = parents.get(value)
while cursor and cursor not in seen:
if cursor in active:
return True, "ATTR_NARROWER_TERM_ACCEPTED"
seen.add(cursor)
cursor = parents.get(cursor)
return False, "ATTR_UNDEFINED_CODE"
The seen set is not defensive decoration. Registers do occasionally publish a cycle after an editorial mistake, and a naive parent walk turns that into an infinite loop inside a validation job that then has to be killed by hand.
The default for both flags is off. allow_narrower off means a dataset that uses 1_1_1 where the specification says 1_1 is flagged, which is usually what an INSPIRE conformance test would say too. allow_deprecated off means a retired code is a rejection; turning it on should always carry an expiry date recorded next to it in the manifest.
Step 5: Review amendments as a pull request, not as a surprise Jump to heading
# .github/workflows/sync-registers.yml
name: sync-inspire-registers
on:
schedule:
- cron: "0 4 * * 1" # weekly, Monday 04:00 UTC
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.11"}
- run: pip install "requests>=2.31"
- run: python -m sync.registers --manifest attribute_domains.yaml --out registers/
- name: Report what would change
run: python -m sync.registers --impact --input data/latest_delivery.parquet
- uses: peter-evans/create-pull-request@v6
with:
branch: chore/register-sync
title: "Register sync: new code list version(s) available"
body-path: registers/IMPACT.md
The --impact step is what turns this from a dependency bump into a decision with evidence. It counts, against the most recent delivery, how many features carry a code that the new version retires or removes — so the pull request says “adopting HILUCS 2026-02-02 would reject 1,412 features currently using 2_3” rather than merely “the register changed”. That number decides whether the upgrade ships with a crosswalk, with a migration window, or immediately.
Verification Jump to heading
# pytest >= 7
def test_pinned_cache_matches_manifest():
active, retired, parents = load_pinned(LAND_USE_SPEC)
assert "1_1_1" in active
assert len(active) == 62 # the pinned version's member count
def test_stale_cache_refuses_to_validate(tmp_path):
spec = dict(LAND_USE_SPEC, register_version="1999-01-01")
with pytest.raises(ValueError, match="refusing to validate"):
load_pinned(spec)
def test_validation_path_never_imports_requests():
# Negative control: proves the runtime cannot reach the network.
import sys
import quality.registers # noqa: F401
assert "requests" not in sys.modules
The third test looks unusual and earns its place: it is a structural guarantee that no future edit reintroduces a live fetch into the validation path. A comment saying “do not fetch here” has never stopped anyone; an assertion that fails the build has.
Confirm the cache is current with a one-liner before a delivery run:
# jq >= 1.6
jq -r '"\(.uri) version=\(.version) members=\(.members | length)"' registers/hilucs-2023-04-11.json
# http://inspire.ec.europa.eu/codelist/HILUCSValue version=2023-04-11 members=62
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Every value rejected as undefined | The cache stores full URIs while the data stores short codes, or vice versa | Normalize both to the short code in normalize(); store the URI alongside for reporting |
| The weekly sync produces a huge diff with no real change | The registry reordered its response, or the fetch captured a timestamp | Sort members before writing and exclude any response field that varies per request |
ValueError: refusing to validate in production |
The pin was bumped in the manifest but the cache file was not regenerated | Regenerate through the sync job; never hand-edit the version field in the cache |
| Narrower terms rejected in one dataset and accepted in another | allow_narrower set inconsistently across manifests |
Set it per dataset deliberately and record why; the INSPIRE specification for each theme states which level is expected |
| Deprecated codes reappear after a migration | The source system was never updated and allow_deprecated has no expiry |
Add an expiry date to the flag and fail the build once it passes |
Related Jump to heading
- Attribute Domain & Code List Validation — the parent stage, its manifest and the three domain kinds
- Enforcing Attribute Domains with pandera Schemas — running the same rules inside the ETL job and the test suite
- INSPIRE Directive Schema Compliance — where the obligation to use these vocabularies comes from
- How to Map INSPIRE Annex III to Local PostgreSQL Schemas — the crosswalk that turns local vocabularies into governed ones