【pytest框架源码分析五】pytest插件的注册流程

发布于:2025-03-22 ⋅ 阅读:(25) ⋅ 点赞:(0)

前文介绍到pytest整体是运用插件来实现其运行流程的。这里仔细介绍下具体过程。
首先进入main方法

def main(
    args: list[str] | os.PathLike[str] | None = None,
    plugins: Sequence[str | _PluggyPlugin] | None = None,
) -> int | ExitCode:
    """Perform an in-process test run.

    :param args:
        List of command line arguments. If `None` or not given, defaults to reading
        arguments directly from the process command line (:data:`sys.argv`).
    :param plugins: List of plugin objects to be auto-registered during initialization.

    :returns: An exit code.
    """
    old_pytest_version = os.environ.get("PYTEST_VERSION")
    try:
        os.environ["PYTEST_VERSION"] = __version__
        try:
            config = _prepareconfig(args, plugins)
        except ConftestImportFailure as e:
            exc_info = ExceptionInfo.from_exception(e.cause)
            tw = TerminalWriter(sys.stderr)
            tw.line(f"ImportError while loading conftest '{e.path}'.", red=True)
            exc_info.traceback = exc_info.traceback.filter(
                filter_traceback_for_conftest_import_failure
            )
            exc_repr = (
                exc_info.getrepr(style="short", chain=False)
                if exc_info.traceback
                else exc_info.exconly()
            )
            formatted_tb = str(exc_repr)
            for line in formatted_tb.splitlines():
                tw.line(line.rstrip(), red=True)
            return ExitCode.USAGE_ERROR
        else:
            try:
                ret: ExitCode | int = config.hook.pytest_cmdline_main(config=config)
                try:
                    return ExitCode(ret)
                except ValueError:
                    return ret
            finally:
                config._ensure_unconfigure()
    except UsageError as e:
        tw = TerminalWriter(sys.stderr)
        for msg in e.args:
            tw.line(f"ERROR: {msg}\n", red=True)
        return ExitCode.USAGE_ERROR
    finally:
        if old_pytest_version is None:
            os.environ.pop("PYTEST_VERSION", None)
        else:
            os.environ["PYTEST_VERSION"] = old_pytest_version

这个main方法,最重要的有两步,第一步是
config = _prepareconfig(args, plugins)
这一步就是读取配置以及注册插件等动作。这里附下_prepareconfig方法

def _prepareconfig(
    args: list[str] | os.PathLike[str] | None = None,
    plugins: Sequence[str | _PluggyPlugin] | None = None,
) -> Config:
    if args is None:
        args = sys.argv[1:]
    elif isinstance(args, os.PathLike):
        args = [os.fspath(args)]
    elif not isinstance(args, list):
        msg = (  # type:ignore[unreachable]
            "`args` parameter expected to be a list of strings, got: {!r} (type: {})"
        )
        raise TypeError(msg.format(args, type(args)))

    config = get_config(args, plugins)
    pluginmanager = config.pluginmanager
    try:
        if plugins:
            for plugin in plugins:
                if isinstance(plugin, str):
                    pluginmanager.consider_pluginarg(plugin)
                else:
                    pluginmanager.register(plugin)
        config = pluginmanager.hook.pytest_cmdline_parse(
            pluginmanager=pluginmanager, args=args
        )
        return config
    except BaseException:
        config._ensure_unconfigure()
        raise

这里这个方法可以看到,如果有plugin传入,则会注册。但是我们知道有些默认的插件是没有传入的,也注册了。其是通过get_config(args, plugins)这个方法来注册的

def get_config(
    args: list[str] | None = None,
    plugins: Sequence[str | _PluggyPlugin] | None = 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=pathlib.Path.cwd(),
        ),
    )

    if args is not None:
        # Handle any "-p no:plugin" args.
        pluginmanager.consider_preparse(args, exclude_only=True)

    for spec in default_plugins:
        pluginmanager.import_plugin(spec)

    return config

这里初始化pluginmanager时,添加了默认的插件接口类
在这里插入图片描述
可以看到这个文件里都是插件的接口类,并且都是以pytest_开头的。
在这里插入图片描述
注意这里有些方法没有加@hookspec的装饰器,但是也添加进去了,这是因为pytest对其做了一层处理。我们知道add hooksepcs时,主要是判断其有无对应的spec_opts,没有添加@hooksepc的就没有sepc_opts。

在这里插入图片描述
在这里插入图片描述
对于这种没有添加@hookspec的方法,pytest重写了parse_hookspec_opts方法
在这里插入图片描述
这里可以看到,其先取了下对应的接口方法有无opts参数,如果没有,则判断一下方法是否是以pytest_开头的,如果是,则添加opts参数。所以这里添加了hooksepc.py文件中所有的接口类方法。
然后PytestPluginManager类中self.register(self)注册了它自己类中的插件,然后运行到get_config方法中的

    for spec in default_plugins:
        pluginmanager.import_plugin(spec)

这里将default_plugins中的插件也都注册了,default_plugin如下
在这里插入图片描述
import_plugin方法如下

   def import_plugin(self, modname: str, consider_entry_points: bool = False) -> None:
        """Import a plugin with ``modname``.

        If ``consider_entry_points`` is True, entry point names are also
        considered to find a plugin.
        """
        # Most often modname refers to builtin modules, e.g. "pytester",
        # "terminal" or "capture".  Those plugins are registered under their
        # basename for historic purposes but must be imported with the
        # _pytest prefix.
        assert isinstance(
            modname, str
        ), f"module name as text required, got {modname!r}"
        if self.is_blocked(modname) or self.get_plugin(modname) is not None:
            return

        importspec = "_pytest." + modname if modname in builtin_plugins else modname
        self.rewrite_hook.mark_rewrite(importspec)

        if consider_entry_points:
            loaded = self.load_setuptools_entrypoints("pytest11", name=modname)
            if loaded:
                return

        try:
            __import__(importspec)
        except ImportError as e:
            raise ImportError(
                f'Error importing plugin "{modname}": {e.args[0]}'
            ).with_traceback(e.__traceback__) from e

        except Skipped as e:
            self.skipped_plugins.append((modname, e.msg or ""))
        else:
            mod = sys.modules[importspec]
            self.register(mod, modname)

这里主要就是把default_plugins中提到的文件中的插件实现都注册了。(注意这里有些接口实现方法也是未加hookimpl装饰器的,但是也能注册,同上面添加spec的方法 ,pytest重新实现了parse_hookimpl_opts方法,只要是以pytest_开头的方法都可以正常注册)
回到main方法,_prepareconfig这一步就是将配置读取完,将默认插件注册完成。
接下来main方法执行到config.hook.pytest_cmdline_main(config=config),这个方法在hookspec中有接口,实现的方法也有多处。
在这里插入图片描述

对比可以看到这些文件中都有实现,其中mark,setuonly,setupplan中的实现方法都加了@pytest.hookimpl(tryfirst=True),按照之前介绍的原则,后加的先执行,加了tryfirst的先执行,这里的执行顺序为setupplan,setuponly,mark,cacheprovider,…中实现的pytest_cmdline_main方法。