diff --git a/.gitignore b/.gitignore index 2db46b823..83ff4ab4c 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ env3?/ # MyPy cache .mypy_cache/ + +all_known_setup.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 623e5d605..71f9e72b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,12 +24,16 @@ repos: args: ["--line-length=120"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.790 + rev: v0.800 hooks: - id: mypy - files: ^(cibuildwheel/|test/|bin/projects.py|bin/update_pythons.py|unit_test/) - pass_filenames: false - additional_dependencies: [packaging, click] + exclude: ^(bin/|cibuildwheel/resources/).*py$ + additional_dependencies: [packaging] + - id: mypy + name: mypy 3.7+ on bin/ + files: ^bin/.*py$ + additional_dependencies: [packaging] + args: ["--python-version=3.7", "--ignore-missing-imports", "--scripts-are-modules"] - repo: https://github.com/asottile/pyupgrade rev: v2.7.4 diff --git a/README.md b/README.md index af701624b..841b4f3ac 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Options | **Build selection** | [`CIBW_PLATFORM`](https://cibuildwheel.readthedocs.io/en/stable/options/#platform) | Override the auto-detected target platform | | | [`CIBW_BUILD`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip)
[`CIBW_SKIP`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip) | Choose the Python versions to build | | | [`CIBW_ARCHS_LINUX`](https://cibuildwheel.readthedocs.io/en/stable/options/#archs) | Build non-native architectures | +| | [`CIBW_PROJECT_REQUIRES_PYTHON`](https://cibuildwheel.readthedocs.io/en/stable/options/#requires-python) | Manually set the Python compatibility of your project | | **Build customization** | [`CIBW_ENVIRONMENT`](https://cibuildwheel.readthedocs.io/en/stable/options/#environment) | Set environment variables needed during the build | | | [`CIBW_BEFORE_ALL`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-all) | Execute a shell command on the build system before any wheels are built. | | | [`CIBW_BEFORE_BUILD`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-build) | Execute a shell command preparing each wheel's build | diff --git a/bin/inspect_all_known_projects.py b/bin/inspect_all_known_projects.py new file mode 100755 index 000000000..5c6547358 --- /dev/null +++ b/bin/inspect_all_known_projects.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Iterator, Optional + +import click +import yaml +from ghapi.core import GhApi, HTTP404NotFoundError # type: ignore +from rich import print + +from cibuildwheel.projectfiles import Analyzer + +DIR = Path(__file__).parent.resolve() + + +def parse(contents: str) -> Optional[str]: + try: + tree = ast.parse(contents) + analyzer = Analyzer() + analyzer.visit(tree) + return analyzer.requires_python or "" + except Exception: + return None + + +def check_repo(name: str, contents: str) -> str: + s = f" {name}: " + if name == "setup.py": + if "python_requires" not in contents: + s += "❌" + res = parse(contents) + if res is None: + s += "⚠️ " + elif res: + s += "✅ " + res + elif "python_requires" in contents: + s += "☑️" + + elif name == "setup.cfg": + s += "✅" if "python_requires" in contents else "❌" + else: + s += "✅" if "requires-python" in contents else "❌" + + return s + + +class MaybeRemote: + def __init__(self, cached_file: Path | str, *, online: bool) -> None: + self.online = online + if self.online: + self.contents: dict[str, dict[str, Optional[str]]] = { + "setup.py": {}, + "setup.cfg": {}, + "pyproject.toml": {}, + } + else: + with open(cached_file) as f: + self.contents = yaml.safe_load(f) + + def get(self, repo: str, filename: str) -> Optional[str]: + if self.online: + try: + self.contents[filename][repo] = ( + GhApi(*repo.split("/")).get_content(filename).decode() + ) + except HTTP404NotFoundError: + self.contents[filename][repo] = None + return self.contents[filename][repo] + elif repo in self.contents[filename]: + return self.contents[filename][repo] + else: + raise RuntimeError( + f"Trying to access {repo}:{filename} and not in cache, rebuild cache" + ) + + def save(self, filename: Path | str) -> None: + with open(filename, "w") as f: + yaml.safe_dump(self.contents, f, default_flow_style=False) + + def on_each(self, repos: list[str]) -> Iterator[tuple[str, str, Optional[str]]]: + for repo in repos: + print(f"[bold]{repo}:") + for filename in sorted(self.contents, reverse=True): + yield repo, filename, self.get(repo, filename) + + +@click.command() +@click.option("--online", is_flag=True, help="Remember to set GITHUB_TOKEN") +def main(online: bool) -> None: + with open(DIR / "../docs/data/projects.yml") as f: + known = yaml.safe_load(f) + + repos = [x["gh"] for x in known] + + ghinfo = MaybeRemote("all_known_setup.yaml", online=online) + + for _, filename, contents in ghinfo.on_each(repos): + if contents is None: + print(f"[red] {filename}: Not found") + else: + print(check_repo(filename, contents)) + + if online: + ghinfo.save("all_known_setup.yaml") + + +if __name__ == "__main__": + main() diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 3b7e2af75..33a570c7f 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -7,12 +7,15 @@ from pathlib import Path from typing import Dict, List, Optional, Set, Union, overload +from packaging.specifiers import SpecifierSet + import cibuildwheel import cibuildwheel.linux import cibuildwheel.macos import cibuildwheel.windows from cibuildwheel.architecture import Architecture, allowed_architectures_check from cibuildwheel.environment import EnvironmentParseError, parse_environment +from cibuildwheel.projectfiles import get_requires_python_str from cibuildwheel.typing import PLATFORMS, PlatformName, assert_never from cibuildwheel.util import ( BuildOptions, @@ -26,10 +29,10 @@ @overload -def get_option_from_environment(option_name: str, platform: Optional[str], default: str) -> str: ... # noqa: E704 +def get_option_from_environment(option_name: str, *, platform: Optional[str] = None, default: str) -> str: ... # noqa: E704 @overload -def get_option_from_environment(option_name: str, platform: Optional[str] = None, default: None = None) -> Optional[str]: ... # noqa: E704 E302 -def get_option_from_environment(option_name: str, platform: Optional[str] = None, default: Optional[str] = None) -> Optional[str]: # noqa: E302 +def get_option_from_environment(option_name: str, *, platform: Optional[str] = None, default: None = None) -> Optional[str]: ... # noqa: E704 E302 +def get_option_from_environment(option_name: str, *, platform: Optional[str] = None, default: Optional[str] = None) -> Optional[str]: # noqa: E302 ''' Returns an option from the environment, optionally scoped by the platform. @@ -156,7 +159,18 @@ def main() -> None: test_extras = get_option_from_environment('CIBW_TEST_EXTRAS', platform=platform, default='') build_verbosity_str = get_option_from_environment('CIBW_BUILD_VERBOSITY', platform=platform, default='') - build_selector = BuildSelector(build_config=build_config, skip_config=skip_config) + package_files = {'setup.py', 'setup.cfg', 'pyproject.toml'} + + if not any(package_dir.joinpath(name).exists() for name in package_files): + names = ', '.join(sorted(package_files, reverse=True)) + print(f'cibuildwheel: Could not find any of {{{names}}} at root of package', file=sys.stderr) + sys.exit(2) + + # Passing this in as an environment variable will override pyproject.toml, setup.cfg, or setup.py + requires_python_str: Optional[str] = os.environ.get('CIBW_PROJECT_REQUIRES_PYTHON') or get_requires_python_str(package_dir) + requires_python = None if requires_python_str is None else SpecifierSet(requires_python_str) + + build_selector = BuildSelector(build_config=build_config, skip_config=skip_config, requires_python=requires_python) test_selector = TestSelector(skip_config=test_skip) try: @@ -186,15 +200,11 @@ def main() -> None: # This needs to be passed on to the docker container in linux.py os.environ['CIBUILDWHEEL'] = '1' - if not any((package_dir / name).exists() - for name in ["setup.py", "setup.cfg", "pyproject.toml"]): - print('cibuildwheel: Could not find setup.py, setup.cfg or pyproject.toml at root of package', file=sys.stderr) - sys.exit(2) - if args.archs is not None: archs_config_str = args.archs else: archs_config_str = get_option_from_environment('CIBW_ARCHS', platform=platform, default='auto') + archs = Architecture.parse_config(archs_config_str, platform=platform) identifiers = get_build_identifiers(platform, build_selector, archs) @@ -334,6 +344,7 @@ def get_build_identifiers( python_configurations: Union[List[cibuildwheel.linux.PythonConfiguration], List[cibuildwheel.windows.PythonConfiguration], List[cibuildwheel.macos.PythonConfiguration]] + if platform == 'linux': python_configurations = cibuildwheel.linux.get_python_configurations(build_selector, architectures) elif platform == 'windows': diff --git a/cibuildwheel/projectfiles.py b/cibuildwheel/projectfiles.py new file mode 100644 index 000000000..8d49fe4d6 --- /dev/null +++ b/cibuildwheel/projectfiles.py @@ -0,0 +1,79 @@ +import ast +import sys +from configparser import ConfigParser +from pathlib import Path +from typing import Any, Optional + +import toml + +if sys.version_info < (3, 8): + Constant = ast.Str + + def get_constant(x: ast.Str) -> str: + return x.s + + +else: + Constant = ast.Constant + + def get_constant(x: ast.Constant) -> Any: + return x.value + + +class Analyzer(ast.NodeVisitor): + def __init__(self) -> None: + self.requires_python: Optional[str] = None + + def visit(self, content: ast.AST) -> None: + for node in ast.walk(content): + for child in ast.iter_child_nodes(node): + child.parent = node # type: ignore + super().visit(content) + + def visit_keyword(self, node: ast.keyword) -> None: + self.generic_visit(node) + if node.arg == "python_requires": + # Must not be nested in an if or other structure + # This will be Module -> Expr -> Call -> keyword + if ( + not hasattr(node.parent.parent.parent, "parent") # type: ignore + and isinstance(node.value, Constant) + ): + self.requires_python = get_constant(node.value) + + +def setup_py_python_requires(content: str) -> Optional[str]: + try: + tree = ast.parse(content) + analyzer = Analyzer() + analyzer.visit(tree) + return analyzer.requires_python or None + except Exception: + return None + + +def get_requires_python_str(package_dir: Path) -> Optional[str]: + "Return the python requires string from the most canonical source available, or None" + + # Read in from pyproject.toml:project.requires-python + try: + info = toml.load(package_dir / 'pyproject.toml') + return str(info['project']['requires-python']) + except (FileNotFoundError, KeyError, IndexError, TypeError): + pass + + # Read in from setup.cfg:options.python_requires + try: + config = ConfigParser() + config.read(package_dir / 'setup.cfg') + return str(config['options']['python_requires']) + except (FileNotFoundError, KeyError, IndexError, TypeError): + pass + + try: + with open(package_dir / 'setup.py') as f: + return setup_py_python_requires(f.read()) + except FileNotFoundError: + pass + + return None diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 9e0bc5d82..761a964ba 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -13,12 +13,15 @@ import bracex import certifi import toml +from packaging.specifiers import SpecifierSet +from packaging.version import Version from .architecture import Architecture from .environment import ParsedEnvironment from .typing import PathOrStr, PlatformName resources_dir = Path(__file__).parent / 'resources' + get_pip_script = resources_dir / 'get-pip.py' install_certifi_script = resources_dir / "install_certifi.py" @@ -53,13 +56,25 @@ class IdentifierSelector: """ This class holds a set of build/skip patterns. You call an instance with a build identifier, and it returns True if that identifier should be - included. + included. Only call this on valid identifiers, ones that have at least 2 + numeric digits before the first dash. """ - def __init__(self, *, build_config: str, skip_config: str): + + def __init__(self, *, build_config: str, skip_config: str, requires_python: Optional[SpecifierSet] = None): self.build_patterns = build_config.split() self.skip_patterns = skip_config.split() + self.requires_python = requires_python def __call__(self, build_id: str) -> bool: + # Filter build selectors by python_requires if set + if self.requires_python is not None: + py_ver_str = build_id.split('-')[0] + major = int(py_ver_str[2]) + minor = int(py_ver_str[3:]) + version = Version(f"{major}.{minor}.99") + if not self.requires_python.contains(version): + return False + build_patterns = itertools.chain.from_iterable(bracex.expand(p) for p in self.build_patterns) skip_patterns = itertools.chain.from_iterable(bracex.expand(p) for p in self.skip_patterns) @@ -78,6 +93,8 @@ class BuildSelector(IdentifierSelector): pass +# Note that requires-python is not needed for TestSelector, as you can't test +# what you can't build. class TestSelector(IdentifierSelector): def __init__(self, *, skip_config: str): super().__init__(build_config="*", skip_config=skip_config) diff --git a/docs/options.md b/docs/options.md index dc46a1108..78ffce929 100644 --- a/docs/options.md +++ b/docs/options.md @@ -208,6 +208,61 @@ Platform-specific variants also available:
This option can also be set using the [command-line option](#command-line) `--archs`. +### `CIBW_PROJECT_REQUIRES_PYTHON` {: #requires-python} +> Manually set the Python compatibility of your project + +By default, cibuildwheel reads your package's Python compatibility from +`pyproject.toml` following [PEP621](https://www.python.org/dev/peps/pep-0621/) +or from `setup.cfg`; finally it will try to inspect the AST of `setup.py` for a +simple keyword assignment in a top level function call. If you need to override +this behaviour for some reason, you can use this option. + +When setting this option, the syntax is the same as `project.requires-python`, +using 'version specifiers' like `>=3.6`, according to +[PEP440](https://www.python.org/dev/peps/pep-0440/#version-specifiers). + +Default: reads your package's Python compatibility from `pyproject.toml` +(`project.requires-python`) or `setup.cfg` (`options.python_requires`) or +`setup.py` `setup(python_requires="...")`. If not found, cibuildwheel assumes +the package is compatible with all versions of Python that it can build. + + +!!! note + Rather than using this option, it's recommended you set + `project.requires-python` in `pyproject.toml` instead: + Example `pyproject.toml`: + + [project] + requires-python = ">=3.6" + + # Aside - in pyproject.toml you should always specify minimal build + # system options, like this: + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + + + Currently, setuptools has not yet added support for reading this value from + pyproject.toml yet, and so does not copy it to Requires-Python in the wheel + metadata. This mechanism is used by `pip` to scan through older versions of + your package until it finds a release compatible with the curernt version + of Python compatible when installing, so it is an important value to set if + you plan to drop support for a version of Python in the future. + + If you don't want to list this value twice, you can also use the setuptools + specific location in `setup.cfg` and cibuildwheel will detect it from + there. Example `setup.cfg`: + + [options] + python_requires = ">=3.6" + +#### Examples + +```yaml +CIBW_PROJECT_REQUIRES_PYTHON: ">=3.6" +``` + ## Build customization ### `CIBW_ENVIRONMENT` {: #environment} diff --git a/setup.cfg b/setup.cfg index 251f0527e..a8815a044 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ install_requires = toml certifi bracex + packaging typing_extensions; python_version < '3.8' [options.package_data] @@ -42,17 +43,17 @@ dev = click mkdocs-include-markdown-plugin==2.1.1 mkdocs==1.0.4 - mypy pip-tools pygithub + ghapi pymdown-extensions - pytest + pytest>=4 pytest-timeout pyyaml requests typing-extensions - packaging>=20.8 rich>=9.6 + mypy>=0.800 [options.packages.find] include = @@ -83,14 +84,14 @@ junit_family=xunit2 [mypy] python_version = 3.6 -files = cibuildwheel,test,unit_test,bin +files = cibuildwheel/*.py,test/**/*.py,unit_test/**/*.py warn_unused_configs = True warn_redundant_casts = True -[mypy-test.*] +[mypy-test.*,mypy-unit_test.*] check_untyped_defs = True -[mypy-cibuildwheel.*,unit_test.*] +[mypy-cibuildwheel.*] disallow_any_generics = True disallow_subclassing_any = True disallow_untyped_calls = True @@ -122,6 +123,9 @@ ignore_missing_imports = True [mypy-bracex.*] ignore_missing_imports = True +[mypy-importlib_resources.*] +ignore_missing_imports = True + [tool:isort] profile=black multi_line_output=3 diff --git a/unit_test/__init__.py b/unit_test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unit_test/build_ids_test.py b/unit_test/build_ids_test.py index 1993efd03..b0d4188a1 100644 --- a/unit_test/build_ids_test.py +++ b/unit_test/build_ids_test.py @@ -1,11 +1,8 @@ -import pytest import toml - -from cibuildwheel.util import resources_dir - -Version = pytest.importorskip("packaging.version").Version +from packaging.version import Version from cibuildwheel.extra import InlineArrayDictEncoder # noqa: E402 +from cibuildwheel.util import resources_dir def test_compare_configs(): diff --git a/unit_test/build_selector_test.py b/unit_test/build_selector_test.py index 6e00fc769..227636bcc 100644 --- a/unit_test/build_selector_test.py +++ b/unit_test/build_selector_test.py @@ -1,3 +1,5 @@ +from packaging.specifiers import SpecifierSet + from cibuildwheel.util import BuildSelector @@ -69,3 +71,36 @@ def test_build_braces(): assert build_selector('cp37-manylinux1_x86_64') assert not build_selector('cp38-manylinux1_x86_64') assert not build_selector('cp39-manylinux1_x86_64') + + +def test_build_limited_python(): + build_selector = BuildSelector(build_config="*", skip_config="", requires_python=SpecifierSet(">=3.6")) + + assert not build_selector('cp27-manylinux1_x86_64') + assert build_selector('cp36-manylinux1_x86_64') + assert build_selector('cp37-manylinux1_x86_64') + assert not build_selector('cp27-manylinux1_i686') + assert build_selector('cp36-manylinux1_i686') + assert build_selector('cp37-manylinux1_i686') + assert not build_selector('cp27-win32') + assert build_selector('cp36-win32') + assert build_selector('cp37-win32') + assert not build_selector('pp27-win32') + assert build_selector('pp36-win32') + assert build_selector('pp37-win32') + + +def test_build_limited_python_partial(): + build_selector = BuildSelector(build_config="*", skip_config="", requires_python=SpecifierSet(">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*")) + + assert build_selector('cp27-manylinux1_x86_64') + assert not build_selector('cp35-manylinux1_x86_64') + assert build_selector('cp36-manylinux1_x86_64') + + +def test_build_limited_python_patch(): + build_selector = BuildSelector(build_config="*", skip_config="", requires_python=SpecifierSet(">=2.7.9")) + + assert build_selector('cp27-manylinux1_x86_64') + assert build_selector('cp36-manylinux1_x86_64') + assert build_selector('cp37-manylinux1_x86_64') diff --git a/unit_test/main_tests/__init__.py b/unit_test/main_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index b55a7db6c..9342dca82 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -51,8 +51,8 @@ def test_build_selector(platform, intercepted_build_args, monkeypatch, allow_emp intercepted_build_selector = intercepted_build_args.args[0].build_selector assert isinstance(intercepted_build_selector, BuildSelector) - assert intercepted_build_selector('build-this') - assert not intercepted_build_selector('skip-that') + assert intercepted_build_selector('build24-this') + assert not intercepted_build_selector('skip65-that') # This unit test is just testing the options of 'main' # Unit tests for BuildSelector are in build_selector_test.py diff --git a/unit_test/main_tests/main_platform_test.py b/unit_test/main_tests/main_platform_test.py index f86de5409..98bcfcdd9 100644 --- a/unit_test/main_tests/main_platform_test.py +++ b/unit_test/main_tests/main_platform_test.py @@ -1,10 +1,11 @@ import sys import pytest -from conftest import MOCK_PACKAGE_DIR # noqa: I100 from cibuildwheel.__main__ import main -from cibuildwheel.util import Architecture +from cibuildwheel.architecture import Architecture + +from .conftest import MOCK_PACKAGE_DIR def test_unknown_platform_non_ci(monkeypatch, capsys): diff --git a/unit_test/main_tests/main_requires_python_test.py b/unit_test/main_tests/main_requires_python_test.py new file mode 100644 index 000000000..a9c86bc2d --- /dev/null +++ b/unit_test/main_tests/main_requires_python_test.py @@ -0,0 +1,103 @@ +import sys +import textwrap + +import pytest +from packaging.specifiers import SpecifierSet + +from cibuildwheel.__main__ import main + + +@pytest.fixture(autouse=True, scope="function") +def fake_package_dir(monkeypatch, tmp_path): + ''' + Set up a fake project + ''' + + local_path = tmp_path / "tmp_project" + local_path.mkdir() + + local_path.joinpath("setup.py").touch() + + monkeypatch.setattr(sys, 'argv', ['cibuildwheel', str(local_path)]) + + return local_path + + +def test_no_override(platform, monkeypatch, intercepted_build_args): + + main() + + intercepted_build_selector = intercepted_build_args.args[0].build_selector + + assert intercepted_build_selector('cp39-win32') + assert intercepted_build_selector('cp36-win32') + + assert intercepted_build_selector.requires_python is None + + +def test_override_env(platform, monkeypatch, intercepted_build_args): + monkeypatch.setenv('CIBW_PROJECT_REQUIRES_PYTHON', '>=3.8') + + main() + + intercepted_build_selector = intercepted_build_args.args[0].build_selector + + assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") + + assert intercepted_build_selector('cp39-win32') + assert not intercepted_build_selector('cp36-win32') + + +def test_override_setup_cfg(platform, monkeypatch, intercepted_build_args, fake_package_dir): + + fake_package_dir.joinpath("setup.cfg").write_text(textwrap.dedent(""" + [options] + python_requires = >=3.8 + """)) + + main() + + intercepted_build_selector = intercepted_build_args.args[0].build_selector + + assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") + + assert intercepted_build_selector('cp39-win32') + assert not intercepted_build_selector('cp36-win32') + + +def test_override_pyproject_toml(platform, monkeypatch, intercepted_build_args, fake_package_dir): + + fake_package_dir.joinpath("pyproject.toml").write_text(textwrap.dedent(""" + [project] + requires-python = ">=3.8" + """)) + + main() + + intercepted_build_selector = intercepted_build_args.args[0].build_selector + + assert intercepted_build_selector.requires_python == SpecifierSet(">=3.8") + + assert intercepted_build_selector('cp39-win32') + assert not intercepted_build_selector('cp36-win32') + + +def test_override_setup_py_simple(platform, monkeypatch, intercepted_build_args, fake_package_dir): + + fake_package_dir.joinpath("setup.py").write_text(textwrap.dedent(""" + from setuptools import setup + + setup( + name = "other", + python_requires = ">=3.7", + ) + """)) + + main() + + intercepted_build_selector = intercepted_build_args.args[0].build_selector + + assert intercepted_build_selector.requires_python == SpecifierSet(">=3.7") + + assert intercepted_build_selector('cp39-win32') + assert not intercepted_build_selector('cp36-win32') diff --git a/unit_test/projectfiles_test.py b/unit_test/projectfiles_test.py new file mode 100644 index 000000000..6b80a13ac --- /dev/null +++ b/unit_test/projectfiles_test.py @@ -0,0 +1,142 @@ +from textwrap import dedent + +from cibuildwheel.projectfiles import get_requires_python_str, setup_py_python_requires + + +def test_read_setup_py_simple(tmp_path): + with open(tmp_path / "setup.py", "w") as f: + f.write(dedent(""" + from setuptools import setup + + setup( + name = "hello", + other = 23, + example = ["item", "other"], + python_requires = "1.23", + ) + """)) + + assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.23" + assert get_requires_python_str(tmp_path) == "1.23" + + +def test_read_setup_py_full(tmp_path): + with open(tmp_path / "setup.py", "w") as f: + f.write(dedent(""" + import setuptools + + setuptools.randomfunc() + + setuptools.setup( + name = "hello", + other = 23, + example = ["item", "other"], + python_requires = "1.24", + ) + """)) + + assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.24" + assert get_requires_python_str(tmp_path) == "1.24" + + +def test_read_setup_py_assign(tmp_path): + with open(tmp_path / "setup.py", "w") as f: + f.write(dedent(""" + from setuptools import setup + + REQUIRES = "3.21" + + setuptools.setup( + name = "hello", + other = 23, + example = ["item", "other"], + python_requires = REQUIRES, + ) + """)) + + assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) is None + assert get_requires_python_str(tmp_path) is None + + +def test_read_setup_py_None(tmp_path): + with open(tmp_path / "setup.py", "w") as f: + f.write(dedent(""" + from setuptools import setup + + REQUIRES = None + + setuptools.setup( + name = "hello", + other = 23, + example = ["item", "other"], + python_requires = None, + ) + """)) + + assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) is None + assert get_requires_python_str(tmp_path) is None + + +def test_read_setup_py_empty(tmp_path): + with open(tmp_path / "setup.py", "w") as f: + f.write(dedent(""" + from setuptools import setup + + REQUIRES = "3.21" + + setuptools.setup( + name = "hello", + other = 23, + example = ["item", "other"], + ) + """)) + + assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) is None + assert get_requires_python_str(tmp_path) is None + + +def test_read_setup_cfg(tmp_path): + with open(tmp_path / "setup.cfg", "w") as f: + f.write(dedent(""" + [options] + python_requires = 1.234 + [metadata] + something = other + """)) + + assert get_requires_python_str(tmp_path) == "1.234" + + +def test_read_setup_cfg_empty(tmp_path): + with open(tmp_path / "setup.cfg", "w") as f: + f.write(dedent(""" + [options] + other = 1.234 + [metadata] + something = other + """)) + + assert get_requires_python_str(tmp_path) is None + + +def test_read_pyproject_toml(tmp_path): + with open(tmp_path / "pyproject.toml", "w") as f: + f.write(dedent(""" + [project] + requires-python = "1.654" + + [tool.cibuildwheel] + something = "other" + """)) + + assert get_requires_python_str(tmp_path) == "1.654" + + +def test_read_pyproject_toml_empty(tmp_path): + with open(tmp_path / "pyproject.toml", "w") as f: + f.write(dedent(""" + [project] + other = 1.234 + """)) + + assert get_requires_python_str(tmp_path) is None