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