Skip to content

Commit 9918d46

Browse files
encukousils
authored andcommitted
Add MypyBear
Add a bear that runs mypy, a checker for PEP 484 type hints. Closes coala#601
1 parent b34e42f commit 9918d46

File tree

4 files changed

+265
-0
lines changed

4 files changed

+265
-0
lines changed

.ci/deps.sh

+1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ for dep_version in "${dep_versions[@]}" ; do
9090
source .ci/env_variables.sh
9191

9292
pip install pip -U
93+
pip install -U setuptools
9394
pip install -r test-requirements.txt
9495
pip install -r requirements.txt
9596
done

bears/python/MypyBear.py

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from collections import namedtuple
2+
import textwrap
3+
import sys
4+
5+
from coalib.bearlib.abstractions.Linter import linter
6+
from coalib.bears.requirements.PipRequirement import PipRequirement
7+
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY
8+
from coalib.results.Result import Result
9+
10+
11+
class FlagInfo(namedtuple('FlagInfo', 'arg doc inverse')):
12+
"""
13+
Information about a command-line flag.
14+
15+
:param arg:
16+
The argument to pass to enable the flag.
17+
:param doc:
18+
A help text for the flag.
19+
:param inverse:
20+
Set to ``True`` when the coala option is the inverse of
21+
the subprocess one, for example coala's ``allow_untyped_calls``
22+
is the inverse of mypy's ``--disallow-untyped-calls``.
23+
"""
24+
25+
def want_flag(self, value):
26+
"""
27+
Check if the flag should be added to the argument list.
28+
29+
:param value: The configuration value.
30+
:return: The flag value, may be negated if the flag specifies so.
31+
"""
32+
33+
if self.inverse:
34+
value = not value
35+
return value
36+
37+
38+
FLAG_MAP = {
39+
'allow_untyped_functions': FlagInfo(
40+
arg='--disallow-untyped-defs',
41+
doc='Allow defining functions without type annotations or with '
42+
'incomplete type annotations.',
43+
inverse=True),
44+
'allow_untyped_calls': FlagInfo(
45+
arg='--disallow-untyped-calls',
46+
doc='Allow calling functions without type annotations from '
47+
'typed functions.',
48+
inverse=True),
49+
'check_untyped_function_bodies': FlagInfo(
50+
arg='--check-untyped-defs',
51+
doc='Do not check the interior of functions without type annotations.',
52+
inverse=False),
53+
'strict_optional': FlagInfo(
54+
arg='--strict-optional',
55+
doc='Enable experimental strict checks related to Optional types. See '
56+
'<http://mypy-lang.blogspot.com.es/2016/07/mypy-043-released.html>'
57+
' for an explanation.',
58+
inverse=False),
59+
}
60+
61+
62+
def add_param_docs(param_map):
63+
"""
64+
Append documentation from FLAG_MAP to a function's docstring.
65+
66+
:param param_map:
67+
A mapping of argument names (strings) to FlagInfo objects.
68+
:return:
69+
A decorator that appends flag information to a function's docstring.
70+
"""
71+
def decorator(func):
72+
func.__doc__ = textwrap.dedent(func.__doc__) + '\n'.join(
73+
':param {}:\n{}'.format(name, textwrap.indent(arg.doc, ' '))
74+
for name, arg in param_map.items())
75+
return func
76+
return decorator
77+
78+
79+
# Mypy generates messages in the format:
80+
# blabla.py: note: In function "f":
81+
# blabla.py:2: error: Unsupported operand types for ...
82+
# The "note" messages are only adding info coala should already know,
83+
# so discard those. We're only capturing the errors.
84+
@linter(executable=sys.executable,
85+
prerequisite_check_command=(sys.executable, '-m', 'mypy', '-V'),
86+
output_format="regex",
87+
output_regex=r'(?P<filename>[^:]+):((?P<line>\d+):)? '
88+
'(?P<severity>error): (?P<message>.*)')
89+
class MypyBear:
90+
"""
91+
Type-checks your Python files!
92+
93+
Checks optional static typing using the mypy tool.
94+
See <http://mypy.readthedocs.io/en/latest/basics.html> for info on how to
95+
add static typing.
96+
"""
97+
98+
LANGUAGES = {"Python", "Python 2", "Python 3"}
99+
AUTHORS = {'Petr Viktorin'}
100+
REQUIREMENTS = {PipRequirement('mypy-lang', '0.*')}
101+
AUTHORS_EMAILS = {'encukou@gmail.com'}
102+
LICENSE = 'AGPL-3.0'
103+
104+
# This detects typing errors, which is pretty unique -- it doesn't
105+
# make sense to add a category for it.
106+
CAN_DETECT = set()
107+
108+
@add_param_docs(FLAG_MAP)
109+
def create_arguments(self, filename, file, config_file,
110+
language: str="Python 3",
111+
python_version: str=None,
112+
allow_untyped_functions: bool=True,
113+
allow_untyped_calls: bool=True,
114+
check_untyped_function_bodies: bool=False,
115+
strict_optional: bool=False):
116+
"""
117+
:param language:
118+
Set to ``Python`` or ``Python 3`` to check Python 3.x source.
119+
Use ``Python 2`` for Python 2.x.
120+
:param python_version:
121+
Set the specific Python version, e.g. ``3.5``.
122+
"""
123+
args = ['-m', 'mypy']
124+
if language.lower() == 'python 2':
125+
args.append('--py2')
126+
elif language.lower() not in ('python 3', 'python'):
127+
# Ideally, this would fail the check, but there's no good
128+
# way to fail from create_arguments.
129+
# See https://github.com/coala-analyzer/coala/issues/2573
130+
self.err(
131+
'Language needs to be "Python", "Python 2" or "Python 3". '
132+
'Assuming Python 3.')
133+
if python_version:
134+
args.extend(['--python-version', python_version])
135+
loc = locals()
136+
args.extend(flag.arg for name, flag in FLAG_MAP.items()
137+
if flag.want_flag(loc[name]))
138+
args.append(filename)
139+
return args

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ vulture==0.10.*
2929
nbformat>=4.*
3030
pyflakes==1.2.* # Although we don't need this directly, solves a dep conflict
3131
scspell3k==2.*
32+
mypy-lang==0.4.*

tests/python/MypyBearTest.py

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from queue import Queue
2+
from textwrap import dedent
3+
4+
from bears.python.MypyBear import MypyBear
5+
from tests.BearTestHelper import generate_skip_decorator
6+
from tests.LocalBearTestHelper import LocalBearTestHelper
7+
from coalib.settings.Section import Section
8+
from coalib.settings.Setting import Setting
9+
from coalib.results.Result import Result
10+
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY
11+
from coalib.misc.ContextManagers import prepare_file
12+
13+
14+
@generate_skip_decorator(MypyBear)
15+
class MypyBearTest(LocalBearTestHelper):
16+
17+
def setUp(self):
18+
self.section = Section('name')
19+
self.queue = Queue()
20+
self.uut = MypyBear(self.section, self.queue)
21+
22+
def test_variable(self):
23+
self.check_validity(self.uut,
24+
["a = 1 # type: int"], valid=True)
25+
self.check_validity(self.uut,
26+
["a = 'abc' # type: int"], valid=False)
27+
28+
def test_call_sum(self):
29+
self.check_validity(self.uut,
30+
["sum([1, 2, 3])"], valid=True)
31+
self.check_validity(self.uut,
32+
["sum(1, 2, 3)"], valid=False)
33+
34+
def test_py2(self):
35+
self.check_validity(self.uut,
36+
["print(123)"], valid=True)
37+
self.check_validity(self.uut,
38+
["print 123"], valid=False)
39+
self.section.append(Setting('language', 'Python 2'))
40+
self.check_validity(self.uut,
41+
["print 123"], valid=True)
42+
43+
def test_py2_version(self):
44+
self.check_validity(self.uut,
45+
["print(123)"], valid=True)
46+
self.check_validity(self.uut,
47+
["print 123"], valid=False)
48+
self.section.append(Setting('python_version', '2.7'))
49+
self.check_validity(self.uut,
50+
["print 123"], valid=True)
51+
52+
def test_bad_language(self):
53+
self.section.append(Setting('language', 'Piet'))
54+
self.check_validity(self.uut,
55+
["1 + 1"], valid=True)
56+
while not self.queue.empty():
57+
message = self.queue.get()
58+
msg = ('Language needs to be "Python", "Python 2" or '
59+
'"Python 3". Assuming Python 3.')
60+
if message.message == msg:
61+
break
62+
else:
63+
assert False, 'Message not found'
64+
65+
def test_check_untyped_function_bodies(self):
66+
source = dedent("""
67+
def foo():
68+
return 1 + "abc"
69+
""").splitlines()
70+
self.check_validity(self.uut, source, valid=True)
71+
self.section.append(Setting('check_untyped_function_bodies', 'true'))
72+
self.check_validity(self.uut, source, valid=False)
73+
74+
def test_allow_untyped_functions(self):
75+
source = dedent("""
76+
def foo():
77+
pass
78+
""").splitlines()
79+
self.check_validity(self.uut, source, valid=True)
80+
self.section.append(Setting('allow_untyped_functions', 'false'))
81+
self.check_validity(self.uut, source, valid=False)
82+
83+
def test_allow_untyped_calls(self):
84+
source = dedent("""
85+
def foo():
86+
pass
87+
88+
foo()
89+
""").splitlines()
90+
self.check_validity(self.uut, source, valid=True)
91+
self.section.append(Setting('allow_untyped_calls', 'false'))
92+
self.check_validity(self.uut, source, valid=False)
93+
94+
def test_strict_optional(self):
95+
source = dedent("""
96+
from typing import Optional, List
97+
98+
def read_data_file(path: Optional[str]) -> List[str]:
99+
with open(path) as f: # Error
100+
return f.read().split(',')
101+
""").splitlines()
102+
self.check_validity(self.uut, source, valid=True)
103+
self.section.append(Setting('strict_optional', 'true'))
104+
self.check_validity(self.uut, source, valid=False)
105+
106+
def test_discarded_note(self):
107+
source = dedent("""
108+
def f() -> None:
109+
1 + "a"
110+
""").splitlines()
111+
prepared = prepare_file(source, filename=None, create_tempfile=True)
112+
with prepared as (file, fname):
113+
results = [
114+
Result.from_values(
115+
message=(
116+
'Unsupported operand types for + ("int" and "str")'),
117+
file=fname,
118+
line=3,
119+
origin=self.uut,
120+
severity=RESULT_SEVERITY.MAJOR,
121+
)
122+
]
123+
self.check_results(self.uut, source, results=results,
124+
filename=fname, create_tempfile=False)

0 commit comments

Comments
 (0)