diff --git a/README.md b/README.md index 0c5a795..cbb91b5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Dump the software license list of Python packages installed with pip. * [Option: fail\-on](#option-fail-on) * [Option: allow\-only](#option-allow-only) * [Option: partial\-match](#option-partial-match) + * [pyproject.toml support](#pyproject-toml-support) * [More Information](#more-information) * [Dockerfile](#dockerfile) * [About UnicodeEncodeError](#about-unicodeencodeerror) @@ -589,6 +590,25 @@ $ echo $? 0 ``` +### pyproject.toml support + +All command-line options for `pip-licenses` can be configured using the `pyproject.toml` file under the `[tool.pip-licenses]` section. +The `pyproject.toml` file is searched in the directory where the `pip-licenses` script is executed. +Command-line options specified during execution will override the corresponding options in `pyproject.toml`. + +Example `pyproject.toml` configuration: + +```toml +[tool.pip-licences] +from = "classifier" +ignore-packages = [ + "scipy" +] +fail-on = "MIT;" +``` + +If you run `pip-licenses` without any command-line options, all options will be taken from the `pyproject.toml` file. +For instance, if you run `pip-licenses --from=mixed`, the `from` option will be overridden to `mixed`, while all other options will be sourced from `pyproject.toml`. ### More Information @@ -664,6 +684,8 @@ See useful reports: * [prettytable](https://pypi.org/project/prettytable/) by Luke Maurits and maintainer of fork version Jazzband team under the BSD-3-Clause License * **Note:** This package implicitly requires [wcwidth](https://pypi.org/project/wcwidth/). +* [tomli](https://pypi.org/project/tomli/) by Taneli Hukkinen under the MIT License + `pip-licenses` has been implemented in the policy to minimize the dependence on external package. ## Uninstallation diff --git a/dev-requirements.in b/dev-requirements.in index 581f708..8be0ac4 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -12,3 +12,4 @@ pytest-cov pytest-pycodestyle pytest-runner twine +tomli-w \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt index fcc473d..555fad7 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -130,6 +130,8 @@ tomli==2.0.1 # mypy # pep517 # pytest +tomli-w==1.0.0 + # via -r dev-requirements.in twine==4.0.2 # via -r dev-requirements.in typing-extensions==4.10.0 diff --git a/piplicenses.py b/piplicenses.py index aa47726..f8fe306 100755 --- a/piplicenses.py +++ b/piplicenses.py @@ -42,6 +42,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Iterable, List, Type, cast +import tomli from prettytable import ALL as RULE_ALL from prettytable import FRAME as RULE_FRAME from prettytable import HEADER as RULE_HEADER @@ -878,6 +879,12 @@ def choices_from_enum(enum_cls: Type[NoValueEnum]) -> List[str]: ] +def get_value_from_enum( + enum_cls: Type[NoValueEnum], value: str +) -> NoValueEnum: + return getattr(enum_cls, value_to_enum_key(value)) + + MAP_DEST_TO_ENUM = { "from_": FromArg, "order": OrderArg, @@ -894,15 +901,25 @@ def __call__( # type: ignore[override] option_string: Optional[str] = None, ) -> None: enum_cls = MAP_DEST_TO_ENUM[self.dest] - values = value_to_enum_key(values) - setattr(namespace, self.dest, getattr(enum_cls, values)) + setattr(namespace, self.dest, get_value_from_enum(enum_cls, values)) + +def load_config_from_file(pyproject_path: str): + if Path(pyproject_path).exists(): + with open(pyproject_path, "rb") as f: + return tomli.load(f).get("tool", {}).get(__pkgname__, {}) + return {} -def create_parser() -> CompatibleArgumentParser: + +def create_parser( + pyproject_path: str = "pyproject.toml", +) -> CompatibleArgumentParser: parser = CompatibleArgumentParser( description=__summary__, formatter_class=CustomHelpFormatter ) + config_from_file = load_config_from_file(pyproject_path) + common_options = parser.add_argument_group("Common options") format_options = parser.add_argument_group("Format options") verify_options = parser.add_argument_group("Verify options") @@ -914,7 +931,7 @@ def create_parser() -> CompatibleArgumentParser: common_options.add_argument( "--python", type=str, - default=sys.executable, + default=config_from_file.get("python", 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" @@ -927,7 +944,9 @@ def create_parser() -> CompatibleArgumentParser: dest="from_", action=SelectAction, type=str, - default=FromArg.MIXED, + default=get_value_from_enum( + FromArg, config_from_file.get("from", "mixed") + ), metavar="SOURCE", choices=choices_from_enum(FromArg), help="R|where to find license information\n" @@ -939,7 +958,9 @@ def create_parser() -> CompatibleArgumentParser: "--order", action=SelectAction, type=str, - default=OrderArg.NAME, + default=get_value_from_enum( + OrderArg, config_from_file.get("order", "name") + ), metavar="COL", choices=choices_from_enum(OrderArg), help="R|order by column\n" @@ -952,7 +973,9 @@ def create_parser() -> CompatibleArgumentParser: dest="format_", action=SelectAction, type=str, - default=FormatArg.PLAIN, + default=get_value_from_enum( + FormatArg, config_from_file.get("format", "plain") + ), metavar="STYLE", choices=choices_from_enum(FormatArg), help="R|dump as set format style\n" @@ -964,12 +987,13 @@ def create_parser() -> CompatibleArgumentParser: common_options.add_argument( "--summary", action="store_true", - default=False, + default=config_from_file.get("summary", False), help="dump summary of each license", ) common_options.add_argument( "--output-file", action="store", + default=config_from_file.get("output-file"), type=str, help="save license list to file", ) @@ -980,7 +1004,7 @@ def create_parser() -> CompatibleArgumentParser: type=str, nargs="+", metavar="PKG", - default=[], + default=config_from_file.get("ignore-packages", []), help="ignore package name in dumped list", ) common_options.add_argument( @@ -990,83 +1014,83 @@ def create_parser() -> CompatibleArgumentParser: type=str, nargs="+", metavar="PKG", - default=[], + default=config_from_file.get("packages", []), help="only include selected packages in output", ) format_options.add_argument( "-s", "--with-system", action="store_true", - default=False, + default=config_from_file.get("with-system", False), help="dump with system packages", ) format_options.add_argument( "-a", "--with-authors", action="store_true", - default=False, + default=config_from_file.get("with-authors", False), help="dump with package authors", ) format_options.add_argument( "--with-maintainers", action="store_true", - default=False, + default=config_from_file.get("with-maintainers", False), help="dump with package maintainers", ) format_options.add_argument( "-u", "--with-urls", action="store_true", - default=False, + default=config_from_file.get("with-urls", False), help="dump with package urls", ) format_options.add_argument( "-d", "--with-description", action="store_true", - default=False, + default=config_from_file.get("with-description", False), help="dump with short package description", ) format_options.add_argument( "-nv", "--no-version", action="store_true", - default=False, + default=config_from_file.get("no-version", False), help="dump without package version", ) format_options.add_argument( "-l", "--with-license-file", action="store_true", - default=False, + default=config_from_file.get("with-license-file", 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, + default=config_from_file.get("no-license-path", 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, + default=config_from_file.get("with-notice-file", 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, + default=config_from_file.get("filter-strings", False), help="filter input according to code page", ) format_options.add_argument( "--filter-code-page", action="store", type=str, - default="latin1", + default=config_from_file.get("filter-code-page", "latin1"), metavar="CODE", help="I|specify code page for filtering " "(default: %(default)s)", ) @@ -1075,7 +1099,7 @@ def create_parser() -> CompatibleArgumentParser: "--fail-on", action="store", type=str, - default=None, + default=config_from_file.get("fail-on", None), help="fail (exit with code 1) on the first occurrence " "of the licenses of the semicolon-separated list", ) @@ -1083,14 +1107,14 @@ def create_parser() -> CompatibleArgumentParser: "--allow-only", action="store", type=str, - default=None, + default=config_from_file.get("allow-only", None), help="fail (exit with code 1) on the first occurrence " "of the licenses not in the semicolon-separated list", ) verify_options.add_argument( "--partial-match", action="store_true", - default=False, + default=config_from_file.get("partial-match", False), help="enables partial matching for --allow-only/--fail-on", ) diff --git a/requirements.in b/requirements.in index deb2d14..d9b739a 100644 --- a/requirements.in +++ b/requirements.in @@ -1 +1,2 @@ prettytable +tomli diff --git a/requirements.txt b/requirements.txt index d22c123..39aef50 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,7 @@ # prettytable==3.9.0 # via -r requirements.in +tomli==2.0.1 + # via -r requirements.in wcwidth==0.2.13 # via prettytable diff --git a/setup.cfg b/setup.cfg index de410df..c5f8620 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ setup_requires = pytest-runner install_requires = prettytable >= 2.3.0 + tomli >= 2 tests_require = docutils mypy @@ -43,6 +44,7 @@ test = pytest-cov pytest-pycodestyle pytest-runner + tomli-w [bdist_wheel] universal = 0 diff --git a/test_piplicenses.py b/test_piplicenses.py index 34ff75f..ca5623b 100644 --- a/test_piplicenses.py +++ b/test_piplicenses.py @@ -4,9 +4,10 @@ import copy import email -import json +import os import re import sys +import tempfile import unittest import venv from enum import Enum, auto @@ -19,6 +20,7 @@ import docutils.parsers.rst import docutils.utils import pytest +import tomli_w from _pytest.capture import CaptureFixture import piplicenses @@ -1108,3 +1110,53 @@ def test_extract_homepage_project_uprl_fallback_capitalisation() -> None: assert "homepage" == extract_homepage(metadata=metadata) # type: ignore metadata.get_all.assert_called_once_with("Project-URL", []) + + +def test_pyproject_toml_args_parsed_correctly(): + # we test that parameters of different types are deserialized correctly + pyptoject_conf = { + "tool": { + __pkgname__: { + # choices_from_enum + "from": "classifier", + # bool + "summary": True, + # list[str] + "ignore-packages": ["package1", "package2"], + # str + "fail-on": "LIC1;LIC2", + } + } + } + + toml_str = tomli_w.dumps(pyptoject_conf) + + # Create a temporary file and write the TOML string to it + with tempfile.NamedTemporaryFile( + suffix=".toml", delete=False + ) as temp_file: + temp_file.write(toml_str.encode("utf-8")) + + parser = create_parser(temp_file.name) + args = parser.parse_args([]) + + tool_conf = pyptoject_conf["tool"][__pkgname__] + + # assert values are correctly parsed from toml + assert args.from_ == FromArg.CLASSIFIER + assert args.summary == tool_conf["summary"] + assert args.ignore_packages == tool_conf["ignore-packages"] + assert args.fail_on == tool_conf["fail-on"] + + # assert args are rewritable using cli + args = parser.parse_args(["--from=meta"]) + + assert args.from_ != FromArg.CLASSIFIER + assert args.from_ == FromArg.META + + # all other are parsed from toml + assert args.summary == tool_conf["summary"] + assert args.ignore_packages == tool_conf["ignore-packages"] + assert args.fail_on == tool_conf["fail-on"] + + os.unlink(temp_file.name)