Skip to content

Commit 91b440c

Browse files
committed
Initial version
This the result of honesty and hdeps testing this code fairly extensively, but now as a separate project with actual tests.
1 parent d6665a9 commit 91b440c

File tree

11 files changed

+392
-4
lines changed

11 files changed

+392
-4
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
# metadata_please
1+
# metadata\_please
22

3+
There are a couple of pretty decent ways to read metadata (`importlib-metadata`,
4+
and `pkginfo`) but they tend to be pretty heavyweight. This lib aims to do two
5+
things, with as minimal dependencies as possible:
6+
7+
1. Support just enough metadata to be able to look up deps.
8+
2. Do "the thing that pip does" when deciding what dist-info dir to look at.
39

410
# Version Compat
511

@@ -8,7 +14,7 @@ compatibility) only on 3.10-3.12. Linting requires 3.12 for full fidelity.
814

915
# License
1016

11-
metadata_please is copyright [Tim Hatch](https://timhatch.com/), and licensed under
17+
metadata\_please is copyright [Tim Hatch](https://timhatch.com/), and licensed under
1218
the MIT license. I am providing code in this repository to you under an open
1319
source license. This is my personal repository; the license you receive to
1420
my code is from me and not from my employer. See the `LICENSE` file for details.

metadata_please/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from .sdist import (
2+
basic_metadata_from_tar_sdist,
3+
basic_metadata_from_zip_sdist,
4+
from_tar_sdist,
5+
from_zip_sdist,
6+
)
7+
from .wheel import basic_metadata_from_wheel, from_wheel
8+
9+
__all__ = [
10+
"basic_metadata_from_tar_sdist",
11+
"basic_metadata_from_zip_sdist",
12+
"basic_metadata_from_wheel",
13+
"from_zip_sdist",
14+
"from_tar_sdist",
15+
"from_wheel",
16+
]

metadata_please/sdist.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from tarfile import TarFile
4+
from zipfile import ZipFile
5+
6+
from .types import BasicMetadata, convert_sdist_requires
7+
8+
9+
def from_zip_sdist(zf: ZipFile) -> bytes:
10+
"""
11+
Returns an emulated dist-info metadata contents from the given ZipFile.
12+
"""
13+
requires = [f for f in zf.namelist() if f.endswith("/requires.txt")]
14+
requires.sort(key=len)
15+
data = zf.read(requires[0])
16+
assert data is not None
17+
requires, extras = convert_sdist_requires(data.decode("utf-8"))
18+
19+
buf: list[str] = []
20+
for req in requires:
21+
buf.append(f"Requires-Dist: {req}\n")
22+
for extra in sorted(extras):
23+
buf.append(f"Provides-Extra: {extra}\n")
24+
return ("".join(buf)).encode("utf-8")
25+
26+
27+
def basic_metadata_from_zip_sdist(zf: ZipFile) -> BasicMetadata:
28+
requires = [f for f in zf.namelist() if f.endswith("/requires.txt")]
29+
requires.sort(key=len)
30+
data = zf.read(requires[0])
31+
assert data is not None
32+
return BasicMetadata.from_sdist_pkg_info_and_requires(b"", data)
33+
34+
35+
def from_tar_sdist(tf: TarFile) -> bytes:
36+
"""
37+
Returns an emulated dist-info metadata contents from the given TarFile.
38+
"""
39+
# XXX Why do ZipFile and TarFile not have a common interface ?!
40+
requires = [f for f in tf.getnames() if f.endswith("/requires.txt")]
41+
requires.sort(key=len)
42+
43+
fo = tf.extractfile(requires[0])
44+
assert fo is not None
45+
46+
requires, extras = convert_sdist_requires(fo.read().decode("utf-8"))
47+
48+
buf: list[str] = []
49+
for req in requires:
50+
buf.append(f"Requires-Dist: {req}\n")
51+
for extra in sorted(extras):
52+
buf.append(f"Provides-Extra: {extra}\n")
53+
return ("".join(buf)).encode("utf-8")
54+
55+
56+
def basic_metadata_from_tar_sdist(tf: TarFile) -> BasicMetadata:
57+
# XXX Why do ZipFile and TarFile not have a common interface ?!
58+
requires = [f for f in tf.getnames() if f.endswith("/requires.txt")]
59+
requires.sort(key=len)
60+
61+
fo = tf.extractfile(requires[0])
62+
assert fo is not None
63+
64+
return BasicMetadata.from_sdist_pkg_info_and_requires(b"", fo.read())

metadata_please/tests/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
# from .foo import FooTest
1+
from .sdist import TarSdistTest, ZipSdistTest
2+
from .wheel import WheelTest
23

34
__all__ = [
4-
# "FooTest",
5+
"WheelTest",
6+
"ZipSdistTest",
7+
"TarSdistTest",
58
]

metadata_please/tests/_tar.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from io import BytesIO
4+
from typing import Sequence
5+
6+
7+
class MemoryTarFile:
8+
def __init__(self, names: Sequence[str], read_value: bytes = b"foo") -> None:
9+
self.names = names
10+
self.read_value = read_value
11+
self.files_read: list[str] = []
12+
13+
def getnames(self) -> Sequence[str]:
14+
return self.names[:]
15+
16+
def extractfile(self, filename: str) -> BytesIO:
17+
return BytesIO(self.read_value)

metadata_please/tests/_zip.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from typing import Sequence
4+
5+
6+
class MemoryZipFile:
7+
def __init__(self, names: Sequence[str], read_value: bytes = b"foo") -> None:
8+
self.names = names
9+
self.read_value = read_value
10+
self.files_read: list[str] = []
11+
12+
def namelist(self) -> Sequence[str]:
13+
return self.names[:]
14+
15+
def read(self, filename: str) -> bytes:
16+
self.files_read.append(filename)
17+
return self.read_value

metadata_please/tests/sdist.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import unittest
2+
3+
from ..sdist import (
4+
basic_metadata_from_tar_sdist,
5+
basic_metadata_from_zip_sdist,
6+
from_tar_sdist,
7+
from_zip_sdist,
8+
)
9+
from ._tar import MemoryTarFile
10+
from ._zip import MemoryZipFile
11+
12+
13+
class ZipSdistTest(unittest.TestCase):
14+
def test_requires_as_expected(self) -> None:
15+
z = MemoryZipFile(
16+
["foo.egg-info/requires.txt", "foo/__init__.py"],
17+
read_value=b"""\
18+
a
19+
[e]
20+
b
21+
""",
22+
)
23+
metadata = from_zip_sdist(z) # type: ignore
24+
self.assertEqual(
25+
b"""\
26+
Requires-Dist: a
27+
Requires-Dist: b; extra == 'e'
28+
Provides-Extra: e
29+
""",
30+
metadata,
31+
)
32+
33+
def test_basic_metadata(self) -> None:
34+
z = MemoryZipFile(
35+
["foo.egg-info/requires.txt", "foo/__init__.py"],
36+
read_value=b"""\
37+
a
38+
[e]
39+
b
40+
""",
41+
)
42+
bm = basic_metadata_from_zip_sdist(z) # type: ignore
43+
self.assertEqual(
44+
["a", "b; extra == 'e'"],
45+
bm.reqs,
46+
)
47+
self.assertEqual({"e"}, bm.provides_extra)
48+
49+
def test_basic_metadata_absl_py_09(self) -> None:
50+
z = MemoryZipFile(
51+
["foo.egg-info/requires.txt", "foo/__init__.py"],
52+
read_value=b"""\
53+
six
54+
55+
[:python_version < "3.4"]
56+
enum34
57+
[test:python_version < "3.4"]
58+
pytest
59+
""",
60+
)
61+
bm = basic_metadata_from_zip_sdist(z) # type: ignore
62+
self.assertEqual(
63+
[
64+
"six",
65+
'enum34; python_version < "3.4"',
66+
# Quoting on the following line is an implementation detail
67+
"pytest; (python_version < \"3.4\") and extra == 'test'",
68+
],
69+
bm.reqs,
70+
)
71+
self.assertEqual({"test"}, bm.provides_extra)
72+
73+
74+
class TarSdistTest(unittest.TestCase):
75+
def test_requires_as_expected(self) -> None:
76+
t = MemoryTarFile(
77+
["foo.egg-info/requires.txt", "foo/__init__.py"],
78+
read_value=b"""\
79+
a
80+
[e]
81+
b
82+
""",
83+
)
84+
metadata = from_tar_sdist(t) # type: ignore
85+
self.assertEqual(
86+
b"""\
87+
Requires-Dist: a
88+
Requires-Dist: b; extra == 'e'
89+
Provides-Extra: e
90+
""",
91+
metadata,
92+
)
93+
94+
def test_basic_metadata(self) -> None:
95+
t = MemoryTarFile(
96+
["foo.egg-info/requires.txt", "foo/__init__.py"],
97+
read_value=b"""\
98+
a
99+
[e]
100+
b
101+
""",
102+
)
103+
bm = basic_metadata_from_tar_sdist(t) # type: ignore
104+
self.assertEqual(
105+
["a", "b; extra == 'e'"],
106+
bm.reqs,
107+
)
108+
self.assertEqual({"e"}, bm.provides_extra)

metadata_please/tests/wheel.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import unittest
2+
3+
from ..wheel import basic_metadata_from_wheel, from_wheel, InvalidWheel
4+
from ._zip import MemoryZipFile
5+
6+
7+
class WheelTest(unittest.TestCase):
8+
def test_well_behaved(self) -> None:
9+
z = MemoryZipFile(["foo.dist-info/METADATA", "foo/__init__.py"])
10+
self.assertEqual(b"foo", from_wheel(z, "foo")) # type: ignore
11+
self.assertEqual(["foo.dist-info/METADATA"], z.files_read)
12+
13+
def test_actually_empty(self) -> None:
14+
z = MemoryZipFile([])
15+
with self.assertRaisesRegex(InvalidWheel, "Zero .dist-info dirs in wheel"):
16+
from_wheel(z, "foo") # type: ignore
17+
18+
def test_no_dist_info(self) -> None:
19+
z = MemoryZipFile(["foo/__init__.py"])
20+
with self.assertRaisesRegex(InvalidWheel, "Zero .dist-info dirs in wheel"):
21+
from_wheel(z, "foo") # type: ignore
22+
23+
def test_too_many_dist_info(self) -> None:
24+
z = MemoryZipFile(["foo.dist-info/METADATA", "bar.dist-info/METADATA"])
25+
with self.assertRaisesRegex(
26+
InvalidWheel,
27+
r"2 .dist-info dirs where there should be just one: \['bar.dist-info', 'foo.dist-info'\]",
28+
):
29+
from_wheel(z, "foo") # type: ignore
30+
31+
def test_bad_project_name(self) -> None:
32+
z = MemoryZipFile(["foo.dist-info/METADATA", "foo/__init__.py"])
33+
with self.assertRaisesRegex(InvalidWheel, "Mismatched foo.dist-info for bar"):
34+
from_wheel(z, "bar") # type: ignore
35+
36+
def test_basic_metadata(self) -> None:
37+
z = MemoryZipFile(
38+
["foo.dist-info/METADATA", "foo/__init__.py"],
39+
read_value=b"Requires-Dist: foo\n",
40+
)
41+
bm = basic_metadata_from_wheel(z, "foo") # type: ignore
42+
self.assertEqual(["foo"], bm.reqs)

metadata_please/types.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from email import message_from_string
6+
from typing import Sequence
7+
8+
9+
@dataclass(frozen=True)
10+
class BasicMetadata:
11+
# Popualted from Requires-Dist or requires.txt
12+
reqs: Sequence[str]
13+
# Populated from Provides-Extra
14+
provides_extra: set[str]
15+
16+
@classmethod
17+
def from_metadata(cls, metadata: bytes) -> BasicMetadata:
18+
msg = message_from_string(metadata.decode("utf-8"))
19+
return BasicMetadata(
20+
msg.get_all("Requires-Dist") or (),
21+
set(msg.get_all("Provides-Extra") or ()),
22+
)
23+
24+
@classmethod
25+
def from_sdist_pkg_info_and_requires(
26+
cls, pkg_info: bytes, requires: bytes
27+
) -> BasicMetadata:
28+
# We can either get Provides-Extra from this, or from the section
29+
# headers in requires.txt...
30+
# msg = message_from_string(pkg_info.decode("utf-8"))
31+
return cls(
32+
*convert_sdist_requires(requires.decode("utf-8")),
33+
)
34+
35+
36+
def convert_sdist_requires(data: str) -> tuple[list[str], set[str]]:
37+
# This is reverse engineered from looking at a couple examples, but there
38+
# does not appear to be a formal spec. Mentioned at
39+
# https://setuptools.readthedocs.io/en/latest/formats.html#requires-txt
40+
# This snippet has existed in `honesty` for a couple of years now.
41+
current_markers = None
42+
extras: set[str] = set()
43+
lst: list[str] = []
44+
for line in data.splitlines():
45+
line = line.strip()
46+
if not line:
47+
continue
48+
elif line[:1] == "[" and line[-1:] == "]":
49+
current_markers = line[1:-1]
50+
if ":" in current_markers:
51+
# absl-py==0.9.0 and requests==2.22.0 are good examples of this
52+
extra, markers = current_markers.split(":", 1)
53+
if extra:
54+
extras.add(extra)
55+
current_markers = f"({markers}) and extra == {extra!r}"
56+
else:
57+
current_markers = markers
58+
else:
59+
# this is an extras_require
60+
extras.add(current_markers)
61+
current_markers = f"extra == {current_markers!r}"
62+
else:
63+
if current_markers:
64+
lst.append(f"{line}; {current_markers}")
65+
else:
66+
lst.append(line)
67+
return lst, extras

0 commit comments

Comments
 (0)