From 4655b7998540d47e6f8dd783c82b37588719556d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 21 Jun 2020 00:34:41 +0300 Subject: [PATCH 1/2] config: improve typing --- src/_pytest/_code/__init__.py | 2 + src/_pytest/_code/code.py | 2 +- src/_pytest/config/__init__.py | 185 ++++++++++++++++++++------------- src/_pytest/helpconfig.py | 6 +- src/_pytest/hookspec.py | 2 +- src/_pytest/logging.py | 12 ++- src/_pytest/pathlib.py | 2 +- src/_pytest/pytester.py | 8 +- src/_pytest/tmpdir.py | 3 +- testing/acceptance_test.py | 4 +- testing/code/test_excinfo.py | 2 +- testing/test_config.py | 6 +- 12 files changed, 143 insertions(+), 91 deletions(-) diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index 38019298c..76963c0eb 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -6,6 +6,7 @@ from .code import Frame from .code import getfslineno from .code import getrawcode from .code import Traceback +from .code import TracebackEntry from .source import compile_ as compile from .source import Source @@ -17,6 +18,7 @@ __all__ = [ "getfslineno", "getrawcode", "Traceback", + "TracebackEntry", "compile", "Source", ] diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 65e5aa6d5..e548bceb7 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -213,7 +213,7 @@ class TracebackEntry: return source.getstatement(self.lineno) @property - def path(self): + def path(self) -> Union[py.path.local, str]: """ path to the source code """ return self.frame.code.path diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index b4a5a70ad..ac7afcd56 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -15,10 +15,13 @@ from typing import Any from typing import Callable from typing import Dict from typing import IO +from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import Sequence from typing import Set +from typing import TextIO from typing import Tuple from typing import Union @@ -42,6 +45,7 @@ from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import import_path +from _pytest.pathlib import ImportMode from _pytest.pathlib import Path from _pytest.store import Store from _pytest.warning_types import PytestConfigWarning @@ -50,6 +54,7 @@ if TYPE_CHECKING: from typing import Type from _pytest._code.code import _TracebackStyle + from _pytest.terminal import TerminalReporter from .argparsing import Argument @@ -88,18 +93,24 @@ class ExitCode(enum.IntEnum): class ConftestImportFailure(Exception): - def __init__(self, path, excinfo): + def __init__( + self, + path: py.path.local, + excinfo: Tuple["Type[Exception]", Exception, TracebackType], + ) -> None: super().__init__(path, excinfo) self.path = path - self.excinfo = excinfo # type: Tuple[Type[Exception], Exception, TracebackType] + self.excinfo = excinfo - def __str__(self): + def __str__(self) -> str: return "{}: {} (from {})".format( self.excinfo[0].__name__, self.excinfo[1], self.path ) -def filter_traceback_for_conftest_import_failure(entry) -> bool: +def filter_traceback_for_conftest_import_failure( + entry: _pytest._code.TracebackEntry, +) -> bool: """filters tracebacks entries which point to pytest internals or importlib. Make a special case for importlib because we use it to import test modules and conftest files @@ -108,7 +119,10 @@ def filter_traceback_for_conftest_import_failure(entry) -> bool: return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep) -def main(args=None, plugins=None) -> Union[int, ExitCode]: +def main( + args: Optional[List[str]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> Union[int, ExitCode]: """ return exit code, after performing an in-process test run. :arg args: list of command line arguments. @@ -177,7 +191,7 @@ class cmdline: # compatibility namespace main = staticmethod(main) -def filename_arg(path, optname): +def filename_arg(path: str, optname: str) -> str: """ Argparse type validator for filename arguments. :path: path of filename @@ -188,7 +202,7 @@ def filename_arg(path, optname): return path -def directory_arg(path, optname): +def directory_arg(path: str, optname: str) -> str: """Argparse type validator for directory arguments. :path: path of directory @@ -239,13 +253,16 @@ builtin_plugins = set(default_plugins) builtin_plugins.add("pytester") -def get_config(args=None, plugins=None): +def get_config( + args: Optional[List[str]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> "Config": # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() config = Config( pluginmanager, invocation_params=Config.InvocationParams( - args=args or (), plugins=plugins, dir=Path.cwd() + args=args or (), plugins=plugins, dir=Path.cwd(), ), ) @@ -255,10 +272,11 @@ def get_config(args=None, plugins=None): for spec in default_plugins: pluginmanager.import_plugin(spec) + return config -def get_plugin_manager(): +def get_plugin_manager() -> "PytestPluginManager": """ Obtain a new instance of the :py:class:`_pytest.config.PytestPluginManager`, with default plugins @@ -271,8 +289,9 @@ def get_plugin_manager(): def _prepareconfig( - args: Optional[Union[py.path.local, List[str]]] = None, plugins=None -): + args: Optional[Union[py.path.local, List[str]]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> "Config": if args is None: args = sys.argv[1:] elif isinstance(args, py.path.local): @@ -290,9 +309,10 @@ def _prepareconfig( pluginmanager.consider_pluginarg(plugin) else: pluginmanager.register(plugin) - return pluginmanager.hook.pytest_cmdline_parse( + config = pluginmanager.hook.pytest_cmdline_parse( pluginmanager=pluginmanager, args=args ) + return config except BaseException: config._ensure_unconfigure() raise @@ -313,13 +333,11 @@ class PytestPluginManager(PluginManager): super().__init__("pytest") # The objects are module objects, only used generically. - self._conftest_plugins = set() # type: Set[object] + self._conftest_plugins = set() # type: Set[types.ModuleType] - # state related to local conftest plugins - # 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] + # State related to local conftest plugins. + self._dirpath2confmods = {} # type: Dict[py.path.local, List[types.ModuleType]] + self._conftestpath2mod = {} # type: Dict[Path, types.ModuleType] self._confcutdir = None # type: Optional[py.path.local] self._noconftest = False self._duplicatepaths = set() # type: Set[py.path.local] @@ -328,7 +346,7 @@ class PytestPluginManager(PluginManager): self.register(self) if os.environ.get("PYTEST_DEBUG"): err = sys.stderr # type: IO[str] - encoding = getattr(err, "encoding", "utf8") + encoding = getattr(err, "encoding", "utf8") # type: str try: err = open( os.dup(err.fileno()), mode=err.mode, buffering=1, encoding=encoding, @@ -343,7 +361,7 @@ class PytestPluginManager(PluginManager): # Used to know when we are importing conftests after the pytest_configure stage self._configured = False - def parse_hookimpl_opts(self, plugin, name): + def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str): # pytest hooks are always prefixed with pytest_ # so we avoid accessing possibly non-readable attributes # (see issue #1073) @@ -372,7 +390,7 @@ class PytestPluginManager(PluginManager): opts.setdefault(name, hasattr(method, name) or name in known_marks) return opts - def parse_hookspec_opts(self, module_or_class, name): + def parse_hookspec_opts(self, module_or_class, name: str): opts = super().parse_hookspec_opts(module_or_class, name) if opts is None: method = getattr(module_or_class, name) @@ -389,7 +407,9 @@ class PytestPluginManager(PluginManager): } return opts - def register(self, plugin: _PluggyPlugin, name: Optional[str] = None): + def register( + self, plugin: _PluggyPlugin, name: Optional[str] = None + ) -> Optional[str]: if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS: warnings.warn( PytestConfigWarning( @@ -399,8 +419,8 @@ class PytestPluginManager(PluginManager): ) ) ) - return - ret = super().register(plugin, name) + return None + ret = super().register(plugin, name) # type: Optional[str] if ret: self.hook.pytest_plugin_registered.call_historic( kwargs=dict(plugin=plugin, manager=self) @@ -410,11 +430,12 @@ class PytestPluginManager(PluginManager): self.consider_module(plugin) return ret - def getplugin(self, name): + def getplugin(self, name: str): # support deprecated naming because plugins (xdist e.g.) use it - return self.get_plugin(name) + plugin = self.get_plugin(name) # type: Optional[_PluggyPlugin] + return plugin - def hasplugin(self, name): + def hasplugin(self, name: str) -> bool: """Return True if the plugin with the given name is registered.""" return bool(self.get_plugin(name)) @@ -436,7 +457,7 @@ class PytestPluginManager(PluginManager): # # internal API for local conftest plugin handling # - def _set_initial_conftests(self, namespace): + def _set_initial_conftests(self, namespace: argparse.Namespace) -> None: """ load initial conftest files given a preparsed "namespace". As conftest files may add their own command line options which have arguments ('--my-opt somepath') we might get some @@ -454,8 +475,8 @@ class PytestPluginManager(PluginManager): self._using_pyargs = namespace.pyargs testpaths = namespace.file_or_dir foundanchor = False - for path in testpaths: - path = str(path) + for testpath in testpaths: + path = str(testpath) # remove node-id syntax i = path.find("::") if i != -1: @@ -467,7 +488,9 @@ class PytestPluginManager(PluginManager): if not foundanchor: self._try_load_conftest(current, namespace.importmode) - def _try_load_conftest(self, anchor, importmode): + def _try_load_conftest( + self, anchor: py.path.local, importmode: Union[str, ImportMode] + ) -> None: self._getconftestmodules(anchor, importmode) # let's also consider test* subdirs if anchor.check(dir=1): @@ -476,7 +499,9 @@ class PytestPluginManager(PluginManager): self._getconftestmodules(x, importmode) @lru_cache(maxsize=128) - def _getconftestmodules(self, path, importmode): + def _getconftestmodules( + self, path: py.path.local, importmode: Union[str, ImportMode], + ) -> List[types.ModuleType]: if self._noconftest: return [] @@ -499,7 +524,9 @@ class PytestPluginManager(PluginManager): self._dirpath2confmods[directory] = clist return clist - def _rget_with_confmod(self, name, path, importmode): + def _rget_with_confmod( + self, name: str, path: py.path.local, importmode: Union[str, ImportMode], + ) -> Tuple[types.ModuleType, Any]: modules = self._getconftestmodules(path, importmode) for mod in reversed(modules): try: @@ -508,7 +535,9 @@ class PytestPluginManager(PluginManager): continue raise KeyError(name) - def _importconftest(self, conftestpath, importmode): + def _importconftest( + self, conftestpath: py.path.local, importmode: Union[str, ImportMode], + ) -> types.ModuleType: # Use a resolved Path object as key to avoid loading the same conftest twice # with build systems that create build directories containing # symlinks to actual files. @@ -526,7 +555,9 @@ class PytestPluginManager(PluginManager): try: mod = import_path(conftestpath, mode=importmode) except Exception as e: - raise ConftestImportFailure(conftestpath, sys.exc_info()) from e + assert e.__traceback__ is not None + exc_info = (type(e), e, e.__traceback__) + raise ConftestImportFailure(conftestpath, exc_info) from e self._check_non_top_pytest_plugins(mod, conftestpath) @@ -542,7 +573,9 @@ class PytestPluginManager(PluginManager): self.consider_conftest(mod) return mod - def _check_non_top_pytest_plugins(self, mod, conftestpath): + def _check_non_top_pytest_plugins( + self, mod: types.ModuleType, conftestpath: py.path.local, + ) -> None: if ( hasattr(mod, "pytest_plugins") and self._configured @@ -564,7 +597,9 @@ class PytestPluginManager(PluginManager): # # - def consider_preparse(self, args, *, exclude_only: bool = False) -> None: + def consider_preparse( + self, args: Sequence[str], *, exclude_only: bool = False + ) -> None: i = 0 n = len(args) while i < n: @@ -585,7 +620,7 @@ class PytestPluginManager(PluginManager): continue self.consider_pluginarg(parg) - def consider_pluginarg(self, arg) -> None: + def consider_pluginarg(self, arg: str) -> None: if arg.startswith("no:"): name = arg[3:] if name in essential_plugins: @@ -610,7 +645,7 @@ class PytestPluginManager(PluginManager): del self._name2plugin["pytest_" + name] self.import_plugin(arg, consider_entry_points=True) - def consider_conftest(self, conftestmodule) -> None: + def consider_conftest(self, conftestmodule: types.ModuleType) -> None: self.register(conftestmodule, name=conftestmodule.__file__) def consider_env(self) -> None: @@ -619,7 +654,7 @@ class PytestPluginManager(PluginManager): def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) - def _import_plugin_specs(self, spec): + def _import_plugin_specs(self, spec) -> None: plugins = _get_plugin_specs_as_list(spec) for import_spec in plugins: self.import_plugin(import_spec) @@ -636,7 +671,6 @@ class PytestPluginManager(PluginManager): assert isinstance(modname, str), ( "module name as text required, got %r" % modname ) - modname = str(modname) if self.is_blocked(modname) or self.get_plugin(modname) is not None: return @@ -668,7 +702,7 @@ class PytestPluginManager(PluginManager): self.register(mod, modname) -def _get_plugin_specs_as_list(specs): +def _get_plugin_specs_as_list(specs) -> List[str]: """ Parses a list of "plugin specs" and returns a list of plugin names. @@ -688,7 +722,7 @@ def _get_plugin_specs_as_list(specs): return [] -def _ensure_removed_sysmodule(modname): +def _ensure_removed_sysmodule(modname: str) -> None: try: del sys.modules[modname] except KeyError: @@ -703,7 +737,7 @@ class Notset: notset = Notset() -def _iter_rewritable_modules(package_files): +def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]: """ Given an iterable of file names in a source distribution, return the "names" that should be marked for assertion rewrite (for example the package "pytest_mock/__init__.py" should @@ -766,6 +800,10 @@ def _iter_rewritable_modules(package_files): yield from _iter_rewritable_modules(new_package_files) +def _args_converter(args: Iterable[str]) -> Tuple[str, ...]: + return tuple(args) + + class Config: """ Access to configuration values, pluginmanager and plugin hooks. @@ -793,9 +831,9 @@ class Config: Plugins accessing ``InvocationParams`` must be aware of that. """ - args = attr.ib(converter=tuple) + args = attr.ib(type=Tuple[str, ...], converter=_args_converter) """tuple of command-line arguments as passed to ``pytest.main()``.""" - plugins = attr.ib() + plugins = attr.ib(type=Optional[Sequence[Union[str, _PluggyPlugin]]]) """list of extra plugins, might be `None`.""" dir = attr.ib(type=Path) """directory where ``pytest.main()`` was invoked from.""" @@ -855,7 +893,7 @@ class Config: """Backward compatibility""" return py.path.local(str(self.invocation_params.dir)) - def add_cleanup(self, func) -> None: + def add_cleanup(self, func: Callable[[], None]) -> None: """ Add a function to be called when the config object gets out of use (usually coninciding with pytest_unconfigure).""" self._cleanup.append(func) @@ -876,12 +914,15 @@ class Config: fin = self._cleanup.pop() fin() - def get_terminal_writer(self): - return self.pluginmanager.get_plugin("terminalreporter")._tw + def get_terminal_writer(self) -> TerminalWriter: + terminalreporter = self.pluginmanager.get_plugin( + "terminalreporter" + ) # type: TerminalReporter + return terminalreporter._tw def pytest_cmdline_parse( self, pluginmanager: PytestPluginManager, args: List[str] - ) -> object: + ) -> "Config": try: self.parse(args) except UsageError: @@ -923,7 +964,7 @@ class Config: sys.stderr.write("INTERNALERROR> %s\n" % line) sys.stderr.flush() - def cwd_relative_nodeid(self, nodeid): + def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd if self.invocation_dir != self.rootdir: fullpath = self.rootdir.join(nodeid) @@ -931,7 +972,7 @@ class Config: return nodeid @classmethod - def fromdictargs(cls, option_dict, args): + def fromdictargs(cls, option_dict, args) -> "Config": """ constructor usable for subprocesses. """ config = get_config(args) config.option.__dict__.update(option_dict) @@ -949,7 +990,7 @@ class Config: setattr(self.option, opt.dest, opt.default) @hookimpl(trylast=True) - def pytest_load_initial_conftests(self, early_config): + def pytest_load_initial_conftests(self, early_config: "Config") -> None: self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) def _initini(self, args: Sequence[str]) -> None: @@ -1078,7 +1119,7 @@ class Config: raise self._validate_keys() - def _checkversion(self): + def _checkversion(self) -> None: import pytest minver = self.inicfg.get("minversion", None) @@ -1167,7 +1208,7 @@ class Config: except PrintHelp: pass - def addinivalue_line(self, name, line): + def addinivalue_line(self, name: str, line: str) -> None: """ add a line to an ini-file option. The option must have been declared but might not yet be set in which case the line becomes the the first line in its value. """ @@ -1186,7 +1227,7 @@ class Config: self._inicache[name] = val = self._getini(name) return val - def _getini(self, name: str) -> Any: + def _getini(self, name: str): try: description, type, default = self._parser._inidict[name] except KeyError as e: @@ -1231,12 +1272,14 @@ class Config: else: return value elif type == "bool": - return bool(_strtobool(str(value).strip())) + return _strtobool(str(value).strip()) else: assert type is None return value - def _getconftest_pathlist(self, name, path): + def _getconftest_pathlist( + self, name: str, path: py.path.local + ) -> Optional[List[py.path.local]]: try: mod, relroots = self.pluginmanager._rget_with_confmod( name, path, self.getoption("importmode") @@ -1244,7 +1287,7 @@ class Config: except KeyError: return None modpath = py.path.local(mod.__file__).dirpath() - values = [] + values = [] # type: List[py.path.local] for relroot in relroots: if not isinstance(relroot, py.path.local): relroot = relroot.replace("/", py.path.local.sep) @@ -1295,16 +1338,16 @@ class Config: pytest.skip("no {!r} option found".format(name)) raise ValueError("no option named {!r}".format(name)) from e - def getvalue(self, name, path=None): + def getvalue(self, name: str, path=None): """ (deprecated, use getoption()) """ return self.getoption(name) - def getvalueorskip(self, name, path=None): + def getvalueorskip(self, name: str, path=None): """ (deprecated, use getoption(skip=True)) """ return self.getoption(name, skip=True) -def _assertion_supported(): +def _assertion_supported() -> bool: try: assert False except AssertionError: @@ -1313,7 +1356,7 @@ def _assertion_supported(): return False -def _warn_about_missing_assertion(mode): +def _warn_about_missing_assertion(mode) -> None: if not _assertion_supported(): if mode == "plain": sys.stderr.write( @@ -1331,12 +1374,14 @@ def _warn_about_missing_assertion(mode): ) -def create_terminal_writer(config: Config, *args, **kwargs) -> TerminalWriter: +def create_terminal_writer( + config: Config, file: Optional[TextIO] = None +) -> TerminalWriter: """Create a TerminalWriter instance configured according to the options in the config object. Every code which requires a TerminalWriter object and has access to a config object should use this function. """ - tw = TerminalWriter(*args, **kwargs) + tw = TerminalWriter(file=file) if config.option.color == "yes": tw.hasmarkup = True if config.option.color == "no": @@ -1344,8 +1389,8 @@ def create_terminal_writer(config: Config, *args, **kwargs) -> TerminalWriter: return tw -def _strtobool(val): - """Convert a string representation of truth to true (1) or false (0). +def _strtobool(val: str) -> bool: + """Convert a string representation of truth to True or False. True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if @@ -1355,8 +1400,8 @@ def _strtobool(val): """ val = val.lower() if val in ("y", "yes", "t", "true", "on", "1"): - return 1 + return True elif val in ("n", "no", "f", "false", "off", "0"): - return 0 + return False else: raise ValueError("invalid truth value {!r}".format(val)) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 06e0954cf..24952852b 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -96,7 +96,7 @@ def pytest_addoption(parser: Parser) -> None: @pytest.hookimpl(hookwrapper=True) def pytest_cmdline_parse(): outcome = yield - config = outcome.get_result() + config = outcome.get_result() # type: Config if config.option.debug: path = os.path.abspath("pytestdebug.log") debugfile = open(path, "w") @@ -124,7 +124,7 @@ def pytest_cmdline_parse(): config.add_cleanup(unset_tracing) -def showversion(config): +def showversion(config: Config) -> None: if config.option.version > 1: sys.stderr.write( "This is pytest version {}, imported from {}\n".format( @@ -224,7 +224,7 @@ def showhelp(config: Config) -> None: conftest_options = [("pytest_plugins", "list of plugin names to load")] -def getpluginversioninfo(config): +def getpluginversioninfo(config: Config) -> List[str]: lines = [] plugininfo = config.pluginmanager.list_plugin_distinfo() if plugininfo: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index eba6f5ba9..c05b60791 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -143,7 +143,7 @@ def pytest_configure(config: "Config") -> None: @hookspec(firstresult=True) def pytest_cmdline_parse( pluginmanager: "PytestPluginManager", args: List[str] -) -> Optional[object]: +) -> Optional["Config"]: """return initialized config object, parsing the specified args. Stops at first non-None result, see :ref:`firstresult` diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index a06dc1ab5..57aa14f27 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -141,9 +141,14 @@ class PercentStyleMultiline(logging.PercentStyle): if auto_indent_option is None: return 0 - elif type(auto_indent_option) is int: + elif isinstance(auto_indent_option, bool): + if auto_indent_option: + return -1 + else: + return 0 + elif isinstance(auto_indent_option, int): return int(auto_indent_option) - elif type(auto_indent_option) is str: + elif isinstance(auto_indent_option, str): try: return int(auto_indent_option) except ValueError: @@ -153,9 +158,6 @@ class PercentStyleMultiline(logging.PercentStyle): return -1 except ValueError: return 0 - elif type(auto_indent_option) is bool: - if auto_indent_option: - return -1 return 0 diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 66ae9a51d..dd7443f07 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -466,7 +466,7 @@ def import_path( """ mode = ImportMode(mode) - path = Path(p) + path = Path(str(p)) if not path.exists(): raise ImportError(path) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index cf3dbd201..fd4c10577 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1054,7 +1054,7 @@ class Testdir: args.append("--basetemp=%s" % self.tmpdir.dirpath("basetemp")) return args - def parseconfig(self, *args: Union[str, py.path.local]) -> Config: + def parseconfig(self, *args) -> Config: """Return a new pytest Config instance from given commandline args. This invokes the pytest bootstrapping code in _pytest.config to create @@ -1070,14 +1070,14 @@ class Testdir: import _pytest.config - config = _pytest.config._prepareconfig(args, self.plugins) # type: Config + config = _pytest.config._prepareconfig(args, self.plugins) # type: ignore[arg-type] # we don't know what the test will do with this half-setup config # object and thus we make sure it gets unconfigured properly in any # case (otherwise capturing could still be active, for example) self.request.addfinalizer(config._ensure_unconfigure) return config - def parseconfigure(self, *args): + def parseconfigure(self, *args) -> Config: """Return a new pytest configured Config instance. This returns a new :py:class:`_pytest.config.Config` instance like @@ -1318,7 +1318,7 @@ class Testdir: Returns a :py:class:`RunResult`. """ __tracebackhide__ = True - p = make_numbered_dir(root=Path(self.tmpdir), prefix="runpytest-") + p = make_numbered_dir(root=Path(str(self.tmpdir)), prefix="runpytest-") args = ("--basetemp=%s" % p,) + args plugins = [x for x in self.plugins if isinstance(x, str)] if plugins: diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 199c7c937..f6d1799ad 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -13,6 +13,7 @@ from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from .pathlib import Path +from _pytest.config import Config from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch @@ -135,7 +136,7 @@ def get_user() -> Optional[str]: return None -def pytest_configure(config) -> None: +def pytest_configure(config: Config) -> None: """Create a TempdirFactory and attach it to the config object. This is to comply with existing plugins which expect the handler to be diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index d8f7a501a..66c2bf0bf 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -585,11 +585,11 @@ class TestInvocationVariants: # Type ignored because `py.test` is not and will not be typed. assert pytest.main == py.test.cmdline.main # type: ignore[attr-defined] - def test_invoke_with_invalid_type(self): + def test_invoke_with_invalid_type(self) -> None: with pytest.raises( TypeError, match="expected to be a list of strings, got: '-h'" ): - pytest.main("-h") + pytest.main("-h") # type: ignore[arg-type] def test_invoke_with_path(self, tmpdir, capsys): retcode = pytest.main(tmpdir) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 0ff00bcaa..75c937612 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -372,7 +372,7 @@ def test_excinfo_no_python_sourcecode(tmpdir): for item in excinfo.traceback: print(item) # XXX: for some reason jinja.Template.render is printed in full item.source # shouldn't fail - if item.path.basename == "test.txt": + if isinstance(item.path, py.path.local) and item.path.basename == "test.txt": assert str(item.source) == "{{ h()}}:" diff --git a/testing/test_config.py b/testing/test_config.py index 4e64a6928..c1e4471b9 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1778,5 +1778,7 @@ def test_conftest_import_error_repr(tmpdir): ): try: raise RuntimeError("some error") - except Exception as e: - raise ConftestImportFailure(path, sys.exc_info()) from e + except Exception as exc: + assert exc.__traceback__ is not None + exc_info = (type(exc), exc, exc.__traceback__) + raise ConftestImportFailure(path, exc_info) from exc From 8994e1e3a17bd625e0c258d0a402062542908fe3 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2020 11:38:21 +0300 Subject: [PATCH 2/2] config: make _get_plugin_specs_as_list a little clearer and more general --- src/_pytest/config/__init__.py | 41 +++++++++++++++++++--------------- testing/test_config.py | 17 ++++++-------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index ac7afcd56..717743e79 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1,5 +1,6 @@ """ command line options, ini-file and conftest.py processing. """ import argparse +import collections.abc import contextlib import copy import enum @@ -654,7 +655,9 @@ class PytestPluginManager(PluginManager): def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) - def _import_plugin_specs(self, spec) -> None: + def _import_plugin_specs( + self, spec: Union[None, types.ModuleType, str, Sequence[str]] + ) -> None: plugins = _get_plugin_specs_as_list(spec) for import_spec in plugins: self.import_plugin(import_spec) @@ -702,24 +705,26 @@ class PytestPluginManager(PluginManager): self.register(mod, modname) -def _get_plugin_specs_as_list(specs) -> List[str]: - """ - Parses a list of "plugin specs" and returns a list of plugin names. - - Plugin specs can be given as a list of strings separated by "," or already as a list/tuple in - which case it is returned as a list. Specs can also be `None` in which case an - empty list is returned. - """ - if specs is not None and not isinstance(specs, types.ModuleType): - if isinstance(specs, str): - specs = specs.split(",") if specs else [] - if not isinstance(specs, (list, tuple)): - raise UsageError( - "Plugin specs must be a ','-separated string or a " - "list/tuple of strings for plugin names. Given: %r" % specs - ) +def _get_plugin_specs_as_list( + specs: Union[None, types.ModuleType, str, Sequence[str]] +) -> List[str]: + """Parse a plugins specification into a list of plugin names.""" + # None means empty. + if specs is None: + return [] + # Workaround for #3899 - a submodule which happens to be called "pytest_plugins". + if isinstance(specs, types.ModuleType): + return [] + # Comma-separated list. + if isinstance(specs, str): + return specs.split(",") if specs else [] + # Direct specification. + if isinstance(specs, collections.abc.Sequence): return list(specs) - return [] + raise UsageError( + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %r" + % specs + ) def _ensure_removed_sysmodule(modname: str) -> None: diff --git a/testing/test_config.py b/testing/test_config.py index c1e4471b9..bc0da93a5 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -11,6 +11,7 @@ import py.path import _pytest._code import pytest from _pytest.compat import importlib_metadata +from _pytest.config import _get_plugin_specs_as_list from _pytest.config import _iter_rewritable_modules from _pytest.config import Config from _pytest.config import ConftestImportFailure @@ -1115,21 +1116,17 @@ def test_load_initial_conftest_last_ordering(_config_for_test): assert [x.function.__module__ for x in values] == expected -def test_get_plugin_specs_as_list(): - from _pytest.config import _get_plugin_specs_as_list - - def exp_match(val): +def test_get_plugin_specs_as_list() -> None: + def exp_match(val: object) -> str: return ( - "Plugin specs must be a ','-separated string" - " or a list/tuple of strings for plugin names. Given: {}".format( - re.escape(repr(val)) - ) + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %s" + % re.escape(repr(val)) ) with pytest.raises(pytest.UsageError, match=exp_match({"foo"})): - _get_plugin_specs_as_list({"foo"}) + _get_plugin_specs_as_list({"foo"}) # type: ignore[arg-type] with pytest.raises(pytest.UsageError, match=exp_match({})): - _get_plugin_specs_as_list(dict()) + _get_plugin_specs_as_list(dict()) # type: ignore[arg-type] assert _get_plugin_specs_as_list(None) == [] assert _get_plugin_specs_as_list("") == []