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
Permissive families, matched case-insensitively against the declaration. |
|
Reciprocal (copyleft) families. |
|
Non-commercial / source-available families. |
|
What a policy forbids unless it says otherwise. |
|
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. |
|
The metadata fields the ladder reads, in decreasing order of precision. |
|
Real declarations, copied from installed |
|
The families |
|
Real declarations that any usable policy must still clear. |
|
|
|
Where the policy lives in |
|
Statuses that never fail a run. |
|
|
|
TOML keys accepted in |
|
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. |
|
Keys read off an |
|
Readers bound to the interpreter running this module. |
|
Exit codes, so the CI step and a human read the same numbers. |
|
Exit codes, so the CI step and a human read the same numbers. |
|
Exit codes, so the CI step and a human read the same numbers. |
Functions
|
Audit a project's installed dependency closure against its policy. |
|
|
|
The DECLARATION, in order of precision -- never the licence document. |
|
Every distribution the project DECLARES, read from |
|
The declared licence text alone -- |
|
Every REQUIREMENT STRING the project declares, markers and all. |
|
Whether a requirement is gated on an ENVIRONMENT marker, not just an extra. |
|
Console-script entry point. |
|
PEP 503 normalisation, so |
|
Parse |
|
Metadata readers that see the distributions installed on |
|
The extra a requirement is conditional on, or |
|
The distribution name out of a |
|
The |
|
Prove the policy still detects, and still discriminates. |
|
Judge one distribution against |
|
The closure of |
Classes
|
One real licence declaration a policy is measured against. |
|
The closure, plus which of its members are reachable only conditionally. |
|
What a distribution declares, and which field said so. |
|
The rule set a perimeter check is run against. |
|
What a run found: the closure it walked, and a verdict for every member. |
|
The pair of readers |
|
What a policy proved about itself before it was allowed to report. |
|
The verdicts a distribution can receive. |
|
One distribution's standing against the policy. |
Exceptions
The live policy cannot detect anything, so its green result means nothing. |
- wads.licence_check.ALL_EXTRAS: str = '*'¶
include_extrasvalue meaning “every optional-dependency group”.
- wads.licence_check.CANARY_FAMILIES: tuple[str, ...] = ('LGPL', 'GPL', 'AGPL', 'Nethack')¶
The families
COPYLEFT_CANARIEScovers, in declaration order.
- wads.licence_check.CLEAN_STATUSES: frozenset[str] = frozenset({'allowed', 'excepted', 'not-applicable'})¶
Statuses that never fail a run.
NOT_INSTALLEDis 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 getsNOT_APPLICABLEinstead, 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-infoand from the official trove classifier list, that a policy is measured against. Seeself_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:
NamedTupleOne real licence declaration a policy is measured against.
familyis 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.
- class wads.licence_check.ClosureWalk(names, conditional)[source]¶
Bases:
NamedTupleThe closure, plus which of its members are reachable only conditionally.
conditionalis 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.
- wads.licence_check.DEPENDENCIES_KEY: str = 'dependencies'¶
[project]key holding the hard requirements. Named because two different absences of it mean two different things – seedeclared_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\bis its own entry because\bBSD\bcannot reach inside0BSD– 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.
\bGPLcannot reach insideLGPL(there is no word boundary betweenLandG), so LGPL and AGPL need their own entries – but a TRAILING\bon those entries is a hole, not a safeguard: it cannot matchLGPLv3,LGPLv2+orAGPLv3, 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_CANARIESnow 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
\bGPLspares the SPDXWITH <exception>spellings (GPL-2.0-only WITH Classpath-exception-2.0,GPLv2 with linking exception) and nothing subtler; anything subtler belongs inexceptions, 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
Licensefield with no trove classifier at all.
- class wads.licence_check.Declaration(text, source)[source]¶
Bases:
NamedTupleWhat 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.
- exception wads.licence_check.DetectorError[source]¶
Bases:
RuntimeErrorThe 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.dependencyandreasonare 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:
objectThe rule set a perimeter check is run against.
exceptionsmaps 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 fromforbiddenis not.- exception_for(name, /)[source]¶
The recorded reason
nameis tolerated, or''.Matched on the PEP 503 normalised name, so a policy written with
typing_extensionsstill clears the distribution installed metadata callstyping-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:
>>> 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-extrasand Python-styleinclude_extras. An unrecognised key is an error rather than a silently ignored typo – a misspeltforbidenwould 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 regexesM,I,Twithout a word, and anexceptionsarray-of-tables used to collapse into{'dependency': 'reason'}. Both read as configured. Neither was.- Return type:
>>> 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
exceptionstakes 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:
- matched_allowed(declaration, /)[source]¶
The first permissive pattern
declarationmatches, or''.- Return type:
- matched_forbidden(declaration, /)[source]¶
The first forbidden pattern
declarationmatches, or''.- Return type:
- permissive_option(declaration, /)[source]¶
A disjunct of
declarationthat is permissive and not forbidden.SPDX’s
ORis 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-triedeclaresMIT AND (BSD-2-Clause OR LGPL-2.1-or-later)and was failed on the option nobody has to take:- Return type:
>>> 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:
objectWhat a run found: the closure it walked, and a verdict for every member.
- 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:
NamedTupleThe pair of readers
check()walks the closure with.
- 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.0is the Boost Software License, permissive and OSI-approved – it is here because a\bBSL\bpattern 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].enabledis read bywads.ci_configfor 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:
NamedTupleWhat a policy proved about itself before it was allowed to report.
- class wads.licence_check.Status[source]¶
Bases:
objectThe 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:
objectOne distribution’s standing against the policy.
- 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=Noneloads[tool.wads.licence]from the project’spyproject.toml, falling back to the module defaults.Raises
DetectorErrorbefore 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 getsNOT_APPLICABLEand is reported without failing.- Return type:
- wads.licence_check.closure(names, /, *, read_requires=<function requires>, include_extras=())[source]¶
namesPLUS 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.>>> 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_metadatais the seam onto the metadata source; it must return something with.get/.get_all, which is what bothimportlib.metadata.metadata()and a parsedMETADATAemail message give you.- Return type:
>>> 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.tomland 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.>>> 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:
- 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, andhtml2textbeing 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
dependenciesarray 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
dependencieskey, or one listed indynamic, 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.>>> 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"orsys_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:
>>> 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:
- wads.licence_check.normalise(name)[source]¶
PEP 503 normalisation, so
ruamel.yamlandruamel-yamlare one name.- Return type:
>>> normalise('Ruamel.YAML') 'ruamel-yaml'
- wads.licence_check.read_pyproject(pkg_dir='.')[source]¶
Parse
pyproject.tomlfrom a directory (or a direct path to the file).- Return type:
- wads.licence_check.readers_for(search_path, /)[source]¶
Metadata readers that see the distributions installed on
search_path.- Return type:
>>> 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:
>>> 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:
>>> requirement_name('uvicorn[standard]>=0.20; python_version >= "3.9"') 'uvicorn'
- wads.licence_check.search_path_of(python_executable, /)[source]¶
The
sys.pathof ANOTHER interpreter, so its packages can be read.This is what lets the CI gate run the tool from an isolated
uvxenvironment while auditing the project’s own.venv. Without it the tool would read its own dependencies and report a confident, wrong green.
- 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
forbiddenlist 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 flagsMITfails 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:
>>> 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\bcatches the legacy classifier and missesLGPLv3:>>> 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:
>>> 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
ORsays the recipient picks, the permissive pick is taken; where the two spellings merely sit side by side – which is what joining a distribution’sLicense ::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:
>>> 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']