Skip to content

Commit d395a83

Browse files
thatchamjith
authored andcommitted
Parse additional fields from metadata.
1 parent 8873151 commit d395a83

File tree

9 files changed

+451
-40
lines changed

9 files changed

+451
-40
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,50 @@ things, with as minimal dependencies as possible:
77
1. Support just enough metadata to be able to look up deps.
88
2. Do "the thing that pip does" when deciding what dist-info dir to look at.
99

10+
# Usage
11+
12+
Example snippet to show how to get the metadata from a wheel.
13+
14+
```python
15+
from zipfile import ZipFile
16+
from metadata_please import basic_metadata_from_wheel
17+
18+
zf = ZipFile('somepkg.whl')
19+
print(basic_metadata_from_wheel(zf, "somepkg"))
20+
```
21+
22+
### Output
23+
24+
```
25+
BasicMetadata(
26+
reqs=[
27+
'build',
28+
'setuptools',
29+
'pip',
30+
'imperfect<1',
31+
'tomlkit<1',
32+
'click~=8.0',
33+
'GitPython~=3.1.18',
34+
'metatron==0.60.0',
35+
'pkginfo~=1.9',
36+
'pyyaml~=6.0',
37+
'runez~=5.2',
38+
'pathspec<1',
39+
'virtualenv<20.21',
40+
'tox~=3.28',
41+
'requests~=2.27',
42+
'urllib3~=1.26'
43+
],
44+
provides_extra=frozenset(),
45+
name='pynt',
46+
requires_python='>=3.6',
47+
url='https://stash.corp.netflix.com/projects/NFPY/repos/pynt/browse',
48+
project_urls={}
49+
)
50+
```
51+
52+
The metadata can be extracted from a `wheel`, `sdist` (zip or tarball). Check [`__init__.py`](metadata_please/__init__.py) file for all available functions.
53+
1054
# Version Compat
1155

1256
Usage of this library should work back to 3.7, but development (and mypy

metadata_please/sdist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def basic_metadata_from_zip_sdist(zf: ZipFile) -> BasicMetadata:
3131
requires = [f for f in zf.namelist() if f.endswith("/requires.txt")]
3232
requires.sort(key=len)
3333
if not requires:
34-
return BasicMetadata((), frozenset())
34+
return BasicMetadata((), frozenset(), "-")
3535

3636
data = zf.read(requires[0])
3737
assert data is not None

metadata_please/source_checkout.py

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
1212
Notably, does not read nontrivial setup.py or attempt to emulate anything that can't be read staticly.
1313
"""
14+
1415
import ast
1516
import re
17+
from dataclasses import asdict
1618
from pathlib import Path
1719

1820
try:
@@ -81,6 +83,54 @@ def from_pep621_checkout(path: Path) -> bytes:
8183
for i in v:
8284
buf.append("Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n")
8385

86+
name = doc.get("project", {}).get("name")
87+
if name:
88+
buf.append(f"Name: {name}\n")
89+
90+
# Version
91+
version = doc.get("project", {}).get("version")
92+
if version:
93+
buf.append(f"Version: {version}\n")
94+
95+
# Requires-Python
96+
requires_python = doc.get("project", {}).get("requires-python")
97+
if requires_python:
98+
buf.append(f"Requires-Python: {requires_python}\n")
99+
100+
# Project-URL
101+
urls = doc.get("project", {}).get("urls")
102+
if urls:
103+
for k, v in urls.items():
104+
buf.append(f"Project-URL: {k}={v}\n")
105+
106+
# Author
107+
authors = doc.get("project", {}).get("authors")
108+
if authors:
109+
for author in authors:
110+
try:
111+
buf.append(f"Author: {author.get('name')}\n")
112+
except AttributeError:
113+
pass
114+
try:
115+
buf.append(f"Author-Email: {author.get('email')}\n")
116+
except AttributeError:
117+
pass
118+
119+
# Summary
120+
summary = doc.get("project", {}).get("description")
121+
if summary:
122+
buf.append(f"Summary: {summary}\n")
123+
124+
# Description
125+
description = doc.get("project", {}).get("readme")
126+
if description:
127+
buf.append(f"Description: {description}\n")
128+
129+
# Keywords
130+
keywords = doc.get("project", {}).get("keywords")
131+
if keywords:
132+
buf.append(f"Keywords: {keywords}\n")
133+
84134
return "".join(buf).encode("utf-8")
85135

86136

@@ -193,6 +243,45 @@ def from_poetry_checkout(path: Path) -> bytes:
193243
f"Requires-Dist: {vi}{constraints}{merge_extra_marker(k, markers)}"
194244
)
195245

246+
name = doc.get("tool", {}).get("poetry", {}).get("name")
247+
if name:
248+
buf.append(f"Name: {name}\n")
249+
250+
# Version
251+
version = doc.get("tool", {}).get("poetry", {}).get("version")
252+
if version:
253+
buf.append(f"Version: {version}\n")
254+
255+
# Requires-Python
256+
requires_python = doc.get("tool", {}).get("poetry", {}).get("requires-python")
257+
if requires_python:
258+
buf.append(f"Requires-Python: {requires_python}\n")
259+
260+
# Project-URL
261+
url = doc.get("tool", {}).get("poetry", {}).get("homepage")
262+
if url:
263+
buf.append(f"Home-Page: {url}\n")
264+
265+
# Author
266+
authors = doc.get("tool", {}).get("poetry", {}).get("authors")
267+
if authors:
268+
buf.append(f"Author: {authors}\n")
269+
270+
# Summary
271+
summary = doc.get("tool", {}).get("poetry", {}).get("description")
272+
if summary:
273+
buf.append(f"Summary: {summary}\n")
274+
275+
# Description
276+
description = doc.get("tool", {}).get("poetry", {}).get("readme")
277+
if description:
278+
buf.append(f"Description: {description}\n")
279+
280+
# Keywords
281+
keywords = doc.get("tool", {}).get("poetry", {}).get("keywords")
282+
if keywords:
283+
buf.append(f"Keywords: {keywords}\n")
284+
196285
return "".join(buf).encode("utf-8")
197286

198287

@@ -206,6 +295,55 @@ def from_setup_cfg_checkout(path: Path) -> bytes:
206295
rc.read_string(data)
207296

208297
buf: list[str] = []
298+
try:
299+
buf.append(f"Name: {rc.get('metadata', 'name')}\n")
300+
except (NoOptionError, NoSectionError):
301+
pass
302+
303+
# Requires-Python
304+
try:
305+
buf.append(f"Requires-Python: {rc.get('options', 'python_requires')}\n")
306+
except (NoOptionError, NoSectionError):
307+
pass
308+
309+
# Home-Page
310+
try:
311+
buf.append(f"Home-Page: {rc.get('metadata', 'url')}\n")
312+
except (NoOptionError, NoSectionError):
313+
pass
314+
315+
# Author
316+
try:
317+
buf.append(f"Author: {rc.get('metadata', 'author')}\n")
318+
except (NoOptionError, NoSectionError):
319+
pass
320+
321+
# Author-Email
322+
try:
323+
buf.append(f"Author-Email: {rc.get('metadata', 'author_email')}\n")
324+
except (NoOptionError, NoSectionError):
325+
pass
326+
327+
# Summary
328+
try:
329+
buf.append(f"Summary: {rc.get('metadata', 'description')}\n")
330+
except (NoOptionError, NoSectionError):
331+
pass
332+
333+
# Description
334+
try:
335+
buf.append(f"Description: {rc.get('metadata', 'long_description')}\n")
336+
except (NoOptionError, NoSectionError):
337+
pass
338+
339+
# Description-Content-Type
340+
try:
341+
buf.append(
342+
f"Description-Content-Type: {rc.get('metadata', 'long_description_content_type')}\n"
343+
)
344+
except (NoOptionError, NoSectionError):
345+
pass
346+
209347
try:
210348
for dep in rc.get("options", "install_requires").splitlines():
211349
dep = dep.strip()
@@ -229,6 +367,8 @@ def from_setup_cfg_checkout(path: Path) -> bytes:
229367
"Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n"
230368
)
231369

370+
# TODO name requires_python url project_urls
371+
232372
return "".join(buf).encode("utf-8")
233373

234374

@@ -252,6 +392,7 @@ def from_setup_py_checkout(path: Path) -> bytes:
252392
raise ValueError("Complex setup call can't extract reqs")
253393
for dep in r:
254394
buf.append(f"Requires-Dist: {dep}\n")
395+
255396
er = v.setup_call_args.get("extras_require")
256397
if er:
257398
if er is UNKNOWN:
@@ -262,6 +403,31 @@ def from_setup_py_checkout(path: Path) -> bytes:
262403
for i in deps:
263404
buf.append("Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n")
264405

406+
n = v.setup_call_args.get("name")
407+
if n:
408+
if n is UNKNOWN:
409+
raise ValueError("Complex setup call can't extract name")
410+
buf.append(f"Name: {n}\n")
411+
412+
n = v.setup_call_args.get("python_requires")
413+
if n:
414+
if n is UNKNOWN:
415+
raise ValueError("Complex setup call can't extract python_requires")
416+
buf.append(f"Requires-Python: {n}\n")
417+
418+
n = v.setup_call_args.get("url")
419+
if n:
420+
if n is UNKNOWN:
421+
raise ValueError("Complex setup call can't extract url")
422+
buf.append(f"Home-Page: {n}\n")
423+
424+
n = v.setup_call_args.get("project_urls")
425+
if n:
426+
if n is UNKNOWN:
427+
raise ValueError("Complex setup call can't extract project_urls")
428+
for k, v in n.items():
429+
buf.append(f"Project-URL: {k}={v}\n")
430+
265431
return "".join(buf).encode("utf-8")
266432

267433

@@ -270,6 +436,11 @@ def basic_metadata_from_source_checkout(path: Path) -> BasicMetadata:
270436

271437

272438
if __name__ == "__main__": # pragma: no cover
439+
import json
273440
import sys
274441

275-
print(basic_metadata_from_source_checkout(Path(sys.argv[1])))
442+
md = basic_metadata_from_source_checkout(Path(sys.argv[1]))
443+
if md.reqs or md.name:
444+
print(json.dumps(asdict(md), default=list))
445+
else:
446+
sys.exit(1)

metadata_please/source_checkout_ast.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313
import ast
14-
from typing import Any, Dict, Optional
14+
from typing import Any, Dict, List, Optional
1515

1616

1717
# Copied from orig-index
@@ -93,12 +93,30 @@ def __init__(self) -> None:
9393
super().__init__()
9494
self.setup_call_args: Optional[Dict[str, Any]] = None
9595
self.setup_call_kwargs: Optional[bool] = None
96+
self.stack: List[ast.AST] = []
97+
98+
def locate_assignment_value(self, body: List[ast.AST], name: ast.Name) -> Any:
99+
for node in body:
100+
if isinstance(node, ast.Assign):
101+
if node.targets == [name]:
102+
return node.value
103+
return UNKNOWN
104+
105+
def visit(self, node: ast.AST) -> Any:
106+
self.stack.append(node)
107+
try:
108+
return super().visit(node)
109+
finally:
110+
self.stack.pop()
96111

97112
def visit_Call(self, node: ast.Call) -> None:
98113
# .func (expr, can just be name)
99114
# .args
100115
# .keywords
101-
qn = self.qualified_name(node.func)
116+
try:
117+
qn = self.qualified_name(node.func)
118+
except ValueError:
119+
return
102120
if qn in ("setuptools.setup", "distutils.setup"):
103121
self.setup_call_args = d = {}
104122
self.setup_call_kwargs = False
@@ -108,7 +126,18 @@ def visit_Call(self, node: ast.Call) -> None:
108126
self.setup_call_kwargs = True
109127
else:
110128
try:
111-
d[k.arg] = ast.literal_eval(k.value)
129+
if isinstance(k.value, ast.Name):
130+
print(self.stack)
131+
for p in self.stack[::-1]:
132+
if hasattr(p, "body"):
133+
v = self.locate_assignment_value(p.body, k.value)
134+
if v is not UNKNOWN:
135+
d[k.arg] = ast.literal_eval(v)
136+
break
137+
else:
138+
raise ValueError("XXX")
139+
else:
140+
d[k.arg] = ast.literal_eval(k.value)
112141
except ValueError: # malformed node or string...
113142
d[k.arg] = UNKNOWN
114143

metadata_please/tests/_zip.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
from __future__ import annotations
22

3-
from typing import Sequence
3+
from typing import Mapping, Sequence
44

55

66
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
7+
def __init__(self, mock_files: Mapping[str, bytes] = {}) -> None:
8+
self.mock_files = mock_files
109
self.files_read: list[str] = []
1110

1211
def namelist(self) -> Sequence[str]:
13-
return self.names[:]
12+
return list(self.mock_files.keys())
1413

1514
def read(self, filename: str) -> bytes:
1615
self.files_read.append(filename)
17-
return self.read_value
16+
return self.mock_files[filename]

0 commit comments

Comments
 (0)