Skip to content

Commit

Permalink
Merge 18a478b into 23dec48
Browse files Browse the repository at this point in the history
  • Loading branch information
orsinium committed Nov 12, 2019
2 parents 23dec48 + 18a478b commit 7a1bede
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 20 deletions.
Binary file modified assets/linter.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions deal/linter/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys

from ._cli import main

if __name__ == '__main__':
exit(main(sys.argv[1:]))
94 changes: 94 additions & 0 deletions deal/linter/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import ast
import json
from argparse import ArgumentParser
from pathlib import Path
from textwrap import dedent, indent
from typing import Iterable, Iterator, Union

from ._checker import Checker


COLORS = dict(
red='\033[91m',
green='\033[92m',
yellow='\033[93m',
blue='\033[94m',
magenta='\033[95m',
end='\033[0m',
)
TEMPLATE = ' {blue}{row}{end}:{blue}{col}{end} {yellow}{text}{end}'
POINTER = '{magenta}^{end}'


def get_paths(path: Path) -> Iterator[Path]:
"""Recursively yields python files.
"""
if not path.exists():
raise FileNotFoundError(str(path))
if path.is_file():
if path.suffix == '.py':
yield path
return
for subpath in path.iterdir():
if subpath.name[0] == '.':
continue
if subpath.name == '__pycache__':
continue
yield from get_paths(subpath)


def get_errors(paths: Iterable[Union[str, Path]]) -> Iterator[dict]:
for arg in paths:
for path in get_paths(Path(arg)):
content = path.read_text()
checker = Checker(
filename=str(path),
tree=ast.parse(content),
)
lines = content.split('\n')
for error in checker.get_errors():
yield dict(
path=str(path),
row=error.row,
col=error.col,
code=error.code,
text=error.text,
value=error.value,
content=lines[error.row - 1],
)


def get_parser() -> ArgumentParser:
parser = ArgumentParser()
parser.add_argument('--json', action='store_true', help='json output')
parser.add_argument('paths', nargs='*', default='.')
return parser


def main(argv: Iterable) -> int:
parser = get_parser()
args = parser.parse_args(argv)
prev = None
errors = list(get_errors(paths=args.paths))
for error in errors:
if args.json:
print(json.dumps(error))
continue

# print file path
if error['path'] != prev:
print('{green}{path}{end}'.format(**COLORS, **error))
prev = error['path']

# print message
line = TEMPLATE.format(**COLORS, **error)
if error['value']:
line += ' {magenta}({value}){end}'.format(**COLORS, **error)
print(line)

# print code line
pointer = ' ' * error['col'] + POINTER.format(**COLORS)
content = error['content'] + '\n' + pointer
content = indent(dedent(content), prefix=' ')
print(content)
return len(errors)
10 changes: 7 additions & 3 deletions deal/linter/_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@


class Error:
def __init__(self, row: int, col: int, code: int, text: str):
def __init__(self, *, row: int, col: int, code: int, text: str, value: str = None):
self.row = row
self.col = col
self.code = code
self.text = text
self.value = value

@property
def message(self) -> str:
return ERROR_FORMAT.format(code=self.code, text=self.text)
msg = ERROR_FORMAT.format(code=self.code, text=self.text)
if self.value:
msg += ' ({})'.format(self.value)
return msg

def __iter__(self) -> typing.Iterator[typing.Union[int, str]]:
yield self.row
Expand All @@ -22,7 +26,7 @@ def __iter__(self) -> typing.Iterator[typing.Union[int, str]]:
def __str__(self) -> str:
return self.message

def __repr__(self):
def __repr__(self) -> str:
return '{name}({content!r})'.format(
name=type(self).__name__,
content=self.__dict__,
Expand Down
3 changes: 3 additions & 0 deletions deal/linter/_extractors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,19 @@ def get_exceptions(body: list) -> Iterator[Token]:
if not name or name[0].islower():
continue
exc = getattr(builtins, name, name)
token_info['col'] = expr.exc.col_offset
yield Token(value=exc, **token_info)
continue

# division by zero
if isinstance(expr, TOKENS.BIN_OP):
if isinstance(expr.op, ast.Div) or expr.op == '/':
if isinstance(expr.right, astroid.Const) and expr.right.value == 0:
token_info['col'] = expr.right.col_offset
yield Token(value=ZeroDivisionError, **token_info)
continue
if isinstance(expr.right, ast.Num) and expr.right.n == 0:
token_info['col'] = expr.right.col_offset
yield Token(value=ZeroDivisionError, **token_info)
continue

Expand Down
2 changes: 1 addition & 1 deletion deal/linter/_extractors/returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

def get_returns(body: list) -> Iterator[Token]:
for expr in traverse(body):
token_info = dict(line=expr.lineno, col=expr.col_offset)
if not isinstance(expr, TOKENS.RETURN):
continue
token_info = dict(line=expr.lineno, col=expr.value.col_offset)

# any constant value in astroid
if isinstance(expr.value, astroid.Const):
Expand Down
17 changes: 12 additions & 5 deletions deal/linter/_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ def __call__(self, func: Func) -> Iterator[Error]:
# cannot resolve contract dependencies
return

error_info = dict(row=token.line, col=token.col, code=self.code)
error_info = dict(
row=token.line,
col=token.col,
code=self.code,
value=str(token.value),
)
if isinstance(result, str):
yield Error(text=result, **error_info)
continue
Expand All @@ -63,7 +68,7 @@ def __call__(self, func: Func) -> Iterator[Error]:
@register
class CheckRaises:
code = 12
message = 'raises contract error ({exc})'
message = 'raises contract error'
required = Required.FUNC

def __call__(self, func: Func) -> Iterator[Error]:
Expand All @@ -81,7 +86,8 @@ def __call__(self, func: Func) -> Iterator[Error]:
exc = exc.__name__
yield Error(
code=self.code,
text=self.message.format(exc=exc),
text=self.message,
value=exc,
row=token.line,
col=token.col,
)
Expand All @@ -90,7 +96,7 @@ def __call__(self, func: Func) -> Iterator[Error]:
@register
class CheckPrints:
code = 13
message = 'silent contract error ({func})'
message = 'silent contract error'
required = Required.FUNC

def __call__(self, func: Func) -> Iterator[Error]:
Expand All @@ -99,7 +105,8 @@ def __call__(self, func: Func) -> Iterator[Error]:
for token in get_prints(body=func.body):
yield Error(
code=self.code,
text=self.message.format(func=token.value),
text=self.message,
value=str(token.value),
row=token.line,
col=token.col,
)
2 changes: 2 additions & 0 deletions docs/linter.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Deal can do static checks for functions with contracts to catch trivial mistakes. Use [flake8](http://flake8.pycqa.org) or [flakehell](https://github.com/life4/flakehell) to run it.

Another option is to use built-in CLI from deal: `python3 -m deal.linter`. I has beautiful colored output by default. Use `--json` option to get compact JSON output. Pipe output into [jq](https://stedolan.github.io/jq/) to beautify JSON.

![](../assets/linter.png)

## Codes
Expand Down
12 changes: 6 additions & 6 deletions tests/test_linter/test_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ def test_stdin():
checker = Checker(tree=ast.parse(TEXT))
errors = list(checker.run())
expected = [
(6, 4, 'DEAL011: post contract error', Checker),
(11, 4, 'DEAL012: raises contract error (ZeroDivisionError)', Checker),
(13, 4, 'DEAL012: raises contract error (KeyError)', Checker),
(6, 11, 'DEAL011: post contract error (-1)', Checker),
(11, 8, 'DEAL012: raises contract error (ZeroDivisionError)', Checker),
(13, 10, 'DEAL012: raises contract error (KeyError)', Checker),
]
assert errors == expected

Expand All @@ -38,8 +38,8 @@ def test_astroid_path(tmp_path: Path):
checker = Checker(tree=ast.parse(TEXT), filename=str(path))
errors = list(checker.run())
expected = [
(6, 4, 'DEAL011: post contract error', Checker),
(11, 4, 'DEAL012: raises contract error (ZeroDivisionError)', Checker),
(13, 4, 'DEAL012: raises contract error (KeyError)', Checker),
(6, 11, 'DEAL011: post contract error (-1)', Checker),
(11, 8, 'DEAL012: raises contract error (ZeroDivisionError)', Checker),
(13, 10, 'DEAL012: raises contract error (KeyError)', Checker),
]
assert errors == expected
61 changes: 61 additions & 0 deletions tests/test_linter/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from pathlib import Path

import pytest

from deal.linter._cli import get_errors, get_paths, main


TEXT = """
import deal
@deal.post(lambda x: x > 0)
def f(x):
return -1
"""


def test_get_paths(tmp_path: Path):
(tmp_path / 'subdir').mkdir()
(tmp_path / 'subdir' / '__pycache__').mkdir()
(tmp_path / '.hidden').mkdir()

(tmp_path / 'setup.py').touch()
(tmp_path / 'subdir' / 'ex.py').touch()
(tmp_path / '.hidden' / 'ex.py').touch()
(tmp_path / 'subdir' / '__pycache__' / 'ex.py').touch()
(tmp_path / 'setup.pl').touch()
actual = {p.relative_to(tmp_path) for p in get_paths(tmp_path)}
assert actual == {Path('setup.py'), Path('subdir/ex.py')}

with pytest.raises(FileNotFoundError):
list(get_paths(tmp_path / 'not_exists'))


def test_get_errors(tmp_path: Path):
(tmp_path / 'example.py').write_text(TEXT)
errors = list(get_errors(paths=[tmp_path]))
assert len(errors) == 1
assert errors[0]['code'] == 11
assert errors[0]['content'] == ' return -1'


def test_main(tmp_path: Path, capsys):
(tmp_path / 'example.py').write_text(TEXT)
count = main([str(tmp_path)])
assert count == 1

captured = capsys.readouterr()
assert 'return -1' in captured.out
assert '(-1)' in captured.out
assert '^' in captured.out


def test_main_json(tmp_path: Path, capsys):
(tmp_path / 'example.py').write_text(TEXT)
count = main(['--json', str(tmp_path)])
assert count == 1

captured = capsys.readouterr()
assert '" return -1"' in captured.out
assert '"-1"' in captured.out
assert '^' not in captured.out
7 changes: 7 additions & 0 deletions tests/test_linter/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import subprocess
import sys


def test_cli_calling():
result = subprocess.run([sys.executable, '-m', 'deal.linter', __file__])
assert result.returncode == 0
10 changes: 5 additions & 5 deletions tests/test_linter/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test(a):
funcs2 = Func.from_astroid(astroid.parse(text))
for func in (funcs1[0], funcs2[0]):
actual = [tuple(err) for err in checker(func)]
expected = [(6, 8, 'DEAL011: post contract error')]
expected = [(6, 15, 'DEAL011: post contract error (-1)')]
assert actual == expected


Expand All @@ -41,7 +41,7 @@ def test(a):
funcs2 = Func.from_astroid(astroid.parse(text))
for func in (funcs1[0], funcs2[0]):
actual = [tuple(err) for err in checker(func)]
expected = [(6, 8, 'DEAL011: oh no!')]
expected = [(6, 15, 'DEAL011: oh no! (-1)')]
assert actual == expected


Expand Down Expand Up @@ -73,7 +73,7 @@ def test(a):
funcs2 = Func.from_astroid(astroid.parse(text))
for func in (funcs1[0], funcs2[0]):
actual = [tuple(err) for err in checker(func)]
expected = [(4, 4, 'DEAL012: raises contract error (KeyError)')]
expected = [(4, 10, 'DEAL012: raises contract error (KeyError)')]
assert actual == expected


Expand All @@ -89,7 +89,7 @@ def test(a):
funcs2 = Func.from_astroid(astroid.parse(text))
for func in (funcs1[0], funcs2[0]):
actual = [tuple(err) for err in checker(func)]
expected = [(3, 4, 'DEAL012: raises contract error (ValueError)')]
expected = [(3, 10, 'DEAL012: raises contract error (ValueError)')]
assert actual == expected


Expand All @@ -106,7 +106,7 @@ def test(a):
funcs2 = Func.from_astroid(astroid.parse(text))
for func in (funcs1[0], funcs2[0]):
actual = [tuple(err) for err in checker(func)]
expected = [(4, 4, 'DEAL012: raises contract error (ValueError)')]
expected = [(4, 10, 'DEAL012: raises contract error (ValueError)')]
assert actual == expected


Expand Down

0 comments on commit 7a1bede

Please sign in to comment.