src/eric7/PipInterface/piplicenses.py

branch
eric7
changeset 9590
8fad82cb88ab
parent 9310
8ab45a4a6d96
child 9653
e67609152c5e
--- 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()
 

eric ide

mercurial