如何测试一个wheel文件在多个Python版本上的兼容性?

0 投票
1 回答
61 浏览
提问于 2025-04-12 16:40

问题描述

我正在写一个Python库,打算把sdist(.tar.gz格式)和wheel格式的包都上传到PyPI。根据构建文档的说法,运行

python -m build

可以从源代码树中创建sdist,并且从sdist中创建wheel,这很好,因为我可以在这里“免费”测试sdist。现在我想用多个Python版本对wheel进行测试(使用pytest)。有什么简单的方法吗?

我一直在使用tox,并且看到有一个选项可以将包设置为“wheel”

[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg

但是这并没有说明wheel是如何生成的;我不确定它是

a) 直接从源代码树创建wheel
b) 从sdist创建wheel,而sdist是以与python -m build相同的方式从源代码树创建的
c) 从sdist创建wheel,而sdist是以与python -m build不同的方式从源代码树创建的

即使答案是c),tox测试的wheel也不会是上传的那个wheel,所以这并没有测试正确的东西。我很可能需要以某种方式将wheel作为参数传递给tox或测试运行器。

问题

我想从源代码树创建一个sdist,然后再从sdist创建一个wheel,并且我想用多个Python版本对这个wheel进行单元测试。这个项目是纯Python的,所以每个版本的包只会有一个wheel。我应该如何以一种标准的方式对我将上传到PyPI的相同的wheel进行测试?我可以用tox来实现吗?

1 个回答

0

Tox 4.12.2 的文档中提到,可以定义一个 external 包选项(感谢评论者 @JürgenGmach)。这个外部 选项意味着你需要设置

[testenv]
...
package = external

除此之外,还需要创建一个叫做 [.pkg_external] 的部分(如果你编辑过你的 package_env,那么可以用 <package_env>_external)。在这个部分里,至少要定义 package_glob,它告诉 tox 到哪里去安装 wheel。如果你还想要 创建 wheel,可以在 [.pkg_external]commands 选项中进行。

简单的方法(多次构建)

以下是一个有效的配置示例(tox 4.12.2):

[testenv:.pkg_external]
deps =
    build==1.1.1
commands =
    python -c 'import shutil; shutil.rmtree("{toxinidir}/dist", ignore_errors=True)'
    python -m build -o {toxinidir}/dist

package_glob = {toxinidir}{/}dist{/}wakepy-*-py3-none-any.whl
  • 优点:实现起来相对简单
  • 缺点:这种方法的一个问题是,对于每个没有 skip_install=True 的环境,你都会触发构建(python -m build)。这有一个未解决的问题:tox #2729

只构建一次 wheel

也可以让 tox 4.14.2 只构建一次 wheel,使用 tox 的 hooks。从 tox 执行顺序(附录中)可以看到,可以用的一个 hook 是 tox_on_install,用于 ".pkg_external"(可以是 "requires" 或 "deps")。我用它来放一个虚拟文件(/dist/.TOX-ASKS-REBUILD),表示需要进行构建。如果这个 .TOX-ASKS-REBUILD 文件存在,当构建脚本运行时,/dist 文件夹及其所有内容会被删除,然后创建一个新的 /dist 文件夹,里面有 .tar.gz 和 .whl 文件。

  • 优点:
    • 运行 tox 更快,因为 sdist 和 wheel 只会根据需要构建。
    • 即使在使用单个环境时,比如 tox -e py311(如果没有 skip_install=True),也会进行构建。
  • 缺点:
    • 实现起来更复杂
    • 在并行模式下无法工作。为此,可能需要在每次运行 tox 之前有一个单独的构建命令(除非并行化插件支持一个公共的预命令)。

希望这个解决方案在某个时候会变得不必要(当 #2729 问题解决时)

这个 hook

  • 位于项目根目录下的 toxfile.py 文件中。
from __future__ import annotations

import typing
from pathlib import Path
from typing import Any

from tox.plugin import impl

if typing.TYPE_CHECKING:
    from tox.tox_env.api import ToxEnv


dist_dir = Path(__file__).resolve().parent / "dist"
tox_asks_rebuild = dist_dir / ".TOX-ASKS-REBUILD"


@impl
def tox_on_install(tox_env: ToxEnv, arguments: Any, section: str, of_type: str):
    if (tox_env.name != ".pkg_external") or (of_type != "requires"):
        return

    # This signals to the build script that the package should be built.
    tox_asks_rebuild.parent.mkdir(parents=True, exist_ok=True)
    tox_asks_rebuild.touch()

tox_build_mypkg.py

  • 位于 /tests/tox_build_mypkg.py
import shutil
import subprocess
from pathlib import Path

dist_dir = Path(__file__).resolve().parent.parent / "dist"


def build():
    if not (dist_dir / ".TOX-ASKS-REBUILD").exists():
        print("Build already done. skipping.")
        return

    print(f"Building sdist and wheel into {dist_dir}")
    # Cleanup. Remove all older builds; the /dist folder and its contents.
    # Note that tox would crash if there were two files with .whl extension.
    # This also resets the TOX-ASKS-REBUILD so we build only once.
    shutil.rmtree(dist_dir, ignore_errors=True)

    out = subprocess.run(
        f"python -m build -o {dist_dir}", capture_output=True, shell=True
    )
    if out.stderr:
        raise RuntimeError(out.stderr.decode("utf-8"))
    print(out.stdout.decode("utf-8"))


if __name__ == "__main__":

    build()

tox.ini

[testenv]
; The following makes the packaging use the external builder defined in
; [testenv:.pkg_external] instead of using tox to create sdist/wheel.
; https://tox.wiki/en/latest/config.html#external-package-builder
package = external


[testenv:.pkg_external]
; This is a special environment which is used to build the sdist and wheel
; to the dist/ folder automatically *before* any other environments are ran.
; All of this require the "package = external" setting.
deps =
    ; The build package from PyPA. See: https://build.pypa.io/en/stable/
    build==1.1.1
commands =
    python tests/tox_build_mypkg.py

; This determines which files tox may use to install mypkg in the test
; environments. The .whl is created with the tox_build_mypkg.py
package_glob = {toxinidir}{/}dist{/}mypkg-*-py3-none-any.whl

注意事项

  • 这个解决方案需要一个相对较新的 tox 版本。tox_on_install hook 是在 tox 4.0.9 中添加的
  • 如果使用了任何 tox 扩展并且遇到 hook 被调用的问题,先尝试不使用 tox 扩展。
  • 也可以将 hooks 放在任何 已安装 的 Python 模块中,并在 pyproject.toml 中定义位置,如 扩展点 所述。然而,toxfile.py 更方便,因为它不需要在当前环境中 安装

附录

tox 执行顺序

可以通过使用附录中定义的虚拟 hook 文件(tox_print_hooks.py)和 系统概述 中关于执行顺序的要点列表,反向推导出 tox 的执行顺序。请注意,我已经设置了 package = external,这对输出有一定影响。以下是 tox 的执行过程:

1) CONFIGURATION
tox_register_tox_env
tox_add_core_config
tox_add_env_config (N+2 times[1])

2) ENVIRONMENT (for each environment)

tox_on_install (envname, deps)
envname: install_deps (if not cached)
    
If not all(skip_install) AND first time: [2]
  tox_on_install (.pkg_external, requires)
  .pkg_external: install_requires (if not cached)
  tox_on_install (.pkg_external, deps)
  .pkg_external: install_deps (if not cached)
      
If not skip_install:
  .pkg_external: commands  
  tox_on_install (envname, package) 
  envname: install_package [3]
    
tox_before_run_commands (envname)
envname: commands
tox_after_run_commands (envname)
  
tox_env_teardown (envname)

[1] N = tox 配置文件中的环境数量。“2”来自 .pkg_external 和 .pkg_external_sdist_meta
[2] “第一次”意味着:在这个 tox 调用中的第一次。这仅在至少有一个未设置 skip_install=True 的环境时执行。
[3] 这会从 wheel 安装包。如果在 [testenv] 中使用 package = external,它会从 [testenv:.pkg_external] 中定义的 package_glob 位置获取 wheel。

虚拟 hook 文件 tox_print_hooks.py

from typing import Any

from tox.config.sets import ConfigSet, EnvConfigSet
from tox.execute.api import Outcome
from tox.plugin import impl
from tox.session.state import State
from tox.tox_env.api import ToxEnv
from tox.tox_env.register import ToxEnvRegister


@impl
def tox_register_tox_env(register: ToxEnvRegister) -> None:
    print("tox_register_tox_env", register)


@impl
def tox_add_core_config(core_conf: ConfigSet, state: State) -> None:
    print("tox_add_core_config", core_conf, state)


@impl
def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None:
    print("tox_add_env_config", env_conf, state)


@impl
def tox_on_install(tox_env: ToxEnv, arguments: Any, section: str, of_type: str):
    print("tox_on_install", tox_env, arguments, section, of_type)


@impl
def tox_before_run_commands(tox_env: ToxEnv):
    print("tox_before_run_commands", tox_env)


@impl
def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]):
    print("tox_after_run_commands", tox_env, exit_code, outcomes)


@impl
def tox_env_teardown(tox_env: ToxEnv):
    print("tox_env_teardown", tox_env)

撰写回答