Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,16 @@ jobs:
args: >-
-Dsonar.qualitygate.wait=true
-Dsonar.qualitygate.timeout=300

- name: Report failed quality-gate conditions
if: failure() && github.event_name == 'pull_request'
env:
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
curl --fail --silent --show-error --user "$SONAR_TOKEN:" \
--get "$SONAR_HOST_URL/api/qualitygates/project_status" \
--data-urlencode "projectKey=EL-BID_urbanpy" \
--data-urlencode "pullRequest=$PULL_REQUEST_NUMBER" \
| jq '{status: .projectStatus.status, conditions: [.projectStatus.conditions[] | {metricKey, status, actualValue, errorThreshold, comparator}]}'
19 changes: 18 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ jobs:
uvx --from check-wheel-contents==0.6.3 check-wheel-contents dist/*.whl

security:
name: Dependency audit
name: Dependency and license audit
runs-on: ubuntu-latest
steps:
- name: Check out repository
Expand All @@ -128,12 +128,29 @@ jobs:
version: 0.12.3
enable-cache: true

- name: Install locked runtime and security environment
run: |
uv python install 3.11
uv sync --group security --python 3.11

- name: Export locked runtime dependencies
run: uv export --no-dev --no-hashes --no-emit-project --output-file requirements-audit.txt

- name: Audit known vulnerabilities
run: uvx --from pip-audit==2.10.1 pip-audit --requirement requirements-audit.txt

- name: Enforce reviewed runtime license policy
run: uv run --python 3.11 python scripts/check_licenses.py --output dependency-licenses.json

- name: Upload dependency evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependency-evidence
path: |
dependency-licenses.json
requirements-audit.txt
if-no-files-found: error

docs:
name: Documentation
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
mkdir -p artifact/evidence
uv export --no-dev --no-hashes --no-emit-project --output-file artifact/evidence/requirements.txt
uvx --from pip-audit==2.10.1 pip-audit --requirement artifact/evidence/requirements.txt --format cyclonedx-json --output artifact/evidence/sbom.cdx.json
uv run --python 3.11 python scripts/check_licenses.py --output artifact/evidence/dependency-licenses.json

- name: Install and import the built wheel in a clean environment
run: |
Expand Down
46 changes: 46 additions & 0 deletions docs/development/dependency-license-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Runtime dependency license review

Snapshot date: 2026-08-09. This is an engineering control and inventory, not
legal advice or a substitute for EL-BID review.

## Scope and result

`scripts/check_licenses.py` walks installed distribution metadata from the
UrbanPy project through its active runtime requirements. It excludes development
extras and emits a deterministic JSON inventory. Against the locked 0.3 alpha
environment, 89 runtime distributions have an explicit reviewed disposition.

The exact accepted metadata strings live in `license-policy.toml`. A new package
or a changed license string fails closed so a maintainer must investigate it.
This repository check complements, and does not remove or dismiss, the external
FOSSA License Compliance check. FOSSA findings remain blocking until EL-BID
records an acceptable disposition in that system.

## Package-specific dispositions

- `defopt` 7.0.0 reports no license in wheel metadata. Its official repository
license is MIT; the policy records that source rather than globally allowing
`UNKNOWN`.
- `text-unidecode` 1.3 offers GPL/GPLv2+ or Artistic terms in its official
license file. UrbanPy relies on the GPLv2+ option, which permits use under
GPLv3. The package override records that selection explicitly.

The runtime closure otherwise reports permissive, public-domain, weak-copyleft,
or GPL-compatible alternatives covered by the reviewed exact policy. Compound
expressions and legacy classifier strings are retained in the output so the
evidence does not overstate metadata quality.

## Release evidence

Normal CI uploads the locked requirements export and dependency-license JSON.
The release build additionally stores them beside the CycloneDX SBOM and
attested wheel/sdist. The publish job promotes that bundle unchanged.

## Maintainer obligations

- Review every policy change and the corresponding primary license source.
- Reconcile FOSSA's complete finding set, including source, development,
generated, and vendored assets that are outside the runtime closure.
- Obtain EL-BID legal approval when license compatibility or data licensing is
ambiguous; do not encode an automated guess as approval.
- Preserve license notices required by distributions and data providers.
39 changes: 39 additions & 0 deletions license-policy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This is an engineering gate, not legal advice. EL-BID maintainers own final
# dependency-license approval. Exact metadata changes fail closed for review.
[policy]
allowed = [
"0BSD",
"0BSD OR CC0-1.0",
"Apache Software License",
"Apache Software License; BSD License",
"Apache-2.0",
"Apache-2.0 OR BSD-2-Clause",
"BSD License",
"BSD License; GNU General Public License (GPL); Public Domain",
"BSD-2-Clause",
"BSD-3-Clause",
"BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0",
"BSD-3-Clause AND ISC",
"GNU General Public License v2 or later (GPLv2+)",
"GPL-3.0-only",
"ISC License (ISCL)",
"MIT",
"MIT License",
"MIT OR AFL-2.1",
"Mozilla Public License 2.0 (MPL 2.0)",
"MPL-2.0",
"MPL-2.0 AND MIT",
"PSF-2.0",
"Python Software Foundation License",
"The Unlicense (Unlicense)",
]

[packages.defopt]
license = "MIT"
source = "https://github.com/anntzer/defopt/blob/main/LICENSE.txt"
disposition = "Version 7.0.0 omits license metadata; the official source license is MIT."

[packages.text-unidecode]
license = "GNU General Public License v2 or later (GPLv2+)"
source = "https://github.com/kmike/text-unidecode/blob/master/LICENSE"
disposition = "The package offers GPL/GPLv2+ or Artistic terms; UrbanPy relies on the GPLv2+ option, which can be used under GPLv3."
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ include = [
"/CONTRIBUTING.md",
"/GOVERNANCE.md",
"/LICENSE",
"/license-policy.toml",
"/README.md",
"/RELEASING.md",
"/SECURITY.md",
Expand Down
121 changes: 121 additions & 0 deletions scripts/check_licenses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Audit installed UrbanPy runtime dependency license metadata."""

from __future__ import annotations

import argparse
import importlib.metadata
import json
import sys
import tomllib
from collections import deque
from pathlib import Path
from typing import Any

from packaging.markers import default_environment
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name


def runtime_closure(root: str = "urbanpy") -> list[importlib.metadata.Distribution]:
installed = {
canonicalize_name(dist.metadata["Name"]): dist
for dist in importlib.metadata.distributions()
if dist.metadata.get("Name")
}
pending = deque([canonicalize_name(root)])
visited: set[str] = set()
result: list[importlib.metadata.Distribution] = []
environment = default_environment() | {"extra": ""}
while pending:
name = pending.popleft()
if name in visited:
continue
visited.add(name)
distribution = installed.get(name)
if distribution is None:
raise ValueError(f"runtime dependency {name!r} is not installed")
result.append(distribution)
for value in distribution.requires or ():
requirement = Requirement(value)
if requirement.marker and not requirement.marker.evaluate(environment):
continue
pending.append(canonicalize_name(requirement.name))
return sorted(result, key=lambda item: canonicalize_name(item.metadata["Name"]))


def observed_license(distribution: importlib.metadata.Distribution) -> str:
expression = distribution.metadata.get("License-Expression")
if expression:
return expression.strip()
classifiers = sorted(
{
value.rsplit(" :: ", 1)[-1].strip()
for value in distribution.metadata.get_all("Classifier", [])
if value.startswith("License :: ")
}
)
if classifiers:
return "; ".join(classifiers)
value = distribution.metadata.get("License")
return value.strip() if value and value.strip() else "UNKNOWN"


def audit(policy_path: Path) -> tuple[list[dict[str, Any]], list[str]]:
with policy_path.open("rb") as source:
policy = tomllib.load(source)
allowed = set(policy["policy"]["allowed"])
overrides = {
canonicalize_name(name): value
for name, value in policy.get("packages", {}).items()
}
report: list[dict[str, Any]] = []
violations: list[str] = []
for distribution in runtime_closure():
name = distribution.metadata["Name"]
observed = observed_license(distribution)
override = overrides.get(canonicalize_name(name))
effective = override["license"] if override else observed
approved = effective in allowed
record = {
"approved": approved,
"effective_license": effective,
"name": name,
"observed_license": observed,
"version": distribution.version,
}
if override:
record["disposition"] = override["disposition"]
record["source"] = override["source"]
report.append(record)
if not approved:
violations.append(f"{name} {distribution.version}: {observed}")
return report, violations


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--policy", type=Path, default=Path("license-policy.toml"))
parser.add_argument("--output", type=Path)
args = parser.parse_args(argv)
try:
report, violations = audit(args.policy)
except (OSError, KeyError, ValueError) as error:
print(f"license audit failed: {error}", file=sys.stderr)
return 2
payload = json.dumps(report, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(payload, encoding="utf-8")
else:
print(payload, end="")
if violations:
print("unapproved runtime dependency licenses:", file=sys.stderr)
for violation in violations:
print(f"- {violation}", file=sys.stderr)
return 1
print(f"approved {len(report)} runtime package license records")
return 0


if __name__ == "__main__":
raise SystemExit(main())
37 changes: 37 additions & 0 deletions tests/license_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from pathlib import Path

from packaging.utils import canonicalize_name

from scripts.check_licenses import audit, observed_license, runtime_closure


def test_runtime_dependency_closure_has_an_approved_license_disposition():
report, violations = audit(Path("license-policy.toml"))

assert not violations
assert report
assert report == sorted(report, key=lambda item: canonicalize_name(item["name"]))
assert all(item["approved"] for item in report)
assert {item["name"].casefold() for item in report} >= {
"geopandas",
"h3",
"osmnx",
"pydantic",
"urbanpy",
}


def test_runtime_closure_excludes_development_only_packages():
names = {item.metadata["Name"].casefold() for item in runtime_closure()}

assert "pytest" not in names
assert "sphinx" not in names


def test_every_runtime_distribution_exposes_or_has_a_reviewed_license():
observed = {
item.metadata["Name"]: observed_license(item) for item in runtime_closure()
}

assert observed["urbanpy"] == "GPL-3.0-only"
assert observed["defopt"] == "UNKNOWN"
Loading