diff --git a/deal/linter/_extractors/__init__.py b/deal/linter/_extractors/__init__.py index fae1f52e..b50f02c7 100644 --- a/deal/linter/_extractors/__init__.py +++ b/deal/linter/_extractors/__init__.py @@ -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 @@ -10,6 +11,7 @@ __all__ = [ + 'get_asserts', 'get_contracts', 'get_exceptions', 'get_exceptions_stubs', diff --git a/deal/linter/_extractors/asserts.py b/deal/linter/_extractors/asserts.py new file mode 100644 index 00000000..88d90719 --- /dev/null +++ b/deal/linter/_extractors/asserts.py @@ -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) diff --git a/deal/linter/_func.py b/deal/linter/_func.py index 58c69c03..d653b393 100644 --- a/deal/linter/_func.py +++ b/deal/linter/_func.py @@ -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, @@ -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, diff --git a/deal/linter/_rules.py b/deal/linter/_rules.py index c9db5f88..200ce13f 100644 --- a/deal/linter/_rules.py +++ b/deal/linter/_rules.py @@ -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 @@ -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, + ) diff --git a/docs/linter.md b/docs/commands/lint.md similarity index 67% rename from docs/linter.md rename to docs/commands/lint.md index e1bb9100..34f9e67f 100644 --- a/docs/linter.md +++ b/docs/commands/lint.md @@ -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 @@ -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 | diff --git a/docs/commands/stub.md b/docs/commands/stub.md new file mode 100644 index 00000000..1436aa25 --- /dev/null +++ b/docs/commands/stub.md @@ -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) diff --git a/docs/conf.py b/docs/conf.py index e7f349e2..bf68f099 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,7 +6,9 @@ from pathlib import Path # external +import sphinx import sphinx_rtd_theme +from m2r import MdInclude from recommonmark.transform import AutoStructify @@ -18,7 +20,7 @@ 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 'sphinx.ext.githubpages', - 'm2r', + 'recommonmark', ] templates_path = ['_templates'] @@ -84,6 +86,28 @@ ] +def monkeypatch(cls): + """ decorator to monkey-patch methods """ + def decorator(f): + method = f.__name__ + old_method = getattr(cls, method) + setattr(cls, method, lambda self, *args, **kwargs: f(old_method, self, *args, **kwargs)) + return decorator + + +# workaround until https://github.com/miyakogi/m2r/pull/55 is merged +def add_source_parser(self, *args, **kwargs): + # signature is (parser: Type[Parser], **kwargs), but m2r expects + # the removed (str, parser: Type[Parser], **kwargs). + if isinstance(args[0], str): + args = args[1:] + return old_add_source_parser(self, *args, **kwargs) + + +old_add_source_parser = sphinx.registry.SphinxComponentRegistry.add_source_parser +sphinx.registry.SphinxComponentRegistry.add_source_parser = add_source_parser + + # https://github.com/rtfd/recommonmark/blob/master/docs/conf.py def setup(app): config = { @@ -93,3 +117,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) diff --git a/docs/decorators/pure.md b/docs/decorators/pure.md index 00719a23..d9396bec 100644 --- a/docs/decorators/pure.md +++ b/docs/decorators/pure.md @@ -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 diff --git a/docs/decorators/raises.md b/docs/decorators/raises.md index 8c1376d7..5c3cdb05 100644 --- a/docs/decorators/raises.md +++ b/docs/decorators/raises.md @@ -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: diff --git a/docs/decorators/reason.md b/docs/decorators/reason.md index 996bb6f8..7a3a5306 100644 --- a/docs/decorators/reason.md +++ b/docs/decorators/reason.md @@ -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: diff --git a/docs/index.md b/docs/index.md index bd8b3025..b2df730b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,13 @@ decorators/ensure decorators/inv +.. toctree:: + :maxdepth: 1 + :caption: CLI + + commands/lint + commands/stub + .. toctree:: :maxdepth: 1 :caption: Main info @@ -19,9 +26,9 @@ chaining validators testing - linter recipes + .. toctree:: :maxdepth: 1 :caption: Take more control diff --git a/docs/testing.md b/docs/testing.md index 5a5c4168..9d04e088 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/tests/test_linter/test_contract.py b/tests/test_linter/test_contract.py index fbcab89f..a04ee312 100644 --- a/tests/test_linter/test_contract.py +++ b/tests/test_linter/test_contract.py @@ -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 diff --git a/tests/test_linter/test_extractors/test_asserts.py b/tests/test_linter/test_extractors/test_asserts.py new file mode 100644 index 00000000..7945ea32 --- /dev/null +++ b/tests/test_linter/test_extractors/test_asserts.py @@ -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 diff --git a/tests/test_linter/test_func.py b/tests/test_linter/test_func.py index cc6fc9d3..5057935a 100644 --- a/tests/test_linter/test_func.py +++ b/tests/test_linter/test_func.py @@ -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 diff --git a/tests/test_linter/test_rules.py b/tests/test_linter/test_rules.py index 77487ac6..7f62e392 100644 --- a/tests/test_linter/test_rules.py +++ b/tests/test_linter/test_rules.py @@ -8,7 +8,7 @@ # project from deal.linter._func import Func from deal.linter._rules import ( - CheckImports, CheckPrints, CheckPure, CheckRaises, CheckReturns, rules, + CheckImports, CheckPrints, CheckPure, CheckRaises, CheckReturns, rules, CheckAsserts, ) @@ -157,6 +157,21 @@ def test(a): assert actual == expected +def test_check_asserts(): + checker = CheckAsserts() + text = """ + def test(a): + assert False, "oh no!" + """ + text = dedent(text).strip() + funcs1 = Func.from_ast(ast.parse(text)) + funcs2 = Func.from_astroid(astroid.parse(text)) + for func in (funcs1[0], funcs2[0]): + actual = [tuple(err) for err in checker(func)] + expected = [(2, 11, 'DEAL015 assert error (False)')] + assert actual == expected + + def test_check_imports(): checker = CheckImports() text = """