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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ uv run licensecheck --only-licenses mit apache --show-only-failing -g dev
usage: licensecheck [-h] [--license LICENSE] [--format FORMAT] [--requirements-paths REQUIREMENTS_PATHS [REQUIREMENTS_PATHS ...]]
[--groups GROUPS [GROUPS ...]] [--extras EXTRAS [EXTRAS ...]] [--file FILE]
[--ignore-packages IGNORE_PACKAGES [IGNORE_PACKAGES ...]] [--fail-packages FAIL_PACKAGES [FAIL_PACKAGES ...]]
[--ignore-licenses IGNORE_LICENSES [IGNORE_LICENSES ...]] [--fail-licenses FAIL_LICENSES [FAIL_LICENSES ...]]
[--ignore-licenses IGNORE_LICENSES [IGNORE_LICENSES ...]]
[--allowed-license-references ALLOWED_LICENSE_REFERENCES [ALLOWED_LICENSE_REFERENCES ...]]
[--fail-licenses FAIL_LICENSES [FAIL_LICENSES ...]]
[--only-licenses ONLY_LICENSES [ONLY_LICENSES ...]]
[--skip-dependencies SKIP_DEPENDENCIES [SKIP_DEPENDENCIES ...]]
[--hide-output-parameters HIDE_OUTPUT_PARAMETERS [HIDE_OUTPUT_PARAMETERS ...]] [--show-only-failing]
Expand All @@ -169,11 +171,13 @@ options:
Select extras from supported files
--file FILE, -o FILE Filename to write output to (omit this for stdout)
--ignore-packages IGNORE_PACKAGES [IGNORE_PACKAGES ...]
List of packages/dependencies to ignore (compat=True), globs are supported
List of packages/dependencies to ignore (compat=True); names, name==version, and globs are supported
--fail-packages FAIL_PACKAGES [FAIL_PACKAGES ...]
List of packages/dependencies to fail (compat=False), globs are supported
List of packages/dependencies to fail (compat=False); names, name==version, and globs are supported
--ignore-licenses IGNORE_LICENSES [IGNORE_LICENSES ...]
List of licenses to ignore (skipped, compat may still be False)
--allowed-license-references ALLOWED_LICENSE_REFERENCES [ALLOWED_LICENSE_REFERENCES ...]
List of exact LicenseRef-* identifiers to accept
--fail-licenses FAIL_LICENSES [FAIL_LICENSES ...]
List of licenses to fail (compat=False)
--only-licenses ONLY_LICENSES [ONLY_LICENSES ...]
Expand Down Expand Up @@ -210,9 +214,11 @@ requirements_paths = [] # List of filenames to read from
groups = [] # List of selected groups
extras = [] # List of selected extras
file = "" # Output file (leave empty for stdout)
ignore_packages = [] # Packages/dependencies to ignore
fail_packages = [] # Packages/dependencies that cause failure
ignore_packages = [] # Names, name==version entries, or globs to ignore
license_overrides = { "sample==1.2.3" = "BSD-3-Clause" } # Reviewed licenses for exact versions
fail_packages = [] # Names, name==version entries, or globs that fail
ignore_licenses = [] # Licenses to ignore
allowed_license_references = [] # Exact LicenseRef identifiers to accept
fail_licenses = [] # Licenses that cause failure
only_licenses = [] # Allowed licenses (all others will fail)
skip_dependencies = [] # Dependencies to skip (compatibility = True)
Expand All @@ -237,7 +243,9 @@ zero = false # Return non-zero exit code for incompatible licen
"groups": [],
"hide_output_parameters": [],
"ignore_licenses": [],
"allowed_license_references": [],
"ignore_packages": [],
"license_overrides": {"sample==1.2.3": "BSD-3-Clause"},
"license": "mit",
"only_licenses": [],
"pypi_api": "https://pypi.org",
Expand Down
27 changes: 15 additions & 12 deletions documentation/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ uv run licensecheck --only-licenses mit apache --show-only-failing -g dev
## Supported tools/ standards

Licensecheck supports a broad range of different tools and workflows. Though please note that
for some of these tools, behaviour may differ from what is expected. We use `uv` for the dependency
resolution due to the good performance across projects, with a fallback to a native parser in case of
an error, which will be logged
for some of these tools, behaviour may differ from what is expected. For a `pyproject.toml` with an
adjacent `uv.lock`, Licensecheck exports the locked dependency graph. Otherwise it uses `uv` to
resolve dependencies. Resolution errors for a `pyproject.toml` are reported directly instead of
falling back to a less accurate dependency graph. Editable local projects emitted by `uv` are
audited using the project metadata from their own `pyproject.toml` files.

Note that `uv` supports requirements.in files. If a pyproject.toml, setup.py, or setup.cfg file is
provided, `uv` will extract the requirements for the relevant project. In testing this seems to have
Expand Down Expand Up @@ -158,19 +160,20 @@ classifiers = [
Previous versions of the licensecheck tool implemented a custom resolver to discover packages.
Current versions look to move away from this for a number of reasons, such as correctness and
reducing the maintenance burden. Over time many contributors helped out with the custom
resolver which is very much appreciated. Now, we use `uv` to attempt to resolve deps before
falling back to the legacy approach, which is needed in certain cases where uv fails
resolver which is very much appreciated. Now, we use `uv` to export an adjacent lockfile or resolve
dependencies from the supplied project file. The legacy parser remains for formats that `uv pip
compile` does not accept directly.

Q: Why doesn't this use packages from my lockfile?
A: The answer to this somewhat depends on what resolver licensecheck ends up using to
find all of the packages in use by your project. Ideally, `uv` is used which has pretty
good support for pyproject.toml and some other standard requirements formats and will discover
packages. The legacy resolver is deprecated and may result in funky output in some cases
A: When a `uv.lock` is adjacent to a supplied `pyproject.toml`, Licensecheck uses `uv export
--locked` and audits those exact versions. Without an adjacent lockfile it resolves the supplied
requirements with `uv`.

Q: The license for my dep has changed in v >1.0, so I'm using v < 1.0, why doesn't licensecheck report the correct license version?
A: In some cases it will, for example if licensecheck can find the dep metadata via importlib. Otherwise we reach out to pypi.org for this metadata. There are no plans at present to resolve this

[note to me: I 'might' look at this as we should have a copy of the package version ]
A: Licensecheck requests the exact resolved version from the PyPI JSON API. If the package is not
available from public PyPI or that response lacks usable license metadata, it asks `uv` to fetch
the exact wheel from the indexes configured for the current project and reads the wheel metadata.
Index credentials and source selection remain `uv`'s responsibility.

## License lookup format

Expand Down
78 changes: 75 additions & 3 deletions licensecheck/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,96 @@

from fnmatch import fnmatch

from loguru import logger
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name

from licensecheck import license_matrix
from licensecheck.models.constants import JOINS
from licensecheck.models.license import License
from licensecheck.models.packageinfo import PackageInfo
from licensecheck.packageinforesolver import PackageInfoManager


def _package_matches(package: PackageInfo, patterns: set[str]) -> bool:
package_names = {package.name.upper()}
if package.version:
package_names.add(f"{package.name}=={package.version}".upper())
return any(
fnmatch(package_name, pattern.upper())
for package_name in package_names
for pattern in patterns
)


def _matches_allowed_license_reference(
allowed_license_references: set[str], dependency_license: str
) -> bool:
dependency_license = dependency_license.strip().casefold()
return dependency_license.startswith("licenseref-") and dependency_license in {
license_ref.strip().casefold() for license_ref in allowed_license_references
}


def _parse_license_overrides(
license_overrides: dict[str, str],
) -> list[tuple[str, SpecifierSet, str]]:
"""Parse the configured overrides once, rather than once per package."""
parsed: list[tuple[str, SpecifierSet, str]] = []
for package_requirement, license_value in license_overrides.items():
requirement = Requirement(package_requirement)
if not requirement.specifier:
# An unversioned override would silently apply to every version of the package.
logger.warning(
f"Ignoring license override '{package_requirement}': "
f"an exact name==version is required"
)
continue
parsed.append(
(canonicalize_name(requirement.name), requirement.specifier, license_value.strip())
)
return parsed


def _license_override(
package: PackageInfo, license_overrides: list[tuple[str, SpecifierSet, str]]
) -> str | None:
if package.version is None:
return None
package_name = canonicalize_name(package.name)
for name, specifier, license_value in license_overrides:
if name == package_name and specifier.contains(package.version, prereleases=True):
return license_value
return None


def check(
requirements_paths: set[str],
groups: set[str],
extras: set[str],
this_license: License,
package_info_manager: PackageInfoManager,
*,
this_license_text: str | None = None,
ignore_packages: set[str] | None = None,
license_overrides: dict[str, str] | None = None,
fail_packages: set[str] | None = None,
ignore_licenses: set[str] | None = None,
allowed_license_references: set[str] | None = None,
fail_licenses: set[str] | None = None,
only_licenses: set[str] | None = None,
skip_dependencies: set[str] | None = None,
) -> tuple[bool, set[PackageInfo]]:
# Def values
ignore_packages = ignore_packages or set()
parsed_license_overrides = _parse_license_overrides(license_overrides or {})
fail_packages = fail_packages or set()
ignore_licenses = ignore_licenses or set()
# The project's own license reference is always an accepted reference
allowed_license_references = (allowed_license_references or set()) | (
{this_license_text} if this_license_text else set()
)
fail_licenses = fail_licenses or set()
only_licenses = only_licenses or set()
skip_dependencies = skip_dependencies or set()
Expand All @@ -51,13 +117,19 @@ def check(
# Check it is compatible with packages and add a note
packages = package_info_manager.getPackages()
for package in packages:
if override := _license_override(package, parsed_license_overrides):
package.license = override
package.licenseSource = "configured override"
# Deal with --ignore-packages and --fail-packages
package.licenseCompat = False
packageName = package.name.upper()
if any(fnmatch(packageName, pattern.upper()) for pattern in ignore_packages):
if _package_matches(package, ignore_packages):
package.licenseCompat = True
elif any(fnmatch(packageName, pattern.upper()) for pattern in fail_packages):
elif _package_matches(package, fail_packages):
pass # package.licenseCompat = False
elif license_matrix.licenseType(str(package.license), ignore_licenses) & failLicensesType:
pass
elif _matches_allowed_license_reference(allowed_license_references, str(package.license)):
package.licenseCompat = True
# Else get compat with myLice
else:
package.licenseCompat = license_matrix.depCompatWMyLice(
Expand Down
18 changes: 16 additions & 2 deletions licensecheck/io/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,30 @@ def cli() -> None: # pragma: no cover
)
parser.add_argument(
"--ignore-packages",
help="set of packages/dependencies to ignore (compat=True), globs are supported",
help=(
"set of packages/dependencies to ignore (compat=True); names, "
"name==version, and globs are supported"
),
nargs="+",
)
parser.add_argument(
"--fail-packages",
help="set of packages/dependencies to fail (compat=False), globs are supported",
help=(
"set of packages/dependencies to fail (compat=False); names, "
"name==version, and globs are supported"
),
nargs="+",
)
parser.add_argument(
"--ignore-licenses",
help="set of licenses to ignore (skipped, compat may still be False)",
nargs="+",
)
parser.add_argument(
"--allowed-license-references",
help="set of exact LicenseRef-* identifiers to accept",
nargs="+",
)
parser.add_argument(
"--fail-licenses",
help="set of licenses to fail (compat=False)",
Expand Down Expand Up @@ -164,10 +175,13 @@ def main(licensecheckConf: LC_Config) -> ExitCode:
groups=licensecheckConf.groups,
extras=licensecheckConf.extras,
this_license=this_license,
this_license_text=this_license_text,
package_info_manager=package_info_manager,
ignore_packages=licensecheckConf.ignore_packages,
license_overrides=licensecheckConf.license_overrides,
fail_packages=licensecheckConf.fail_packages,
ignore_licenses=licensecheckConf.ignore_licenses,
allowed_license_references=licensecheckConf.allowed_license_references,
fail_licenses=licensecheckConf.fail_licenses,
only_licenses=licensecheckConf.only_licenses,
skip_dependencies=licensecheckConf.skip_dependencies,
Expand Down
8 changes: 7 additions & 1 deletion licensecheck/io/fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ def ansi(
table.add_column("Package", style="magenta")
if license_bool := "license" in packages[0]:
table.add_column("License(s)", style="magenta")
license_source_bool = any("licenseSource" in package for package in packages)
if license_source_bool:
table.add_column("License Source", style="magenta")
licenseCompat = (
"[red]✖[/]",
"[green]✔[/]",
Expand All @@ -173,6 +176,7 @@ def ansi(
([licenseCompat[x.get("licenseCompat", 0)]] if licensecompat_bool else [])
+ ([x.get("name")] if name_bool else [])
+ ([x.get("license")] if license_bool else [])
+ ([x.get("licenseSource", "")] if license_source_bool else [])
)
)
for x in packages
Expand Down Expand Up @@ -227,6 +231,7 @@ def markdown(
"homePage": "HomePage",
"author": "Author",
"license": "License",
"licenseSource": "License Source",
"licenseCompat": "Compatible",
"size": "Size",
}
Expand Down Expand Up @@ -295,7 +300,8 @@ def rawCsv(

_ = myLice
string = StringIO()
writer = csv.DictWriter(string, fieldnames=list(packages[0]), lineterminator="\n")
fieldnames = list(dict.fromkeys(key for package in packages for key in package))
writer = csv.DictWriter(string, fieldnames=fieldnames, lineterminator="\n")
writer.writeheader()
writer.writerows(packages)
return string.getvalue()
Expand Down
17 changes: 13 additions & 4 deletions licensecheck/license_matrix/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
with Path(THISDIR / "matrix.csv").open(mode="r", newline="", encoding="utf-8") as csv_file:
LICENSE_MATRIX: list[list[str]] = list[list[str]](csv.reader(csv_file))

# Look the matrix up by license name because the csv column/row order does not match.
LICENSE_MATRIX_ROWS: dict[str, list[str]] = {row[0]: row for row in LICENSE_MATRIX[1:]}
LICENSE_MATRIX_COLUMNS: dict[str, int] = {
name: index for index, name in enumerate(LICENSE_MATRIX[0])
}


termToLicenseData = {
"UNKNOWN": L.UNKNOWN,
Expand Down Expand Up @@ -183,6 +189,9 @@ def depCompatWMyLice(
ignoreLicenses = ignoreLicenses or set()
onlyLicenses = onlyLicenses or set()

if depLice & failLicenses:
return False

return any(
liceCompat(
myLicense,
Expand Down Expand Up @@ -218,11 +227,11 @@ def liceCompat(
return True
if len(onlyLicenses) > 0 and (lice not in onlyLicenses):
return False
licenses = list(L)
row, col = licenses.index(myLicense) + 1, licenses.index(lice) + 1
if myLicense in {L.UNKNOWN, L.NO_LICENSE} or lice in {L.UNKNOWN, L.NO_LICENSE}:
return False

try:
return LICENSE_MATRIX[row][col] == "1"
except KeyError:
return LICENSE_MATRIX_ROWS[myLicense.name][LICENSE_MATRIX_COLUMNS[lice.name]] == "1"
except (IndexError, KeyError):
logger.warning(f"Licenses {myLicense} and {lice} cannot be compared")
return False
29 changes: 29 additions & 0 deletions licensecheck/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, Literal

from depgather.models.defaultonnone import DefaultOnNoneModel
from packaging.requirements import InvalidRequirement, Requirement
from pydantic import field_validator

from licensecheck.io.fmt import FMT
Expand All @@ -23,8 +24,10 @@ class LC_Config(DefaultOnNoneModel):
groups: set[str] = field(default_factory=set)
extras: set[str] = field(default_factory=set)
ignore_packages: set[str] = field(default_factory=set)
license_overrides: dict[str, str] = field(default_factory=dict)
fail_packages: set[str] = field(default_factory=set)
ignore_licenses: set[str] = field(default_factory=set)
allowed_license_references: set[str] = field(default_factory=set)
fail_licenses: set[str] = field(default_factory=set)
only_licenses: set[str] = field(default_factory=set)
skip_dependencies: set[str] = field(default_factory=set)
Expand All @@ -36,3 +39,29 @@ def normalize_format(cls, value: Any) -> Any | Literal[FMT.simple]:
if value not in FMT:
return FMT.simple
return value

@field_validator("license_overrides")
@classmethod
def validate_license_overrides(cls, value: dict[str, str]) -> dict[str, str]:
for package, license_value in value.items():
try:
requirement = Requirement(package)
except InvalidRequirement as exc:
message = f"Invalid license override package: {package}"
raise ValueError(message) from exc

specifiers = list(requirement.specifier)
if (
requirement.url
or requirement.extras
or requirement.marker
or len(specifiers) != 1
or specifiers[0].operator != "=="
or specifiers[0].version.endswith(".*")
):
message = f"License override packages must use an exact name==version: {package}"
raise ValueError(message)
if not license_value.strip():
message = f"License override must not be empty: {package}"
raise ValueError(message)
return value
Loading