src/eric7/PipInterface/piplicenses.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 9098
fb9351497cea
child 9218
71cf3979a6c9
diff -r 3fc8dfeb6ebe -r b99e7fd55fd3 src/eric7/PipInterface/piplicenses.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/PipInterface/piplicenses.py	Thu Jul 07 11:23:56 2022 +0200
@@ -0,0 +1,664 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# vim:fenc=utf-8 ff=unix ft=python ts=4 sw=4 sts=4 si et
+"""
+pip-licenses
+
+MIT License
+
+Copyright (c) 2018 raimon
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+#
+# Modified to be used within the eric-ide project.
+#
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+import argparse
+import codecs
+import glob
+import json
+import os
+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
+
+
+def get_installed_distributions(local_only=True, user_only=False):
+    try:
+        from pip._internal.metadata import get_environment
+    except ImportError:
+        # For backward compatibility with pip version 20.3.4
+        from pip._internal.utils import misc
+        return misc.get_installed_distributions(
+            local_only=local_only,
+            user_only=user_only
+        )
+    else:
+        from pip._internal.utils.compat import stdlib_pkgs
+        dists = get_environment(None).iter_installed_distributions(
+            local_only=local_only,
+            user_only=user_only,
+            skip=stdlib_pkgs,
+            include_editables=True,
+            editables_only=False,
+        )
+        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'
+
+
+FIELD_NAMES = (
+    'Name',
+    'Version',
+    'License',
+    'LicenseFile',
+    'LicenseText',
+    'NoticeFile',
+    'NoticeText',
+    'Author',
+    'Description',
+    'URL',
+)
+
+
+DEFAULT_OUTPUT_FIELDS = (
+    'Name',
+    'Version',
+)
+
+
+SUMMARY_OUTPUT_FIELDS = (
+    'Count',
+    'License',
+)
+
+
+METADATA_KEYS = (
+    'home-page',
+    'author',
+    'license',
+    'summary',
+    'license_classifier',
+)
+
+# Mapping of FIELD_NAMES to METADATA_KEYS where they differ by more than case
+FIELDS_TO_METADATA_KEYS = {
+    'URL': 'home-page',
+    'Description': 'summary',
+    'License-Metadata': 'license',
+    'License-Classifier': 'license_classifier',
+}
+
+
+SYSTEM_PACKAGES = (
+    __pkgname__,
+    'pip',
+    'setuptools',
+    'wheel',
+)
+
+LICENSE_UNKNOWN = 'UNKNOWN'
+
+
+def get_packages(args: "CustomNamespace"):
+
+    def get_pkg_included_file(pkg, file_names):
+        """
+        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
+        return (included_file, included_text)
+
+    def get_pkg_info(pkg):
+        (license_file, license_text) = get_pkg_included_file(
+            pkg,
+            ('LICENSE*', 'LICENCE*', 'COPYING*')
+        )
+        (notice_file, notice_text) = get_pkg_included_file(
+            pkg,
+            ('NOTICE*',)
+        )
+        pkg_info = {
+            'name': pkg.project_name,
+            'version': pkg.version,
+            'namever': str(pkg),
+            '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()
+
+        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)
+
+        if args.filter_strings:
+            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)
+                else:
+                    pkg_info[k] = pkg_info[k]. \
+                        encode(args.filter_code_page, errors="ignore"). \
+                        decode(args.filter_code_page)
+
+        return pkg_info
+
+    pkgs = get_installed_distributions(
+        local_only=args.local_only,
+        user_only=args.user_only,
+    )
+    ignore_pkgs_as_lower = [pkg.lower() for pkg in args.ignore_packages]
+    pkgs_as_lower = [pkg.lower() for pkg in args.packages]
+
+    fail_on_licenses = set()
+    if args.fail_on:
+        fail_on_licenses = set(map(str.strip, args.fail_on.split(";")))
+
+    allow_only_licenses = set()
+    if args.allow_only:
+        allow_only_licenses = set(map(str.strip, args.allow_only.split(";")))
+
+    for pkg in pkgs:
+        pkg_name = pkg.project_name
+
+        if pkg_name.lower() in ignore_pkgs_as_lower:
+            continue
+
+        if pkgs_as_lower and pkg_name.lower() not in pkgs_as_lower:
+            continue
+
+        if not args.with_system and pkg_name in SYSTEM_PACKAGES:
+            continue
+
+        pkg_info = get_pkg_info(pkg)
+
+        license_names = select_license_by_source(
+            args.from_,
+            pkg_info['license_classifier'],
+            pkg_info['license'])
+
+        if fail_on_licenses:
+            failed_licenses = license_names.intersection(fail_on_licenses)
+            if failed_licenses:
+                sys.stderr.write(
+                    "fail-on license {} was found for package "
+                    "{}:{}".format(
+                        '; '.join(sorted(failed_licenses)),
+                        pkg_info['name'],
+                        pkg_info['version'])
+                )
+                sys.exit(1)
+
+        if allow_only_licenses:
+            uncommon_licenses = license_names.difference(allow_only_licenses)
+            if len(uncommon_licenses) == len(license_names):
+                sys.stderr.write(
+                    "license {} not in allow-only licenses was found"
+                    " for package {}:{}".format(
+                        '; '.join(sorted(uncommon_licenses)),
+                        pkg_info['name'],
+                        pkg_info['version'])
+                )
+                sys.exit(1)
+
+        yield pkg_info
+
+
+def create_licenses_list(
+        args: "CustomNamespace", output_fields=DEFAULT_OUTPUT_FIELDS):
+    
+    licenses = []
+    for pkg in get_packages(args):
+        row = {}
+        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))
+                row[field] = license_str
+            elif field == 'License-Classifier':
+                row[field] = ('; '.join(sorted(pkg['license_classifier']))
+                           or LICENSE_UNKNOWN)
+            elif field.lower() in pkg:
+                row[field] = pkg[field.lower()]
+            else:
+                row[field] = pkg[FIELDS_TO_METADATA_KEYS[field]]
+        licenses.append(row)
+
+    return licenses
+
+
+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))
+
+    licenses = []
+    for license, count in counts.items():
+        licenses.append({
+            "Count": count,
+            "License": license,
+        })
+    
+    return licenses
+
+
+def find_license_from_classifier(message):
+    licenses = []
+    for k, v in message.items():
+        if k == 'Classifier' and v.startswith('License'):
+            license = v.split(' :: ')[-1]
+
+            # 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):
+    license_classifier_set = set(license_classifier) or {LICENSE_UNKNOWN}
+    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"):
+    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')
+    else:
+        output_fields.append('License')
+
+    if args.with_authors:
+        output_fields.append('Author')
+
+    if args.with_urls:
+        output_fields.append('URL')
+
+    if args.with_description:
+        output_fields.append('Description')
+
+    if args.with_license_file:
+        if not args.no_license_path:
+            output_fields.append('LicenseFile')
+
+        output_fields.append('LicenseText')
+
+        if args.with_notice_file:
+            output_fields.append('NoticeText')
+            if not args.no_license_path:
+                output_fields.append('NoticeFile')
+
+    return output_fields
+
+
+def create_output_string(args: "CustomNamespace"):
+    output_fields = get_output_fields(args)
+
+    if args.summary:
+        licenses = create_summary_list(args)
+    else:
+        licenses = create_licenses_list(args, output_fields)
+    
+    return json.dumps(licenses)
+
+
+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
+    ) -> None:
+        max_help_position = 30
+        super().__init__(
+            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]:
+            self._indent()
+            flag_indent_argument = True
+        help_str = super()._format_action(action)
+        if flag_indent_argument:
+            self._dedent()
+        return help_str
+
+    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 super()._expand_help(action)
+
+    def _split_lines(self, text: Text, 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:]
+            if flag_splitlines:
+                return text.splitlines()
+        return super()._split_lines(text, width)
+
+
+class CustomNamespace(argparse.Namespace):
+    from_: "FromArg"
+    order: "OrderArg"
+    summary: bool
+    local_only: bool
+    user_only:bool
+    output_file: str
+    ignore_packages: List[str]
+    packages: List[str]
+    with_system: bool
+    with_authors: bool
+    with_urls: bool
+    with_description: bool
+    with_license_file: bool
+    no_license_path: bool
+    with_notice_file: bool
+    filter_strings: bool
+    filter_code_page: str
+    fail_on: Optional[str]
+    allow_only: Optional[str]
+
+
+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 _verify_args(self, args: CustomNamespace):
+        if args.with_license_file is False and (
+                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':
+            self.error(
+                "'--filter-code-page' requires the '--filter-strings' "
+                "option to be set")
+        try:
+            codecs.lookup(args.filter_code_page)
+        except LookupError:
+            self.error(
+                "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)
+
+
+class NoValueEnum(Enum):
+    def __repr__(self):  # pragma: no cover
+        return '<%s.%s>' % (self.__class__.__name__, self.name)
+
+
+class FromArg(NoValueEnum):
+    META = M = auto()
+    CLASSIFIER = C = auto()
+    MIXED = MIX = auto()
+    ALL = auto()
+
+
+class OrderArg(NoValueEnum):
+    COUNT = C = auto()
+    LICENSE = L = auto()
+    NAME = N = auto()
+    AUTHOR = A = auto()
+    URL = U = auto()
+
+
+def value_to_enum_key(value: str) -> str:
+    return value.replace('-', '_').upper()
+
+
+def enum_key_to_value(enum_key: Enum) -> str:
+    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()]
+
+
+MAP_DEST_TO_ENUM = {
+    'from_': FromArg,
+    'order': OrderArg,
+}
+
+
+class SelectAction(argparse.Action):
+    def __call__(
+        self, parser: argparse.ArgumentParser,
+        namespace: argparse.Namespace,
+        values: Text,
+        option_string: Optional[Text] = None,
+    ) -> None:
+        enum_cls = MAP_DEST_TO_ENUM[self.dest]
+        values = value_to_enum_key(values)
+        setattr(namespace, self.dest, getattr(enum_cls, values))
+
+
+def create_parser():
+    parser = CompatibleArgumentParser(
+        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__)
+
+    common_options.add_argument(
+        '--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)')
+    common_options.add_argument(
+        '-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)')
+    common_options.add_argument(
+        '--summary',
+        action='store_true',
+        default=False,
+        help='dump summary of each license')
+    common_options.add_argument(
+        '--output-file',
+        action='store', type=str,
+        help='save license list to file')
+    common_options.add_argument(
+        '-i', '--ignore-packages',
+        action='store', type=str,
+        nargs='+', metavar='PKG',
+        default=[],
+        help='ignore package name in dumped list')
+    common_options.add_argument(
+        '-p', '--packages',
+        action='store', type=str,
+        nargs='+', metavar='PKG',
+        default=[],
+        help='only include selected packages in output')
+    common_options.add_argument(
+        '--local-only',
+        action='store_true',
+        default=False,
+        help='include only local packages')
+    common_options.add_argument(
+        '--user-only',
+        action='store_true',
+        default=False,
+        help='include only packages of the user site dir')
+    
+    format_options.add_argument(
+        '-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')
+    format_options.add_argument(
+        '-u', '--with-urls',
+        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')
+    format_options.add_argument(
+        '-l', '--with-license-file',
+        action='store_true',
+        default=False,
+        help='dump with location of license file and '
+             'contents, most useful with JSON output')
+    format_options.add_argument(
+        '--no-license-path',
+        action='store_true',
+        default=False,
+        help='I|when specified together with option -l, '
+             'suppress location of license file output')
+    format_options.add_argument(
+        '--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)')
+
+    verify_options.add_argument(
+        '--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')
+    verify_options.add_argument(
+        '--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')
+
+    return parser
+
+
+def main():  # pragma: no cover
+    parser = create_parser()
+    args = parser.parse_args()
+
+    output_string = create_output_string(args)
+
+    print(output_string)
+
+
+if __name__ == '__main__':  # pragma: no cover
+    main()

eric ide

mercurial