Files
cibuildwheel/unit_test/utils_test.py
T
79244d366c feet: general approach to auditing wheels with abi3audit default (#2805)
* WIP - initial punt at audit command

* Add `abi3audit` as a dependency

* Add helper functions to check stable ABI wheels

* Run `abi3audit` for macOS and Windows wheels

* Copy out of container for repairing?

* Add some notes that `cibuildwheel` runs `abi3audit`

* Add basic unit tests

* Add a basic C extension with `Py_LIMITED_API`

* Add a test project that violates Stable ABI

* Fix linux test

* Skip abi3 wheel tests for Pyodide

* Patch the correct subprocess module

* wrap cleanup of abi3audit dir

* Write the docs for the new options

* Move to above testing in docs

* Implement audit-requires and audit-command

* Some cleanups after self-review

* Add default value

* fix type errors

* the key is `audit-command`, not `audit`

* Add a variety of tests for audit requires options

* Add `test_audit_requires` similar to `test_test_requires`

* Add some configurability-related audit tests

* Fix parsing error with options docs leaving out commands

* Better way to extract version (maybe helps Pyodide?)

* Fix a case of unbound `use_uv`

* Standardise: rename to `abi3_wheel`

* Fix audit command run message

* Simplify custom audit command a bit

* Remove unnecessary skip for Pyodide

* Pyodide should have no default audit command

* More accurate skip messages for Pyodide skips

* Wheels are audited after they are repaired

* Regenerate constraints to include `abi3audit`

* Fix typos

* Some attempts for Windows fixes

* Check `pyvenv.cfg` instead of directory existence

* Add validation for lack of wheel placeholders

* Try yet another Windows `uv` fix

* Regenerate diagram and re-trigger Azure CI

* Add missing `import sys` for abi3 C extension tests

* Remove audit-command at the global level

* Clarify `abi3audit` pinning a little bit

* Regen constraints

* Discard changes to cibuildwheel/resources/constraints-pyodide312.txt

* Discard changes to cibuildwheel/resources/constraints-pyodide313.txt

* try opt-in uv again

* fix issue on windows on Python 3.13 related to nested venvs

On win / python 3.13, virtualenv creates a venv where the 'home'
points back to the venv that sys.executable was running in, rather
than the root install. that seemingly leads to problems with package
resolution, where pip.exe couldn't find the pip python package.
this appears to fix it!

* Update constraints

* chore: revert python-discovery bump

Assisted-by: OpenCode:glm-5.1
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

* fix: restore workaround for graalpy

Assisted-by: OpenCode:glm-5.1
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

---------

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com>
Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
2026-05-14 07:41:14 -07:00

424 lines
14 KiB
Python

import textwrap
from pathlib import Path, PurePath
from unittest.mock import Mock, call
import pytest
from cibuildwheel import errors
from cibuildwheel.ci import fix_ansi_codes_for_github_actions
from cibuildwheel.util.file import copy_test_sources
from cibuildwheel.util.helpers import (
FlexibleVersion,
format_safe,
parse_key_value_string,
prepare_command,
unwrap,
unwrap_preserving_paragraphs,
)
from cibuildwheel.util.packaging import find_compatible_wheel, is_abi3_wheel
def test_format_safe() -> None:
assert format_safe("{wheel}", wheel="filename.whl") == "filename.whl"
assert format_safe("command #{wheel}", wheel="filename.whl") == "command {wheel}"
assert format_safe("{command #{wheel}}", wheel="filename.whl") == "{command {wheel}}"
# check unmatched brackets
assert format_safe("{command {wheel}", wheel="filename.whl") == "{command filename.whl"
# check positional-style arguments i.e. {}
assert (
format_safe("find . -name * -exec ls -a {} \\;", project="/project")
== "find . -name * -exec ls -a {} \\;"
)
assert format_safe("{param} {param}", param="1") == "1 1"
assert format_safe("# {param} {param}", param="1") == "# 1 1"
assert format_safe("#{not_a_param} {param}", param="1") == "#{not_a_param} 1"
def test_prepare_command() -> None:
assert prepare_command("python -m {project}", project="project") == "python -m project"
assert prepare_command("python -m {something}", project="project") == "python -m {something}"
assert (
prepare_command("python -m {something.abc}", project="project")
== "python -m {something.abc}"
)
assert (
prepare_command("python -m {something.abc[4]:3f}", project="project")
== "python -m {something.abc[4]:3f}"
)
# test backslashes in the replacement
assert (
prepare_command(
"command {wheel} \\Users\\Temp\\output_dir", wheel="\\Temporary Files\\cibw"
)
== "command \\Temporary Files\\cibw \\Users\\Temp\\output_dir"
)
# test some unusual syntax that used to trip up the str.format approach
assert (
prepare_command("{a}{a,b}{b:.2e}{c}{d%s}{e:3}{f[0]}", a="42", b="3.14159")
== "42{a,b}{b:.2e}{c}{d%s}{e:3}{f[0]}"
)
@pytest.mark.parametrize(
("wheel", "identifier"),
[
("foo-0.1-cp38-abi3-win_amd64.whl", "cp310-win_amd64"),
("foo-0.1-cp38-abi3-macosx_11_0_x86_64.whl", "cp310-macosx_x86_64"),
("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-manylinux_x86_64"),
("foo-0.1-cp38-abi3-musllinux_1_1_x86_64.whl", "cp310-musllinux_x86_64"),
("foo-0.1-py2.py3-none-win_amd64.whl", "cp310-win_amd64"),
("foo-0.1-py2.py3-none-win_amd64.whl", "pp310-win_amd64"),
("foo-0.1-py3-none-win_amd64.whl", "cp310-win_amd64"),
("foo-0.1-py38-none-win_amd64.whl", "cp310-win_amd64"),
("foo-0.1-py38-none-win_amd64.whl", "pp310-win_amd64"),
],
)
def test_find_compatible_wheel_found(wheel: str, identifier: str) -> None:
wheel_ = PurePath(wheel)
found = find_compatible_wheel([wheel_], identifier)
assert found is wheel_
@pytest.mark.parametrize(
("wheel", "identifier"),
[
("foo-0.1-cp38-abi3-win_amd64.whl", "cp310-win32"),
("foo-0.1-cp38-abi3-win_amd64.whl", "cp37-win_amd64"),
("foo-0.1-cp38-abi3-macosx_11_0_x86_64.whl", "cp310-macosx_universal2"),
("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-musllinux_x86_64"),
("foo-0.1-cp38-abi3-musllinux_1_1_x86_64.whl", "cp310-manylinux_x86_64"),
("foo-0.1-py2-none-win_amd64.whl", "cp310-win_amd64"),
("foo-0.1-py38-none-win_amd64.whl", "cp37-win_amd64"),
("foo-0.1-py38-none-win_amd64.whl", "pp37-win_amd64"),
("foo-0.1-cp38-cp38-win_amd64.whl", "cp310-win_amd64"),
],
)
def test_find_compatible_wheel_not_found(wheel: str, identifier: str) -> None:
assert find_compatible_wheel([PurePath(wheel)], identifier) is None
def test_fix_ansi_codes_for_github_actions() -> None:
input = textwrap.dedent(
"""
This line is normal
\033[1mThis line is bold
This line is also bold
\033[31m this line is red and bold
This line is red and bold, too\033[0m
This line is normal again
"""
)
expected = textwrap.dedent(
"""
This line is normal
\033[1mThis line is bold
\033[1mThis line is also bold
\033[1m\033[31m this line is red and bold
\033[1m\033[31mThis line is red and bold, too\033[0m
This line is normal again
"""
)
output = fix_ansi_codes_for_github_actions(input)
assert output == expected
def test_parse_key_value_string() -> None:
assert parse_key_value_string("bar", positional_arg_names=["foo"]) == {"foo": ["bar"]}
assert parse_key_value_string("foo:bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
with pytest.raises(ValueError, match="Too many positional arguments"):
parse_key_value_string("bar")
with pytest.raises(ValueError, match="Unknown field name"):
parse_key_value_string("foo:bar")
assert parse_key_value_string("foo:bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
assert parse_key_value_string("foo:bar", positional_arg_names=["foo"]) == {"foo": ["bar"]}
assert parse_key_value_string("foo: bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
assert parse_key_value_string("foo: bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
assert parse_key_value_string("foo: bar; baz: qux", kw_arg_names=["foo", "baz"]) == {
"foo": ["bar"],
"baz": ["qux"],
}
# some common options
assert parse_key_value_string(
"docker; create_args: --some-option --another-option=foo",
positional_arg_names=["name"],
kw_arg_names=["create_args"],
) == {
"name": ["docker"],
"create_args": ["--some-option", "--another-option=foo"],
}
# semicolon in value
assert parse_key_value_string(
"docker; create_args: --some-option='this; that'",
positional_arg_names=["name"],
kw_arg_names=["create_args"],
) == {
"name": ["docker"],
"create_args": ["--some-option=this; that"],
}
# colon in value
assert parse_key_value_string(
"docker; create_args: --mount a:b",
positional_arg_names=["name"],
kw_arg_names=["create_args"],
) == {
"name": ["docker"],
"create_args": ["--mount", "a:b"],
}
assert parse_key_value_string(
"docker;create_args:--mount a:b",
positional_arg_names=["name"],
kw_arg_names=["create_args"],
) == {
"name": ["docker"],
"create_args": ["--mount", "a:b"],
}
# quoted value with spaces
assert parse_key_value_string(
"docker;create_args:'some string with spaces'",
positional_arg_names=["name"],
kw_arg_names=["create_args"],
) == {
"name": ["docker"],
"create_args": ["some string with spaces"],
}
# colon in positional value
assert parse_key_value_string(
"docker; --mount a:b",
positional_arg_names=["name", "create_args"],
) == {
"name": ["docker"],
"create_args": ["--mount", "a:b"],
}
# empty option gives empty array
assert parse_key_value_string(
"docker;create_args:",
positional_arg_names=["name"],
kw_arg_names=["create_args"],
) == {
"name": ["docker"],
"create_args": [],
}
def test_flexible_version_comparisons() -> None:
assert FlexibleVersion("2.0") == FlexibleVersion("2")
assert FlexibleVersion("2.0") < FlexibleVersion("2.1")
assert FlexibleVersion("2.1") > FlexibleVersion("2")
assert FlexibleVersion("1.9.9") < FlexibleVersion("2.0")
assert FlexibleVersion("1.10") > FlexibleVersion("1.9.9")
assert FlexibleVersion("3.0.1") > FlexibleVersion("3.0")
assert FlexibleVersion("3.0") < FlexibleVersion("3.0.1")
# Suffix should not affect comparisons
assert FlexibleVersion("1.0.1-rhel") > FlexibleVersion("1.0")
assert FlexibleVersion("1.0.1-rhel") < FlexibleVersion("1.1")
assert FlexibleVersion("1.0.1") == FlexibleVersion("v1.0.1")
@pytest.fixture
def sample_project(tmp_path: Path) -> Path:
"""Create a directory structure that contains a range of files."""
project_path = tmp_path / "project"
(project_path / "src/deep").mkdir(parents=True)
(project_path / "tests/deep").mkdir(parents=True)
(project_path / "other").mkdir(parents=True)
(project_path / "pyproject.toml").write_text("A pyproject.toml file")
(project_path / "test.cfg").write_text("A test config file")
(project_path / "src/__init__.py").write_text("source init")
(project_path / "src/module.py").write_text("source module")
(project_path / "src/deep/__init__.py").write_text("deep source init")
(project_path / "tests/test_module.py").write_text("test module")
(project_path / "tests/deep/test_module.py").write_text("deep test module")
(project_path / "tests/deep/__init__.py").write_text("deep test init")
(project_path / "other/module.py").write_text("other module")
return project_path
@pytest.mark.parametrize(
("test_sources", "expected", "not_expected"),
[
# Empty test_sources copies nothing.
pytest.param(
[],
[],
[
"pyproject.toml",
"test.cfg",
"other/module.py",
"src/__init__.py",
"src/module.py",
"src/deep/__init__.py",
"tests/test_module.py",
"tests/deep/__init__.py",
"tests/deep/test_module.py",
],
id="empty",
),
# Single standalone files
pytest.param(
["pyproject.toml", "tests/deep/test_module.py"],
["pyproject.toml", "tests/deep/test_module.py"],
[
"test.cfg",
"other/module.py",
"src/__init__.py",
"src/module.py",
"src/deep/__init__.py",
"tests/test_module.py",
"tests/deep/__init__.py",
],
id="single-file",
),
# A full Directory
pytest.param(
["tests"],
[
"tests/test_module.py",
"tests/deep/__init__.py",
"tests/deep/test_module.py",
],
[
"pyproject.toml",
"test.cfg",
"other/module.py",
"src/__init__.py",
"src/module.py",
"src/deep/__init__.py",
],
id="top-level-directory",
),
# A partial deep directory
pytest.param(
["tests/deep"],
[
"tests/deep/__init__.py",
"tests/deep/test_module.py",
],
[
"pyproject.toml",
"test.cfg",
"other/module.py",
"src/__init__.py",
"src/module.py",
"src/deep/__init__.py",
"tests/test_module.py",
],
id="partial-directory",
),
],
)
def test_copy_test_sources(
tmp_path: Path,
sample_project: Path,
test_sources: list[str],
expected: list[str],
not_expected: list[str],
) -> None:
"""Test sources can be copied into the test directory."""
target = tmp_path / "somewhere/test_cwd"
copy_test_sources(test_sources, sample_project, target)
for path in expected:
assert (tmp_path / "somewhere/test_cwd" / path).is_file()
for path in not_expected:
assert not (tmp_path / "somewhere/test_cwd" / path).exists()
def test_copy_test_sources_missing_file(tmp_path: Path, sample_project: Path) -> None:
"""If test_sources references a folder that doesn't exist, an error is raised."""
with pytest.raises(
errors.FatalError,
match=r"Test source tests/does_not_exist.py does not exist.",
):
copy_test_sources(
["pyproject.toml", "tests/does_not_exist.py"],
sample_project,
tmp_path / "somewhere/test_cwd",
)
def test_copy_test_sources_alternate_copy_into(sample_project: Path) -> None:
"""If an alternate copy_into method is provided, it is used."""
target = PurePath("/container/test_cwd")
copy_into = Mock()
copy_test_sources(["pyproject.toml", "tests"], sample_project, target, copy_into=copy_into)
copy_into.assert_has_calls(
[
call(sample_project / "pyproject.toml", target / "pyproject.toml"),
call(sample_project / "tests", target / "tests"),
],
any_order=True,
)
def test_unwrap() -> None:
assert (
unwrap("""
This is a
multiline
string
""")
== "This is a multiline string"
)
def test_unwrap_preserving_paragraphs() -> None:
assert (
unwrap("""
This is a
multiline
string
""")
== "This is a multiline string"
)
assert (
unwrap_preserving_paragraphs("""
paragraph one
paragraph two
""")
== "paragraph one\n\nparagraph two"
)
class TestIsAbi3Wheel:
def test_abi3_wheel(self) -> None:
assert is_abi3_wheel("foo-1.0-cp310-abi3-manylinux_2_28_x86_64.whl") is True
def test_abi3_wheel_macos(self) -> None:
assert is_abi3_wheel("foo-1.0-cp311-abi3-macosx_11_0_arm64.whl") is True
def test_abi3_wheel_windows(self) -> None:
assert is_abi3_wheel("foo-1.0-cp310-abi3-win_amd64.whl") is True
def test_cpython_wheel(self) -> None:
assert is_abi3_wheel("foo-1.0-cp310-cp310-manylinux_2_28_x86_64.whl") is False
def test_none_any_wheel(self) -> None:
assert is_abi3_wheel("foo-1.0-py3-none-any.whl") is False
def test_none_platform_wheel(self) -> None:
assert is_abi3_wheel("foo-1.0-cp310-none-win_amd64.whl") is False