Skip to content

Commit a58d4b3

Browse files
committed
Add basic setup.py extraction
1 parent 59e16af commit a58d4b3

File tree

3 files changed

+191
-1
lines changed

3 files changed

+191
-1
lines changed

metadata_please/source_checkout.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
- PEP 621 metadata (pyproject.toml)
88
- Poetry metadata (pyproject.toml)
99
- Setuptools static metadata (setup.cfg)
10+
- Setuptools, low effort reading (setup.py)
1011
11-
Notably, does not read setup.py or attempt to emulate anything that can't be read staticly.
12+
Notably, does not read nontrivial setup.py or attempt to emulate anything that can't be read staticly.
1213
"""
14+
import ast
1315
import re
1416
from pathlib import Path
1517

@@ -22,6 +24,8 @@
2224

2325
from packaging.utils import canonicalize_name
2426

27+
from .source_checkout_ast import SetupFindingVisitor, UNKNOWN
28+
2529
from .types import BasicMetadata
2630

2731
OPERATOR_RE = re.compile(r"([<>=~]+)(\d.*)")
@@ -54,6 +58,7 @@ def from_source_checkout(path: Path) -> bytes:
5458
from_pep621_checkout(path)
5559
or from_poetry_checkout(path)
5660
or from_setup_cfg_checkout(path)
61+
or from_setup_py_checkout(path)
5762
)
5863

5964

@@ -227,6 +232,36 @@ def from_setup_cfg_checkout(path: Path) -> bytes:
227232
return "".join(buf).encode("utf-8")
228233

229234

235+
def from_setup_py_checkout(path: Path) -> bytes:
236+
try:
237+
data = (path / "setup.py").read_bytes()
238+
except FileNotFoundError:
239+
return b""
240+
241+
v = SetupFindingVisitor()
242+
v.visit(ast.parse(data))
243+
244+
if not v.setup_call_args:
245+
return b""
246+
247+
buf = []
248+
if r := v.setup_call_args.get("install_requires"):
249+
if r is UNKNOWN:
250+
raise ValueError("Complex setup call can't extract reqs")
251+
for dep in r:
252+
buf.append(f"Requires-Dist: {dep}\n")
253+
if er := v.setup_call_args.get("extras_require"):
254+
if er is UNKNOWN:
255+
raise ValueError("Complex setup call can't extract extras")
256+
for k, deps in er.items():
257+
extra_name = canonicalize_name(k)
258+
buf.append(f"Provides-Extra: {extra_name}\n")
259+
for i in deps:
260+
buf.append("Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n")
261+
262+
return "".join(buf).encode("utf-8")
263+
264+
230265
def basic_metadata_from_source_checkout(path: Path) -> BasicMetadata:
231266
return BasicMetadata.from_metadata(from_source_checkout(path))
232267

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""
2+
Reads static values from setup.py when they are simple enough.
3+
4+
With the goal of just getting dependencies, and returning a clear error if we don't understand, this has a simpler
5+
6+
This only reads ~50% of current setup.py, vs dowsing which is more like 80%.
7+
8+
I experimented with a more complex version of this in
9+
[dowsing](https://github.com/python-packaging/dowsing/) with a goal of 100%
10+
coverage of open source
11+
"""
12+
13+
import ast
14+
15+
16+
# Copied from orig-index
17+
class ShortCircuitingVisitor(ast.NodeVisitor):
18+
"""
19+
This visitor behaves more like libcst.CSTVisitor in that a visit_ method
20+
can return true or false to specify whether children get visited, and the
21+
visiting of children is not the responsibility of the visit_ method.
22+
"""
23+
24+
def visit(self, node):
25+
method = "visit_" + node.__class__.__name__
26+
visitor = getattr(self, method, self.generic_visit)
27+
rv = visitor(node)
28+
if rv:
29+
self.visit_children(node)
30+
31+
def visit_children(self, node):
32+
for field, value in ast.iter_fields(node):
33+
if isinstance(value, list):
34+
for item in value:
35+
if isinstance(item, ast.AST):
36+
self.visit(item)
37+
elif isinstance(value, ast.AST):
38+
self.visit(value)
39+
40+
def generic_visit(self, node) -> bool:
41+
return True
42+
43+
44+
class QualifiedNameSaver(ShortCircuitingVisitor):
45+
"""Similar to LibCST's QualifiedNameProvider except simpler and wronger"""
46+
47+
def __init__(self):
48+
super().__init__()
49+
self.qualified_name_prefixes = {}
50+
51+
def qualified_name(self, node: ast.AST) -> str:
52+
if isinstance(node, ast.Attribute):
53+
return self.qualified_name(node.value) + "." + node.attr
54+
elif isinstance(node, ast.Expr):
55+
return self.qualified_name(node.value)
56+
elif isinstance(node, ast.Name):
57+
if new := self.qualified_name_prefixes.get(node.id):
58+
return new
59+
return f"<locals>.{node.id}"
60+
else:
61+
raise ValueError(f"Complex expression: {type(node)}")
62+
63+
def visit_Import(self, node: ast.Import):
64+
# .names
65+
# alias = (identifier name, identifier? asname)
66+
for a in node.names:
67+
self.qualified_name_prefixes[a.asname or a.name] = a.name
68+
69+
def visit_ImportFrom(self, node: ast.ImportFrom):
70+
# .identifier / .level
71+
# .names
72+
# alias = (identifier name, identifier? asname)
73+
if node.module:
74+
prefix = f"{node.module}."
75+
else:
76+
prefix = "." * node.level
77+
78+
for a in node.names:
79+
self.qualified_name_prefixes[a.asname or a.name] = prefix + a.name
80+
81+
82+
class Unknown:
83+
pass
84+
85+
86+
UNKNOWN = Unknown()
87+
88+
89+
class SetupFindingVisitor(QualifiedNameSaver):
90+
def __init__(self):
91+
super().__init__()
92+
self.setup_call_args = None
93+
self.setup_call_kwargs = None
94+
95+
def visit_Call(self, node):
96+
# .func (expr, can just be name)
97+
# .args
98+
# .keywords
99+
qn = self.qualified_name(node.func)
100+
if qn in ("setuptools.setup", "distutils.setup"):
101+
self.setup_call_args = d = {}
102+
self.setup_call_kwargs = False
103+
# Positional args are rarely used
104+
for k in node.keywords:
105+
if not k.arg:
106+
self.setup_call_kwargs = True
107+
else:
108+
try:
109+
d[k.arg] = ast.literal_eval(k.value)
110+
except ValueError: # malformed node or string...
111+
d[k.arg] = UNKNOWN
112+
113+
114+
if __name__ == "__main__":
115+
import sys
116+
from pathlib import Path
117+
118+
mod = ast.parse(Path(sys.argv[1]).read_bytes())
119+
v = SetupFindingVisitor()
120+
v.visit(mod)
121+
print(v.setup_call_args)

metadata_please/tests/source_checkout.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,40 @@ def test_setuptools_empty(self) -> None:
9595
basic_metadata_from_source_checkout(Path(d)),
9696
)
9797

98+
def test_setuppy_empty(self) -> None:
99+
with tempfile.TemporaryDirectory() as d:
100+
Path(d, "setup.py").write_text("")
101+
self.assertEqual(
102+
BasicMetadata((), frozenset()),
103+
basic_metadata_from_source_checkout(Path(d)),
104+
)
105+
106+
def test_setuppy_trivial(self) -> None:
107+
with tempfile.TemporaryDirectory() as d:
108+
Path(d, "setup.py").write_text("from setuptools import setup; setup()")
109+
self.assertEqual(
110+
BasicMetadata((), frozenset()),
111+
basic_metadata_from_source_checkout(Path(d)),
112+
)
113+
114+
def test_setuppy(self) -> None:
115+
with tempfile.TemporaryDirectory() as d:
116+
Path(d, "setup.py").write_text(
117+
"import setuptools; setuptools.setup(install_requires=['a'], extras_require={'b': ['c']})"
118+
)
119+
self.assertEqual(
120+
BasicMetadata(["a", 'c ; extra == "b"'], frozenset("b")),
121+
basic_metadata_from_source_checkout(Path(d)),
122+
)
123+
124+
def test_setuppy_toocomplex(self) -> None:
125+
with tempfile.TemporaryDirectory() as d:
126+
Path(d, "setup.py").write_text(
127+
"from setuptools import setup; setup(install_requires=blarg)"
128+
)
129+
with self.assertRaises(ValueError):
130+
basic_metadata_from_source_checkout(Path(d))
131+
98132
def test_setuptools_extras(self) -> None:
99133
with tempfile.TemporaryDirectory() as d:
100134
Path(d, "setup.cfg").write_text(

0 commit comments

Comments
 (0)