Detecting CC Licence Versions in Legacy Metadata
Scan every metadata carrier for a version token, resolve to a canonical SPDX identifier only when the version is explicit, and record anything that names a Creative Commons licence without a version as contested rather than guessing at the most likely one.
Inherited catalogues express Creative Commons licences in dozens of ways: a URL, a badge image filename, a sentence in an abstract, a .dbf column truncated to ten characters, a rights element containing the words “Creative Commons Attribution”. Some of these carry a version and some do not, and the difference matters more for spatial data than for most content, because version 4.0 extends its conditions to database rights while earlier versions do not. This guide sits under Creative Commons licensing for GIS datasets, within Geospatial Data Licensing & Compliance Fundamentals.
Automated Python Implementation
The scanner below reads every carrier it can reach, extracts candidate claims, and classifies each as resolved, versionless or contested. It never picks a version that was not stated.
#!/usr/bin/env python3
"""Detect Creative Commons licence claims and their versions in legacy metadata."""
import pathlib
import re
from collections import Counter
# Canonical forms. A claim resolves only when the version is explicit.
FAMILIES = {
"by-nc-sa": "CC-BY-NC-SA",
"by-nc-nd": "CC-BY-NC-ND",
"by-nc": "CC-BY-NC",
"by-nd": "CC-BY-ND",
"by-sa": "CC-BY-SA",
"by": "CC-BY",
"zero": "CC0",
"publicdomain": "CC0",
}
URL_RE = re.compile(
r"creativecommons\.org/(?:licenses|publicdomain)/([a-z\-]+)(?:/(\d\.\d))?"
r"(?:/([a-z]{2}))?", re.I)
PROSE_RE = re.compile(
r"creative\s+commons\s+((?:attribution|zero|public\s+domain)"
r"(?:[\s\-]+(?:share[\s\-]?alike|non[\s\-]?commercial|no\s+deriv\w*))*)"
r"(?:[^\d]{0,20}(\d\.\d))?", re.I)
PROSE_TOKENS = [
("non commercial", "nc"), ("noncommercial", "nc"),
("share alike", "sa"), ("sharealike", "sa"),
("no derivatives", "nd"), ("noderiv", "nd"),
("attribution", "by"), ("zero", "zero"), ("public domain", "publicdomain"),
]
def claims_from_text(text):
"""Yield (family, version, port, evidence) for every claim found."""
for match in URL_RE.finditer(text):
family = FAMILIES.get(match.group(1).lower())
if family:
yield family, match.group(2), match.group(3), match.group(0)
for match in PROSE_RE.finditer(text):
phrase = " ".join(match.group(1).lower().split())
parts = [code for token, code in PROSE_TOKENS if token in phrase]
if "zero" in parts or "publicdomain" in parts:
family = "CC0"
elif "by" in parts:
family = "CC-BY" + "".join(
f"-{p.upper()}" for p in ("nc", "sa", "nd") if p in parts)
else:
continue
yield family, match.group(2), None, match.group(0)
def classify(claims):
"""Resolve to a single SPDX id, or report why not."""
if not claims:
return {"status": "none"}
families = {c[0] for c in claims}
if len(families) > 1:
return {"status": "contested", "families": sorted(families),
"evidence": [c[3] for c in claims]}
family = families.pop()
versions = {c[1] for c in claims if c[1]}
ports = {c[2] for c in claims if c[2]}
if len(versions) > 1:
return {"status": "contested", "versions": sorted(versions),
"evidence": [c[3] for c in claims]}
if not versions:
return {"status": "versionless", "family": family,
"evidence": [c[3] for c in claims]}
version = versions.pop()
spdx = f"{family}-{version}"
if ports:
spdx += "-" + sorted(ports).pop().upper()
return {"status": "resolved", "spdx": spdx, "evidence": [c[3] for c in claims]}
def scan(root):
results, counts = {}, Counter()
for path in sorted(pathlib.Path(root).rglob("*")):
if path.suffix.lower() not in {".xml", ".txt", ".json", ".md"}:
continue
text = path.read_text(encoding="utf-8", errors="replace")
outcome = classify(list(claims_from_text(text)))
results[str(path)] = outcome
counts[outcome["status"]] += 1
return results, counts
The versionless status is the whole point of the exercise. A record reading “Creative Commons Attribution” is compatible with 1.0 through 4.0, and those differ on the question that decides whether the licence reaches a feature table at all. Resolving it to CC-BY-4.0 because that is the current version invents a fact; resolving it to CC-BY-3.0 because the record is old invents a different one. Reporting it as versionless is the only honest option, and it produces a work list rather than a false clean bill.
Validation and Pipeline Integration
The scanner needs a fixture corpus more than it needs unit tests, because the thing being tested is coverage of forms found in the wild.
def test_versioned_url_resolves():
claims = list(claims_from_text("http://creativecommons.org/licenses/by-sa/4.0/"))
assert classify(claims)["spdx"] == "CC-BY-SA-4.0"
def test_unversioned_prose_is_not_guessed():
claims = list(claims_from_text("Released under a Creative Commons Attribution licence."))
outcome = classify(claims)
assert outcome["status"] == "versionless"
assert "spdx" not in outcome
def test_disagreeing_carriers_are_contested():
text = ("creativecommons.org/licenses/by/4.0/ ... "
"Creative Commons Attribution-ShareAlike 3.0")
outcome = classify(list(claims_from_text(text)))
assert outcome["status"] == "contested"
Run the scan as a scheduled job rather than a gate. Legacy records do not change on commit, and a build that fails because a twenty-year-old record is ambiguous blocks work that has nothing to do with it. Emit the counts as a metric and the versionless list as a work queue, and the numbers will move as the queue is cleared — which is the visible progress that keeps a remediation effort funded.
What to Do With a Versionless Record
The queue is only useful if there is a decision procedure at the end of it, and there is one, in a fixed order.
Ask the publisher. For an active portal this resolves most of the queue and is the only route that produces an authoritative answer. It is worth doing in bulk: a single message listing forty datasets gets a better response than forty messages.
Look for a dated announcement. Publishers frequently announced a licensing change with a date. A record created after that date, under a portal that stated it was moving to 4.0, is reasonably resolved to 4.0 — and the announcement is the evidence to record alongside the resolution.
Infer from the record’s own date, conservatively. Where nothing better exists, the version in force when the record was created is the least unreasonable inference. Record it as inferred, with the reasoning, never as a plain identifier — and treat the dataset as carrying the more restrictive reading where the versions differ materially.
Stop using it. For a dataset that is not load-bearing, the cheapest resolution is frequently to replace it with one whose terms are clear. This option is skipped almost universally and is often the right one for a legacy layer nobody has queried in three years.
Long-Term Compliance Best Practices
- Record the evidence string with the resolution. The substring that produced the identifier is what makes the resolution reviewable, and it costs nothing to store.
- Keep ports in the identifier.
CC-BY-3.0-DEis notCC-BY-3.0; dropping the suffix discards the pointer to the operative text. - Never write a resolved identifier back over the original record. Store it alongside. The legacy text is the evidence, and overwriting it destroys the ability to revisit the resolution.
- Re-scan after every ingestion of an external catalogue. New legacy arrives constantly, usually inside a bulk import that nobody thought of as a licensing event.
- Track the versionless count as a metric. A number that goes down is the only convincing evidence that a remediation effort is working.
Related
- Creative Commons Licensing for GIS Datasets — what the version difference actually changes for spatial data
- Applying CC0 to Public Agency Reference Data — the dedication decision for data an agency does own
- Scoring License-Conflict Risk in a Data Inventory — how an unresolved licence weighs in a risk score
- Automating License Checks with Python and OGR — reading licence strings out of the data files themselves