-
Notifications
You must be signed in to change notification settings - Fork 277
feat: requires-python #536
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
Changes from 6 commits
fa6a1a7
e83bb4d
ce663b1
531f93e
bee2eb6
11ee70e
7dec6f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -105,3 +105,5 @@ env3?/ | |
|
||
# MyPy cache | ||
.mypy_cache/ | ||
|
||
all_known_setup.yaml |
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() |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|
||
|
@@ -55,11 +58,25 @@ class IdentifierSelector: | |
build identifier, and it returns True if that identifier should be | ||
included. | ||
""" | ||
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 and '-' in build_id: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we assert for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We haven't in the past, and there are unit tests that put really weird things in here. That's why this is here. :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aaaaaaaaah, right. Forgot about the unit tests again :-/ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mmmyeah, this does seem a little wrong... also the ValueError catch below. These shouldn't be hit in real use, so I think we should fix the unit tests. It looks like only |
||
py_ver_str = build_id.split('-')[0] | ||
try: | ||
major = int(py_ver_str[2]) | ||
minor = int(py_ver_str[3:]) | ||
except ValueError: | ||
pass | ||
else: | ||
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 +95,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) | ||
|
Uh oh!
There was an error while loading. Please reload this page.