Skip to content

Commit

Permalink
feat: requires-python (#536)
Browse files Browse the repository at this point in the history
* feat: requires-python

refactor: updating to current proposal

fix: add tests and fix a few issues

docs: Update mostly from @joerick

docs: update README from readthedocs

* refactor: pull out config file reading

* fix: always assume highest Python patch version

* refactor: try new design

* refactor: function and bump MyPy to 0.800

* fix: tighten AST to call only, add docs

* fix: address review points from @joerick
  • Loading branch information
henryiii authored Jan 31, 2021
1 parent b95dca5 commit eb700a1
Show file tree
Hide file tree
Showing 17 changed files with 591 additions and 30 deletions.
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()
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)

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
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

0 comments on commit eb700a1

Please sign in to comment.