From 89dfde95353651f12474621becd808e2330371da Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 8 Jul 2019 10:04:19 +0300 Subject: [PATCH] Add rudimentary mypy type checking Add a very lax mypy configuration, add it to tox -e linting, and fix/ignore the few errors that come up. The idea is to get it running before diving in too much. This enables: - Progressively adding type annotations and enabling more strict options, which will improve the codebase (IMO). - Annotating the public API in-line, and eventually exposing it to library users who use type checkers (with a py.typed file). Though, none of this is done yet. Refs https://github.com/pytest-dev/pytest/issues/3342. --- .gitignore | 1 + .pre-commit-config.yaml | 12 +++++++++ bench/bench.py | 2 +- setup.cfg | 8 ++++++ src/_pytest/_argcomplete.py | 3 ++- src/_pytest/_code/code.py | 13 +++++----- src/_pytest/_code/source.py | 3 ++- src/_pytest/assertion/rewrite.py | 25 +++++++++++-------- src/_pytest/capture.py | 12 ++++++--- src/_pytest/debugging.py | 2 +- src/_pytest/fixtures.py | 12 ++++++--- src/_pytest/mark/__init__.py | 3 ++- src/_pytest/mark/structures.py | 3 ++- src/_pytest/nodes.py | 3 ++- src/_pytest/outcomes.py | 12 ++++++--- src/_pytest/python_api.py | 15 +++++++---- src/_pytest/reports.py | 3 ++- src/_pytest/tmpdir.py | 5 +++- testing/code/test_excinfo.py | 3 ++- .../package_infinite_recursion/__init__.pyi | 0 .../config/collect_pytest_prefix/__init__.pyi | 0 .../conftest_usageerror/__init__.pyi | 0 .../fixtures/custom_item/__init__.pyi | 0 .../__init__.pyi | 0 .../__init__.pyi | 0 .../__init__.pyi | 0 .../marks_considered_keywords/__init__.pyi | 0 testing/python/raises.py | 3 ++- testing/test_compat.py | 3 ++- testing/test_pdb.py | 3 ++- 30 files changed, 104 insertions(+), 45 deletions(-) create mode 100644 testing/example_scripts/collect/package_infinite_recursion/__init__.pyi create mode 100644 testing/example_scripts/config/collect_pytest_prefix/__init__.pyi create mode 100644 testing/example_scripts/conftest_usageerror/__init__.pyi create mode 100644 testing/example_scripts/fixtures/custom_item/__init__.pyi create mode 100644 testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/__init__.pyi create mode 100644 testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/__init__.pyi create mode 100644 testing/example_scripts/issue88_initial_file_multinodes/__init__.pyi create mode 100644 testing/example_scripts/marks/marks_considered_keywords/__init__.pyi diff --git a/.gitignore b/.gitignore index a008b4363..27bd93c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ env/ .tox .cache .pytest_cache +.mypy_cache .coverage .coverage.* coverage.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12fa0d343..fce7978c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,7 @@ repos: hooks: - id: flake8 language_version: python3 + additional_dependencies: [flake8-typing-imports] - repo: https://github.com/asottile/reorder_python_imports rev: v1.4.0 hooks: @@ -42,6 +43,17 @@ repos: rev: v1.4.0 hooks: - id: rst-backticks +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.711 + hooks: + - id: mypy + name: mypy (src) + files: ^src/ + args: [] + - id: mypy + name: mypy (testing) + files: ^testing/ + args: [] - repo: local hooks: - id: rst diff --git a/bench/bench.py b/bench/bench.py index 31cc7ac13..c40fc8636 100644 --- a/bench/bench.py +++ b/bench/bench.py @@ -6,7 +6,7 @@ if __name__ == "__main__": import pstats script = sys.argv[1:] if len(sys.argv) > 1 else ["empty.py"] - stats = cProfile.run("pytest.cmdline.main(%r)" % script, "prof") + cProfile.run("pytest.cmdline.main(%r)" % script, "prof") p = pstats.Stats("prof") p.strip_dirs() p.sort_stats("cumulative") diff --git a/setup.cfg b/setup.cfg index 2d6e5bee1..60e866562 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,3 +61,11 @@ ignore = [devpi:upload] formats = sdist.tgz,bdist_wheel + +[mypy] +ignore_missing_imports = True +no_implicit_optional = True +strict_equality = True +warn_redundant_casts = True +warn_return_any = True +warn_unused_configs = True diff --git a/src/_pytest/_argcomplete.py b/src/_pytest/_argcomplete.py index 1ebf7432c..688c9077d 100644 --- a/src/_pytest/_argcomplete.py +++ b/src/_pytest/_argcomplete.py @@ -56,6 +56,7 @@ If things do not work right away: import os import sys from glob import glob +from typing import Optional class FastFilesCompleter: @@ -91,7 +92,7 @@ if os.environ.get("_ARGCOMPLETE"): import argcomplete.completers except ImportError: sys.exit(-1) - filescompleter = FastFilesCompleter() + filescompleter = FastFilesCompleter() # type: Optional[FastFilesCompleter] def try_argcomplete(parser): argcomplete.autocomplete(parser, always_complete_options=False) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index aa4dcffce..d63c010e4 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -33,7 +33,8 @@ class Code: def __eq__(self, other): return self.raw == other.raw - __hash__ = None + # Ignore type because of https://github.com/python/mypy/issues/4266. + __hash__ = None # type: ignore def __ne__(self, other): return not self == other @@ -188,11 +189,11 @@ class TracebackEntry: """ path to the source code """ return self.frame.code.path - def getlocals(self): + @property + def locals(self): + """ locals of underlaying frame """ return self.frame.f_locals - locals = property(getlocals, None, None, "locals of underlaying frame") - def getfirstlinesource(self): return self.frame.code.firstlineno @@ -255,11 +256,11 @@ class TracebackEntry: line = "???" return " File %r:%d in %s\n %s\n" % (fn, self.lineno + 1, name, line) + @property def name(self): + """ co_name of underlaying code """ return self.frame.code.raw.co_name - name = property(name, None, None, "co_name of underlaying code") - class Traceback(list): """ Traceback objects encapsulate and offer higher level diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index 70d5f8fcd..ea2fc5e3f 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -44,7 +44,8 @@ class Source: return str(self) == other return False - __hash__ = None + # Ignore type because of https://github.com/python/mypy/issues/4266. + __hash__ = None # type: ignore def __getitem__(self, key): if isinstance(key, int): diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 8b2c1e146..7bd46eeb6 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -12,6 +12,10 @@ import struct import sys import tokenize import types +from typing import Dict +from typing import List +from typing import Optional +from typing import Set import atomicwrites @@ -459,17 +463,18 @@ def set_location(node, lineno, col_offset): return node -def _get_assertion_exprs(src: bytes): # -> Dict[int, str] +def _get_assertion_exprs(src: bytes) -> Dict[int, str]: """Returns a mapping from {lineno: "assertion test expression"}""" - ret = {} + ret = {} # type: Dict[int, str] depth = 0 - lines = [] - assert_lineno = None - seen_lines = set() + lines = [] # type: List[str] + assert_lineno = None # type: Optional[int] + seen_lines = set() # type: Set[int] def _write_and_reset() -> None: nonlocal depth, lines, assert_lineno, seen_lines + assert assert_lineno is not None ret[assert_lineno] = "".join(lines).rstrip().rstrip("\\") depth = 0 lines = [] @@ -477,21 +482,21 @@ def _get_assertion_exprs(src: bytes): # -> Dict[int, str] seen_lines = set() tokens = tokenize.tokenize(io.BytesIO(src).readline) - for tp, src, (lineno, offset), _, line in tokens: - if tp == tokenize.NAME and src == "assert": + for tp, source, (lineno, offset), _, line in tokens: + if tp == tokenize.NAME and source == "assert": assert_lineno = lineno elif assert_lineno is not None: # keep track of depth for the assert-message `,` lookup - if tp == tokenize.OP and src in "([{": + if tp == tokenize.OP and source in "([{": depth += 1 - elif tp == tokenize.OP and src in ")]}": + elif tp == tokenize.OP and source in ")]}": depth -= 1 if not lines: lines.append(line[offset:]) seen_lines.add(lineno) # a non-nested comma separates the expression from the message - elif depth == 0 and tp == tokenize.OP and src == ",": + elif depth == 0 and tp == tokenize.OP and source == ",": # one line assert with message if lineno in seen_lines and len(lines) == 1: offset_in_trimmed = offset + len(lines[-1]) - len(line) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 302979ef4..f89aaefba 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -547,6 +547,8 @@ class FDCaptureBinary: self.start = lambda: None self.done = lambda: None else: + self.start = self._start + self.done = self._done if targetfd == 0: assert not tmpfile, "cannot set tmpfile with stdin" tmpfile = open(os.devnull, "r") @@ -568,7 +570,7 @@ class FDCaptureBinary: self.targetfd, getattr(self, "targetfd_save", None), self._state ) - def start(self): + def _start(self): """ Start capturing on targetfd using memorized tmpfile. """ try: os.fstat(self.targetfd_save) @@ -585,7 +587,7 @@ class FDCaptureBinary: self.tmpfile.truncate() return res - def done(self): + def _done(self): """ stop capturing, restore streams, return original capture file, seeked to position zero. """ targetfd_save = self.__dict__.pop("targetfd_save") @@ -618,7 +620,8 @@ class FDCapture(FDCaptureBinary): snap() produces text """ - EMPTY_BUFFER = str() + # Ignore type because it doesn't match the type in the superclass (bytes). + EMPTY_BUFFER = str() # type: ignore def snap(self): res = super().snap() @@ -679,7 +682,8 @@ class SysCapture: class SysCaptureBinary(SysCapture): - EMPTY_BUFFER = b"" + # Ignore type because it doesn't match the type in the superclass (str). + EMPTY_BUFFER = b"" # type: ignore def snap(self): res = self.tmpfile.buffer.getvalue() diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 891630b43..2e3d49c37 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -74,7 +74,7 @@ class pytestPDB: _pluginmanager = None _config = None - _saved = [] + _saved = [] # type: list _recursive_debug = 0 _wrapped_pdb_cls = None diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 3262b65bb..965a2e6e9 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -6,6 +6,8 @@ import warnings from collections import defaultdict from collections import deque from collections import OrderedDict +from typing import Dict +from typing import Tuple import attr import py @@ -31,6 +33,9 @@ from _pytest.deprecated import FIXTURE_NAMED_REQUEST from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME +if False: # TYPE_CHECKING + from typing import Type + @attr.s(frozen=True) class PseudoFixtureDef: @@ -54,10 +59,10 @@ def pytest_sessionstart(session): session._fixturemanager = FixtureManager(session) -scopename2class = {} +scopename2class = {} # type: Dict[str, Type[nodes.Node]] -scope2props = dict(session=()) +scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]] scope2props["package"] = ("fspath",) scope2props["module"] = ("fspath", "module") scope2props["class"] = scope2props["module"] + ("cls",) @@ -960,7 +965,8 @@ class FixtureFunctionMarker: scope = attr.ib() params = attr.ib(converter=attr.converters.optional(tuple)) autouse = attr.ib(default=False) - ids = attr.ib(default=None, converter=_ensure_immutable_ids) + # Ignore type because of https://github.com/python/mypy/issues/6172. + ids = attr.ib(default=None, converter=_ensure_immutable_ids) # type: ignore name = attr.ib(default=None) def __call__(self, function): diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 30c6e0048..e76bb7857 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -91,7 +91,8 @@ def pytest_cmdline_main(config): return 0 -pytest_cmdline_main.tryfirst = True +# Ignore type because of https://github.com/python/mypy/issues/2087. +pytest_cmdline_main.tryfirst = True # type: ignore def deselect_by_keyword(items, config): diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 1af7a9b42..0887d6b9c 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -3,6 +3,7 @@ import warnings from collections import namedtuple from collections.abc import MutableMapping from operator import attrgetter +from typing import Set import attr @@ -298,7 +299,7 @@ class MarkGenerator: on the ``test_function`` object. """ _config = None - _markers = set() + _markers = set() # type: Set[str] def __getattr__(self, name): if name[0] == "_": diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 491cf9d2c..7e1c40bcb 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -280,7 +280,8 @@ class Node: truncate_locals=truncate_locals, ) - repr_failure = _repr_failure_py + def repr_failure(self, excinfo, style=None): + return self._repr_failure_py(excinfo, style) def get_fslocation_from_item(item): diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index fb4d471b5..c7e26f5cc 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -70,7 +70,8 @@ def exit(msg, returncode=None): raise Exit(msg, returncode) -exit.Exception = Exit +# Ignore type because of https://github.com/python/mypy/issues/2087. +exit.Exception = Exit # type: ignore def skip(msg="", *, allow_module_level=False): @@ -96,7 +97,8 @@ def skip(msg="", *, allow_module_level=False): raise Skipped(msg=msg, allow_module_level=allow_module_level) -skip.Exception = Skipped +# Ignore type because of https://github.com/python/mypy/issues/2087. +skip.Exception = Skipped # type: ignore def fail(msg="", pytrace=True): @@ -111,7 +113,8 @@ def fail(msg="", pytrace=True): raise Failed(msg=msg, pytrace=pytrace) -fail.Exception = Failed +# Ignore type because of https://github.com/python/mypy/issues/2087. +fail.Exception = Failed # type: ignore class XFailed(Failed): @@ -132,7 +135,8 @@ def xfail(reason=""): raise XFailed(reason) -xfail.Exception = XFailed +# Ignore type because of https://github.com/python/mypy/issues/2087. +xfail.Exception = XFailed # type: ignore def importorskip(modname, minversion=None, reason=None): diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 374fa598f..cbd833946 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -9,6 +9,7 @@ from collections.abc import Sized from decimal import Decimal from itertools import filterfalse from numbers import Number +from typing import Union from more_itertools.more import always_iterable @@ -58,7 +59,8 @@ class ApproxBase: a == self._approx_scalar(x) for a, x in self._yield_comparisons(actual) ) - __hash__ = None + # Ignore type because of https://github.com/python/mypy/issues/4266. + __hash__ = None # type: ignore def __ne__(self, actual): return not (actual == self) @@ -202,8 +204,10 @@ class ApproxScalar(ApproxBase): Perform approximate comparisons where the expected value is a single number. """ - DEFAULT_ABSOLUTE_TOLERANCE = 1e-12 - DEFAULT_RELATIVE_TOLERANCE = 1e-6 + # Using Real should be better than this Union, but not possible yet: + # https://github.com/python/typeshed/pull/3108 + DEFAULT_ABSOLUTE_TOLERANCE = 1e-12 # type: Union[float, Decimal] + DEFAULT_RELATIVE_TOLERANCE = 1e-6 # type: Union[float, Decimal] def __repr__(self): """ @@ -261,7 +265,8 @@ class ApproxScalar(ApproxBase): # Return true if the two numbers are within the tolerance. return abs(self.expected - actual) <= self.tolerance - __hash__ = None + # Ignore type because of https://github.com/python/mypy/issues/4266. + __hash__ = None # type: ignore @property def tolerance(self): @@ -691,7 +696,7 @@ def raises(expected_exception, *args, **kwargs): fail(message) -raises.Exception = fail.Exception +raises.Exception = fail.Exception # type: ignore class RaisesContext: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index d2f1f33e2..4682d5b6e 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,4 +1,5 @@ from pprint import pprint +from typing import Optional import py @@ -28,7 +29,7 @@ def getslaveinfoline(node): class BaseReport: - when = None + when = None # type: Optional[str] location = None def __init__(self, **kw): diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index f2c4d905c..48680c07e 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -26,7 +26,10 @@ class TempPathFactory: # using os.path.abspath() to get absolute path instead of resolve() as it # does not work the same in all platforms (see #4427) # Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012) - converter=attr.converters.optional(lambda p: Path(os.path.abspath(str(p)))) + # Ignore type because of https://github.com/python/mypy/issues/6172. + converter=attr.converters.optional( + lambda p: Path(os.path.abspath(str(p))) # type: ignore + ) ) _trace = attr.ib() _basetemp = attr.ib(default=None) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index f7787c282..d7771833a 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -589,7 +589,8 @@ raise ValueError() def test_repr_local_with_exception_in_class_property(self): class ExceptionWithBrokenClass(Exception): - @property + # Type ignored because it's bypassed intentionally. + @property # type: ignore def __class__(self): raise TypeError("boom!") diff --git a/testing/example_scripts/collect/package_infinite_recursion/__init__.pyi b/testing/example_scripts/collect/package_infinite_recursion/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/config/collect_pytest_prefix/__init__.pyi b/testing/example_scripts/config/collect_pytest_prefix/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/conftest_usageerror/__init__.pyi b/testing/example_scripts/conftest_usageerror/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/fixtures/custom_item/__init__.pyi b/testing/example_scripts/fixtures/custom_item/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/__init__.pyi b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/__init__.pyi b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/issue88_initial_file_multinodes/__init__.pyi b/testing/example_scripts/issue88_initial_file_multinodes/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/marks/marks_considered_keywords/__init__.pyi b/testing/example_scripts/marks/marks_considered_keywords/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/testing/python/raises.py b/testing/python/raises.py index ed3a5cd37..a67f25534 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -265,7 +265,8 @@ class TestRaises: """Test current behavior with regard to exceptions via __class__ (#4284).""" class CrappyClass(Exception): - @property + # Type ignored because it's bypassed intentionally. + @property # type: ignore def __class__(self): assert False, "via __class__" diff --git a/testing/test_compat.py b/testing/test_compat.py index 028d48bed..9e7d05c5d 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -141,7 +141,8 @@ def test_safe_isclass(): assert safe_isclass(type) is True class CrappyClass(Exception): - @property + # Type ignored because it's bypassed intentionally. + @property # type: ignore def __class__(self): assert False, "Should be ignored" diff --git a/testing/test_pdb.py b/testing/test_pdb.py index f3f7ca702..8d327cbb3 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -6,7 +6,8 @@ import pytest from _pytest.debugging import _validate_usepdb_cls try: - breakpoint + # Type ignored for Python <= 3.6. + breakpoint # type: ignore except NameError: SUPPORTS_BREAKPOINT_BUILTIN = False else: