|
| 1 | +""" |
| 2 | +
|
| 3 | +The format of a blurb file: |
| 4 | +
|
| 5 | + ENTRY |
| 6 | + [ENTRY2 |
| 7 | + ENTRY3 |
| 8 | + ...] |
| 9 | +
|
| 10 | +In other words, you may have one or more ENTRYs (entries) in a blurb file. |
| 11 | +
|
| 12 | +The format of an ENTRY: |
| 13 | +
|
| 14 | + METADATA |
| 15 | + BODY |
| 16 | +
|
| 17 | +The METADATA section is optional. |
| 18 | +The BODY section is mandatory and must be non-empty. |
| 19 | +
|
| 20 | +Format of the METADATA section: |
| 21 | +
|
| 22 | + * Lines starting with ".." are metadata lines of the format: |
| 23 | + .. name: value |
| 24 | + * Lines starting with "#" are comments: |
| 25 | + # comment line |
| 26 | + * Empty and whitespace-only lines are ignored. |
| 27 | + * Trailing whitespace is removed. Leading whitespace is not removed |
| 28 | + or ignored. |
| 29 | +
|
| 30 | +The first nonblank line that doesn't start with ".." or "#" automatically |
| 31 | +terminates the METADATA section and is the first line of the BODY. |
| 32 | +
|
| 33 | +Format of the BODY section: |
| 34 | +
|
| 35 | + * The BODY section should be a single paragraph of English text |
| 36 | + in ReST format. It should not use the following ReST markup |
| 37 | + features: |
| 38 | + * section headers |
| 39 | + * comments |
| 40 | + * directives, citations, or footnotes |
| 41 | + * Any features that require significant line breaks, |
| 42 | + like lists, definition lists, quoted paragraphs, line blocks, |
| 43 | + literal code blocks, and tables. |
| 44 | + Note that this is not (currently) enforced. |
| 45 | + * Trailing whitespace is stripped. Leading whitespace is preserved. |
| 46 | + * Empty lines between non-empty lines are preserved. |
| 47 | + Trailing empty lines are stripped. |
| 48 | + * The BODY mustn't start with "Issue #", "gh-", or "- ". |
| 49 | + (This formatting will be inserted when rendering the final output.) |
| 50 | + * Lines longer than 76 characters will be wordwrapped. |
| 51 | + * In the final output, the first line will have |
| 52 | + "- gh-issue-<gh-issue-number>: " inserted at the front, |
| 53 | + and subsequent lines will have two spaces inserted |
| 54 | + at the front. |
| 55 | +
|
| 56 | +To terminate an ENTRY, specify a line containing only "..". End of file |
| 57 | +also terminates the last ENTRY. |
| 58 | +
|
| 59 | +----------------------------------------------------------------------------- |
| 60 | +
|
| 61 | +The format of a "next" file is exactly the same, except that we're storing |
| 62 | +four pieces of metadata in the filename instead of in the metadata section. |
| 63 | +Those four pieces of metadata are: section, gh-issue, date, and nonce. |
| 64 | +
|
| 65 | +----------------------------------------------------------------------------- |
| 66 | +
|
| 67 | +In addition to the four conventional metadata (section, gh-issue, date, and nonce), |
| 68 | +there are two additional metadata used per-version: "release date" and |
| 69 | +"no changes". These may only be present in the metadata block in the *first* |
| 70 | +blurb in a blurb file. |
| 71 | + * "release date" is the day a particular version of Python was released. |
| 72 | + * "no changes", if present, notes that there were no actual changes |
| 73 | + for this version. When used, there are two more things that must be |
| 74 | + true about the the blurb file: |
| 75 | + * There should only be one entry inside the blurb file. |
| 76 | + * That entry's gh-issue number must be 0. |
| 77 | +
|
| 78 | +""" |
| 79 | + |
| 80 | +from __future__ import annotations |
| 81 | + |
| 82 | +import os |
| 83 | +import re |
| 84 | + |
| 85 | +from blurb._template import sanitize_section, sections, unsanitize_section |
| 86 | +from blurb.blurb import BlurbError, textwrap_body, sortable_datetime, nonceify |
| 87 | + |
| 88 | +root = None # Set by chdir_to_repo_root() |
| 89 | +lowest_possible_gh_issue_number = 32426 |
| 90 | + |
| 91 | + |
| 92 | +class Blurbs(list): |
| 93 | + def parse(self, text: str, *, metadata: dict[str, str] | None = None, |
| 94 | + filename: str = 'input') -> None: |
| 95 | + """Parses a string. |
| 96 | +
|
| 97 | + Appends a list of blurb ENTRIES to self, as tuples: (metadata, body) |
| 98 | + metadata is a dict. body is a string. |
| 99 | + """ |
| 100 | + |
| 101 | + metadata = metadata or {} |
| 102 | + body = [] |
| 103 | + in_metadata = True |
| 104 | + |
| 105 | + line_number = None |
| 106 | + |
| 107 | + def throw(s: str): |
| 108 | + raise BlurbError(f'Error in {filename}:{line_number}:\n{s}') |
| 109 | + |
| 110 | + def finish_entry() -> None: |
| 111 | + nonlocal body |
| 112 | + nonlocal in_metadata |
| 113 | + nonlocal metadata |
| 114 | + nonlocal self |
| 115 | + |
| 116 | + if not body: |
| 117 | + throw("Blurb 'body' text must not be empty!") |
| 118 | + text = textwrap_body(body) |
| 119 | + for naughty_prefix in ('- ', 'Issue #', 'bpo-', 'gh-', 'gh-issue-'): |
| 120 | + if re.match(naughty_prefix, text, re.I): |
| 121 | + throw(f"Blurb 'body' can't start with {naughty_prefix!r}!") |
| 122 | + |
| 123 | + no_changes = metadata.get('no changes') |
| 124 | + |
| 125 | + issue_keys = { |
| 126 | + 'gh-issue': 'GitHub', |
| 127 | + 'bpo': 'bpo', |
| 128 | + } |
| 129 | + for key, value in metadata.items(): |
| 130 | + # Iterate over metadata items in order. |
| 131 | + # We parsed the blurb file line by line, |
| 132 | + # so we'll insert metadata keys in the |
| 133 | + # order we see them. So if we issue the |
| 134 | + # errors in the order we see the keys, |
| 135 | + # we'll complain about the *first* error |
| 136 | + # we see in the blurb file, which is a |
| 137 | + # better user experience. |
| 138 | + if key in issue_keys: |
| 139 | + try: |
| 140 | + int(value) |
| 141 | + except (TypeError, ValueError): |
| 142 | + throw(f'Invalid {issue_keys[key]} number: {value!r}') |
| 143 | + |
| 144 | + if key == 'gh-issue' and int(value) < lowest_possible_gh_issue_number: |
| 145 | + throw(f'Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})') |
| 146 | + |
| 147 | + if key == 'section': |
| 148 | + if no_changes: |
| 149 | + continue |
| 150 | + if value not in sections: |
| 151 | + throw(f'Invalid section {value!r}! You must use one of the predefined sections.') |
| 152 | + |
| 153 | + if 'gh-issue' not in metadata and 'bpo' not in metadata: |
| 154 | + throw("'gh-issue:' or 'bpo:' must be specified in the metadata!") |
| 155 | + |
| 156 | + if 'section' not in metadata: |
| 157 | + throw("No 'section' specified. You must provide one!") |
| 158 | + |
| 159 | + self.append((metadata, text)) |
| 160 | + metadata = {} |
| 161 | + body = [] |
| 162 | + in_metadata = True |
| 163 | + |
| 164 | + for line_number, line in enumerate(text.split('\n')): |
| 165 | + line = line.rstrip() |
| 166 | + if in_metadata: |
| 167 | + if line.startswith('..'): |
| 168 | + line = line[2:].strip() |
| 169 | + name, colon, value = line.partition(':') |
| 170 | + assert colon |
| 171 | + name = name.lower().strip() |
| 172 | + value = value.strip() |
| 173 | + if name in metadata: |
| 174 | + throw(f'Blurb metadata sets {name!r} twice!') |
| 175 | + metadata[name] = value |
| 176 | + continue |
| 177 | + if line.startswith('#') or not line: |
| 178 | + continue |
| 179 | + in_metadata = False |
| 180 | + |
| 181 | + if line == '..': |
| 182 | + finish_entry() |
| 183 | + continue |
| 184 | + body.append(line) |
| 185 | + |
| 186 | + finish_entry() |
| 187 | + |
| 188 | + def load(self, filename: str, *, metadata: dict[str, str] | None = None) -> None: |
| 189 | + """Read a blurb file. |
| 190 | +
|
| 191 | + Broadly equivalent to blurb.parse(open(filename).read()). |
| 192 | + """ |
| 193 | + with open(filename, encoding='utf-8') as file: |
| 194 | + text = file.read() |
| 195 | + self.parse(text, metadata=metadata, filename=filename) |
| 196 | + |
| 197 | + def __str__(self) -> str: |
| 198 | + output = [] |
| 199 | + add = output.append |
| 200 | + add_separator = False |
| 201 | + for metadata, body in self: |
| 202 | + if add_separator: |
| 203 | + add('\n..\n\n') |
| 204 | + else: |
| 205 | + add_separator = True |
| 206 | + if metadata: |
| 207 | + for name, value in sorted(metadata.items()): |
| 208 | + add(f'.. {name}: {value}\n') |
| 209 | + add('\n') |
| 210 | + add(textwrap_body(body)) |
| 211 | + return ''.join(output) |
| 212 | + |
| 213 | + def save(self, path: str) -> None: |
| 214 | + dirname = os.path.dirname(path) |
| 215 | + os.makedirs(dirname, exist_ok=True) |
| 216 | + |
| 217 | + text = str(self) |
| 218 | + with open(path, 'w', encoding='utf-8') as file: |
| 219 | + file.write(text) |
| 220 | + |
| 221 | + @staticmethod |
| 222 | + def _parse_next_filename(filename: str) -> dict[str, str]: |
| 223 | + """Returns a dict of blurb metadata from a parsed "next" filename.""" |
| 224 | + components = filename.split(os.sep) |
| 225 | + section, filename = components[-2:] |
| 226 | + section = unsanitize_section(section) |
| 227 | + assert section in sections, f'Unknown section {section}' |
| 228 | + |
| 229 | + fields = [x.strip() for x in filename.split('.')] |
| 230 | + assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}" |
| 231 | + assert fields[-1] == 'rst' |
| 232 | + |
| 233 | + metadata = {'date': fields[0], 'nonce': fields[-2], 'section': section} |
| 234 | + |
| 235 | + for field in fields[1:-2]: |
| 236 | + for name in ('gh-issue', 'bpo'): |
| 237 | + _, got, value = field.partition(f'{name}-') |
| 238 | + if got: |
| 239 | + metadata[name] = value.strip() |
| 240 | + break |
| 241 | + else: |
| 242 | + assert False, f"Found unparsable field in 'next' filename: {field!r}" |
| 243 | + |
| 244 | + return metadata |
| 245 | + |
| 246 | + def load_next(self, filename: str) -> None: |
| 247 | + metadata = self._parse_next_filename(filename) |
| 248 | + o = type(self)() |
| 249 | + o.load(filename, metadata=metadata) |
| 250 | + assert len(o) == 1 |
| 251 | + self.extend(o) |
| 252 | + |
| 253 | + def ensure_metadata(self) -> None: |
| 254 | + metadata, body = self[-1] |
| 255 | + assert 'section' in metadata |
| 256 | + for name, default in ( |
| 257 | + ('gh-issue', '0'), |
| 258 | + ('bpo', '0'), |
| 259 | + ('date', sortable_datetime()), |
| 260 | + ('nonce', nonceify(body)), |
| 261 | + ): |
| 262 | + if name not in metadata: |
| 263 | + metadata[name] = default |
| 264 | + |
| 265 | + def _extract_next_filename(self) -> str: |
| 266 | + """Changes metadata!""" |
| 267 | + self.ensure_metadata() |
| 268 | + metadata, body = self[-1] |
| 269 | + metadata['section'] = sanitize_section(metadata['section']) |
| 270 | + metadata['root'] = root |
| 271 | + if int(metadata['gh-issue']) > 0: |
| 272 | + path = '{root}/Misc/NEWS.d/next/{section}/{date}.gh-issue-{gh-issue}.{nonce}.rst'.format_map(metadata) |
| 273 | + elif int(metadata['bpo']) > 0: |
| 274 | + # assume it's a GH issue number |
| 275 | + path = '{root}/Misc/NEWS.d/next/{section}/{date}.bpo-{bpo}.{nonce}.rst'.format_map(metadata) |
| 276 | + for name in ('root', 'section', 'date', 'gh-issue', 'bpo', 'nonce'): |
| 277 | + del metadata[name] |
| 278 | + return path |
| 279 | + |
| 280 | + def save_next(self) -> str: |
| 281 | + assert len(self) == 1 |
| 282 | + blurb = type(self)() |
| 283 | + metadata, body = self[0] |
| 284 | + metadata = dict(metadata) |
| 285 | + blurb.append((metadata, body)) |
| 286 | + filename = blurb._extract_next_filename() |
| 287 | + blurb.save(filename) |
| 288 | + return filename |
0 commit comments