diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5faa971..8cabb1e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,3 +51,14 @@ repos: hooks: - id: flake8 additional_dependencies: [flake8-bugbear] + + +- repo: local # self-test for `validate-pyproject` hook + hooks: + - id: validate-pyproject + name: Validate pyproject.toml + language: python + files: ^tests/examples/pretend-setuptools/07-pyproject.toml$ + entry: validate-pyproject + additional_dependencies: + - validate-pyproject[all] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 8d2c324..81cb137 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -4,7 +4,7 @@ description: Validation library for a simple check on pyproject.toml, including optional dependencies language: python - files: pyproject.toml + files: ^pyproject.toml$ entry: validate-pyproject additional_dependencies: - .[all] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 35e77b5..9b631ea 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,11 +2,18 @@ Changelog ========= +Version 0.8 +=========== + +- New :pypi:`pre-commit` hook, #40 +- Allow multiple TOML files to be validated at once via **CLI** + (*no changes regarding the Python API*). + Version 0.7.2 ============= - ``setuptools`` plugin: - - Allow ``dependencies``/``optional-dependencies`` to use file directives (#37) + - Allow ``dependencies``/``optional-dependencies`` to use file directives, #37 Version 0.7.1 ============= diff --git a/README.rst b/README.rst index 4a1a2f6..fb926e9 100644 --- a/README.rst +++ b/README.rst @@ -150,6 +150,12 @@ pre-commit hooks: - id: validate-pyproject +By default, this ``pre-commit`` hook will only validate the ``pyproject.toml`` +file at the root of the project repository. +You can customize that by defining a `custom regular expression pattern`_ using +the ``files`` parameter. + + Note ==== @@ -164,6 +170,7 @@ For details and usage information on PyScaffold see https://pyscaffold.org/. .. _contribution guides: https://validate-pyproject.readthedocs.io/en/latest/contributing.html +.. _custom regular expression pattern: https://pre-commit.com/#regular-expressions .. _our docs: https://validate-pyproject.readthedocs.io .. _ini2toml: https://ini2toml.readthedocs.io .. _JSON Schema: https://json-schema.org/ diff --git a/src/validate_pyproject/cli.py b/src/validate_pyproject/cli.py index bd163cb..74d4b75 100644 --- a/src/validate_pyproject/cli.py +++ b/src/validate_pyproject/cli.py @@ -10,7 +10,17 @@ from contextlib import contextmanager from itertools import chain from textwrap import dedent, wrap -from typing import Callable, Dict, List, NamedTuple, Sequence, Type, TypeVar +from typing import ( + Callable, + Dict, + Iterator, + List, + NamedTuple, + Sequence, + Tuple, + Type, + TypeVar, +) from . import __version__ from .api import Validator @@ -23,13 +33,16 @@ try: - from tomli import loads + from tomli import TOMLDecodeError, loads except ImportError: # pragma: no cover try: + from toml import TomlDecodeError as TOMLDecodeError # type: ignore from toml import loads # type: ignore except ImportError as ex: raise ImportError("Please install a TOML parser (e.g. `tomli`)") from ex +_REGULAR_EXCEPTIONS = (ValidationError, TOMLDecodeError) + @contextmanager def critical_logging(): @@ -50,8 +63,8 @@ def critical_logging(): ), "input_file": dict( dest="input_file", - nargs="?", - default="-", + nargs="*", + default=[argparse.FileType("r")("-")], type=argparse.FileType("r"), help="TOML file to be verified (`stdin` by default)", ), @@ -94,7 +107,7 @@ def critical_logging(): class CliParams(NamedTuple): - input_file: io.TextIOBase + input_file: List[io.TextIOBase] plugins: List[PluginWrapper] loglevel: int = logging.WARNING dump_json: bool = False @@ -164,13 +177,18 @@ def setup_logging(loglevel: int): @contextmanager -def exceptisons2exit(): +def exceptions2exit(): try: yield - except ValidationError as ex: + except _ExceptionGroup as group: + for prefix, ex in group: + print(prefix) + _logger.error(str(ex) + "\n") + raise SystemExit(1) + except _REGULAR_EXCEPTIONS as ex: _logger.error(str(ex)) raise SystemExit(1) - except Exception as ex: + except Exception as ex: # pragma: no cover _logger.error(f"{ex.__class__.__name__}: {ex}\n") _logger.debug("Please check the following information:", exc_info=True) raise SystemExit(1) @@ -191,16 +209,25 @@ def run(args: Sequence[str] = ()): params: CliParams = parse_args(args, plugins) setup_logging(params.loglevel) validator = Validator(plugins=params.plugins) - toml_equivalent = loads(params.input_file.read()) - validator(toml_equivalent) - if params.dump_json: - print(json.dumps(toml_equivalent, indent=2)) - else: - print("Valid file") + + exceptions = _ExceptionGroup() + for file in params.input_file: + try: + toml_equivalent = loads(file.read()) + validator(toml_equivalent) + if params.dump_json: + print(json.dumps(toml_equivalent, indent=2)) + else: + print(f"Valid {_format_file(file)}") + except _REGULAR_EXCEPTIONS as ex: + exceptions.add(f"Invalid {_format_file(file)}", ex) + + exceptions.raise_if_any() + return 0 -main = exceptisons2exit()(run) +main = exceptions2exit()(run) class Formatter(argparse.RawTextHelpFormatter): @@ -226,3 +253,29 @@ def _format_plugin_help(plugin: PluginWrapper) -> str: help_text = plugin.help_text help_text = f": {_flatten_str(help_text)}" if help_text else "" return f'* "{plugin.tool}"{help_text}' + + +def _format_file(file: io.TextIOBase) -> str: + if hasattr(file, "name") and file.name: # type: ignore[attr-defined] + return f"file: {file.name}" # type: ignore[attr-defined] + return "file" # pragma: no cover + + +class _ExceptionGroup(Exception): + def __init__(self): + self._members: List[Tuple[str, Exception]] = [] + super().__init__() + + def add(self, prefix: str, ex: Exception): + self._members.append((prefix, ex)) + + def __iter__(self) -> Iterator[Tuple[str, Exception]]: + return iter(self._members) + + def raise_if_any(self): + number = len(self._members) + if number == 1: + print(self._members[0][0]) + raise self._members[0][1] + if number > 0: + raise self diff --git a/src/validate_pyproject/extra_validations.py b/src/validate_pyproject/extra_validations.py index 11fa8f2..4130a42 100644 --- a/src/validate_pyproject/extra_validations.py +++ b/src/validate_pyproject/extra_validations.py @@ -5,12 +5,12 @@ from typing import Mapping, TypeVar -from ._vendor.fastjsonschema import JsonSchemaValueException +from .error_reporting import ValidationError T = TypeVar("T", bound=Mapping) -class RedefiningStaticFieldAsDynamic(JsonSchemaValueException): +class RedefiningStaticFieldAsDynamic(ValidationError): """According to PEP 621: Build back-ends MUST raise an error if the metadata specifies a field diff --git a/src/validate_pyproject/pre_compile/cli.py b/src/validate_pyproject/pre_compile/cli.py index cb11769..fc526e6 100644 --- a/src/validate_pyproject/pre_compile/cli.py +++ b/src/validate_pyproject/pre_compile/cli.py @@ -83,7 +83,7 @@ def run(args: Sequence[str] = ()): return 0 -main = cli.exceptisons2exit()(run) +main = cli.exceptions2exit()(run) if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e5467f..3b4e5de 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from uuid import uuid4 import pytest from validate_pyproject._vendor.fastjsonschema import JsonSchemaValueException @@ -46,23 +47,25 @@ def parse_args(args): """ -def write_example(dir_path, text): - path = Path(dir_path, "pyproject.toml") - path.write_text(text, "UTF-8") +def write_example(dir_path, *, name="pyproject.toml", _text=simple_example): + path = Path(dir_path, name) + path.write_text(_text, "UTF-8") return path +def write_invalid_example(dir_path, *, name="pyproject.toml"): + text = simple_example.replace("zip-safe = false", "zip-safe = { hello = 'world' }") + return write_example(dir_path, name=name, _text=text) + + @pytest.fixture def valid_example(tmp_path): - return write_example(tmp_path, simple_example) + return write_example(tmp_path) @pytest.fixture def invalid_example(tmp_path): - example = simple_example.replace( - "zip-safe = false", "zip-safe = { hello = 'world' }" - ) - return write_example(tmp_path, example) + return write_invalid_example(tmp_path) class TestEnable: @@ -130,3 +133,31 @@ def test_invalid(self, caplog, invalid_example): assert "offending rule" in captured assert "given value" in captured assert '"type": "boolean"' in captured + + +def test_multiple_files(tmp_path, capsys): + N = 3 + + valid_files = [ + write_example(tmp_path, name=f"valid-pyproject{i}.toml") for i in range(N) + ] + cli.run(map(str, valid_files)) + captured = capsys.readouterr().out.lower() + number_valid = captured.count("valid file:") + assert number_valid == N + + invalid_files = [ + write_invalid_example(tmp_path, name=f"invalid-pyproject{i}.toml") + for i in range(N + 3) + ] + with pytest.raises(SystemExit): + cli.main(map(str, valid_files + invalid_files)) + + repl = str(uuid4()) + captured = capsys.readouterr().out.lower() + captured = captured.replace("invalid file:", repl) + number_invalid = captured.count(repl) + number_valid = captured.count("valid file:") + captured = captured.replace(repl, "invalid file:") + assert number_valid == N + assert number_invalid == N + 3