Skip to content

Commit 377efe7

Browse files
authored
Move blurb file utilities to blurb._blurb_file (#58)
1 parent 45ee2e7 commit 377efe7

File tree

9 files changed

+445
-438
lines changed

9 files changed

+445
-438
lines changed

src/blurb/_add.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
import sys
99
import tempfile
1010

11+
from blurb._blurb_file import Blurbs
1112
from blurb._cli import subcommand,error,prompt
1213
from blurb._git import flush_git_add_files, git_add_files
1314
from blurb._template import sections, template
14-
from blurb.blurb import Blurbs, BlurbError
15+
from blurb.blurb import BlurbError
1516

1617
TYPE_CHECKING = False
1718
if TYPE_CHECKING:

src/blurb/_blurb_file.py

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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

src/blurb/_cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,6 @@ def test_first_line(filename, test):
306306

307307
break
308308

309-
import blurb.blurb
310-
blurb.blurb.root = path
309+
import blurb._blurb_file
310+
blurb._blurb_file.root = path
311311
return path

src/blurb/_merge.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import sys
33
from pathlib import Path
44

5+
from blurb._blurb_file import Blurbs
56
from blurb._cli import require_ok, subcommand
67
from blurb._versions import glob_versions, printable_version
7-
from blurb.blurb import Blurbs, glob_blurbs, textwrap_body
8+
from blurb.blurb import glob_blurbs, textwrap_body
89

910
original_dir: str = os.getcwd()
1011

src/blurb/_release.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import time
55

66
import blurb.blurb
7+
from blurb._blurb_file import Blurbs
78
from blurb._cli import error, subcommand
89
from blurb._git import (flush_git_add_files, flush_git_rm_files,
910
git_rm_files, git_add_files)
10-
from blurb.blurb import Blurbs, glob_blurbs, nonceify
11+
from blurb.blurb import glob_blurbs, nonceify
1112

1213

1314
@subcommand

0 commit comments

Comments
 (0)