Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <litecli-users@googlegroups.com>',
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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think long-term these shouldn't raise; the snippets above do that because finding deps was the original use of this. It doesn't matter for your artifact-validation use case though.

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
Loading