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