Skip to content

Commit 271a5a6

Browse files
committed
Support reading static metadata from a source tree.
Currently this handles pep621, poetry, and setuptools in setup.cfg in a best-effort sort of way. Callers of this should check for nonempty deps to know whether it found anything. Because of the fragility with intreehooks this does not even look at the build-backend.
1 parent d3f3a88 commit 271a5a6

File tree

7 files changed

+338
-4
lines changed

7 files changed

+338
-4
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,6 @@ venv.bak/
105105

106106
# Visual Studio Code
107107
.vscode/
108+
109+
# Vim swapfiles
110+
*.sw[op]

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ format:
2727
lint:
2828
python -m ufmt check $(SOURCES)
2929
python -m flake8 $(SOURCES)
30-
python -m checkdeps --allow-names metadata_please metadata_please
30+
python -m checkdeps --allow-names metadata_please,toml metadata_please
3131
mypy --strict --install-types --non-interactive metadata_please
3232

3333
.PHONY: release

metadata_please/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
from_tar_sdist,
55
from_zip_sdist,
66
)
7+
from .source_checkout import basic_metadata_from_source_checkout, from_source_checkout
78
from .wheel import basic_metadata_from_wheel, from_wheel
89

910
__all__ = [
11+
"basic_metadata_from_source_checkout",
1012
"basic_metadata_from_tar_sdist",
11-
"basic_metadata_from_zip_sdist",
1213
"basic_metadata_from_wheel",
13-
"from_zip_sdist",
14+
"basic_metadata_from_zip_sdist",
15+
"from_source_checkout",
1416
"from_tar_sdist",
1517
"from_wheel",
18+
"from_zip_sdist",
1619
]

metadata_please/source_checkout.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""
2+
Best-effort metadata extraction for "source checkouts" -- e.g. a local dir containing pyproject.toml.
3+
4+
This is different from an (extracted) sdist, which *should* have a generated dist-info already.
5+
6+
Prefers:
7+
- PEP 621 metadata (pyproject.toml)
8+
- Poetry metadata (pyproject.toml)
9+
- Setuptools static metadata (setup.cfg)
10+
11+
Notably, does not read setup.py or attempt to emulate anything that can't be read staticly.
12+
"""
13+
14+
from pathlib import Path
15+
16+
try:
17+
import tomllib as toml
18+
except ImportError:
19+
import toml # type: ignore[no-redef,unused-ignore]
20+
21+
from configparser import NoOptionError, NoSectionError, RawConfigParser
22+
23+
from packaging.utils import canonicalize_name
24+
25+
from .types import BasicMetadata
26+
27+
28+
def merge_markers(extra_name: str, value: str) -> str:
29+
"""Simulates what a dist-info requirement string would look like if also restricted to an extra."""
30+
if ";" not in value:
31+
return f'{value} ; extra == "{extra_name}"'
32+
else:
33+
a, _, b = value.partition(";")
34+
a = a.strip()
35+
b = b.strip()
36+
return f'{a} ; ({b}) and extra == "{extra_name}"'
37+
38+
39+
def from_source_checkout(path: Path) -> bytes:
40+
return (
41+
from_pep621_checkout(path)
42+
or from_poetry_checkout(path)
43+
or from_setup_cfg_checkout(path)
44+
)
45+
46+
47+
def from_pep621_checkout(path: Path) -> bytes:
48+
"""
49+
Returns a metadata snippet (which is zero-length if this is none of this style).
50+
"""
51+
try:
52+
data = (path / "pyproject.toml").read_text()
53+
except FileNotFoundError:
54+
return b""
55+
doc = toml.loads(data)
56+
57+
buf: list[str] = []
58+
for dep in doc.get("project", {}).get("dependencies", ()):
59+
buf.append(f"Requires-Dist: {dep}\n")
60+
for k, v in doc.get("project", {}).get("optional-dependencies", {}).items():
61+
extra_name = canonicalize_name(k)
62+
buf.append(f"Provides-Extra: {extra_name}\n")
63+
for i in v:
64+
buf.append("Requires-Dist: " + merge_markers(extra_name, i) + "\n")
65+
66+
return "".join(buf).encode("utf-8")
67+
68+
69+
def _translate_caret(specifier: str) -> str:
70+
"""
71+
Given a string like "^0.2.3" returns ">=0.2.3,<0.3.0".
72+
"""
73+
assert "," not in specifier
74+
parts = specifier[1:].split(".")
75+
while len(parts) < 3:
76+
parts.append("0")
77+
78+
for i in range(len(parts)):
79+
if parts[i] != "0":
80+
# The docs are not super clear about how this behaves, but let's
81+
# assume integer-valued parts and just let the exception raise
82+
# otherwise.
83+
incremented = parts[:]
84+
incremented[i] = str(int(parts[i]) + 1)
85+
del incremented[i + 1 :]
86+
incremented_version = ".".join(incremented)
87+
break
88+
else:
89+
raise ValueError("All components were zero?")
90+
return f">={specifier[1:]},<{incremented_version}"
91+
92+
93+
def _translate_tilde(specifier: str) -> str:
94+
"""
95+
Given a string like "~1.2.3" returns ">=1.2.3,<1.3".
96+
"""
97+
assert "," not in specifier
98+
parts = specifier[1:].split(".")
99+
incremented = parts[:2]
100+
incremented[-1] = str(int(incremented[-1]) + 1)
101+
incremented_version = ".".join(incremented)
102+
103+
return f">={specifier[1:]},<{incremented_version}"
104+
105+
106+
def from_poetry_checkout(path: Path) -> bytes:
107+
"""
108+
Returns a metadata snippet (which is zero-length if this is none of this style).
109+
"""
110+
try:
111+
data = (path / "pyproject.toml").read_text()
112+
except FileNotFoundError:
113+
return b""
114+
doc = toml.loads(data)
115+
116+
saved_extra_constraints = {}
117+
118+
buf: list[str] = []
119+
for k, v in doc.get("tool", {}).get("poetry", {}).get("dependencies", {}).items():
120+
if k == "python":
121+
pass # TODO requires-python
122+
else:
123+
k = canonicalize_name(k)
124+
if isinstance(v, dict):
125+
version = v.get("version", "")
126+
if "extras" in v:
127+
extras = "[%s]" % (",".join(v["extras"]))
128+
else:
129+
extras = ""
130+
optional = v.get("optional", False)
131+
else:
132+
version = v
133+
extras = ""
134+
optional = False
135+
136+
if not version:
137+
# e.g. git, path or url dependencies, skip for now
138+
continue
139+
140+
# https://python-poetry.org/docs/dependency-specification/#version-constraints
141+
# 1.2.* type wildcards are supported natively in packaging
142+
if version.startswith("^"):
143+
version = _translate_caret(version)
144+
elif version.startswith("~"):
145+
version = _translate_tilde(version)
146+
elif version == "*":
147+
version = ""
148+
149+
if version[:1].isdigit():
150+
version = "==" + version
151+
152+
if optional:
153+
saved_extra_constraints[k] = f"{extras}{version}"
154+
else:
155+
buf.append(f"Requires-Dist: {k}{extras}{version}\n")
156+
157+
for k, v in doc.get("tool", {}).get("poetry", {}).get("extras", {}).items():
158+
k = canonicalize_name(k)
159+
buf.append(f"Provides-Extra: {k}\n")
160+
for vi in v:
161+
vi = canonicalize_name(vi)
162+
buf.append(
163+
f"Requires-Dist: {vi}{merge_markers(k, saved_extra_constraints[vi])}"
164+
)
165+
166+
return "".join(buf).encode("utf-8")
167+
168+
169+
def from_setup_cfg_checkout(path: Path) -> bytes:
170+
try:
171+
data = (path / "setup.cfg").read_text()
172+
except FileNotFoundError:
173+
return b""
174+
175+
rc = RawConfigParser()
176+
rc.read_string(data)
177+
178+
buf: list[str] = []
179+
try:
180+
for dep in rc.get("options", "install_requires").splitlines():
181+
dep = dep.strip()
182+
if dep:
183+
buf.append(f"Requires-Dist: {dep}\n")
184+
except (NoOptionError, NoSectionError):
185+
pass
186+
187+
try:
188+
section = rc["options.extras_require"]
189+
except KeyError:
190+
pass
191+
else:
192+
for k, v in section.items():
193+
extra_name = canonicalize_name(k)
194+
buf.append(f"Provides-Extra: {extra_name}\n")
195+
for i in v.splitlines():
196+
i = i.strip()
197+
if i:
198+
buf.append("Requires-Dist: " + merge_markers(extra_name, i) + "\n")
199+
200+
return "".join(buf).encode("utf-8")
201+
202+
203+
def basic_metadata_from_source_checkout(path: Path) -> BasicMetadata:
204+
return BasicMetadata.from_metadata(from_source_checkout(path))
205+
206+
207+
if __name__ == "__main__": # pragma: no cover
208+
import sys
209+
210+
print(basic_metadata_from_source_checkout(Path(sys.argv[1])))

metadata_please/tests/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from .sdist import TarSdistTest, ZipSdistTest
2+
from .source_checkout import SourceCheckoutTest
23
from .wheel import WheelTest
34

45
__all__ = [
6+
"SourceCheckoutTest",
7+
"TarSdistTest",
58
"WheelTest",
69
"ZipSdistTest",
7-
"TarSdistTest",
810
]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import tempfile
2+
3+
import unittest
4+
from pathlib import Path
5+
6+
from ..source_checkout import basic_metadata_from_source_checkout
7+
from ..types import BasicMetadata
8+
9+
10+
class SourceCheckoutTest(unittest.TestCase):
11+
def test_pep621_empty(self) -> None:
12+
with tempfile.TemporaryDirectory() as d:
13+
Path(d, "pyproject.toml").write_text("")
14+
self.assertEqual(
15+
BasicMetadata((), frozenset()),
16+
basic_metadata_from_source_checkout(Path(d)),
17+
)
18+
19+
def test_pep621_extras(self) -> None:
20+
with tempfile.TemporaryDirectory() as d:
21+
Path(d, "pyproject.toml").write_text(
22+
"""\
23+
[project]
24+
dependencies = ["x"]
25+
26+
[project.optional-dependencies]
27+
dev = ["Foo <= 2"]
28+
"""
29+
)
30+
self.assertEqual(
31+
BasicMetadata(["x", 'Foo <= 2 ; extra == "dev"'], frozenset(["dev"])),
32+
basic_metadata_from_source_checkout(Path(d)),
33+
)
34+
35+
def test_poetry_full(self) -> None:
36+
with tempfile.TemporaryDirectory() as d:
37+
Path(d, "pyproject.toml").write_text(
38+
"""\
39+
[tool.poetry.dependencies]
40+
python = "^3.6"
41+
a = "1.0"
42+
a2 = "*"
43+
b = "^1.2.3"
44+
b2 = "^0.2.3"
45+
c = "~1.2.3"
46+
c2 = "~1.2"
47+
c3 = "~1"
48+
skipped = {git = "..."}
49+
complex = {extras=["bar", "baz"], version="2"}
50+
opt = { version = "^2.9", optional = true}
51+
unused-extra = { version = "2", optional = true }
52+
53+
[tool.poetry.extras]
54+
Foo = ["Opt"] # intentionally uppercased
55+
"""
56+
)
57+
rv = basic_metadata_from_source_checkout(Path(d))
58+
self.assertEqual(
59+
[
60+
"a==1.0",
61+
"a2",
62+
"b>=1.2.3,<2",
63+
"b2>=0.2.3,<0.3",
64+
"c>=1.2.3,<1.3",
65+
"c2>=1.2,<1.3",
66+
"c3>=1,<2",
67+
"complex[bar,baz]==2",
68+
'opt>=2.9,<3 ; extra == "foo"',
69+
],
70+
rv.reqs,
71+
)
72+
self.assertEqual(
73+
frozenset({"foo"}),
74+
rv.provides_extra,
75+
)
76+
77+
def test_setuptools_empty(self) -> None:
78+
with tempfile.TemporaryDirectory() as d:
79+
Path(d, "setup.cfg").write_text("")
80+
self.assertEqual(
81+
BasicMetadata((), frozenset()),
82+
basic_metadata_from_source_checkout(Path(d)),
83+
)
84+
85+
def test_setuptools_extras(self) -> None:
86+
with tempfile.TemporaryDirectory() as d:
87+
Path(d, "setup.cfg").write_text(
88+
"""\
89+
[options]
90+
install_requires =
91+
x
92+
y
93+
94+
[options.extras_require]
95+
dev =
96+
# comment
97+
Foo <= 2
98+
# comment after
99+
marker =
100+
Bar ; python_version < "3"
101+
"""
102+
)
103+
self.assertEqual(
104+
BasicMetadata(
105+
[
106+
"x",
107+
"y",
108+
'Foo <= 2 ; extra == "dev"',
109+
'Bar ; (python_version < "3") and extra == "marker"',
110+
],
111+
frozenset(["dev", "marker"]),
112+
),
113+
basic_metadata_from_source_checkout(Path(d)),
114+
)

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ setup_requires =
1818
include_package_data = true
1919
install_requires =
2020
packaging
21+
configparser
22+
toml; python_version < '3.11'
2123

2224
[options.extras_require]
2325
dev =

0 commit comments

Comments
 (0)