Building a License Compatibility Matrix in Python
Generate the compatibility matrix from an obligation model rather than writing the cells by hand: derive every pair’s verdict from the licences’ recorded obligations and a stated distribution intent, and the matrix becomes a rendering of a rule set instead of a document that drifts away from one.
Hand-maintained compatibility tables fail in a specific way. They start correct, acquire a new licence in one row and not the corresponding column, and thereafter disagree with themselves. Because nobody reads a matrix diagonally, the disagreement survives for years. Deriving the whole grid from a per-licence obligation record removes the failure mode entirely — there is only one place to be wrong, and a test can check it. The obligation model this builds on is described in license compatibility and derivative works, under Geospatial Data Licensing & Compliance Fundamentals.
Automated Python Implementation
The generator below reads a small obligation register, evaluates every ordered pair under a chosen intent, and emits a matrix. It is deliberately free of special cases: every cell comes from the same function, so a surprising cell is evidence about the model rather than about the cell.
#!/usr/bin/env python3
"""Generate a licence compatibility matrix from an obligation register."""
import itertools
import json
from dataclasses import dataclass, asdict
@dataclass(frozen=True)
class Licence:
spdx: str
attribution: bool
# None, "database" (fires on a distributed derivative DB) or "work"
# (fires on any distributed adaptation, including a rendered map).
share_alike: str | None
non_commercial: bool
REGISTER = [
Licence("CC0-1.0", False, None, False),
Licence("CC-BY-4.0", True, None, False),
Licence("OGL-3.0", True, None, False),
Licence("ODbL-1.0", True, "database", False),
Licence("CC-BY-SA-4.0", True, "work", False),
Licence("CC-BY-NC-4.0", True, None, True),
]
BY_ID = {lic.spdx: lic for lic in REGISTER}
def fires(lic, form):
"""Does this licence's share-alike term attach to a distributed product?"""
if lic.share_alike is None:
return False
return True if lic.share_alike == "work" else form == "database"
def verdict(a, b, form="database", commercial=True):
"""Return a short verdict string for one ordered pair."""
if commercial and (a.non_commercial or b.non_commercial):
return "non-commercial only"
firing = {lic.spdx for lic in (a, b) if fires(lic, form)}
if len(firing) > 1:
return "conflict"
if len(firing) == 1:
return f"output is {firing.pop()}"
return "compatible"
def build_matrix(form="database", commercial=True):
"""Full grid keyed by (row spdx, column spdx)."""
grid = {}
for a, b in itertools.product(REGISTER, repeat=2):
grid[(a.spdx, b.spdx)] = verdict(a, b, form, commercial)
return grid
def to_markdown(grid, ids):
header = "| |" + "|".join(ids) + "|"
rule = "|---" * (len(ids) + 1) + "|"
rows = [header, rule]
for a in ids:
cells = [grid[(a, b)] for b in ids]
rows.append(f"| **{a}** |" + "|".join(cells) + "|")
return "\n".join(rows)
if __name__ == "__main__":
ids = [lic.spdx for lic in REGISTER]
print(to_markdown(build_matrix(), ids))
print()
print(json.dumps([asdict(lic) for lic in REGISTER], indent=2))
Two properties of this generator are worth stating because they are the reasons to prefer it over a table.
It is symmetric by construction. verdict(a, b) reads only unordered properties, so the grid cannot disagree with itself across the diagonal. A hand-maintained table has no such guarantee, and the asymmetries that creep in are exactly the cells nobody checks.
It is parameterised by intent. Calling build_matrix(form="work") produces the matrix for rendered outputs, where ODbL’s database-scope share-alike does not fire. Teams that publish both data and tiles need both matrices, and generating them from one model means they cannot drift apart.
Validation and Pipeline Integration
A generated matrix needs three tests, and the third is the one that catches real regressions.
def test_matrix_is_symmetric():
grid = build_matrix()
for (a, b), value in grid.items():
assert grid[(b, a)] == value, f"asymmetric cell: {a}/{b}"
def test_share_alike_pair_is_the_only_conflict():
grid = build_matrix()
conflicts = {pair for pair, v in grid.items() if v == "conflict"}
assert conflicts == {("ODbL-1.0", "CC-BY-SA-4.0"), ("CC-BY-SA-4.0", "ODbL-1.0")}
def test_rendered_form_removes_the_database_conflict():
grid = build_matrix(form="work")
assert grid[("ODbL-1.0", "CC-BY-4.0")] == "compatible"
Regenerate the matrix in CI and fail the build if the committed copy differs from the generated one. That single check keeps the documentation and the rule set in agreement permanently, and it is three lines:
python generate_matrix.py > /tmp/matrix.md
diff -u docs/licence-matrix.md /tmp/matrix.md
Wire the same verdict function into the gate described in policy enforcement gates for data pull requests so that the published matrix and the enforced rule are the same code. A matrix that documents one policy while the pipeline enforces another is worse than having no matrix, because people will rely on it.
Long-Term Compliance Best Practices
- Keep the register in version control and review changes as policy changes. Adding a licence to the register alters every verdict involving it; that deserves a review, and a pull request supplies one for free.
- Emit the matrix in two forms. A human-readable table for documentation and a machine-readable JSON grid for other tools. Generating both from one call means a consumer never has to parse the markdown.
- Version the register and stamp verdicts with the version. When a modelling decision changes — the day someone concludes a licence’s scope was recorded wrongly — the affected verdicts are the ones stamped with the old version.
- Never add an exception cell. The temptation to hard-code one pair’s verdict because “everyone knows that combination is fine” reintroduces exactly the drift the generator removes. If the model gives the wrong answer, the model is wrong.
- Render the matrix for each intent you actually publish under. A single matrix implies a single distribution mode, and teams that publish data and tiles will read the wrong one.
What the Matrix Cannot Tell You
A compatibility matrix answers one question well and is routinely asked three it cannot answer, so it is worth being explicit about the boundary.
It does not know about contracts. A dataset obtained under a commercial agreement may carry terms that no SPDX identifier expresses — a territory restriction, a seat cap, a prohibition on redistribution that survives any amount of derivation. Those live in commercial EULA compliance tracking and are evaluated separately. A matrix that shows a proprietary source as compatible with anything is reporting the absence of an open-licence conflict, not the presence of permission.
It does not know about three-way interactions. Every cell describes a pair. A product built from three sources needs the whole set evaluated at once, because two share-alike terms that never appear in the same cell can still both attach to the same output. The matrix is a reference for humans; the gate must call the resolver with the full input list.
It does not know whether the licence recorded is the licence that applies. Every verdict is downstream of a register entry that somebody resolved on some date, possibly from a free-text field on a portal page. The matrix will confidently combine a layer recorded as CC-BY-4.0 that is in fact CC-BY-SA-3.0, and no property of the matrix can detect that. Resolution quality is a separate problem, addressed by re-resolving on a schedule and recording the date.
Stating these limits alongside the matrix, in the same document, costs a paragraph and prevents the specific failure where a correct tool is used to answer a question it was never given the inputs for.
Related
- License Compatibility & Derivative Works — the obligation model the generator reads
- Combining ODbL and CC-BY Layers in One Product — one cell of the matrix worked through end to end
- Scoring License-Conflict Risk in a Data Inventory — ranking an inventory once the verdicts are computable
- Open Data License Selection & Comparison — the parent discipline of picking a licence rather than reconciling two