diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index dcd3739..0000000 --- a/.coveragerc +++ /dev/null @@ -1,12 +0,0 @@ -# .coveragerc to control coverage.py - -[report] -# Regexes for lines to exclude from consideration -exclude_also = - # Don't complain if non-runnable code isn't run: - if __name__ == .__main__.: - def main - -[run] -omit = - **/blurb/__main__.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index eb36340..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,8 +0,0 @@ -# See https://help.github.com/articles/about-codeowners/ -# for more info about CODEOWNERS file - -# It uses the same pattern rule for gitignore file -# https://git-scm.com/docs/gitignore#_pattern_format - -# cherry_picker -**/*cherry_picker* @Mariatta diff --git a/.github/ISSUE_TEMPLATE/blurb.md b/.github/ISSUE_TEMPLATE/blurb.md deleted file mode 100644 index c1f5372..0000000 --- a/.github/ISSUE_TEMPLATE/blurb.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: blurb feature request/bug -about: Feature request or bug related to blurb (command line tool) ---- - - - -# The short story - -It would be nice if ... - -# Long version - -... - -Thanks for blurb! diff --git a/.github/ISSUE_TEMPLATE/core-workflow.md b/.github/ISSUE_TEMPLATE/core-workflow.md index 5cf334e..283d704 100644 --- a/.github/ISSUE_TEMPLATE/core-workflow.md +++ b/.github/ISSUE_TEMPLATE/core-workflow.md @@ -9,6 +9,7 @@ Feature-request/bug specific to GitHub bots/tools can be reported in their own r the-knights-who-say-ni: https://github.com/python/the-knights-who-say-ni bedevere: https://github.com/python/bedevere +blurb: https://github.com/python/blurb blurb-it: https://github.com/python/blurb-it miss-islington: https://github.com/python/miss-islington diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 7e764d1..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Tests - -on: [push, pull_request, workflow_dispatch] - -env: - FORCE_COLOR: 1 - -jobs: - test: - strategy: - fail-fast: false - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] - name: ${{ matrix.python-version }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - cache: pip - - - name: Install dependencies - run: | - python --version - python -m pip install --upgrade pip - python -m pip install --upgrade tox - - - name: Tox tests - run: | - cd blurb - tox -e py - - python -m pip install -e . - - - name: Upload coverage - uses: codecov/codecov-action@v3 - with: - flags: ${{ matrix.python-version }} - name: Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3aea1fd..b5e8fda 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,15 +4,9 @@ repos: hooks: - id: check-case-conflict - id: check-merge-conflict - - id: check-toml - id: check-yaml - id: debug-statements - - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.3.1 - hooks: - - id: tox-ini-fmt - - repo: meta hooks: - id: check-hooks-apply diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b49d0f --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# core-workflow + +[![Lint](https://github.com/python/core-workflow/actions/workflows/lint.yml/badge.svg)](https://github.com/python/core-workflow/actions/workflows/lint.yml) +[![Python Discourse chat](https://img.shields.io/badge/Discourse-join_chat-brightgreen.svg)](https://discuss.python.org/) + +Issue tracker and relevant tools for CPython's workflow. + +## Core workflow tools + + + + + + + + + + +
Name +Description +Issue tracker +Owner/Maintainer +
python/bedevere +Bot to help identify missing information for CPython pull requests +GitHub +Brett Cannon +
python/blurb +blurb add on the command line +GitHub + +
python/blurb_it +blurb add on the web +GitHub +Mariatta +
python/cherry-picker +Command line tool for backporting CPython pull requests +GitHub +Mariatta +
python/miss-islington +Bot for backporting CPython pull requests +GitHub +Mariatta +
ambv/cla-bot +CLA enforcement bot for Python organization projects + +Łukasz Langa +
berkerpeksag/cpython-emailer-webhook +Webhook to send every CPython commit to python-checkins mailing list +GitHub +Berker Peksag +
diff --git a/README.rst b/README.rst deleted file mode 100644 index 5b40da1..0000000 --- a/README.rst +++ /dev/null @@ -1,61 +0,0 @@ -core-workflow -============= -Issue tracker and relevant tools for CPython's workflow - -.. image:: https://github.com/python/core-workflow/actions/workflows/tests.yml/badge.svg - :alt: GitHub Actions - :target: https://github.com/python/core-workflow/actions - -.. image:: https://img.shields.io/badge/Discourse-join_chat-brightgreen.svg - :alt: Python Discourse chat - :target: https://discuss.python.org/ - -blurb ------ - -.. image:: https://img.shields.io/pypi/v/blurb.svg - :target: https://pypi.org/project/blurb/ - -Interactive utility for writing CPython ``Misc/NEWS.d`` entries. See -the blurb_ directory for more details. - -.. _blurb: https://github.com/python/core-workflow/tree/main/blurb - - -Other core workflow tools -------------------------- - -======================================= ======================= =============================================== ================ - Name Description Issue tracker Owner/Maintainer -======================================= ======================= =============================================== ================ -`python/bedevere`_ A bot to help identify `GitHub `__ - CPython pull requests. -`python/blurb_it`_ ``blurb add`` on the `GitHub `__ -`python/cherry-picker`_ Command line tool for `GitHub `__ - pull requests. -`python/miss-islington`_ A bot for backporting `GitHub `__ -`ambv/cla-bot`_ CLA enforcement bot for `Łukasz Langa`_ - Python organization - projects. -`berkerpeksag/cpython-emailer-webhook`_ A webhook to send every `GitHub `__ - python-checkins mailing - list. -======================================= ======================= =============================================== ================ - -.. _`python/bedevere`: https://github.com/python/bedevere -.. _`python/blurb_it`: https://github.com/python/blurb_it -.. _`python/cherry-picker`: https://github.com/python/cherry-picker -.. _`python/miss-islington`: https://github.com/python/miss-islington -.. _`ambv/cla-bot`: https://github.com/ambv/cla-bot -.. _`berkerpeksag/cpython-emailer-webhook`: https://github.com/berkerpeksag/cpython-emailer-webhook -.. _`Brett Cannon`: https://github.com/brettcannon -.. _`Berker Peksag`: https://github.com/berkerpeksag -.. _`Łukasz Langa`: https://github.com/ambv -.. _`Mariatta`: https://github.com/mariatta - - diff --git a/blurb/LICENSE.txt b/blurb/LICENSE.txt deleted file mode 100644 index cb9d831..0000000 --- a/blurb/LICENSE.txt +++ /dev/null @@ -1,33 +0,0 @@ -blurb version 1.0 -Part of the blurb package. -Copyright 2015-2018 by Larry Hastings - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - -1. Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright -notice, this list of conditions and the following disclaimer in the -documentation and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -Licensed to the Python Software Foundation under a contributor agreement. diff --git a/blurb/README.md b/blurb/README.md new file mode 100644 index 0000000..12159cf --- /dev/null +++ b/blurb/README.md @@ -0,0 +1,5 @@ +# blurb + +**blurb** has moved to https://github.com/python/blurb + +Please update your bookmarks. diff --git a/blurb/README.rst b/blurb/README.rst deleted file mode 100644 index 8179007..0000000 --- a/blurb/README.rst +++ /dev/null @@ -1,270 +0,0 @@ -blurb -===== - -.. image:: https://img.shields.io/pypi/v/blurb.svg - :target: https://pypi.org/project/blurb/ - -Overview --------- - -**blurb** is a tool designed to rid CPython core development -of the scourge of ``Misc/NEWS`` conflicts. - -The core concept: split ``Misc/NEWS`` into many -separate files that, when concatenated back together -in sorted order, reconstitute the original ``Misc/NEWS`` file. -After that, ``Misc/NEWS`` could be deleted from the CPython -repo and thereafter rendered on demand (e.g. when building -a release). When checking in a change to CPython, the checkin -process will write out a new file that sorts into the correct place, -using a filename unlikely to have a merge conflict. - -**blurb** is a single command with a number of subcommands. -It's designed to be run inside a valid CPython (git) repo, -and automatically uses the correct file paths. - -You can install **blurb** from PyPI using ``pip``. Alternatively, -simply add ``blurb`` to a directory on your path. -**blurb**'s only dependency is Python 3.8+. - - -Files used by blurb -------------------- - -**blurb** uses a new directory tree called ``Misc/NEWS.d``. -Everything it does is in there, except for possibly -modifying ``Misc/NEWS``. - -Under ``Misc/NEWS.d`` you'll find the following: - -* A single file for all news entries per previous revision, - named for the exact version number, with the extension ``.rst``. - Example: ``Misc/NEWS.d/3.6.0b2.rst``. - -* The ``next`` directory, which contains subdirectories representing - the various ``Misc/NEWS`` categories. Inside these subdirectories - are more ``.rst`` files with long, uninteresting, computer-generated - names. Example: - ``Misc/NEWS.d/next/Library/2017-05-04-12-24-06.gh-issue-25458.Yl4gI2.rst`` - - -blurb subcommands ------------------ - -Like many modern utilities, **blurb** has only one executable -(called ``blurb``), but provides a diverse set of functionality -through subcommands. The subcommand is the first argument specified -on the command-line. - -If you're a CPython core developer, you probably don't need to use -anything except ``blurb add``--and you don't even need to specify -the ``add`` part. -(If no subcommand is specified, **blurb** assumes you meant ``blurb add``.) -The other commands are only expected to be useful for CPython release -managers. - - - -blurb help -~~~~~~~~~~ - -**blurb** is self-documenting through the ``blurb help`` subcommand. -Run without any further arguments, it prints a list of all subcommands, -with a one-line summary of the functionality of each. Run with a -third argument, it prints help on that subcommand (e.g. ``blurb help release``). - - -blurb add -~~~~~~~~~ - -``blurb add`` adds a new Misc/NEWS entry for you. -It opens a text editor on a template; you edit the -file, save, and exit. **blurb** then stores the file -in the correct place, and stages it in ``git`` for you. - -The template for the ``blurb add`` message looks like this:: - - # - # Please enter the relevant GitHub issue number here: - # - .. gh-issue: - - # - # Uncomment one of these "section:" lines to specify which section - # this entry should go in in Misc/NEWS. - # - #.. section: Security - #.. section: Core and Builtins - #.. section: Library - #.. section: Documentation - #.. section: Tests - #.. section: Build - #.. section: Windows - #.. section: macOS - #.. section: IDLE - #.. section: Tools/Demos - #.. section: C API - - # Write your Misc/NEWS entry below. It should be a simple ReST paragraph. - # Don't start with "- Issue #: " or "- gh-issue: " or that sort of stuff. - ########################################################################### - -Here's how you interact with the file: - -* Add the GitHub issue number for this checkin to the - end of the ``.. gh-issue:`` line. - -* Uncomment the line with the relevant ``Misc/NEWS`` section for this entry. - For example, if this should go in the ``Library`` section, uncomment - the line reading ``#.. section: Library``. To uncomment, just delete - the ``#`` at the front of the line. - -* Finally, go to the end of the file, and enter your NEWS entry. - This should be a single paragraph of English text using - simple ReST markup. - -When ``blurb add`` gets a valid entry, it writes it to a file -with the following format:: - - Misc/NEWS.d/next/
/.gh-issue-..rst - -For example, a file added by ``blurb add`` might look like this:: - - Misc/NEWS.d/next/Library/2017-05-04-12-24-06.gh-issue-25458.Yl4gI2.rst - -``
`` is the section provided in the checkin message. - -```` is the current UTC time, formatted as -``YYYY-MM-DD-hh-mm-ss``. - -```` is a hopefully-unique string of characters meant to -prevent filename collisions. **blurb** creates this by computing -the MD5 hash of the text, converting it to base64 (using the -"urlsafe" alphabet), and taking the first 6 characters of that. - - -This filename ensures several things: - -* All entries in ``Misc/NEWS`` will be sorted by time. - -* It is unthinkably unlikely that there'll be a conflict - between the filenames generated for two developers checking in, - even if they check in at the exact same second. - - -Finally, ``blurb add`` stages the file in git for you. - - -blurb merge -~~~~~~~~~~~ - -``blurb merge`` recombines all the files in the -``Misc/NEWS.d`` tree back into a single ``NEWS`` file. - -``blurb merge`` accepts only a single command-line argument: -the file to write to. By default it writes to -``Misc/NEWS`` (relative to the root of your CPython checkout). - -Splitting and recombining the existing ``Misc/NEWS`` file -doesn't recreate the previous ``Misc/NEWS`` exactly. This -is because ``Misc/NEWS`` never used a consistent ordering -for the "sections" inside each release, whereas ``blurb merge`` -has a hard-coded preferred ordering for the sections. Also, -**blurb** aggressively reflows paragraphs to < 78 columns, -wheras the original hand-edited file occasionally had lines -> 80 columns. Finally, **blurb** strictly uses ``gh-issue-:`` to -specify issue numbers at the beginnings of entries, wheras -the legacy approach to ``Misc/NEWS`` required using ``Issue #:``. - - -blurb release -~~~~~~~~~~~~~ - -``blurb release`` is used by the release manager as part of -the CPython release process. It takes exactly one argument, -the name of the version being released. - -Here's what it does under the hood: - -* Combines all recently-added NEWS entries from - the ``Misc/NEWS.d/next`` directory into ``Misc/NEWS.d/.rst``. -* Runs ``blurb merge`` to produce an updated ``Misc/NEWS`` file. - -One hidden feature: if the version specified is ``.``, ``blurb release`` -uses the name of the directory CPython is checked out to. -(When making a release I generally name the directory after the -version I'm releasing, and using this shortcut saves me some typing.) - - -blurb split -~~~~~~~~~~~ - -``blurb split`` only needs to be run once per-branch, ever. -It reads in ``Misc/NEWS`` -and splits it into individual ``.rst`` files. -The text files are stored as follows:: - - Misc/NEWS.d/.rst - -```` is the version number of Python where the -change was committed. Pre-release versions are denoted -with an abbreviation: ``a`` for alphas, ``b`` for betas, -and ``rc`` for release candidates. - -The individual ``.rst`` files actually (usually) -contain multiple entries. Each entry is delimited by a -single line containing ``..`` by itself. - -The assumption is, at the point we convert over to *blurb*, -we'll run ``blurb split`` on each active branch, -remove ``Misc/NEWS`` from the repo entirely, -never run ``blurb split`` ever again, -and ride off into the sunset, confident that the world is now -a better place. - - - -The "next" directory --------------------- - -You may have noticed that ``blurb add`` adds news entries to -a directory called ``next``, and ``blurb release`` combines those -news entries into a single file named with the version. Why -is that? - -First, it makes naming the next version a late-binding decision. -If we are currently working on 3.6.5rc1, but there's a zero-day -exploit and we need to release an emergency 3.6.5 final, we don't -have to fix up a bunch of metadata. - -Second, it means that if you cherry-pick a commit forward or -backwards, you automatically pick up the NEWS entry too. You -don't need to touch anything up--the system will already do -the right thing. If NEWS entries were already written to the -final version directory, you'd have to move those around as -part of the cherry-picking process. - -Changelog ---------- - -1.1.0 -~~~~~ - -- Support GitHub Issues in addition to b.p.o (bugs.python.org). - If "gh-issue" is in the metadata, then the filename will contain "gh-issue-" instead of "bpo-". - -1.0.7 -~~~~~ - -- When word wrapping, don't break on long words or hyphens. -- Use the ``-f`` flag when adding **blurb** files to a ``git`` - checkin. This forces them to be added, even when the files - might normally be ignored based on a ``.gitignore`` directive. -- Explicitly support the ``-help`` command-line option. -- Fix Travis CI integration. - -Copyright ---------- - -**blurb** is Copyright 2015-2018 by Larry Hastings. -Licensed to the PSF under a contributor agreement. diff --git a/blurb/__main__.py b/blurb/__main__.py deleted file mode 100644 index 6aff72b..0000000 --- a/blurb/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Run blurb using `python3 blurb/`.""" -import blurb - - -if __name__ == '__main__': - blurb.main() diff --git a/blurb/blurb.py b/blurb/blurb.py deleted file mode 100755 index 3a21dab..0000000 --- a/blurb/blurb.py +++ /dev/null @@ -1,1295 +0,0 @@ -#!/usr/bin/env python3 -"""Command-line tool to manage CPython Misc/NEWS.d entries.""" -__version__ = "1.1.0" - -## -## blurb version 1.0 -## Part of the blurb package. -## Copyright 2015-2018 by Larry Hastings -## -## Redistribution and use in source and binary forms, with or without -## modification, are permitted provided that the following conditions are -## met: -## -## 1. Redistributions of source code must retain the above copyright -## notice, this list of conditions and the following disclaimer. -## -## 2. Redistributions in binary form must reproduce the above copyright -## notice, this list of conditions and the following disclaimer in the -## documentation and/or other materials provided with the distribution. -## -## 3. Neither the name of the copyright holder nor the names of its -## contributors may be used to endorse or promote products derived from -## this software without specific prior written permission. -## -## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -## IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -## TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -## PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -## HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -## TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -## PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -## LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -## NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -## SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -## -## -## Licensed to the Python Software Foundation under a contributor agreement. -## - -# TODO -# -# automatic git adds and removes - -import atexit -import base64 -import builtins -import glob -import hashlib -import io -import inspect -import itertools -import os -from pathlib import Path -import re -import shlex -import shutil -import subprocess -import sys -import tempfile -import textwrap -import time -import unittest - - -# -# This template is the canonical list of acceptable section names! -# It's parsed internally into the "sections" set. -# - -template = """ - -# -# Please enter the relevant GitHub issue number here: -# -.. gh-issue: - -# -# Uncomment one of these "section:" lines to specify which section -# this entry should go in in Misc/NEWS.d. -# -#.. section: Security -#.. section: Core and Builtins -#.. section: Library -#.. section: Documentation -#.. section: Tests -#.. section: Build -#.. section: Windows -#.. section: macOS -#.. section: IDLE -#.. section: Tools/Demos -#.. section: C API - -# Write your Misc/NEWS.d entry below. It should be a simple ReST paragraph. -# Don't start with "- Issue #: " or "- gh-issue-: " or that sort of stuff. -########################################################################### - - -""".lstrip() - -root = None -original_dir = None -sections = [] - -for line in template.split('\n'): - line = line.strip() - 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", - } - - -def sanitize_section(section): - """ - Clean up a section string, making it viable as a directory name. - """ - return _sanitize_section.get(section, section) - - -def sanitize_section_legacy(section): - """ - Clean up a section string, making it viable as a directory name (allow spaces). - """ - return section.replace("/", "-") - - -_unsanitize_section = { - "C_API": "C API", - "Core_and_Builtins": "Core and Builtins", - "Tools-Demos": "Tools/Demos", - } - - -def unsanitize_section(section): - return _unsanitize_section.get(section, section) - -def next_filename_unsanitize_sections(filename): - s = filename - for key, value in _unsanitize_section.items(): - for separator in "/\\": - key = f"{separator}{key}{separator}" - value = f"{separator}{value}{separator}" - filename = filename.replace(key, value) - return filename - - -def textwrap_body(body, *, subsequent_indent=''): - """ - Accepts either a string or an iterable of strings. - (Iterable is assumed to be individual lines.) - Returns a string. - """ - if isinstance(body, str): - text = body - else: - 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"): - lines.append(line.rstrip()) - text = "\n".join(lines) - # step 2: break into paragraphs and wrap those - paragraphs = text.split("\n\n") - paragraphs2 = [] - kwargs = {'break_long_words': False, 'break_on_hyphens': False} - if 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. ", "#. ")) - if dont_reflow: - initial = kwargs.get("initial_indent", "") - subsequent = kwargs.get("subsequent_indent", "") - if initial or subsequent: - 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) - paragraphs2.append(paragraph) - else: - # Why do we reflow the text twice? Because it can actually change - # between the first and second reflows, and we want the text to - # be stable. The problem is that textwrap.wrap is deliberately - # dumb about how many spaces follow a period in prose. - # - # We're reflowing at 76 columns, but let's pretend it's 30 for - # illustration purposes. If we give textwrap.wrap the following - # text--ignore the line of 30 dashes, that's just to help you - # with visualization: - # - # ------------------------------ - # xxxx xxxx xxxx xxxx xxxx. xxxx - # - # The first textwrap.wrap will return this: - # "xxxx xxxx xxxx xxxx xxxx.\nxxxx" - # - # If we reflow it again, textwrap will rejoin the lines, but - # only with one space after the period! So this time it'll - # all fit on one line, behold: - # ------------------------------ - # xxxx xxxx xxxx xxxx xxxx. xxxx - # and so it now returns: - # "xxxx xxxx xxxx xxxx xxxx. xxxx" - # - # textwrap.wrap supports trying to add two spaces after a peroid: - # https://docs.python.org/3/library/textwrap.html#textwrap.TextWrapper.fix_sentence_endings - # But it doesn't work all that well, because it's not smart enough - # to do a really good job. - # - # Since blurbs are eventually turned into ReST and rendered anyway, - # and since the Zen says "In the face of ambiguity, refuse the - # temptation to guess", I don't sweat it. I run textwrap.wrap - # twice, so it's stable, and this means occasionally it'll - # convert two spaces to one space, no big deal. - - paragraph = "\n".join(textwrap.wrap(paragraph.strip(), width=76, **kwargs)).rstrip() - 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("::") - if subsequent_indent: - kwargs['initial_indent'] = subsequent_indent - text = "\n\n".join(paragraphs2).rstrip() - if not text.endswith("\n"): - text += "\n" - return text - - -def current_date(): - return time.strftime("%Y-%m-%d", time.localtime()) - -def sortable_datetime(): - return time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime()) - - -def prompt(prompt): - return input(f"[{prompt}> ") - -def require_ok(prompt): - prompt = f"[{prompt}> " - while True: - s = input(prompt).strip() - if s == 'ok': - return s - -class pushd: - def __init__(self, path): - self.path = path - - def __enter__(self): - self.previous_cwd = os.getcwd() - os.chdir(self.path) - - def __exit__(self, *args): - os.chdir(self.previous_cwd) - - -def safe_mkdir(path): - if not os.path.exists(path): - os.makedirs(path) - - -def version_key(element): - fields = list(element.split(".")) - if len(fields) == 1: - return element - - # in sorted order, - # 3.5.0a1 < 3.5.0b1 < 3.5.0rc1 < 3.5.0 - # 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"): - if s in last: - last, stage, stage_version = last.partition(s) - break - else: - stage = 'zz' - stage_version = "0" - - fields.append(last) - while len(fields) < 3: - fields.append("0") - - fields.extend([stage, stage_version]) - fields = [s.rjust(6, "0") for s in fields] - - return ".".join(fields) - - -def nonceify(body): - digest = hashlib.md5(body.encode("utf-8")).digest() - return base64.urlsafe_b64encode(digest)[0:6].decode('ascii') - - -def glob_versions(): - with pushd("Misc/NEWS.d"): - versions = [] - for wildcard in ("2.*.rst", "3.*.rst", "next"): - files = [x.partition(".rst")[0] for x in glob.glob(wildcard)] - versions.extend(files) - xform = [version_key(x) for x in versions] - xform.sort(reverse=True) - versions = sorted(versions, key=version_key, reverse=True) - return versions - - -def glob_blurbs(version): - filenames = [] - base = os.path.join("Misc", "NEWS.d", version) - if version != "next": - wildcard = base + ".rst" - filenames.extend(glob.glob(wildcard)) - else: - sanitized_sections = ( - {sanitize_section(section) for section in sections} | - {sanitize_section_legacy(section) for section in sections} - ) - for section in sanitized_sections: - wildcard = os.path.join(base, section, "*.rst") - entries = glob.glob(wildcard) - deletables = [x for x in entries if x.endswith("/README.rst")] - for filename in deletables: - entries.remove(filename) - filenames.extend(entries) - filenames.sort(reverse=True, key=next_filename_unsanitize_sections) - return filenames - - -def printable_version(version): - 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" - - -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 == "gh-issue" and int(value) < lowest_possible_gh_issue_number: - throw(f"The gh-issue number must be {lowest_possible_gh_issue_number} or above, not a PR number.") - - if key in issue_keys: - try: - int(value) - except (TypeError, ValueError): - throw(f"Invalid {issue_keys[key]} issue number! ({value!r})") - - 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 not 'section' 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) - safe_mkdir(dirname) - - 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 - - -tests_run = 0 - -class TestParserPasses(unittest.TestCase): - directory = "blurb/tests/pass" - - def filename_test(self, filename): - b = Blurbs() - b.load(filename) - self.assertTrue(b) - if os.path.exists(filename + '.res'): - with open(filename + '.res', encoding='utf-8') as file: - expected = file.read() - self.assertEqual(str(b), expected) - - def test_files(self): - global tests_run - with pushd(self.directory): - for filename in glob.glob("*"): - if filename[-4:] == '.res': - self.assertTrue(os.path.exists(filename[:-4]), filename) - continue - self.filename_test(filename) - print(".", end="") - sys.stdout.flush() - tests_run += 1 - - -class TestParserFailures(TestParserPasses): - directory = "blurb/tests/fail" - - def filename_test(self, filename): - b = Blurbs() - with self.assertRaises(Exception): - b.load(filename) - - -readme_re = re.compile(r"This is \w+ version \d+\.\d+").match - -def chdir_to_repo_root(): - global root - - # find the root of the local CPython repo - # note that we can't ask git, because we might - # be in an exported directory tree! - - # we intentionally start in a (probably nonexistant) subtree - # the first thing the while loop does is .., basically - path = os.path.abspath("garglemox") - while True: - next_path = os.path.dirname(path) - if next_path == path: - sys.exit('You\'re not inside a CPython repo right now!') - path = next_path - - os.chdir(path) - - 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') - 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)): - continue - - if not test_first_line("LICENSE", "A. HISTORY OF THE SOFTWARE".__eq__): - continue - if not os.path.exists("Include/Python.h"): - continue - if not os.path.exists("Python/ceval.c"): - continue - - break - - root = path - return root - - -def error(*a): - s = " ".join(str(x) for x in a) - sys.exit("Error: " + s) - - -subcommands = {} - -def subcommand(fn): - global subcommands - name = fn.__name__ - subcommands[name] = fn - return fn - -def get_subcommand(subcommand): - fn = subcommands.get(subcommand) - if not fn: - error(f"Unknown subcommand: {subcommand}\nRun 'blurb help' for help.") - return fn - - - -@subcommand -def help(subcommand=None): - """ -Print help for subcommands. - -Prints the help text for the specified subcommand. -If subcommand is not specified, prints one-line summaries for every command. - """ - - if not subcommand: - print("blurb version", __version__) - print() - print("Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.") - print() - 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('-'): - continue - longest_name_len = max(longest_name_len, len(name)) - if not fn.__doc__: - error("help is broken, no docstring for " + fn.__name__) - fields = fn.__doc__.lstrip().split("\n") - if not fields: - first_line = "(no help available)" - else: - first_line = fields[0] - summaries.append((name, first_line)) - summaries.sort() - - print("Available subcommands:") - print() - for name, summary in summaries: - print(" ", name.ljust(longest_name_len), " ", summary) - - print() - print("If blurb is run without any arguments, this is equivalent to 'blurb add'.") - - sys.exit(0) - - fn = get_subcommand(subcommand) - doc = fn.__doc__.strip() - if not doc: - error("help is broken, no docstring for " + subcommand) - - options = [] - positionals = [] - - nesting = 0 - for name, p in inspect.signature(fn).parameters.items(): - if p.kind == inspect.Parameter.KEYWORD_ONLY: - short_option = name[0] - options.append(f" [-{short_option}|--{name}]") - elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - positionals.append(" ") - has_default = (p.default != inspect._empty) - if has_default: - positionals.append("[") - nesting += 1 - positionals.append(f"<{name}>") - positionals.append("]" * nesting) - - - parameters = "".join(options + positionals) - print(f"blurb {subcommand}{parameters}") - print() - print(doc) - sys.exit(0) - -# Make "blurb --help" work. -subcommands["--help"] = help - - -@subcommand -def test(*args): - """ -Run unit tests. Only works inside source repo, not when installed. - """ - # unittest.main doesn't work because this isn't a module - # so we'll do it ourselves - - while not (os.path.isdir(".git") and os.path.isdir("blurb")): - old_dir = os.getcwd() - os.chdir("..") - if old_dir == os.getcwd(): - # we reached the root and never found it! - sys.exit("Error: Couldn't find the root of your blurb repo!") - - print("-" * 79) - - for clsname, cls in sorted(globals().items()): - if clsname.startswith("Test") and isinstance(cls, type): - o = cls() - for fnname in sorted(dir(o)): - if fnname.startswith("test"): - fn = getattr(o, fnname) - if callable(fn): - fn() - print() - print(tests_run, "tests passed.") - - -def find_editor(): - for var in 'GIT_EDITOR', 'EDITOR': - editor = os.environ.get(var) - if editor is not None: - return editor - if sys.platform == 'win32': - fallbacks = ['notepad.exe'] - else: - fallbacks = ['/etc/alternatives/editor', 'nano'] - for fallback in fallbacks: - if os.path.isabs(fallback): - found_path = fallback - else: - 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.') - - -@subcommand -def add(): - """ -Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. - """ - - editor = find_editor() - - handle, tmp_path = tempfile.mkstemp(".rst") - os.close(handle) - atexit.register(lambda : os.unlink(tmp_path)) - - def init_tmp_with_template(): - with open(tmp_path, "wt", encoding="utf-8") as file: - # hack: - # my editor likes to strip trailing whitespace from lines. - # normally this is a good idea. but in the case of the template - # it's unhelpful. - # so, manually ensure there's a space at the end of the gh-issue line. - text = template - - issue_line = ".. gh-issue:" - without_space = "\n" + issue_line + "\n" - with_space = "\n" + issue_line + " \n" - if without_space not in text: - sys.exit("Can't find gh-issue line to ensure there's a space on the end!") - text = text.replace(without_space, with_space) - file.write(text) - - init_tmp_with_template() - - # We need to be clever about EDITOR. - # On the one hand, it might be a legitimate path to an - # executable containing spaces. - # On the other hand, it might be a partial command-line - # with options. - if shutil.which(editor): - args = [editor] - else: - args = list(shlex.split(editor)) - if not shutil.which(args[0]): - sys.exit(f"Invalid GIT_EDITOR / EDITOR value: {editor}") - args.append(tmp_path) - - while True: - subprocess.run(args) - - failure = None - blurb = Blurbs() - try: - blurb.load(tmp_path) - except BlurbError as e: - failure = str(e) - - if not failure: - assert len(blurb) # if parse_blurb succeeds, we should always have a body - if len(blurb) > 1: - failure = "Too many entries! Don't specify '..' on a line by itself." - - if failure: - print() - print(f"Error: {failure}") - print() - try: - prompt("Hit return to retry (or Ctrl-C to abort)") - except KeyboardInterrupt: - print() - return - print() - continue - break - - path = blurb.save_next() - git_add_files.append(path) - flush_git_add_files() - print("Ready for commit.") - - - -@subcommand -def release(version): - """ -Move all new blurbs to a single blurb file for the release. - -This is used by the release manager when cutting a new release. - """ - if version == ".": - # harvest version number from dirname of repo - # I remind you, we're in the Misc subdir right now - version = os.path.basename(root) - - existing_filenames = glob_blurbs(version) - if existing_filenames: - error("Sorry, can't handle appending 'next' files to an existing version (yet).") - - 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" - metadata = {"no changes": "True", "gh-issue": "0", "section": "Library", "date": date, "nonce": nonceify(body)} - blurbs.append((metadata, body)) - else: - count = len(filenames) - print(f'Merging {count} blurbs to "{output}".') - - for filename in filenames: - if not filename.endswith(".rst"): - continue - blurbs.load_next(filename) - - metadata = blurbs[0][0] - - metadata['release date'] = date - print("Saving.") - - blurbs.save(output) - git_add_files.append(output) - flush_git_add_files() - - how_many = len(filenames) - print(f"Removing {how_many} 'next' files from git.") - git_rm_files.extend(filenames) - flush_git_rm_files() - - # sanity check: ensuring that saving/reloading the merged blurb file works. - blurbs2 = Blurbs() - blurbs2.load(output) - assert blurbs2 == blurbs, f"Reloading {output} isn't reproducible?!" - - print() - print("Ready for commit.") - - - -@subcommand -def merge(output=None, *, forced=False): - """ -Merge all blurbs together into a single Misc/NEWS file. - -Optional output argument specifies where to write to. -Default is /Misc/NEWS. - -If overwriting, blurb merge will prompt you to make sure it's okay. -To force it to overwrite, use -f. - """ - if output: - output = os.path.join(original_dir, output) - else: - 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: - builtins.print("You already have a", repr(output), "file.") - require_ok("Type ok to overwrite") - - write_news(output, versions=versions) - - -def write_news(output, *, versions): - buff = io.StringIO() - - def print(*a, sep=" "): - s = sep.join(str(x) for x in a) - return builtins.print(s, file=buff) - - print (""" -+++++++++++ -Python News -+++++++++++ - -""".strip()) - - for version in versions: - filenames = glob_blurbs(version) - - blurbs = Blurbs() - if version == "next": - for filename in filenames: - 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" - else: - assert len(filenames) == 1 - blurbs.load(filenames[0]) - - header = "What's New in Python " + printable_version(version) + "?" - print() - print(header) - print("=" * len(header)) - print() - - - metadata, body = blurbs[0] - release_date = metadata["release date"] - - print(f"*Release date: {release_date}*") - print() - - if "no changes" in metadata: - print(body) - print() - continue - - last_section = None - for metadata, body in blurbs: - section = metadata['section'] - if last_section != section: - last_section = section - print(section) - print("-" * len(section)) - print() - if metadata.get("gh-issue"): - issue_number = metadata['gh-issue'] - if int(issue_number): - body = "gh-" + issue_number + ": " + body - elif metadata.get("bpo"): - issue_number = metadata['bpo'] - if int(issue_number): - body = "bpo-" + issue_number + ": " + body - - body = "- " + body - text = textwrap_body(body, subsequent_indent=' ') - print(text) - print() - print("**(For information about older versions, consult the HISTORY file.)**") - - - new_contents = buff.getvalue() - - # 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") - except (FileNotFoundError, UnicodeError): - previous_contents = None - if new_contents != previous_contents: - Path(output).write_text(new_contents, encoding="UTF-8") - else: - builtins.print(output, "is already up to date") - - -git_add_files = [] -def flush_git_add_files(): - if git_add_files: - subprocess.run(["git", "add", "--force", *git_add_files]).check_returncode() - git_add_files.clear() - -git_rm_files = [] -def flush_git_rm_files(): - if git_rm_files: - try: - subprocess.run(["git", "rm", "--quiet", "--force", *git_rm_files]).check_returncode() - except subprocess.CalledProcessError: - pass - - # clean up - for path in git_rm_files: - try: - os.unlink(path) - except FileNotFoundError: - pass - - git_rm_files.clear() - - -# @subcommand -# def noop(): -# "Do-nothing command. Used for blurb smoke-testing." -# pass - - -@subcommand -def populate(): - """ -Creates and populates the Misc/NEWS.d directory tree. - """ - os.chdir("Misc") - safe_mkdir("NEWS.d/next") - - for section in sections: - dir_name = sanitize_section(section) - dir_path = f"NEWS.d/next/{dir_name}" - safe_mkdir(dir_path) - readme_path = f"NEWS.d/next/{dir_name}/README.rst" - with open(readme_path, "wt", encoding="utf-8") as readme: - readme.write(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) - flush_git_add_files() - - -@subcommand -def export(): - """ -Removes blurb data files, for building release tarballs/installers. - """ - os.chdir("Misc") - shutil.rmtree("NEWS.d", ignore_errors=True) - - - -# @subcommand -# def arg(*, boolean=False, option=True): -# """ -# Test function for blurb command-line processing. -# """ -# print(f"arg: boolean {boolean} option {option}") - - -def main(): - global original_dir - - args = sys.argv[1:] - - if not args: - args = ["add"] - elif args[0] == "-h": - # slight hack - args[0] = "help" - - subcommand = args[0] - args = args[1:] - - fn = get_subcommand(subcommand) - - # hack - if fn in (test, help): - sys.exit(fn(*args)) - - try: - original_dir = os.getcwd() - chdir_to_repo_root() - - # map keyword arguments to options - # we only handle boolean options - # and they must have default values - short_options = {} - long_options = {} - kwargs = {} - for name, p in inspect.signature(fn).parameters.items(): - if p.kind == inspect.Parameter.KEYWORD_ONLY: - assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" - kwargs[name] = p.default - short_options[name[0]] = name - long_options[name] = name - - filtered_args = [] - done_with_options = False - - def handle_option(s, dict): - name = dict.get(s, None) - if not name: - sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') - kwargs[name] = not kwargs[name] - - # print(f"short_options {short_options} long_options {long_options}") - for a in args: - if done_with_options: - filtered_args.append(a) - continue - if a.startswith('-'): - if a == "--": - done_with_options = True - elif a.startswith("--"): - handle_option(a[2:], long_options) - else: - for s in a[1:]: - handle_option(s, short_options) - continue - filtered_args.append(a) - - - sys.exit(fn(*filtered_args, **kwargs)) - except TypeError as e: - # almost certainly wrong number of arguments. - # count arguments of function and print appropriate error message. - specified = len(args) - required = optional = 0 - for p in inspect.signature(fn).parameters.values(): - if p.default == inspect._empty: - required += 1 - else: - optional += 1 - total = required + optional - - if required <= specified <= total: - # whoops, must be a real type error, reraise - raise e - - how_many = f"{specified} argument" - if specified != 1: - how_many += "s" - - if total == 0: - middle = "accepts no arguments" - else: - if total == required: - middle = "requires" - else: - 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" - - print(f'Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}.') - print() - print("usage: ", end="") - help(subcommand) - - -if __name__ == '__main__': - main() diff --git a/blurb/pyproject.toml b/blurb/pyproject.toml deleted file mode 100644 index 68a8963..0000000 --- a/blurb/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[build-system] -build-backend = "flit_core.buildapi" -requires = [ - "flit_core<4,>=2", -] - -[project] -name = "blurb" -description = "Command-line tool to manage CPython Misc/NEWS.d entries." -readme = "README.rst" -maintainers = [{name = "Python Core Developers", email="core-workflow@mail.python.org"}] -authors = [{ name="Larry Hastings", email="larry@hastings.org"}] -requires-python = ">=3.8" -classifiers = [ - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3 :: Only", -] -dynamic = [ - "version", -] -[project.optional-dependencies] -tests = [ - "pyfakefs", - "pytest", - "pytest-cov", -] -[project.urls] -Changelog = "https://github.com/python/core-workflow/tree/main/blurb#changelog" -Homepage = "https://github.com/python/core-workflow/tree/main/blurb" -Source = "https://github.com/python/core-workflow/tree/main/blurb" -[project.scripts] -blurb = "blurb:main" diff --git a/blurb/tests/fail/bpo-.rst b/blurb/tests/fail/bpo-.rst deleted file mode 100644 index 33442ca..0000000 --- a/blurb/tests/fail/bpo-.rst +++ /dev/null @@ -1 +0,0 @@ -bpo-12345: Fixed some problem or other. \ No newline at end of file diff --git a/blurb/tests/fail/dash-space.rst b/blurb/tests/fail/dash-space.rst deleted file mode 100644 index dad92e9..0000000 --- a/blurb/tests/fail/dash-space.rst +++ /dev/null @@ -1,2 +0,0 @@ -- Issue 345: Thingy, and - we did our own line wrapping, as if. \ No newline at end of file diff --git a/blurb/tests/fail/double-metadata.rst b/blurb/tests/fail/double-metadata.rst deleted file mode 100644 index 4ce90db..0000000 --- a/blurb/tests/fail/double-metadata.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. double: foo -.. double: bar - -xyz! \ No newline at end of file diff --git a/blurb/tests/fail/empty.rst b/blurb/tests/fail/empty.rst deleted file mode 100644 index e69de29..0000000 diff --git a/blurb/tests/fail/gh-.rst b/blurb/tests/fail/gh-.rst deleted file mode 100644 index 371e781..0000000 --- a/blurb/tests/fail/gh-.rst +++ /dev/null @@ -1 +0,0 @@ -gh-12345: Fixed some problem or other. \ No newline at end of file diff --git a/blurb/tests/fail/invalid-gh-number.rst b/blurb/tests/fail/invalid-gh-number.rst deleted file mode 100644 index 6d60917..0000000 --- a/blurb/tests/fail/invalid-gh-number.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. gh-issue: abcde -.. section: Library - -Things, stuff. \ No newline at end of file diff --git a/blurb/tests/fail/invalid-section.rst b/blurb/tests/fail/invalid-section.rst deleted file mode 100644 index 1c0af55..0000000 --- a/blurb/tests/fail/invalid-section.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. gh-issue: 8675309 -.. section: Funky Kong - -This is an invalid blurb. Shockingly, "Funky Kong" is not a valid section name. \ No newline at end of file diff --git a/blurb/tests/fail/issue-number.rst b/blurb/tests/fail/issue-number.rst deleted file mode 100644 index 0ba2715..0000000 --- a/blurb/tests/fail/issue-number.rst +++ /dev/null @@ -1 +0,0 @@ -Issue #12345: Fixed some problem or other. \ No newline at end of file diff --git a/blurb/tests/fail/no-colon.rst b/blurb/tests/fail/no-colon.rst deleted file mode 100644 index 5034489..0000000 --- a/blurb/tests/fail/no-colon.rst +++ /dev/null @@ -1,3 +0,0 @@ -.. hello there - -xyz! \ No newline at end of file diff --git a/blurb/tests/fail/no-gh-number.rst b/blurb/tests/fail/no-gh-number.rst deleted file mode 100644 index 480fcbf..0000000 --- a/blurb/tests/fail/no-gh-number.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. gh-issue: -.. section: Library - -Things, stuff. \ No newline at end of file diff --git a/blurb/tests/fail/no-section.rst b/blurb/tests/fail/no-section.rst deleted file mode 100644 index f6e06aa..0000000 --- a/blurb/tests/fail/no-section.rst +++ /dev/null @@ -1,3 +0,0 @@ -.. gh-issue: 8675309 - -This is an invalid blurb. It doesn't have a "section". \ No newline at end of file diff --git a/blurb/tests/fail/small-gh-number.rst b/blurb/tests/fail/small-gh-number.rst deleted file mode 100644 index 9e702fb..0000000 --- a/blurb/tests/fail/small-gh-number.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. gh-issue: 100 -.. section: Library - -This is an invalid blurb. GitHub issues should be 32426 or above. \ No newline at end of file diff --git a/blurb/tests/pass/basic.rst b/blurb/tests/pass/basic.rst deleted file mode 100644 index e6b0b75..0000000 --- a/blurb/tests/pass/basic.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 2017-05-02 -.. gh-issue: 40000 -.. nonce: xyz -.. section: Library - -Hello world! diff --git a/blurb/tests/pass/basic.rst.res b/blurb/tests/pass/basic.rst.res deleted file mode 100644 index e6b0b75..0000000 --- a/blurb/tests/pass/basic.rst.res +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 2017-05-02 -.. gh-issue: 40000 -.. nonce: xyz -.. section: Library - -Hello world! diff --git a/blurb/tests/pass/bpo-in-metadata.rst b/blurb/tests/pass/bpo-in-metadata.rst deleted file mode 100644 index 22d22f5..0000000 --- a/blurb/tests/pass/bpo-in-metadata.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. bpo: 0 -.. date: 2017-05-02 -.. nonce: xyz -.. section: Library - -Hello world! diff --git a/blurb/tests/pass/bpo-in-metadata.rst.res b/blurb/tests/pass/bpo-in-metadata.rst.res deleted file mode 100644 index 22d22f5..0000000 --- a/blurb/tests/pass/bpo-in-metadata.rst.res +++ /dev/null @@ -1,6 +0,0 @@ -.. bpo: 0 -.. date: 2017-05-02 -.. nonce: xyz -.. section: Library - -Hello world! diff --git a/blurb/tests/pass/case-insensitive.rst b/blurb/tests/pass/case-insensitive.rst deleted file mode 100644 index ca1cbd5..0000000 --- a/blurb/tests/pass/case-insensitive.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 2017-05-02 -.. GH-Issue: 35000 -.. nonce: xyz -.. section: Library - -Hello world! diff --git a/blurb/tests/pass/case-insensitive.rst.res b/blurb/tests/pass/case-insensitive.rst.res deleted file mode 100644 index 4f1aad5..0000000 --- a/blurb/tests/pass/case-insensitive.rst.res +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 2017-05-02 -.. gh-issue: 35000 -.. nonce: xyz -.. section: Library - -Hello world! diff --git a/blurb/tests/pass/no-break-long-words.rst b/blurb/tests/pass/no-break-long-words.rst deleted file mode 100644 index 10f5d9e..0000000 --- a/blurb/tests/pass/no-break-long-words.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 1234 -.. gh-issue: 35000 -.. nonce: xyz -.. section: Library - -0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 diff --git a/blurb/tests/pass/no-break-long-words.rst.res b/blurb/tests/pass/no-break-long-words.rst.res deleted file mode 100644 index 10f5d9e..0000000 --- a/blurb/tests/pass/no-break-long-words.rst.res +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 1234 -.. gh-issue: 35000 -.. nonce: xyz -.. section: Library - -0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 diff --git a/blurb/tests/pass/no-break-on-hyphens.rst b/blurb/tests/pass/no-break-on-hyphens.rst deleted file mode 100644 index 48bd427..0000000 --- a/blurb/tests/pass/no-break-on-hyphens.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. date: 7333 -.. gh-issue: 41121 -.. nonce: ZLsRil -.. section: Library - -Don't force 3rd party C extensions to be built with ``-Werror=declaration-after-statement``. diff --git a/blurb/tests/pass/no-break-on-hyphens.rst.res b/blurb/tests/pass/no-break-on-hyphens.rst.res deleted file mode 100644 index cb76b62..0000000 --- a/blurb/tests/pass/no-break-on-hyphens.rst.res +++ /dev/null @@ -1,7 +0,0 @@ -.. date: 7333 -.. gh-issue: 41121 -.. nonce: ZLsRil -.. section: Library - -Don't force 3rd party C extensions to be built with -``-Werror=declaration-after-statement``. diff --git a/blurb/tests/test_blurb.py b/blurb/tests/test_blurb.py deleted file mode 100644 index 515f6b2..0000000 --- a/blurb/tests/test_blurb.py +++ /dev/null @@ -1,181 +0,0 @@ -import pytest -from pyfakefs.fake_filesystem import FakeFilesystem - -import blurb - - -UNCHANGED_SECTIONS = ( - "Library", -) - - -@pytest.mark.parametrize("section", UNCHANGED_SECTIONS) -def test_sanitize_section_no_change(section): - sanitized = blurb.sanitize_section(section) - assert sanitized == section - - -@pytest.mark.parametrize( - "section, expected", - ( - ("C API", "C_API"), - ("Core and Builtins", "Core_and_Builtins"), - ("Tools/Demos", "Tools-Demos"), - ), -) -def test_sanitize_section_changed(section, expected): - sanitized = blurb.sanitize_section(section) - assert sanitized == expected - - -@pytest.mark.parametrize("section", UNCHANGED_SECTIONS) -def test_unsanitize_section_no_change(section): - unsanitized = blurb.unsanitize_section(section) - assert unsanitized == section - - -@pytest.mark.parametrize( - "section, expected", - ( - ("Tools-Demos", "Tools/Demos"), - ), -) -def test_unsanitize_section_changed(section, expected): - unsanitized = blurb.unsanitize_section(section) - assert unsanitized == expected - - -def test_glob_blurbs_next(fs): - # 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", - ) - 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", - ) - for fn in fake_news_entries + fake_readmes: - fs.create_file(fn) - - # Act - filenames = blurb.glob_blurbs("next") - - # Assert - assert set(filenames) == set(fake_news_entries) - - -def test_glob_blurbs_sort_order(fs): - """ - It shouldn't make a difference to sorting whether - section names have spaces or underscores. - """ - # 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", - ) - # 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", - ] - 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", - ) - for fn in fake_news_entries + fake_readmes: - fs.create_file(fn) - - # Act - filenames = blurb.glob_blurbs("next") - - # Assert - 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 diff --git a/blurb/tox.ini b/blurb/tox.ini deleted file mode 100644 index 29d8a8a..0000000 --- a/blurb/tox.ini +++ /dev/null @@ -1,21 +0,0 @@ -[tox] -requires = - tox>=4.2 -env_list = - py{313, 312, 311, 310, 39, 38} - -[testenv] -extras = - tests -pass_env = - FORCE_COLOR -commands = - {envpython} -I -m pytest \ - --cov blurb \ - --cov tests \ - --cov-report html \ - --cov-report term \ - --cov-report xml \ - {posargs} - blurb test - blurb help