--- a/src/eric7/PipInterface/piplicenses.py Thu Dec 08 10:38:38 2022 +0100 +++ b/src/eric7/PipInterface/piplicenses.py Thu Dec 08 14:35:50 2022 +0100 @@ -41,22 +41,40 @@ # '--summary-by-license' switch to count each individual # license used # - changed 'create_output_string' to return a JSON string +# - added 'get_installed_distributions' to get the distributions +# via pip in order to filter for 'local only' and/or 'user only' # +from __future__ import annotations + import argparse import codecs -import glob import json -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 typing import List, Optional, Sequence, Text +from pathlib import Path +from typing import TYPE_CHECKING, List, Type, cast + +if TYPE_CHECKING: + from typing import Iterator, Optional, Sequence def get_installed_distributions(local_only=True, user_only=False): + """ + Function to get the installed packages via pip. + + Note: importlib_metadata.distributions() does not respect + 'local_only' and 'user_only' keyword parameters. + + @param local_only DESCRIPTION (defaults to True) + @type TYPE (optional) + @param user_only DESCRIPTION (defaults to False) + @type TYPE (optional) + @return DESCRIPTION + @rtype TYPE + """ try: from pip._internal.metadata import get_environment except ImportError: @@ -78,13 +96,14 @@ return [d._dist for d in dists] -__pkgname__ = 'pip-licenses' -__version__ = '3.5.4' -__author__ = 'raimon' -__license__ = 'MIT' -__summary__ = ('Dump the software license list of ' - 'Python packages installed with pip.') -__url__ = 'https://github.com/raimon49/pip-licenses' +__pkgname__ = "pip-licenses" +__version__ = "4.0.2" +__author__ = "raimon" +__license__ = "MIT" +__summary__ = ( + "Dump the software license list of Python packages installed with pip." +) +__url__ = "https://github.com/raimon49/pip-licenses" FIELD_NAMES = ( @@ -101,6 +120,12 @@ ) +SUMMARY_FIELD_NAMES = ( + "Count", + "License", +) + + DEFAULT_OUTPUT_FIELDS = ( 'Name', 'Version', @@ -140,85 +165,71 @@ LICENSE_UNKNOWN = 'UNKNOWN' -def get_packages(args: "CustomNamespace"): - - def get_pkg_included_file(pkg, file_names): +def get_packages( + args: CustomNamespace, +) -> Iterator[dict[str, str | list[str]]]: + def get_pkg_included_file( + pkg, file_names_rgx: str + ) -> tuple[str, str]: """ 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] - for test_file in patterns: - if os.path.exists(test_file) and not os.path.isdir(test_file): - included_file = test_file - with open(test_file, encoding='utf-8', - errors='backslashreplace') as included_file_handle: - included_text = included_file_handle.read() - break + + 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 = Path(pkg.locate_file(rel_path)) + if not abs_path.is_file(): + continue + included_file = str(abs_path) + with open( + abs_path, encoding="utf-8", errors="backslashreplace" + ) as included_file_handle: + included_text = included_file_handle.read() + break return (included_file, included_text) - def get_pkg_info(pkg): + def get_pkg_info(pkg) -> dict[str, str | list[str]]: (license_file, license_text) = get_pkg_included_file( - pkg, - ('LICENSE*', 'LICENCE*', 'COPYING*') - ) - (notice_file, notice_text) = get_pkg_included_file( - pkg, - ('NOTICE*',) + pkg, "LICEN[CS]E.*|COPYING.*" ) - pkg_info = { - 'name': pkg.project_name, - 'version': pkg.version, - 'namever': str(pkg), - 'licensefile': license_file, - 'licensetext': license_text, - 'noticefile': notice_file, - 'noticetext': notice_text, + (notice_file, notice_text) = get_pkg_included_file(pkg, "NOTICE.*") + pkg_info: dict[str, str | list[str]] = { + "name": pkg.metadata["name"], + "version": pkg.version, + "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 + metadata = pkg.metadata + for key in METADATA_KEYS: + pkg_info[key] = metadata.get(key, LICENSE_UNKNOWN) # type: ignore[attr-defined] # noqa: E501 - return pkg_info - - feed_parser = FeedParser() - feed_parser.feed(metadata) - parsed_metadata = feed_parser.close() - - for key in METADATA_KEYS: - pkg_info[key] = parsed_metadata.get(key, LICENSE_UNKNOWN) - - if metadata is not None: - message = message_from_string(metadata) - pkg_info['license_classifier'] = \ - find_license_from_classifier(message) + classifiers: list[str] = metadata.get_all("classifier", []) + pkg_info["license_classifier"] = find_license_from_classifier( + classifiers + ) if args.filter_strings: + + def filter_string(item: str) -> str: + return item.encode( + args.filter_code_page, errors="ignore" + ).decode(args.filter_code_page) + for k in pkg_info: if isinstance(pkg_info[k], list): - for i, item in enumerate(pkg_info[k]): - pkg_info[k][i] = item. \ - encode(args.filter_code_page, errors="ignore"). \ - decode(args.filter_code_page) + pkg_info[k] = list(map(filter_string, pkg_info[k])) else: - pkg_info[k] = pkg_info[k]. \ - encode(args.filter_code_page, errors="ignore"). \ - decode(args.filter_code_page) + pkg_info[k] = filter_string(cast(str, pkg_info[k])) return pkg_info @@ -238,7 +249,7 @@ 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 @@ -253,8 +264,9 @@ license_names = select_license_by_source( args.from_, - pkg_info['license_classifier'], - pkg_info['license']) + cast(List[str], pkg_info["license_classifier"]), + cast(str, pkg_info["license"]), + ) if fail_on_licenses: failed_licenses = license_names.intersection(fail_on_licenses) @@ -262,9 +274,10 @@ sys.stderr.write( "fail-on license {} was found for package " "{}:{}".format( - '; '.join(sorted(failed_licenses)), - pkg_info['name'], - pkg_info['version']) + "; ".join(sorted(failed_licenses)), + pkg_info["name"], + pkg_info["version"], + ) ) sys.exit(1) @@ -274,9 +287,10 @@ sys.stderr.write( "license {} not in allow-only licenses was found" " for package {}:{}".format( - '; '.join(sorted(uncommon_licenses)), - pkg_info['name'], - pkg_info['version']) + "; ".join(sorted(uncommon_licenses)), + pkg_info["name"], + pkg_info["version"], + ) ) sys.exit(1) @@ -292,16 +306,21 @@ for field in output_fields: if field == 'License': license_set = select_license_by_source( - args.from_, pkg['license_classifier'], pkg['license']) - license_str = '; '.join(sorted(license_set)) + args.from_, + cast(List[str], pkg["license_classifier"]), + cast(str, pkg["license"]), + ) + license_str = "; ".join(sorted(license_set)) row[field] = license_str elif field == 'License-Classifier': - row[field] = ('; '.join(sorted(pkg['license_classifier'])) - or LICENSE_UNKNOWN) + row[field] = ( + "; ".join(sorted(pkg["license_classifier"])) + or LICENSE_UNKNOWN + ) elif field.lower() in pkg: - row[field] = pkg[field.lower()] + row[field] = cast(str, pkg[field.lower()]) else: - row[field] = pkg[FIELDS_TO_METADATA_KEYS[field]] + row[field] = cast(str, pkg[FIELDS_TO_METADATA_KEYS[field]]) licenses.append(row) return licenses @@ -309,9 +328,17 @@ def create_summary_list(args: "CustomNamespace"): counts = Counter( - '; '.join(sorted(select_license_by_source( - args.from_, pkg['license_classifier'], pkg['license']))) - for pkg in get_packages(args)) + "; ".join( + sorted( + select_license_by_source( + args.from_, + cast(List[str], pkg["license_classifier"]), + cast(str, pkg["license"]), + ) + ) + ) + for pkg in get_packages(args) + ) licenses = [] for license, count in counts.items(): @@ -340,59 +367,63 @@ return licenses -def find_license_from_classifier(message): +def find_license_from_classifier(classifiers: list[str]) -> list[str]: 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 -def select_license_by_source(from_source, license_classifier, license_meta): +def select_license_by_source( + from_source: FromArg, license_classifier: list[str], license_meta: str +) -> set[str]: license_classifier_set = set(license_classifier) or {LICENSE_UNKNOWN} - if (from_source == FromArg.CLASSIFIER or - from_source == FromArg.MIXED and len(license_classifier) > 0): + if ( + from_source == FromArg.CLASSIFIER + or from_source == FromArg.MIXED + and len(license_classifier) > 0 + ): return license_classifier_set else: return {license_meta} -def get_output_fields(args: "CustomNamespace"): +def get_output_fields(args: CustomNamespace) -> list[str]: if args.summary: return list(SUMMARY_OUTPUT_FIELDS) output_fields = list(DEFAULT_OUTPUT_FIELDS) if args.from_ == FromArg.ALL: - output_fields.append('License-Metadata') - output_fields.append('License-Classifier') + output_fields.append("License-Metadata") + output_fields.append("License-Classifier") else: - output_fields.append('License') + output_fields.append("License") if args.with_authors: - output_fields.append('Author') + output_fields.append("Author") if args.with_urls: - output_fields.append('URL') + output_fields.append("URL") if args.with_description: - output_fields.append('Description') + output_fields.append("Description") if args.with_license_file: if not args.no_license_path: - output_fields.append('LicenseFile') + output_fields.append("LicenseFile") - output_fields.append('LicenseText') + output_fields.append("LicenseText") if args.with_notice_file: - output_fields.append('NoticeText') + output_fields.append("NoticeText") if not args.no_license_path: - output_fields.append('NoticeFile') + output_fields.append("NoticeFile") return output_fields @@ -412,19 +443,25 @@ class CustomHelpFormatter(argparse.HelpFormatter): # pragma: no cover def __init__( - self, prog: Text, indent_increment: int = 2, - max_help_position: int = 24, width: Optional[int] = None + self, + prog: str, + indent_increment: int = 2, + max_help_position: int = 24, + width: Optional[int] = None, ) -> None: max_help_position = 30 super().__init__( - prog, indent_increment=indent_increment, - max_help_position=max_help_position, width=width) + prog, + indent_increment=indent_increment, + max_help_position=max_help_position, + width=width, + ) def _format_action(self, action: argparse.Action) -> str: flag_indent_argument: bool = False text = self._expand_help(action) - separator_pos = text[:3].find('|') - if separator_pos != -1 and 'I' in text[:separator_pos]: + separator_pos = text[:3].find("|") + if separator_pos != -1 and "I" in text[:separator_pos]: self._indent() flag_indent_argument = True help_str = super()._format_action(action) @@ -435,14 +472,16 @@ def _expand_help(self, action: argparse.Action) -> str: if isinstance(action.default, Enum): default_value = enum_key_to_value(action.default) - return self._get_help_string(action) % {'default': default_value} + return cast(str, self._get_help_string(action)) % { + "default": default_value + } return super()._expand_help(action) - def _split_lines(self, text: Text, width: int) -> List[str]: - separator_pos = text[:3].find('|') + def _split_lines(self, text: str, width: int) -> List[str]: + separator_pos = text[:3].find("|") if separator_pos != -1: - flag_splitlines: bool = 'R' in text[:separator_pos] - text = text[separator_pos + 1:] + flag_splitlines: bool = "R" in text[:separator_pos] + text = text[separator_pos + 1:] # fmt: skip if flag_splitlines: return text.splitlines() return super()._split_lines(text, width) @@ -472,24 +511,28 @@ class CompatibleArgumentParser(argparse.ArgumentParser): - def parse_args(self, args: Optional[Sequence[Text]] = None, - namespace: CustomNamespace = None) -> CustomNamespace: - args = super().parse_args(args, namespace) - self._verify_args(args) - return args + def parse_args( # type: ignore[override] + self, + args: None | Sequence[str] = None, + namespace: None | CustomNamespace = None, + ) -> CustomNamespace: + args_ = cast(CustomNamespace, super().parse_args(args, namespace)) + self._verify_args(args_) + return args_ - def _verify_args(self, args: CustomNamespace): + def _verify_args(self, args: CustomNamespace) -> None: if args.with_license_file is False and ( - args.no_license_path is True or - args.with_notice_file is True): + args.no_license_path is True or args.with_notice_file is True + ): self.error( "'--no-license-path' and '--with-notice-file' require " - "the '--with-license-file' option to be set") - if args.filter_strings is False and \ - args.filter_code_page != 'latin1': + "the '--with-license-file' option to be set" + ) + if args.filter_strings is False and args.filter_code_page != "latin1": self.error( "'--filter-code-page' requires the '--filter-strings' " - "option to be set") + "option to be set" + ) try: codecs.lookup(args.filter_code_page) except LookupError: @@ -497,7 +540,8 @@ "invalid code page '%s' given for '--filter-code-page, " "check https://docs.python.org/3/library/codecs.html" "#standard-encodings for valid code pages" - % args.filter_code_page) + % args.filter_code_page + ) class NoValueEnum(Enum): @@ -528,9 +572,10 @@ return enum_key.name.replace('_', '-').lower() -def choices_from_enum(enum_cls: NoValueEnum) -> List[str]: - return [key.replace('_', '-').lower() - for key in enum_cls.__members__.keys()] +def choices_from_enum(enum_cls: Type[NoValueEnum]) -> List[str]: + return [ + key.replace("_", "-").lower() for key in enum_cls.__members__.keys() + ] MAP_DEST_TO_ENUM = { @@ -540,11 +585,12 @@ class SelectAction(argparse.Action): - def __call__( - self, parser: argparse.ArgumentParser, + def __call__( # type: ignore[override] + self, + parser: argparse.ArgumentParser, namespace: argparse.Namespace, - values: Text, - option_string: Optional[Text] = None, + values: str, + option_string: Optional[str] = None, ) -> None: enum_cls = MAP_DEST_TO_ENUM[self.dest] values = value_to_enum_key(values) @@ -553,35 +599,41 @@ def create_parser(): parser = CompatibleArgumentParser( - description=__summary__, - formatter_class=CustomHelpFormatter) + description=__summary__, formatter_class=CustomHelpFormatter + ) common_options = parser.add_argument_group('Common options') format_options = parser.add_argument_group('Format options') verify_options = parser.add_argument_group('Verify options') parser.add_argument( - '-v', '--version', - action='version', - version='%(prog)s ' + __version__) + "-v", "--version", action="version", version="%(prog)s " + __version__ + ) common_options.add_argument( - '--from', - dest='from_', - action=SelectAction, type=str, - default=FromArg.MIXED, metavar='SOURCE', + "--from", + dest="from_", + action=SelectAction, + type=str, + default=FromArg.MIXED, + metavar="SOURCE", choices=choices_from_enum(FromArg), - help='R|where to find license information\n' - '"meta", "classifier, "mixed", "all"\n' - '(default: %(default)s)') + help="R|where to find license information\n" + '"meta", "classifier, "mixed", "all"\n' + "(default: %(default)s)", + ) common_options.add_argument( - '-o', '--order', - action=SelectAction, type=str, - default=OrderArg.NAME, metavar='COL', + "-o", + "--order", + action=SelectAction, + type=str, + default=OrderArg.NAME, + metavar="COL", choices=choices_from_enum(OrderArg), - help='R|order by column\n' - '"name", "license", "author", "url"\n' - '(default: %(default)s)') + help="R|order by column\n" + '"name", "license", "author", "url"\n' + "(default: %(default)s)", + ) common_options.add_argument( '--summary', action='store_true', @@ -597,17 +649,25 @@ action='store', type=str, help='save license list to file') common_options.add_argument( - '-i', '--ignore-packages', - action='store', type=str, - nargs='+', metavar='PKG', + "-i", + "--ignore-packages", + action="store", + type=str, + nargs="+", + metavar="PKG", default=[], - help='ignore package name in dumped list') + help="ignore package name in dumped list", + ) common_options.add_argument( - '-p', '--packages', - action='store', type=str, - nargs='+', metavar='PKG', + "-p", + "--packages", + action="store", + type=str, + nargs="+", + metavar="PKG", default=[], - help='only include selected packages in output') + help="only include selected packages in output", + ) common_options.add_argument( '--local-only', action='store_true', @@ -620,76 +680,91 @@ help='include only packages of the user site dir') format_options.add_argument( - '-s', '--with-system', - action='store_true', + "-s", + "--with-system", + action="store_true", default=False, - help='dump with system packages') - format_options.add_argument( - '-a', '--with-authors', - action='store_true', - default=False, - help='dump with package authors') + help="dump with system packages", + ) format_options.add_argument( - '-u', '--with-urls', - action='store_true', + "-a", + "--with-authors", + action="store_true", default=False, - help='dump with package urls') - format_options.add_argument( - '-d', '--with-description', - action='store_true', - default=False, - help='dump with short package description') + help="dump with package authors", + ) format_options.add_argument( - '-l', '--with-license-file', - action='store_true', + "-u", + "--with-urls", + action="store_true", default=False, - help='dump with location of license file and ' - 'contents, most useful with JSON output') + help="dump with package urls", + ) format_options.add_argument( - '--no-license-path', - action='store_true', + "-d", + "--with-description", + action="store_true", default=False, - help='I|when specified together with option -l, ' - 'suppress location of license file output') + help="dump with short package description", + ) format_options.add_argument( - '--with-notice-file', - action='store_true', + "-l", + "--with-license-file", + action="store_true", default=False, - help='I|when specified together with option -l, ' - 'dump with location of license file and contents') + help="dump with location of license file and " + "contents, most useful with JSON output", + ) format_options.add_argument( - '--filter-strings', + "--no-license-path", action="store_true", default=False, - help='filter input according to code page') + help="I|when specified together with option -l, " + "suppress location of license file output", + ) format_options.add_argument( - '--filter-code-page', - action="store", type=str, + "--with-notice-file", + action="store_true", + default=False, + help="I|when specified together with option -l, " + "dump with location of license file and contents", + ) + format_options.add_argument( + "--filter-strings", + action="store_true", + default=False, + help="filter input according to code page", + ) + format_options.add_argument( + "--filter-code-page", + action="store", + type=str, default="latin1", metavar="CODE", - help='I|specify code page for filtering ' - '(default: %(default)s)') + help="I|specify code page for filtering " "(default: %(default)s)", + ) verify_options.add_argument( - '--fail-on', - action='store', type=str, + "--fail-on", + action="store", + type=str, default=None, - help='fail (exit with code 1) on the first occurrence ' - 'of the licenses of the semicolon-separated list') + help="fail (exit with code 1) on the first occurrence " + "of the licenses of the semicolon-separated list", + ) verify_options.add_argument( - '--allow-only', - action='store', type=str, + "--allow-only", + action="store", + type=str, default=None, - help='fail (exit with code 1) on the first occurrence ' - 'of the licenses not in the semicolon-separated list') + help="fail (exit with code 1) on the first occurrence " + "of the licenses not in the semicolon-separated list", + ) return parser def main(): # pragma: no cover - os.environ["_PIP_USE_IMPORTLIB_METADATA"] = "False" - # patched for 3.11+ compatibility - parser = create_parser() args = parser.parse_args()