Skip to content

Commit

Permalink
Merge pull request #5 from python-packaging/homepage-project-urls
Browse files Browse the repository at this point in the history
Homepage project urls
  • Loading branch information
amjith authored Nov 14, 2024
2 parents 4097d58 + ff57b75 commit 5815c7a
Show file tree
Hide file tree
Showing 9 changed files with 464 additions and 40 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,65 @@ things, with as minimal dependencies as possible:
1. Support just enough metadata to be able to look up deps.
2. Do "the thing that pip does" when deciding what dist-info dir to look at.

# Usage

Example snippet to show how to get the metadata from a wheel.

```python
from zipfile import ZipFile
from metadata_please import basic_metadata_from_wheel

zf = ZipFile('somepkg.whl')
print(basic_metadata_from_wheel(zf, "somepkg"))
```

### Output

```
BasicMetadata(
reqs=[
'cli-helpers[styles] >=2.2.1',
'click >=4.1',
'configobj >=5.0.5',
'prompt-toolkit <4.0.0,>=3.0.3',
'pygments >=1.6',
'sqlparse >=0.4.4',
"behave >=1.2.6 ; extra == 'dev'",
"coverage >=7.2.7 ; extra == 'dev'",
"pexpect >=4.9.0 ; extra == 'dev'",
"pytest >=7.4.4 ; extra == 'dev'",
"pytest-cov >=4.1.0 ; extra == 'dev'",
"tox >=4.8.0 ; extra == 'dev'",
"pdbpp >=0.10.3 ; extra == 'dev'"
],
provides_extra=frozenset({'dev'}),
name='litecli',
version='1.12.4',
requires_python='>=3.7',
url=None,
project_urls={'homepage, https://github.com/dbcli/litecli': ''},
author=None,
author_email='dbcli <[email protected]>',
summary='CLI for SQLite Databases with auto-completion and syntax highlighting.',
description='# litecli\n\n[![GitHub
Actions](https://github.com/dbcli/litecli/actions/workflows/ci.yml/badge.svg)](https://github.com/dbcli/litecli/actions/workflows/ci.yml "GitHub
Actions")\n\n[Docs](https://litecli.com)\n\nA command-line client for SQLite databases that has auto-completion and syntax
highlighting.\n\n![Completion](screenshots/litecli.png)\n![CompletionGif](screenshots/litecli.gif)\n\n## Installation\n\nIf you already know how to install python
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
AUR in two versions: [litecli](https://aur.archlinux.org/packages/litecli/) is based the latest release (git tag) and
[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
`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
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
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
the file itself for a description of all available options.\n\n## Docs\n\nVisit: [litecli.com/features](https://litecli.com/features)\n',
keywords=None,
long_description_content_type='text/markdown'
)
```

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.

# Version Compat

Usage of this library should work back to 3.7, but development (and mypy
Expand Down
2 changes: 1 addition & 1 deletion metadata_please/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def basic_metadata_from_zip_sdist(zf: ZipFile) -> BasicMetadata:
requires = [f for f in zf.namelist() if f.endswith("/requires.txt")]
requires.sort(key=len)
if not requires:
return BasicMetadata((), frozenset())
return BasicMetadata((), frozenset(), "-")

data = zf.read(requires[0])
assert data is not None
Expand Down
171 changes: 170 additions & 1 deletion metadata_please/source_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
Notably, does not read nontrivial setup.py or attempt to emulate anything that can't be read staticly.
"""

import ast
import re
from dataclasses import asdict
from pathlib import Path

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

name = doc.get("project", {}).get("name")
if name:
buf.append(f"Name: {name}\n")

# Version
version = doc.get("project", {}).get("version")
if version:
buf.append(f"Version: {version}\n")

# Requires-Python
requires_python = doc.get("project", {}).get("requires-python")
if requires_python:
buf.append(f"Requires-Python: {requires_python}\n")

# Project-URL
urls = doc.get("project", {}).get("urls")
if urls:
for k, v in urls.items():
buf.append(f"Project-URL: {k}={v}\n")

# Author
authors = doc.get("project", {}).get("authors")
if authors:
for author in authors:
try:
buf.append(f"Author: {author.get('name')}\n")
except AttributeError:
pass
try:
buf.append(f"Author-Email: {author.get('email')}\n")
except AttributeError:
pass

# Summary
summary = doc.get("project", {}).get("description")
if summary:
buf.append(f"Summary: {summary}\n")

# Description
description = doc.get("project", {}).get("readme")
if description:
buf.append(f"Description: {description}\n")

# Keywords
keywords = doc.get("project", {}).get("keywords")
if keywords:
buf.append(f"Keywords: {keywords}\n")

return "".join(buf).encode("utf-8")


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

name = doc.get("tool", {}).get("poetry", {}).get("name")
if name:
buf.append(f"Name: {name}\n")

# Version
version = doc.get("tool", {}).get("poetry", {}).get("version")
if version:
buf.append(f"Version: {version}\n")

# Requires-Python
requires_python = doc.get("tool", {}).get("poetry", {}).get("requires-python")
if requires_python:
buf.append(f"Requires-Python: {requires_python}\n")

# Project-URL
url = doc.get("tool", {}).get("poetry", {}).get("homepage")
if url:
buf.append(f"Home-Page: {url}\n")

# Author
authors = doc.get("tool", {}).get("poetry", {}).get("authors")
if authors:
buf.append(f"Author: {authors}\n")

# Summary
summary = doc.get("tool", {}).get("poetry", {}).get("description")
if summary:
buf.append(f"Summary: {summary}\n")

# Description
description = doc.get("tool", {}).get("poetry", {}).get("readme")
if description:
buf.append(f"Description: {description}\n")

# Keywords
keywords = doc.get("tool", {}).get("poetry", {}).get("keywords")
if keywords:
buf.append(f"Keywords: {keywords}\n")

return "".join(buf).encode("utf-8")


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

buf: list[str] = []
try:
buf.append(f"Name: {rc.get('metadata', 'name')}\n")
except (NoOptionError, NoSectionError):
pass

# Requires-Python
try:
buf.append(f"Requires-Python: {rc.get('options', 'python_requires')}\n")
except (NoOptionError, NoSectionError):
pass

# Home-Page
try:
buf.append(f"Home-Page: {rc.get('metadata', 'url')}\n")
except (NoOptionError, NoSectionError):
pass

# Author
try:
buf.append(f"Author: {rc.get('metadata', 'author')}\n")
except (NoOptionError, NoSectionError):
pass

# Author-Email
try:
buf.append(f"Author-Email: {rc.get('metadata', 'author_email')}\n")
except (NoOptionError, NoSectionError):
pass

# Summary
try:
buf.append(f"Summary: {rc.get('metadata', 'description')}\n")
except (NoOptionError, NoSectionError):
pass

# Description
try:
buf.append(f"Description: {rc.get('metadata', 'long_description')}\n")
except (NoOptionError, NoSectionError):
pass

# Description-Content-Type
try:
buf.append(
f"Description-Content-Type: {rc.get('metadata', 'long_description_content_type')}\n"
)
except (NoOptionError, NoSectionError):
pass

try:
for dep in rc.get("options", "install_requires").splitlines():
dep = dep.strip()
Expand Down Expand Up @@ -252,6 +390,7 @@ def from_setup_py_checkout(path: Path) -> bytes:
raise ValueError("Complex setup call can't extract reqs")
for dep in r:
buf.append(f"Requires-Dist: {dep}\n")

er = v.setup_call_args.get("extras_require")
if er:
if er is UNKNOWN:
Expand All @@ -262,6 +401,31 @@ def from_setup_py_checkout(path: Path) -> bytes:
for i in deps:
buf.append("Requires-Dist: " + merge_extra_marker(extra_name, i) + "\n")

n = v.setup_call_args.get("name")
if n:
if n is UNKNOWN:
raise ValueError("Complex setup call can't extract name")
buf.append(f"Name: {n}\n")

n = v.setup_call_args.get("python_requires")
if n:
if n is UNKNOWN:
raise ValueError("Complex setup call can't extract python_requires")
buf.append(f"Requires-Python: {n}\n")

n = v.setup_call_args.get("url")
if n:
if n is UNKNOWN:
raise ValueError("Complex setup call can't extract url")
buf.append(f"Home-Page: {n}\n")

n = v.setup_call_args.get("project_urls")
if n:
if n is UNKNOWN:
raise ValueError("Complex setup call can't extract project_urls")
for k, v in n.items():
buf.append(f"Project-URL: {k}={v}\n")

return "".join(buf).encode("utf-8")


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


if __name__ == "__main__": # pragma: no cover
import json
import sys

print(basic_metadata_from_source_checkout(Path(sys.argv[1])))
md = basic_metadata_from_source_checkout(Path(sys.argv[1]))
if md.reqs or md.name:
print(json.dumps(asdict(md), default=list))
else:
sys.exit(1)
35 changes: 32 additions & 3 deletions metadata_please/source_checkout_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"""

import ast
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional


# Copied from orig-index
Expand Down Expand Up @@ -93,12 +93,30 @@ def __init__(self) -> None:
super().__init__()
self.setup_call_args: Optional[Dict[str, Any]] = None
self.setup_call_kwargs: Optional[bool] = None
self.stack: List[ast.AST] = []

def locate_assignment_value(self, body: List[ast.AST], name: ast.Name) -> Any:
for node in body:
if isinstance(node, ast.Assign):
if node.targets == [name]:
return node.value
return UNKNOWN

def visit(self, node: ast.AST) -> Any:
self.stack.append(node)
try:
return super().visit(node)
finally:
self.stack.pop()

def visit_Call(self, node: ast.Call) -> None:
# .func (expr, can just be name)
# .args
# .keywords
qn = self.qualified_name(node.func)
try:
qn = self.qualified_name(node.func)
except ValueError:
return
if qn in ("setuptools.setup", "distutils.setup"):
self.setup_call_args = d = {}
self.setup_call_kwargs = False
Expand All @@ -108,7 +126,18 @@ def visit_Call(self, node: ast.Call) -> None:
self.setup_call_kwargs = True
else:
try:
d[k.arg] = ast.literal_eval(k.value)
if isinstance(k.value, ast.Name):
print(self.stack)
for p in self.stack[::-1]:
if hasattr(p, "body"):
v = self.locate_assignment_value(p.body, k.value)
if v is not UNKNOWN:
d[k.arg] = ast.literal_eval(v)
break
else:
raise ValueError("XXX")
else:
d[k.arg] = ast.literal_eval(k.value)
except ValueError: # malformed node or string...
d[k.arg] = UNKNOWN

Expand Down
11 changes: 5 additions & 6 deletions metadata_please/tests/_zip.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
from __future__ import annotations

from typing import Sequence
from typing import Mapping, Sequence


class MemoryZipFile:
def __init__(self, names: Sequence[str], read_value: bytes = b"foo") -> None:
self.names = names
self.read_value = read_value
def __init__(self, mock_files: Mapping[str, bytes] = {}) -> None:
self.mock_files = mock_files
self.files_read: list[str] = []

def namelist(self) -> Sequence[str]:
return self.names[:]
return list(self.mock_files.keys())

def read(self, filename: str) -> bytes:
self.files_read.append(filename)
return self.read_value
return self.mock_files[filename]
Loading

0 comments on commit 5815c7a

Please sign in to comment.