diff --git a/README.md b/README.md index b2585ad..5784407 100644 --- a/README.md +++ b/README.md @@ -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 ', + 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 diff --git a/metadata_please/sdist.py b/metadata_please/sdist.py index 743d673..7194c1c 100644 --- a/metadata_please/sdist.py +++ b/metadata_please/sdist.py @@ -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 diff --git a/metadata_please/source_checkout.py b/metadata_please/source_checkout.py index 6bc6d1b..497e643 100644 --- a/metadata_please/source_checkout.py +++ b/metadata_please/source_checkout.py @@ -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: @@ -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") @@ -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") @@ -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() @@ -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: @@ -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") @@ -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) diff --git a/metadata_please/source_checkout_ast.py b/metadata_please/source_checkout_ast.py index 2c2037d..b78891f 100644 --- a/metadata_please/source_checkout_ast.py +++ b/metadata_please/source_checkout_ast.py @@ -11,7 +11,7 @@ """ import ast -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional # Copied from orig-index @@ -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 @@ -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 diff --git a/metadata_please/tests/_zip.py b/metadata_please/tests/_zip.py index 182fa9a..e61d8a7 100644 --- a/metadata_please/tests/_zip.py +++ b/metadata_please/tests/_zip.py @@ -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] diff --git a/metadata_please/tests/sdist.py b/metadata_please/tests/sdist.py index 529b29e..b222c95 100644 --- a/metadata_please/tests/sdist.py +++ b/metadata_please/tests/sdist.py @@ -13,12 +13,14 @@ class ZipSdistTest(unittest.TestCase): def test_requires_as_expected(self) -> None: z = MemoryZipFile( - ["foo.egg-info/requires.txt", "foo/__init__.py"], - read_value=b"""\ + { + "foo/__init__.py": b"", + "foo.egg-info/requires.txt": b"""\ a [e] b """, + } ) metadata = from_zip_sdist(z) # type: ignore self.assertEqual( @@ -32,12 +34,14 @@ def test_requires_as_expected(self) -> None: def test_basic_metadata(self) -> None: z = MemoryZipFile( - ["foo.egg-info/requires.txt", "foo/__init__.py"], - read_value=b"""\ + { + "foo/__init__.py": b"", + "foo.egg-info/requires.txt": b"""\ a [e] b """, + } ) bm = basic_metadata_from_zip_sdist(z) # type: ignore self.assertEqual( @@ -48,8 +52,10 @@ def test_basic_metadata(self) -> None: def test_basic_metadata_no_requires_file(self) -> None: z = MemoryZipFile( - ["foo.egg-info/PKG-INFO", "foo/__init__.py"], - read_value=b"\n", + { + "foo/__init__.py": b"", + "foo.egg-info/PKG-INFO": b"\n", + }, ) bm = basic_metadata_from_zip_sdist(z) # type: ignore self.assertEqual( @@ -60,8 +66,9 @@ def test_basic_metadata_no_requires_file(self) -> None: def test_basic_metadata_absl_py_09(self) -> None: z = MemoryZipFile( - ["foo.egg-info/requires.txt", "foo/__init__.py"], - read_value=b"""\ + { + "foo/__init__.py": b"", + "foo.egg-info/requires.txt": b"""\ six [:python_version < "3.4"] @@ -69,6 +76,7 @@ def test_basic_metadata_absl_py_09(self) -> None: [test:python_version < "3.4"] pytest """, + } ) bm = basic_metadata_from_zip_sdist(z) # type: ignore self.assertEqual( diff --git a/metadata_please/tests/source_checkout.py b/metadata_please/tests/source_checkout.py index c165181..8fb0191 100644 --- a/metadata_please/tests/source_checkout.py +++ b/metadata_please/tests/source_checkout.py @@ -11,10 +11,38 @@ class SourceCheckoutTest(unittest.TestCase): def test_pep621_empty(self) -> None: with tempfile.TemporaryDirectory() as d: Path(d, "pyproject.toml").write_text("") - self.assertEqual( - BasicMetadata((), frozenset()), - basic_metadata_from_source_checkout(Path(d)), + result = basic_metadata_from_source_checkout(Path(d)) + self.assertEqual(BasicMetadata(), result) + + def test_pep621_fields(self) -> None: + with tempfile.TemporaryDirectory() as d: + Path(d, "pyproject.toml").write_text( + """\ +[project] +name = "somepkg" +version = "1.2.58" +description = "Example Summary" +readme = "README.md" +requires-python = ">=3.7" +authors = [ + {name = "chicken", email = "duck@example.com"} +] +keywords = ["farm", "animals"] +urls = { "homepage" = "https://example.com" } +""" ) + rv = basic_metadata_from_source_checkout(Path(d)) + self.assertEqual("somepkg", rv.name) + self.assertEqual("1.2.58", rv.version) + self.assertEqual("Example Summary", rv.summary) + self.assertEqual(None, rv.url) + self.assertEqual({"homepage": "https://example.com"}, rv.project_urls) + self.assertEqual("chicken", rv.author) + self.assertEqual("duck@example.com", rv.author_email) + self.assertEqual("['farm', 'animals']", rv.keywords) + self.assertEqual(None, rv.long_description_content_type) + self.assertEqual("README.md", rv.description) + self.assertEqual(">=3.7", rv.requires_python) def test_pep621_extras(self) -> None: with tempfile.TemporaryDirectory() as d: @@ -41,6 +69,32 @@ def test_pep621_extras(self) -> None: ) self.assertEqual(frozenset({"dev", "marker"}), rv.provides_extra) + def test_poetry_fields(self) -> None: + with tempfile.TemporaryDirectory() as d: + Path(d, "pyproject.toml").write_text( + """\ +[tool.poetry] +name = "somepkg" +version = "1.2.30" +description = "Example Summary" +authors = ["chicken "] +readme = "README.md" +keywords = ["farm", "animals"] +homepage = "https://example.com" +""" + ) + rv = basic_metadata_from_source_checkout(Path(d)) + self.assertEqual("somepkg", rv.name) + self.assertEqual("1.2.30", rv.version) + self.assertEqual("Example Summary", rv.summary) + self.assertEqual("https://example.com", rv.url) + self.assertEqual({}, rv.project_urls) + self.assertEqual("['chicken ']", rv.author) + self.assertEqual("['farm', 'animals']", rv.keywords) + self.assertEqual(None, rv.long_description_content_type) + self.assertEqual("README.md", rv.description) + self.assertEqual(None, rv.requires_python) + def test_poetry_full(self) -> None: with tempfile.TemporaryDirectory() as d: Path(d, "pyproject.toml").write_text( @@ -163,3 +217,33 @@ def test_setuptools_extras(self) -> None: frozenset({"dev", "marker"}), rv.provides_extra, ) + + def test_setuptools_cfg_fields(self) -> None: + with tempfile.TemporaryDirectory() as d: + Path(d, "setup.cfg").write_text( + """\ +[metadata] +name = somepkg +description = Example Summary +long_description = file: README.md +long_description_content_type = text/markdown +license = MIT +url = https://example.com +author = chicken +author_email = duck@example.com + +[options] +python_requires = >=3.7 +""" + ) + rv = basic_metadata_from_source_checkout(Path(d)) + self.assertEqual("somepkg", rv.name) + self.assertEqual(None, rv.version) + self.assertEqual("Example Summary", rv.summary) + self.assertEqual("https://example.com", rv.url) + self.assertEqual("chicken", rv.author) + self.assertEqual("duck@example.com", rv.author_email) + self.assertEqual(None, rv.keywords) + self.assertEqual("file: README.md", rv.description) + self.assertEqual(">=3.7", rv.requires_python) + self.assertEqual("text/markdown", rv.long_description_content_type) diff --git a/metadata_please/tests/wheel.py b/metadata_please/tests/wheel.py index 9339c3b..7343202 100644 --- a/metadata_please/tests/wheel.py +++ b/metadata_please/tests/wheel.py @@ -6,22 +6,29 @@ class WheelTest(unittest.TestCase): def test_well_behaved(self) -> None: - z = MemoryZipFile(["foo.dist-info/METADATA", "foo/__init__.py"]) - self.assertEqual(b"foo", from_wheel(z, "foo")) # type: ignore + z = MemoryZipFile( + { + "foo.dist-info/METADATA": b"MD", + "foo/__init__.py": b"", + } + ) + self.assertEqual(b"MD", from_wheel(z, "foo")) # type: ignore self.assertEqual(["foo.dist-info/METADATA"], z.files_read) def test_actually_empty(self) -> None: - z = MemoryZipFile([]) + z = MemoryZipFile({}) with self.assertRaisesRegex(InvalidWheel, "Zero .dist-info dirs in wheel"): from_wheel(z, "foo") # type: ignore def test_no_dist_info(self) -> None: - z = MemoryZipFile(["foo/__init__.py"]) + z = MemoryZipFile({"foo/__init__.py": b""}) with self.assertRaisesRegex(InvalidWheel, "Zero .dist-info dirs in wheel"): from_wheel(z, "foo") # type: ignore def test_too_many_dist_info(self) -> None: - z = MemoryZipFile(["foo.dist-info/METADATA", "bar.dist-info/METADATA"]) + z = MemoryZipFile( + {"foo.dist-info/METADATA": b"", "bar.dist-info/METADATA": b""} + ) with self.assertRaisesRegex( InvalidWheel, r"2 .dist-info dirs where there should be just one: \['bar.dist-info', 'foo.dist-info'\]", @@ -29,14 +36,39 @@ def test_too_many_dist_info(self) -> None: from_wheel(z, "foo") # type: ignore def test_bad_project_name(self) -> None: - z = MemoryZipFile(["foo.dist-info/METADATA", "foo/__init__.py"]) + z = MemoryZipFile( + { + "foo.dist-info/METADATA": b"", + "foo/__init__.py": b"", + } + ) with self.assertRaisesRegex(InvalidWheel, "Mismatched foo.dist-info for bar"): from_wheel(z, "bar") # type: ignore def test_basic_metadata(self) -> None: z = MemoryZipFile( - ["foo.dist-info/METADATA", "foo/__init__.py"], - read_value=b"Requires-Dist: foo\n", + { + "foo.dist-info/METADATA": b"Requires-Dist: foo\n", + "foo/__init__.py": b"", + } + ) + bm = basic_metadata_from_wheel(z, "foo") # type: ignore + self.assertEqual(["foo"], bm.reqs) + + def test_basic_metadata_more_fields(self) -> None: + z = MemoryZipFile( + { + "foo.dist-info/METADATA": b"Requires-Dist: foo\nVersion: 1.2.58\nSummary: Some Summary\nHome-page: http://example.com\nAuthor: Chicken\nAuthor-email: duck@example.com\nKeywords: farm,animals\nRequires-Python: >=3.6\nDescription-Content-Type: text/markdown", + "foo/__init__.py": b"", + } ) bm = basic_metadata_from_wheel(z, "foo") # type: ignore self.assertEqual(["foo"], bm.reqs) + self.assertEqual("1.2.58", bm.version) + self.assertEqual("Some Summary", bm.summary) + self.assertEqual("http://example.com", bm.url) + self.assertEqual("Chicken", bm.author) + self.assertEqual("duck@example.com", bm.author_email) + self.assertEqual("farm,animals", bm.keywords) + self.assertEqual("text/markdown", bm.long_description_content_type) + self.assertEqual(None, bm.description) diff --git a/metadata_please/types.py b/metadata_please/types.py index 368f83e..183d348 100644 --- a/metadata_please/types.py +++ b/metadata_please/types.py @@ -1,17 +1,43 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from email import message_from_string -from typing import Sequence +from types import MappingProxyType +from typing import Mapping, Optional, Sequence @dataclass(frozen=True) class BasicMetadata: # Popualted from Requires-Dist or requires.txt - reqs: Sequence[str] + reqs: Sequence[str] = () # Populated from Provides-Extra - provides_extra: frozenset[str] + provides_extra: frozenset[str] = field(default_factory=frozenset) + # Populated from Name + name: Optional[str] = None + version: Optional[str] = None + requires_python: Optional[str] = None + url: Optional[str] = None + project_urls: Mapping[str, str] = field( + default_factory=lambda: MappingProxyType({}) + ) + author: Optional[str] = None + author_email: Optional[str] = None + summary: Optional[str] = None + description: Optional[str] = None + keywords: Optional[str] = None + long_description_content_type: Optional[str] = None + + def __or__(self, other: BasicMetadata) -> BasicMetadata: + """ + Fieldwise `or` -- if both copies are truthy, prefer `other`'s. + """ + # N.b. this can't use asdict because it tries to copy, and + # MappingProxyType isn't pickleable. + my_args = self.__dict__.copy() + truthy_other_args = {k: v for k, v in other.__dict__.items() if v} + my_args.update(truthy_other_args) + return BasicMetadata(**my_args) @classmethod def from_metadata(cls, metadata: bytes) -> BasicMetadata: @@ -19,18 +45,36 @@ def from_metadata(cls, metadata: bytes) -> BasicMetadata: return BasicMetadata( msg.get_all("Requires-Dist") or (), frozenset(msg.get_all("Provides-Extra") or ()), + msg.get("Name"), + msg.get("Version"), + msg.get("Requires-Python"), + msg.get("Home-Page"), + { + k: v + for k, _, v in map( + (lambda line: line.partition("=")), msg.get_all("Project-URL") or () + ) + }, + msg.get("Author"), + msg.get("Author-Email"), + msg.get("Summary"), + msg.get("Description") or msg.get_payload() or None, + msg.get("Keywords"), + msg.get("Description-Content-Type"), ) @classmethod def from_sdist_pkg_info_and_requires( cls, pkg_info: bytes, requires: bytes ) -> BasicMetadata: - # We can either get Provides-Extra from this, or from the section - # headers in requires.txt... - # msg = message_from_string(pkg_info.decode("utf-8")) - return cls( - *convert_sdist_requires(requires.decode("utf-8")), - ) + # Both of these can theoretically include Provides-Extra; we keep the + # pkg-info version if present. + + pkg_info_metadata = cls.from_metadata(pkg_info) + seq_requires, provides_extra = convert_sdist_requires(requires.decode("utf-8")) + sdist_metadata = cls(reqs=seq_requires, provides_extra=provides_extra) + + return sdist_metadata | pkg_info_metadata def convert_sdist_requires(data: str) -> tuple[tuple[str, ...], frozenset[str]]: