From 783d7161b90199d678b0c5a37208cd402cbb7318 Mon Sep 17 00:00:00 2001 From: vincent van beek Date: Tue, 1 Sep 2026 13:47:12 +0200 Subject: [PATCH] Add Python 3.13 support - Replace pkg_resources with importlib.metadata + packaging in liccheck/requirements.py and command_line.py. A Python 3.13 venv created the normal way has no setuptools, so pkg_resources was unimportable and `liccheck` crashed immediately after install. This removes the dependency on it entirely instead of papering over it with a setuptools pin. - Keep pip._internal for requirements.txt parsing (still resolves fine against current pip; the only pip-free alternative library is stale and has an open bug against current packaging releases). Drop the dead pip<10 fallback import branches. - Add `packaging` as a runtime dependency. - Raise python_requires to >=3.8 and update classifiers (3.8-3.13). - Add Python 3.12/3.13 to the CI matrix and tox envlist; drop the already-unsupported py35-py37 tox entries. - Authorize Apache-2.0 in liccheck.ini for packaging's own license. - Update tests: rewrite pkg_resources-based fixtures with importlib.metadata.PathDistribution, and adjust expected package counts for the new packaging dependency. Verified on real Python 3.10-3.13 interpreters, including a clean 3.13 venv with no setuptools preinstalled. --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- liccheck.ini | 1 + liccheck/command_line.py | 21 ++++++---- liccheck/requirements.py | 69 ++++++++++++++++++--------------- requirements.txt | 4 +- setup.py | 14 ++++--- tests/test_cli.py | 8 ++-- tests/test_get_packages_info.py | 11 +++--- tox.ini | 7 ++-- 10 files changed, 76 insertions(+), 63 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b5ea1c..29ebec5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: [ '3.8', '3.9', '3.10', '3.11' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] steps: - uses: actions/checkout@v4 - name: set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f396a31..b798409 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: [ '3.8', '3.9', '3.10', '3.11' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] steps: - uses: actions/checkout@v4 - name: set up Python ${{ matrix.python-version }} diff --git a/liccheck.ini b/liccheck.ini index feb9b5a..94aadab 100644 --- a/liccheck.ini +++ b/liccheck.ini @@ -8,6 +8,7 @@ authorized_licenses: simplified BSD Apache Apache 2.0 + Apache-2.0 Apache software license gnu LGPL LGPL with exceptions or zpl diff --git a/liccheck/command_line.py b/liccheck/command_line.py index 3dc2e4a..4283675 100644 --- a/liccheck/command_line.py +++ b/liccheck/command_line.py @@ -3,6 +3,7 @@ import os.path from liccheck.requirements import parse_requirements, resolve, resolve_without_deps +from packaging.requirements import Requirement from configparser import ConfigParser, NoOptionError import enum @@ -153,16 +154,21 @@ def transform(dist): licenses = list(set([strip_license(l) for l in licenses])) return { - "name": dist.project_name, + "name": dist.metadata["Name"], "version": dist.version, - "location": dist.location, - "dependencies": [dependency.project_name for dependency in dist.requires()], + "location": str(dist.locate_file("")), + "dependencies": [ + Requirement(dependency).name for dependency in dist.requires or [] + ], "licenses": licenses, } + def get_metadata_text(dist): + return dist.read_text("METADATA") or dist.read_text("PKG-INFO") + def get_license(dist): - if dist.has_metadata(dist.PKG_INFO): - metadata = dist.get_metadata(dist.PKG_INFO) + metadata = get_metadata_text(dist) + if metadata: match = regex_license.search(metadata) if match: license = match.group("license") @@ -172,9 +178,8 @@ def get_license(dist): return [] def get_licenses_from_classifiers(dist): - if dist.has_metadata(dist.PKG_INFO): - metadata = dist.get_metadata(dist.PKG_INFO) - + metadata = get_metadata_text(dist) + if metadata: # match might be found, but None if using the classifier: # License :: OSI Approved return [m for m in regex_classifier.findall(metadata) if m] diff --git a/liccheck/requirements.py b/liccheck/requirements.py index 8c8e106..f131396 100644 --- a/liccheck/requirements.py +++ b/liccheck/requirements.py @@ -1,52 +1,59 @@ -import pkg_resources +from importlib.metadata import PackageNotFoundError, distribution -try: - from pip._internal.network.session import PipSession -except ImportError: - try: - from pip._internal.download import PipSession - except ImportError: - from pip.download import PipSession - -try: - from pip._internal.req import parse_requirements as pip_parse_requirements -except ImportError: - from pip.req import parse_requirements as pip_parse_requirements +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name -try: - from pip._internal.req.constructors import install_req_from_parsed_requirement -except ImportError: - def install_req_from_parsed_requirement(r): - return r +from pip._internal.network.session import PipSession +from pip._internal.req import parse_requirements as pip_parse_requirements +from pip._internal.req.constructors import install_req_from_parsed_requirement def parse_requirements(requirement_file): requirements = [] for req in pip_parse_requirements(requirement_file, session=PipSession()): install_req = install_req_from_parsed_requirement(req) - if install_req.markers and not pkg_resources.evaluate_marker(str(install_req.markers)): + if install_req.markers and not install_req.markers.evaluate(): # req should not installed due to env markers continue elif install_req.editable: # skip editable req as they are failing in the resolve phase continue - requirements.append(pkg_resources.Requirement.parse(str(install_req.req))) + requirements.append(Requirement(str(install_req.req))) return requirements +def _find_distribution(req): + try: + dist = distribution(req.name) + except PackageNotFoundError: + return None + if req.specifier and not req.specifier.contains(dist.version, prereleases=True): + return None + return dist + + def resolve_without_deps(requirements): - working_set = pkg_resources.working_set for req in requirements: - env = pkg_resources.Environment(working_set.entries) - dist = env.best_match( - req=req, - working_set=working_set, - installer=None, - replace_conflicting=False, - ) - yield dist + yield _find_distribution(req) + + +def _resolve_one(req, seen): + dist = _find_distribution(req) + if dist is None: + return + key = canonicalize_name(dist.metadata["Name"]) + if key in seen: + return + seen.add(key) + yield dist + for dep_str in dist.requires or []: + dep_req = Requirement(dep_str) + if dep_req.marker and not dep_req.marker.evaluate(): + continue + yield from _resolve_one(dep_req, seen) def resolve(requirements): - for dist in pkg_resources.working_set.resolve(requirements): - yield dist + seen = set() + for req in requirements: + yield from _resolve_one(req, seen) diff --git a/requirements.txt b/requirements.txt index dc2f55a..f4c05d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pip>=9.0.1 -enum34;python_version<"3.4" +pip>=20.1 semantic_version toml +packaging diff --git a/setup.py b/setup.py index d82e737..9b780b3 100644 --- a/setup.py +++ b/setup.py @@ -57,10 +57,12 @@ # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8' + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13' ], # What does your project relate to? @@ -74,9 +76,9 @@ # this: # py_modules=["my_module"], - python_requires='>=3.5', + python_requires='>=3.8', - install_requires=['semantic_version>=2.7.0', 'toml'], + install_requires=['semantic_version>=2.7.0', 'toml', 'packaging'], # If there are data files included in your packages that need to be # installed, specify them here. If using Python 2.6 or less, then these diff --git a/tests/test_cli.py b/tests/test_cli.py index b00c42f..2dfe69d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,9 +51,9 @@ def test_run(capsys): expected = textwrap.dedent( '''\ gathering licenses... - 3 packages and dependencies. + 4 packages and dependencies. check authorized packages... - 3 packages. + 4 packages. ''' ) assert captured == expected @@ -67,9 +67,9 @@ def test_run_without_deps(capsys): expected = textwrap.dedent( '''\ gathering licenses... - 3 packages. + 4 packages. check authorized packages... - 3 packages. + 4 packages. ''' ) assert captured == expected diff --git a/tests/test_get_packages_info.py b/tests/test_get_packages_info.py index 5df46f7..feaa59f 100644 --- a/tests/test_get_packages_info.py +++ b/tests/test_get_packages_info.py @@ -1,6 +1,7 @@ +import importlib.metadata +import pathlib import sys -import pkg_resources import pytest from liccheck.command_line import get_packages_info @@ -24,9 +25,8 @@ def test_license_strip_with_return_carriage(tmp_path, mocker): tmpfh.write(b"Name: pip\r\n") tmpfh.write(b"Version: 23.3.1\r\n") tmpfh.write(b"Classifier: License :: OSI Approved :: MIT License\r\n") - metadata = pkg_resources.PathMetadata(tmp_path, tmp_path) resolve.return_value = [ - pkg_resources.Distribution(project_name="pip", metadata=metadata) + importlib.metadata.PathDistribution(pathlib.Path(tmp_path)) ] assert get_packages_info(req_path)[0]["licenses"] == ["MIT"] @@ -60,7 +60,7 @@ def test_editable_requirements_get_ignored(tmpfile): ('no_deps', 'expected_packages'), ( pytest.param( False, - ('liccheck', 'semantic-version', 'toml'), + ('liccheck', 'packaging', 'semantic-version', 'toml'), id='with deps' ), pytest.param(True, ('liccheck',), id='without deps'), @@ -86,8 +86,7 @@ def test_license_expression(tmp_path, mocker): tmpfh.write("Name: Twisted\n") tmpfh.write("Version: 23.8.0\n") tmpfh.write("License-Expression: MIT\n") - metadata = pkg_resources.FileMetadata(pkg_info_path) resolve.return_value = [ - pkg_resources.Distribution(project_name="Twisted", metadata=metadata) + importlib.metadata.PathDistribution(pathlib.Path(tmp_path)) ] assert get_packages_info(req_path)[0]["licenses"] == ["MIT"] diff --git a/tox.ini b/tox.ini index 17f5854..6eb230a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,15 @@ [tox] -envlist = py35, py36, py37, py38, py39, py310, py311 +envlist = py38, py39, py310, py311, py312, py313 skip_missing_interpreters = True [gh-actions] python = - 3.5: py35 - 3.6: py36 - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 + 3.13: py313 [testenv] deps =