wads.licence_check

Audit the licence perimeter of a package’s installed dependency closure.

A licence rule nobody checks is a sentence, not a policy. This module walks the installed metadata of everything a bare pip install <pkg> pulls in and reports every distribution whose declared licence falls outside the perimeter.

Run it from the command line:

wads-licence-check                      # the project in the current directory
wads-licence-check path/to/project
wads-licence-check . --python .venv/bin/python   # read ANOTHER environment

Exit code is 1 when the perimeter is breached, 0 when it holds.

Configure it in the audited project’s pyproject.toml:

[tool.wads.licence]
enabled = true                # read by wads.ci_config for the CI gate
allowed = ["MIT", "BSD", "Apache-2.0", "ISC"]
forbidden = ["AGPL", "GPL", "LGPL", "SSPL", "BUSL", "Elastic-2.0", "RAIL"]
include-extras = []           # [] = hard deps only; ["*"] = every extra
unknown-is-failure = true
unclassified-is-failure = false

[tool.wads.licence.exceptions]
certifi = "MPL-2.0 - file-level weak copyleft over an unmodified CA bundle."

allowed and forbidden REPLACE the defaults, they do not extend them, so a hand-written list is a narrowing unless it is a superset of DFLT_ALLOWED / DFLT_FORBIDDEN. self_check() refuses to run a policy that has narrowed away a whole licence family, which is the guard that makes this survivable – but the honest move is to start from the defaults.

Write patterns as TOML literal strings (single quotes):

forbidden = ['\bGPL', '\bLGPL']      # right
forbidden = ["\bGPL", "\bLGPL"]      # WRONG: \b is a backspace character

TOML basic strings process escapes, so "\bGPL" reaches the regex engine as "\x08GPL" and silently matches nothing at all.

Exit codes: 0 the perimeter holds, 1 it is breached, 2 the tool could not run (bad config, unreadable environment, a policy that cannot detect).

Three properties are load-bearing, and each has a recorded failure behind it.

1. The precision ladder (declare()). Read, in order, the PEP 639 License-Expression, then the License :: trove classifiers, and only then the first line of the free-text License field. Never substring-scan that field whole: numpy is BSD-3-Clause and its field carries an LGPL URL for a vendored component’s notice, so a naive scan reports numpy as copyleft. A one-field check is wrong in both directions: click declares a PEP 639 expression and no classifiers, i2 declares neither and only a free-text field, and Arize Phoenix declares Elastic-2.0 in the field with no classifier at all – so a classifier-only gate sails straight past it.

2. The transitive closure (closure()). A copyleft distribution three levels down is exactly as much a part of what a downstream consumer inherits as a declared one. This is how html2text (GPL-3.0-or-later) sat unnoticed. The walk reads installed metadata, so its answer is a fact about the environment it runs in, not about the package – LicenceReport.render() says so out loud rather than letting the number read as universal.

3. A demonstrated true positive (self_check()). A detector with no demonstrated true positive is a detector nobody has checked: emptying the forbidden patterns left the originating test suite entirely green, because every other assertion only ever checked that nothing matched. So every run first proves the live policy still catches known-copyleft declarations and still clears known-permissive ones, and refuses to report at all if it cannot.

This module’s own file imports nothing outside the standard library (tomli only on Python 3.10, where tomllib does not yet exist), so it adds no dependency beyond wads’s own core and needs no toolchain of its own. That is a claim about the file, not isolation: importing it runs wads/__init__, which pulls wads’s core dependencies into sys.modules like any other import.

Module Attributes

DFLT_ALLOWED

Permissive families, matched case-insensitively against the declaration.

DFLT_COPYLEFT

Reciprocal (copyleft) families.

DFLT_NON_COMMERCIAL

Non-commercial / source-available families.

DFLT_FORBIDDEN

What a policy forbids unless it says otherwise.

UNKNOWN_DECLARATIONS

the terms may live in a repo file no scanner reads, which is exactly where LGPL and non-commercial model weights have been found hiding.

LADDER

The metadata fields the ladder reads, in decreasing order of precision.

COPYLEFT_CANARIES

Real declarations, copied from installed dist-info and from the official trove classifier list, that a policy is measured against.

CANARY_FAMILIES

The families COPYLEFT_CANARIES covers, in declaration order.

PERMISSIVE_CANARIES

Real declarations that any usable policy must still clear.

ALL_EXTRAS

include_extras value meaning "every optional-dependency group".

POLICY_TOML_PATH

Where the policy lives in pyproject.toml.

CLEAN_STATUSES

Statuses that never fail a run.

DEPENDENCIES_KEY

[project] key holding the hard requirements.

POLICY_TOML_KEYS

TOML keys accepted in [tool.wads.licence].

LEGACY_POLICY_KEYS

Keys from the earlier, hand-written table shape that shipped in a couple of repos before this module existed, mapped to what to do instead.

EXCEPTION_TABLE_NAME_KEY

Keys read off an [[tool.wads.licence.exceptions]] array-of-tables entry.

THIS_ENVIRONMENT

Readers bound to the interpreter running this module.

EXIT_OK

Exit codes, so the CI step and a human read the same numbers.

EXIT_BREACH

Exit codes, so the CI step and a human read the same numbers.

EXIT_ERROR

Exit codes, so the CI step and a human read the same numbers.

Functions

check([pkg_dir, policy, readers])

Audit a project's installed dependency closure against its policy.

closure(names, /, *[, read_requires, ...])

names PLUS everything they pull in, as INSTALLED in this environment.

declare(dist_name, /, *[, read_metadata])

The DECLARATION, in order of precision -- never the licence document.

declared_dependencies([pkg_dir, ...])

Every distribution the project DECLARES, read from pyproject.toml.

declared_licence(dist_name, /, *[, ...])

The declared licence text alone -- declare() without the source.

declared_requirements([pkg_dir, ...])

Every REQUIREMENT STRING the project declares, markers and all.

has_environment_marker(requirement, /)

Whether a requirement is gated on an ENVIRONMENT marker, not just an extra.

main([argv])

Console-script entry point.

normalise(name)

PEP 503 normalisation, so ruamel.yaml and ruamel-yaml are one name.

read_pyproject([pkg_dir])

Parse pyproject.toml from a directory (or a direct path to the file).

readers_for(search_path, /)

Metadata readers that see the distributions installed on search_path.

requirement_extra(requirement)

The extra a requirement is conditional on, or '' if it is a hard one.

requirement_name(requirement)

The distribution name out of a Requires-Dist / requirement string.

search_path_of(python_executable, /)

The sys.path of ANOTHER interpreter, so its packages can be read.

self_check(policy, /)

Prove the policy still detects, and still discriminates.

verdict(name, /, *, policy[, read_metadata, ...])

Judge one distribution against policy.

walk_closure(requirements, /, *[, ...])

The closure of requirements (raw requirement strings), marked up.

Classes

Canary(family, label, declaration)

One real licence declaration a policy is measured against.

ClosureWalk(names, conditional)

The closure, plus which of its members are reachable only conditionally.

Declaration(text, source)

What a distribution declares, and which field said so.

LicencePolicy([allowed, forbidden, ...])

The rule set a perimeter check is run against.

LicenceReport(policy, declared, verdicts[, ...])

What a run found: the closure it walked, and a verdict for every member.

MetadataReaders(read_metadata, read_requires)

The pair of readers check() walks the closure with.

SelfCheck(caught, uncovered_families)

What a policy proved about itself before it was allowed to report.

Status()

The verdicts a distribution can receive.

Verdict(name, status[, declaration, note])

One distribution's standing against the policy.

Exceptions

DetectorError

The live policy cannot detect anything, so its green result means nothing.

wads.licence_check.ALL_EXTRAS: str = '*'

include_extras value meaning “every optional-dependency group”.

wads.licence_check.CANARY_FAMILIES: tuple[str, ...] = ('LGPL', 'GPL', 'AGPL', 'Nethack')

The families COPYLEFT_CANARIES covers, in declaration order.

wads.licence_check.CLEAN_STATUSES: frozenset[str] = frozenset({'allowed', 'excepted', 'not-applicable'})

Statuses that never fail a run. NOT_INSTALLED is deliberately absent: a hard dependency the check could not read is the same confident-green failure as reading the wrong environment. A dependency gated on an environment marker gets NOT_APPLICABLE instead, which is clean.

wads.licence_check.COPYLEFT_CANARIES: tuple[Canary, ...] = (('LGPL', 'argh / PyGithub (legacy LGPL classifier)', 'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)'), ('LGPL', 'LGPLv2 classifier', 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)'), ('LGPL', 'LGPLv2+ classifier', 'License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)'), ('LGPL', 'LGPLv3 classifier', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)'), ('LGPL', 'LGPLv3+ classifier', 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)'), ('LGPL', 'spelled-out LGPL', 'GNU Lesser General Public License'), ('LGPL', 'bare LGPLv3+', 'LGPLv3+'), ('LGPL', 'soxr', 'LGPL-2.1-or-later'), ('GPL', 'GPLv3 classifier', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)'), ('GPL', 'GPLv2+ classifier', 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)'), ('GPL', 'spelled-out GPL', 'GNU General Public License v2 or later'), ('GPL', 'html2text', 'GPL-3.0-or-later'), ('AGPL', 'AGPLv3+ classifier', 'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)'), ('AGPL', 'AGPLv3 classifier', 'License :: OSI Approved :: GNU Affero General Public License v3'), ('AGPL', 'spelled-out AGPL', 'GNU Affero General Public License v3'), ('AGPL', 'bare AGPLv3', 'AGPLv3'), ('AGPL', 'ultralytics', 'AGPL-3.0'), ('Nethack', 'Nethack GPL classifier', 'License :: OSI Approved :: Nethack General Public License'))

Real declarations, copied from installed dist-info and from the official trove classifier list, that a policy is measured against. See self_check(), which requires each FAMILY to be caught whole or not at all.

The list is deliberately wider than the acronyms: it was hand-picked from the same mental model as the patterns once, and the four LGPL spellings that escaped were exactly the ones nobody thought to write down. Every officially published GPL-family trove classifier spelling is here, plus the bare acronym-with-version and the fully-spelled-out prose forms.

class wads.licence_check.Canary(family, label, declaration)[source]

Bases: NamedTuple

One real licence declaration a policy is measured against.

family is what makes the measurement useful. Permitting a whole family is a coherent stance somebody can take on purpose – LGPL for dynamically linked libraries is the usual one. Catching part of a family is never a stance: it means the author meant to catch it and the spelling got away. self_check() allows the first and refuses the second.

declaration: str

Alias for field number 2

family: str

Alias for field number 0

label: str

Alias for field number 1

class wads.licence_check.ClosureWalk(names, conditional)[source]

Bases: NamedTuple

The closure, plus which of its members are reachable only conditionally.

conditional is every name that could ONLY be reached by following a requirement gated on an environment marker (python_version, sys_platform, …). Those are the names whose absence from the environment being read is expected rather than alarming; every other absence means the check did not get to look at something the project genuinely requires.

conditional: frozenset[str]

Alias for field number 1

names: tuple[str, ...]

Alias for field number 0

wads.licence_check.DEPENDENCIES_KEY: str = 'dependencies'

[project] key holding the hard requirements. Named because two different absences of it mean two different things – see declared_requirements().

wads.licence_check.DFLT_ALLOWED: tuple[str, ...] = ('\\bMIT\\b', '\\bBSD\\b', '\\b0BSD\\b', '\\bApache[- ]?2', '\\bApache Software License\\b', '\\bISC\\b', '\\bPython Software Foundation\\b', '\\bPSF\\b', '\\bHPND\\b', '\\bUnlicense\\b', '\\bCC0\\b', '\\bZlib\\b', '\\bBoost Software License\\b', '\\bBSL[- ]?1\\.0\\b')

Permissive families, matched case-insensitively against the declaration. \b0BSD\b is its own entry because \bBSD\b cannot reach inside 0BSD – there is no word boundary between a digit and a letter – so the SPDX id of one of the most permissive licences in existence would otherwise land in UNCLASSIFIED.

wads.licence_check.DFLT_COPYLEFT: tuple[str, ...] = ('\\bAGPL', '\\bAffero\\b', '\\bGPL(?![\\w.+-]*\\s+with\\b)', '\\bGNU General Public\\b', '\\bLGPL', '\\bLesser General Public\\b', '\\bLibrary General Public\\b', '\\bNethack General Public\\b', '\\bEUPL\\b')

Reciprocal (copyleft) families.

Every pattern here is anchored at its LEFT edge only, and that asymmetry is the whole point. \bGPL cannot reach inside LGPL (there is no word boundary between L and G), so LGPL and AGPL need their own entries – but a TRAILING \b on those entries is a hole, not a safeguard: it cannot match LGPLv3, LGPLv2+ or AGPLv3, which is every modern trove classifier and most SPDX-adjacent free text. A gate built to catch copyleft spent its first life letting the four commonest LGPL spellings straight through. COPYLEFT_CANARIES now pins all of them.

Every family carries a spelled-out pattern beside its acronym, because the prose forms (“GNU General Public License v2 or later”) carry no acronym at all. They are deliberately family-SPECIFIC rather than one broad General Public License: a policy that permits one family and gates on the others is a coherent stance, and a cross-family pattern makes it inexpressible – self_check() would see a family caught in part and refuse to run.

The lookahead on \bGPL spares the SPDX WITH <exception> spellings (GPL-2.0-only WITH Classpath-exception-2.0, GPLv2 with linking exception) and nothing subtler; anything subtler belongs in exceptions, with a written reason, rather than in a regex nobody will re-derive.

wads.licence_check.DFLT_FORBIDDEN: tuple[str, ...] = ('\\bAGPL', '\\bAffero\\b', '\\bGPL(?![\\w.+-]*\\s+with\\b)', '\\bGNU General Public\\b', '\\bLGPL', '\\bLesser General Public\\b', '\\bLibrary General Public\\b', '\\bNethack General Public\\b', '\\bEUPL\\b', '\\bBusiness Source\\b', '\\bBUSL\\b', '\\bSSPL\\b', '\\bElastic[- ]?(2\\.0|License|v2)\\b', '(?:\\b|-)(?:open)?rail(?:-m)?\\b', '\\bCC[- ]BY[- ]NC\\b', '\\bNon[- ]?Commercial\\b', '\\bProprietary\\b')

What a policy forbids unless it says otherwise.

wads.licence_check.DFLT_NON_COMMERCIAL: tuple[str, ...] = ('\\bBusiness Source\\b', '\\bBUSL\\b', '\\bSSPL\\b', '\\bElastic[- ]?(2\\.0|License|v2)\\b', '(?:\\b|-)(?:open)?rail(?:-m)?\\b', '\\bCC[- ]BY[- ]NC\\b', '\\bNon[- ]?Commercial\\b', '\\bProprietary\\b')

Non-commercial / source-available families. These are not copyleft, but they restrict redistribution or hosting, and a classifier-only gate cannot see them: Arize Phoenix ships Elastic-2.0 in its License field with no trove classifier at all.

class wads.licence_check.Declaration(text, source)[source]

Bases: NamedTuple

What a distribution declares, and which field said so.

Carrying the source is not decoration: it is the difference between “argh is LGPL” and “argh is LGPL according to its trove classifier”, and only the second is a claim a reader can go and check.

property is_blank: bool

True when the distribution declared nothing usable.

source: str

Alias for field number 1

text: str

Alias for field number 0

exception wads.licence_check.DetectorError[source]

Bases: RuntimeError

The live policy cannot detect anything, so its green result means nothing.

wads.licence_check.EXCEPTION_TABLE_NAME_KEY: str = 'dependency'

Keys read off an [[tool.wads.licence.exceptions]] array-of-tables entry. dependency and reason are required; the rest are adjudication provenance and are folded into the rendered reason so a reader sees when the call was made and where to go and argue with it.

wads.licence_check.EXIT_BREACH = 1

Exit codes, so the CI step and a human read the same numbers.

wads.licence_check.EXIT_ERROR = 2

Exit codes, so the CI step and a human read the same numbers.

wads.licence_check.EXIT_OK = 0

Exit codes, so the CI step and a human read the same numbers.

wads.licence_check.LADDER: tuple[str, ...] = ('License-Expression', 'Classifier', 'License')

The metadata fields the ladder reads, in decreasing order of precision.

wads.licence_check.LEGACY_POLICY_KEYS: Mapping[str, str] = mappingproxy({'allow': 'rename it to `allowed` (it pairs with `forbidden`, where `allow` had no counterpart)', 'deny': 'rename it to `forbidden`', 'project_licence': "drop it -- the project's own licence is `[project].license`, and restating it here is a second source of truth that nothing reads", 'project-licence': "drop it -- the project's own licence is `[project].license`, and restating it here is a second source of truth that nothing reads"})

Keys from the earlier, hand-written table shape that shipped in a couple of repos before this module existed, mapped to what to do instead.

Two spellings of the same table is worse than either one: a repo written against the other shape reads as configured and is not. These are rejected rather than silently aliased – but rejected with the migration, not with a bare “unknown key”, because the person hitting this did not choose it.

class wads.licence_check.LicencePolicy(allowed=('\\\\bMIT\\\\b', '\\\\bBSD\\\\b', '\\\\b0BSD\\\\b', '\\\\bApache[- ]?2', '\\\\bApache Software License\\\\b', '\\\\bISC\\\\b', '\\\\bPython Software Foundation\\\\b', '\\\\bPSF\\\\b', '\\\\bHPND\\\\b', '\\\\bUnlicense\\\\b', '\\\\bCC0\\\\b', '\\\\bZlib\\\\b', '\\\\bBoost Software License\\\\b', '\\\\bBSL[- ]?1\\\\.0\\\\b'), forbidden=('\\\\bAGPL', '\\\\bAffero\\\\b', '\\\\bGPL(?![\\\\w.+-]*\\\\s+with\\\\b)', '\\\\bGNU General Public\\\\b', '\\\\bLGPL', '\\\\bLesser General Public\\\\b', '\\\\bLibrary General Public\\\\b', '\\\\bNethack General Public\\\\b', '\\\\bEUPL\\\\b', '\\\\bBusiness Source\\\\b', '\\\\bBUSL\\\\b', '\\\\bSSPL\\\\b', '\\\\bElastic[- ]?(2\\\\.0|License|v2)\\\\b', '(?: \\\\b|-)(?:open)?rail(?:-m)?\\\\b', '\\\\bCC[- ]BY[- ]NC\\\\b', '\\\\bNon[- ]?Commercial\\\\b', '\\\\bProprietary\\\\b'), exceptions=<factory>, include_extras=(), unknown_is_failure=True, unclassified_is_failure=False)[source]

Bases: object

The rule set a perimeter check is run against.

exceptions maps a distribution name to the WRITTEN reason it is tolerated. Listing beats silence: a name here is a decision somebody made and can be asked about, where a name quietly missing from forbidden is not.

exception_for(name, /)[source]

The recorded reason name is tolerated, or ''.

Matched on the PEP 503 normalised name, so a policy written with typing_extensions still clears the distribution installed metadata calls typing-extensions. Getting this wrong is invisible: the exception simply never applies and the report keeps flagging a name somebody believes they already adjudicated.

Return type:

str

>>> LicencePolicy(exceptions={'Ruamel.YAML': 'audited'}).exception_for(
...     'ruamel-yaml')
'audited'
classmethod from_mapping(config, /)[source]

Build a policy from a [tool.wads.licence] table.

Accepts both TOML-style include-extras and Python-style include_extras. An unrecognised key is an error rather than a silently ignored typo – a misspelt forbiden would leave the perimeter on its defaults while reading as configured.

Value TYPES are checked too, and for the same reason the key names are: allowed = "MIT" used to become the three regexes M, I, T without a word, and an exceptions array-of-tables used to collapse into {'dependency': 'reason'}. Both read as configured. Neither was.

Return type:

LicencePolicy

>>> policy = LicencePolicy.from_mapping(
...     {'allowed': ['MIT'], 'include-extras': ['*'], 'unknown-is-failure': False}
... )
>>> policy.allowed, policy.include_extras, policy.unknown_is_failure
(('MIT',), ('*',), False)
>>> policy.forbidden == DFLT_FORBIDDEN  # untouched keys keep their defaults
True

exceptions takes either the terse map or the array-of-tables that carries an adjudication record (see _exceptions_mapping()):

>>> LicencePolicy.from_mapping({'exceptions': [
...     {'dependency': 'PyGithub', 'reason': 'Accepted.', 'decided': '2026-08-30'}
... ]}).exception_for('pygithub')
'Accepted. [decided: 2026-08-30]'
>>> try:
...     LicencePolicy.from_mapping({'forbiden': ['GPL']})
... except ValueError as error:
...     print(error)
unknown [tool.wads.licence] key 'forbiden'; known keys are: allowed, enabled,
exceptions, forbidden, include-extras, unclassified-is-failure, unknown-is-failure

A key from the earlier hand-written table shape is rejected with the migration rather than with a bare “unknown key”:

>>> try:
...     LicencePolicy.from_mapping({'allow': ['MIT']})
... except ValueError as error:
...     print(error)
[tool.wads.licence] key 'allow' is from the earlier table shape: rename it to
`allowed` (it pairs with `forbidden`, where `allow` had no counterpart)
classmethod from_pyproject(pkg_dir='.', /, *, pyproject=None)[source]

The policy declared in [tool.wads.licence], or the defaults.

Return type:

LicencePolicy

matched_allowed(declaration, /)[source]

The first permissive pattern declaration matches, or ''.

Return type:

str

matched_forbidden(declaration, /)[source]

The first forbidden pattern declaration matches, or ''.

Return type:

str

permissive_option(declaration, /)[source]

A disjunct of declaration that is permissive and not forbidden.

SPDX’s OR is a genuine choice offered to the recipient, so a declaration is only as restrictive as its most permissive option. Reading the whole string as one blob gets that backwards – marisa-trie declares MIT AND (BSD-2-Clause OR LGPL-2.1-or-later) and was failed on the option nobody has to take:

Return type:

str

>>> LicencePolicy().permissive_option('MIT AND (BSD-2-Clause OR LGPL-2.1-or-later)')
'MIT AND (BSD-2-Clause'

When every option is restricted there is nothing to pick, and the declaration stands as it is:

>>> LicencePolicy().permissive_option('LGPL-2.1-only OR MPL-1.1')
''
class wads.licence_check.LicenceReport(policy, declared, verdicts, canaries=(), uncovered_families=(), pkg_dir='.')[source]

Bases: object

What a run found: the closure it walked, and a verdict for every member.

as_dict()[source]

A JSON-serialisable view, for fleet-wide sweeps.

Return type:

dict

property closure: tuple[str, ...]

Every distribution the walk reached.

property failures: tuple[Verdict, ...]

The verdicts that make this run fail, under this policy.

of_status(*statuses)[source]

Every verdict whose status is one of statuses.

Return type:

tuple[Verdict, ...]

property ok: bool

Whether the perimeter holds.

render()[source]

A human-readable report, failures first.

Return type:

str

property stale_exceptions: tuple[str, ...]

Recorded exceptions for distributions the walk never reached.

An exception for a distribution that has left the tree is stale advice, and one whose licence has since changed is worse: it reads as adjudicated when nobody has looked at the current terms. Reported, not failed – an extra is often the reason, and breaking a build over a tidiness issue is how gates get switched off.

class wads.licence_check.MetadataReaders(read_metadata, read_requires)[source]

Bases: NamedTuple

The pair of readers check() walks the closure with.

read_metadata: Callable[[str], Any]

Alias for field number 0

read_requires: Callable[[str], list | None]

Alias for field number 1

wads.licence_check.PERMISSIVE_CANARIES: tuple[str, ...] = ('MIT', 'BSD-3-Clause', '0BSD', 'Apache-2.0', 'Apache Software License', 'ISC', 'BSL-1.0', 'License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)')

Real declarations that any usable policy must still clear. A policy that flags these is not strict, it is broken, and it would fail every repo. BSL-1.0 is the Boost Software License, permissive and OSI-approved – it is here because a \bBSL\b pattern aimed at the Business Source License (BUSL-1.1) flags it, and that pattern shipped once already.

wads.licence_check.POLICY_TOML_KEYS: tuple[str, ...] = ('enabled', 'allowed', 'forbidden', 'exceptions', 'include-extras', 'unknown-is-failure', 'unclassified-is-failure')

TOML keys accepted in [tool.wads.licence]. enabled is read by wads.ci_config for the CI gate rather than by the policy itself.

wads.licence_check.POLICY_TOML_PATH: tuple[str, ...] = ('tool', 'wads', 'licence')

Where the policy lives in pyproject.toml.

class wads.licence_check.SelfCheck(caught, uncovered_families)[source]

Bases: NamedTuple

What a policy proved about itself before it was allowed to report.

caught: tuple[str, ...]

Alias for field number 0

uncovered_families: tuple[str, ...]

Alias for field number 1

class wads.licence_check.Status[source]

Bases: object

The verdicts a distribution can receive. Not an Enum: these are printed.

wads.licence_check.THIS_ENVIRONMENT = (<function metadata>, <function requires>)

Readers bound to the interpreter running this module.

wads.licence_check.UNKNOWN_DECLARATIONS: frozenset[str] = frozenset({'', 'NOASSERTION', 'NONE', 'UNKNOWN'})

the terms may live in a repo file no scanner reads, which is exactly where LGPL and non-commercial model weights have been found hiding.

Type:

Declarations that say nothing. Blank is not “fine”, it is unaudited

class wads.licence_check.Verdict(name, status, declaration=('', ''), note='')[source]

Bases: object

One distribution’s standing against the policy.

property is_clean: bool

Whether this verdict is one that never fails a run.

wads.licence_check.check(pkg_dir='.', /, *, policy=None, readers=(<function metadata>, <function requires>))[source]

Audit a project’s installed dependency closure against its policy.

policy=None loads [tool.wads.licence] from the project’s pyproject.toml, falling back to the module defaults.

Raises DetectorError before reading anything if the live policy could not detect every known-copyleft canary declaration, if the project does not declare its dependencies statically, and again if not one declared dependency is installed – all states in which a green result would be a lie rather than a finding.

A dependency that IS declared and is NOT installed here is a failure, not a footnote: it is a piece of the perimeter nobody looked at, and the previous all-or-nothing refusal let one installed survivor turn three unread copyleft dependencies into a green. The one honest exception is a requirement gated on an environment marker (tomli; python_version < "3.11"), which gets NOT_APPLICABLE and is reported without failing.

Return type:

LicenceReport

wads.licence_check.closure(names, /, *, read_requires=<function requires>, include_extras=())[source]

names PLUS everything they pull in, as INSTALLED in this environment.

Checking only the declared names is a smaller claim than it reads as: pip install <pkg> installs their transitive closure, and a copyleft distribution three levels down is exactly as much a part of what a downstream consumer inherits.

By default the walk skips requirements gated on extra == "...", because a bare install does not take them. include_extras=('*',) takes all of them; a tuple of names takes those.

Return type:

tuple[str, ...]

>>> tree = {
...     'citeget': ['html2text', 'requests', 'pytest; extra == "test"'],
...     'html2text': [],
...     'requests': ['certifi'],
...     'certifi': [],
... }
>>> closure(['citeget'], read_requires=tree.get)
('certifi', 'citeget', 'html2text', 'requests')
>>> closure(['citeget'], read_requires=tree.get, include_extras=('test',))
('certifi', 'citeget', 'html2text', 'pytest', 'requests')

An uninstalled name simply ends that branch – it is still reported by check(), but it cannot contribute requirements it does not have:

>>> closure(['citeget', 'nowhere'], read_requires=tree.get)
('certifi', 'citeget', 'html2text', 'nowhere', 'requests')

walk_closure() is the same walk, and additionally says which members are reachable only through an environment-marker-gated requirement.

wads.licence_check.declare(dist_name, /, *, read_metadata=<function metadata>)[source]

The DECLARATION, in order of precision – never the licence document.

read_metadata is the seam onto the metadata source; it must return something with .get / .get_all, which is what both importlib.metadata.metadata() and a parsed METADATA email message give you.

Return type:

Declaration

>>> from email import message_from_string
>>> def fake(name, records=None):
...     return message_from_string(records[name])
>>> import functools
>>> records = {
...     'click': 'License-Expression: BSD-3-Clause\n',
...     'argh': ('Classifier: License :: OSI Approved :: '
...              'GNU Library or Lesser General Public License (LGPL)\n'),
...     'i2': 'License: Apache Software License\n',
... }
>>> read = functools.partial(fake, records=records)
>>> declare('click', read_metadata=read)
Declaration(text='BSD-3-Clause', source='License-Expression')
>>> declare('i2', read_metadata=read)
Declaration(text='Apache Software License', source='License')
>>> declare('argh', read_metadata=read).source
'Classifier'

The free-text field is read ONE LINE deep. numpy’s field is the whole 48k-character licence document, and it contains an LGPL URL for a vendored component’s notice – substring-scanning it reports BSD-3-Clause numpy as copyleft:

>>> numpy_field = 'Copyright (c) 2005-2024, NumPy Developers.\n  http://x/lgpl'
>>> read_numpy = functools.partial(fake, records={'numpy': f'License: {numpy_field}'})
>>> declare('numpy', read_metadata=read_numpy).text
'Copyright (c) 2005-2024, NumPy Developers.'
wads.licence_check.declared_dependencies(pkg_dir='.', /, *, include_extras=(), pyproject=None)[source]

Every distribution the project DECLARES, read from pyproject.toml.

Derived, never restated: a dependency added to pyproject.toml and forgotten in a hand-written list is a dependency the perimeter never looks at, and the perimeter reads as green either way.

declared_requirements() without the version specifiers and markers.

Return type:

tuple[str, ...]

>>> declared_dependencies(pyproject={'project': {
...     'dependencies': ['html2text', 'requests>=2'],
...     'optional-dependencies': {'test': ['pytest']},
... }})
('html2text', 'requests')
>>> declared_dependencies(include_extras=('test',), pyproject={'project': {
...     'dependencies': ['html2text'],
...     'optional-dependencies': {'test': ['pytest']},
... }})
('html2text', 'pytest')
wads.licence_check.declared_licence(dist_name, /, *, read_metadata=<function metadata>)[source]

The declared licence text alone – declare() without the source.

Return type:

str

wads.licence_check.declared_requirements(pkg_dir='.', /, *, include_extras=(), pyproject=None)[source]

Every REQUIREMENT STRING the project declares, markers and all.

Requirement strings rather than bare names, because the marker is the information: tomli; python_version < "3.11" being absent from a 3.12 environment is expected, and html2text being absent is the check not having looked.

Parsed with a real TOML parser rather than scanned as text. That is not a style preference: a text scan that ends the dependencies array at the first ] stops at the first requirement carrying an extra ("uvicorn[standard]") and silently drops every name below it – one line disarming the whole check. A parser cannot have that bug.

An ABSENT dependencies key, or one listed in dynamic, is refused rather than read as zero. dependencies = [] is a project saying it has none; a missing key is a project whose requirements live somewhere this function cannot see (setup.py, requirements.txt), and reporting “declared: 0 … PERIMETER HOLDS” over it is the confident-green failure this module exists to stop.

Return type:

tuple[str, ...]

>>> declared_requirements(pyproject={'project': {
...     'dependencies': ['html2text', 'requests>=2'],
...     'optional-dependencies': {'test': ['pytest']},
... }})
('html2text', 'requests>=2')
>>> try:
...     declared_requirements(pyproject={'project': {
...         'name': 'x', 'dynamic': ['dependencies']}})
... except DetectorError as error:
...     print(str(error)[:58])
this project lists `dependencies` in [project].dynamic, so
wads.licence_check.has_environment_marker(requirement, /)[source]

Whether a requirement is gated on an ENVIRONMENT marker, not just an extra.

This is the one honest reason a declared dependency can be missing from the environment being read. The gate runs on one interpreter, on one OS, so a dependency gated on python_version < "3.11" or sys_platform == "win32" is structurally invisible – and an absent-but-required distribution is the same confident-green failure as reading the wrong environment altogether. Telling the two apart is what lets one be a note and the other a failure.

Return type:

bool

>>> has_environment_marker('tomli>=1.0.0; python_version < "3.11"')
True
>>> has_environment_marker('pytest; extra == "test"')
False
>>> has_environment_marker('requests>=2')
False
wads.licence_check.main(argv=None)[source]

Console-script entry point. Returns the process exit code.

Return type:

int

wads.licence_check.normalise(name)[source]

PEP 503 normalisation, so ruamel.yaml and ruamel-yaml are one name.

Return type:

str

>>> normalise('Ruamel.YAML')
'ruamel-yaml'
wads.licence_check.read_pyproject(pkg_dir='.')[source]

Parse pyproject.toml from a directory (or a direct path to the file).

Return type:

dict

wads.licence_check.readers_for(search_path, /)[source]

Metadata readers that see the distributions installed on search_path.

Return type:

MetadataReaders

>>> import sys
>>> readers = readers_for(sys.path)
>>> readers.read_metadata('wads')['Name']
'wads'
wads.licence_check.requirement_extra(requirement)[source]

The extra a requirement is conditional on, or '' if it is a hard one.

Return type:

str

>>> requirement_extra('anthropic; extra == "vision"')
'vision'
>>> requirement_extra('pydantic>=2')
''
wads.licence_check.requirement_name(requirement)[source]

The distribution name out of a Requires-Dist / requirement string.

Return type:

str

>>> requirement_name('uvicorn[standard]>=0.20; python_version >= "3.9"')
'uvicorn'
wads.licence_check.search_path_of(python_executable, /)[source]

The sys.path of ANOTHER interpreter, so its packages can be read.

This is what lets the CI gate run the tool from an isolated uvx environment while auditing the project’s own .venv. Without it the tool would read its own dependencies and report a confident, wrong green.

Return type:

tuple[str, ...]

wads.licence_check.self_check(policy, /)[source]

Prove the policy still detects, and still discriminates. Or refuse to run.

Three failure modes, all observed. A policy whose forbidden list has been emptied reports every repo clean and nobody notices, because every other assertion in a licence check only ever says that nothing matched. A policy so broad it flags MIT fails every repo, which gets the gate switched off within the day. And – the one that actually shipped – a policy that catches some spellings of a family while missing the commonest ones reads as a pass on the strength of the spellings it does catch.

So the bar is per FAMILY, caught whole or not at all. Permitting a family outright is a stance somebody can take on purpose and defend; catching four of a family’s nine spellings is never a stance, it is the bug. A family nobody covers is reported rather than assumed – see LicenceReport.uncovered_families, which puts it in every run’s output so “we do not gate on GPL here” is a visible fact rather than an inference.

Return type:

SelfCheck

>>> checked = self_check(LicencePolicy())
>>> len(checked.caught) == len(COPYLEFT_CANARIES), checked.uncovered_families
(True, ())

Dropping a whole family is allowed, and is then stated out loud:

>>> lgpl_ok = LicencePolicy(forbidden=tuple(
...     p for p in DFLT_FORBIDDEN if 'LGPL' not in p and 'Lesser' not in p))
>>> self_check(lgpl_ok).uncovered_families
('LGPL',)

Catching only PART of a family is not. This is the shape of the real bug: \bLGPL\b catches the legacy classifier and misses LGPLv3:

>>> try:
...     self_check(LicencePolicy(forbidden=(r'\bAGPL', r'\bGNU Affero\b',
...                                        r'\bLGPL\b')))
... except DetectorError as error:
...     print('LGPLv3 classifier' in str(error))
True

A policy that detects nothing refuses to report:

>>> try:
...     self_check(LicencePolicy(forbidden=()))
... except DetectorError as error:
...     print(str(error)[:52])
this policy's `forbidden` patterns catch none of the

And so does one broad enough to flag everything:

>>> try:
...     self_check(LicencePolicy(forbidden=(r'\bAGPL', r'\bApache\b')))
... except DetectorError as error:
...     print('over-broad, and it names what it wrongly flagged:',
...           'Apache-2.0' in str(error))
over-broad, and it names what it wrongly flagged: True
wads.licence_check.verdict(name, /, *, policy, read_metadata=<function metadata>, conditional=False)[source]

Judge one distribution against policy.

An adjudicated exception wins over everything, including a forbidden match: that is what recording one means, and the reason travels with it.

Return type:

Verdict

>>> from email import message_from_string
>>> import functools
>>> records = {'html2text': 'License-Expression: GPL-3.0-or-later\n',
...            'requests': 'License-Expression: Apache-2.0\n',
...            'certifi': 'License-Expression: MPL-2.0\n',
...            'mystery': 'Name: mystery\n'}
>>> read = functools.partial(lambda n, r: message_from_string(r[n]), r=records)
>>> verdict('html2text', policy=LicencePolicy(), read_metadata=read).status
'forbidden'
>>> verdict('requests', policy=LicencePolicy(), read_metadata=read).status
'allowed'

MPL-2.0 is deliberately neither: it is file-level weak copyleft, so it is not forbidden, and it matches no permissive family either. It surfaces as unclassified – a decision to make, not a build to break.

>>> verdict('certifi', policy=LicencePolicy(), read_metadata=read).status
'unclassified'
>>> verdict('mystery', policy=LicencePolicy(), read_metadata=read).status
'unknown'

A declaration matching a forbidden AND a permissive pattern is not simply forbidden. Where SPDX’s OR says the recipient picks, the permissive pick is taken; where the two spellings merely sit side by side – which is what joining a distribution’s License :: classifiers produces – nobody knows which applies to which file, so it becomes a question rather than a failure:

>>> both = {'marisa-trie': ('License-Expression: '
...                         'MIT AND (BSD-2-Clause OR LGPL-2.1-or-later)\n'),
...         'docutils': ('Classifier: License :: Public Domain\n'
...                      'Classifier: License :: OSI Approved :: BSD License\n'
...                      'Classifier: License :: OSI Approved :: '
...                      'GNU General Public License (GPL)\n')}
>>> read_both = functools.partial(lambda n, r: message_from_string(r[n]), r=both)
>>> verdict('marisa-trie', policy=LicencePolicy(), read_metadata=read_both).status
'allowed'
>>> verdict('docutils', policy=LicencePolicy(), read_metadata=read_both).status
'unclassified'
wads.licence_check.walk_closure(requirements, /, *, read_requires=<function requires>, include_extras=())[source]

The closure of requirements (raw requirement strings), marked up.

Return type:

ClosureWalk

>>> tree = {'app': ['requests', 'tomli; python_version < "3.11"'],
...         'requests': ['certifi'], 'certifi': [], 'tomli': []}
>>> walk = walk_closure(['app'], read_requires=tree.get)
>>> walk.names
('app', 'certifi', 'requests', 'tomli')
>>> sorted(walk.conditional)
['tomli']