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

Asserts #54

Merged
merged 6 commits into from
May 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions deal/linter/_extractors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# app
from .asserts import get_asserts
from .common import get_name
from .contracts import get_contracts
from .exceptions import get_exceptions
Expand All @@ -10,6 +11,7 @@


__all__ = [
'get_asserts',
'get_contracts',
'get_exceptions',
'get_exceptions_stubs',
Expand Down
66 changes: 66 additions & 0 deletions deal/linter/_extractors/asserts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# built-in
import ast
from typing import Optional

# external
import astroid

# app
from .common import TOKENS, Extractor, Token, infer


get_asserts = Extractor()
inner_extractor = Extractor()


@get_asserts.register(*TOKENS.ASSERT)
def handle_assert(expr) -> Optional[Token]:
handler = inner_extractor.handlers.get(type(expr.test))
if handler:
token = handler(expr=expr.test)
if token is not None:
return token

# astroid inference
if hasattr(expr.test, 'infer'):
for value in infer(expr.test):
if not isinstance(value, astroid.Const):
continue
if value.value:
continue
return Token(value=value.value, line=expr.lineno, col=expr.col_offset)
return None


# any constant value in astroid
@inner_extractor.register(astroid.Const)
def handle_const(expr: astroid.Const) -> Optional[Token]:
if expr.value:
return None
return Token(value=expr.value, line=expr.lineno, col=expr.col_offset)


# Python <3.8
# string, binary string
@inner_extractor.register(ast.Str, ast.Bytes)
def handle_str(expr) -> Optional[Token]:
if expr.s:
return None
return Token(value=expr.s, line=expr.lineno, col=expr.col_offset)


# Python <3.8
# True, False, None
@inner_extractor.register(ast.NameConstant)
def handle_name_constant(expr: ast.NameConstant) -> Optional[Token]:
if expr.value:
return None
return Token(value=expr.value, line=expr.lineno, col=expr.col_offset)


# positive number
@inner_extractor.register(ast.Num, getattr(ast, 'Constant', None))
def handle_num(expr) -> Optional[Token]:
if expr.n:
return None
return Token(value=expr.n, line=expr.lineno, col=expr.col_offset)
13 changes: 4 additions & 9 deletions deal/linter/_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ def from_ast(cls, tree: ast.Module) -> List['Func']:
for category, args in get_contracts(expr.decorator_list):
contract = Contract(args=args, category=Category(category))
contracts.append(contract)
if not contracts:
continue
funcs.append(cls(
name=expr.name,
body=expr.body,
Expand All @@ -61,14 +59,11 @@ def from_astroid(cls, tree: astroid.Module) -> List['Func']:
for expr in tree.body:
if not isinstance(expr, astroid.FunctionDef):
continue
if not expr.decorators:
continue
contracts = []
for category, args in get_contracts(expr.decorators.nodes):
contract = Contract(args=args, category=Category(category))
contracts.append(contract)
if not contracts:
continue
if expr.decorators:
for category, args in get_contracts(expr.decorators.nodes):
contract = Contract(args=args, category=Category(category))
contracts.append(contract)
funcs.append(cls(
name=expr.name,
body=expr.body,
Expand Down
20 changes: 19 additions & 1 deletion 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_exceptions, get_exceptions_stubs, get_globals,
get_imports, get_prints, get_returns,
get_imports, get_prints, get_returns, get_asserts,
)
from ._func import Func
from ._stub import StubsManager
Expand Down Expand Up @@ -166,3 +166,21 @@ def _check(self, func: Func, stubs: StubsManager = None) -> Iterator[Error]:
row=token.line,
col=token.col,
)


@register
class CheckAsserts:
__slots__ = ()
code = 15
message = 'assert error'
required = Required.FUNC

def __call__(self, func: Func, stubs: StubsManager = None) -> Iterator[Error]:
for token in get_asserts(body=func.body):
yield Error(
code=self.code,
text=self.message,
value=str(token.value),
row=token.line,
col=token.col,
)
7 changes: 4 additions & 3 deletions docs/linter.md → docs/commands/lint.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Static analysis
# **lint**: run static analysis

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.
Another option is to use built-in CLI from deal: `python3 -m deal lint`. 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)
![](../../assets/linter.png)

## Codes

Expand All @@ -15,3 +15,4 @@ Another option is to use built-in CLI from deal: `python3 -m deal.linter`. I has
| DEAL012 | raises contract error |
| DEAL013 | silent contract error |
| DEAL014 | pure contract error |
| DEAL015 | assert error |
55 changes: 55 additions & 0 deletions docs/commands/stub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# **stub**: generate stub files

When [linter](lint) analyses a function, it checks all called functions inside it, even if these functions have no explicit contracts. For example:

```python
import deal

def a():
raise ValueError

@deal.raises(NameError)
def b():
return a()
```

Here deal finds and error:

```bash
$ python3 -m deal lint tmp.py
tmp.py
8:11 raises contract error (ValueError)
return a()
```

However, in the next case deal doesn't report anything:

```python
import deal

def a():
raise ValueError

def b():
return a()

@deal.raises(NameError)
def c():
return b()
```

That's because the exception is raised deep inside the call chain. Analyzing function calls too deep would make deal too slow. The solution is to make contracts for everything in your code that you want to be analyzed. However, when it's about third-party libraries where you can't modify the code, stubs come into play.

Use `stub` command to generate stubs for a Python file:

```bash
$ python3 -m deal stub /path/to/a/file.py
```

The command above will produce `/path/to/a/file.json` stub. On the next runs linter will use it do detect contracts.

## Built-in stubs

Deal comes with some pre-generated stubs that are automatically used:

+ Standard library (CPython 3.7)
10 changes: 9 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

# external
import sphinx_rtd_theme
from m2r import MdInclude
from recommonmark.transform import AutoStructify


Expand All @@ -18,7 +19,7 @@
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
'sphinx.ext.githubpages',
'm2r',
'recommonmark',
]

templates_path = ['_templates']
Expand Down Expand Up @@ -93,3 +94,10 @@ def setup(app):
}
app.add_config_value('recommonmark_config', config, True)
app.add_transform(AutoStructify)

# from m2r to make `mdinclude` work
app.add_config_value('no_underscore_emphasis', False, 'env')
app.add_config_value('m2r_parse_relative_links', False, 'env')
app.add_config_value('m2r_anonymous_references', False, 'env')
app.add_config_value('m2r_disable_inline_math', False, 'env')
app.add_directive('mdinclude', MdInclude)
2 changes: 1 addition & 1 deletion docs/decorators/pure.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pure

Pure function cannot do network requests, write anything into stdout or raise any exceptions. It gets some parameters and returns some result. That's all. In runtime, it does the same checks as `chain(safe, silent, offline)`. However, [linter](../linter.html) checks a bit more, like no `import`, `global`, `nonlocal`, etc.
Pure function cannot do network requests, write anything into stdout or raise any exceptions. It gets some parameters and returns some result. That's all. In runtime, it does the same checks as `chain(safe, silent, offline)`. However, [linter](../commands/lint) checks a bit more, like no `import`, `global`, `nonlocal`, etc.

```python
@deal.pure
Expand Down
2 changes: 1 addition & 1 deletion docs/decorators/raises.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ It works the same for generators and async functions.

Exceptions are the most explicit part of Python. Any code can raise any exception. None of the tools can say you which exceptions can be raised in some function. However, sometimes you can infer it yourself and say it to other people. And `@deal.raises` will remain you if function has raised something that you forgot to specify.

Also, it's the most important decorator for [autotesting](../testing.html). Deal won't fail tests for exceptions that was marked as allowed with `@deal.raises`.
Also, it's the most important decorator for [autotesting](../testing). Deal won't fail tests for exceptions that was marked as allowed with `@deal.raises`.

Bad:

Expand Down
2 changes: 1 addition & 1 deletion docs/decorators/reason.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ It works the same for generators and async functions.

## Motivation

This is the [@deal.ensure](./ensure.html) for exceptions. It works perfect when it's easy to check correctness of conditions when exception is raised.
This is the [@deal.ensure](./ensure) for exceptions. It works perfect when it's easy to check correctness of conditions when exception is raised.

For example, if function `index_of` returns index of the first element that equal to the given element and raises `LookupError` if element is not found, we can check that if `LookupError` is raised, element not in the list:

Expand Down
9 changes: 8 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
decorators/ensure
decorators/inv

.. toctree::
:maxdepth: 1
:caption: CLI

commands/lint
commands/stub

.. toctree::
:maxdepth: 1
:caption: Main info
Expand All @@ -19,9 +26,9 @@
chaining
validators
testing
linter
recipes


.. toctree::
:maxdepth: 1
:caption: Take more control
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
Deal can automatically test your functions. First of all, your function has to be prepared:

1. All function arguments are type-annotated.
1. All exceptions that function can raise are specified in [@deal.raises](decorators/raises.html).
1. All pre-conditions are specified with [@deal.pre](decorators/pre.html).
1. All exceptions that function can raise are specified in [@deal.raises](decorators/raises).
1. All pre-conditions are specified with [@deal.pre](decorators/pre).

Then use `deal.cases` to get test cases for the function. Every case is a callable object that gets no arguments. Calling it will call the original function with suppressing allowed exceptions.

Expand Down
4 changes: 2 additions & 2 deletions tests/test_linter/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ def f(x):
"""
text = dedent(text).strip()
funcs = Func.from_astroid(astroid.parse(text))
assert len(funcs) == 1
func = funcs[0]
assert len(funcs) == 2
func = funcs[-1]
assert len(func.contracts) == 1
c = func.contracts[0]
assert c.run(1) is True
Expand Down
46 changes: 46 additions & 0 deletions tests/test_linter/test_extractors/test_asserts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# built-in
import ast

# external
import astroid
import pytest

# project
from deal.linter._extractors import get_asserts


@pytest.mark.parametrize('text, expected', [
('assert 1', ()),
('assert True', ()),
('assert "abc"', ()),
('assert "abc", "do not panic"', ()),

('assert 0', (0, )),
('assert False', (False, )),
('assert ""', ('', )),
])
def test_get_asserts_simple(text, expected):
tree = astroid.parse(text)
print(tree.repr_tree())
returns = tuple(r.value for r in get_asserts(body=tree.body))
assert returns == expected

tree = ast.parse(text)
print(ast.dump(tree))
returns = tuple(r.value for r in get_asserts(body=tree.body))
assert returns == expected


@pytest.mark.parametrize('text, expected', [
('assert 3 - 2', ()),
('assert a', ()), # ignore uninferrable names
('a = object()\nassert a', ()),

('assert 2 - 2', (0, )), # do a simple arithmetic
('a = ""\nassert a', ('', )),
])
def test_get_asserts_inference(text, expected):
tree = astroid.parse(text)
print(tree.repr_tree())
returns = tuple(r.value for r in get_asserts(body=tree.body))
assert returns == expected
6 changes: 3 additions & 3 deletions tests/test_linter/test_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@ def k(x):

def test_from_text():
funcs = Func.from_text(TEXT)
assert len(funcs) == 1
assert len(funcs) == 3
assert len(funcs[0].contracts) == 2


def test_from_ast():
funcs = Func.from_ast(ast.parse(TEXT))
assert len(funcs) == 1
assert len(funcs) == 3
assert len(funcs[0].contracts) == 2


def test_from_astroid():
funcs = Func.from_astroid(astroid.parse(TEXT))
assert len(funcs) == 1
assert len(funcs) == 3
assert len(funcs[0].contracts) == 2


Expand Down