Aluode/PerceptionLabPortable
0
1"""2Tools for converting old- to new-style metadata.3"""4 5from __future__ import annotations6 7import functools8import itertools9import os.path10import re11import textwrap12from email.message import Message13from email.parser import Parser14from typing import Generator, Iterable, Iterator, Literal15 16from .vendored.packaging.requirements import Requirement17 18 19def _nonblank(str: str) -> bool | Literal[""]:20 return str and not str.startswith("#")21 22 23@functools.singledispatch24def yield_lines(iterable: Iterable[str]) -> Iterator[str]:25 r"""26 Yield valid lines of a string or iterable.27 >>> list(yield_lines(''))28 []29 >>> list(yield_lines(['foo', 'bar']))30 ['foo', 'bar']31 >>> list(yield_lines('foo\nbar'))32 ['foo', 'bar']33 >>> list(yield_lines('\nfoo\n#bar\nbaz #comment'))34 ['foo', 'baz #comment']35 >>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n']))36 ['foo', 'bar', 'baz', 'bing']37 """38 return itertools.chain.from_iterable(map(yield_lines, iterable))39 40 41@yield_lines.register(str)42def _(text: str) -> Iterator[str]:43 return filter(_nonblank, map(str.strip, text.splitlines()))44 45 46def split_sections(47 s: str | Iterator[str],48) -> Generator[tuple[str | None, list[str]], None, None]:49 """Split a string or iterable thereof into (section, content) pairs50 Each ``section`` is a stripped version of the section header ("[section]")51 and each ``content`` is a list of stripped lines excluding blank lines and52 comment-only lines. If there are any such lines before the first section53 header, they're returned in a first ``section`` of ``None``.54 """55 section = None56 content: list[str] = []57 for line in yield_lines(s):58 if line.startswith("["):59 if line.endswith("]"):60 if section or content:61 yield section, content62 section = line[1:-1].strip()63 content = []64 else:65 raise ValueError("Invalid section heading", line)66 else:67 content.append(line)68 69 # wrap up last segment70 yield section, content71 72 73def safe_extra(extra: str) -> str:74 """Convert an arbitrary string to a standard 'extra' name75 Any runs of non-alphanumeric characters are replaced with a single '_',76 and the result is always lowercased.77 """78 return re.sub("[^A-Za-z0-9.-]+", "_", extra).lower()79 80 81def safe_name(name: str) -> str:82 """Convert an arbitrary string to a standard distribution name83 Any runs of non-alphanumeric/. characters are replaced with a single '-'.84 """85 return re.sub("[^A-Za-z0-9.]+", "-", name)86 87 88def requires_to_requires_dist(requirement: Requirement) -> str:89 """Return the version specifier for a requirement in PEP 345/566 fashion."""90 if requirement.url:91 return " @ " + requirement.url92 93 requires_dist: list[str] = []94 for spec in requirement.specifier:95 requires_dist.append(spec.operator + spec.version)96 97 if requires_dist:98 return " " + ",".join(sorted(requires_dist))99 else:100 return ""101 102 103def convert_requirements(requirements: list[str]) -> Iterator[str]:104 """Yield Requires-Dist: strings for parsed requirements strings."""105 for req in requirements:106 parsed_requirement = Requirement(req)107 spec = requires_to_requires_dist(parsed_requirement)108 extras = ",".join(sorted(safe_extra(e) for e in parsed_requirement.extras))109 if extras:110 extras = f"[{extras}]"111 112 yield safe_name(parsed_requirement.name) + extras + spec113 114 115def generate_requirements(116 extras_require: dict[str | None, list[str]],117) -> Iterator[tuple[str, str]]:118 """119 Convert requirements from a setup()-style dictionary to120 ('Requires-Dist', 'requirement') and ('Provides-Extra', 'extra') tuples.121 122 extras_require is a dictionary of {extra: [requirements]} as passed to setup(),123 using the empty extra {'': [requirements]} to hold install_requires.124 """125 for extra, depends in extras_require.items():126 condition = ""127 extra = extra or ""128 if ":" in extra: # setuptools extra:condition syntax129 extra, condition = extra.split(":", 1)130 131 extra = safe_extra(extra)132 if extra:133 yield "Provides-Extra", extra134 if condition:135 condition = "(" + condition + ") and "136 condition += f"extra == '{extra}'"137 138 if condition:139 condition = " ; " + condition140 141 for new_req in convert_requirements(depends):142 canonical_req = str(Requirement(new_req + condition))143 yield "Requires-Dist", canonical_req144 145 146def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message:147 """148 Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format149 """150 with open(pkginfo_path, encoding="utf-8") as headers:151 pkg_info = Parser().parse(headers)152 153 pkg_info.replace_header("Metadata-Version", "2.1")154 # Those will be regenerated from `requires.txt`.155 del pkg_info["Provides-Extra"]156 del pkg_info["Requires-Dist"]157 requires_path = os.path.join(egg_info_path, "requires.txt")158 if os.path.exists(requires_path):159 with open(requires_path, encoding="utf-8") as requires_file:160 requires = requires_file.read()161 162 parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "")163 for extra, reqs in parsed_requirements:164 for key, value in generate_requirements({extra: reqs}):165 if (key, value) not in pkg_info.items():166 pkg_info[key] = value167 168 description = pkg_info["Description"]169 if description:170 description_lines = pkg_info["Description"].splitlines()171 dedented_description = "\n".join(172 # if the first line of long_description is blank,173 # the first line here will be indented.174 (175 description_lines[0].lstrip(),176 textwrap.dedent("\n".join(description_lines[1:])),177 "\n",178 )179 )180 pkg_info.set_payload(dedented_description)181 del pkg_info["Description"]182 183 return pkg_info184 