Skip to content

Commit

Permalink
Merge pull request #154 from raimon49/release-4.2.0
Browse files Browse the repository at this point in the history
Release 4.2.0
  • Loading branch information
raimon49 authored Apr 12, 2023
2 parents 8b38914 + 15da96c commit ca3160d
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 19 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## CHANGELOG

### 4.2.0

* Implement new option `--with-maintainers`
* Implement new option `--python`
* Allow version spec in `--ignore-packages` parameters
* When the `Author` field is `UNKNOWN`, the output is automatically completed from `Author-email`
* When the `home-page` field is `UNKNOWN`, the output is automatically completed from `Project-URL`

### 4.1.0

* Support case-insensitive license name matching around `--fail-on` and `--allow-only` parameters
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Dump the software license list of Python packages installed with pip.
* [Format options](#format-options)
* [Option: with\-system](#option-with-system)
* [Option: with\-authors](#option-with-authors)
* [Option: with\-maintainers](#option-with-maintainers)
* [Option: with\-urls](#option-with-urls)
* [Option: with\-description](#option-with-description)
* [Option: with\-license\-file](#option-with-license-file)
Expand Down Expand Up @@ -429,6 +430,12 @@ When executed with the `--with-authors` option, output with author of the packag
pytz 2017.3 MIT Stuart Bishop
```

#### Option: with-maintainers

When executed with the `--with-maintainers` option, output with maintainer of the package.

**Note:** This option is available for users who want information about the maintainer as well as the author. See [#144](https://github.com/raimon49/pip-licenses/issues/144)

#### Option: with-urls

For packages without Metadata, the license is output as `UNKNOWN`. To get more package information, use the `--with-urls` option.
Expand Down
75 changes: 65 additions & 10 deletions piplicenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@
from prettytable import PrettyTable

if TYPE_CHECKING:
from typing import Iterator, Optional, Sequence
from email.message import Message
from typing import Callable, Dict, Iterator, Optional, Sequence


open = open # allow monkey patching

__pkgname__ = "pip-licenses"
__version__ = "4.1.0"
__version__ = "4.2.0"
__author__ = "raimon"
__license__ = "MIT"
__summary__ = (
Expand All @@ -73,6 +74,7 @@
"NoticeFile",
"NoticeText",
"Author",
"Maintainer",
"Description",
"URL",
)
Expand All @@ -95,11 +97,50 @@
"License",
)

METADATA_KEYS = {
"home-page": ["home-page"],
"author": ["author", "author-email"],
"license": ["license"],
"summary": ["summary"],

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


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
Expand Down Expand Up @@ -168,10 +209,12 @@ def get_pkg_info(pkg: Distribution) -> dict[str, str | list[str]]:
"noticetext": notice_text,
}
metadata = pkg.metadata
for field_name, field_selectors in METADATA_KEYS.items():
for field_name, field_selector_fns in METADATA_KEYS.items():
value = None
for selector in field_selectors:
value = metadata.get(selector, None) # type: ignore[attr-defined] # noqa: E501
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
Expand Down Expand Up @@ -542,6 +585,9 @@ def get_output_fields(args: CustomNamespace) -> list[str]:
if args.with_authors:
output_fields.append("Author")

if args.with_maintainers:
output_fields.append("Maintainer")

if args.with_urls:
output_fields.append("URL")

Expand Down Expand Up @@ -571,6 +617,8 @@ def get_sortby(args: CustomNamespace) -> str:
return "Name"
elif args.order == OrderArg.AUTHOR and args.with_authors:
return "Author"
elif args.order == OrderArg.MAINTAINER and args.with_maintainers:
return "Maintainer"
elif args.order == OrderArg.URL and args.with_urls:
return "URL"

Expand Down Expand Up @@ -739,6 +787,7 @@ class OrderArg(NoValueEnum):
LICENSE = L = auto()
NAME = N = auto()
AUTHOR = A = auto()
MAINTAINER = M = auto()
URL = U = auto()


Expand Down Expand Up @@ -897,6 +946,12 @@ def create_parser() -> CompatibleArgumentParser:
default=False,
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",
Expand Down
103 changes: 94 additions & 9 deletions test_piplicenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from importlib.metadata import Distribution
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, List
from unittest.mock import MagicMock

import docutils.frontend
import docutils.parsers.rst
Expand All @@ -39,6 +40,7 @@
create_parser,
create_warn_string,
enum_key_to_value,
extract_homepage,
factory_styled_table_with_args,
find_license_from_classifier,
get_output_fields,
Expand Down Expand Up @@ -309,6 +311,17 @@ def test_with_authors(self) -> None:
output_string = create_output_string(args)
self.assertIn("Author", output_string)

def test_with_maintainers(self) -> None:
with_maintainers_args = ["--with-maintainers"]
args = self.parser.parse_args(with_maintainers_args)

output_fields = get_output_fields(args)
self.assertNotEqual(output_fields, list(DEFAULT_OUTPUT_FIELDS))
self.assertIn("Maintainer", output_fields)

output_string = create_output_string(args)
self.assertIn("Maintainer", output_string)

def test_with_urls(self) -> None:
with_urls_args = ["--with-urls"]
args = self.parser.parse_args(with_urls_args)
Expand Down Expand Up @@ -394,17 +407,32 @@ def test_with_license_file_warning(self) -> None:
self.assertIn("best paired with --format=json", warn_string)

def test_ignore_packages(self) -> None:
if "PTable" in SYSTEM_PACKAGES:
ignore_pkg_name = "PTable"
else:
ignore_pkg_name = "prettytable"
ignore_packages_args = ["--ignore-package=" + ignore_pkg_name]
ignore_pkg_name = "prettytable"
ignore_packages_args = [
"--ignore-package=" + ignore_pkg_name,
"--with-system",
]
args = self.parser.parse_args(ignore_packages_args)
table = create_licenses_table(args)

pkg_name_columns = self._create_pkg_name_columns(table)
self.assertNotIn(ignore_pkg_name, pkg_name_columns)

def test_ignore_packages_and_version(self) -> None:
# Fictitious version that does not exist
ignore_pkg_name = "prettytable"
ignore_pkg_spec = ignore_pkg_name + ":1.99.99"
ignore_packages_args = [
"--ignore-package=" + ignore_pkg_spec,
"--with-system",
]
args = self.parser.parse_args(ignore_packages_args)
table = create_licenses_table(args)

pkg_name_columns = self._create_pkg_name_columns(table)
# It is expected that prettytable will include
self.assertIn(ignore_pkg_name, pkg_name_columns)

def test_with_packages(self) -> None:
pkg_name = "py"
only_packages_args = ["--packages=" + pkg_name]
Expand All @@ -415,10 +443,7 @@ def test_with_packages(self) -> None:
self.assertListEqual([pkg_name], pkg_name_columns)

def test_with_packages_with_system(self) -> None:
if "PTable" in SYSTEM_PACKAGES:
pkg_name = "PTable"
else:
pkg_name = "prettytable"
pkg_name = "prettytable"
only_packages_args = ["--packages=" + pkg_name, "--with-system"]
args = self.parser.parse_args(only_packages_args)
table = create_licenses_table(args)
Expand Down Expand Up @@ -447,6 +472,13 @@ def test_order_author(self) -> None:
sortby = get_sortby(args)
self.assertEqual("Author", sortby)

def test_order_maintainer(self) -> None:
order_maintainer_args = ["--order=maintainer", "--with-maintainers"]
args = self.parser.parse_args(order_maintainer_args)

sortby = get_sortby(args)
self.assertEqual("Maintainer", sortby)

def test_order_url(self) -> None:
order_url_args = ["--order=url", "--with-urls"]
args = self.parser.parse_args(order_url_args)
Expand Down Expand Up @@ -875,3 +907,56 @@ def test_verify_args(
capture = capsys.readouterr().err
for arg in ("invalid code", "--filter-code-page"):
assert arg in capture


def test_extract_homepage_home_page_set() -> None:
metadata = MagicMock()
metadata.get.return_value = "Foobar"

assert "Foobar" == extract_homepage(metadata=metadata) # type: ignore

metadata.get.assert_called_once_with("home-page", None)


def test_extract_homepage_project_url_fallback() -> None:
metadata = MagicMock()
metadata.get.return_value = None

# `Homepage` is prioritized higher than `Source`
metadata.get_all.return_value = [
"Source, source",
"Homepage, homepage",
]

assert "homepage" == extract_homepage(metadata=metadata) # type: ignore

metadata.get_all.assert_called_once_with("Project-URL", [])


def test_extract_homepage_project_url_fallback_multiple_parts() -> None:
metadata = MagicMock()
metadata.get.return_value = None

# `Homepage` is prioritized higher than `Source`
metadata.get_all.return_value = [
"Source, source",
"Homepage, homepage, foo, bar",
]

assert "homepage, foo, bar" == extract_homepage(
metadata=metadata # type: ignore
)

metadata.get_all.assert_called_once_with("Project-URL", [])


def test_extract_homepage_empty() -> None:
metadata = MagicMock()

metadata.get.return_value = None
metadata.get_all.return_value = []

assert None is extract_homepage(metadata=metadata) # type: ignore

metadata.get.assert_called_once_with("home-page", None)
metadata.get_all.assert_called_once_with("Project-URL", [])

0 comments on commit ca3160d

Please sign in to comment.