Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix pre-commit hook when called with multiple files #41

Merged
merged 9 commits into from
May 17, 2022
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 1 addition & 1 deletion .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
9 changes: 8 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============
Expand Down
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
====

Expand All @@ -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/
Expand Down
83 changes: 68 additions & 15 deletions src/validate_pyproject/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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)",
),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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
4 changes: 2 additions & 2 deletions src/validate_pyproject/extra_validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/validate_pyproject/pre_compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def run(args: Sequence[str] = ()):
return 0


main = cli.exceptisons2exit()(run)
main = cli.exceptions2exit()(run)


if __name__ == "__main__":
Expand Down
47 changes: 39 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from pathlib import Path
from uuid import uuid4

import pytest
from validate_pyproject._vendor.fastjsonschema import JsonSchemaValueException
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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