* Use auditwheel on Android * Add auditwheel command to defaults * pkgconfig fixes * Pre-import ctypes before monkey-patching in _cross_venv * Set PKG_CONFIG and PKG_CONFIG_RELOCATE_PATHS variables * Initial attempt at using mzakharo/android-gfortran * Switch to using termux/ndk-toolchain-clang-with-flang * Move PKG_CONFIG variables from build_env to android_env * Simplify flang installation * Add cross build files for NumPy * Add ldpaths entry for libomp * Add `--rm` to docker command line * Make Rust and Fortran shims consistent * Update documentation * Default to API level 24 on all Python versions * Clarify comments * Cleanups * Fix tests: * Set up Android env after installing pkgconf * Add tests for successfully using an older API level * Previous commit's auditwheel failure is fixed in the auditwheel PR * Update how-it-works diagram * Add tests for repair errors * Add more repair tests * Add test for Meson and Fortran * Add test for cross build files * Improve test_api_level error message * Add xbuild-files option * use pypa/auditwheel@main * Remove dependencies which are no longer needed * Fix README * Update to auditwheel 6.7.0 * Fix compatibility with pkgconf 2.5.1.post2 * Documentation clarifications Co-authored-by: Joe Rickerby <joerick@mac.com> * Fortran shim improvements * Add Jinja variables to new_meson_project * Update run_example_ci_configs for changed new_meson_project signature * Add missing dependency to run_example_ci_configs --------- Co-authored-by: mayeut <mayeut@users.noreply.github.com> Co-authored-by: Joe Rickerby <joerick@mac.com>
180 lines
5.6 KiB
Python
180 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import itertools
|
|
import re
|
|
import shlex
|
|
import textwrap
|
|
from collections import defaultdict
|
|
|
|
TYPE_CHECKING = False
|
|
if TYPE_CHECKING:
|
|
import os
|
|
from collections.abc import Sequence
|
|
|
|
from cibuildwheel.typing import PathOrStr
|
|
|
|
|
|
def format_safe(template: str, **kwargs: str | os.PathLike[str]) -> str:
|
|
"""
|
|
Works similarly to `template.format(**kwargs)`, except that unmatched
|
|
fields in `template` are passed through untouched.
|
|
|
|
>>> format_safe('{a} {b}', a='123')
|
|
'123 {b}'
|
|
>>> format_safe('{a} {b[4]:3f}', a='123')
|
|
'123 {b[4]:3f}'
|
|
|
|
To avoid variable expansion, precede with a single hash e.g.
|
|
>>> format_safe('#{a} {b}', a='123')
|
|
'{a} {b}'
|
|
"""
|
|
|
|
result = template
|
|
|
|
for key, value in kwargs.items():
|
|
find_pattern = re.compile(
|
|
rf"""
|
|
(?<!\#) # don't match if preceded by a hash
|
|
{{ # literal open curly bracket
|
|
{re.escape(key)} # the field name
|
|
}} # literal close curly bracket
|
|
""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
result = re.sub(
|
|
pattern=find_pattern,
|
|
repl=str(value).replace("\\", r"\\"),
|
|
string=result,
|
|
)
|
|
|
|
# transform escaped sequences into their literal equivalents
|
|
result = result.replace(f"#{{{key}}}", f"{{{key}}}")
|
|
|
|
return result
|
|
|
|
|
|
def prepare_command(command: str, **kwargs: PathOrStr) -> str:
|
|
"""
|
|
Preprocesses a command by expanding variables like {project}.
|
|
|
|
For example, used in the test_command option to specify the path to the
|
|
project's root. Unmatched syntax will mostly be allowed through.
|
|
"""
|
|
return format_safe(command, **kwargs)
|
|
|
|
|
|
def strtobool(val: str) -> bool:
|
|
return val.lower() in {"y", "yes", "t", "true", "on", "1"}
|
|
|
|
|
|
def unwrap(text: str) -> str:
|
|
"""
|
|
Unwraps multi-line text to a single line
|
|
"""
|
|
# remove initial line indent
|
|
text = textwrap.dedent(text)
|
|
# remove leading/trailing whitespace
|
|
text = text.strip()
|
|
# remove consecutive whitespace
|
|
return re.sub(r"\s+", " ", text)
|
|
|
|
|
|
def unwrap_preserving_paragraphs(text: str) -> str:
|
|
"""
|
|
Unwraps multi-line text to a single line, but preserves paragraphs
|
|
"""
|
|
# remove initial line indent
|
|
text = textwrap.dedent(text)
|
|
# remove leading/trailing whitespace
|
|
text = text.strip()
|
|
|
|
paragraphs = text.split("\n\n")
|
|
# remove consecutive whitespace
|
|
paragraphs = [re.sub(r"\s+", " ", paragraph) for paragraph in paragraphs]
|
|
return "\n\n".join(paragraphs)
|
|
|
|
|
|
def parse_key_value_string(
|
|
key_value_string: str,
|
|
positional_arg_names: Sequence[str] | None = None,
|
|
kw_arg_names: Sequence[str] | None = None,
|
|
) -> dict[str, list[str]]:
|
|
"""
|
|
Parses a string like "docker; create_args: --some-option=value another-option"
|
|
"""
|
|
if positional_arg_names is None:
|
|
positional_arg_names = []
|
|
if kw_arg_names is None:
|
|
kw_arg_names = []
|
|
|
|
all_field_names = None if ("*" in kw_arg_names) else [*positional_arg_names, *kw_arg_names]
|
|
|
|
shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";")
|
|
shlexer.commenters = ""
|
|
shlexer.whitespace_split = True
|
|
parts = list(shlexer)
|
|
# parts now looks like
|
|
# ['docker', ';', 'create_args:', '--some-option=value', 'another-option']
|
|
|
|
# split by semicolon
|
|
fields = [list(group) for k, group in itertools.groupby(parts, lambda x: x == ";") if not k]
|
|
|
|
result: defaultdict[str, list[str]] = defaultdict(list)
|
|
for field_i, field in enumerate(fields):
|
|
# check to see if the option name is specified
|
|
field_name, sep, first_value = field[0].partition(":")
|
|
if sep:
|
|
if (all_field_names is not None) and (field_name not in all_field_names):
|
|
msg = f"Failed to parse {key_value_string!r}. Unknown field name {field_name!r}"
|
|
raise ValueError(msg)
|
|
|
|
values = ([first_value] if first_value else []) + field[1:]
|
|
else:
|
|
try:
|
|
field_name = positional_arg_names[field_i]
|
|
except IndexError:
|
|
msg = f"Failed to parse {key_value_string!r}. Too many positional arguments - expected a maximum of {len(positional_arg_names)}"
|
|
raise ValueError(msg) from None
|
|
|
|
values = field
|
|
|
|
result[field_name] += values
|
|
|
|
return dict(result)
|
|
|
|
|
|
@dataclasses.dataclass(order=True)
|
|
class FlexibleVersion:
|
|
version_parts: tuple[int, ...] = dataclasses.field(init=False, repr=False)
|
|
suffix: str = dataclasses.field(init=False, repr=False)
|
|
version_str: str = dataclasses.field(compare=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
# Split into numeric parts and the optional suffix
|
|
match = re.match(r"^[v]?(\d+(\.\d+)*)(.*)$", self.version_str)
|
|
if not match:
|
|
msg = f"Invalid version string: {self.version_str}"
|
|
raise ValueError(msg)
|
|
|
|
version_part, _, suffix = match.groups()
|
|
|
|
# Convert numeric version part into a tuple of integers
|
|
self.version_parts = tuple(map(int, version_part.split(".")))
|
|
self.suffix = suffix.strip() if suffix else ""
|
|
|
|
# Normalize by removing trailing zeros
|
|
self.version_parts = self._remove_trailing_zeros(self.version_parts)
|
|
|
|
@staticmethod
|
|
def _remove_trailing_zeros(parts: tuple[int, ...]) -> tuple[int, ...]:
|
|
# Remove trailing zeros for accurate comparisons
|
|
# without this, "3.0" would be considered greater than "3"
|
|
while parts and parts[-1] == 0:
|
|
parts = parts[:-1]
|
|
return parts
|
|
|
|
def __str__(self) -> str:
|
|
return self.version_str
|