Skip to content

Commit b564958

Browse files
committed
Move blurb file utilities to blurb._blurb_file
1 parent 45ee2e7 commit b564958

File tree

9 files changed

+443
-436
lines changed

9 files changed

+443
-436
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: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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+
import os
81+
import re
82+
83+
from blurb._template import sanitize_section, sections, unsanitize_section
84+
from blurb.blurb import BlurbError, textwrap_body, sortable_datetime, nonceify
85+
86+
root = None # Set by chdir_to_repo_root()
87+
lowest_possible_gh_issue_number = 32426
88+
89+
90+
class Blurbs(list):
91+
def parse(self, text: str, *, metadata: dict[str, str] | None = None,
92+
filename: str = 'input') -> None:
93+
"""Parses a string.
94+
95+
Appends a list of blurb ENTRIES to self, as tuples: (metadata, body)
96+
metadata is a dict. body is a string.
97+
"""
98+
99+
metadata = metadata or {}
100+
body = []
101+
in_metadata = True
102+
103+
line_number = None
104+
105+
def throw(s: str):
106+
raise BlurbError(f'Error in {filename}:{line_number}:\n{s}')
107+
108+
def finish_entry() -> None:
109+
nonlocal body
110+
nonlocal in_metadata
111+
nonlocal metadata
112+
nonlocal self
113+
114+
if not body:
115+
throw("Blurb 'body' text must not be empty!")
116+
text = textwrap_body(body)
117+
for naughty_prefix in ('- ', 'Issue #', 'bpo-', 'gh-', 'gh-issue-'):
118+
if re.match(naughty_prefix, text, re.I):
119+
throw(f"Blurb 'body' can't start with {naughty_prefix!r}!")
120+
121+
no_changes = metadata.get('no changes')
122+
123+
issue_keys = {
124+
'gh-issue': 'GitHub',
125+
'bpo': 'bpo',
126+
}
127+
for key, value in metadata.items():
128+
# Iterate over metadata items in order.
129+
# We parsed the blurb file line by line,
130+
# so we'll insert metadata keys in the
131+
# order we see them. So if we issue the
132+
# errors in the order we see the keys,
133+
# we'll complain about the *first* error
134+
# we see in the blurb file, which is a
135+
# better user experience.
136+
if key in issue_keys:
137+
try:
138+
int(value)
139+
except (TypeError, ValueError):
140+
throw(f'Invalid {issue_keys[key]} number: {value!r}')
141+
142+
if key == 'gh-issue' and int(value) < lowest_possible_gh_issue_number:
143+
throw(f'Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})')
144+
145+
if key == 'section':
146+
if no_changes:
147+
continue
148+
if value not in sections:
149+
throw(f'Invalid section {value!r}! You must use one of the predefined sections.')
150+
151+
if 'gh-issue' not in metadata and 'bpo' not in metadata:
152+
throw("'gh-issue:' or 'bpo:' must be specified in the metadata!")
153+
154+
if 'section' not in metadata:
155+
throw("No 'section' specified. You must provide one!")
156+
157+
self.append((metadata, text))
158+
metadata = {}
159+
body = []
160+
in_metadata = True
161+
162+
for line_number, line in enumerate(text.split('\n')):
163+
line = line.rstrip()
164+
if in_metadata:
165+
if line.startswith('..'):
166+
line = line[2:].strip()
167+
name, colon, value = line.partition(':')
168+
assert colon
169+
name = name.lower().strip()
170+
value = value.strip()
171+
if name in metadata:
172+
throw(f'Blurb metadata sets {name!r} twice!')
173+
metadata[name] = value
174+
continue
175+
if line.startswith('#') or not line:
176+
continue
177+
in_metadata = False
178+
179+
if line == '..':
180+
finish_entry()
181+
continue
182+
body.append(line)
183+
184+
finish_entry()
185+
186+
def load(self, filename: str, *, metadata: dict[str, str] | None = None) -> None:
187+
"""Read a blurb file.
188+
189+
Broadly equivalent to blurb.parse(open(filename).read()).
190+
"""
191+
with open(filename, encoding='utf-8') as file:
192+
text = file.read()
193+
self.parse(text, metadata=metadata, filename=filename)
194+
195+
def __str__(self) -> str:
196+
output = []
197+
add = output.append
198+
add_separator = False
199+
for metadata, body in self:
200+
if add_separator:
201+
add('\n..\n\n')
202+
else:
203+
add_separator = True
204+
if metadata:
205+
for name, value in sorted(metadata.items()):
206+
add(f'.. {name}: {value}\n')
207+
add('\n')
208+
add(textwrap_body(body))
209+
return ''.join(output)
210+
211+
def save(self, path: str) -> None:
212+
dirname = os.path.dirname(path)
213+
os.makedirs(dirname, exist_ok=True)
214+
215+
text = str(self)
216+
with open(path, 'w', encoding='utf-8') as file:
217+
file.write(text)
218+
219+
@staticmethod
220+
def _parse_next_filename(filename: str) -> dict[str, str]:
221+
"""Returns a dict of blurb metadata from a parsed "next" filename."""
222+
components = filename.split(os.sep)
223+
section, filename = components[-2:]
224+
section = unsanitize_section(section)
225+
assert section in sections, f'Unknown section {section}'
226+
227+
fields = [x.strip() for x in filename.split('.')]
228+
assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}"
229+
assert fields[-1] == 'rst'
230+
231+
metadata = {'date': fields[0], 'nonce': fields[-2], 'section': section}
232+
233+
for field in fields[1:-2]:
234+
for name in ('gh-issue', 'bpo'):
235+
_, got, value = field.partition(f'{name}-')
236+
if got:
237+
metadata[name] = value.strip()
238+
break
239+
else:
240+
assert False, f"Found unparsable field in 'next' filename: {field!r}"
241+
242+
return metadata
243+
244+
def load_next(self, filename: str) -> None:
245+
metadata = self._parse_next_filename(filename)
246+
o = type(self)()
247+
o.load(filename, metadata=metadata)
248+
assert len(o) == 1
249+
self.extend(o)
250+
251+
def ensure_metadata(self) -> None:
252+
metadata, body = self[-1]
253+
assert 'section' in metadata
254+
for name, default in (
255+
('gh-issue', '0'),
256+
('bpo', '0'),
257+
('date', sortable_datetime()),
258+
('nonce', nonceify(body)),
259+
):
260+
if name not in metadata:
261+
metadata[name] = default
262+
263+
def _extract_next_filename(self) -> str:
264+
"""Changes metadata!"""
265+
self.ensure_metadata()
266+
metadata, body = self[-1]
267+
metadata['section'] = sanitize_section(metadata['section'])
268+
metadata['root'] = root
269+
if int(metadata['gh-issue']) > 0:
270+
path = '{root}/Misc/NEWS.d/next/{section}/{date}.gh-issue-{gh-issue}.{nonce}.rst'.format_map(metadata)
271+
elif int(metadata['bpo']) > 0:
272+
# assume it's a GH issue number
273+
path = '{root}/Misc/NEWS.d/next/{section}/{date}.bpo-{bpo}.{nonce}.rst'.format_map(metadata)
274+
for name in ('root', 'section', 'date', 'gh-issue', 'bpo', 'nonce'):
275+
del metadata[name]
276+
return path
277+
278+
def save_next(self) -> str:
279+
assert len(self) == 1
280+
blurb = type(self)()
281+
metadata, body = self[0]
282+
metadata = dict(metadata)
283+
blurb.append((metadata, body))
284+
filename = blurb._extract_next_filename()
285+
blurb.save(filename)
286+
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)