如何测试一个wheel文件在多个Python版本上的兼容性?
问题描述
我正在写一个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 个回答
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)