Skip to content

Commit 5815c7a

Browse files
authored
Merge pull request #5 from python-packaging/homepage-project-urls
Homepage project urls
2 parents 4097d58 + ff57b75 commit 5815c7a

File tree

9 files changed

+464
-40
lines changed

9 files changed

+464
-40
lines changed

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,65 @@ 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+
'cli-helpers[styles] >=2.2.1',
28+
'click >=4.1',
29+
'configobj >=5.0.5',
30+
'prompt-toolkit <4.0.0,>=3.0.3',
31+
'pygments >=1.6',
32+
'sqlparse >=0.4.4',
33+
"behave >=1.2.6 ; extra == 'dev'",
34+
"coverage >=7.2.7 ; extra == 'dev'",
35+
"pexpect >=4.9.0 ; extra == 'dev'",
36+
"pytest >=7.4.4 ; extra == 'dev'",
37+
"pytest-cov >=4.1.0 ; extra == 'dev'",
38+
"tox >=4.8.0 ; extra == 'dev'",
39+
"pdbpp >=0.10.3 ; extra == 'dev'"
40+
],
41+
provides_extra=frozenset({'dev'}),
42+
name='litecli',
43+
version='1.12.4',
44+
requires_python='>=3.7',
45+
url=None,
46+
project_urls={'homepage, https://github.com/dbcli/litecli': ''},
47+
author=None,
48+
author_email='dbcli <[email protected]>',
49+
summary='CLI for SQLite Databases with auto-completion and syntax highlighting.',
50+
description='# litecli\n\n[![GitHub
51+
Actions](https://github.com/dbcli/litecli/actions/workflows/ci.yml/badge.svg)](https://github.com/dbcli/litecli/actions/workflows/ci.yml "GitHub
52+
Actions")\n\n[Docs](https://litecli.com)\n\nA command-line client for SQLite databases that has auto-completion and syntax
53+
highlighting.\n\n![Completion](screenshots/litecli.png)\n![CompletionGif](screenshots/litecli.gif)\n\n## Installation\n\nIf you already know how to install python
54+
packages, then you can install it via pip:\n\nYou might need sudo on linux.\n\n```\n$ pip install -U litecli\n```\n\nThe package is also available on Arch Linux through
55+
AUR in two versions: [litecli](https://aur.archlinux.org/packages/litecli/) is based the latest release (git tag) and
56+
[litecli-git](https://aur.archlinux.org/packages/litecli-git/) is based on the master branch of the git repo. You can install them manually or with an AUR helper such as
57+
`yay`:\n\n```\n$ yay -S litecli\n```\n\nor\n\n```\n$ yay -S litecli-git\n```\n\nFor MacOS users, you can also use Homebrew to install it:\n\n```\n$ brew install
58+
litecli\n```\n\n## Usage\n\n```\n$ litecli --help\n\nUsage: litecli [OPTIONS] [DATABASE]\n\nExamples:\n - litecli sqlite_db_name\n```\n\nA config file is automatically
59+
created at `~/.config/litecli/config` at first launch. For Windows machines a config file is created at `~\\AppData\\Local\\dbcli\\litecli\\config` at first launch. See
60+
the file itself for a description of all available options.\n\n## Docs\n\nVisit: [litecli.com/features](https://litecli.com/features)\n',
61+
keywords=None,
62+
long_description_content_type='text/markdown'
63+
)
64+
65+
```
66+
67+
The metadata can be extracted from a `wheel`, `sdist` (zip or tarball) or a source checkout (best effort). Check [`__init__.py`](metadata_please/__init__.py) file for all available functions.
68+
1069
# Version Compat
1170

1271
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: 170 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()
@@ -252,6 +390,7 @@ def from_setup_py_checkout(path: Path) -> bytes:
252390
raise ValueError("Complex setup call can't extract reqs")
253391
for dep in r:
254392
buf.append(f"Requires-Dist: {dep}\n")
393+
255394
er = v.setup_call_args.get("extras_require")
256395
if er:
257396
if er is UNKNOWN:
@@ -262,6 +401,31 @@ def from_setup_py_checkout(path: Path) -> bytes:
262401
for i in deps:
263402
buf.append("Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n")
264403

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

267431

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

271435

272436
if __name__ == "__main__": # pragma: no cover
437+
import json
273438
import sys
274439

275-
print(basic_metadata_from_source_checkout(Path(sys.argv[1])))
440+
md = basic_metadata_from_source_checkout(Path(sys.argv[1]))
441+
if md.reqs or md.name:
442+
print(json.dumps(asdict(md), default=list))
443+
else:
444+
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)