From b5649582156dcd676cf1829c988c31859591c540 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:19:44 +0100 Subject: [PATCH 1/3] Move blurb file utilities to ``blurb._blurb_file`` --- src/blurb/_add.py | 3 +- src/blurb/_blurb_file.py | 286 ++++++++++++++++++++++++++++++++++++++ src/blurb/_cli.py | 4 +- src/blurb/_merge.py | 3 +- src/blurb/_release.py | 3 +- src/blurb/blurb.py | 290 --------------------------------------- tests/test_blurb.py | 140 ------------------- tests/test_blurb_file.py | 148 ++++++++++++++++++++ tests/test_parser.py | 2 +- 9 files changed, 443 insertions(+), 436 deletions(-) create mode 100644 src/blurb/_blurb_file.py create mode 100644 tests/test_blurb_file.py diff --git a/src/blurb/_add.py b/src/blurb/_add.py index 7487e8b..f1b2cd6 100644 --- a/src/blurb/_add.py +++ b/src/blurb/_add.py @@ -8,10 +8,11 @@ import sys import tempfile +from blurb._blurb_file import Blurbs from blurb._cli import subcommand,error,prompt from blurb._git import flush_git_add_files, git_add_files from blurb._template import sections, template -from blurb.blurb import Blurbs, BlurbError +from blurb.blurb import BlurbError TYPE_CHECKING = False if TYPE_CHECKING: diff --git a/src/blurb/_blurb_file.py b/src/blurb/_blurb_file.py new file mode 100644 index 0000000..56e5273 --- /dev/null +++ b/src/blurb/_blurb_file.py @@ -0,0 +1,286 @@ +""" + +The format of a blurb file: + + ENTRY + [ENTRY2 + ENTRY3 + ...] + +In other words, you may have one or more ENTRYs (entries) in a blurb file. + +The format of an ENTRY: + + METADATA + BODY + +The METADATA section is optional. +The BODY section is mandatory and must be non-empty. + +Format of the METADATA section: + + * Lines starting with ".." are metadata lines of the format: + .. name: value + * Lines starting with "#" are comments: + # comment line + * Empty and whitespace-only lines are ignored. + * Trailing whitespace is removed. Leading whitespace is not removed + or ignored. + +The first nonblank line that doesn't start with ".." or "#" automatically +terminates the METADATA section and is the first line of the BODY. + +Format of the BODY section: + + * The BODY section should be a single paragraph of English text + in ReST format. It should not use the following ReST markup + features: + * section headers + * comments + * directives, citations, or footnotes + * Any features that require significant line breaks, + like lists, definition lists, quoted paragraphs, line blocks, + literal code blocks, and tables. + Note that this is not (currently) enforced. + * Trailing whitespace is stripped. Leading whitespace is preserved. + * Empty lines between non-empty lines are preserved. + Trailing empty lines are stripped. + * The BODY mustn't start with "Issue #", "gh-", or "- ". + (This formatting will be inserted when rendering the final output.) + * Lines longer than 76 characters will be wordwrapped. + * In the final output, the first line will have + "- gh-issue-: " inserted at the front, + and subsequent lines will have two spaces inserted + at the front. + +To terminate an ENTRY, specify a line containing only "..". End of file +also terminates the last ENTRY. + +----------------------------------------------------------------------------- + +The format of a "next" file is exactly the same, except that we're storing +four pieces of metadata in the filename instead of in the metadata section. +Those four pieces of metadata are: section, gh-issue, date, and nonce. + +----------------------------------------------------------------------------- + +In addition to the four conventional metadata (section, gh-issue, date, and nonce), +there are two additional metadata used per-version: "release date" and +"no changes". These may only be present in the metadata block in the *first* +blurb in a blurb file. + * "release date" is the day a particular version of Python was released. + * "no changes", if present, notes that there were no actual changes + for this version. When used, there are two more things that must be + true about the the blurb file: + * There should only be one entry inside the blurb file. + * That entry's gh-issue number must be 0. + +""" + +import os +import re + +from blurb._template import sanitize_section, sections, unsanitize_section +from blurb.blurb import BlurbError, textwrap_body, sortable_datetime, nonceify + +root = None # Set by chdir_to_repo_root() +lowest_possible_gh_issue_number = 32426 + + +class Blurbs(list): + def parse(self, text: str, *, metadata: dict[str, str] | None = None, + filename: str = 'input') -> None: + """Parses a string. + + Appends a list of blurb ENTRIES to self, as tuples: (metadata, body) + metadata is a dict. body is a string. + """ + + metadata = metadata or {} + body = [] + in_metadata = True + + line_number = None + + def throw(s: str): + raise BlurbError(f'Error in {filename}:{line_number}:\n{s}') + + def finish_entry() -> None: + nonlocal body + nonlocal in_metadata + nonlocal metadata + nonlocal self + + if not body: + throw("Blurb 'body' text must not be empty!") + text = textwrap_body(body) + for naughty_prefix in ('- ', 'Issue #', 'bpo-', 'gh-', 'gh-issue-'): + if re.match(naughty_prefix, text, re.I): + throw(f"Blurb 'body' can't start with {naughty_prefix!r}!") + + no_changes = metadata.get('no changes') + + issue_keys = { + 'gh-issue': 'GitHub', + 'bpo': 'bpo', + } + for key, value in metadata.items(): + # Iterate over metadata items in order. + # We parsed the blurb file line by line, + # so we'll insert metadata keys in the + # order we see them. So if we issue the + # errors in the order we see the keys, + # we'll complain about the *first* error + # we see in the blurb file, which is a + # better user experience. + if key in issue_keys: + try: + int(value) + except (TypeError, ValueError): + throw(f'Invalid {issue_keys[key]} number: {value!r}') + + if key == 'gh-issue' and int(value) < lowest_possible_gh_issue_number: + throw(f'Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})') + + if key == 'section': + if no_changes: + continue + if value not in sections: + throw(f'Invalid section {value!r}! You must use one of the predefined sections.') + + if 'gh-issue' not in metadata and 'bpo' not in metadata: + throw("'gh-issue:' or 'bpo:' must be specified in the metadata!") + + if 'section' not in metadata: + throw("No 'section' specified. You must provide one!") + + self.append((metadata, text)) + metadata = {} + body = [] + in_metadata = True + + for line_number, line in enumerate(text.split('\n')): + line = line.rstrip() + if in_metadata: + if line.startswith('..'): + line = line[2:].strip() + name, colon, value = line.partition(':') + assert colon + name = name.lower().strip() + value = value.strip() + if name in metadata: + throw(f'Blurb metadata sets {name!r} twice!') + metadata[name] = value + continue + if line.startswith('#') or not line: + continue + in_metadata = False + + if line == '..': + finish_entry() + continue + body.append(line) + + finish_entry() + + def load(self, filename: str, *, metadata: dict[str, str] | None = None) -> None: + """Read a blurb file. + + Broadly equivalent to blurb.parse(open(filename).read()). + """ + with open(filename, encoding='utf-8') as file: + text = file.read() + self.parse(text, metadata=metadata, filename=filename) + + def __str__(self) -> str: + output = [] + add = output.append + add_separator = False + for metadata, body in self: + if add_separator: + add('\n..\n\n') + else: + add_separator = True + if metadata: + for name, value in sorted(metadata.items()): + add(f'.. {name}: {value}\n') + add('\n') + add(textwrap_body(body)) + return ''.join(output) + + def save(self, path: str) -> None: + dirname = os.path.dirname(path) + os.makedirs(dirname, exist_ok=True) + + text = str(self) + with open(path, 'w', encoding='utf-8') as file: + file.write(text) + + @staticmethod + def _parse_next_filename(filename: str) -> dict[str, str]: + """Returns a dict of blurb metadata from a parsed "next" filename.""" + components = filename.split(os.sep) + section, filename = components[-2:] + section = unsanitize_section(section) + assert section in sections, f'Unknown section {section}' + + fields = [x.strip() for x in filename.split('.')] + assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}" + assert fields[-1] == 'rst' + + metadata = {'date': fields[0], 'nonce': fields[-2], 'section': section} + + for field in fields[1:-2]: + for name in ('gh-issue', 'bpo'): + _, got, value = field.partition(f'{name}-') + if got: + metadata[name] = value.strip() + break + else: + assert False, f"Found unparsable field in 'next' filename: {field!r}" + + return metadata + + def load_next(self, filename: str) -> None: + metadata = self._parse_next_filename(filename) + o = type(self)() + o.load(filename, metadata=metadata) + assert len(o) == 1 + self.extend(o) + + def ensure_metadata(self) -> None: + metadata, body = self[-1] + assert 'section' in metadata + for name, default in ( + ('gh-issue', '0'), + ('bpo', '0'), + ('date', sortable_datetime()), + ('nonce', nonceify(body)), + ): + if name not in metadata: + metadata[name] = default + + def _extract_next_filename(self) -> str: + """Changes metadata!""" + self.ensure_metadata() + metadata, body = self[-1] + metadata['section'] = sanitize_section(metadata['section']) + metadata['root'] = root + if int(metadata['gh-issue']) > 0: + path = '{root}/Misc/NEWS.d/next/{section}/{date}.gh-issue-{gh-issue}.{nonce}.rst'.format_map(metadata) + elif int(metadata['bpo']) > 0: + # assume it's a GH issue number + path = '{root}/Misc/NEWS.d/next/{section}/{date}.bpo-{bpo}.{nonce}.rst'.format_map(metadata) + for name in ('root', 'section', 'date', 'gh-issue', 'bpo', 'nonce'): + del metadata[name] + return path + + def save_next(self) -> str: + assert len(self) == 1 + blurb = type(self)() + metadata, body = self[0] + metadata = dict(metadata) + blurb.append((metadata, body)) + filename = blurb._extract_next_filename() + blurb.save(filename) + return filename diff --git a/src/blurb/_cli.py b/src/blurb/_cli.py index 352964e..f4a3f76 100644 --- a/src/blurb/_cli.py +++ b/src/blurb/_cli.py @@ -306,6 +306,6 @@ def test_first_line(filename, test): break - import blurb.blurb - blurb.blurb.root = path + import blurb._blurb_file + blurb._blurb_file.root = path return path diff --git a/src/blurb/_merge.py b/src/blurb/_merge.py index 618b38e..ab26a3e 100644 --- a/src/blurb/_merge.py +++ b/src/blurb/_merge.py @@ -2,9 +2,10 @@ import sys from pathlib import Path +from blurb._blurb_file import Blurbs from blurb._cli import require_ok, subcommand from blurb._versions import glob_versions, printable_version -from blurb.blurb import Blurbs, glob_blurbs, textwrap_body +from blurb.blurb import glob_blurbs, textwrap_body original_dir: str = os.getcwd() diff --git a/src/blurb/_release.py b/src/blurb/_release.py index 8f5f708..60e76c8 100644 --- a/src/blurb/_release.py +++ b/src/blurb/_release.py @@ -4,10 +4,11 @@ import time import blurb.blurb +from blurb._blurb_file import Blurbs from blurb._cli import error, subcommand from blurb._git import (flush_git_add_files, flush_git_rm_files, git_rm_files, git_add_files) -from blurb.blurb import Blurbs, glob_blurbs, nonceify +from blurb.blurb import glob_blurbs, nonceify @subcommand diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index 3848bfe..4e0082b 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -44,7 +44,6 @@ import hashlib import itertools import os -import re import sys import textwrap import time @@ -54,9 +53,6 @@ sanitize_section_legacy, sections, unsanitize_section, ) -root = None # Set by chdir_to_repo_root() - - def textwrap_body(body, *, subsequent_indent=''): """ Accepts either a string or an iterable of strings. @@ -180,292 +176,6 @@ def glob_blurbs(version): class BlurbError(RuntimeError): pass -""" - -The format of a blurb file: - - ENTRY - [ENTRY2 - ENTRY3 - ...] - -In other words, you may have one or more ENTRYs (entries) in a blurb file. - -The format of an ENTRY: - - METADATA - BODY - -The METADATA section is optional. -The BODY section is mandatory and must be non-empty. - -Format of the METADATA section: - - * Lines starting with ".." are metadata lines of the format: - .. name: value - * Lines starting with "#" are comments: - # comment line - * Empty and whitespace-only lines are ignored. - * Trailing whitespace is removed. Leading whitespace is not removed - or ignored. - -The first nonblank line that doesn't start with ".." or "#" automatically -terminates the METADATA section and is the first line of the BODY. - -Format of the BODY section: - - * The BODY section should be a single paragraph of English text - in ReST format. It should not use the following ReST markup - features: - * section headers - * comments - * directives, citations, or footnotes - * Any features that require significant line breaks, - like lists, definition lists, quoted paragraphs, line blocks, - literal code blocks, and tables. - Note that this is not (currently) enforced. - * Trailing whitespace is stripped. Leading whitespace is preserved. - * Empty lines between non-empty lines are preserved. - Trailing empty lines are stripped. - * The BODY mustn't start with "Issue #", "gh-", or "- ". - (This formatting will be inserted when rendering the final output.) - * Lines longer than 76 characters will be wordwrapped. - * In the final output, the first line will have - "- gh-issue-: " inserted at the front, - and subsequent lines will have two spaces inserted - at the front. - -To terminate an ENTRY, specify a line containing only "..". End of file -also terminates the last ENTRY. - ------------------------------------------------------------------------------ - -The format of a "next" file is exactly the same, except that we're storing -four pieces of metadata in the filename instead of in the metadata section. -Those four pieces of metadata are: section, gh-issue, date, and nonce. - ------------------------------------------------------------------------------ - -In addition to the four conventional metadata (section, gh-issue, date, and nonce), -there are two additional metadata used per-version: "release date" and -"no changes". These may only be present in the metadata block in the *first* -blurb in a blurb file. - * "release date" is the day a particular version of Python was released. - * "no changes", if present, notes that there were no actual changes - for this version. When used, there are two more things that must be - true about the the blurb file: - * There should only be one entry inside the blurb file. - * That entry's gh-issue number must be 0. - -""" - -class Blurbs(list): - - def parse(self, text, *, metadata=None, filename="input"): - """ - Parses a string. Appends a list of blurb ENTRIES to self, as tuples: - (metadata, body) - metadata is a dict. body is a string. - """ - - metadata = metadata or {} - body = [] - in_metadata = True - - line_number = None - - def throw(s): - raise BlurbError(f"Error in {filename}:{line_number}:\n{s}") - - def finish_entry(): - nonlocal body - nonlocal in_metadata - nonlocal metadata - nonlocal self - - if not body: - throw("Blurb 'body' text must not be empty!") - text = textwrap_body(body) - for naughty_prefix in ("- ", "Issue #", "bpo-", "gh-", "gh-issue-"): - if re.match(naughty_prefix, text, re.I): - throw("Blurb 'body' can't start with " + repr(naughty_prefix) + "!") - - no_changes = metadata.get('no changes') - - lowest_possible_gh_issue_number = 32426 - - issue_keys = { - 'gh-issue': 'GitHub', - 'bpo': 'bpo', - } - for key, value in metadata.items(): - # Iterate over metadata items in order. - # We parsed the blurb file line by line, - # so we'll insert metadata keys in the - # order we see them. So if we issue the - # errors in the order we see the keys, - # we'll complain about the *first* error - # we see in the blurb file, which is a - # better user experience. - if key in issue_keys: - try: - int(value) - except (TypeError, ValueError): - throw(f"Invalid {issue_keys[key]} number: {value!r}") - - if key == "gh-issue" and int(value) < lowest_possible_gh_issue_number: - throw(f"Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})") - - if key == "section": - if no_changes: - continue - if value not in sections: - throw(f"Invalid section {value!r}! You must use one of the predefined sections.") - - if "gh-issue" not in metadata and "bpo" not in metadata: - throw("'gh-issue:' or 'bpo:' must be specified in the metadata!") - - if 'section' not in metadata: - throw("No 'section' specified. You must provide one!") - - self.append((metadata, text)) - metadata = {} - body = [] - in_metadata = True - - for line_number, line in enumerate(text.split("\n")): - line = line.rstrip() - if in_metadata: - if line.startswith('..'): - line = line[2:].strip() - name, colon, value = line.partition(":") - assert colon - name = name.lower().strip() - value = value.strip() - if name in metadata: - throw("Blurb metadata sets " + repr(name) + " twice!") - metadata[name] = value - continue - if line.startswith("#") or not line: - continue - in_metadata = False - - if line == "..": - finish_entry() - continue - body.append(line) - - finish_entry() - - def load(self, filename, *, metadata=None): - """ -Read a blurb file. - -Broadly equivalent to blurb.parse(open(filename).read()). - """ - with open(filename, encoding="utf-8") as file: - text = file.read() - self.parse(text, metadata=metadata, filename=filename) - - def __str__(self): - output = [] - add = output.append - add_separator = False - for metadata, body in self: - if add_separator: - add("\n..\n\n") - else: - add_separator = True - if metadata: - for name, value in sorted(metadata.items()): - add(f".. {name}: {value}\n") - add("\n") - add(textwrap_body(body)) - return "".join(output) - - def save(self, path): - dirname = os.path.dirname(path) - os.makedirs(dirname, exist_ok=True) - - text = str(self) - with open(path, "wt", encoding="utf-8") as file: - file.write(text) - - @staticmethod - def _parse_next_filename(filename): - """ - Parses a "next" filename into its equivalent blurb metadata. - Returns a dict. - """ - components = filename.split(os.sep) - section, filename = components[-2:] - section = unsanitize_section(section) - assert section in sections, f"Unknown section {section}" - - fields = [x.strip() for x in filename.split(".")] - assert len(fields) >= 4, f"Can't parse 'next' filename! filename {filename!r} fields {fields}" - assert fields[-1] == "rst" - - metadata = {"date": fields[0], "nonce": fields[-2], "section": section} - - for field in fields[1:-2]: - for name in ("gh-issue", "bpo"): - _, got, value = field.partition(name + "-") - if got: - metadata[name] = value.strip() - break - else: - assert False, "Found unparsable field in 'next' filename: " + repr(field) - - return metadata - - def load_next(self, filename): - metadata = self._parse_next_filename(filename) - o = type(self)() - o.load(filename, metadata=metadata) - assert len(o) == 1 - self.extend(o) - - def ensure_metadata(self): - metadata, body = self[-1] - assert 'section' in metadata - for name, default in ( - ("gh-issue", "0"), - ("bpo", "0"), - ("date", sortable_datetime()), - ("nonce", nonceify(body)), - ): - if name not in metadata: - metadata[name] = default - - def _extract_next_filename(self): - """ - changes metadata! - """ - self.ensure_metadata() - metadata, body = self[-1] - metadata['section'] = sanitize_section(metadata['section']) - metadata['root'] = root - if int(metadata["gh-issue"]) > 0: - path = "{root}/Misc/NEWS.d/next/{section}/{date}.gh-issue-{gh-issue}.{nonce}.rst".format_map(metadata) - elif int(metadata["bpo"]) > 0: - # assume it's a GH issue number - path = "{root}/Misc/NEWS.d/next/{section}/{date}.bpo-{bpo}.{nonce}.rst".format_map(metadata) - for name in "root section date gh-issue bpo nonce".split(): - del metadata[name] - return path - - def save_next(self): - assert len(self) == 1 - blurb = type(self)() - metadata, body = self[0] - metadata = dict(metadata) - blurb.append((metadata, body)) - filename = blurb._extract_next_filename() - blurb.save(filename) - return filename - - def error(*a): s = " ".join(str(x) for x in a) sys.exit("Error: " + s) diff --git a/tests/test_blurb.py b/tests/test_blurb.py index 87801cf..ea2910c 100644 --- a/tests/test_blurb.py +++ b/tests/test_blurb.py @@ -152,143 +152,3 @@ def test_glob_blurbs_sort_order(fs): assert filenames == expected -@pytest.mark.parametrize( - "news_entry, expected_section", - ( - ( - "Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst", - "Library", - ), - ( - "Misc/NEWS.d/next/Core_and_Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst", - "Core and Builtins", - ), - ( - "Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-55555.Pf_BI7.rst", - "Core and Builtins", - ), - ( - "Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-66666.2F1Byz.rst", - "Tools/Demos", - ), - ( - "Misc/NEWS.d/next/C_API/2023-03-27-22-09-07.gh-issue-77777.3SN8Bs.rst", - "C API", - ), - ( - "Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-88888.3SN8Bs.rst", - "C API", - ), - ), -) -def test_load_next(news_entry, expected_section, fs): - # Arrange - fs.create_file(news_entry, contents="testing") - blurbs = blurb.Blurbs() - - # Act - blurbs.load_next(news_entry) - - # Assert - metadata = blurbs[0][0] - assert metadata["section"] == expected_section - - -@pytest.mark.parametrize( - "news_entry, expected_path", - ( - ( - "Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst", - "root/Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst", - ), - ( - "Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst", - "root/Misc/NEWS.d/next/Core_and_Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst", - ), - ( - "Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-55555.2F1Byz.rst", - "root/Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-55555.2F1Byz.rst", - ), - ( - "Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst", - "root/Misc/NEWS.d/next/C_API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst", - ), - ), -) -def test_extract_next_filename(news_entry, expected_path, fs): - # Arrange - fs.create_file(news_entry, contents="testing") - blurb.root = "root" - blurbs = blurb.Blurbs() - blurbs.load_next(news_entry) - - # Act - path = blurbs._extract_next_filename() - - # Assert - assert path == expected_path - - -def test_parse(): - # Arrange - contents = ".. gh-issue: 123456\n.. section: IDLE\nHello world!" - blurbs = blurb.Blurbs() - - # Act - blurbs.parse(contents) - - # Assert - metadata, body = blurbs[0] - assert metadata["gh-issue"] == "123456" - assert metadata["section"] == "IDLE" - assert body == "Hello world!\n" - - -@pytest.mark.parametrize( - "contents, expected_error", - ( - ( - "", - r"Blurb 'body' text must not be empty!", - ), - ( - "gh-issue: Hello world!", - r"Blurb 'body' can't start with 'gh-'!", - ), - ( - ".. gh-issue: 1\n.. section: IDLE\nHello world!", - r"Invalid gh-issue number: '1' \(must be >= 32426\)", - ), - ( - ".. bpo: one-two\n.. section: IDLE\nHello world!", - r"Invalid bpo number: 'one-two'", - ), - ( - ".. gh-issue: one-two\n.. section: IDLE\nHello world!", - r"Invalid GitHub number: 'one-two'", - ), - ( - ".. gh-issue: 123456\n.. section: Funky Kong\nHello world!", - r"Invalid section 'Funky Kong'! You must use one of the predefined sections", - ), - ( - ".. gh-issue: 123456\nHello world!", - r"No 'section' specified. You must provide one!", - ), - ( - ".. gh-issue: 123456\n.. section: IDLE\n.. section: IDLE\nHello world!", - r"Blurb metadata sets 'section' twice!", - ), - ( - ".. section: IDLE\nHello world!", - r"'gh-issue:' or 'bpo:' must be specified in the metadata!", - ), - ), -) -def test_parse_no_body(contents, expected_error): - # Arrange - blurbs = blurb.Blurbs() - - # Act / Assert - with pytest.raises(blurb.BlurbError, match=expected_error): - blurbs.parse(contents) diff --git a/tests/test_blurb_file.py b/tests/test_blurb_file.py new file mode 100644 index 0000000..d21f40b --- /dev/null +++ b/tests/test_blurb_file.py @@ -0,0 +1,148 @@ +import pytest +import time_machine + +import blurb._blurb_file +from blurb._blurb_file import Blurbs +from blurb.blurb import BlurbError + + +@pytest.mark.parametrize( + "news_entry, expected_section", + ( + ( + "Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst", + "Library", + ), + ( + "Misc/NEWS.d/next/Core_and_Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst", + "Core and Builtins", + ), + ( + "Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-55555.Pf_BI7.rst", + "Core and Builtins", + ), + ( + "Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-66666.2F1Byz.rst", + "Tools/Demos", + ), + ( + "Misc/NEWS.d/next/C_API/2023-03-27-22-09-07.gh-issue-77777.3SN8Bs.rst", + "C API", + ), + ( + "Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-88888.3SN8Bs.rst", + "C API", + ), + ), +) +def test_load_next(news_entry, expected_section, fs): + # Arrange + fs.create_file(news_entry, contents="testing") + blurbs = Blurbs() + + # Act + blurbs.load_next(news_entry) + + # Assert + metadata = blurbs[0][0] + assert metadata["section"] == expected_section + + +@pytest.mark.parametrize( + "news_entry, expected_path", + ( + ( + "Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst", + "root/Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst", + ), + ( + "Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst", + "root/Misc/NEWS.d/next/Core_and_Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst", + ), + ( + "Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-55555.2F1Byz.rst", + "root/Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-55555.2F1Byz.rst", + ), + ( + "Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst", + "root/Misc/NEWS.d/next/C_API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst", + ), + ), +) +def test_extract_next_filename(news_entry, expected_path, fs, monkeypatch): + # Arrange + monkeypatch.setattr(blurb._blurb_file, 'root', 'root') + fs.create_file(news_entry, contents="testing") + blurbs = Blurbs() + blurbs.load_next(news_entry) + + # Act + path = blurbs._extract_next_filename() + + # Assert + assert path == expected_path + + +def test_parse(): + # Arrange + contents = ".. gh-issue: 123456\n.. section: IDLE\nHello world!" + blurbs = Blurbs() + + # Act + blurbs.parse(contents) + + # Assert + metadata, body = blurbs[0] + assert metadata["gh-issue"] == "123456" + assert metadata["section"] == "IDLE" + assert body == "Hello world!\n" + + +@pytest.mark.parametrize( + "contents, expected_error", + ( + ( + "", + r"Blurb 'body' text must not be empty!", + ), + ( + "gh-issue: Hello world!", + r"Blurb 'body' can't start with 'gh-'!", + ), + ( + ".. gh-issue: 1\n.. section: IDLE\nHello world!", + r"Invalid gh-issue number: '1' \(must be >= 32426\)", + ), + ( + ".. bpo: one-two\n.. section: IDLE\nHello world!", + r"Invalid bpo number: 'one-two'", + ), + ( + ".. gh-issue: one-two\n.. section: IDLE\nHello world!", + r"Invalid GitHub number: 'one-two'", + ), + ( + ".. gh-issue: 123456\n.. section: Funky Kong\nHello world!", + r"Invalid section 'Funky Kong'! You must use one of the predefined sections", + ), + ( + ".. gh-issue: 123456\nHello world!", + r"No 'section' specified. You must provide one!", + ), + ( + ".. gh-issue: 123456\n.. section: IDLE\n.. section: IDLE\nHello world!", + r"Blurb metadata sets 'section' twice!", + ), + ( + ".. section: IDLE\nHello world!", + r"'gh-issue:' or 'bpo:' must be specified in the metadata!", + ), + ), +) +def test_parse_no_body(contents, expected_error): + # Arrange + blurbs = Blurbs() + + # Act / Assert + with pytest.raises(BlurbError, match=expected_error): + blurbs.parse(contents) diff --git a/tests/test_parser.py b/tests/test_parser.py index ca6e724..cc1587f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,8 +3,8 @@ import pytest +from blurb._blurb_file import Blurbs from blurb._versions import chdir -from blurb.blurb import Blurbs class TestParserPasses: From 2bb192d86ff6bedd13e597c6c79edd2b08d13b81 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:27:57 +0100 Subject: [PATCH 2/3] fixup! Move blurb file utilities to ``blurb._blurb_file`` --- src/blurb/_blurb_file.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/blurb/_blurb_file.py b/src/blurb/_blurb_file.py index 56e5273..aaa5c29 100644 --- a/src/blurb/_blurb_file.py +++ b/src/blurb/_blurb_file.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """ The format of a blurb file: From 7839fbb1a263dbdc2848a35ff976f7507af83b00 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:28:59 +0100 Subject: [PATCH 3/3] fixup! Move blurb file utilities to ``blurb._blurb_file`` --- src/blurb/_blurb_file.py | 4 ++-- tests/test_blurb.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/blurb/_blurb_file.py b/src/blurb/_blurb_file.py index aaa5c29..fbd1cf5 100644 --- a/src/blurb/_blurb_file.py +++ b/src/blurb/_blurb_file.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ The format of a blurb file: @@ -79,6 +77,8 @@ """ +from __future__ import annotations + import os import re diff --git a/tests/test_blurb.py b/tests/test_blurb.py index ea2910c..862c41e 100644 --- a/tests/test_blurb.py +++ b/tests/test_blurb.py @@ -150,5 +150,3 @@ def test_glob_blurbs_sort_order(fs): # Assert assert filenames == expected - -