jpayne@68: """ jpayne@68: Tools for converting old- to new-style metadata. jpayne@68: """ jpayne@68: jpayne@68: from __future__ import annotations jpayne@68: jpayne@68: import functools jpayne@68: import itertools jpayne@68: import os.path jpayne@68: import re jpayne@68: import textwrap jpayne@68: from email.message import Message jpayne@68: from email.parser import Parser jpayne@68: from typing import Generator, Iterable, Iterator, Literal jpayne@68: jpayne@68: from .vendored.packaging.requirements import Requirement jpayne@68: jpayne@68: jpayne@68: def _nonblank(str: str) -> bool | Literal[""]: jpayne@68: return str and not str.startswith("#") jpayne@68: jpayne@68: jpayne@68: @functools.singledispatch jpayne@68: def yield_lines(iterable: Iterable[str]) -> Iterator[str]: jpayne@68: r""" jpayne@68: Yield valid lines of a string or iterable. jpayne@68: >>> list(yield_lines('')) jpayne@68: [] jpayne@68: >>> list(yield_lines(['foo', 'bar'])) jpayne@68: ['foo', 'bar'] jpayne@68: >>> list(yield_lines('foo\nbar')) jpayne@68: ['foo', 'bar'] jpayne@68: >>> list(yield_lines('\nfoo\n#bar\nbaz #comment')) jpayne@68: ['foo', 'baz #comment'] jpayne@68: >>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n'])) jpayne@68: ['foo', 'bar', 'baz', 'bing'] jpayne@68: """ jpayne@68: return itertools.chain.from_iterable(map(yield_lines, iterable)) jpayne@68: jpayne@68: jpayne@68: @yield_lines.register(str) jpayne@68: def _(text: str) -> Iterator[str]: jpayne@68: return filter(_nonblank, map(str.strip, text.splitlines())) jpayne@68: jpayne@68: jpayne@68: def split_sections( jpayne@68: s: str | Iterator[str], jpayne@68: ) -> Generator[tuple[str | None, list[str]], None, None]: jpayne@68: """Split a string or iterable thereof into (section, content) pairs jpayne@68: Each ``section`` is a stripped version of the section header ("[section]") jpayne@68: and each ``content`` is a list of stripped lines excluding blank lines and jpayne@68: comment-only lines. If there are any such lines before the first section jpayne@68: header, they're returned in a first ``section`` of ``None``. jpayne@68: """ jpayne@68: section = None jpayne@68: content: list[str] = [] jpayne@68: for line in yield_lines(s): jpayne@68: if line.startswith("["): jpayne@68: if line.endswith("]"): jpayne@68: if section or content: jpayne@68: yield section, content jpayne@68: section = line[1:-1].strip() jpayne@68: content = [] jpayne@68: else: jpayne@68: raise ValueError("Invalid section heading", line) jpayne@68: else: jpayne@68: content.append(line) jpayne@68: jpayne@68: # wrap up last segment jpayne@68: yield section, content jpayne@68: jpayne@68: jpayne@68: def safe_extra(extra: str) -> str: jpayne@68: """Convert an arbitrary string to a standard 'extra' name jpayne@68: Any runs of non-alphanumeric characters are replaced with a single '_', jpayne@68: and the result is always lowercased. jpayne@68: """ jpayne@68: return re.sub("[^A-Za-z0-9.-]+", "_", extra).lower() jpayne@68: jpayne@68: jpayne@68: def safe_name(name: str) -> str: jpayne@68: """Convert an arbitrary string to a standard distribution name jpayne@68: Any runs of non-alphanumeric/. characters are replaced with a single '-'. jpayne@68: """ jpayne@68: return re.sub("[^A-Za-z0-9.]+", "-", name) jpayne@68: jpayne@68: jpayne@68: def requires_to_requires_dist(requirement: Requirement) -> str: jpayne@68: """Return the version specifier for a requirement in PEP 345/566 fashion.""" jpayne@68: if requirement.url: jpayne@68: return " @ " + requirement.url jpayne@68: jpayne@68: requires_dist: list[str] = [] jpayne@68: for spec in requirement.specifier: jpayne@68: requires_dist.append(spec.operator + spec.version) jpayne@68: jpayne@68: if requires_dist: jpayne@68: return " " + ",".join(sorted(requires_dist)) jpayne@68: else: jpayne@68: return "" jpayne@68: jpayne@68: jpayne@68: def convert_requirements(requirements: list[str]) -> Iterator[str]: jpayne@68: """Yield Requires-Dist: strings for parsed requirements strings.""" jpayne@68: for req in requirements: jpayne@68: parsed_requirement = Requirement(req) jpayne@68: spec = requires_to_requires_dist(parsed_requirement) jpayne@68: extras = ",".join(sorted(safe_extra(e) for e in parsed_requirement.extras)) jpayne@68: if extras: jpayne@68: extras = f"[{extras}]" jpayne@68: jpayne@68: yield safe_name(parsed_requirement.name) + extras + spec jpayne@68: jpayne@68: jpayne@68: def generate_requirements( jpayne@68: extras_require: dict[str | None, list[str]], jpayne@68: ) -> Iterator[tuple[str, str]]: jpayne@68: """ jpayne@68: Convert requirements from a setup()-style dictionary to jpayne@68: ('Requires-Dist', 'requirement') and ('Provides-Extra', 'extra') tuples. jpayne@68: jpayne@68: extras_require is a dictionary of {extra: [requirements]} as passed to setup(), jpayne@68: using the empty extra {'': [requirements]} to hold install_requires. jpayne@68: """ jpayne@68: for extra, depends in extras_require.items(): jpayne@68: condition = "" jpayne@68: extra = extra or "" jpayne@68: if ":" in extra: # setuptools extra:condition syntax jpayne@68: extra, condition = extra.split(":", 1) jpayne@68: jpayne@68: extra = safe_extra(extra) jpayne@68: if extra: jpayne@68: yield "Provides-Extra", extra jpayne@68: if condition: jpayne@68: condition = "(" + condition + ") and " jpayne@68: condition += f"extra == '{extra}'" jpayne@68: jpayne@68: if condition: jpayne@68: condition = " ; " + condition jpayne@68: jpayne@68: for new_req in convert_requirements(depends): jpayne@68: canonical_req = str(Requirement(new_req + condition)) jpayne@68: yield "Requires-Dist", canonical_req jpayne@68: jpayne@68: jpayne@68: def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message: jpayne@68: """ jpayne@68: Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format jpayne@68: """ jpayne@68: with open(pkginfo_path, encoding="utf-8") as headers: jpayne@68: pkg_info = Parser().parse(headers) jpayne@68: jpayne@68: pkg_info.replace_header("Metadata-Version", "2.1") jpayne@68: # Those will be regenerated from `requires.txt`. jpayne@68: del pkg_info["Provides-Extra"] jpayne@68: del pkg_info["Requires-Dist"] jpayne@68: requires_path = os.path.join(egg_info_path, "requires.txt") jpayne@68: if os.path.exists(requires_path): jpayne@68: with open(requires_path, encoding="utf-8") as requires_file: jpayne@68: requires = requires_file.read() jpayne@68: jpayne@68: parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "") jpayne@68: for extra, reqs in parsed_requirements: jpayne@68: for key, value in generate_requirements({extra: reqs}): jpayne@68: if (key, value) not in pkg_info.items(): jpayne@68: pkg_info[key] = value jpayne@68: jpayne@68: description = pkg_info["Description"] jpayne@68: if description: jpayne@68: description_lines = pkg_info["Description"].splitlines() jpayne@68: dedented_description = "\n".join( jpayne@68: # if the first line of long_description is blank, jpayne@68: # the first line here will be indented. jpayne@68: ( jpayne@68: description_lines[0].lstrip(), jpayne@68: textwrap.dedent("\n".join(description_lines[1:])), jpayne@68: "\n", jpayne@68: ) jpayne@68: ) jpayne@68: pkg_info.set_payload(dedented_description) jpayne@68: del pkg_info["Description"] jpayne@68: jpayne@68: return pkg_info