diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 03e914e..ef930db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 + rev: v0.16.0 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] @@ -20,7 +20,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.1 + rev: 0.37.4 hooks: - id: check-dependabot - id: check-github-workflows @@ -31,12 +31,12 @@ repos: - id: actionlint - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.24.1 + rev: v1.28.0 hooks: - id: zizmor - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.21.1 + rev: v2.25.3 hooks: - id: pyproject-fmt @@ -46,7 +46,7 @@ repos: - id: validate-pyproject - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.7.1 + rev: 1.8.0 hooks: - id: tox-ini-fmt diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 3e19ac2..0000000 --- a/.ruff.toml +++ /dev/null @@ -1,16 +0,0 @@ -target-version = "py310" -fix = true - -[format] -preview = true -quote-style = "single" -docstring-code-format = true - -[lint] -preview = true -select = [ - "I", # isort -] -ignore = [ - "E501", # Ignore line length errors (we use auto-formatting) -] diff --git a/pyproject.toml b/pyproject.toml index 800cbc6..a31730b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "blurb" description = "Command-line tool to manage CPython Misc/NEWS.d entries." readme = "README.md" maintainers = [ - { name = "Python Core Developers", email = "core-workflow@mail.python.org" }, + { name = "Python Core Team", email = "core-workflow@mail.python.org" }, ] authors = [ { name = "Larry Hastings", email = "larry@hastings.org" }, @@ -27,9 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] -dynamic = [ - "version", -] +dynamic = [ "version" ] optional-dependencies.tests = [ "pyfakefs", "pytest", @@ -46,5 +44,17 @@ version.source = "vcs" version.raw-options.local_scheme = "no-local-version" build.hooks.vcs.version-file = "src/blurb/_version.py" +[tool.ruff] +fix = true +format.preview = true +format.docstring-code-format = true +lint.select = [ + "I", # isort +] +lint.ignore = [ + "E501", # Ignore line length errors (we use auto-formatting) +] +lint.preview = true + [tool.pyproject-fmt] max_supported_python = "3.15" diff --git a/src/blurb/__main__.py b/src/blurb/__main__.py index ee9eeb5..ee1ca16 100644 --- a/src/blurb/__main__.py +++ b/src/blurb/__main__.py @@ -4,5 +4,5 @@ from blurb._cli import main -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/blurb/_add.py b/src/blurb/_add.py index c70fd42..a1ac7c8 100644 --- a/src/blurb/_add.py +++ b/src/blurb/_add.py @@ -17,10 +17,10 @@ if TYPE_CHECKING: from collections.abc import Sequence -if sys.platform == 'win32': - FALLBACK_EDITORS = ('notepad.exe',) +if sys.platform == "win32": + FALLBACK_EDITORS = ("notepad.exe",) else: - FALLBACK_EDITORS = ('/etc/alternatives/editor', 'nano') + FALLBACK_EDITORS = ("/etc/alternatives/editor", "nano") def add(*, issue: str | None = None, section: str | None = None): @@ -44,12 +44,12 @@ def add(*, issue: str | None = None, section: str | None = None): {sections} """ # fmt: skip - handle, tmp_path = tempfile.mkstemp('.rst') + handle, tmp_path = tempfile.mkstemp(".rst") os.close(handle) atexit.register(lambda: os.unlink(tmp_path)) text = _blurb_template_text(issue=issue, section=section) - with open(tmp_path, 'w', encoding='utf-8') as file: + with open(tmp_path, "w", encoding="utf-8") as file: file.write(text) args = _editor_args() @@ -59,7 +59,7 @@ def add(*, issue: str | None = None, section: str | None = None): blurb = _add_blurb_from_template(args, tmp_path) if blurb is None: try: - prompt('Hit return to retry (or Ctrl-C to abort)') + prompt("Hit return to retry (or Ctrl-C to abort)") except KeyboardInterrupt: print() return @@ -70,10 +70,10 @@ def add(*, issue: str | None = None, section: str | None = None): path = blurb.save_next() git_add_files.append(path) flush_git_add_files() - print('Ready for commit.') + print("Ready for commit.") -add.__doc__ = add.__doc__.format(sections='\n'.join(f'* {s}' for s in sections)) +add.__doc__ = add.__doc__.format(sections="\n".join(f"* {s}" for s in sections)) def _editor_args() -> list[str]: @@ -89,12 +89,12 @@ def _editor_args() -> list[str]: else: args = list(shlex.split(editor)) if not shutil.which(args[0]): - raise SystemExit(f'Invalid GIT_EDITOR / EDITOR value: {editor}') + raise SystemExit(f"Invalid GIT_EDITOR / EDITOR value: {editor}") return args def _find_editor() -> str: - for var in 'GIT_EDITOR', 'EDITOR': + for var in "GIT_EDITOR", "EDITOR": editor = os.environ.get(var) if editor is not None: return editor @@ -105,7 +105,7 @@ def _find_editor() -> str: found_path = shutil.which(fallback) if found_path and os.path.exists(found_path): return found_path - error('Could not find an editor! Set the EDITOR environment variable.') + error("Could not find an editor! Set the EDITOR environment variable.") def _blurb_template_text(*, issue: str | None, section: str | None) -> str: @@ -117,21 +117,21 @@ def _blurb_template_text(*, issue: str | None, section: str | None) -> str: # Ensure that there is a trailing space after '.. gh-issue:' to make # filling in the template easier, unless an issue number was given # through the --issue command-line flag. - issue_line = '.. gh-issue:' - without_space = f'\n{issue_line}\n' + issue_line = ".. gh-issue:" + without_space = f"\n{issue_line}\n" if without_space not in text: raise SystemExit("Can't find gh-issue line in the template!") if issue_number is None: - with_space = f'\n{issue_line} \n' + with_space = f"\n{issue_line} \n" text = text.replace(without_space, with_space) else: - with_issue_number = f'\n{issue_line} {issue_number}\n' + with_issue_number = f"\n{issue_line} {issue_number}\n" text = text.replace(without_space, with_issue_number) # Uncomment the section if needed. if section_name is not None: - pattern = f'.. section: {section_name}' - text = text.replace(f'#{pattern}', pattern) + pattern = f".. section: {section_name}" + text = text.replace(f"#{pattern}", pattern) return text @@ -141,10 +141,10 @@ def _extract_issue_number(issue: str | None, /) -> int | None: return None issue = issue.strip() - if issue.startswith(('GH-', 'gh-')): + if issue.startswith(("GH-", "gh-")): stripped = issue[3:] else: - stripped = issue.removeprefix('#') + stripped = issue.removeprefix("#") try: if stripped.isdecimal(): return int(stripped) @@ -152,15 +152,15 @@ def _extract_issue_number(issue: str | None, /) -> int | None: pass # Allow GitHub URL with or without the scheme - stripped = issue.removeprefix('https://') - stripped = stripped.removeprefix('github.com/python/cpython/issues/') + stripped = issue.removeprefix("https://") + stripped = stripped.removeprefix("github.com/python/cpython/issues/") try: if stripped.isdecimal(): return int(stripped) except ValueError: pass - raise SystemExit(f'Invalid GitHub issue number: {issue}') + raise SystemExit(f"Invalid GitHub issue number: {issue}") def _extract_section_name(section: str | None, /) -> str | None: @@ -169,7 +169,7 @@ def _extract_section_name(section: str | None, /) -> str | None: section = section.strip() if not section: - raise SystemExit('Empty section name!') + raise SystemExit("Empty section name!") matches = [] # Try an exact or lowercase match @@ -178,14 +178,14 @@ def _extract_section_name(section: str | None, /) -> str | None: matches.append(section_name) if not matches: - section_list = '\n'.join(f'* {s}' for s in sections) + section_list = "\n".join(f"* {s}" for s in sections) raise SystemExit( - f'Invalid section name: {section!r}\n\nValid names are:\n\n{section_list}' + f"Invalid section name: {section!r}\n\nValid names are:\n\n{section_list}" ) if len(matches) > 1: - multiple_matches = ', '.join(f'* {m}' for m in sorted(matches)) - raise SystemExit(f'More than one match for {section!r}:\n\n{multiple_matches}') + multiple_matches = ", ".join(f"* {m}" for m in sorted(matches)) + raise SystemExit(f"More than one match for {section!r}:\n\n{multiple_matches}") return matches[0] @@ -193,7 +193,7 @@ def _extract_section_name(section: str | None, /) -> str | None: def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | None: subprocess.run(args) - failure = '' + failure = "" blurb = Blurbs() try: blurb.load(tmp_path) @@ -207,7 +207,7 @@ def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | Non if failure: print() - print(f'Error: {failure}') + print(f"Error: {failure}") print() return None return blurb diff --git a/src/blurb/_blurb_file.py b/src/blurb/_blurb_file.py index b0015b9..141bfa5 100644 --- a/src/blurb/_blurb_file.py +++ b/src/blurb/_blurb_file.py @@ -100,7 +100,7 @@ def parse( text: str, *, metadata: dict[str, str] | None = None, - filename: str = 'input', + filename: str = "input", ) -> None: """Parses a string. @@ -115,7 +115,7 @@ def parse( line_number = None def throw(s: str): - raise BlurbError(f'Error in {filename}:{line_number}:\n{s}') + raise BlurbError(f"Error in {filename}:{line_number}:\n{s}") def finish_entry() -> None: nonlocal body @@ -126,15 +126,15 @@ def finish_entry() -> None: if not body: throw("Blurb 'body' text must not be empty!") text = textwrap_body(body) - for naughty_prefix in ('- ', 'Issue #', 'bpo-', 'gh-', 'gh-issue-'): + 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') + no_changes = metadata.get("no changes") issue_keys = { - 'gh-issue': 'GitHub', - 'bpo': 'bpo', + "gh-issue": "GitHub", + "bpo": "bpo", } for key, value in metadata.items(): # Iterate over metadata items in order. @@ -149,25 +149,25 @@ def finish_entry() -> None: try: int(value) except (TypeError, ValueError): - throw(f'Invalid {issue_keys[key]} number: {value!r}') + throw(f"Invalid {issue_keys[key]} number: {value!r}") - if key == 'gh-issue' and int(value) < lowest_possible_gh_issue_number: + 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})' + f"Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})" ) - if key == 'section': + 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.' + f"Invalid section {value!r}! You must use one of the predefined sections." ) - if 'gh-issue' not in metadata and 'bpo' not in metadata: + 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: + if "section" not in metadata: throw("No 'section' specified. You must provide one!") self.append((metadata, text)) @@ -175,24 +175,24 @@ def finish_entry() -> None: body = [] in_metadata = True - for line_number, line in enumerate(text.split('\n')): + for line_number, line in enumerate(text.split("\n")): line = line.rstrip() if in_metadata: - if line.startswith('..'): + if line.startswith(".."): line = line[2:].strip() - name, colon, value = line.partition(':') + 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!') + throw(f"Blurb metadata sets {name!r} twice!") metadata[name] = value continue - if line.startswith('#') or not line: + if line.startswith("#") or not line: continue in_metadata = False - if line == '..': + if line == "..": finish_entry() continue body.append(line) @@ -204,7 +204,7 @@ def load(self, filename: str, *, metadata: dict[str, str] | None = None) -> None Broadly equivalent to blurb.parse(open(filename).read()). """ - with open(filename, encoding='utf-8') as file: + with open(filename, encoding="utf-8") as file: text = file.read() self.parse(text, metadata=metadata, filename=filename) @@ -214,22 +214,22 @@ def __str__(self) -> str: add_separator = False for metadata, body in self: if add_separator: - add('\n..\n\n') + 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(f".. {name}: {value}\n") + add("\n") add(textwrap_body(body)) - return ''.join(output) + 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: + with open(path, "w", encoding="utf-8") as file: file.write(text) @staticmethod @@ -238,19 +238,19 @@ def _parse_next_filename(filename: str) -> dict[str, str]: components = filename.split(os.sep) section, filename = components[-2:] section = unsanitize_section(section) - assert section in sections, f'Unknown section {section}' + assert section in sections, f"Unknown section {section}" - fields = [x.strip() for x in filename.split('.')] + 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' + assert fields[-1] == "rst" - metadata = {'date': fields[0], 'nonce': fields[-2], 'section': section} + 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}-') + for name in ("gh-issue", "bpo"): + _, got, value = field.partition(f"{name}-") if got: metadata[name] = value.strip() break @@ -268,12 +268,12 @@ def load_next(self, filename: str) -> None: def ensure_metadata(self) -> None: metadata, body = self[-1] - assert 'section' in metadata + assert "section" in metadata for name, default in ( - ('gh-issue', '0'), - ('bpo', '0'), - ('date', sortable_datetime()), - ('nonce', generate_nonce(body)), + ("gh-issue", "0"), + ("bpo", "0"), + ("date", sortable_datetime()), + ("nonce", generate_nonce(body)), ): if name not in metadata: metadata[name] = default @@ -282,18 +282,18 @@ 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["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: + 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( + 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'): + for name in ("root", "section", "date", "gh-issue", "bpo", "nonce"): del metadata[name] return path @@ -309,4 +309,4 @@ def save_next(self) -> str: def sortable_datetime() -> str: - return time.strftime('%Y-%m-%d-%H-%M-%S', time.localtime()) + return time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime()) diff --git a/src/blurb/_cli.py b/src/blurb/_cli.py index 27ac9b3..4c15f77 100644 --- a/src/blurb/_cli.py +++ b/src/blurb/_cli.py @@ -16,7 +16,7 @@ subcommands: dict[str, CommandFunc] = {} -readme_re = re.compile(r'This is \w+ version \d+\.\d+').match +readme_re = re.compile(r"This is \w+ version \d+\.\d+").match def initialise_subcommands() -> None: @@ -29,33 +29,33 @@ def initialise_subcommands() -> None: from blurb._release import release subcommands = { - 'version': version, - 'help': help, - 'add': add, - 'export': export, - 'merge': merge, - 'populate': populate, - 'release': release, + "version": version, + "help": help, + "add": add, + "export": export, + "merge": merge, + "populate": populate, + "release": release, # Make 'blurb --help/--version/-V' work. - '--help': help, - '--version': version, - '-V': version, + "--help": help, + "--version": version, + "-V": version, } def error(msg: str, /) -> NoReturn: - raise SystemExit(f'Error: {msg}') + raise SystemExit(f"Error: {msg}") def prompt(prompt: str, /) -> str: - return input(f'[{prompt}> ') + return input(f"[{prompt}> ") def require_ok(prompt: str, /) -> str: - prompt = f'[{prompt}> ' + prompt = f"[{prompt}> " while True: s = input(prompt).strip() - if s == 'ok': + if s == "ok": return s @@ -68,7 +68,7 @@ def get_subcommand(subcommand: str, /) -> CommandFunc: def version() -> None: """Print blurb version.""" - print('blurb version', blurb.__version__) + print("blurb version", blurb.__version__) def help(subcommand: str | None = None) -> None: @@ -85,7 +85,7 @@ def help(subcommand: str | None = None) -> None: fn = get_subcommand(subcommand) doc = fn.__doc__.strip() if not doc: - error(f'help is broken, no docstring for {subcommand}') + error(f"help is broken, no docstring for {subcommand}") options = [] positionals = [] @@ -95,24 +95,24 @@ def help(subcommand: str | None = None) -> None: if p.kind == inspect.Parameter.KEYWORD_ONLY: short_option = name[0] if isinstance(p.default, bool): - options.append(f' [-{short_option}|--{name}]') + options.append(f" [-{short_option}|--{name}]") else: if p.default is None: - metavar = f'{name.upper()}' + metavar = f"{name.upper()}" else: - metavar = f'{name.upper()}[={p.default}]' - options.append(f' [-{short_option}|--{name} {metavar}]') + metavar = f"{name.upper()}[={p.default}]" + options.append(f" [-{short_option}|--{name} {metavar}]") elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - positionals.append(' ') + positionals.append(" ") has_default = p.default != inspect._empty if has_default: - positionals.append('[') + positionals.append("[") nesting += 1 - positionals.append(f'<{name}>') - positionals.append(']' * nesting) + positionals.append(f"<{name}>") + positionals.append("]" * nesting) - parameters = ''.join(options + positionals) - print(f'blurb {subcommand}{parameters}') + parameters = "".join(options + positionals) + print(f"blurb {subcommand}{parameters}") print() print(doc) raise SystemExit(0) @@ -121,35 +121,35 @@ def help(subcommand: str | None = None) -> None: def _blurb_help() -> None: """Print default help for blurb.""" - print('blurb version', blurb.__version__) + print("blurb version", blurb.__version__) print() - print('Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.') + print("Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.") print() - print('Usage:') - print(' blurb [subcommand] [options...]') + print("Usage:") + print(" blurb [subcommand] [options...]") print() # print list of subcommands summaries = [] longest_name_len = -1 for name, fn in subcommands.items(): - if name.startswith('-'): + if name.startswith("-"): continue longest_name_len = max(longest_name_len, len(name)) if not fn.__doc__: - error(f'help is broken, no docstring for {fn.__name__}') - fields = fn.__doc__.lstrip().split('\n') + error(f"help is broken, no docstring for {fn.__name__}") + fields = fn.__doc__.lstrip().split("\n") if not fields: - first_line = '(no help available)' + first_line = "(no help available)" else: first_line = fields[0] summaries.append((name, first_line)) summaries.sort() - print('Available subcommands:') + print("Available subcommands:") print() for name, summary in summaries: - print(' ', name.ljust(longest_name_len), ' ', summary) + print(" ", name.ljust(longest_name_len), " ", summary) print() print("If blurb is run without any arguments, this is equivalent to 'blurb add'.") @@ -159,10 +159,10 @@ def main() -> None: args = sys.argv[1:] if not args: - args = ['add'] - elif args[0] == '-h': + args = ["add"] + elif args[0] == "-h": # slight hack - args[0] = 'help' + args[0] = "help" subcommand = args[0] args = args[1:] @@ -190,8 +190,8 @@ def main() -> None: if p.kind == inspect.Parameter.KEYWORD_ONLY: if p.default is not None and not isinstance(p.default, (bool, str)): raise SystemExit( - 'blurb command-line processing cannot handle ' - f'options of type {type(p.default).__qualname__}' + "blurb command-line processing cannot handle " + f"options of type {type(p.default).__qualname__}" ) kwargs[name] = p.default @@ -222,10 +222,10 @@ def handle_option(s, dict): if done_with_options: filtered_args.append(a) continue - if a.startswith('-'): - if a == '--': + if a.startswith("-"): + if a == "--": done_with_options = True - elif a.startswith('--'): + elif a.startswith("--"): handle_option(a[2:], long_options) else: for s in a[1:]: @@ -235,8 +235,8 @@ def handle_option(s, dict): if consume_after: raise SystemExit( - f'Error: blurb: {subcommand} {consume_after} ' - 'must be followed by an option argument' + f"Error: blurb: {subcommand} {consume_after} " + "must be followed by an option argument" ) raise SystemExit(fn(*filtered_args, **kwargs)) @@ -256,27 +256,27 @@ def handle_option(s, dict): # whoops, must be a real type error, reraise raise e - how_many = f'{specified} argument' + how_many = f"{specified} argument" if specified != 1: - how_many += 's' + how_many += "s" if total == 0: - middle = 'accepts no arguments' + middle = "accepts no arguments" else: if total == required: - middle = 'requires' + middle = "requires" else: - plural = '' if required == 1 else 's' - middle = f'requires at least {required} argument{plural} and at most' - middle += f' {total} argument' + plural = "" if required == 1 else "s" + middle = f"requires at least {required} argument{plural} and at most" + middle += f" {total} argument" if total != 1: - middle += 's' + middle += "s" print( - f'Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}.' + f"Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}." ) print() - print('usage: ', end='') + print("usage: ", end="") help(subcommand) @@ -287,7 +287,7 @@ def chdir_to_repo_root() -> str: # we intentionally start in a (probably nonexistant) subtree # the first thing the while loop does is .., basically - path = os.path.abspath('garglemox') + path = os.path.abspath("garglemox") while True: next_path = os.path.dirname(path) if next_path == path: @@ -299,23 +299,23 @@ def chdir_to_repo_root() -> str: def test_first_line(filename, test): if not os.path.exists(filename): return False - with open(filename, encoding='utf-8') as file: - lines = file.read().split('\n') + with open(filename, encoding="utf-8") as file: + lines = file.read().split("\n") if not (lines and test(lines[0])): return False return True if not ( - test_first_line('README', readme_re) - or test_first_line('README.rst', readme_re) + test_first_line("README", readme_re) + or test_first_line("README.rst", readme_re) ): continue - if not test_first_line('LICENSE', 'A. HISTORY OF THE SOFTWARE'.__eq__): + if not test_first_line("LICENSE", "A. HISTORY OF THE SOFTWARE".__eq__): continue - if not os.path.exists('Include/Python.h'): + if not os.path.exists("Include/Python.h"): continue - if not os.path.exists('Python/ceval.c'): + if not os.path.exists("Python/ceval.c"): continue break diff --git a/src/blurb/_export.py b/src/blurb/_export.py index 73328ea..732d99e 100644 --- a/src/blurb/_export.py +++ b/src/blurb/_export.py @@ -6,5 +6,5 @@ def export() -> None: """Removes blurb data files, for building release tarballs/installers.""" - os.chdir('Misc') - shutil.rmtree('NEWS.d', ignore_errors=True) + os.chdir("Misc") + shutil.rmtree("NEWS.d", ignore_errors=True) diff --git a/src/blurb/_git.py b/src/blurb/_git.py index 4ee1c01..52ed75d 100644 --- a/src/blurb/_git.py +++ b/src/blurb/_git.py @@ -10,7 +10,7 @@ def flush_git_add_files() -> None: if not git_add_files: return - args = ('git', 'add', '--force', *git_add_files) + args = ("git", "add", "--force", *git_add_files) subprocess.run(args, check=True) git_add_files.clear() @@ -18,7 +18,7 @@ def flush_git_add_files() -> None: def flush_git_rm_files() -> None: if not git_rm_files: return - args = ('git', 'rm', '--quiet', '--force', *git_rm_files) + args = ("git", "rm", "--quiet", "--force", *git_rm_files) subprocess.run(args, check=False) # clean up diff --git a/src/blurb/_merge.py b/src/blurb/_merge.py index b18f483..a48bf8b 100644 --- a/src/blurb/_merge.py +++ b/src/blurb/_merge.py @@ -25,15 +25,15 @@ def merge(output: str | None = None, *, forced: bool = False) -> None: if output: output = os.path.join(original_dir, output) else: - output = 'Misc/NEWS' + output = "Misc/NEWS" versions = glob_versions() if not versions: sys.exit("You literally don't have ANY blurbs to merge together!") if os.path.exists(output) and not forced: - print(f'You already have a {output!r} file.') - require_ok('Type ok to overwrite') + print(f"You already have a {output!r} file.") + require_ok("Type ok to overwrite") write_news(output, versions=versions) @@ -41,7 +41,7 @@ def merge(output: str | None = None, *, forced: bool = False) -> None: def write_news(output: str, *, versions: list[str]) -> None: buff = [] - def prnt(msg: str = '', /): + def prnt(msg: str = "", /): buff.append(msg) prnt( @@ -57,15 +57,15 @@ def prnt(msg: str = '', /): filenames = glob_blurbs(version) blurbs = Blurbs() - if version == 'next': + if version == "next": for filename in filenames: - if os.path.basename(filename) == 'README.rst': + if os.path.basename(filename) == "README.rst": continue blurbs.load_next(filename) if not blurbs: continue metadata = blurbs[0][0] - metadata['release date'] = 'XXXX-XX-XX' + metadata["release date"] = "XXXX-XX-XX" else: assert len(filenames) == 1 blurbs.load(filenames[0]) @@ -73,52 +73,52 @@ def prnt(msg: str = '', /): header = f"What's New in Python {printable_version(version)}?" prnt() prnt(header) - prnt('=' * len(header)) + prnt("=" * len(header)) prnt() metadata, body = blurbs[0] - release_date = metadata['release date'] + release_date = metadata["release date"] - prnt(f'*Release date: {release_date}*') + prnt(f"*Release date: {release_date}*") prnt() - if 'no changes' in metadata: + if "no changes" in metadata: prnt(body) prnt() continue last_section = None for metadata, body in blurbs: - section = metadata['section'] + section = metadata["section"] if last_section != section: last_section = section prnt(section) - prnt('-' * len(section)) + prnt("-" * len(section)) prnt() - if metadata.get('gh-issue'): - issue_number = metadata['gh-issue'] + if metadata.get("gh-issue"): + issue_number = metadata["gh-issue"] if int(issue_number): - body = f'gh-{issue_number}: {body}' - elif metadata.get('bpo'): - issue_number = metadata['bpo'] + body = f"gh-{issue_number}: {body}" + elif metadata.get("bpo"): + issue_number = metadata["bpo"] if int(issue_number): - body = f'bpo-{issue_number}: {body}' + body = f"bpo-{issue_number}: {body}" - body = f'- {body}' - text = textwrap_body(body, subsequent_indent=' ') + body = f"- {body}" + text = textwrap_body(body, subsequent_indent=" ") prnt(text) prnt() - prnt('**(For information about older versions, consult the HISTORY file.)**') + prnt("**(For information about older versions, consult the HISTORY file.)**") - new_contents = '\n'.join(buff) + new_contents = "\n".join(buff) # Only write in `output` if the contents are different # This speeds up subsequent Sphinx builds try: - previous_contents = Path(output).read_text(encoding='utf-8') + previous_contents = Path(output).read_text(encoding="utf-8") except (FileNotFoundError, UnicodeError): previous_contents = None if new_contents != previous_contents: - Path(output).write_text(new_contents, encoding='utf-8') + Path(output).write_text(new_contents, encoding="utf-8") else: - print(output, 'is already up to date') + print(output, "is already up to date") diff --git a/src/blurb/_populate.py b/src/blurb/_populate.py index 10fde1e..049168f 100644 --- a/src/blurb/_populate.py +++ b/src/blurb/_populate.py @@ -8,17 +8,17 @@ def populate() -> None: """Creates and populates the Misc/NEWS.d directory tree.""" - os.chdir('Misc') - os.makedirs('NEWS.d/next', exist_ok=True) + os.chdir("Misc") + os.makedirs("NEWS.d/next", exist_ok=True) for section in sections: dir_name = sanitize_section(section) - dir_path = f'NEWS.d/next/{dir_name}' + dir_path = f"NEWS.d/next/{dir_name}" os.makedirs(dir_path, exist_ok=True) - readme_path = f'NEWS.d/next/{dir_name}/README.rst' - with open(readme_path, 'w', encoding='utf-8') as readme: + readme_path = f"NEWS.d/next/{dir_name}/README.rst" + with open(readme_path, "w", encoding="utf-8") as readme: readme.write( - f'Put news entry ``blurb`` files for the *{section}* section in this directory.\n' + f"Put news entry ``blurb`` files for the *{section}* section in this directory.\n" ) git_add_files.append(dir_path) git_add_files.append(readme_path) diff --git a/src/blurb/_release.py b/src/blurb/_release.py index 148d56f..964f297 100644 --- a/src/blurb/_release.py +++ b/src/blurb/_release.py @@ -21,7 +21,7 @@ def release(version: str) -> None: This is used by the release manager when cutting a new release. """ - if version == '.': + if version == ".": # harvest version number from dirname of repo # I remind you, we're in the Misc subdir right now version = os.path.basename(blurb._blurb_file.root) @@ -32,20 +32,20 @@ def release(version: str) -> None: "Sorry, can't handle appending 'next' files to an existing version (yet)." ) - output = f'Misc/NEWS.d/{version}.rst' - filenames = glob_blurbs('next') + output = f"Misc/NEWS.d/{version}.rst" + filenames = glob_blurbs("next") blurbs = Blurbs() date = current_date() if not filenames: - print(f'No blurbs found. Setting {version} as having no changes.') - body = f'There were no new changes in version {version}.\n' + print(f"No blurbs found. Setting {version} as having no changes.") + body = f"There were no new changes in version {version}.\n" metadata = { - 'no changes': 'True', - 'gh-issue': '0', - 'section': 'Library', - 'date': date, - 'nonce': generate_nonce(body), + "no changes": "True", + "gh-issue": "0", + "section": "Library", + "date": date, + "nonce": generate_nonce(body), } blurbs.append((metadata, body)) else: @@ -53,14 +53,14 @@ def release(version: str) -> None: print(f'Merging {count} blurbs to "{output}".') for filename in filenames: - if not filename.endswith('.rst'): + if not filename.endswith(".rst"): continue blurbs.load_next(filename) metadata = blurbs[0][0] - metadata['release date'] = date - print('Saving.') + metadata["release date"] = date + print("Saving.") blurbs.save(output) git_add_files.append(output) @@ -77,8 +77,8 @@ def release(version: str) -> None: assert blurbs2 == blurbs, f"Reloading {output} isn't reproducible?!" print() - print('Ready for commit.') + print("Ready for commit.") def current_date() -> str: - return time.strftime('%Y-%m-%d', time.localtime()) + return time.strftime("%Y-%m-%d", time.localtime()) diff --git a/src/blurb/_template.py b/src/blurb/_template.py index 36429d7..166def0 100644 --- a/src/blurb/_template.py +++ b/src/blurb/_template.py @@ -36,22 +36,22 @@ """.lstrip() sections: list[str] = [] -for line in template.split('\n'): +for line in template.split("\n"): line = line.strip() - prefix, found, section = line.partition('#.. section: ') + prefix, found, section = line.partition("#.. section: ") if found and not prefix: sections.append(section.strip()) _sanitize_section = { - 'C API': 'C_API', - 'Core and Builtins': 'Core_and_Builtins', - 'Tools/Demos': 'Tools-Demos', + "C API": "C_API", + "Core and Builtins": "Core_and_Builtins", + "Tools/Demos": "Tools-Demos", } _unsanitize_section = { - 'C_API': 'C API', - 'Core_and_Builtins': 'Core and Builtins', - 'Tools-Demos': 'Tools/Demos', + "C_API": "C API", + "Core_and_Builtins": "Core and Builtins", + "Tools-Demos": "Tools/Demos", } @@ -68,7 +68,7 @@ def sanitize_section_legacy(section: str, /) -> str: This makes it viable as a directory name. """ - return section.replace('/', '-') + return section.replace("/", "-") def unsanitize_section(section: str, /) -> str: @@ -77,8 +77,8 @@ def unsanitize_section(section: str, /) -> str: def next_filename_unsanitize_sections(filename: str, /) -> str: for key, value in _unsanitize_section.items(): - for separator in ('/', '\\'): - key = f'{separator}{key}{separator}' - value = f'{separator}{value}{separator}' + for separator in ("/", "\\"): + key = f"{separator}{key}{separator}" + value = f"{separator}{value}{separator}" filename = filename.replace(key, value) return filename diff --git a/src/blurb/_utils/globs.py b/src/blurb/_utils/globs.py index 849d035..95967cb 100644 --- a/src/blurb/_utils/globs.py +++ b/src/blurb/_utils/globs.py @@ -13,10 +13,10 @@ def glob_blurbs(version: str) -> list[str]: filenames = [] - base = os.path.join('Misc', 'NEWS.d', version) + base = os.path.join("Misc", "NEWS.d", version) - if version != 'next': - wildcard = f'{base}.rst' + if version != "next": + wildcard = f"{base}.rst" filenames.extend(glob.glob(wildcard)) return filenames @@ -31,9 +31,9 @@ def glob_blurbs(version: str) -> list[str]: continue seen_dirs.add(dir_name) - wildcard = os.path.join(base, dir_name, '*.rst') + wildcard = os.path.join(base, dir_name, "*.rst") for entry in glob.glob(wildcard): - if not entry.endswith('/README.rst'): + if not entry.endswith("/README.rst"): entries.append(entry) entries.sort(reverse=True, key=next_filename_unsanitize_sections) diff --git a/src/blurb/_utils/text.py b/src/blurb/_utils/text.py index 39c0399..8c584bf 100644 --- a/src/blurb/_utils/text.py +++ b/src/blurb/_utils/text.py @@ -10,7 +10,7 @@ from collections.abc import Iterable -def textwrap_body(body: str | Iterable[str], *, subsequent_indent: str = '') -> str: +def textwrap_body(body: str | Iterable[str], *, subsequent_indent: str = "") -> str: """Wrap body text. Accepts either a string or an iterable of strings. @@ -20,37 +20,37 @@ def textwrap_body(body: str | Iterable[str], *, subsequent_indent: str = '') -> if isinstance(body, str): text = body else: - text = '\n'.join(body).rstrip() + text = "\n".join(body).rstrip() # textwrap merges paragraphs, ARGH # step 1: remove trailing whitespace from individual lines # (this means that empty lines will just have \n, no invisible whitespace) lines = [] - for line in text.split('\n'): + for line in text.split("\n"): lines.append(line.rstrip()) - text = '\n'.join(lines) + text = "\n".join(lines) # step 2: break into paragraphs and wrap those - paragraphs = text.split('\n\n') + paragraphs = text.split("\n\n") paragraphs2 = [] - kwargs: dict[str, object] = {'break_long_words': False, 'break_on_hyphens': False} + kwargs: dict[str, object] = {"break_long_words": False, "break_on_hyphens": False} if subsequent_indent: - kwargs['subsequent_indent'] = subsequent_indent + kwargs["subsequent_indent"] = subsequent_indent dont_reflow = False for paragraph in paragraphs: # don't reflow bulleted / numbered lists - dont_reflow = dont_reflow or paragraph.startswith(('* ', '1. ', '#. ')) + dont_reflow = dont_reflow or paragraph.startswith(("* ", "1. ", "#. ")) if dont_reflow: - initial = kwargs.get('initial_indent', '') - subsequent = kwargs.get('subsequent_indent', '') + initial = kwargs.get("initial_indent", "") + subsequent = kwargs.get("subsequent_indent", "") if initial or subsequent: - lines = [line.rstrip() for line in paragraph.split('\n')] + lines = [line.rstrip() for line in paragraph.split("\n")] indents = itertools.chain( itertools.repeat(initial, 1), itertools.repeat(subsequent), ) lines = [indent + line for indent, line in zip(indents, lines)] - paragraph = '\n'.join(lines) + paragraph = "\n".join(lines) paragraphs2.append(paragraph) else: # Why do we reflow the text twice? Because it can actually change @@ -88,23 +88,23 @@ def textwrap_body(body: str | Iterable[str], *, subsequent_indent: str = '') -> # twice, so it's stable, and this means occasionally it'll # convert two spaces to one space, no big deal. - paragraph = '\n'.join( + paragraph = "\n".join( textwrap.wrap(paragraph.strip(), width=76, **kwargs) ).rstrip() - paragraph = '\n'.join( + paragraph = "\n".join( textwrap.wrap(paragraph.strip(), width=76, **kwargs) ).rstrip() paragraphs2.append(paragraph) # don't reflow literal code blocks (I hope) - dont_reflow = paragraph.endswith('::') + dont_reflow = paragraph.endswith("::") if subsequent_indent: - kwargs['initial_indent'] = subsequent_indent - text = '\n\n'.join(paragraphs2).rstrip() - if not text.endswith('\n'): - text += '\n' + kwargs["initial_indent"] = subsequent_indent + text = "\n\n".join(paragraphs2).rstrip() + if not text.endswith("\n"): + text += "\n" return text def generate_nonce(body: str) -> str: - digest = hashlib.md5(body.encode('utf-8')).digest() - return base64.urlsafe_b64encode(digest)[0:6].decode('ascii') + digest = hashlib.md5(body.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest)[0:6].decode("ascii") diff --git a/src/blurb/_versions.py b/src/blurb/_versions.py index c3e03eb..74a053f 100644 --- a/src/blurb/_versions.py +++ b/src/blurb/_versions.py @@ -22,15 +22,15 @@ def __exit__(self, *args) -> None: def glob_versions() -> list[str]: versions = [] - with chdir('Misc/NEWS.d'): - for wildcard in ('2.*.rst', '3.*.rst', 'next'): - versions += [x.partition('.rst')[0] for x in glob.glob(wildcard)] + with chdir("Misc/NEWS.d"): + for wildcard in ("2.*.rst", "3.*.rst", "next"): + versions += [x.partition(".rst")[0] for x in glob.glob(wildcard)] versions.sort(key=version_key, reverse=True) return versions def version_key(element: str, /) -> str: - fields = list(element.split('.')) + fields = list(element.split(".")) if len(fields) == 1: return element @@ -39,31 +39,31 @@ def version_key(element: str, /) -> str: # so for sorting purposes we transform # "3.5." and "3.5.0" into "3.5.0zz0" last = fields.pop() - for s in ('a', 'b', 'rc'): + for s in ("a", "b", "rc"): if s in last: last, stage, stage_version = last.partition(s) break else: - stage = 'zz' - stage_version = '0' + stage = "zz" + stage_version = "0" fields.append(last) while len(fields) < 3: - fields.append('0') + fields.append("0") fields.extend([stage, stage_version]) - fields = [s.rjust(6, '0') for s in fields] + fields = [s.rjust(6, "0") for s in fields] - return '.'.join(fields) + return ".".join(fields) def printable_version(version: str, /) -> str: - if version == 'next': + if version == "next": return version - if 'a' in version: - return version.replace('a', ' alpha ') - if 'b' in version: - return version.replace('b', ' beta ') - if 'rc' in version: - return version.replace('rc', ' release candidate ') - return version + ' final' + if "a" in version: + return version.replace("a", " alpha ") + if "b" in version: + return version.replace("b", " beta ") + if "rc" in version: + return version.replace("rc", " release candidate ") + return version + " final" diff --git a/tests/test_add.py b/tests/test_add.py index 23eb404..a650fe0 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -16,31 +16,31 @@ def test_valid_no_issue_number(): assert _extract_issue_number(None) is None res = _blurb_template_text(issue=None, section=None) lines = frozenset(res.splitlines()) - assert '.. gh-issue:' not in lines - assert '.. gh-issue: ' in lines + assert ".. gh-issue:" not in lines + assert ".. gh-issue: " in lines @pytest.mark.parametrize( - 'issue', + "issue", ( # issue given by their number - '12345', - ' 12345 ', + "12345", + " 12345 ", # issue given by their number and a 'GH-' prefix - 'GH-12345', - ' GH-12345 ', + "GH-12345", + " GH-12345 ", # issue given by their number and a 'gh-' prefix - 'gh-12345', - ' gh-12345 ', + "gh-12345", + " gh-12345 ", # issue given by their number and a '#' prefix - '#12345', - ' #12345 ', + "#12345", + " #12345 ", # issue given by their URL (no scheme) - 'github.com/python/cpython/issues/12345', - ' github.com/python/cpython/issues/12345 ', + "github.com/python/cpython/issues/12345", + " github.com/python/cpython/issues/12345 ", # issue given by their URL (with scheme) - 'https://github.com/python/cpython/issues/12345', - ' https://github.com/python/cpython/issues/12345 ', + "https://github.com/python/cpython/issues/12345", + " https://github.com/python/cpython/issues/12345 ", ), ) def test_valid_issue_number_12345(issue): @@ -49,58 +49,58 @@ def test_valid_issue_number_12345(issue): res = _blurb_template_text(issue=issue, section=None) lines = frozenset(res.splitlines()) - assert '.. gh-issue:' not in lines - assert '.. gh-issue: ' not in lines - assert '.. gh-issue: 12345' in lines + assert ".. gh-issue:" not in lines + assert ".. gh-issue: " not in lines + assert ".. gh-issue: 12345" in lines @pytest.mark.parametrize( - 'issue', + "issue", ( - '', - 'abc', - 'Gh-123', - 'gh-abc', - 'gh- 123', - 'gh -123', - 'gh-', - 'bpo-', - 'bpo-12345', - 'github.com/python/cpython/issues', - 'github.com/python/cpython/issues/', - 'github.com/python/cpython/issues/abc', - 'github.com/python/cpython/issues/gh-abc', - 'github.com/python/cpython/issues/gh-123', - 'github.com/python/cpython/issues/1234?param=1', - 'https://github.com/python/cpython/issues', - 'https://github.com/python/cpython/issues/', - 'https://github.com/python/cpython/issues/abc', - 'https://github.com/python/cpython/issues/gh-abc', - 'https://github.com/python/cpython/issues/gh-123', - 'https://github.com/python/cpython/issues/1234?param=1', + "", + "abc", + "Gh-123", + "gh-abc", + "gh- 123", + "gh -123", + "gh-", + "bpo-", + "bpo-12345", + "github.com/python/cpython/issues", + "github.com/python/cpython/issues/", + "github.com/python/cpython/issues/abc", + "github.com/python/cpython/issues/gh-abc", + "github.com/python/cpython/issues/gh-123", + "github.com/python/cpython/issues/1234?param=1", + "https://github.com/python/cpython/issues", + "https://github.com/python/cpython/issues/", + "https://github.com/python/cpython/issues/abc", + "https://github.com/python/cpython/issues/gh-abc", + "https://github.com/python/cpython/issues/gh-123", + "https://github.com/python/cpython/issues/1234?param=1", ), ) def test_invalid_issue_number(issue): - error_message = re.escape(f'Invalid GitHub issue number: {issue}') + error_message = re.escape(f"Invalid GitHub issue number: {issue}") with pytest.raises(SystemExit, match=error_message): _blurb_template_text(issue=issue, section=None) @pytest.mark.parametrize( - 'invalid', + "invalid", ( - 'gh-issue: ', - 'gh-issue: 1', - 'gh-issue', + "gh-issue: ", + "gh-issue: 1", + "gh-issue", ), ) def test_malformed_gh_issue_line(invalid, monkeypatch): - template = blurb_template.replace('.. gh-issue:', invalid) + template = blurb_template.replace(".. gh-issue:", invalid) error_message = re.escape("Can't find gh-issue line in the template!") with monkeypatch.context() as cm: - cm.setattr(blurb._add, 'template', template) + cm.setattr(blurb._add, "template", template) with pytest.raises(SystemExit, match=error_message): - _blurb_template_text(issue='1234', section=None) + _blurb_template_text(issue="1234", section=None) def _check_section_name(section_name, expected): @@ -111,14 +111,14 @@ def _check_section_name(section_name, expected): res = res.splitlines() for section_name in SECTIONS: if section_name == expected: - assert f'.. section: {section_name}' in res + assert f".. section: {section_name}" in res else: - assert f'#.. section: {section_name}' in res - assert f'.. section: {section_name}' not in res + assert f"#.. section: {section_name}" in res + assert f".. section: {section_name}" not in res @pytest.mark.parametrize( - ('section_name', 'expected'), + ("section_name", "expected"), [(name, name) for name in SECTIONS], ) def test_exact_names(section_name, expected): @@ -126,7 +126,7 @@ def test_exact_names(section_name, expected): @pytest.mark.parametrize( - ('section_name', 'expected'), + ("section_name", "expected"), [(name.lower(), name) for name in SECTIONS], ) def test_exact_names_lowercase(section_name, expected): @@ -134,18 +134,18 @@ def test_exact_names_lowercase(section_name, expected): @pytest.mark.parametrize( - 'section', + "section", ( - '', - ' ', - '\t', - '\n', - '\r\n', - ' ', + "", + " ", + "\t", + "\n", + "\r\n", + " ", ), ) def test_empty_section_name(section): - error_message = re.escape('Empty section name!') + error_message = re.escape("Empty section name!") with pytest.raises(SystemExit, match=error_message): _extract_section_name(section) @@ -154,26 +154,26 @@ def test_empty_section_name(section): @pytest.mark.parametrize( - 'section', + "section", [ # Wrong capitalisation - 'C api', - 'c API', - 'LibrarY', + "C api", + "c API", + "LibrarY", # Invalid - '_', - '-', - '/', - 'invalid', - 'Not a section', + "_", + "-", + "/", + "invalid", + "Not a section", # Non-special names - 'c?api', - 'cXapi', - 'C+API', + "c?api", + "cXapi", + "C+API", # Super-strings - 'Library and more', - 'library3', - 'librari', + "Library and more", + "library3", + "librari", ], ) def test_invalid_section_name(section): diff --git a/tests/test_blurb_file.py b/tests/test_blurb_file.py index fccfcb4..153b55e 100644 --- a/tests/test_blurb_file.py +++ b/tests/test_blurb_file.py @@ -6,37 +6,37 @@ @pytest.mark.parametrize( - 'news_entry, expected_section', + "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/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-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/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/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-77777.3SN8Bs.rst", + "C API", ), ( - 'Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-88888.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') + fs.create_file(news_entry, contents="testing") blurbs = Blurbs() # Act @@ -44,34 +44,34 @@ def test_load_next(news_entry, expected_section, fs): # Assert metadata = blurbs[0][0] - assert metadata['section'] == expected_section + assert metadata["section"] == expected_section @pytest.mark.parametrize( - 'news_entry, expected_path', + "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/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/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/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', + "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') + monkeypatch.setattr(blurb._blurb_file, "root", "root") + fs.create_file(news_entry, contents="testing") blurbs = Blurbs() blurbs.load_next(news_entry) @@ -84,7 +84,7 @@ def test_extract_next_filename(news_entry, expected_path, fs, monkeypatch): def test_parse(): # Arrange - contents = '.. gh-issue: 123456\n.. section: IDLE\nHello world!' + contents = ".. gh-issue: 123456\n.. section: IDLE\nHello world!" blurbs = Blurbs() # Act @@ -92,48 +92,48 @@ def test_parse(): # Assert metadata, body = blurbs[0] - assert metadata['gh-issue'] == '123456' - assert metadata['section'] == 'IDLE' - assert body == 'Hello world!\n' + assert metadata["gh-issue"] == "123456" + assert metadata["section"] == "IDLE" + assert body == "Hello world!\n" @pytest.mark.parametrize( - 'contents, expected_error', + "contents, expected_error", ( ( - '', + "", r"Blurb 'body' text must not be empty!", ), ( - 'gh-issue: Hello world!', + "gh-issue: Hello world!", r"Blurb 'body' can't start with 'gh-'!", ), ( - '.. gh-issue: 1\n.. section: IDLE\nHello world!', + ".. 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!', + ".. bpo: one-two\n.. section: IDLE\nHello world!", r"Invalid bpo number: 'one-two'", ), ( - '.. gh-issue: one-two\n.. section: IDLE\nHello world!', + ".. gh-issue: one-two\n.. section: IDLE\nHello world!", r"Invalid GitHub number: 'one-two'", ), ( - '.. gh-issue: 123456\n.. section: Funky Kong\nHello world!', + ".. 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!', + ".. gh-issue: 123456\nHello world!", r"No 'section' specified. You must provide one!", ), ( - '.. gh-issue: 123456\n.. section: IDLE\n.. section: IDLE\nHello world!', + ".. gh-issue: 123456\n.. section: IDLE\n.. section: IDLE\nHello world!", r"Blurb metadata sets 'section' twice!", ), ( - '.. section: IDLE\nHello world!', + ".. section: IDLE\nHello world!", r"'gh-issue:' or 'bpo:' must be specified in the metadata!", ), ), @@ -147,6 +147,6 @@ def test_parse_no_body(contents, expected_error): blurbs.parse(contents) -@time_machine.travel('2025-01-07 16:28:41') +@time_machine.travel("2025-01-07 16:28:41") def test_sortable_datetime(): - assert sortable_datetime() == '2025-01-07-16-28-41' + assert sortable_datetime() == "2025-01-07-16-28-41" diff --git a/tests/test_cli.py b/tests/test_cli.py index d70b615..334fc0e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,4 +7,4 @@ def test_version(capfd): # Assert captured = capfd.readouterr() - assert captured.out.startswith('blurb version ') + assert captured.out.startswith("blurb version ") diff --git a/tests/test_parser.py b/tests/test_parser.py index 1b063e3..cc1587f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -8,28 +8,28 @@ class TestParserPasses: - directory = 'tests/pass' + directory = "tests/pass" def filename_test(self, filename): b = Blurbs() b.load(filename) assert b - if os.path.exists(filename + '.res'): - with open(filename + '.res', encoding='utf-8') as file: + if os.path.exists(filename + ".res"): + with open(filename + ".res", encoding="utf-8") as file: expected = file.read() assert str(b) == expected def test_files(self): with chdir(self.directory): - for filename in glob.glob('*'): - if filename.endswith('.res'): + for filename in glob.glob("*"): + if filename.endswith(".res"): assert os.path.exists(filename[:-4]), filename continue self.filename_test(filename) class TestParserFailures(TestParserPasses): - directory = 'tests/fail' + directory = "tests/fail" def filename_test(self, filename): b = Blurbs() diff --git a/tests/test_release.py b/tests/test_release.py index 3c09dcc..3b4d25b 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -3,6 +3,6 @@ from blurb._release import current_date -@time_machine.travel('2025-01-07') +@time_machine.travel("2025-01-07") def test_current_date(): - assert current_date() == '2025-01-07' + assert current_date() == "2025-01-07" diff --git a/tests/test_template.py b/tests/test_template.py index 7ba9bb5..b70ce77 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -3,37 +3,37 @@ import blurb._template from blurb._template import sanitize_section, unsanitize_section -UNCHANGED_SECTIONS = ('Library',) +UNCHANGED_SECTIONS = ("Library",) def test_section_names(): assert tuple(blurb._template.sections) == ( - 'Security', - 'Core and Builtins', - 'Library', - 'Documentation', - 'Tests', - 'Build', - 'Windows', - 'macOS', - 'IDLE', - 'Tools/Demos', - 'C API', + "Security", + "Core and Builtins", + "Library", + "Documentation", + "Tests", + "Build", + "Windows", + "macOS", + "IDLE", + "Tools/Demos", + "C API", ) -@pytest.mark.parametrize('section', UNCHANGED_SECTIONS) +@pytest.mark.parametrize("section", UNCHANGED_SECTIONS) def test_sanitize_section_no_change(section): sanitized = sanitize_section(section) assert sanitized == section @pytest.mark.parametrize( - 'section, expected', + "section, expected", ( - ('C API', 'C_API'), - ('Core and Builtins', 'Core_and_Builtins'), - ('Tools/Demos', 'Tools-Demos'), + ("C API", "C_API"), + ("Core and Builtins", "Core_and_Builtins"), + ("Tools/Demos", "Tools-Demos"), ), ) def test_sanitize_section_changed(section, expected): @@ -41,15 +41,15 @@ def test_sanitize_section_changed(section, expected): assert sanitized == expected -@pytest.mark.parametrize('section', UNCHANGED_SECTIONS) +@pytest.mark.parametrize("section", UNCHANGED_SECTIONS) def test_unsanitize_section_no_change(section): unsanitized = unsanitize_section(section) assert unsanitized == section @pytest.mark.parametrize( - 'section, expected', - (('Tools-Demos', 'Tools/Demos'),), + "section, expected", + (("Tools-Demos", "Tools/Demos"),), ) def test_unsanitize_section_changed(section, expected): unsanitized = unsanitize_section(section) diff --git a/tests/test_utils_globs.py b/tests/test_utils_globs.py index aca5f84..455959b 100644 --- a/tests/test_utils_globs.py +++ b/tests/test_utils_globs.py @@ -4,22 +4,22 @@ def test_glob_blurbs_next(fs) -> None: # Arrange fake_news_entries = ( - 'Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-11111.pC7gnM.rst', - 'Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-33333.Pf_BI7.rst', - 'Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-44444.2F1Byz.rst', - 'Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst', + "Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-11111.pC7gnM.rst", + "Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-33333.Pf_BI7.rst", + "Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-44444.2F1Byz.rst", + "Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst", ) fake_readmes = ( - 'Misc/NEWS.d/next/Library/README.rst', - 'Misc/NEWS.d/next/Core and Builtins/README.rst', - 'Misc/NEWS.d/next/Tools-Demos/README.rst', - 'Misc/NEWS.d/next/C API/README.rst', + "Misc/NEWS.d/next/Library/README.rst", + "Misc/NEWS.d/next/Core and Builtins/README.rst", + "Misc/NEWS.d/next/Tools-Demos/README.rst", + "Misc/NEWS.d/next/C API/README.rst", ) for fn in fake_news_entries + fake_readmes: fs.create_file(fn) # Act - filenames = glob_blurbs('next') + filenames = glob_blurbs("next") # Assert assert set(filenames) == set(fake_news_entries) @@ -32,29 +32,29 @@ def test_glob_blurbs_sort_order(fs) -> None: """ # Arrange fake_news_entries = ( - 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-01-00.gh-issue-33331.Pf_BI1.rst', - 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-02-00.gh-issue-33332.Pf_BI2.rst', - 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-03-00.gh-issue-33333.Pf_BI3.rst', - 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-04-00.gh-issue-33334.Pf_BI4.rst', + "Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-01-00.gh-issue-33331.Pf_BI1.rst", + "Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-02-00.gh-issue-33332.Pf_BI2.rst", + "Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-03-00.gh-issue-33333.Pf_BI3.rst", + "Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-04-00.gh-issue-33334.Pf_BI4.rst", ) # As fake_news_entries, but reverse sorted by *filename* only expected = [ - 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-04-00.gh-issue-33334.Pf_BI4.rst', - 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-03-00.gh-issue-33333.Pf_BI3.rst', - 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-02-00.gh-issue-33332.Pf_BI2.rst', - 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-01-00.gh-issue-33331.Pf_BI1.rst', + "Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-04-00.gh-issue-33334.Pf_BI4.rst", + "Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-03-00.gh-issue-33333.Pf_BI3.rst", + "Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-02-00.gh-issue-33332.Pf_BI2.rst", + "Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-01-00.gh-issue-33331.Pf_BI1.rst", ] fake_readmes = ( - 'Misc/NEWS.d/next/Library/README.rst', - 'Misc/NEWS.d/next/Core and Builtins/README.rst', - 'Misc/NEWS.d/next/Tools-Demos/README.rst', - 'Misc/NEWS.d/next/C API/README.rst', + "Misc/NEWS.d/next/Library/README.rst", + "Misc/NEWS.d/next/Core and Builtins/README.rst", + "Misc/NEWS.d/next/Tools-Demos/README.rst", + "Misc/NEWS.d/next/C API/README.rst", ) for fn in fake_news_entries + fake_readmes: fs.create_file(fn) # Act - filenames = glob_blurbs('next') + filenames = glob_blurbs("next") # Assert assert filenames == expected @@ -68,23 +68,23 @@ def test_glob_blurbs_section_ordering(fs) -> None: """ # Arrange: one entry per section fake_news_entries = [ - 'Misc/NEWS.d/next/Security/2024-01-01-00-00-00.gh-issue-00000.aAAAAA.rst', - 'Misc/NEWS.d/next/Core_and_Builtins/2024-01-01-00-00-00.gh-issue-00001.bBBBBB.rst', - 'Misc/NEWS.d/next/Library/2024-01-01-00-00-00.gh-issue-00002.cCCCCC.rst', - 'Misc/NEWS.d/next/Documentation/2024-01-01-00-00-00.gh-issue-00003.dDDDDD.rst', - 'Misc/NEWS.d/next/Tests/2024-01-01-00-00-00.gh-issue-00004.eEEEEE.rst', - 'Misc/NEWS.d/next/Build/2024-01-01-00-00-00.gh-issue-00005.fFFFFF.rst', - 'Misc/NEWS.d/next/Windows/2024-01-01-00-00-00.gh-issue-00006.gGGGGG.rst', - 'Misc/NEWS.d/next/macOS/2024-01-01-00-00-00.gh-issue-00007.hHHHHH.rst', - 'Misc/NEWS.d/next/IDLE/2024-01-01-00-00-00.gh-issue-00008.iIIIII.rst', - 'Misc/NEWS.d/next/Tools-Demos/2024-01-01-00-00-00.gh-issue-00009.jJJJJJ.rst', - 'Misc/NEWS.d/next/C_API/2024-01-01-00-00-00.gh-issue-00010.kKKKKK.rst', + "Misc/NEWS.d/next/Security/2024-01-01-00-00-00.gh-issue-00000.aAAAAA.rst", + "Misc/NEWS.d/next/Core_and_Builtins/2024-01-01-00-00-00.gh-issue-00001.bBBBBB.rst", + "Misc/NEWS.d/next/Library/2024-01-01-00-00-00.gh-issue-00002.cCCCCC.rst", + "Misc/NEWS.d/next/Documentation/2024-01-01-00-00-00.gh-issue-00003.dDDDDD.rst", + "Misc/NEWS.d/next/Tests/2024-01-01-00-00-00.gh-issue-00004.eEEEEE.rst", + "Misc/NEWS.d/next/Build/2024-01-01-00-00-00.gh-issue-00005.fFFFFF.rst", + "Misc/NEWS.d/next/Windows/2024-01-01-00-00-00.gh-issue-00006.gGGGGG.rst", + "Misc/NEWS.d/next/macOS/2024-01-01-00-00-00.gh-issue-00007.hHHHHH.rst", + "Misc/NEWS.d/next/IDLE/2024-01-01-00-00-00.gh-issue-00008.iIIIII.rst", + "Misc/NEWS.d/next/Tools-Demos/2024-01-01-00-00-00.gh-issue-00009.jJJJJJ.rst", + "Misc/NEWS.d/next/C_API/2024-01-01-00-00-00.gh-issue-00010.kKKKKK.rst", ] for path in fake_news_entries: fs.create_file(path) # Act - filenames = glob_blurbs('next') + filenames = glob_blurbs("next") # Assert: must be in importance order, not alphabetical assert filenames == fake_news_entries diff --git a/tests/test_utils_text.py b/tests/test_utils_text.py index 962792c..e3f1fc0 100644 --- a/tests/test_utils_text.py +++ b/tests/test_utils_text.py @@ -4,41 +4,41 @@ @pytest.mark.parametrize( - 'body, subsequent_indent, expected', + "body, subsequent_indent, expected", ( ( - 'This is a test of the textwrap_body function with a string. It should wrap the text to 79 characters.', - '', - 'This is a test of the textwrap_body function with a string. It should wrap\n' - 'the text to 79 characters.\n', + "This is a test of the textwrap_body function with a string. It should wrap the text to 79 characters.", + "", + "This is a test of the textwrap_body function with a string. It should wrap\n" + "the text to 79 characters.\n", ), ( [ - 'This is a test of the textwrap_body function', - 'with an iterable of strings.', - 'It should wrap the text to 79 characters.', + "This is a test of the textwrap_body function", + "with an iterable of strings.", + "It should wrap the text to 79 characters.", ], - '', - 'This is a test of the textwrap_body function with an iterable of strings. It\n' - 'should wrap the text to 79 characters.\n', + "", + "This is a test of the textwrap_body function with an iterable of strings. It\n" + "should wrap the text to 79 characters.\n", ), ( - 'This is a test of the textwrap_body function with a string and subsequent indent.', - ' ', - 'This is a test of the textwrap_body function with a string and subsequent\n' - ' indent.\n', + "This is a test of the textwrap_body function with a string and subsequent indent.", + " ", + "This is a test of the textwrap_body function with a string and subsequent\n" + " indent.\n", ), ( - 'This is a test of the textwrap_body function with a bullet list and subsequent indent. The list should not be wrapped.\n' - '\n' - '* Item 1\n' - '* Item 2\n', - ' ', - 'This is a test of the textwrap_body function with a bullet list and\n' - ' subsequent indent. The list should not be wrapped.\n' - '\n' - ' * Item 1\n' - ' * Item 2\n', + "This is a test of the textwrap_body function with a bullet list and subsequent indent. The list should not be wrapped.\n" + "\n" + "* Item 1\n" + "* Item 2\n", + " ", + "This is a test of the textwrap_body function with a bullet list and\n" + " subsequent indent. The list should not be wrapped.\n" + "\n" + " * Item 1\n" + " * Item 2\n", ), ), ) diff --git a/tests/test_versions.py b/tests/test_versions.py index f625927..8f34882 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -4,18 +4,18 @@ @pytest.mark.parametrize( - 'version1, version2', + "version1, version2", ( - ('2', '3'), - ('3.5.0a1', '3.5.0b1'), - ('3.5.0a1', '3.5.0rc1'), - ('3.5.0a1', '3.5.0'), - ('3.6.0b1', '3.6.0b2'), - ('3.6.0b1', '3.6.0rc1'), - ('3.6.0b1', '3.6.0'), - ('3.7.0rc1', '3.7.0rc2'), - ('3.7.0rc1', '3.7.0'), - ('3.8', '3.8.1'), + ("2", "3"), + ("3.5.0a1", "3.5.0b1"), + ("3.5.0a1", "3.5.0rc1"), + ("3.5.0a1", "3.5.0"), + ("3.6.0b1", "3.6.0b2"), + ("3.6.0b1", "3.6.0rc1"), + ("3.6.0b1", "3.6.0"), + ("3.7.0rc1", "3.7.0rc2"), + ("3.7.0rc1", "3.7.0"), + ("3.8", "3.8.1"), ), ) def test_version_key(version1, version2): @@ -30,15 +30,15 @@ def test_version_key(version1, version2): def test_glob_versions(fs): # Arrange fake_version_blurbs = ( - 'Misc/NEWS.d/3.7.0.rst', - 'Misc/NEWS.d/3.7.0a1.rst', - 'Misc/NEWS.d/3.7.0a2.rst', - 'Misc/NEWS.d/3.7.0b1.rst', - 'Misc/NEWS.d/3.7.0b2.rst', - 'Misc/NEWS.d/3.7.0rc1.rst', - 'Misc/NEWS.d/3.7.0rc2.rst', - 'Misc/NEWS.d/3.9.0b1.rst', - 'Misc/NEWS.d/3.12.0a1.rst', + "Misc/NEWS.d/3.7.0.rst", + "Misc/NEWS.d/3.7.0a1.rst", + "Misc/NEWS.d/3.7.0a2.rst", + "Misc/NEWS.d/3.7.0b1.rst", + "Misc/NEWS.d/3.7.0b2.rst", + "Misc/NEWS.d/3.7.0rc1.rst", + "Misc/NEWS.d/3.7.0rc2.rst", + "Misc/NEWS.d/3.9.0b1.rst", + "Misc/NEWS.d/3.12.0a1.rst", ) for fn in fake_version_blurbs: fs.create_file(fn) @@ -48,27 +48,27 @@ def test_glob_versions(fs): # Assert assert versions == [ - '3.12.0a1', - '3.9.0b1', - '3.7.0', - '3.7.0rc2', - '3.7.0rc1', - '3.7.0b2', - '3.7.0b1', - '3.7.0a2', - '3.7.0a1', + "3.12.0a1", + "3.9.0b1", + "3.7.0", + "3.7.0rc2", + "3.7.0rc1", + "3.7.0b2", + "3.7.0b1", + "3.7.0a2", + "3.7.0a1", ] @pytest.mark.parametrize( - 'version, expected', + "version, expected", ( - ('next', 'next'), - ('3.12.0a1', '3.12.0 alpha 1'), - ('3.12.0b2', '3.12.0 beta 2'), - ('3.12.0rc2', '3.12.0 release candidate 2'), - ('3.12.0', '3.12.0 final'), - ('3.12.1', '3.12.1 final'), + ("next", "next"), + ("3.12.0a1", "3.12.0 alpha 1"), + ("3.12.0b2", "3.12.0 beta 2"), + ("3.12.0rc2", "3.12.0 release candidate 2"), + ("3.12.0", "3.12.0 final"), + ("3.12.1", "3.12.1 final"), ), ) def test_printable_version(version, expected):