diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index db3a915..45fa3c8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -12,8 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] - pip-version: ['', '==21.2.4'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 @@ -23,7 +22,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip${{ matrix.pip-version }} + python -m pip install --upgrade pip if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi - name: Test with pytest run: | diff --git a/piplicenses.py b/piplicenses.py index 92b4510..8e63359 100644 --- a/piplicenses.py +++ b/piplicenses.py @@ -28,41 +28,13 @@ """ import argparse import codecs -import glob -import os +import re import sys from collections import Counter -from email import message_from_string -from email.parser import FeedParser from enum import Enum, auto from functools import partial from typing import List, Optional, Sequence, Text -try: - from pip._internal.utils.misc import get_installed_distributions -except ImportError: # pragma: no cover - try: - from pip import get_installed_distributions - except ImportError: - def get_installed_distributions(): - from pip._internal.metadata import ( - get_default_environment, get_environment, - ) - from pip._internal.metadata.pkg_resources import ( - Distribution as _Dist, - ) - from pip._internal.utils.compat import stdlib_pkgs - - env = get_default_environment() - dists = env.iter_installed_distributions( - local_only=True, - skip=stdlib_pkgs, - include_editables=True, - editables_only=False, - user_only=False, - ) - return [dist._dist for dist in dists] - from prettytable import PrettyTable try: @@ -78,6 +50,8 @@ def get_installed_distributions(): from prettytable import NONE as RULE_NONE PTABLE = False +from importlib import metadata as importlib_metadata + open = open # allow monkey patching __pkgname__ = 'pip-licenses' @@ -151,78 +125,60 @@ def get_installed_distributions(): def get_packages(args: "CustomNamespace"): - def get_pkg_included_file(pkg, file_names): + def get_pkg_included_file(pkg, file_names_rgx): """ Attempt to find the package's included file on disk and return the tuple (included_file_path, included_file_contents). """ included_file = LICENSE_UNKNOWN included_text = LICENSE_UNKNOWN - pkg_dirname = "{}-{}.dist-info".format( - pkg.project_name.replace("-", "_"), pkg.version) - patterns = [] - [patterns.extend(sorted(glob.glob(os.path.join(pkg.location, - pkg_dirname, - f)))) - for f in file_names] - # Search for path defined in PEP 639 https://peps.python.org/pep-0639/ - [patterns.extend(sorted(glob.glob(os.path.join(pkg.location, - pkg_dirname, - "licenses", - f)))) - for f in file_names] - for test_file in patterns: - if os.path.exists(test_file) and \ - os.path.isdir(test_file) is not True: - included_file = test_file - with open(test_file, encoding='utf-8', - errors='backslashreplace') as included_file_handle: - included_text = included_file_handle.read() - break - return (included_file, included_text) + + pkg_files = pkg.files or () + pattern = re.compile(file_names_rgx) + matched_rel_paths = filter( + lambda file: pattern.match(file.name), + pkg_files + ) + for rel_path in matched_rel_paths: + abs_path = pkg.locate_file(rel_path) + if not abs_path.is_file(): + continue + included_file = abs_path + with open( + abs_path, + encoding='utf-8', + errors='backslashreplace' + ) as included_file_handle: + included_text = included_file_handle.read() + break + return (str(included_file), included_text) def get_pkg_info(pkg): (license_file, license_text) = get_pkg_included_file( pkg, - ('LICENSE*', 'LICENCE*', 'COPYING*') + "LICEN[CS]E.*|COPYING.*" ) (notice_file, notice_text) = get_pkg_included_file( pkg, - ('NOTICE*',) + "NOTICE.*" ) pkg_info = { - 'name': pkg.project_name, + 'name': pkg.metadata["name"], 'version': pkg.version, - 'namever': str(pkg), + 'namever': "{} {}".format(pkg.metadata["name"], pkg.version), 'licensefile': license_file, 'licensetext': license_text, 'noticefile': notice_file, 'noticetext': notice_text, } - metadata = None - if pkg.has_metadata('METADATA'): - metadata = pkg.get_metadata('METADATA') - - if pkg.has_metadata('PKG-INFO') and metadata is None: - metadata = pkg.get_metadata('PKG-INFO') - - if metadata is None: - for key in METADATA_KEYS: - pkg_info[key] = LICENSE_UNKNOWN - - return pkg_info - - feed_parser = FeedParser() - feed_parser.feed(metadata) - parsed_metadata = feed_parser.close() - + metadata = pkg.metadata for key in METADATA_KEYS: - pkg_info[key] = parsed_metadata.get(key, LICENSE_UNKNOWN) + pkg_info[key] = metadata.get(key, LICENSE_UNKNOWN) - if metadata is not None: - message = message_from_string(metadata) + classifiers = metadata.get_all("classifier") + if classifiers: pkg_info['license_classifier'] = \ - find_license_from_classifier(message) + find_license_from_classifier(classifiers) if args.filter_strings: for k in pkg_info: @@ -238,7 +194,10 @@ def get_pkg_info(pkg): return pkg_info - pkgs = get_installed_distributions() + pkgs = filter( + lambda pkg: pkg.metadata["name"] != "pip-licenses", + importlib_metadata.distributions() + ) ignore_pkgs_as_lower = [pkg.lower() for pkg in args.ignore_packages] pkgs_as_lower = [pkg.lower() for pkg in args.packages] @@ -251,7 +210,7 @@ def get_pkg_info(pkg): allow_only_licenses = set(map(str.strip, args.allow_only.split(";"))) for pkg in pkgs: - pkg_name = pkg.project_name + pkg_name = pkg.metadata["name"] if pkg_name.lower() in ignore_pkgs_as_lower: continue @@ -477,15 +436,14 @@ def factory_styled_table_with_args( return table -def find_license_from_classifier(message): +def find_license_from_classifier(classifiers): licenses = [] - for k, v in message.items(): - if k == 'Classifier' and v.startswith('License'): - license = v.split(' :: ')[-1] + for classifier in filter(lambda c: c.startswith("License"), classifiers): + license = classifier.split(' :: ')[-1] - # Through the declaration of 'Classifier: License :: OSI Approved' - if license != 'OSI Approved': - licenses.append(license) + # Through the declaration of 'Classifier: License :: OSI Approved' + if license != 'OSI Approved': + licenses.append(license) return licenses diff --git a/setup.cfg b/setup.cfg index 1c18d90..f3f351c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,17 +10,17 @@ classifiers = License :: OSI Approved :: MIT License Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: System :: Systems Administration Topic :: System :: System Shells [options] packages = find: include_package_data = True -python_requires = ~=3.7 +python_requires = ~=3.8 py_modules = piplicenses setup_requires = diff --git a/test_piplicenses.py b/test_piplicenses.py index c5e5756..74bfb68 100644 --- a/test_piplicenses.py +++ b/test_piplicenses.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # vim:fenc=utf-8 ff=unix ft=python ts=4 sw=4 sts=4 si et import copy +import email import re import sys import unittest @@ -29,14 +30,35 @@ UNICODE_APPENDIX = f.readline().replace("\n", "") -def get_installed_distributions_mocked(*args, **kwargs): - packages = get_installed_distributions_orig(*args, **kwargs) - if not packages[-1].project_name.endswith(UNICODE_APPENDIX): - packages[-1].project_name += " "+UNICODE_APPENDIX +def importlib_metadata_distributions_mocked(*args, **kwargs): + + class DistributionMocker(piplicenses.importlib_metadata.Distribution): + def __init__(self, orig_dist): + self.__dist = orig_dist + + @property + def metadata(self): + return EmailMessageMocker(self.__dist.metadata) + + class EmailMessageMocker(email.message.Message): + def __init__(self, orig_msg): + self.__msg = orig_msg + + def __getattr__(self, attr): + return getattr(self.__msg, attr) + + def __getitem__(self, key): + if key.lower() == "name": + return self.__msg["name"] + " " + UNICODE_APPENDIX + return self.__msg[key] + + packages = list(importlib_metadata_distributions_orig(*args, **kwargs)) + packages[-1] = DistributionMocker(packages[-1]) return packages -get_installed_distributions_orig = piplicenses.get_installed_distributions +importlib_metadata_distributions_orig = \ + piplicenses.importlib_metadata.distributions class CommandLineTestCase(unittest.TestCase): @@ -171,28 +193,21 @@ def test_from_all(self): self.assertIn(license, license_classifier) def test_find_license_from_classifier(self): - metadata = ('Metadata-Version: 2.0\r\n' - 'Name: pip-licenses\r\n' - 'Version: 1.0.0\r\n' - 'Classifier: License :: OSI Approved :: MIT License\r\n') - message = message_from_string(metadata) + classifiers = ['License :: OSI Approved :: MIT License'] self.assertEqual(['MIT License'], - find_license_from_classifier(message)) + find_license_from_classifier(classifiers)) def test_display_multiple_license_from_classifier(self): - metadata = ('Metadata-Version: 2.0\r\n' - 'Name: helga\r\n' - 'Version: 1.7.6\r\n' - 'Classifier: License :: OSI Approved\r\n' - 'Classifier: License :: OSI Approved :: ' - 'GNU General Public License v3 (GPLv3)\r\n' - 'Classifier: License :: OSI Approved :: MIT License\r\n' - 'Classifier: License :: Public Domain\r\n') - message = message_from_string(metadata) + classifiers = [ + 'License :: OSI Approved', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'License :: OSI Approved :: MIT License', + 'License :: Public Domain' + ] self.assertEqual(['GNU General Public License v3 (GPLv3)', 'MIT License', 'Public Domain'], - find_license_from_classifier(message)) + find_license_from_classifier(classifiers)) def test_not_found_license_from_classifier(self): metadata_as_no_license = ('Metadata-Version: 2.0\r\n' @@ -426,8 +441,8 @@ def test_format_markdown(self): @unittest.skipIf(sys.version_info < (3, 6, 0), "To unsupport Python 3.5 in the near future") def test_format_rst_without_filter(self): - piplicenses.get_installed_distributions = \ - get_installed_distributions_mocked + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_mocked format_rst_args = ['--format=rst'] args = self.parser.parse_args(format_rst_args) table = create_licenses_table(args) @@ -439,12 +454,12 @@ def test_format_rst_without_filter(self): self.assertEqual(RULE_ALL, table.hrules) with self.assertRaises(docutils.utils.SystemMessage): self.check_rst(str(table)) - piplicenses.get_installed_distributions = \ - get_installed_distributions_orig + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_orig def test_format_rst_default_filter(self): - piplicenses.get_installed_distributions = \ - get_installed_distributions_mocked + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_mocked format_rst_args = ['--format=rst', '--filter-strings'] args = self.parser.parse_args(format_rst_args) table = create_licenses_table(args) @@ -455,8 +470,8 @@ def test_format_rst_default_filter(self): self.assertEqual('+', table.junction_char) self.assertEqual(RULE_ALL, table.hrules) self.check_rst(str(table)) - piplicenses.get_installed_distributions = \ - get_installed_distributions_orig + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_orig def test_format_confluence(self): format_confluence_args = ['--format=confluence'] @@ -562,32 +577,32 @@ def test_output_colored_bold(self): self.assertTrue(actual.endswith('\033[0m')) def test_without_filter(self): - piplicenses.get_installed_distributions = \ - get_installed_distributions_mocked + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_mocked args = self.parser.parse_args([]) packages = list(piplicenses.get_packages(args)) self.assertIn(UNICODE_APPENDIX, packages[-1]["name"]) - piplicenses.get_installed_distributions = \ - get_installed_distributions_orig + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_orig def test_with_default_filter(self): - piplicenses.get_installed_distributions = \ - get_installed_distributions_mocked + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_mocked args = self.parser.parse_args(["--filter-strings"]) packages = list(piplicenses.get_packages(args)) - piplicenses.get_installed_distributions = \ - get_installed_distributions_orig + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_orig self.assertNotIn(UNICODE_APPENDIX, packages[-1]["name"]) def test_with_specified_filter(self): - piplicenses.get_installed_distributions = \ - get_installed_distributions_mocked + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_mocked args = self.parser.parse_args(["--filter-strings", "--filter-code-page=ascii"]) packages = list(piplicenses.get_packages(args)) self.assertNotIn(UNICODE_APPENDIX, packages[-1]["summary"]) - piplicenses.get_installed_distributions = \ - get_installed_distributions_orig + piplicenses.importlib_metadata.distributions = \ + importlib_metadata_distributions_orig class MockStdStream(object):