Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: requires-python #536

Merged
merged 7 commits into from
Jan 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,5 @@ env3?/

# MyPy cache
.mypy_cache/

all_known_setup.yaml
12 changes: 8 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) <br> [`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 |
Expand Down
110 changes: 110 additions & 0 deletions bin/inspect_all_known_projects.py
Original file line number Diff line number Diff line change
@@ -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()
29 changes: 20 additions & 9 deletions cibuildwheel/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
henryiii marked this conversation as resolved.
Show resolved Hide resolved
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)
Expand Down Expand Up @@ -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':
Expand Down
79 changes: 79 additions & 0 deletions cibuildwheel/projectfiles.py
Original file line number Diff line number Diff line change
@@ -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)
henryiii marked this conversation as resolved.
Show resolved Hide resolved

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()
henryiii marked this conversation as resolved.
Show resolved Hide resolved
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
21 changes: 19 additions & 2 deletions cibuildwheel/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
Loading