* 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>
424 lines
14 KiB
Python
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
|