Skip to content

Commit

Permalink
Added support for named expressions in contracts
Browse files Browse the repository at this point in the history
Named expressions were [introduced recently in Python 3.8][1].
This patch updates icontract's representation module so that they can be
properly recomputed and represented in the violation messages.

[1]: https://www.python.org/dev/peps/pep-0572/
  • Loading branch information
mristin committed Nov 9, 2020
1 parent 9e8451b commit 3719d06
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Expand Up @@ -3,7 +3,7 @@ python:
- "3.5"
- "3.6"
- "3.7"
- "3.8"
- "3.8.5"
install:
- pip3 install -e .[dev]
- pip3 install coveralls
Expand Down
26 changes: 24 additions & 2 deletions icontract/_recompute.py
Expand Up @@ -5,7 +5,8 @@
import functools
import platform
import sys
from typing import Any, Mapping, Dict, List, Optional, Union, Tuple, Set, Callable # pylint: disable=unused-import
from typing import Any, Mapping, Dict, List, Optional, Union, Tuple, Set, Callable, \
cast # pylint: disable=unused-import


class Placeholder:
Expand Down Expand Up @@ -37,8 +38,11 @@ def __init__(self, variable_lookup: List[Mapping[str, Any]]) -> None:
:param variable_lookup: list of lookup tables to look-up the values of the variables, sorted by precedence
"""
# Resolve precedence of variable lookup
# _name_to_value maps the variable names to variable values.
# This is important for Load contexts as well as Store contexts in, e.g., named expressions.
self._name_to_value = dict() # type: Dict[str, Any]

# Resolve precedence of variable lookups
for lookup in variable_lookup:
for name, value in lookup.items():
if name not in self._name_to_value:
Expand Down Expand Up @@ -314,6 +318,24 @@ def visit_Attribute(self, node: ast.Attribute) -> Any:
self.recomputed_values[node] = result
return result

if sys.version_info >= (3, 8):

def visit_NamedExpr(self, node: ast.NamedExpr) -> Any:
"""Visit the node's ``value`` and assign it to both this node and the target."""
value = self.visit(node=node.value)
self.recomputed_values[node] = value

# This assignment is needed to make mypy happy.
target = cast(ast.Name, node.target)

if not isinstance(target.ctx, ast.Store):
raise NotImplementedError(
"Expected Store context in the target of a named expression, but got: {}".format(target.ctx))

self._name_to_value[target.id] = value

return value

def visit_Index(self, node: ast.Index) -> Any:
"""Visit the node's ``value``."""
result = self.visit(node=node.value)
Expand Down
10 changes: 10 additions & 0 deletions icontract/_represent.py
Expand Up @@ -88,6 +88,16 @@ def visit_Attribute(self, node: ast.Attribute) -> None:

self.generic_visit(node=node)

def visit_NamedExpr(self, node: ast.NamedExpr) -> Any:
"""Represent the target with the value of the node."""
if node in self._recomputed_values:
value = self._recomputed_values[node]

if _representable(value=value):
self.reprs[node.target.id] = value # type: ignore

self.generic_visit(node=node)

def visit_Call(self, node: ast.Call) -> None:
"""Represent the call by dumping its source code."""
if node in self._recomputed_values:
Expand Down
22 changes: 19 additions & 3 deletions precommit.py
Expand Up @@ -24,6 +24,10 @@ def main() -> int:

print("YAPF'ing...")
yapf_targets = ["tests", "icontract", "setup.py", "precommit.py", "benchmark.py", "benchmarks", "tests_with_others"]

if sys.version_info >= (3, 8, 5):
yapf_targets.append('tests_3_8')

if overwrite:
subprocess.check_call(
["yapf", "--in-place", "--style=style.yapf", "--recursive"] + yapf_targets, cwd=str(repo_root))
Expand All @@ -32,10 +36,18 @@ def main() -> int:
["yapf", "--diff", "--style=style.yapf", "--recursive"] + yapf_targets, cwd=str(repo_root))

print("Mypy'ing...")
subprocess.check_call(["mypy", "--strict", "icontract", "tests"], cwd=str(repo_root))
mypy_targets = ["icontract", "tests"]
if sys.version_info >= (3, 8):
mypy_targets.append('tests_3_8')

subprocess.check_call(["mypy", "--strict"] + mypy_targets, cwd=str(repo_root))

print("Pylint'ing...")
subprocess.check_call(["pylint", "--rcfile=pylint.rc", "tests", "icontract"], cwd=str(repo_root))
pylint_targets = ['icontract', 'tests']

if sys.version_info >= (3, 8):
pylint_targets.append('tests_3_8')
subprocess.check_call(["pylint", "--rcfile=pylint.rc"] + pylint_targets, cwd=str(repo_root))

print("Pydocstyle'ing...")
subprocess.check_call(["pydocstyle", "icontract"], cwd=str(repo_root))
Expand All @@ -45,10 +57,14 @@ def main() -> int:
env['ICONTRACT_SLOW'] = 'true'

# yapf: disable
unittest_targets = ['tests']
if sys.version_info > (3, 8):
unittest_targets.append('tests_3_8')

subprocess.check_call(
["coverage", "run",
"--source", "icontract",
"-m", "unittest", "discover", "tests"],
"-m", "unittest", "discover"] + unittest_targets,
cwd=str(repo_root),
env=env)
# yapf: enable
Expand Down
7 changes: 7 additions & 0 deletions tests_3_8/__init__.py
@@ -0,0 +1,7 @@
"""
Test Python 3.8-specific features.
For example, one such feature is walrus operator used in named expressions.
We have to exclude these tests running on prior versions of Python since the syntax would be considered
invalid.
"""
35 changes: 35 additions & 0 deletions tests_3_8/test_represent.py
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# pylint: disable=missing-docstring,invalid-name,too-many-public-methods,no-self-use
# pylint: disable=unused-argument

import textwrap
import unittest
from typing import Optional # pylint: disable=unused-import

import icontract._represent
import tests.error
import tests.mock


class TestReprValues(unittest.TestCase):
def test_named_expression(self) -> None:
@icontract.require(lambda x: (t := x + 1) and t > 1) # pylint: disable=undefined-variable
def func(x: int) -> int:
return x

violation_err = None # type: Optional[icontract.ViolationError]
try:
func(x=0)
except icontract.ViolationError as err:
violation_err = err

self.assertIsNotNone(violation_err)
self.assertEqual(
textwrap.dedent('''\
(t := x + 1) and t > 1:
t was 1
x was 0'''), tests.error.wo_mandatory_location(str(violation_err)))


if __name__ == '__main__':
unittest.main()

0 comments on commit 3719d06

Please sign in to comment.