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
+
+
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