--- a/src/eric7/PipInterface/piplicenses.py Sat May 13 11:35:51 2023 +0200 +++ b/src/eric7/PipInterface/piplicenses.py Sat May 13 12:15:43 2023 +0200 @@ -45,7 +45,9 @@ import argparse import codecs import json +import os import re +import subprocess import sys from collections import Counter from enum import Enum, auto @@ -59,11 +61,12 @@ importlib_metadata = None if TYPE_CHECKING: - from typing import Iterator, Optional, Sequence + from email.message import Message + from typing import Callable, Dict, Iterator, Optional, Sequence __pkgname__ = "pip-licenses" -__version__ = "4.1.0" +__version__ = "4.3.1" __author__ = "raimon" __license__ = "MIT" __summary__ = ( @@ -81,6 +84,7 @@ 'NoticeFile', 'NoticeText', 'Author', + "Maintainer", 'Description', 'URL', ) @@ -104,13 +108,68 @@ ) -METADATA_KEYS = ( - 'home-page', - 'author', - 'license', - 'summary', - 'license_classifier', -) +def extract_homepage(metadata: Message) -> Optional[str]: + """Extracts the homepage attribute from the package metadata. + + Not all python packages have defined a home-page attribute. + As a fallback, the `Project-URL` metadata can be used. + The python core metadata supports multiple (free text) values for + the `Project-URL` field that are comma separated. + + Args: + metadata: The package metadata to extract the homepage from. + + Returns: + The home page if applicable, None otherwise. + """ + homepage = metadata.get("home-page", None) + if homepage is not None: + return homepage + + candidates: Dict[str, str] = {} + + for entry in metadata.get_all("Project-URL", []): + key, value = entry.split(",", 1) + candidates[key.strip()] = value.strip() + + for priority_key in ["Homepage", "Source", "Changelog", "Bug Tracker"]: + if priority_key in candidates: + return candidates[priority_key] + + return None + + +PATTERN_DELIMITER = re.compile(r"[-_.]+") + + +def normalize_pkg_name(pkg_name: str) -> str: + """Return normalized name according to PEP specification + + See here: https://peps.python.org/pep-0503/#normalized-names + + Args: + pkg_name: Package name it is extracted from the package metadata + or specified in the CLI + + Returns: + normalized packege name + """ + return PATTERN_DELIMITER.sub("-", pkg_name).lower() + + +METADATA_KEYS: Dict[str, List[Callable[[Message], Optional[str]]]] = { + "home-page": [extract_homepage], + "author": [ + lambda metadata: metadata.get("author"), + lambda metadata: metadata.get("author-email"), + ], + "maintainer": [ + lambda metadata: metadata.get("maintainer"), + lambda metadata: metadata.get("maintainer-email"), + ], + "license": [lambda metadata: metadata.get("license")], + "summary": [lambda metadata: metadata.get("summary")], +} # Mapping of FIELD_NAMES to METADATA_KEYS where they differ by more than case FIELDS_TO_METADATA_KEYS = { @@ -176,8 +235,15 @@ "noticetext": notice_text, } metadata = pkg.metadata - for key in METADATA_KEYS: - pkg_info[key] = metadata.get(key, LICENSE_UNKNOWN) # type: ignore[attr-defined] # noqa: E501 + for field_name, field_selector_fns in METADATA_KEYS.items(): + value = None + for field_selector_fn in field_selector_fns: + # Type hint of `Distribution.metadata` states `PackageMetadata` + # but it's actually of type `email.Message` + value = field_selector_fn(metadata) # type: ignore + if value: + break + pkg_info[field_name] = value or LICENSE_UNKNOWN classifiers: list[str] = metadata.get_all("classifier", []) pkg_info["license_classifier"] = find_license_from_classifier( @@ -199,13 +265,28 @@ return pkg_info + def get_python_sys_path(executable: str) -> list[str]: + script = "import sys; print(' '.join(filter(bool, sys.path)))" + output = subprocess.run( + [executable, "-c", script], + capture_output=True, + env={**os.environ, "PYTHONPATH": "", "VIRTUAL_ENV": ""}, + ) + return output.stdout.decode().strip().split() + # modified for 'eric-ide' for Python < 3.8 if importlib_metadata is None: return [] else: - pkgs = 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] + if args.python == sys.executable: + search_paths = sys.path + else: + search_paths = get_python_sys_path(args.python) + pkgs = importlib_metadata.distributions(path=search_paths) + ignore_pkgs_as_normalize = [ + normalize_pkg_name(pkg) for pkg in args.ignore_packages + ] + pkgs_as_normalize = [normalize_pkg_name(pkg) for pkg in args.packages] fail_on_licenses = set() if args.fail_on: @@ -216,12 +297,16 @@ allow_only_licenses = set(map(str.strip, args.allow_only.split(";"))) for pkg in pkgs: - pkg_name = pkg.metadata["name"] + pkg_name = normalize_pkg_name(pkg.metadata["name"]) + pkg_name_and_version = pkg_name + ":" + pkg.metadata["version"] - if pkg_name.lower() in ignore_pkgs_as_lower: + if ( + pkg_name.lower() in ignore_pkgs_as_normalize + or pkg_name_and_version.lower() in ignore_pkgs_as_normalize + ): continue - if pkgs_as_lower and pkg_name.lower() not in pkgs_as_lower: + if pkgs_as_normalize and pkg_name.lower() not in pkgs_as_normalize: continue if not args.with_system and pkg_name in SYSTEM_PACKAGES: @@ -270,8 +355,8 @@ # modified for 'eric-ide' def create_licenses_list( - args: "CustomNamespace", output_fields=DEFAULT_OUTPUT_FIELDS): - + args: "CustomNamespace", output_fields=DEFAULT_OUTPUT_FIELDS): + licenses = [] for pkg in get_packages(args): row = {} @@ -384,12 +469,18 @@ if args.with_authors: output_fields.append("Author") + if args.with_maintainers: + output_fields.append("Maintainer") + if args.with_urls: output_fields.append("URL") if args.with_description: output_fields.append("Description") + if args.no_version: + output_fields.remove("Version") + if args.with_license_file: if not args.no_license_path: output_fields.append("LicenseFile") @@ -533,6 +624,7 @@ LICENSE = L = auto() NAME = N = auto() AUTHOR = A = auto() + MAINTAINER = M = auto() URL = U = auto() @@ -583,6 +675,17 @@ ) common_options.add_argument( + "--python", + type=str, + default=sys.executable, + metavar="PYTHON_EXEC", + help="R| path to python executable to search distributions from\n" + "Package will be searched in the selected python's sys.path\n" + "By default, will search packages for current env executable\n" + "(default: sys.executable)", + ) + + common_options.add_argument( "--from", dest="from_", action=SelectAction, @@ -651,6 +754,12 @@ help="dump with package authors", ) format_options.add_argument( + "--with-maintainers", + action="store_true", + default=False, + help="dump with package maintainers", + ) + format_options.add_argument( "-u", "--with-urls", action="store_true", @@ -665,6 +774,13 @@ help="dump with short package description", ) format_options.add_argument( + "-nv", + "--no-version", + action="store_true", + default=False, + help="dump without package version", + ) + format_options.add_argument( "-l", "--with-license-file", action="store_true",