Source code for rs_metadata.normalize.licenses

"""License normalization against the vendored SPDX license list.

The SPDX list ships inside the package, so identifiers are resolved against
the real thing, including deprecations.
"""

from __future__ import annotations

import re
from typing import Any

from ..vocabulary import spdx_index
from .scalars import normalize_text, scalarize

__all__ = [
    "SPDX_OPERATORS",
    "SPDX_URL_PREFIX",
    "normalize_license",
    "spdx_identifier",
]

SPDX_URL_PREFIX = "https://spdx.org/licenses/"

_SPDX_URL_RE = re.compile(
    r"^https?://spdx\.org/licenses/([^/#?]+?)(?:\.html|\.json)?/?$", re.IGNORECASE
)

#: Operators in an SPDX license expression, uppercased when canonicalising.
#: The operators that make an SPDX expression rather than a bare identifier.
SPDX_OPERATORS = frozenset({"AND", "OR", "WITH"})


[docs] def spdx_identifier(raw: Any) -> str | None: """Resolve a value to a canonical SPDX identifier. Parameters ---------- raw : Any An SPDX identifier, an ``spdx.org/licenses/`` URL, or a node carrying one. Returns ------- str or None The identifier in SPDX's own capitalisation, or ``None`` if the value is not a recognized SPDX identifier. Notes ----- SPDX identifiers are case-sensitive by specification, but people write ``apache-2.0`` constantly. Recognizing the value and reporting the canonical spelling is more useful than rejecting it. ``LicenseRef-`` identifiers name a license outside the SPDX list; they are returned as written, since there is nothing to canonicalise against. Examples -------- URL and bare forms resolve alike, and casing is corrected: >>> spdx_identifier("https://spdx.org/licenses/Apache-2.0") 'Apache-2.0' >>> spdx_identifier("apache-2.0") 'Apache-2.0' >>> spdx_identifier("https://spdx.org/licenses/Apache-2.0.html") 'Apache-2.0' A ``CreativeWork`` node is reduced to its URL first: >>> spdx_identifier({"@type": "CreativeWork", "name": "Apache License 2.0", ... "url": "https://spdx.org/licenses/Apache-2.0"}) 'Apache-2.0' A URL that claims SPDX but names no real license is not resolved — the profile reports that as an error: >>> spdx_identifier("https://spdx.org/licenses/Apache-2.0-only") is None True Neither is free text: >>> spdx_identifier("Our institutional license") is None True """ text = scalarize(raw) if text is None: return None url_match = _SPDX_URL_RE.match(text) candidate = url_match.group(1) if url_match else text canonical = spdx_index().get(candidate.casefold()) if canonical: return canonical if candidate.casefold().startswith("licenseref-"): return candidate return None
[docs] def normalize_license(raw: Any) -> str | None: """Normalize a license value, including simple SPDX expressions. Parameters ---------- raw : Any An SPDX identifier or URL, an SPDX expression, or free text. Returns ------- str or None A comparison key, or ``None`` if empty. Notes ----- Falls back to text comparison for licenses that are not SPDX at all, so a custom or institutional license still compares consistently across files rather than being dropped. Expression operands are canonicalised and operators uppercased, but operands are **not** reordered: ``A OR B`` and ``B OR A`` mean the same thing, but reordering around mixed ``AND``/``OR`` precedence would not be safe. Multi-license records normally use a CodeMeta array, where the set comparison already makes order irrelevant. Examples -------- Every spelling of one license collapses together: >>> spellings = [ ... "Apache-2.0", ... "apache-2.0", ... "https://spdx.org/licenses/Apache-2.0", ... "http://spdx.org/licenses/Apache-2.0", ... ] >>> {normalize_license(spelling) for spelling in spellings} {'Apache-2.0'} Different licenses stay different: >>> normalize_license("MIT") == normalize_license("Apache-2.0") False Expressions are canonicalised: >>> normalize_license("mit OR apache-2.0") 'MIT OR Apache-2.0' >>> normalize_license("GPL-2.0-only WITH Classpath-exception-2.0") 'GPL-2.0-only WITH Classpath-exception-2.0' Unrecognized licenses fall back to text, so they still compare: >>> normalize_license("Our License") == normalize_license(" our license ") True """ single = spdx_identifier(raw) if single: return single text = scalarize(raw) if text is None: return None tokens = text.replace("(", " ( ").replace(")", " ) ").split() if len(tokens) > 1 and any(token.upper() in SPDX_OPERATORS for token in tokens): rendered: list[str] = [] recognized = False for token in tokens: if token.upper() in SPDX_OPERATORS or token in {"(", ")"}: rendered.append(token.upper()) continue plus = token.endswith("+") bare = token.rstrip("+") canonical = spdx_index().get(bare.casefold()) if canonical: recognized = True rendered.append((canonical or bare) + ("+" if plus else "")) if recognized: return " ".join(rendered) return normalize_text(text)