Module continuous_delivery_scripts.spdx_report.licence_assessment
Explainable, directional licence-risk screening for third-party reports.
Rules are data, not legal conclusions. The embedded TOML file can be reviewed and selectively overridden by a project without changing this evaluator.
Functions
def verified_spdx_licence_expression(value: str | None) ‑> str | None-
Expand source code
def verified_spdx_licence_expression(value: Optional[str]) -> Optional[str]: """Extract a verified SPDX choice without treating exemption prose as a licence. Accept a complete expression, or an explicit 'either X or Y' choice at the end of a manual review explanation. Match spelled-out licence names exactly; fuzzy matching could silently select unrelated licence terms. """ if not isinstance(value, str) or not value.strip(): return None licensing = get_spdx_licensing() def parse_known_expression(text: str) -> Optional[str]: try: expression = licensing.parse(normalise_proprietary_licence(text), strict=True) except (ExpressionError, ValueError): return None if expression is None: return None for symbol in expression.symbols: if isinstance(symbol, LicenseWithExceptionSymbol): if symbol.exception_symbol.key not in licensing.known_symbols: return None identifiers = (symbol.license_symbol.key,) elif isinstance(symbol, LicenseSymbol): if symbol.is_exception: return None identifiers = (symbol.key,) else: return None if any( identifier not in licensing.known_symbols and not re.fullmatch(r"LicenseRef-[A-Za-z0-9.-]+", identifier) for identifier in identifiers ): return None return str(expression.simplify()) whole_expression = parse_known_expression(value) if whole_expression: return whole_expression choices = re.search(r"\beither\s+(.+?)\s+or\s+(.+)$", value.strip(), flags=re.IGNORECASE) if not choices: return None prefix = value.strip()[: choices.start()].strip() if prefix and ( re.search(r"\b(?:not|never|neither|example|hypothetical)\b", prefix, flags=re.IGNORECASE) or not re.search( r"(?:\bdual[- ]licen[cs]e(?:d)?\s*[-:;,]?|\blicen[cs]ed\s+under)\s*$", prefix, flags=re.IGNORECASE, ) ): return None alternatives = [] for alternative in choices.groups(): candidate = re.sub(r"^the\s+", "", alternative.strip(" .;"), flags=re.IGNORECASE) expression = parse_known_expression(candidate) if expression is None: licence = OPENSOURCE_LICENCES.get_licence(candidate, allow_fuzzy=False) expression = parse_known_expression(licence.identifier) if licence else None if expression is None: return None alternatives.append(expression) return parse_known_expression(f"({alternatives[0]}) OR ({alternatives[1]})")Extract a verified SPDX choice without treating exemption prose as a licence.
Accept a complete expression, or an explicit 'either X or Y' choice at the end of a manual review explanation. Match spelled-out licence names exactly; fuzzy matching could silently select unrelated licence terms.
Classes
class LicenceAssessment (*values)-
Expand source code
class LicenceAssessment(Enum): """Outcomes of an advisory assessment, separate from allowlist compliance.""" ALLOW = "ALLOW" REVIEW = "REVIEW" MANUALLY_REVIEWED = "MANUALLY_REVIEWED" DENY = "DENY" UNKNOWN = "UNKNOWN"Outcomes of an advisory assessment, separate from allowlist compliance.
Ancestors
- enum.Enum
Class variables
var ALLOW-
The type of the None singleton.
var DENY-
The type of the None singleton.
var MANUALLY_REVIEWED-
The type of the None singleton.
var REVIEW-
The type of the None singleton.
var UNKNOWN-
The type of the None singleton.
class LicenceAssessmentPolicy (default: dict,
override: dict | None = None,
inline_override: dict | None = None)-
Expand source code
class LicenceAssessmentPolicy: """Packaged screening rules with optional file and inline project overlays.""" def __init__(self, default: dict, override: Optional[dict] = None, inline_override: Optional[dict] = None) -> None: """Validate and merge classifications, rules and package exceptions.""" self.classifications: Dict[str, LicenceCategory] = {} self.scancode_categories: Dict[str, LicenceCategory] = {} self.rules: Dict[str, _Rule] = {} self.packages: Dict[str, _PackageOverride] = {} self.fail_on: Tuple[LicenceAssessment, ...] = () for data, source in ((default, "built-in"), (override, "project"), (inline_override, "pyproject.toml")): if data is None: continue self._apply(data, source) @classmethod def from_config(cls) -> "LicenceAssessmentPolicy": """Load packaged defaults, an optional project file, then inline rules.""" resource = resources.files("continuous_delivery_scripts.spdx_report").joinpath( "data", "licence_assessment.toml" ) default = toml.loads(resource.read_text(encoding="utf8")) override_path = configuration.get_value_or_default(ConfigurationVariable.LICENCE_ASSESSMENT_RULES_PATH, None) override = toml.load(Path(override_path)) if override_path else None inline_override = configuration.get_value_or_default(ConfigurationVariable.LICENCE_ASSESSMENT_RULES, None) policy = cls(default, override, inline_override) fail_on = configuration.get_value_or_default(ConfigurationVariable.LICENCE_ASSESSMENT_FAIL_ON, None) if fail_on is not None: policy._set_fail_on(fail_on, "ProjectConfig.LICENCE_ASSESSMENT_FAIL_ON") return policy def _set_fail_on(self, values: Any, source: str) -> None: """Validate the same status list for inline, file and top-level settings.""" if not isinstance(values, list) or any(not isinstance(value, str) for value in values): raise ValueError(f"{source}: fail_on must be a list of assessment statuses") self.fail_on = tuple(_require_status(value, f"{source} fail_on") for value in values) if LicenceAssessment.ALLOW in self.fail_on: raise ValueError(f"{source}: ALLOW cannot be a failing assessment status") def _apply(self, data: dict, source: str) -> None: if not isinstance(data, dict) or set(data) - { "schema_version", "settings", "classifications", "scancode_categories", "rules", "packages", }: raise ValueError(f"{source}: unexpected licence assessment policy fields") if data.get("schema_version") != 1: raise ValueError(f"{source}: licence assessment policy requires schema_version = 1") settings = data.get("settings", {}) if not isinstance(settings, dict) or set(settings) - {"fail_on"}: raise ValueError(f"{source}: unknown licence assessment settings") if "fail_on" in settings: self._set_fail_on(settings["fail_on"], source) classifications = data.get("classifications", {}) if not isinstance(classifications, dict): raise ValueError(f"{source}: classifications must be a table") for identifier, category in classifications.items(): self.classifications[_require_text(identifier, "Licence identifier")] = _require_category( category, identifier ) categories = data.get("scancode_categories", {}) if not isinstance(categories, dict): raise ValueError(f"{source}: scancode_categories must be a table") for category_name, classification in categories.items(): self.scancode_categories[_require_text(category_name, "ScanCode category")] = _require_category( classification, category_name ) entries = data.get("rules", []) if not isinstance(entries, list): raise ValueError(f"{source}: rules must be an array of tables") parsed_rules = [_parse_rule(entry, source) for entry in entries] if len({rule.id for rule in parsed_rules}) != len(parsed_rules): raise ValueError(f"{source}: duplicate licence assessment rule IDs") self.rules.update((rule.id, rule) for rule in parsed_rules) self.packages.update(_parse_packages(data.get("packages", []), source))Packaged screening rules with optional file and inline project overlays.
Validate and merge classifications, rules and package exceptions.
Static methods
def from_config() ‑> LicenceAssessmentPolicy-
Load packaged defaults, an optional project file, then inline rules.
class LicenceAssessmentResult (status: LicenceAssessment,
project_licence: str,
dependency_licence: str,
reason: str,
rule: str,
source: str,
selected_licence: str | None = None,
automatic_status: LicenceAssessment | None = None,
manual_review_reason: str = '',
scancode_licences: Tuple[ScanCodeLicenceInfo, ...] = (),
discovered_licence: str | None = None,
assessed_licence_source: str = 'discovered metadata')-
Expand source code
@dataclass(frozen=True) class LicenceAssessmentResult: """A reportable, auditable assessment of one dependency.""" status: LicenceAssessment project_licence: str dependency_licence: str reason: str rule: str source: str selected_licence: Optional[str] = None automatic_status: Optional[LicenceAssessment] = None manual_review_reason: str = "" scancode_licences: Tuple[ScanCodeLicenceInfo, ...] = () discovered_licence: Optional[str] = None assessed_licence_source: str = "discovered metadata" def as_report(self) -> Dict[str, Any]: """Return JSON- and template-friendly values.""" return { "status": self.status.value, "project_licence": self.project_licence, "dependency_licence": self.dependency_licence, "discovered_licence": self.discovered_licence or self.dependency_licence, "assessed_licence_source": self.assessed_licence_source, "reason": self.reason, "rule": self.rule, "source": self.source, "selected_licence": self.selected_licence or "", "automatic_status": (self.automatic_status or self.status).value, "manual_review": { "reviewed": self.status is LicenceAssessment.MANUALLY_REVIEWED, "reason": self.manual_review_reason, }, "scancode_licences": [ {"identifier": info.identifier, "category": info.category, "url": info.url} for info in self.scancode_licences ], }A reportable, auditable assessment of one dependency.
Instance variables
var assessed_licence_source : str-
The type of the None singleton.
var automatic_status : LicenceAssessment | None-
The type of the None singleton.
var dependency_licence : str-
The type of the None singleton.
var discovered_licence : str | None-
The type of the None singleton.
var manual_review_reason : str-
The type of the None singleton.
var project_licence : str-
The type of the None singleton.
var reason : str-
The type of the None singleton.
var rule : str-
The type of the None singleton.
var scancode_licences : Tuple[ScanCodeLicenceInfo, ...]-
The type of the None singleton.
var selected_licence : str | None-
The type of the None singleton.
var source : str-
The type of the None singleton.
var status : LicenceAssessment-
The type of the None singleton.
Methods
def as_report(self) ‑> Dict[str, Any]-
Expand source code
def as_report(self) -> Dict[str, Any]: """Return JSON- and template-friendly values.""" return { "status": self.status.value, "project_licence": self.project_licence, "dependency_licence": self.dependency_licence, "discovered_licence": self.discovered_licence or self.dependency_licence, "assessed_licence_source": self.assessed_licence_source, "reason": self.reason, "rule": self.rule, "source": self.source, "selected_licence": self.selected_licence or "", "automatic_status": (self.automatic_status or self.status).value, "manual_review": { "reviewed": self.status is LicenceAssessment.MANUALLY_REVIEWED, "reason": self.manual_review_reason, }, "scancode_licences": [ {"identifier": info.identifier, "category": info.category, "url": info.url} for info in self.scancode_licences ], }Return JSON- and template-friendly values.
class LicenceAssessor (policy: LicenceAssessmentPolicy,
lookup_scancode: bool = False)-
Expand source code
class LicenceAssessor: """Screen an SPDX dependency expression against a project licence.""" def __init__(self, policy: LicenceAssessmentPolicy, lookup_scancode: bool = False) -> None: """Use the same policy for reports and optional CI gating.""" self.policy = policy self._licensing = get_spdx_licensing() self.manual_reviews = _load_manual_reviews() self._scancode = ScanCodeLicenceDB() if lookup_scancode else None self._scancode_references: Dict[str, ScanCodeLicenceInfo] = {} def _lookup_info(self, identifier: str) -> Optional[ScanCodeLicenceInfo]: """Consult the exact SPDX key only when lookup is explicitly enabled.""" if self._scancode is None: return None info = self._scancode.find(identifier) if info: self._scancode_references[identifier] = info return info def _category_for(self, identifier: str) -> Optional[LicenceCategory]: """Prefer reviewed project and embedded classifications over LicenseDB.""" category = self.policy.classifications.get(identifier) if category is not None: return category info = self._lookup_info(identifier) return self.policy.scancode_categories.get(info.category) if info else None def _attach_lookup_references( self, result: LicenceAssessmentResult, project: str, dependency: str ) -> LicenceAssessmentResult: """Preserve where externally obtained licence information came from.""" if not self._scancode_references: return result identifiers = set() for expression in (project, dependency): try: parsed = self._licensing.parse(normalise_proprietary_licence(expression)) for symbol in parsed.symbols: if isinstance(symbol, LicenseWithExceptionSymbol): identifiers.add(symbol.license_symbol.key) elif isinstance(symbol, LicenseSymbol): identifiers.add(symbol.key) except (ValueError, AttributeError): continue references = tuple( self._scancode_references[key] for key in sorted(identifiers & self._scancode_references.keys()) ) if not references: return result categories = ", ".join(f"{info.identifier}: {info.category}" for info in references) return replace( result, reason=( f"{result.reason} ScanCode LicenseDB describes {categories}; " "its category is not a compatibility verdict." ), source=f"{result.source} + ScanCode LicenseDB", scancode_licences=references, ) def _result( self, status: LicenceAssessment, project: str, dependency: str, reason: str, rule: str, source: str = "built-in", selected: Optional[str] = None, ) -> LicenceAssessmentResult: return LicenceAssessmentResult(status, project, dependency, reason, rule, source, selected) def assess( self, project_licence: str, dependency_licence: str, dependency_name: str = "", dependency_version: str = "", project_unknown: bool = False, dependency_unknown: bool = False, verified_licence: Optional[str] = None, ) -> LicenceAssessmentResult: """Keep the automatic finding while recording a matching manual review.""" manual_licence = verified_spdx_licence_expression(verified_licence) assessed_licence = dependency_licence automatic = self._assess_automatic( project_licence, dependency_licence, dependency_name, dependency_version, project_unknown, dependency_unknown, ) if automatic.status is LicenceAssessment.UNKNOWN and automatic.rule != "project-unknown" and manual_licence: assessed_licence = manual_licence automatic = self._assess_automatic( project_licence, assessed_licence, dependency_name, dependency_version, project_unknown, False ) source_details = ( "the discovered licence was unknown" if dependency_unknown else ( "the discovered licence could not be assessed reliably; " "verify any obligations omitted by the manual value" ) ) automatic = replace( automatic, discovered_licence=dependency_licence, assessed_licence_source="manual licence review", reason=( f"Assessed using manually verified {manual_licence} because {source_details}. " + automatic.reason ), ) elif automatic.status is LicenceAssessment.UNKNOWN and verified_licence and not manual_licence: automatic = replace( automatic, reason=automatic.reason + " The manual review record is not a recognised SPDX licence expression.", ) automatic = self._attach_lookup_references(automatic, project_licence, assessed_licence) review = self.manual_reviews.get(dependency_name) if ( automatic.status is LicenceAssessment.REVIEW and review and review.matches(assessed_licence, dependency_version) ): return replace( automatic, status=LicenceAssessment.MANUALLY_REVIEWED, automatic_status=LicenceAssessment.REVIEW, manual_review_reason=review.reason, ) return automatic def _assess_automatic( self, project_licence: str, dependency_licence: str, dependency_name: str, dependency_version: str, project_unknown: bool, dependency_unknown: bool, ) -> LicenceAssessmentResult: """Return a conservative, directional result without invoking licence discovery.""" project = project_licence or "Unknown" dependency = dependency_licence or "Unknown" if project_unknown or project.casefold() in ("unknown", "none", "noassertion"): return self._result( LicenceAssessment.UNKNOWN, project, dependency, "Project licence is not reliably known.", "project-unknown", ) matching = [ item for item in self.policy.packages.values() if item.name.casefold() == dependency_name.casefold() and (item.version is None or item.version == dependency_version) ] if matching: most_specific = sorted(matching, key=lambda item: item.version is not None, reverse=True) if len(most_specific) > 1 and most_specific[0].version == most_specific[1].version: raise ValueError(f"Conflicting licence assessment overrides for {dependency_name}") override = most_specific[0] return self._result(override.status, project, dependency, override.reason, override.id, "project") if dependency_unknown or dependency.casefold() in ("unknown", "none", "noassertion"): return self._result( LicenceAssessment.UNKNOWN, project, dependency, "Dependency licence is not reliably known.", "dependency-unknown", ) try: project_node = self._licensing.parse(normalise_proprietary_licence(project)) dependency_node = self._licensing.parse(normalise_proprietary_licence(dependency)) if project_node is None or dependency_node is None: raise ValueError("Empty SPDX expression") except Exception: return self._result( LicenceAssessment.UNKNOWN, project, dependency, "Licence expression cannot be parsed.", "expression-invalid", ) project_name = str(project_node) project_category = self._category_for(project_node.key) if isinstance(project_node, LicenseSymbol) else None if project_category is LicenceCategory.UNKNOWN: return self._result( LicenceAssessment.UNKNOWN, project, dependency, "The project licence is classified as unknown.", "project-unknown", ) return self._assess_node(project, dependency, project_name, project_category, dependency_node) def _matching_rule( self, project: str, dependency: str, project_category: Optional[LicenceCategory], dependency_category: Optional[LicenceCategory], ) -> Optional[_Rule]: matching = [ rule for rule in self.policy.rules.values() if rule.matches( project, dependency, project_category.value if project_category else None, dependency_category.value if dependency_category else "UNKNOWN", ) ] if not matching: return None matching.sort(key=lambda rule: rule.specificity, reverse=True) if len(matching) > 1 and matching[0].specificity == matching[1].specificity: raise ValueError(f"Conflicting licence assessment rules: {matching[0].id} and {matching[1].id}") return matching[0] def _assess_node( self, project: str, dependency: str, project_name: str, project_category: Optional[LicenceCategory], node: Any ) -> LicenceAssessmentResult: name = str(node) category = self._category_for(node.key) if isinstance(node, LicenseSymbol) else None rule = self._matching_rule(project_name, name, project_category, category) if rule: return self._result(rule.status, project, dependency, rule.reason, rule.id, rule.source, name) if isinstance(node, LicenseSymbol) and category is not None: self._lookup_info(node.key) if category is LicenceCategory.UNKNOWN: return self._result( LicenceAssessment.UNKNOWN, project, dependency, f"The dependency licence {name} is classified as unknown.", "dependency-unknown", selected=name, ) if not project_category: return self._result( LicenceAssessment.REVIEW, project, dependency, "Project licence is unclassified or offers multiple terms; " "determine the applicable terms before comparison.", "project-needs-review", ) if isinstance(node, OR): branches = [ self._assess_node(project, dependency, project_name, project_category, part) for part in node.args ] for status in ( LicenceAssessment.ALLOW, LicenceAssessment.REVIEW, LicenceAssessment.UNKNOWN, LicenceAssessment.DENY, ): selected = next((branch for branch in branches if branch.status is status), None) if selected: return self._result( status, project, dependency, f"An OR choice of {selected.selected_licence or name} is {status.value}: {selected.reason}", "expression-or", source=selected.source, selected=selected.selected_licence, ) if isinstance(node, AND): branches = [ self._assess_node(project, dependency, project_name, project_category, part) for part in node.args ] for status in ( LicenceAssessment.DENY, LicenceAssessment.UNKNOWN, LicenceAssessment.REVIEW, LicenceAssessment.ALLOW, ): if any(branch.status is status for branch in branches): source = "project" if any(branch.source == "project" for branch in branches) else "built-in" return self._result( status, project, dependency, "All AND obligations apply: " + "; ".join(f"{branch.selected_licence}: {branch.reason}" for branch in branches), "expression-and", source=source, selected=name, ) if isinstance(node, LicenseWithExceptionSymbol): self._lookup_info(node.license_symbol.key) return self._result( LicenceAssessment.REVIEW, project, dependency, f"The exception in {name} has not been reviewed in the configured policy.", "exception-needs-review", selected=name, ) if isinstance(node, LicenseSymbol): if name.startswith("LicenseRef-"): reason = f"The terms of the project-defined licence reference {name} need human review." status = LicenceAssessment.REVIEW identifier = "licence-reference-needs-review" elif category is not None: reason = f"No directional assessment rule is defined for {name}; review its obligations." status = LicenceAssessment.REVIEW identifier = "directional-rule-missing" else: reason = f"No classification or directional rule is available for {name}." status = LicenceAssessment.UNKNOWN identifier = "licence-unclassified" return self._result(status, project, dependency, reason, identifier, selected=name) return self._result( LicenceAssessment.UNKNOWN, project, dependency, "Unsupported SPDX expression.", "expression-unsupported" )Screen an SPDX dependency expression against a project licence.
Use the same policy for reports and optional CI gating.
Methods
def assess(self,
project_licence: str,
dependency_licence: str,
dependency_name: str = '',
dependency_version: str = '',
project_unknown: bool = False,
dependency_unknown: bool = False,
verified_licence: str | None = None) ‑> LicenceAssessmentResult-
Expand source code
def assess( self, project_licence: str, dependency_licence: str, dependency_name: str = "", dependency_version: str = "", project_unknown: bool = False, dependency_unknown: bool = False, verified_licence: Optional[str] = None, ) -> LicenceAssessmentResult: """Keep the automatic finding while recording a matching manual review.""" manual_licence = verified_spdx_licence_expression(verified_licence) assessed_licence = dependency_licence automatic = self._assess_automatic( project_licence, dependency_licence, dependency_name, dependency_version, project_unknown, dependency_unknown, ) if automatic.status is LicenceAssessment.UNKNOWN and automatic.rule != "project-unknown" and manual_licence: assessed_licence = manual_licence automatic = self._assess_automatic( project_licence, assessed_licence, dependency_name, dependency_version, project_unknown, False ) source_details = ( "the discovered licence was unknown" if dependency_unknown else ( "the discovered licence could not be assessed reliably; " "verify any obligations omitted by the manual value" ) ) automatic = replace( automatic, discovered_licence=dependency_licence, assessed_licence_source="manual licence review", reason=( f"Assessed using manually verified {manual_licence} because {source_details}. " + automatic.reason ), ) elif automatic.status is LicenceAssessment.UNKNOWN and verified_licence and not manual_licence: automatic = replace( automatic, reason=automatic.reason + " The manual review record is not a recognised SPDX licence expression.", ) automatic = self._attach_lookup_references(automatic, project_licence, assessed_licence) review = self.manual_reviews.get(dependency_name) if ( automatic.status is LicenceAssessment.REVIEW and review and review.matches(assessed_licence, dependency_version) ): return replace( automatic, status=LicenceAssessment.MANUALLY_REVIEWED, automatic_status=LicenceAssessment.REVIEW, manual_review_reason=review.reason, ) return automaticKeep the automatic finding while recording a matching manual review.
class LicenceCategory (*values)-
Expand source code
class LicenceCategory(Enum): """Broad screening categories, not a substitute for individual licence terms.""" PERMISSIVE = "PERMISSIVE" WEAK_COPYLEFT = "WEAK_COPYLEFT" STRONG_COPYLEFT = "STRONG_COPYLEFT" NETWORK_COPYLEFT = "NETWORK_COPYLEFT" PUBLIC_DOMAIN = "PUBLIC_DOMAIN" PROPRIETARY = "PROPRIETARY" UNKNOWN = "UNKNOWN"Broad screening categories, not a substitute for individual licence terms.
Ancestors
- enum.Enum
Class variables
var NETWORK_COPYLEFT-
The type of the None singleton.
var PERMISSIVE-
The type of the None singleton.
var PROPRIETARY-
The type of the None singleton.
var PUBLIC_DOMAIN-
The type of the None singleton.
var STRONG_COPYLEFT-
The type of the None singleton.
var UNKNOWN-
The type of the None singleton.
var WEAK_COPYLEFT-
The type of the None singleton.