Skip to content

Commit

Permalink
Merge 5639625 into 33def2a
Browse files Browse the repository at this point in the history
  • Loading branch information
orsinium committed May 9, 2020
2 parents 33def2a + 5639625 commit 8a50813
Show file tree
Hide file tree
Showing 13 changed files with 237 additions and 77 deletions.
1 change: 1 addition & 0 deletions deal/linter/_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@


class Category(enum.Enum):
PRE = 'pre'
POST = 'post'
RAISES = 'raises'
SILENT = 'silent'
Expand Down
2 changes: 2 additions & 0 deletions deal/linter/_extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .exceptions_stubs import get_exceptions_stubs
from .globals import get_globals
from .imports import get_imports
from .pre import get_pre
from .prints import get_prints
from .returns import get_returns, has_returns
from .value import get_value
Expand All @@ -19,6 +20,7 @@
'get_globals',
'get_imports',
'get_name',
'get_pre',
'get_prints',
'get_returns',
'get_value',
Expand Down
18 changes: 15 additions & 3 deletions deal/linter/_extractors/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

TOKENS = SimpleNamespace(
ASSERT=(ast.Assert, astroid.Assert),
ASSIGN=(ast.Assign, astroid.Assign),
ATTR=(ast.Attribute, astroid.Attribute),
BIN_OP=(ast.BinOp, astroid.BinOp),
CALL=(ast.Call, astroid.Call),
Expand All @@ -23,7 +24,6 @@
NONLOCAL=(ast.Nonlocal, astroid.Nonlocal),
RAISE=(ast.Raise, astroid.Raise),
RETURN=(ast.Return, astroid.Return),
# TRY=(ast.Try, astroid.TryExcept, astroid.TryFinally),
UNARY_OP=(ast.UnaryOp, astroid.UnaryOp),
WITH=(ast.With, astroid.With),
YIELD=(ast.Yield, astroid.Yield),
Expand All @@ -38,15 +38,17 @@ class Token(NamedTuple):

def traverse(body: List) -> Iterator:
for expr in body:
# breaking apart
# breaking apart statements
if isinstance(expr, TOKENS.EXPR):
yield expr.value
yield from _travers_expr(expr=expr.value)
continue
if isinstance(expr, TOKENS.IF + TOKENS.FOR):
yield from traverse(body=expr.body)
yield from traverse(body=expr.orelse)
continue

# breaking apart try-except
if isinstance(expr, (ast.Try, astroid.TryExcept)):
for handler in expr.handlers:
yield from traverse(body=handler.body)
Expand All @@ -58,11 +60,19 @@ def traverse(body: List) -> Iterator:
# extracting things
if isinstance(expr, TOKENS.WITH):
yield from traverse(body=expr.body)
if isinstance(expr, TOKENS.RETURN):
elif isinstance(expr, TOKENS.RETURN + TOKENS.ASSIGN):
yield expr.value
yield expr


def _travers_expr(expr):
if isinstance(expr, TOKENS.CALL):
for subnode in expr.args:
yield from traverse(body=[subnode])
for subnode in (expr.keywords or ()):
yield from traverse(body=[subnode.value])


def get_name(expr) -> Optional[str]:
if isinstance(expr, ast.Name):
return expr.id
Expand All @@ -84,6 +94,8 @@ def get_name(expr) -> Optional[str]:


def infer(expr) -> Tuple:
if not isinstance(expr, astroid.node_classes.NodeNG):
return tuple()
with suppress(astroid.exceptions.InferenceError, RecursionError):
return tuple(g for g in expr.infer() if type(g) is not astroid.Uninferable)
return tuple()
Expand Down
2 changes: 1 addition & 1 deletion deal/linter/_extractors/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .common import TOKENS, get_name


SUPPORTED_CONTRACTS = {'deal.post', 'deal.raises', 'deal.silent', 'deal.pure'}
SUPPORTED_CONTRACTS = {'deal.pre', 'deal.post', 'deal.raises', 'deal.silent', 'deal.pure'}
SUPPORTED_MARKERS = {'deal.silent', 'deal.pure'}


Expand Down
60 changes: 16 additions & 44 deletions deal/linter/_extractors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,56 +70,28 @@ def handle_call(expr, dive: bool = True) -> Optional[Union[Token, Iterator[Token
return Token(value=SystemExit, **token_info)
# infer function call and check the function body for raises
if dive:
return _exceptions_from_funcs(expr=expr)
return _exceptions_from_func(expr=expr)
return None


@get_exceptions.register(astroid.Assign)
def handle_assign(expr: astroid.Assign, dive: bool = True) -> Iterator[Token]:
# infer function call and check the function body for raises
if dive:
yield from _exceptions_from_funcs(expr=expr)


def _exceptions_from_funcs(expr) -> Iterator[Token]:
for name_node in get_names(expr):
# node have to be a name
if type(name_node) is not astroid.Name:
def _exceptions_from_func(expr) -> Iterator[Token]:
for value in infer(expr.func):
if type(value) is not astroid.FunctionDef:
continue

extra = dict(
line=name_node.lineno,
col=name_node.col_offset,
)
for value in infer(name_node):
if type(value) is not astroid.FunctionDef:
continue
# recursively infer exceptions from the function body
for error in get_exceptions(body=value.body, dive=False):
yield Token(value=error.value, line=expr.lineno, col=expr.col_offset)

# recursively infer exceptions from the function body
for error in get_exceptions(body=value.body, dive=False):
yield Token(value=error.value, **extra)

# get explicitly specified exceptions from `@deal.raises`
if not value.decorators:
# get explicitly specified exceptions from `@deal.raises`
if not value.decorators:
continue
for category, args in get_contracts(value.decorators.nodes):
if category != 'raises':
continue
for category, args in get_contracts(value.decorators.nodes):
if category != 'raises':
for arg in args:
name = get_name(arg)
if name is None:
continue
for arg in args:
name = get_name(arg)
if name is None:
continue
yield Token(value=name, **extra)
yield Token(value=name, line=expr.lineno, col=expr.col_offset)
return None


def get_names(expr) -> Iterator:
if isinstance(expr, astroid.Assign):
yield from get_names(expr.value)
if isinstance(expr, TOKENS.CALL):
if isinstance(expr.func, TOKENS.NAME):
yield expr.func
for subnode in expr.args:
yield from get_names(subnode)
for subnode in (expr.keywords or ()):
yield from get_names(subnode.value)
67 changes: 67 additions & 0 deletions deal/linter/_extractors/pre.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# built-in
import ast
from typing import Iterator

# external
import astroid

# app
from .common import Extractor, Token, infer
from .contracts import get_contracts
from .value import UNKNOWN, get_value


get_pre = Extractor()


@get_pre.register(astroid.Call)
def handle_call(expr: astroid.Call) -> Iterator[Token]:
from .._contract import Contract, Category

args = []
for subnode in expr.args:
value = get_value(expr=subnode)
if value is UNKNOWN:
return
args.append(value)

kwargs = {}
for subnode in (expr.keywords or ()):
value = get_value(expr=subnode.value)
if value is UNKNOWN:
return
kwargs[subnode.arg] = value

for func in infer(expr.func):
if type(func) is not astroid.FunctionDef:
continue
if not func.decorators:
continue
code = 'def f({}):0'.format(func.args.as_string())
func_args = ast.parse(code).body[0].args # type: ignore

for category, contract_args in get_contracts(func.decorators.nodes):
if category != 'pre':
continue

contract = Contract(
args=contract_args,
category=Category.PRE,
func_args=func_args,
)
try:
result = contract.run(*args, **kwargs)
except NameError:
continue
if result is False or type(result) is str:
msg = _format_message(args, kwargs)
yield Token(value=msg, line=expr.lineno, col=expr.col_offset)


def _format_message(args: list, kwargs: dict) -> str:
sep = ', '
args_s = sep.join(map(repr, args))
kwargs_s = sep.join(['{}={!r}'.format(k, v) for k, v in kwargs.items()])
if args and kwargs:
return args_s + sep + kwargs_s
return args_s + kwargs_s
8 changes: 5 additions & 3 deletions deal/linter/_extractors/value.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ def get_value(expr):
return ast.literal_eval(expr)

if isinstance(expr, astroid.node_classes.NodeNG):
renderred = expr.as_string()
with suppress(ValueError, SyntaxError):
return ast.literal_eval(renderred)
# AttributeError: 'AsStringVisitor3' object has no attribute 'visit_unknown'
with suppress(AttributeError): # pragma: no cover
renderred = expr.as_string()
with suppress(ValueError, SyntaxError):
return ast.literal_eval(renderred)

handler = inner_extractor.handlers.get(type(expr))
if handler:
Expand Down
30 changes: 24 additions & 6 deletions deal/linter/_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from ._error import Error
from ._extractors import (
get_asserts, get_exceptions, get_exceptions_stubs, get_globals,
get_imports, get_prints, get_returns, has_returns,
get_imports, get_pre, get_prints, get_returns, has_returns,
)
from ._func import Func
from ._stub import StubsManager
Expand Down Expand Up @@ -48,9 +48,27 @@ def __call__(self, tree: ast.Module) -> Iterator[Error]:


@register
class CheckReturns:
class CheckPre:
__slots__ = ()
code = 11
message = 'pre contract error'
required = Required.FUNC

def __call__(self, func: Func, stubs: StubsManager = None) -> Iterator[Error]:
for token in get_pre(body=func.body):
yield Error(
code=self.code,
text=self.message,
value=token.value, # type: ignore
row=token.line,
col=token.col,
)


@register
class CheckReturns:
__slots__ = ()
code = 12
message = 'post contract error'
required = Required.FUNC

Expand Down Expand Up @@ -84,7 +102,7 @@ def _check(self, func: Func, contract: Contract) -> Iterator[Error]:
@register
class CheckRaises:
__slots__ = ()
code = 12
code = 21
message = 'raises contract error'
required = Required.FUNC

Expand Down Expand Up @@ -120,7 +138,7 @@ def _check(self, func: Func, contract: Contract, stubs: StubsManager = None) ->
@register
class CheckPrints:
__slots__ = ()
code = 13
code = 22
message = 'silent contract error'
required = Required.FUNC

Expand All @@ -146,7 +164,7 @@ def _check(self, func: Func, stubs: StubsManager = None) -> Iterator[Error]:
@register
class CheckPure:
__slots__ = ()
code = 14
code = 23
message = 'pure contract error'
required = Required.FUNC

Expand Down Expand Up @@ -179,7 +197,7 @@ def _check(self, func: Func, stubs: StubsManager = None) -> Iterator[Error]:
@register
class CheckAsserts:
__slots__ = ()
code = 15
code = 31
message = 'assert error'
required = Required.FUNC

Expand Down
11 changes: 6 additions & 5 deletions docs/commands/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Another option is to use built-in CLI from deal: `python3 -m deal lint`. I has b
| Code | Message |
| ------- | --------------------- |
| DEAL001 | do not use `from deal import ...`, use `import deal` instead |
| DEAL011 | post contract error |
| DEAL012 | raises contract error |
| DEAL013 | silent contract error |
| DEAL014 | pure contract error |
| DEAL015 | assert error |
| DEAL011 | pre contract error |
| DEAL012 | post contract error |
| DEAL021 | raises contract error |
| DEAL022 | silent contract error |
| DEAL023 | pure contract error |
| DEAL031 | assert error |
2 changes: 1 addition & 1 deletion tests/test_cli/test_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ 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]['code'] == 12
assert errors[0]['content'] == ' return -1'


Expand Down
6 changes: 3 additions & 3 deletions tests/test_linter/test_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ def test2():
""".strip()

EXPECTED = [
(6, 11, 'DEAL011 post contract error (-1)', Checker),
(11, 8, 'DEAL012 raises contract error (ZeroDivisionError)', Checker),
(13, 10, 'DEAL012 raises contract error (KeyError)', Checker),
(6, 11, 'DEAL012 post contract error (-1)', Checker),
(11, 8, 'DEAL021 raises contract error (ZeroDivisionError)', Checker),
(13, 10, 'DEAL021 raises contract error (KeyError)', Checker),
]


Expand Down

0 comments on commit 8a50813

Please sign in to comment.