111 lines
3.4 KiB
Python
111 lines
3.4 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import configparser
|
|
import contextlib
|
|
from pathlib import Path
|
|
|
|
from ._compat import tomllib
|
|
|
|
|
|
def get_parent(node: ast.AST | None, depth: int = 1) -> ast.AST | None:
|
|
for _ in range(depth):
|
|
node = getattr(node, "parent", None)
|
|
return node
|
|
|
|
|
|
def is_main(parent: ast.AST | None) -> bool:
|
|
if parent is None:
|
|
return False
|
|
|
|
# This would be much nicer with 3.10's pattern matching!
|
|
if not isinstance(parent, ast.If):
|
|
return False
|
|
if not isinstance(parent.test, ast.Compare):
|
|
return False
|
|
|
|
try:
|
|
(op,) = parent.test.ops
|
|
(comp,) = parent.test.comparators
|
|
except ValueError:
|
|
return False
|
|
|
|
if not isinstance(op, ast.Eq):
|
|
return False
|
|
|
|
values = {comp, parent.test.left}
|
|
|
|
mains = {x for x in values if isinstance(x, ast.Constant) and x.value == "__main__"}
|
|
if len(mains) != 1:
|
|
return False
|
|
consts = {x for x in values if isinstance(x, ast.Name) and x.id == "__name__"}
|
|
if len(consts) != 1:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class Analyzer(ast.NodeVisitor):
|
|
def __init__(self) -> None:
|
|
self.requires_python: str | None = None
|
|
|
|
def visit(self, node: ast.AST) -> None:
|
|
for inner_node in ast.walk(node):
|
|
for child in ast.iter_child_nodes(inner_node):
|
|
child.parent = inner_node # type: ignore[attr-defined]
|
|
super().visit(node)
|
|
|
|
def visit_keyword(self, node: ast.keyword) -> None:
|
|
# Must not be nested except for if __name__ == "__main__"
|
|
|
|
self.generic_visit(node)
|
|
# This will be Module -> Expr -> Call -> keyword
|
|
parent = get_parent(node, 4)
|
|
unnested = parent is None
|
|
|
|
# This will be Module -> If -> Expr -> Call -> keyword
|
|
name_main_unnested = (
|
|
parent is not None and get_parent(parent) is None and is_main(get_parent(node, 3))
|
|
)
|
|
|
|
if (
|
|
node.arg == "python_requires"
|
|
and isinstance(node.value, ast.Constant)
|
|
and (unnested or name_main_unnested)
|
|
):
|
|
self.requires_python = node.value.value
|
|
|
|
|
|
def setup_py_python_requires(content: str) -> str | None:
|
|
try:
|
|
tree = ast.parse(content)
|
|
analyzer = Analyzer()
|
|
analyzer.visit(tree)
|
|
return analyzer.requires_python or None
|
|
except Exception: # pylint: disable=broad-except
|
|
return None
|
|
|
|
|
|
def get_requires_python_str(package_dir: Path) -> str | None:
|
|
"""Return the python requires string from the most canonical source available, or None"""
|
|
|
|
# Read in from pyproject.toml:project.requires-python
|
|
with contextlib.suppress(FileNotFoundError):
|
|
with (package_dir / "pyproject.toml").open("rb") as f1:
|
|
info = tomllib.load(f1)
|
|
with contextlib.suppress(KeyError, IndexError, TypeError):
|
|
return str(info["project"]["requires-python"])
|
|
|
|
# Read in from setup.cfg:options.python_requires
|
|
config = configparser.ConfigParser()
|
|
with contextlib.suppress(FileNotFoundError):
|
|
config.read(package_dir / "setup.cfg")
|
|
with contextlib.suppress(KeyError, IndexError, TypeError):
|
|
return str(config["options"]["python_requires"])
|
|
|
|
setup_py = package_dir / "setup.py"
|
|
with contextlib.suppress(FileNotFoundError), setup_py.open(encoding="utf8") as f2:
|
|
return setup_py_python_requires(f2.read())
|
|
|
|
return None
|