From 28761c8da1ec2f16a63fd283e89196f100e7ea03 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 18 Jul 2019 00:39:48 +0300 Subject: [PATCH 1/9] Have AssertionRewritingHook derive from importlib.abc.MetaPathFinder This is nice for self-documentation, and is the type required by mypy for adding to sys.meta_path. --- src/_pytest/assertion/rewrite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 0567e8fb80b..0782bfbeee7 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -2,6 +2,7 @@ import ast import errno import functools +import importlib.abc import importlib.machinery import importlib.util import io @@ -37,7 +38,7 @@ AST_NONE = ast.NameConstant(None) -class AssertionRewritingHook: +class AssertionRewritingHook(importlib.abc.MetaPathFinder): """PEP302/PEP451 import hook which rewrites asserts.""" def __init__(self, config): From 7259c453d6c1dba6727cd328e6db5635ccf5821c Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jul 2019 18:45:40 +0300 Subject: [PATCH 2/9] Fix some check_untyped_defs = True mypy warnings --- src/_pytest/_code/code.py | 27 ++++++++++------ src/_pytest/_code/source.py | 13 ++++---- src/_pytest/assertion/__init__.py | 11 +++++-- src/_pytest/assertion/rewrite.py | 47 +++++++++++++++------------- src/_pytest/assertion/util.py | 15 +++++---- src/_pytest/config/__init__.py | 50 +++++++++++++++++++---------- src/_pytest/config/argparsing.py | 38 +++++++++++++--------- src/_pytest/config/findpaths.py | 12 ++++++- src/_pytest/mark/evaluate.py | 2 ++ src/_pytest/mark/structures.py | 2 +- src/_pytest/nodes.py | 52 +++++++++++++++++++++---------- src/_pytest/reports.py | 7 +++-- src/_pytest/runner.py | 22 ++++++++++--- 13 files changed, 194 insertions(+), 104 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 7d72234e7cb..744e9cf66f6 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -5,10 +5,15 @@ from inspect import CO_VARARGS from inspect import CO_VARKEYWORDS from traceback import format_exception_only +from types import CodeType from types import TracebackType +from typing import Any +from typing import Dict from typing import Generic +from typing import List from typing import Optional from typing import Pattern +from typing import Set from typing import Tuple from typing import TypeVar from typing import Union @@ -29,7 +34,7 @@ class Code: """ wrapper around Python code objects """ - def __init__(self, rawcode): + def __init__(self, rawcode) -> None: if not hasattr(rawcode, "co_filename"): rawcode = getrawcode(rawcode) try: @@ -38,7 +43,7 @@ def __init__(self, rawcode): self.name = rawcode.co_name except AttributeError: raise TypeError("not a code object: {!r}".format(rawcode)) - self.raw = rawcode + self.raw = rawcode # type: CodeType def __eq__(self, other): return self.raw == other.raw @@ -351,7 +356,7 @@ def recursionindex(self): """ return the index of the frame/TracebackEntry where recursion originates if appropriate, None if no recursion occurred """ - cache = {} + cache = {} # type: Dict[Tuple[Any, int, int], List[Dict[str, Any]]] for i, entry in enumerate(self): # id for the code.raw is needed to work around # the strange metaprogramming in the decorator lib from pypi @@ -650,7 +655,7 @@ def repr_args(self, entry): args.append((argname, saferepr(argvalue))) return ReprFuncArgs(args) - def get_source(self, source, line_index=-1, excinfo=None, short=False): + def get_source(self, source, line_index=-1, excinfo=None, short=False) -> List[str]: """ return formatted and marked up source lines. """ import _pytest._code @@ -722,7 +727,7 @@ def repr_traceback_entry(self, entry, excinfo=None): else: line_index = entry.lineno - entry.getfirstlinesource() - lines = [] + lines = [] # type: List[str] style = entry._repr_style if style is None: style = self.style @@ -799,7 +804,7 @@ def _truncate_recursive_traceback(self, traceback): exc_msg=str(e), max_frames=max_frames, total=len(traceback), - ) + ) # type: Optional[str] traceback = traceback[:max_frames] + traceback[-max_frames:] else: if recursionindex is not None: @@ -812,10 +817,12 @@ def _truncate_recursive_traceback(self, traceback): def repr_excinfo(self, excinfo): - repr_chain = [] + repr_chain = ( + [] + ) # type: List[Tuple[ReprTraceback, Optional[ReprFileLocation], Optional[str]]] e = excinfo.value descr = None - seen = set() + seen = set() # type: Set[int] while e is not None and id(e) not in seen: seen.add(id(e)) if excinfo: @@ -868,8 +875,8 @@ def __repr__(self): class ExceptionRepr(TerminalRepr): - def __init__(self): - self.sections = [] + def __init__(self) -> None: + self.sections = [] # type: List[Tuple[str, str, str]] def addsection(self, name, content, sep="-"): self.sections.append((name, content, sep)) diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index ea2fc5e3f53..db78bbd0d35 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -7,6 +7,7 @@ import warnings from ast import PyCF_ONLY_AST as _AST_FLAG from bisect import bisect_right +from typing import List import py @@ -19,11 +20,11 @@ class Source: _compilecounter = 0 def __init__(self, *parts, **kwargs): - self.lines = lines = [] + self.lines = lines = [] # type: List[str] de = kwargs.get("deindent", True) for part in parts: if not part: - partlines = [] + partlines = [] # type: List[str] elif isinstance(part, Source): partlines = part.lines elif isinstance(part, (tuple, list)): @@ -157,8 +158,7 @@ def compile( source = "\n".join(self.lines) + "\n" try: co = compile(source, filename, mode, flag) - except SyntaxError: - ex = sys.exc_info()[1] + except SyntaxError as ex: # re-represent syntax errors from parsing python strings msglines = self.lines[: ex.lineno] if ex.offset: @@ -173,7 +173,8 @@ def compile( if flag & _AST_FLAG: return co lines = [(x + "\n") for x in self.lines] - linecache.cache[filename] = (1, None, lines, filename) + # Type ignored because linecache.cache is private. + linecache.cache[filename] = (1, None, lines, filename) # type: ignore return co @@ -282,7 +283,7 @@ def get_statement_startend2(lineno, node): return start, end -def getstatementrange_ast(lineno, source, assertion=False, astnode=None): +def getstatementrange_ast(lineno, source: Source, assertion=False, astnode=None): if astnode is None: content = str(source) # See #4260: diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 126929b6ad9..3b42b356d5b 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -2,6 +2,7 @@ support for presenting detailed information in failing assertions. """ import sys +from typing import Optional from _pytest.assertion import rewrite from _pytest.assertion import truncate @@ -52,7 +53,9 @@ def register_assert_rewrite(*names): importhook = hook break else: - importhook = DummyRewriteHook() + # TODO(typing): Add a protocol for mark_rewrite() and use it + # for importhook and for PytestPluginManager.rewrite_hook. + importhook = DummyRewriteHook() # type: ignore importhook.mark_rewrite(*names) @@ -69,7 +72,7 @@ class AssertionState: def __init__(self, config, mode): self.mode = mode self.trace = config.trace.root.get("assertion") - self.hook = None + self.hook = None # type: Optional[rewrite.AssertionRewritingHook] def install_importhook(config): @@ -108,6 +111,7 @@ def pytest_runtest_setup(item): """ def callbinrepr(op, left, right): + # type: (str, object, object) -> Optional[str] """Call the pytest_assertrepr_compare hook and prepare the result This uses the first result from the hook and then ensures the @@ -133,12 +137,13 @@ def callbinrepr(op, left, right): if item.config.getvalue("assertmode") == "rewrite": res = res.replace("%", "%%") return res + return None util._reprcompare = callbinrepr if item.ihook.pytest_assertion_pass.get_hookimpls(): - def call_assertion_pass_hook(lineno, expl, orig): + def call_assertion_pass_hook(lineno, orig, expl): item.ihook.pytest_assertion_pass( item=item, lineno=lineno, orig=orig, expl=expl ) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 0782bfbeee7..df513144982 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -17,6 +17,7 @@ from typing import List from typing import Optional from typing import Set +from typing import Tuple import atomicwrites @@ -48,13 +49,13 @@ def __init__(self, config): except ValueError: self.fnpats = ["test_*.py", "*_test.py"] self.session = None - self._rewritten_names = set() - self._must_rewrite = set() + self._rewritten_names = set() # type: Set[str] + self._must_rewrite = set() # type: Set[str] # flag to guard against trying to rewrite a pyc file while we are already writing another pyc file, # which might result in infinite recursion (#3506) self._writing_pyc = False self._basenames_to_check_rewrite = {"conftest"} - self._marked_for_rewrite_cache = {} + self._marked_for_rewrite_cache = {} # type: Dict[str, bool] self._session_paths_checked = False def set_session(self, session): @@ -203,7 +204,7 @@ def _should_rewrite(self, name, fn, state): return self._is_marked_for_rewrite(name, state) - def _is_marked_for_rewrite(self, name, state): + def _is_marked_for_rewrite(self, name: str, state): try: return self._marked_for_rewrite_cache[name] except KeyError: @@ -218,7 +219,7 @@ def _is_marked_for_rewrite(self, name, state): self._marked_for_rewrite_cache[name] = False return False - def mark_rewrite(self, *names): + def mark_rewrite(self, *names: str) -> None: """Mark import names as needing to be rewritten. The named module or package as well as any nested modules will @@ -385,6 +386,7 @@ def _format_boolop(explanations, is_or): def _call_reprcompare(ops, results, expls, each_obj): + # type: (Tuple[str, ...], Tuple[bool, ...], Tuple[str, ...], Tuple[object, ...]) -> str for i, res, expl in zip(range(len(ops)), results, expls): try: done = not res @@ -400,11 +402,13 @@ def _call_reprcompare(ops, results, expls, each_obj): def _call_assertion_pass(lineno, orig, expl): + # type: (int, str, str) -> None if util._assertion_pass is not None: - util._assertion_pass(lineno=lineno, orig=orig, expl=expl) + util._assertion_pass(lineno, orig, expl) def _check_if_assertion_pass_impl(): + # type: () -> bool """Checks if any plugins implement the pytest_assertion_pass hook in order not to generate explanation unecessarily (might be expensive)""" return True if util._assertion_pass else False @@ -578,7 +582,7 @@ def __init__(self, module_path, config, source): def _assert_expr_to_lineno(self): return _get_assertion_exprs(self.source) - def run(self, mod): + def run(self, mod: ast.Module) -> None: """Find all assert statements in *mod* and rewrite them.""" if not mod.body: # Nothing to do. @@ -620,12 +624,12 @@ def run(self, mod): ] mod.body[pos:pos] = imports # Collect asserts. - nodes = [mod] + nodes = [mod] # type: List[ast.AST] while nodes: node = nodes.pop() for name, field in ast.iter_fields(node): if isinstance(field, list): - new = [] + new = [] # type: List for i, child in enumerate(field): if isinstance(child, ast.Assert): # Transform assert. @@ -699,7 +703,7 @@ def push_format_context(self): .explanation_param(). """ - self.explanation_specifiers = {} + self.explanation_specifiers = {} # type: Dict[str, ast.expr] self.stack.append(self.explanation_specifiers) def pop_format_context(self, expl_expr): @@ -742,7 +746,8 @@ def visit_Assert(self, assert_): from _pytest.warning_types import PytestAssertRewriteWarning import warnings - warnings.warn_explicit( + # Ignore type: typeshed bug https://github.com/python/typeshed/pull/3121 + warnings.warn_explicit( # type: ignore PytestAssertRewriteWarning( "assertion is always true, perhaps remove parentheses?" ), @@ -751,15 +756,15 @@ def visit_Assert(self, assert_): lineno=assert_.lineno, ) - self.statements = [] - self.variables = [] + self.statements = [] # type: List[ast.stmt] + self.variables = [] # type: List[str] self.variable_counter = itertools.count() if self.enable_assertion_pass_hook: - self.format_variables = [] + self.format_variables = [] # type: List[str] - self.stack = [] - self.expl_stmts = [] + self.stack = [] # type: List[Dict[str, ast.expr]] + self.expl_stmts = [] # type: List[ast.stmt] self.push_format_context() # Rewrite assert into a bunch of statements. top_condition, explanation = self.visit(assert_.test) @@ -897,7 +902,7 @@ def visit_BoolOp(self, boolop): # Process each operand, short-circuiting if needed. for i, v in enumerate(boolop.values): if i: - fail_inner = [] + fail_inner = [] # type: List[ast.stmt] # cond is set in a prior loop iteration below self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa self.expl_stmts = fail_inner @@ -908,10 +913,10 @@ def visit_BoolOp(self, boolop): call = ast.Call(app, [expl_format], []) self.expl_stmts.append(ast.Expr(call)) if i < levels: - cond = res + cond = res # type: ast.expr if is_or: cond = ast.UnaryOp(ast.Not(), cond) - inner = [] + inner = [] # type: List[ast.stmt] self.statements.append(ast.If(cond, inner, [])) self.statements = body = inner self.statements = save @@ -977,7 +982,7 @@ def visit_Attribute(self, attr): expl = pat % (res_expl, res_expl, value_expl, attr.attr) return res, expl - def visit_Compare(self, comp): + def visit_Compare(self, comp: ast.Compare): self.push_format_context() left_res, left_expl = self.visit(comp.left) if isinstance(comp.left, (ast.Compare, ast.BoolOp)): @@ -1010,7 +1015,7 @@ def visit_Compare(self, comp): ast.Tuple(results, ast.Load()), ) if len(comp.ops) > 1: - res = ast.BoolOp(ast.And(), load_names) + res = ast.BoolOp(ast.And(), load_names) # type: ast.expr else: res = load_names[0] return res, self.explanation_param(self.pop_format_context(expl_call)) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 732194ec222..11c7bdf6f85 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -1,6 +1,9 @@ """Utilities for assertion debugging""" import pprint from collections.abc import Sequence +from typing import Callable +from typing import List +from typing import Optional import _pytest._code from _pytest import outcomes @@ -10,11 +13,11 @@ # interpretation code and assertion rewriter to detect this plugin was # loaded and in turn call the hooks defined here as part of the # DebugInterpreter. -_reprcompare = None +_reprcompare = None # type: Optional[Callable[[str, object, object], Optional[str]]] # Works similarly as _reprcompare attribute. Is populated with the hook call # when pytest_runtest_setup is called. -_assertion_pass = None +_assertion_pass = None # type: Optional[Callable[[int, str, str], None]] def format_explanation(explanation): @@ -177,7 +180,7 @@ def _diff_text(left, right, verbose=0): """ from difflib import ndiff - explanation = [] + explanation = [] # type: List[str] def escape_for_readable_diff(binary_text): """ @@ -235,7 +238,7 @@ def _compare_eq_verbose(left, right): left_lines = repr(left).splitlines(keepends) right_lines = repr(right).splitlines(keepends) - explanation = [] + explanation = [] # type: List[str] explanation += ["-" + line for line in left_lines] explanation += ["+" + line for line in right_lines] @@ -259,7 +262,7 @@ def _compare_eq_iterable(left, right, verbose=0): def _compare_eq_sequence(left, right, verbose=0): comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) - explanation = [] + explanation = [] # type: List[str] len_left = len(left) len_right = len(right) for i in range(min(len_left, len_right)): @@ -327,7 +330,7 @@ def _compare_eq_set(left, right, verbose=0): def _compare_eq_dict(left, right, verbose=0): - explanation = [] + explanation = [] # type: List[str] set_left = set(left) set_right = set(right) common = set_left.intersection(set_right) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index b861563e99b..d547f033de1 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -9,6 +9,15 @@ import warnings from functools import lru_cache from pathlib import Path +from types import TracebackType +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Set +from typing import Tuple import attr import py @@ -32,6 +41,10 @@ from _pytest.outcomes import Skipped from _pytest.warning_types import PytestConfigWarning +if False: # TYPE_CHECKING + from typing import Type + + hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") @@ -40,7 +53,7 @@ class ConftestImportFailure(Exception): def __init__(self, path, excinfo): Exception.__init__(self, path, excinfo) self.path = path - self.excinfo = excinfo + self.excinfo = excinfo # type: Tuple[Type[Exception], Exception, TracebackType] def main(args=None, plugins=None): @@ -237,14 +250,18 @@ class PytestPluginManager(PluginManager): def __init__(self): super().__init__("pytest") - self._conftest_plugins = set() + # The objects are module objects, only used generically. + self._conftest_plugins = set() # type: Set[object] # state related to local conftest plugins - self._dirpath2confmods = {} - self._conftestpath2mod = {} + # Maps a py.path.local to a list of module objects. + self._dirpath2confmods = {} # type: Dict[Any, List[object]] + # Maps a py.path.local to a module object. + self._conftestpath2mod = {} # type: Dict[Any, object] self._confcutdir = None self._noconftest = False - self._duplicatepaths = set() + # Set of py.path.local's. + self._duplicatepaths = set() # type: Set[Any] self.add_hookspecs(_pytest.hookspec) self.register(self) @@ -653,7 +670,7 @@ class InvocationParams: args = attr.ib() plugins = attr.ib() - dir = attr.ib() + dir = attr.ib(type=Path) def __init__(self, pluginmanager, *, invocation_params=None): from .argparsing import Parser, FILE_OR_DIR @@ -674,10 +691,10 @@ def __init__(self, pluginmanager, *, invocation_params=None): self.pluginmanager = pluginmanager self.trace = self.pluginmanager.trace.root.get("config") self.hook = self.pluginmanager.hook - self._inicache = {} - self._override_ini = () - self._opt2dest = {} - self._cleanup = [] + self._inicache = {} # type: Dict[str, Any] + self._override_ini = () # type: Sequence[str] + self._opt2dest = {} # type: Dict[str, str] + self._cleanup = [] # type: List[Callable[[], None]] self.pluginmanager.register(self, "pytestconfig") self._configured = False self.hook.pytest_addoption.call_historic(kwargs=dict(parser=self._parser)) @@ -778,7 +795,7 @@ def _processopt(self, opt): def pytest_load_initial_conftests(self, early_config): self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) - def _initini(self, args): + def _initini(self, args) -> None: ns, unknown_args = self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) @@ -879,8 +896,7 @@ def _preparse(self, args, addopts=True): self.hook.pytest_load_initial_conftests( early_config=self, args=args, parser=self._parser ) - except ConftestImportFailure: - e = sys.exc_info()[1] + except ConftestImportFailure as e: if ns.help or ns.version: # we don't want to prevent --help/--version to work # so just let is pass and print a warning at the end @@ -946,7 +962,7 @@ def addinivalue_line(self, name, line): assert isinstance(x, list) x.append(line) # modifies the cached list inline - def getini(self, name): + def getini(self, name: str): """ return configuration value from an :ref:`ini file `. If the specified name hasn't been registered through a prior :py:func:`parser.addini <_pytest.config.Parser.addini>` @@ -957,7 +973,7 @@ def getini(self, name): self._inicache[name] = val = self._getini(name) return val - def _getini(self, name): + def _getini(self, name: str) -> Any: try: description, type, default = self._parser._inidict[name] except KeyError: @@ -1002,7 +1018,7 @@ def _getconftest_pathlist(self, name, path): values.append(relroot) return values - def _get_override_ini_value(self, name): + def _get_override_ini_value(self, name: str) -> Optional[str]: value = None # override_ini is a list of "ini=value" options # always use the last item if multiple values are set for same ini-name, @@ -1017,7 +1033,7 @@ def _get_override_ini_value(self, name): value = user_ini_value return value - def getoption(self, name, default=notset, skip=False): + def getoption(self, name: str, default=notset, skip: bool = False): """ return command line option value. :arg name: name of the option. You may also specify diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 8994ff7d9d7..4bf3b54ba03 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -2,6 +2,11 @@ import sys import warnings from gettext import gettext +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple import py @@ -21,12 +26,12 @@ class Parser: def __init__(self, usage=None, processopt=None): self._anonymous = OptionGroup("custom options", parser=self) - self._groups = [] + self._groups = [] # type: List[OptionGroup] self._processopt = processopt self._usage = usage - self._inidict = {} - self._ininames = [] - self.extra_info = {} + self._inidict = {} # type: Dict[str, Tuple[str, Optional[str], Any]] + self._ininames = [] # type: List[str] + self.extra_info = {} # type: Dict[str, Any] def processoption(self, option): if self._processopt: @@ -80,7 +85,7 @@ def parse(self, args, namespace=None): args = [str(x) if isinstance(x, py.path.local) else x for x in args] return self.optparser.parse_args(args, namespace=namespace) - def _getparser(self): + def _getparser(self) -> "MyOptionParser": from _pytest._argcomplete import filescompleter optparser = MyOptionParser(self, self.extra_info, prog=self.prog) @@ -94,7 +99,10 @@ def _getparser(self): a = option.attrs() arggroup.add_argument(*n, **a) # bash like autocompletion for dirs (appending '/') - optparser.add_argument(FILE_OR_DIR, nargs="*").completer = filescompleter + # Type ignored because typeshed doesn't know about argcomplete. + optparser.add_argument( # type: ignore + FILE_OR_DIR, nargs="*" + ).completer = filescompleter return optparser def parse_setoption(self, args, option, namespace=None): @@ -103,13 +111,15 @@ def parse_setoption(self, args, option, namespace=None): setattr(option, name, value) return getattr(parsedoption, FILE_OR_DIR) - def parse_known_args(self, args, namespace=None): + def parse_known_args(self, args, namespace=None) -> argparse.Namespace: """parses and returns a namespace object with known arguments at this point. """ return self.parse_known_and_unknown_args(args, namespace=namespace)[0] - def parse_known_and_unknown_args(self, args, namespace=None): + def parse_known_and_unknown_args( + self, args, namespace=None + ) -> Tuple[argparse.Namespace, List[str]]: """parses and returns a namespace object with known arguments, and the remaining arguments unknown at this point. """ @@ -163,8 +173,8 @@ class Argument: def __init__(self, *names, **attrs): """store parms in private vars for use in add_argument""" self._attrs = attrs - self._short_opts = [] - self._long_opts = [] + self._short_opts = [] # type: List[str] + self._long_opts = [] # type: List[str] self.dest = attrs.get("dest") if "%default" in (attrs.get("help") or ""): warnings.warn( @@ -268,8 +278,8 @@ def _set_opt_strings(self, opts): ) self._long_opts.append(opt) - def __repr__(self): - args = [] + def __repr__(self) -> str: + args = [] # type: List[str] if self._short_opts: args += ["_short_opts: " + repr(self._short_opts)] if self._long_opts: @@ -286,7 +296,7 @@ class OptionGroup: def __init__(self, name, description="", parser=None): self.name = name self.description = description - self.options = [] + self.options = [] # type: List[Argument] self.parser = parser def addoption(self, *optnames, **attrs): @@ -421,7 +431,7 @@ def _format_action_invocation(self, action): option_map = getattr(action, "map_long_option", {}) if option_map is None: option_map = {} - short_long = {} + short_long = {} # type: Dict[str, str] for option in options: if len(option) == 2 or option[2] == " ": continue diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index ec991316af1..f06c9cfffb1 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -1,10 +1,15 @@ import os +from typing import List +from typing import Optional import py from .exceptions import UsageError from _pytest.outcomes import fail +if False: + from . import Config # noqa: F401 + def exists(path, ignore=EnvironmentError): try: @@ -102,7 +107,12 @@ def get_dir_from_path(path): CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead." -def determine_setup(inifile, args, rootdir_cmd_arg=None, config=None): +def determine_setup( + inifile: str, + args: List[str], + rootdir_cmd_arg: Optional[str] = None, + config: Optional["Config"] = None, +): dirs = get_dirs_from_args(args) if inifile: iniconfig = py.iniconfig.IniConfig(inifile) diff --git a/src/_pytest/mark/evaluate.py b/src/_pytest/mark/evaluate.py index 898278e30b3..b9f2d61f835 100644 --- a/src/_pytest/mark/evaluate.py +++ b/src/_pytest/mark/evaluate.py @@ -51,6 +51,8 @@ def istrue(self): except TEST_OUTCOME: self.exc = sys.exc_info() if isinstance(self.exc[1], SyntaxError): + # TODO: Investigate why SyntaxError.offset is Optional, and if it can be None here. + assert self.exc[1].offset is not None msg = [" " * (self.exc[1].offset + 4) + "^"] msg.append("SyntaxError: invalid syntax") else: diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 332c86bdecf..f8cf55b4cb6 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -292,7 +292,7 @@ def test_function(): _config = None _markers = set() # type: Set[str] - def __getattr__(self, name): + def __getattr__(self, name: str) -> MarkDecorator: if name[0] == "_": raise AttributeError("Marker name must NOT start with underscore") diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 9b78dca38aa..b1bbc2943db 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -1,14 +1,26 @@ import os import warnings from functools import lru_cache +from typing import Any +from typing import Dict +from typing import List +from typing import Set +from typing import Tuple +from typing import Union import py import _pytest._code from _pytest.compat import getfslineno +from _pytest.mark.structures import Mark +from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords from _pytest.outcomes import fail +if False: # TYPE_CHECKING + # Imported here due to circular import. + from _pytest.fixtures import FixtureDef + SEP = "/" tracebackcutdir = py.path.local(_pytest.__file__).dirpath() @@ -78,13 +90,13 @@ def __init__( self.keywords = NodeKeywords(self) #: the marker objects belonging to this node - self.own_markers = [] + self.own_markers = [] # type: List[Mark] #: allow adding of extra keywords to use for matching - self.extra_keyword_matches = set() + self.extra_keyword_matches = set() # type: Set[str] # used for storing artificial fixturedefs for direct parametrization - self._name2pseudofixturedef = {} + self._name2pseudofixturedef = {} # type: Dict[str, FixtureDef] if nodeid is not None: assert "::()" not in nodeid @@ -127,7 +139,8 @@ def warn(self, warning): ) ) path, lineno = get_fslocation_from_item(self) - warnings.warn_explicit( + # Type ignored: https://github.com/python/typeshed/pull/3121 + warnings.warn_explicit( # type: ignore warning, category=None, filename=str(path), @@ -160,7 +173,9 @@ def listchain(self): chain.reverse() return chain - def add_marker(self, marker, append=True): + def add_marker( + self, marker: Union[str, MarkDecorator], append: bool = True + ) -> None: """dynamically add a marker object to the node. :type marker: ``str`` or ``pytest.mark.*`` object @@ -168,17 +183,19 @@ def add_marker(self, marker, append=True): ``append=True`` whether to append the marker, if ``False`` insert at position ``0``. """ - from _pytest.mark import MarkDecorator, MARK_GEN + from _pytest.mark import MARK_GEN - if isinstance(marker, str): - marker = getattr(MARK_GEN, marker) - elif not isinstance(marker, MarkDecorator): + if isinstance(marker, MarkDecorator): + marker_ = marker + elif isinstance(marker, str): + marker_ = getattr(MARK_GEN, marker) + else: raise ValueError("is not a string or pytest.mark.* Marker") - self.keywords[marker.name] = marker + self.keywords[marker_.name] = marker if append: - self.own_markers.append(marker.mark) + self.own_markers.append(marker_.mark) else: - self.own_markers.insert(0, marker.mark) + self.own_markers.insert(0, marker_.mark) def iter_markers(self, name=None): """ @@ -211,7 +228,7 @@ def get_closest_marker(self, name, default=None): def listextrakeywords(self): """ Return a set of all extra keywords in self and any parents.""" - extra_keywords = set() + extra_keywords = set() # type: Set[str] for item in self.listchain(): extra_keywords.update(item.extra_keyword_matches) return extra_keywords @@ -239,7 +256,8 @@ def _prunetraceback(self, excinfo): pass def _repr_failure_py(self, excinfo, style=None): - if excinfo.errisinstance(fail.Exception): + # Type ignored: see comment where fail.Exception is defined. + if excinfo.errisinstance(fail.Exception): # type: ignore if not excinfo.value.pytrace: return str(excinfo.value) fm = self.session._fixturemanager @@ -385,13 +403,13 @@ class Item(Node): def __init__(self, name, parent=None, config=None, session=None, nodeid=None): super().__init__(name, parent, config, session, nodeid=nodeid) - self._report_sections = [] + self._report_sections = [] # type: List[Tuple[str, str, str]] #: user properties is a list of tuples (name, value) that holds user #: defined properties for this test. - self.user_properties = [] + self.user_properties = [] # type: List[Tuple[str, Any]] - def add_report_section(self, when, key, content): + def add_report_section(self, when: str, key: str, content: str) -> None: """ Adds a new report section, similar to what's done internally to add stdout and stderr captured output:: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 4682d5b6ec2..7324083239a 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,5 +1,6 @@ from pprint import pprint from typing import Optional +from typing import Union import py @@ -221,7 +222,6 @@ def _from_json(cls, reportdict): reprcrash = reportdict["longrepr"]["reprcrash"] unserialized_entries = [] - reprentry = None for entry_data in reprtraceback["reprentries"]: data = entry_data["data"] entry_type = entry_data["type"] @@ -242,7 +242,7 @@ def _from_json(cls, reportdict): reprlocals=reprlocals, filelocrepr=reprfileloc, style=data["style"], - ) + ) # type: Union[ReprEntry, ReprEntryNative] elif entry_type == "ReprEntryNative": reprentry = ReprEntryNative(data["lines"]) else: @@ -352,7 +352,8 @@ def from_item_and_call(cls, item, call): if not isinstance(excinfo, ExceptionInfo): outcome = "failed" longrepr = excinfo - elif excinfo.errisinstance(skip.Exception): + # Type ignored -- see comment where skip.Exception is defined. + elif excinfo.errisinstance(skip.Exception): # type: ignore outcome = "skipped" r = excinfo._getreprcrash() longrepr = (str(r.path), r.lineno, r.message) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 8aae163c3d0..7d8b74a806e 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -3,6 +3,10 @@ import os import sys from time import time +from typing import Callable +from typing import Dict +from typing import List +from typing import Tuple import attr @@ -10,10 +14,14 @@ from .reports import CollectReport from .reports import TestReport from _pytest._code.code import ExceptionInfo +from _pytest.nodes import Node from _pytest.outcomes import Exit from _pytest.outcomes import Skipped from _pytest.outcomes import TEST_OUTCOME +if False: # TYPE_CHECKING + from typing import Type + # # pytest plugin hooks @@ -118,6 +126,7 @@ def pytest_runtest_call(item): except Exception: # Store trace info to allow postmortem debugging type, value, tb = sys.exc_info() + assert tb is not None tb = tb.tb_next # Skip *this* frame sys.last_type = type sys.last_value = value @@ -185,7 +194,7 @@ def check_interactive_exception(call, report): def call_runtest_hook(item, when, **kwds): hookname = "pytest_runtest_" + when ihook = getattr(item.ihook, hookname) - reraise = (Exit,) + reraise = (Exit,) # type: Tuple[Type[BaseException], ...] if not item.config.getoption("usepdb", False): reraise += (KeyboardInterrupt,) return CallInfo.from_call( @@ -252,7 +261,8 @@ def pytest_make_collect_report(collector): skip_exceptions = [Skipped] unittest = sys.modules.get("unittest") if unittest is not None: - skip_exceptions.append(unittest.SkipTest) + # Type ignored because unittest is loaded dynamically. + skip_exceptions.append(unittest.SkipTest) # type: ignore if call.excinfo.errisinstance(tuple(skip_exceptions)): outcome = "skipped" r = collector._repr_failure_py(call.excinfo, "line").reprcrash @@ -266,7 +276,7 @@ def pytest_make_collect_report(collector): rep = CollectReport( collector.nodeid, outcome, longrepr, getattr(call, "result", None) ) - rep.call = call # see collect_one_node + rep.call = call # type: ignore # see collect_one_node return rep @@ -274,8 +284,8 @@ class SetupState: """ shared state for setting up/tearing down test items or collectors. """ def __init__(self): - self.stack = [] - self._finalizers = {} + self.stack = [] # type: List[Node] + self._finalizers = {} # type: Dict[Node, List[Callable[[], None]]] def addfinalizer(self, finalizer, colitem): """ attach a finalizer to the given colitem. """ @@ -302,6 +312,7 @@ def _callfinalizers(self, colitem): exc = sys.exc_info() if exc: _, val, tb = exc + assert val is not None raise val.with_traceback(tb) def _teardown_with_finalization(self, colitem): @@ -335,6 +346,7 @@ def _teardown_towards(self, needed_collectors): exc = sys.exc_info() if exc: _, val, tb = exc + assert val is not None raise val.with_traceback(tb) def prepare(self, colitem): From 2a6a1ca07dac49cc7ceb64543ea16ad457f1a1dd Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 5 Apr 2019 15:16:35 +0200 Subject: [PATCH 3/9] Inject width via pylib to argparse formatter `argparse.HelpFormatter` looks at `$COLUMNS` only, falling back to a default of 80. `py.io.get_terminal_width()` is smarter there, and could even work better with https://github.com/pytest-dev/py/pull/219. This ensures to use a consistent value for formatting the ini values etc. --- changelog/5056.trivial.rst | 1 + src/_pytest/config/argparsing.py | 6 ++++++ testing/test_config.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 changelog/5056.trivial.rst diff --git a/changelog/5056.trivial.rst b/changelog/5056.trivial.rst new file mode 100644 index 00000000000..75e01a88b1d --- /dev/null +++ b/changelog/5056.trivial.rst @@ -0,0 +1 @@ +The HelpFormatter uses ``py.io.get_terminal_width`` for better width detection. diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 8994ff7d9d7..de3c7d90b0e 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -405,6 +405,12 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): - cache result on action object as this is called at least 2 times """ + def __init__(self, *args, **kwargs): + """Use more accurate terminal width via pylib.""" + if "width" not in kwargs: + kwargs["width"] = py.io.get_terminal_width() + super().__init__(*args, **kwargs) + def _format_action_invocation(self, action): orgstr = argparse.HelpFormatter._format_action_invocation(self, action) if orgstr and orgstr[0] != "-": # only optional arguments diff --git a/testing/test_config.py b/testing/test_config.py index fc3659d2ae1..71dae5c4cdb 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1194,6 +1194,21 @@ def pytest_addoption(parser): assert result.ret == ExitCode.USAGE_ERROR +def test_help_formatter_uses_py_get_terminal_width(testdir, monkeypatch): + from _pytest.config.argparsing import DropShorterLongHelpFormatter + + monkeypatch.setenv("COLUMNS", "90") + formatter = DropShorterLongHelpFormatter("prog") + assert formatter._width == 90 + + monkeypatch.setattr("py.io.get_terminal_width", lambda: 160) + formatter = DropShorterLongHelpFormatter("prog") + assert formatter._width == 160 + + formatter = DropShorterLongHelpFormatter("prog", width=42) + assert formatter._width == 42 + + def test_config_does_not_load_blocked_plugin_from_args(testdir): """This tests that pytest's config setup handles "-p no:X".""" p = testdir.makepyfile("def test(capfd): pass") From d47b9d04d4cf824150caef46c9c888779c1b3f58 Mon Sep 17 00:00:00 2001 From: Michael Goerz Date: Sun, 18 Aug 2019 13:32:46 -0400 Subject: [PATCH 4/9] Gracefully handle HTTP errors from pastebin We find that the --pastebin option to pytest sometimes fails with "HTTP Error 400: Bad Request". We're still investigating the exact cause of these errors, but in the meantime, a failure to upload to the pastebin service should probably not crash pytest and cause a test failure in the continuous-integration. This patch catches exceptions like HTTPError that may be thrown while trying to communicate with the pastebin service, and reports them as a "bad response", without crashing with a backtrace or failing the entire test suite. --- AUTHORS | 1 + changelog/5764.feature.rst | 1 + src/_pytest/pastebin.py | 13 ++++++--- testing/test_pastebin.py | 56 +++++++++++++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 changelog/5764.feature.rst diff --git a/AUTHORS b/AUTHORS index 88bbfe3527b..6d1a2a81606 100644 --- a/AUTHORS +++ b/AUTHORS @@ -173,6 +173,7 @@ mbyt Michael Aquilina Michael Birtwell Michael Droettboom +Michael Goerz Michael Seifert Michal Wajszczuk Mihai Capotă diff --git a/changelog/5764.feature.rst b/changelog/5764.feature.rst new file mode 100644 index 00000000000..3ac77b8fe7d --- /dev/null +++ b/changelog/5764.feature.rst @@ -0,0 +1 @@ +New behavior of the ``--pastebin`` option: failures to connect to the pastebin server are reported, without failing the pytest run diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 91aa5f1fdcb..38ff97f2ddd 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -59,7 +59,7 @@ def create_new_paste(contents): Creates a new paste using bpaste.net service. :contents: paste contents as utf-8 encoded bytes - :returns: url to the pasted contents + :returns: url to the pasted contents or error message """ import re from urllib.request import urlopen @@ -67,12 +67,17 @@ def create_new_paste(contents): params = {"code": contents, "lexer": "python3", "expiry": "1week"} url = "https://bpaste.net" - response = urlopen(url, data=urlencode(params).encode("ascii")).read() - m = re.search(r'href="/raw/(\w+)"', response.decode("utf-8")) + try: + response = ( + urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") + ) + except OSError as exc_info: # urllib errors + return "bad response: %s" % exc_info + m = re.search(r'href="/raw/(\w+)"', response) if m: return "{}/show/{}".format(url, m.group(1)) else: - return "bad response: " + response.decode("utf-8") + return "bad response: invalid format ('" + response + "')" def pytest_terminal_summary(terminalreporter): diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index 4e8bac56cb2..a1bc0622eb3 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -82,6 +82,47 @@ class TestPaste: def pastebin(self, request): return request.config.pluginmanager.getplugin("pastebin") + @pytest.fixture + def mocked_urlopen_fail(self, monkeypatch): + """ + monkeypatch the actual urlopen call to emulate a HTTP Error 400 + """ + calls = [] + + import urllib.error + import urllib.request + + def mocked(url, data): + calls.append((url, data)) + raise urllib.error.HTTPError(url, 400, "Bad request", None, None) + + monkeypatch.setattr(urllib.request, "urlopen", mocked) + return calls + + @pytest.fixture + def mocked_urlopen_invalid(self, monkeypatch): + """ + monkeypatch the actual urlopen calls done by the internal plugin + function that connects to bpaste service, but return a url in an + unexpected format + """ + calls = [] + + def mocked(url, data): + calls.append((url, data)) + + class DummyFile: + def read(self): + # part of html of a normal response + return b'View raw.' + + return DummyFile() + + import urllib.request + + monkeypatch.setattr(urllib.request, "urlopen", mocked) + return calls + @pytest.fixture def mocked_urlopen(self, monkeypatch): """ @@ -105,6 +146,19 @@ def read(self): monkeypatch.setattr(urllib.request, "urlopen", mocked) return calls + def test_pastebin_invalid_url(self, pastebin, mocked_urlopen_invalid): + result = pastebin.create_new_paste(b"full-paste-contents") + assert ( + result + == "bad response: invalid format ('View raw.')" + ) + assert len(mocked_urlopen_invalid) == 1 + + def test_pastebin_http_error(self, pastebin, mocked_urlopen_fail): + result = pastebin.create_new_paste(b"full-paste-contents") + assert result == "bad response: HTTP Error 400: Bad request" + assert len(mocked_urlopen_fail) == 1 + def test_create_new_paste(self, pastebin, mocked_urlopen): result = pastebin.create_new_paste(b"full-paste-contents") assert result == "https://bpaste.net/show/3c0c6750bd" @@ -127,4 +181,4 @@ def response(url, data): monkeypatch.setattr(urllib.request, "urlopen", response) result = pastebin.create_new_paste(b"full-paste-contents") - assert result == "bad response: something bad occurred" + assert result == "bad response: invalid format ('something bad occurred')" From f8dd6349c13d47223f6c280f8c755cc0e1196d41 Mon Sep 17 00:00:00 2001 From: Michael Goerz Date: Fri, 30 Aug 2019 15:34:03 -0400 Subject: [PATCH 5/9] Fix "lexer" being used when uploading to bpaste.net Closes #5806. --- changelog/5806.bugfix.rst | 1 + src/_pytest/pastebin.py | 2 +- testing/test_pastebin.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changelog/5806.bugfix.rst diff --git a/changelog/5806.bugfix.rst b/changelog/5806.bugfix.rst new file mode 100644 index 00000000000..ec887768ddd --- /dev/null +++ b/changelog/5806.bugfix.rst @@ -0,0 +1 @@ +Fix "lexer" being used when uploading to bpaste.net from ``--pastebin`` to "text". diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 38ff97f2ddd..77b4e2621eb 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -65,7 +65,7 @@ def create_new_paste(contents): from urllib.request import urlopen from urllib.parse import urlencode - params = {"code": contents, "lexer": "python3", "expiry": "1week"} + params = {"code": contents, "lexer": "text", "expiry": "1week"} url = "https://bpaste.net" try: response = ( diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index a1bc0622eb3..86a42f9e8a1 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -165,7 +165,7 @@ def test_create_new_paste(self, pastebin, mocked_urlopen): assert len(mocked_urlopen) == 1 url, data = mocked_urlopen[0] assert type(data) is bytes - lexer = "python3" + lexer = "text" assert url == "https://bpaste.net" assert "lexer=%s" % lexer in data.decode() assert "code=full-paste-contents" in data.decode() From 10bf6aac76d5060a0db4a94871d6dcf0a1cce397 Mon Sep 17 00:00:00 2001 From: aklajnert Date: Wed, 21 Aug 2019 14:41:37 +0200 Subject: [PATCH 6/9] Implemented the dynamic scope feature. --- AUTHORS | 1 + changelog/1682.deprecation.rst | 2 + changelog/1682.feature.rst | 3 + doc/en/example/costlysetup/conftest.py | 2 +- doc/en/fixture.rst | 26 ++++++ src/_pytest/deprecated.py | 5 ++ src/_pytest/fixtures.py | 79 +++++++++++++++-- testing/python/fixtures.py | 113 +++++++++++++++++++++++++ 8 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 changelog/1682.deprecation.rst create mode 100644 changelog/1682.feature.rst diff --git a/AUTHORS b/AUTHORS index 6d1a2a81606..1ceb66dde61 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Andras Tim Andrea Cimatoribus Andreas Zeidler Andrey Paramonov +Andrzej Klajnert Andrzej Ostrowski Andy Freeland Anthon van der Neut diff --git a/changelog/1682.deprecation.rst b/changelog/1682.deprecation.rst new file mode 100644 index 00000000000..741164eb67b --- /dev/null +++ b/changelog/1682.deprecation.rst @@ -0,0 +1,2 @@ +Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them +as a keyword argument instead. diff --git a/changelog/1682.feature.rst b/changelog/1682.feature.rst new file mode 100644 index 00000000000..392de636390 --- /dev/null +++ b/changelog/1682.feature.rst @@ -0,0 +1,3 @@ +The ``scope`` parameter of ``@pytest.fixture`` can now be a callable that receives +the fixture name and the ``config`` object as keyword-only parameters. +See `the docs `__ for more information. diff --git a/doc/en/example/costlysetup/conftest.py b/doc/en/example/costlysetup/conftest.py index 8fa9c9fe475..80355983466 100644 --- a/doc/en/example/costlysetup/conftest.py +++ b/doc/en/example/costlysetup/conftest.py @@ -1,7 +1,7 @@ import pytest -@pytest.fixture("session") +@pytest.fixture(scope="session") def setup(request): setup = CostlySetup() yield setup diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index 91b5aca85e2..1d6370bb5fc 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -301,6 +301,32 @@ are finalized when the last test of a *package* finishes. Use this new feature sparingly and please make sure to report any issues you find. +Dynamic scope +^^^^^^^^^^^^^ + +In some cases, you might want to change the scope of the fixture without changing the code. +To do that, pass a callable to ``scope``. The callable must return a string with a valid scope +and will be executed only once - during the fixture definition. It will be called with two +keyword arguments - ``fixture_name`` as a string and ``config`` with a configuration object. + +This can be especially useful when dealing with fixtures that need time for setup, like spawning +a docker container. You can use the command-line argument to control the scope of the spawned +containers for different environments. See the example below. + +.. code-block:: python + + def determine_scope(fixture_name, config): + if config.getoption("--keep-containers"): + return "session" + return "function" + + + @pytest.fixture(scope=determine_scope) + def docker_container(): + yield spawn_container() + + + Order: Higher-scoped fixtures are instantiated first ---------------------------------------------------- diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index c0690893207..5186067ef05 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -29,3 +29,8 @@ "--result-log is deprecated and scheduled for removal in pytest 6.0.\n" "See https://docs.pytest.org/en/latest/deprecations.html#result-log-result-log for more information." ) + +FIXTURE_POSITIONAL_ARGUMENTS = PytestDeprecationWarning( + "Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them " + "as a keyword argument instead." +) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index d5f9ad2d3f0..156d55dc7bc 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2,6 +2,7 @@ import inspect import itertools import sys +import warnings from collections import defaultdict from collections import deque from collections import OrderedDict @@ -27,6 +28,7 @@ from _pytest.compat import is_generator from _pytest.compat import NOTSET from _pytest.compat import safe_getattr +from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -58,7 +60,6 @@ def pytest_sessionstart(session): scopename2class = {} # type: Dict[str, Type[nodes.Node]] - scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]] scope2props["package"] = ("fspath",) scope2props["module"] = ("fspath", "module") @@ -792,6 +793,25 @@ def _teardown_yield_fixture(fixturefunc, it): ) +def _eval_scope_callable(scope_callable, fixture_name, config): + try: + result = scope_callable(fixture_name=fixture_name, config=config) + except Exception: + raise TypeError( + "Error evaluating {} while defining fixture '{}'.\n" + "Expected a function with the signature (*, fixture_name, config)".format( + scope_callable, fixture_name + ) + ) + if not isinstance(result, str): + fail( + "Expected {} to return a 'str' while defining fixture '{}', but it returned:\n" + "{!r}".format(scope_callable, fixture_name, result), + pytrace=False, + ) + return result + + class FixtureDef: """ A container for a factory definition. """ @@ -811,6 +831,8 @@ def __init__( self.has_location = baseid is not None self.func = func self.argname = argname + if callable(scope): + scope = _eval_scope_callable(scope, argname, fixturemanager.config) self.scope = scope self.scopenum = scope2index( scope or "function", @@ -986,7 +1008,40 @@ def __call__(self, function): return function -def fixture(scope="function", params=None, autouse=False, ids=None, name=None): +FIXTURE_ARGS_ORDER = ("scope", "params", "autouse", "ids", "name") + + +def _parse_fixture_args(callable_or_scope, *args, **kwargs): + arguments = dict(scope="function", params=None, autouse=False, ids=None, name=None) + + fixture_function = None + if isinstance(callable_or_scope, str): + args = list(args) + args.insert(0, callable_or_scope) + else: + fixture_function = callable_or_scope + + positionals = set() + for positional, argument_name in zip(args, FIXTURE_ARGS_ORDER): + arguments[argument_name] = positional + positionals.add(argument_name) + + duplicated_kwargs = {kwarg for kwarg in kwargs.keys() if kwarg in positionals} + if duplicated_kwargs: + raise TypeError( + "The fixture arguments are defined as positional and keyword: {}. " + "Use only keyword arguments.".format(", ".join(duplicated_kwargs)) + ) + + if positionals: + warnings.warn(FIXTURE_POSITIONAL_ARGUMENTS, stacklevel=2) + + arguments.update(kwargs) + + return fixture_function, arguments + + +def fixture(callable_or_scope=None, *args, **kwargs): """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a @@ -1032,21 +1087,33 @@ def fixture(scope="function", params=None, autouse=False, ids=None, name=None): ``fixture_`` and then use ``@pytest.fixture(name='')``. """ - if callable(scope) and params is None and autouse is False: + fixture_function, arguments = _parse_fixture_args( + callable_or_scope, *args, **kwargs + ) + scope = arguments.get("scope") + params = arguments.get("params") + autouse = arguments.get("autouse") + ids = arguments.get("ids") + name = arguments.get("name") + + if fixture_function and params is None and autouse is False: # direct decoration - return FixtureFunctionMarker("function", params, autouse, name=name)(scope) + return FixtureFunctionMarker(scope, params, autouse, name=name)( + fixture_function + ) + if params is not None and not isinstance(params, (list, tuple)): params = list(params) return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name) -def yield_fixture(scope="function", params=None, autouse=False, ids=None, name=None): +def yield_fixture(callable_or_scope=None, *args, **kwargs): """ (return a) decorator to mark a yield-fixture factory function. .. deprecated:: 3.0 Use :py:func:`pytest.fixture` directly instead. """ - return fixture(scope=scope, params=params, autouse=autouse, ids=ids, name=name) + return fixture(callable_or_scope=callable_or_scope, *args, **kwargs) defaultfuncargprefixmarker = fixture() diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 1f383e75253..762237bde4b 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2216,6 +2216,68 @@ def test_1(arg): ["*ScopeMismatch*You tried*function*session*request*"] ) + def test_dynamic_scope(self, testdir): + testdir.makeconftest( + """ + import pytest + + + def pytest_addoption(parser): + parser.addoption("--extend-scope", action="store_true", default=False) + + + def dynamic_scope(fixture_name, config): + if config.getoption("--extend-scope"): + return "session" + return "function" + + + @pytest.fixture(scope=dynamic_scope) + def dynamic_fixture(calls=[]): + calls.append("call") + return len(calls) + + """ + ) + + testdir.makepyfile( + """ + def test_first(dynamic_fixture): + assert dynamic_fixture == 1 + + + def test_second(dynamic_fixture): + assert dynamic_fixture == 2 + + """ + ) + + reprec = testdir.inline_run() + reprec.assertoutcome(passed=2) + + reprec = testdir.inline_run("--extend-scope") + reprec.assertoutcome(passed=1, failed=1) + + def test_dynamic_scope_bad_return(self, testdir): + testdir.makepyfile( + """ + import pytest + + def dynamic_scope(**_): + return "wrong-scope" + + @pytest.fixture(scope=dynamic_scope) + def fixture(): + pass + + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines( + "Fixture 'fixture' from test_dynamic_scope_bad_return.py " + "got an unexpected scope value 'wrong-scope'" + ) + def test_register_only_with_mark(self, testdir): testdir.makeconftest( """ @@ -4009,3 +4071,54 @@ def test_fixture_named_request(testdir): " *test_fixture_named_request.py:5", ] ) + + +def test_fixture_duplicated_arguments(testdir): + testdir.makepyfile( + """ + import pytest + + with pytest.raises(TypeError) as excinfo: + + @pytest.fixture("session", scope="session") + def arg(arg): + pass + + def test_error(): + assert ( + str(excinfo.value) + == "The fixture arguments are defined as positional and keyword: scope. " + "Use only keyword arguments." + ) + + """ + ) + + reprec = testdir.inline_run() + reprec.assertoutcome(passed=1) + + +def test_fixture_with_positionals(testdir): + """Raise warning, but the positionals should still works.""" + testdir.makepyfile( + """ + import os + + import pytest + from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS + + with pytest.warns(pytest.PytestDeprecationWarning) as warnings: + @pytest.fixture("function", [0], True) + def arg(monkeypatch): + monkeypatch.setenv("AUTOUSE_WORKS", "1") + + + def test_autouse(): + assert os.environ.get("AUTOUSE_WORKS") == "1" + assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS) + + """ + ) + + reprec = testdir.inline_run() + reprec.assertoutcome(passed=1) From f2f3ced508c6b74398bf8d309e38260ffbb7e507 Mon Sep 17 00:00:00 2001 From: Andrzej Klajnert Date: Tue, 10 Sep 2019 16:20:44 +0200 Subject: [PATCH 7/9] Fixed the fixture function signature. --- src/_pytest/fixtures.py | 49 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 156d55dc7bc..8d7188426ec 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1012,7 +1012,16 @@ def __call__(self, function): def _parse_fixture_args(callable_or_scope, *args, **kwargs): - arguments = dict(scope="function", params=None, autouse=False, ids=None, name=None) + arguments = { + "scope": "function", + "params": None, + "autouse": False, + "ids": None, + "name": None, + } + kwargs = { + key: value for key, value in kwargs.items() if arguments.get(key) != value + } fixture_function = None if isinstance(callable_or_scope, str): @@ -1041,7 +1050,15 @@ def _parse_fixture_args(callable_or_scope, *args, **kwargs): return fixture_function, arguments -def fixture(callable_or_scope=None, *args, **kwargs): +def fixture( + callable_or_scope=None, + *args, + scope="function", + params=None, + autouse=False, + ids=None, + name=None +): """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a @@ -1088,7 +1105,13 @@ def fixture(callable_or_scope=None, *args, **kwargs): ``@pytest.fixture(name='')``. """ fixture_function, arguments = _parse_fixture_args( - callable_or_scope, *args, **kwargs + callable_or_scope, + *args, + scope=scope, + params=params, + autouse=autouse, + ids=ids, + name=name ) scope = arguments.get("scope") params = arguments.get("params") @@ -1107,13 +1130,29 @@ def fixture(callable_or_scope=None, *args, **kwargs): return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name) -def yield_fixture(callable_or_scope=None, *args, **kwargs): +def yield_fixture( + callable_or_scope=None, + *args, + scope="function", + params=None, + autouse=False, + ids=None, + name=None +): """ (return a) decorator to mark a yield-fixture factory function. .. deprecated:: 3.0 Use :py:func:`pytest.fixture` directly instead. """ - return fixture(callable_or_scope=callable_or_scope, *args, **kwargs) + return fixture( + callable_or_scope, + *args, + scope=scope, + params=params, + autouse=autouse, + ids=ids, + name=name + ) defaultfuncargprefixmarker = fixture() From df46afc96d05823f6f13354b58cdba81dcf66336 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 18 Sep 2019 07:47:41 -0300 Subject: [PATCH 8/9] Change fixture argument handling tests to unit-tests --- testing/python/fixtures.py | 54 ++++++++++++-------------------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 2a0c0341a82..5b1459fb69b 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4108,54 +4108,34 @@ def test_fixture_named_request(testdir): def test_fixture_duplicated_arguments(testdir): """Raise error if there are positional and keyword arguments for the same parameter (#1682).""" - testdir.makepyfile( - """ - import pytest + with pytest.raises(TypeError) as excinfo: - with pytest.raises(TypeError) as excinfo: - - @pytest.fixture("session", scope="session") - def arg(arg): - pass - - def test_error(): - assert ( - str(excinfo.value) - == "The fixture arguments are defined as positional and keyword: scope. " - "Use only keyword arguments." - ) + @pytest.fixture("session", scope="session") + def arg(arg): + pass - """ + assert ( + str(excinfo.value) + == "The fixture arguments are defined as positional and keyword: scope. " + "Use only keyword arguments." ) - reprec = testdir.inline_run() - reprec.assertoutcome(passed=1) - def test_fixture_with_positionals(testdir): """Raise warning, but the positionals should still works (#1682).""" - testdir.makepyfile( - """ - import os + from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS - import pytest - from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS + with pytest.warns(pytest.PytestDeprecationWarning) as warnings: - with pytest.warns(pytest.PytestDeprecationWarning) as warnings: - @pytest.fixture("function", [0], True) - def arg(monkeypatch): - monkeypatch.setenv("AUTOUSE_WORKS", "1") + @pytest.fixture("function", [0], True) + def arg(monkeypatch): + monkeypatch.setenv("AUTOUSE_WORKS", "1") + assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS) - def test_autouse(): - assert os.environ.get("AUTOUSE_WORKS") == "1" - assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS) - - """ - ) - - reprec = testdir.inline_run() - reprec.assertoutcome(passed=1) + assert arg._pytestfixturefunction.scope == "function" + assert arg._pytestfixturefunction.params == (0,) + assert arg._pytestfixturefunction.autouse def test_indirect_fixture_does_not_break_scope(testdir): From e2382e96ed24ab73986abc34707f73b8417a8f2e Mon Sep 17 00:00:00 2001 From: Andrzej Klajnert Date: Thu, 19 Sep 2019 11:13:22 +0200 Subject: [PATCH 9/9] Minor cleanup in tests. --- testing/python/fixtures.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 5b1459fb69b..f4dbfdf0977 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4128,14 +4128,14 @@ def test_fixture_with_positionals(testdir): with pytest.warns(pytest.PytestDeprecationWarning) as warnings: @pytest.fixture("function", [0], True) - def arg(monkeypatch): - monkeypatch.setenv("AUTOUSE_WORKS", "1") + def fixture_with_positionals(): + pass assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS) - assert arg._pytestfixturefunction.scope == "function" - assert arg._pytestfixturefunction.params == (0,) - assert arg._pytestfixturefunction.autouse + assert fixture_with_positionals._pytestfixturefunction.scope == "function" + assert fixture_with_positionals._pytestfixturefunction.params == (0,) + assert fixture_with_positionals._pytestfixturefunction.autouse def test_indirect_fixture_does_not_break_scope(testdir):