Skip to content

Commit 59e16af

Browse files
authored
Merge pull request #3 from python-packaging/pep621-metadata
Support PEP 621 dependencies (pyproject.toml)
2 parents 56e4dba + c096436 commit 59e16af

File tree

8 files changed

+386
-4
lines changed

8 files changed

+386
-4
lines changed

.github/workflows/build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ jobs:
1717
matrix:
1818
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
1919
os: [macOS-latest, ubuntu-latest, windows-latest]
20+
exclude:
21+
# macOS-latest are now on arm64
22+
- os: macOS-latest
23+
python-version: "3.7"
2024

2125
steps:
2226
- name: Checkout

.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: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
import re
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+
OPERATOR_RE = re.compile(r"([<>=~]+)(\d.*)")
28+
29+
30+
def combine_markers(*markers: str) -> str:
31+
filtered_markers = [m for m in markers if m and m.strip()]
32+
if len(filtered_markers) == 0:
33+
return ""
34+
elif len(filtered_markers) == 1:
35+
return filtered_markers[0]
36+
else:
37+
return " and ".join(f"({m})" for m in filtered_markers)
38+
39+
40+
def merge_extra_marker(extra_name: str, value: str) -> str:
41+
"""Simulates what a dist-info requirement string would look like if also restricted to an extra."""
42+
if ";" not in value:
43+
return f'{value} ; extra == "{extra_name}"'
44+
else:
45+
a, _, b = value.partition(";")
46+
a = a.strip()
47+
b = b.strip()
48+
c = f'extra == "{extra_name}"'
49+
return f"{a} ; {combine_markers(b, c)}"
50+
51+
52+
def from_source_checkout(path: Path) -> bytes:
53+
return (
54+
from_pep621_checkout(path)
55+
or from_poetry_checkout(path)
56+
or from_setup_cfg_checkout(path)
57+
)
58+
59+
60+
def from_pep621_checkout(path: Path) -> bytes:
61+
"""
62+
Returns a metadata snippet (which is zero-length if this is none of this style).
63+
"""
64+
try:
65+
data = (path / "pyproject.toml").read_text()
66+
except FileNotFoundError:
67+
return b""
68+
doc = toml.loads(data)
69+
70+
buf: list[str] = []
71+
for dep in doc.get("project", {}).get("dependencies", ()):
72+
buf.append(f"Requires-Dist: {dep}\n")
73+
for k, v in doc.get("project", {}).get("optional-dependencies", {}).items():
74+
extra_name = canonicalize_name(k)
75+
buf.append(f"Provides-Extra: {extra_name}\n")
76+
for i in v:
77+
buf.append("Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n")
78+
79+
return "".join(buf).encode("utf-8")
80+
81+
82+
def _translate_caret(specifier: str) -> str:
83+
"""
84+
Given a string like "^0.2.3" returns ">=0.2.3,<0.3.0".
85+
"""
86+
assert "," not in specifier
87+
parts = specifier[1:].split(".")
88+
while len(parts) < 3:
89+
parts.append("0")
90+
91+
for i in range(len(parts)):
92+
if parts[i] != "0":
93+
# The docs are not super clear about how this behaves, but let's
94+
# assume integer-valued parts and just let the exception raise
95+
# otherwise.
96+
incremented = parts[:]
97+
incremented[i] = str(int(parts[i]) + 1)
98+
del incremented[i + 1 :]
99+
incremented_version = ".".join(incremented)
100+
break
101+
else:
102+
raise ValueError("All components were zero?")
103+
return f">={specifier[1:]},<{incremented_version}"
104+
105+
106+
def _translate_tilde(specifier: str) -> str:
107+
"""
108+
Given a string like "~1.2.3" returns ">=1.2.3,<1.3".
109+
"""
110+
assert "," not in specifier
111+
parts = specifier[1:].split(".")
112+
incremented = parts[:2]
113+
incremented[-1] = str(int(incremented[-1]) + 1)
114+
incremented_version = ".".join(incremented)
115+
116+
return f">={specifier[1:]},<{incremented_version}"
117+
118+
119+
def from_poetry_checkout(path: Path) -> bytes:
120+
"""
121+
Returns a metadata snippet (which is zero-length if this is none of this style).
122+
"""
123+
try:
124+
data = (path / "pyproject.toml").read_text()
125+
except FileNotFoundError:
126+
return b""
127+
doc = toml.loads(data)
128+
129+
saved_extra_constraints = {}
130+
131+
buf: list[str] = []
132+
for k, v in doc.get("tool", {}).get("poetry", {}).get("dependencies", {}).items():
133+
if k == "python":
134+
pass # TODO requires-python
135+
else:
136+
k = canonicalize_name(k)
137+
if isinstance(v, dict):
138+
version = v.get("version", "")
139+
if "extras" in v:
140+
extras = "[%s]" % (",".join(v["extras"]))
141+
else:
142+
extras = ""
143+
markers = v.get("markers", "")
144+
python = v.get("python", "")
145+
if python:
146+
m = OPERATOR_RE.fullmatch(python)
147+
assert m is not None
148+
# TODO do ^/~ work on python version?
149+
python = f"python_version {m.group(1)} '{m.group(2)}'"
150+
markers = combine_markers(markers, python)
151+
if markers:
152+
markers = " ; " + markers
153+
optional = v.get("optional", False)
154+
else:
155+
version = v
156+
extras = ""
157+
markers = ""
158+
optional = False
159+
160+
if not version:
161+
# e.g. git, path or url dependencies, skip for now
162+
continue
163+
164+
# https://python-poetry.org/docs/dependency-specification/#version-constraints
165+
# 1.2.* type wildcards are supported natively in packaging
166+
if version.startswith("^"):
167+
version = _translate_caret(version)
168+
elif version.startswith("~"):
169+
version = _translate_tilde(version)
170+
elif version == "*":
171+
version = ""
172+
173+
if version[:1].isdigit():
174+
version = "==" + version
175+
176+
if optional:
177+
saved_extra_constraints[k] = (f"{extras}{version}", markers)
178+
else:
179+
buf.append(f"Requires-Dist: {k}{extras}{version}{markers}\n")
180+
181+
for k, v in doc.get("tool", {}).get("poetry", {}).get("extras", {}).items():
182+
k = canonicalize_name(k)
183+
buf.append(f"Provides-Extra: {k}\n")
184+
for vi in v:
185+
vi = canonicalize_name(vi)
186+
constraints, markers = saved_extra_constraints[vi]
187+
buf.append(
188+
f"Requires-Dist: {vi}{constraints}{merge_extra_marker(k, markers)}"
189+
)
190+
191+
return "".join(buf).encode("utf-8")
192+
193+
194+
def from_setup_cfg_checkout(path: Path) -> bytes:
195+
try:
196+
data = (path / "setup.cfg").read_text()
197+
except FileNotFoundError:
198+
return b""
199+
200+
rc = RawConfigParser()
201+
rc.read_string(data)
202+
203+
buf: list[str] = []
204+
try:
205+
for dep in rc.get("options", "install_requires").splitlines():
206+
dep = dep.strip()
207+
if dep:
208+
buf.append(f"Requires-Dist: {dep}\n")
209+
except (NoOptionError, NoSectionError):
210+
pass
211+
212+
try:
213+
section = rc["options.extras_require"]
214+
except KeyError:
215+
pass
216+
else:
217+
for k, v in section.items():
218+
extra_name = canonicalize_name(k)
219+
buf.append(f"Provides-Extra: {extra_name}\n")
220+
for i in v.splitlines():
221+
i = i.strip()
222+
if i:
223+
buf.append(
224+
"Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n"
225+
)
226+
227+
return "".join(buf).encode("utf-8")
228+
229+
230+
def basic_metadata_from_source_checkout(path: Path) -> BasicMetadata:
231+
return BasicMetadata.from_metadata(from_source_checkout(path))
232+
233+
234+
if __name__ == "__main__": # pragma: no cover
235+
import sys
236+
237+
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
]

0 commit comments

Comments
 (0)