├── tests ├── fail │ ├── empty.rst │ ├── no-colon.rst │ ├── bpo-.rst │ ├── gh-.rst │ ├── issue-number.rst │ ├── double-metadata.rst │ ├── no-gh-number.rst │ ├── dash-space.rst │ ├── invalid-gh-number.rst │ ├── no-section.rst │ ├── small-gh-number.rst │ └── invalid-section.rst ├── pass │ ├── basic.rst │ ├── bpo-in-metadata.rst │ ├── basic.rst.res │ ├── bpo-in-metadata.rst.res │ ├── case-insensitive.rst │ ├── case-insensitive.rst.res │ ├── no-break-on-hyphens.rst │ ├── no-break-long-words.rst │ ├── no-break-long-words.rst.res │ └── no-break-on-hyphens.rst.res ├── test_release.py ├── test_cli.py ├── test_parser.py ├── test_template.py ├── test_utils_text.py ├── test_versions.py ├── test_utils_globs.py ├── test_blurb_file.py └── test_add.py ├── src └── blurb │ ├── _utils │ ├── __init__.py │ ├── globs.py │ └── text.py │ ├── __init__.py │ ├── __main__.py │ ├── _export.py │ ├── _git.py │ ├── _populate.py │ ├── _versions.py │ ├── _template.py │ ├── _release.py │ ├── _merge.py │ ├── _add.py │ ├── _cli.py │ └── _blurb_file.py ├── .github ├── release.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ └── blurb.md └── workflows │ ├── lint.yml │ ├── test.yml │ └── release.yml ├── .ruff.toml ├── .coveragerc ├── tox.ini ├── RELEASING.md ├── .pre-commit-config.yaml ├── .gitignore ├── pyproject.toml ├── LICENSE.txt ├── CHANGELOG.md └── README.md /tests/fail/empty.rst: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/blurb/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fail/no-colon.rst: -------------------------------------------------------------------------------- 1 | .. hello there 2 | 3 | xyz! 4 | -------------------------------------------------------------------------------- /tests/fail/bpo-.rst: -------------------------------------------------------------------------------- 1 | bpo-12345: Fixed some problem or other. 2 | -------------------------------------------------------------------------------- /tests/fail/gh-.rst: -------------------------------------------------------------------------------- 1 | gh-12345: Fixed some problem or other. 2 | -------------------------------------------------------------------------------- /tests/fail/issue-number.rst: -------------------------------------------------------------------------------- 1 | Issue #12345: Fixed some problem or other. 2 | -------------------------------------------------------------------------------- /tests/fail/double-metadata.rst: -------------------------------------------------------------------------------- 1 | .. double: foo 2 | .. double: bar 3 | 4 | xyz! 5 | -------------------------------------------------------------------------------- /tests/fail/no-gh-number.rst: -------------------------------------------------------------------------------- 1 | .. gh-issue: 2 | .. section: Library 3 | 4 | Things, stuff. 5 | -------------------------------------------------------------------------------- /tests/fail/dash-space.rst: -------------------------------------------------------------------------------- 1 | - Issue 345: Thingy, and 2 | we did our own line wrapping, as if. 3 | -------------------------------------------------------------------------------- /tests/fail/invalid-gh-number.rst: -------------------------------------------------------------------------------- 1 | .. gh-issue: abcde 2 | .. section: Library 3 | 4 | Things, stuff. 5 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /tests/fail/no-section.rst: -------------------------------------------------------------------------------- 1 | .. gh-issue: 8675309 2 | 3 | This is an invalid blurb. It doesn't have a "section". 4 | -------------------------------------------------------------------------------- /src/blurb/__init__.py: -------------------------------------------------------------------------------- 1 | """Command-line tool to manage CPython Misc/NEWS.d entries.""" 2 | 3 | from ._version import __version__ 4 | -------------------------------------------------------------------------------- /tests/pass/basic.rst: -------------------------------------------------------------------------------- 1 | .. date: 2017-05-02 2 | .. gh-issue: 40000 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | Hello world! 7 | -------------------------------------------------------------------------------- /tests/pass/bpo-in-metadata.rst: -------------------------------------------------------------------------------- 1 | .. bpo: 0 2 | .. date: 2017-05-02 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | Hello world! 7 | -------------------------------------------------------------------------------- /tests/pass/basic.rst.res: -------------------------------------------------------------------------------- 1 | .. date: 2017-05-02 2 | .. gh-issue: 40000 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | Hello world! 7 | -------------------------------------------------------------------------------- /tests/pass/bpo-in-metadata.rst.res: -------------------------------------------------------------------------------- 1 | .. bpo: 0 2 | .. date: 2017-05-02 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | Hello world! 7 | -------------------------------------------------------------------------------- /tests/pass/case-insensitive.rst: -------------------------------------------------------------------------------- 1 | .. date: 2017-05-02 2 | .. GH-Issue: 35000 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | Hello world! 7 | -------------------------------------------------------------------------------- /tests/pass/case-insensitive.rst.res: -------------------------------------------------------------------------------- 1 | .. date: 2017-05-02 2 | .. gh-issue: 35000 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | Hello world! 7 | -------------------------------------------------------------------------------- /tests/fail/small-gh-number.rst: -------------------------------------------------------------------------------- 1 | .. gh-issue: 100 2 | .. section: Library 3 | 4 | This is an invalid blurb. GitHub issues should be 32426 or above. 5 | -------------------------------------------------------------------------------- /tests/fail/invalid-section.rst: -------------------------------------------------------------------------------- 1 | .. gh-issue: 8675309 2 | .. section: Funky Kong 3 | 4 | This is an invalid blurb. Shockingly, "Funky Kong" is not a valid section name. 5 | -------------------------------------------------------------------------------- /src/blurb/__main__.py: -------------------------------------------------------------------------------- 1 | """Run blurb using ``python3 -m blurb``.""" 2 | 3 | from __future__ import annotations 4 | 5 | from blurb._cli import main 6 | 7 | if __name__ == '__main__': 8 | main() 9 | -------------------------------------------------------------------------------- /tests/test_release.py: -------------------------------------------------------------------------------- 1 | import time_machine 2 | 3 | from blurb._release import current_date 4 | 5 | 6 | @time_machine.travel('2025-01-07') 7 | def test_current_date(): 8 | assert current_date() == '2025-01-07' 9 | -------------------------------------------------------------------------------- /tests/pass/no-break-on-hyphens.rst: -------------------------------------------------------------------------------- 1 | .. date: 7333 2 | .. gh-issue: 41121 3 | .. nonce: ZLsRil 4 | .. section: Library 5 | 6 | Don't force 3rd party C extensions to be built with ``-Werror=declaration-after-statement``. 7 | -------------------------------------------------------------------------------- /tests/pass/no-break-long-words.rst: -------------------------------------------------------------------------------- 1 | .. date: 1234 2 | .. gh-issue: 35000 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 7 | -------------------------------------------------------------------------------- /tests/pass/no-break-long-words.rst.res: -------------------------------------------------------------------------------- 1 | .. date: 1234 2 | .. gh-issue: 35000 3 | .. nonce: xyz 4 | .. section: Library 5 | 6 | 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 7 | -------------------------------------------------------------------------------- /tests/pass/no-break-on-hyphens.rst.res: -------------------------------------------------------------------------------- 1 | .. date: 7333 2 | .. gh-issue: 41121 3 | .. nonce: ZLsRil 4 | .. section: Library 5 | 6 | Don't force 3rd party C extensions to be built with 7 | ``-Werror=declaration-after-statement``. 8 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import blurb._cli 2 | 3 | 4 | def test_version(capfd): 5 | # Act 6 | blurb._cli.version() 7 | 8 | # Assert 9 | captured = capfd.readouterr() 10 | assert captured.out.startswith('blurb version ') 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | assignees: 8 | - "ezio-melotti" 9 | groups: 10 | actions: 11 | patterns: 12 | - "*" 13 | -------------------------------------------------------------------------------- /src/blurb/_export.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import shutil 5 | 6 | 7 | def export() -> None: 8 | """Removes blurb data files, for building release tarballs/installers.""" 9 | os.chdir('Misc') 10 | shutil.rmtree('NEWS.d', ignore_errors=True) 11 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py310" 2 | 3 | [format] 4 | preview = true 5 | quote-style = "single" 6 | docstring-code-format = true 7 | 8 | [lint] 9 | preview = true 10 | select = [ 11 | "I", # isort 12 | ] 13 | ignore = [ 14 | "E501", # Ignore line length errors (we use auto-formatting) 15 | ] 16 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | 3 | [report] 4 | # Regexes for lines to exclude from consideration 5 | exclude_also = 6 | # Don't complain if non-runnable code isn't run: 7 | if __name__ == .__main__.: 8 | def main 9 | 10 | [run] 11 | omit = 12 | **/blurb/__main__.py 13 | **/blurb/_version.py 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/blurb.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: blurb feature request/bug 3 | about: Feature request or bug related to blurb (command line tool) 4 | --- 5 | 6 | 12 | 13 | # The short story 14 | 15 | It would be nice if ... 16 | 17 | # Long version 18 | 19 | ... 20 | 21 | Thanks for blurb! 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v6 17 | with: 18 | persist-credentials: false 19 | - uses: actions/setup-python@v6 20 | with: 21 | python-version: "3.x" 22 | - uses: tox-dev/action-pre-commit-uv@v1 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | py{314, 313, 312, 311, 310} 6 | 7 | [testenv] 8 | extras = 9 | tests 10 | pass_env = 11 | FORCE_COLOR 12 | commands = 13 | {envpython} -I -m pytest \ 14 | --cov blurb \ 15 | --cov tests \ 16 | --cov-report html \ 17 | --cov-report term \ 18 | --cov-report xml \ 19 | {posargs} 20 | blurb help 21 | blurb --version 22 | {envpython} -I -m blurb help 23 | {envpython} -I -m blurb version 24 | -------------------------------------------------------------------------------- /src/blurb/_git.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import subprocess 5 | 6 | git_add_files: list[str] = [] 7 | git_rm_files: list[str] = [] 8 | 9 | 10 | def flush_git_add_files() -> None: 11 | if not git_add_files: 12 | return 13 | args = ('git', 'add', '--force', *git_add_files) 14 | subprocess.run(args, check=True) 15 | git_add_files.clear() 16 | 17 | 18 | def flush_git_rm_files() -> None: 19 | if not git_rm_files: 20 | return 21 | args = ('git', 'rm', '--quiet', '--force', *git_rm_files) 22 | subprocess.run(args, check=False) 23 | 24 | # clean up 25 | for path in git_rm_files: 26 | try: 27 | os.unlink(path) 28 | except FileNotFoundError: 29 | pass 30 | 31 | git_rm_files.clear() 32 | -------------------------------------------------------------------------------- /src/blurb/_populate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from blurb._git import flush_git_add_files, git_add_files 6 | from blurb._template import sanitize_section, sections 7 | 8 | 9 | def populate() -> None: 10 | """Creates and populates the Misc/NEWS.d directory tree.""" 11 | os.chdir('Misc') 12 | os.makedirs('NEWS.d/next', exist_ok=True) 13 | 14 | for section in sections: 15 | dir_name = sanitize_section(section) 16 | dir_path = f'NEWS.d/next/{dir_name}' 17 | os.makedirs(dir_path, exist_ok=True) 18 | readme_path = f'NEWS.d/next/{dir_name}/README.rst' 19 | with open(readme_path, 'w', encoding='utf-8') as readme: 20 | readme.write( 21 | f'Put news entry ``blurb`` files for the *{section}* section in this directory.\n' 22 | ) 23 | git_add_files.append(dir_path) 24 | git_add_files.append(readme_path) 25 | flush_git_add_files() 26 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | 4 | import pytest 5 | 6 | from blurb._blurb_file import Blurbs 7 | from blurb._versions import chdir 8 | 9 | 10 | class TestParserPasses: 11 | directory = 'tests/pass' 12 | 13 | def filename_test(self, filename): 14 | b = Blurbs() 15 | b.load(filename) 16 | assert b 17 | if os.path.exists(filename + '.res'): 18 | with open(filename + '.res', encoding='utf-8') as file: 19 | expected = file.read() 20 | assert str(b) == expected 21 | 22 | def test_files(self): 23 | with chdir(self.directory): 24 | for filename in glob.glob('*'): 25 | if filename.endswith('.res'): 26 | assert os.path.exists(filename[:-4]), filename 27 | continue 28 | self.filename_test(filename) 29 | 30 | 31 | class TestParserFailures(TestParserPasses): 32 | directory = 'tests/fail' 33 | 34 | def filename_test(self, filename): 35 | b = Blurbs() 36 | with pytest.raises(Exception): 37 | b.load(filename) 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | persist-credentials: false 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | allow-prereleases: true 29 | 30 | - name: Install uv 31 | uses: hynek/setup-cached-uv@v2 32 | 33 | - name: Tox tests 34 | run: | 35 | uvx --with tox-uv tox -e py 36 | 37 | - name: Upload coverage 38 | uses: codecov/codecov-action@v5 39 | with: 40 | flags: ${{ matrix.python-version }} 41 | name: Python ${{ matrix.python-version }} 42 | token: ${{ secrets.CODECOV_ORG_TOKEN }} 43 | -------------------------------------------------------------------------------- /src/blurb/_utils/globs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import glob 4 | import os 5 | 6 | from blurb._template import ( 7 | next_filename_unsanitize_sections, 8 | sanitize_section, 9 | sanitize_section_legacy, 10 | sections, 11 | ) 12 | 13 | 14 | def glob_blurbs(version: str) -> list[str]: 15 | filenames = [] 16 | base = os.path.join('Misc', 'NEWS.d', version) 17 | if version != 'next': 18 | wildcard = f'{base}.rst' 19 | filenames.extend(glob.glob(wildcard)) 20 | else: 21 | sanitized_sections = set(map(sanitize_section, sections)) 22 | sanitized_sections |= set(map(sanitize_section_legacy, sections)) 23 | for section in sanitized_sections: 24 | wildcard = os.path.join(base, section, '*.rst') 25 | entries = glob.glob(wildcard) 26 | deletables = [x for x in entries if x.endswith('/README.rst')] 27 | for filename in deletables: 28 | entries.remove(filename) 29 | filenames.extend(entries) 30 | filenames.sort(reverse=True, key=next_filename_unsanitize_sections) 31 | return filenames 32 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | - [ ] check tests pass on [GitHub Actions](https://github.com/python/blurb/actions) 4 | [![GitHub Actions status](https://github.com/python/blurb/actions/workflows/test.yml/badge.svg)](https://github.com/python/blurb/actions/workflows/test.yml) 5 | 6 | - [ ] Update [changelog](https://github.com/python/blurb/blob/main/CHANGELOG.md) 7 | 8 | - [ ] Go to the [Releases page](https://github.com/python/blurb/releases) and 9 | 10 | - [ ] Click "Draft a new release" 11 | 12 | - [ ] Click "Choose a tag" 13 | 14 | - [ ] Type the next `vX.Y.Z` version and select "**Create new tag: vX.Y.Z** on publish" 15 | 16 | - [ ] Leave the "Release title" blank (it will be autofilled) 17 | 18 | - [ ] Click "Generate release notes" and amend as required 19 | 20 | - [ ] Click "Publish release" 21 | 22 | - [ ] Check the tagged [GitHub Actions build](https://github.com/python/blurb/actions/workflows/release.yml) 23 | has deployed to [PyPI](https://pypi.org/project/blurb/#history) 24 | 25 | - [ ] Check installation: 26 | 27 | ```bash 28 | python -m pip uninstall -y blurb && python -m pip install -U blurb && blurb help 29 | ``` 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-merge-conflict 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: debug-statements 11 | - id: end-of-file-fixer 12 | - id: forbid-submodules 13 | - id: trailing-whitespace 14 | 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.12.8 17 | hooks: 18 | - id: ruff-check 19 | args: [--exit-non-zero-on-fix] 20 | - id: ruff-format 21 | args: [--check] 22 | 23 | - repo: https://github.com/python-jsonschema/check-jsonschema 24 | rev: 0.33.2 25 | hooks: 26 | - id: check-dependabot 27 | - id: check-github-workflows 28 | 29 | - repo: https://github.com/rhysd/actionlint 30 | rev: v1.7.7 31 | hooks: 32 | - id: actionlint 33 | 34 | - repo: https://github.com/tox-dev/pyproject-fmt 35 | rev: v2.6.0 36 | hooks: 37 | - id: pyproject-fmt 38 | 39 | - repo: https://github.com/abravalheri/validate-pyproject 40 | rev: v0.24.1 41 | hooks: 42 | - id: validate-pyproject 43 | 44 | - repo: https://github.com/tox-dev/tox-ini-fmt 45 | rev: 1.6.0 46 | hooks: 47 | - id: tox-ini-fmt 48 | 49 | - repo: meta 50 | hooks: 51 | - id: check-hooks-apply 52 | - id: check-useless-excludes 53 | 54 | ci: 55 | autoupdate_schedule: quarterly 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # pytest 92 | .pytest_cache/ 93 | 94 | # hatch-vcs 95 | src/*/_version.py 96 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling", 6 | ] 7 | 8 | [project] 9 | name = "blurb" 10 | description = "Command-line tool to manage CPython Misc/NEWS.d entries." 11 | readme = "README.md" 12 | maintainers = [ 13 | { name = "Python Core Developers", email = "core-workflow@mail.python.org" }, 14 | ] 15 | authors = [ 16 | { name = "Larry Hastings", email = "larry@hastings.org" }, 17 | ] 18 | requires-python = ">=3.10" 19 | classifiers = [ 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: BSD License", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Programming Language :: Python :: 3.14", 28 | ] 29 | dynamic = [ 30 | "version", 31 | ] 32 | optional-dependencies.tests = [ 33 | "pyfakefs", 34 | "pytest", 35 | "pytest-cov", 36 | "time-machine", 37 | ] 38 | urls.Changelog = "https://github.com/python/blurb/blob/main/CHANGELOG.md" 39 | urls.Homepage = "https://github.com/python/blurb" 40 | urls.Source = "https://github.com/python/blurb" 41 | scripts.blurb = "blurb._cli:main" 42 | 43 | [tool.hatch] 44 | version.source = "vcs" 45 | 46 | [tool.hatch.build.hooks.vcs] 47 | version-file = "src/blurb/_version.py" 48 | 49 | [tool.hatch.version.raw-options] 50 | local_scheme = "no-local-version" 51 | 52 | [tool.pyproject-fmt] 53 | max_supported_python = "3.14" 54 | -------------------------------------------------------------------------------- /tests/test_template.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import blurb._template 4 | from blurb._template import sanitize_section, unsanitize_section 5 | 6 | UNCHANGED_SECTIONS = ('Library',) 7 | 8 | 9 | def test_section_names(): 10 | assert tuple(blurb._template.sections) == ( 11 | 'Security', 12 | 'Core and Builtins', 13 | 'Library', 14 | 'Documentation', 15 | 'Tests', 16 | 'Build', 17 | 'Windows', 18 | 'macOS', 19 | 'IDLE', 20 | 'Tools/Demos', 21 | 'C API', 22 | ) 23 | 24 | 25 | @pytest.mark.parametrize('section', UNCHANGED_SECTIONS) 26 | def test_sanitize_section_no_change(section): 27 | sanitized = sanitize_section(section) 28 | assert sanitized == section 29 | 30 | 31 | @pytest.mark.parametrize( 32 | 'section, expected', 33 | ( 34 | ('C API', 'C_API'), 35 | ('Core and Builtins', 'Core_and_Builtins'), 36 | ('Tools/Demos', 'Tools-Demos'), 37 | ), 38 | ) 39 | def test_sanitize_section_changed(section, expected): 40 | sanitized = sanitize_section(section) 41 | assert sanitized == expected 42 | 43 | 44 | @pytest.mark.parametrize('section', UNCHANGED_SECTIONS) 45 | def test_unsanitize_section_no_change(section): 46 | unsanitized = unsanitize_section(section) 47 | assert unsanitized == section 48 | 49 | 50 | @pytest.mark.parametrize( 51 | 'section, expected', 52 | (('Tools-Demos', 'Tools/Demos'),), 53 | ) 54 | def test_unsanitize_section_changed(section, expected): 55 | unsanitized = unsanitize_section(section) 56 | assert unsanitized == expected 57 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Part of the blurb package. 2 | Copyright 2015-2018 by Larry Hastings 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 20 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 21 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 22 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 25 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 26 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 27 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 28 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | 32 | Licensed to the Python Software Foundation under a contributor agreement. 33 | -------------------------------------------------------------------------------- /tests/test_utils_text.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blurb._utils.text import textwrap_body 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'body, subsequent_indent, expected', 8 | ( 9 | ( 10 | 'This is a test of the textwrap_body function with a string. It should wrap the text to 79 characters.', 11 | '', 12 | 'This is a test of the textwrap_body function with a string. It should wrap\n' 13 | 'the text to 79 characters.\n', 14 | ), 15 | ( 16 | [ 17 | 'This is a test of the textwrap_body function', 18 | 'with an iterable of strings.', 19 | 'It should wrap the text to 79 characters.', 20 | ], 21 | '', 22 | 'This is a test of the textwrap_body function with an iterable of strings. It\n' 23 | 'should wrap the text to 79 characters.\n', 24 | ), 25 | ( 26 | 'This is a test of the textwrap_body function with a string and subsequent indent.', 27 | ' ', 28 | 'This is a test of the textwrap_body function with a string and subsequent\n' 29 | ' indent.\n', 30 | ), 31 | ( 32 | 'This is a test of the textwrap_body function with a bullet list and subsequent indent. The list should not be wrapped.\n' 33 | '\n' 34 | '* Item 1\n' 35 | '* Item 2\n', 36 | ' ', 37 | 'This is a test of the textwrap_body function with a bullet list and\n' 38 | ' subsequent indent. The list should not be wrapped.\n' 39 | '\n' 40 | ' * Item 1\n' 41 | ' * Item 2\n', 42 | ), 43 | ), 44 | ) 45 | def test_textwrap_body(body, subsequent_indent, expected): 46 | assert textwrap_body(body, subsequent_indent=subsequent_indent) == expected 47 | -------------------------------------------------------------------------------- /src/blurb/_versions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import glob 4 | import sys 5 | 6 | if sys.version_info[:2] >= (3, 11): 7 | from contextlib import chdir 8 | else: 9 | import os 10 | 11 | class chdir: 12 | def __init__(self, path: str, /) -> None: 13 | self.path = path 14 | 15 | def __enter__(self) -> None: 16 | self.previous_cwd = os.getcwd() 17 | os.chdir(self.path) 18 | 19 | def __exit__(self, *args) -> None: 20 | os.chdir(self.previous_cwd) 21 | 22 | 23 | def glob_versions() -> list[str]: 24 | versions = [] 25 | with chdir('Misc/NEWS.d'): 26 | for wildcard in ('2.*.rst', '3.*.rst', 'next'): 27 | versions += [x.partition('.rst')[0] for x in glob.glob(wildcard)] 28 | versions.sort(key=version_key, reverse=True) 29 | return versions 30 | 31 | 32 | def version_key(element: str, /) -> str: 33 | fields = list(element.split('.')) 34 | if len(fields) == 1: 35 | return element 36 | 37 | # in sorted order, 38 | # 3.5.0a1 < 3.5.0b1 < 3.5.0rc1 < 3.5.0 39 | # so for sorting purposes we transform 40 | # "3.5." and "3.5.0" into "3.5.0zz0" 41 | last = fields.pop() 42 | for s in ('a', 'b', 'rc'): 43 | if s in last: 44 | last, stage, stage_version = last.partition(s) 45 | break 46 | else: 47 | stage = 'zz' 48 | stage_version = '0' 49 | 50 | fields.append(last) 51 | while len(fields) < 3: 52 | fields.append('0') 53 | 54 | fields.extend([stage, stage_version]) 55 | fields = [s.rjust(6, '0') for s in fields] 56 | 57 | return '.'.join(fields) 58 | 59 | 60 | def printable_version(version: str, /) -> str: 61 | if version == 'next': 62 | return version 63 | if 'a' in version: 64 | return version.replace('a', ' alpha ') 65 | if 'b' in version: 66 | return version.replace('b', ' beta ') 67 | if 'rc' in version: 68 | return version.replace('rc', ' release candidate ') 69 | return version + ' final' 70 | -------------------------------------------------------------------------------- /tests/test_versions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blurb._versions import glob_versions, printable_version, version_key 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'version1, version2', 8 | ( 9 | ('2', '3'), 10 | ('3.5.0a1', '3.5.0b1'), 11 | ('3.5.0a1', '3.5.0rc1'), 12 | ('3.5.0a1', '3.5.0'), 13 | ('3.6.0b1', '3.6.0b2'), 14 | ('3.6.0b1', '3.6.0rc1'), 15 | ('3.6.0b1', '3.6.0'), 16 | ('3.7.0rc1', '3.7.0rc2'), 17 | ('3.7.0rc1', '3.7.0'), 18 | ('3.8', '3.8.1'), 19 | ), 20 | ) 21 | def test_version_key(version1, version2): 22 | # Act 23 | key1 = version_key(version1) 24 | key2 = version_key(version2) 25 | 26 | # Assert 27 | assert key1 < key2 28 | 29 | 30 | def test_glob_versions(fs): 31 | # Arrange 32 | fake_version_blurbs = ( 33 | 'Misc/NEWS.d/3.7.0.rst', 34 | 'Misc/NEWS.d/3.7.0a1.rst', 35 | 'Misc/NEWS.d/3.7.0a2.rst', 36 | 'Misc/NEWS.d/3.7.0b1.rst', 37 | 'Misc/NEWS.d/3.7.0b2.rst', 38 | 'Misc/NEWS.d/3.7.0rc1.rst', 39 | 'Misc/NEWS.d/3.7.0rc2.rst', 40 | 'Misc/NEWS.d/3.9.0b1.rst', 41 | 'Misc/NEWS.d/3.12.0a1.rst', 42 | ) 43 | for fn in fake_version_blurbs: 44 | fs.create_file(fn) 45 | 46 | # Act 47 | versions = glob_versions() 48 | 49 | # Assert 50 | assert versions == [ 51 | '3.12.0a1', 52 | '3.9.0b1', 53 | '3.7.0', 54 | '3.7.0rc2', 55 | '3.7.0rc1', 56 | '3.7.0b2', 57 | '3.7.0b1', 58 | '3.7.0a2', 59 | '3.7.0a1', 60 | ] 61 | 62 | 63 | @pytest.mark.parametrize( 64 | 'version, expected', 65 | ( 66 | ('next', 'next'), 67 | ('3.12.0a1', '3.12.0 alpha 1'), 68 | ('3.12.0b2', '3.12.0 beta 2'), 69 | ('3.12.0rc2', '3.12.0 release candidate 2'), 70 | ('3.12.0', '3.12.0 final'), 71 | ('3.12.1', '3.12.1 final'), 72 | ), 73 | ) 74 | def test_printable_version(version, expected): 75 | # Act / Assert 76 | assert printable_version(version) == expected 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build package 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: 8 | - published 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | FORCE_COLOR: 1 16 | 17 | jobs: 18 | # Always build & lint package. 19 | build-package: 20 | name: Build & verify package 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v6 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@v2 30 | 31 | # Publish to Test PyPI on every commit on main. 32 | release-test-pypi: 33 | name: Publish in-dev package to test.pypi.org 34 | if: | 35 | github.repository_owner == 'python' 36 | && github.event_name == 'push' 37 | && github.ref == 'refs/heads/main' 38 | runs-on: ubuntu-latest 39 | needs: build-package 40 | 41 | permissions: 42 | id-token: write 43 | 44 | steps: 45 | - name: Download packages built by build-and-inspect-python-package 46 | uses: actions/download-artifact@v6 47 | with: 48 | name: Packages 49 | path: dist 50 | 51 | - name: Publish to Test PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | repository-url: https://test.pypi.org/legacy/ 55 | 56 | # Publish to PyPI on GitHub Releases. 57 | release-pypi: 58 | name: Publish to PyPI 59 | # Only run for published releases. 60 | if: | 61 | github.repository_owner == 'python' 62 | && github.event.action == 'published' 63 | runs-on: ubuntu-latest 64 | needs: build-package 65 | 66 | environment: 67 | name: pypi 68 | url: >- 69 | https://pypi.org/project/blurb/${{ 70 | github.event.release.tag_name 71 | }} 72 | 73 | permissions: 74 | id-token: write 75 | 76 | steps: 77 | - name: Download packages built by build-and-inspect-python-package 78 | uses: actions/download-artifact@v6 79 | with: 80 | name: Packages 81 | path: dist 82 | 83 | - name: Publish to PyPI 84 | uses: pypa/gh-action-pypi-publish@release/v1 85 | -------------------------------------------------------------------------------- /src/blurb/_template.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | # 4 | # This template is the canonical list of acceptable section names! 5 | # It's parsed internally into the "sections" set. 6 | # 7 | 8 | template = """ 9 | 10 | # 11 | # Please enter the relevant GitHub issue number here: 12 | # 13 | .. gh-issue: 14 | 15 | # 16 | # Uncomment one of these "section:" lines to specify which section 17 | # this entry should go in in Misc/NEWS.d. 18 | # 19 | #.. section: Security 20 | #.. section: Core and Builtins 21 | #.. section: Library 22 | #.. section: Documentation 23 | #.. section: Tests 24 | #.. section: Build 25 | #.. section: Windows 26 | #.. section: macOS 27 | #.. section: IDLE 28 | #.. section: Tools/Demos 29 | #.. section: C API 30 | 31 | # Write your Misc/NEWS.d entry below. It should be a simple ReST paragraph. 32 | # Don't start with "- Issue #: " or "- gh-issue-: " or that sort of stuff. 33 | ########################################################################### 34 | 35 | 36 | """.lstrip() 37 | 38 | sections: list[str] = [] 39 | for line in template.split('\n'): 40 | line = line.strip() 41 | prefix, found, section = line.partition('#.. section: ') 42 | if found and not prefix: 43 | sections.append(section.strip()) 44 | 45 | _sanitize_section = { 46 | 'C API': 'C_API', 47 | 'Core and Builtins': 'Core_and_Builtins', 48 | 'Tools/Demos': 'Tools-Demos', 49 | } 50 | 51 | _unsanitize_section = { 52 | 'C_API': 'C API', 53 | 'Core_and_Builtins': 'Core and Builtins', 54 | 'Tools-Demos': 'Tools/Demos', 55 | } 56 | 57 | 58 | def sanitize_section(section: str, /) -> str: 59 | """Clean up a section string. 60 | 61 | This makes it viable as a directory name. 62 | """ 63 | return _sanitize_section.get(section, section) 64 | 65 | 66 | def sanitize_section_legacy(section: str, /) -> str: 67 | """Clean up a section string, allowing spaces. 68 | 69 | This makes it viable as a directory name. 70 | """ 71 | return section.replace('/', '-') 72 | 73 | 74 | def unsanitize_section(section: str, /) -> str: 75 | return _unsanitize_section.get(section, section) 76 | 77 | 78 | def next_filename_unsanitize_sections(filename: str, /) -> str: 79 | for key, value in _unsanitize_section.items(): 80 | for separator in ('/', '\\'): 81 | key = f'{separator}{key}{separator}' 82 | value = f'{separator}{value}{separator}' 83 | filename = filename.replace(key, value) 84 | return filename 85 | -------------------------------------------------------------------------------- /tests/test_utils_globs.py: -------------------------------------------------------------------------------- 1 | from blurb._utils.globs import glob_blurbs 2 | 3 | 4 | def test_glob_blurbs_next(fs) -> None: 5 | # Arrange 6 | fake_news_entries = ( 7 | 'Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-11111.pC7gnM.rst', 8 | 'Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-33333.Pf_BI7.rst', 9 | 'Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-44444.2F1Byz.rst', 10 | 'Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst', 11 | ) 12 | fake_readmes = ( 13 | 'Misc/NEWS.d/next/Library/README.rst', 14 | 'Misc/NEWS.d/next/Core and Builtins/README.rst', 15 | 'Misc/NEWS.d/next/Tools-Demos/README.rst', 16 | 'Misc/NEWS.d/next/C API/README.rst', 17 | ) 18 | for fn in fake_news_entries + fake_readmes: 19 | fs.create_file(fn) 20 | 21 | # Act 22 | filenames = glob_blurbs('next') 23 | 24 | # Assert 25 | assert set(filenames) == set(fake_news_entries) 26 | 27 | 28 | def test_glob_blurbs_sort_order(fs) -> None: 29 | """ 30 | It shouldn't make a difference to sorting whether 31 | section names have spaces or underscores. 32 | """ 33 | # Arrange 34 | fake_news_entries = ( 35 | 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-01-00.gh-issue-33331.Pf_BI1.rst', 36 | 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-02-00.gh-issue-33332.Pf_BI2.rst', 37 | 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-03-00.gh-issue-33333.Pf_BI3.rst', 38 | 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-04-00.gh-issue-33334.Pf_BI4.rst', 39 | ) 40 | # As fake_news_entries, but reverse sorted by *filename* only 41 | expected = [ 42 | 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-04-00.gh-issue-33334.Pf_BI4.rst', 43 | 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-03-00.gh-issue-33333.Pf_BI3.rst', 44 | 'Misc/NEWS.d/next/Core_and_Builtins/2023-07-23-12-02-00.gh-issue-33332.Pf_BI2.rst', 45 | 'Misc/NEWS.d/next/Core and Builtins/2023-07-23-12-01-00.gh-issue-33331.Pf_BI1.rst', 46 | ] 47 | fake_readmes = ( 48 | 'Misc/NEWS.d/next/Library/README.rst', 49 | 'Misc/NEWS.d/next/Core and Builtins/README.rst', 50 | 'Misc/NEWS.d/next/Tools-Demos/README.rst', 51 | 'Misc/NEWS.d/next/C API/README.rst', 52 | ) 53 | for fn in fake_news_entries + fake_readmes: 54 | fs.create_file(fn) 55 | 56 | # Act 57 | filenames = glob_blurbs('next') 58 | 59 | # Assert 60 | assert filenames == expected 61 | -------------------------------------------------------------------------------- /src/blurb/_release.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import time 5 | 6 | import blurb._blurb_file 7 | from blurb._blurb_file import Blurbs 8 | from blurb._cli import error 9 | from blurb._git import ( 10 | flush_git_add_files, 11 | flush_git_rm_files, 12 | git_add_files, 13 | git_rm_files, 14 | ) 15 | from blurb._utils.globs import glob_blurbs 16 | from blurb._utils.text import generate_nonce 17 | 18 | 19 | def release(version: str) -> None: 20 | """Move all new blurbs to a single blurb file for the release. 21 | 22 | This is used by the release manager when cutting a new release. 23 | """ 24 | if version == '.': 25 | # harvest version number from dirname of repo 26 | # I remind you, we're in the Misc subdir right now 27 | version = os.path.basename(blurb._blurb_file.root) 28 | 29 | existing_filenames = glob_blurbs(version) 30 | if existing_filenames: 31 | error( 32 | "Sorry, can't handle appending 'next' files to an existing version (yet)." 33 | ) 34 | 35 | output = f'Misc/NEWS.d/{version}.rst' 36 | filenames = glob_blurbs('next') 37 | blurbs = Blurbs() 38 | date = current_date() 39 | 40 | if not filenames: 41 | print(f'No blurbs found. Setting {version} as having no changes.') 42 | body = f'There were no new changes in version {version}.\n' 43 | metadata = { 44 | 'no changes': 'True', 45 | 'gh-issue': '0', 46 | 'section': 'Library', 47 | 'date': date, 48 | 'nonce': generate_nonce(body), 49 | } 50 | blurbs.append((metadata, body)) 51 | else: 52 | count = len(filenames) 53 | print(f'Merging {count} blurbs to "{output}".') 54 | 55 | for filename in filenames: 56 | if not filename.endswith('.rst'): 57 | continue 58 | blurbs.load_next(filename) 59 | 60 | metadata = blurbs[0][0] 61 | 62 | metadata['release date'] = date 63 | print('Saving.') 64 | 65 | blurbs.save(output) 66 | git_add_files.append(output) 67 | flush_git_add_files() 68 | 69 | how_many = len(filenames) 70 | print(f"Removing {how_many} 'next' files from git.") 71 | git_rm_files.extend(filenames) 72 | flush_git_rm_files() 73 | 74 | # sanity check: ensuring that saving/reloading the merged blurb file works. 75 | blurbs2 = Blurbs() 76 | blurbs2.load(output) 77 | assert blurbs2 == blurbs, f"Reloading {output} isn't reproducible?!" 78 | 79 | print() 80 | print('Ready for commit.') 81 | 82 | 83 | def current_date() -> str: 84 | return time.strftime('%Y-%m-%d', time.localtime()) 85 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.1.0 4 | 5 | - Add the `-i` / `--issue` option to the 'blurb add' command. 6 | This lets you pre-fill the `gh-issue` field in the template. 7 | - Add the `-s` / `--section` option to the 'blurb add' command. 8 | This lets you pre-fill the `section` field in the template. 9 | 10 | ## 2.0.0 11 | 12 | * Move 'blurb test' subcommand into test suite by @hugovk in https://github.com/python/blurb/pull/37 13 | * Add support for Python 3.14 by @ezio-melotti in https://github.com/python/blurb/pull/40 14 | * Validate gh-issue is int before checking range, and that gh-issue or bpo exists by @hugovk in https://github.com/python/blurb/pull/35 15 | * Replace `safe_mkdir(path)` with `os.makedirs(path, exist_ok=True)` by @hugovk in https://github.com/python/blurb/pull/38 16 | * Test version handling functions by @hugovk in https://github.com/python/blurb/pull/36 17 | * CI: Lint and test via uv by @hugovk in https://github.com/python/blurb/pull/32 18 | 19 | ## 1.3.0 20 | 21 | * Add support for Python 3.13 by @hugovk in https://github.com/python/blurb/pull/26 22 | * Drop support for Python 3.8 by @hugovk in https://github.com/python/blurb/pull/27 23 | * Generate digital attestations for PyPI (PEP 740) by @hugovk in https://github.com/python/blurb/pull/28 24 | * Allow running blurb test from blurb-* directories by @hroncok in https://github.com/python/blurb/pull/24 25 | * Add `version` subcommand by @hugovk in https://github.com/python/blurb/pull/29 26 | * Generate `__version__` at build to avoid slow `importlib.metadata` import by @hugovk in https://github.com/python/blurb/pull/30 27 | 28 | ## 1.2.1 29 | 30 | - Fix `python3 -m blurb`. 31 | - Undocument removed `blurb split`. 32 | 33 | ## 1.2.0 34 | 35 | - Replace spaces with underscores in news directory. 36 | - Drop support for Python 3.7. 37 | - Remove `blurb split` command. 38 | - Replace `gh-issue-NNNN:` with `gh-NNNN:` in the output. 39 | - Accept GitHub issues numbered only 32426 or above. 40 | - Improve error checking when parsing a Blurb. 41 | - Loosen README check for CPython forks. 42 | - Move code from `python/core-workflow` to own `python/blurb` repo. 43 | - Deploy to PyPI via Trusted Publishers. 44 | 45 | ## 1.1.0 46 | 47 | - Support GitHub Issues in addition to b.p.o (bugs.python.org). 48 | If `gh-issue` is in the metadata, then the filename will contain 49 | `gh-issue-` instead of `bpo-`. 50 | 51 | ## 1.0.7 52 | 53 | - When word wrapping, don't break on long words or hyphens. 54 | - Use the `-f` flag when adding **blurb** files to a Git 55 | commit. This forces them to be added, even when the files 56 | might normally be ignored based on a `.gitignore` directive. 57 | - Explicitly support the `-help` command-line option. 58 | - Fix Travis CI integration. 59 | -------------------------------------------------------------------------------- /src/blurb/_merge.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | from blurb._blurb_file import Blurbs 8 | from blurb._cli import require_ok 9 | from blurb._utils.globs import glob_blurbs 10 | from blurb._utils.text import textwrap_body 11 | from blurb._versions import glob_versions, printable_version 12 | 13 | original_dir: str = os.getcwd() 14 | 15 | 16 | def merge(output: str | None = None, *, forced: bool = False) -> None: 17 | """Merge all blurbs together into a single Misc/NEWS file. 18 | 19 | Optional output argument specifies where to write to. 20 | Default is /Misc/NEWS. 21 | 22 | If overwriting, blurb merge will prompt you to make sure it's okay. 23 | To force it to overwrite, use -f. 24 | """ 25 | if output: 26 | output = os.path.join(original_dir, output) 27 | else: 28 | output = 'Misc/NEWS' 29 | 30 | versions = glob_versions() 31 | if not versions: 32 | sys.exit("You literally don't have ANY blurbs to merge together!") 33 | 34 | if os.path.exists(output) and not forced: 35 | print(f'You already have a {output!r} file.') 36 | require_ok('Type ok to overwrite') 37 | 38 | write_news(output, versions=versions) 39 | 40 | 41 | def write_news(output: str, *, versions: list[str]) -> None: 42 | buff = [] 43 | 44 | def prnt(msg: str = '', /): 45 | buff.append(msg) 46 | 47 | prnt( 48 | """ 49 | +++++++++++ 50 | Python News 51 | +++++++++++ 52 | 53 | """.strip() 54 | ) 55 | 56 | for version in versions: 57 | filenames = glob_blurbs(version) 58 | 59 | blurbs = Blurbs() 60 | if version == 'next': 61 | for filename in filenames: 62 | if os.path.basename(filename) == 'README.rst': 63 | continue 64 | blurbs.load_next(filename) 65 | if not blurbs: 66 | continue 67 | metadata = blurbs[0][0] 68 | metadata['release date'] = 'XXXX-XX-XX' 69 | else: 70 | assert len(filenames) == 1 71 | blurbs.load(filenames[0]) 72 | 73 | header = f"What's New in Python {printable_version(version)}?" 74 | prnt() 75 | prnt(header) 76 | prnt('=' * len(header)) 77 | prnt() 78 | 79 | metadata, body = blurbs[0] 80 | release_date = metadata['release date'] 81 | 82 | prnt(f'*Release date: {release_date}*') 83 | prnt() 84 | 85 | if 'no changes' in metadata: 86 | prnt(body) 87 | prnt() 88 | continue 89 | 90 | last_section = None 91 | for metadata, body in blurbs: 92 | section = metadata['section'] 93 | if last_section != section: 94 | last_section = section 95 | prnt(section) 96 | prnt('-' * len(section)) 97 | prnt() 98 | if metadata.get('gh-issue'): 99 | issue_number = metadata['gh-issue'] 100 | if int(issue_number): 101 | body = f'gh-{issue_number}: {body}' 102 | elif metadata.get('bpo'): 103 | issue_number = metadata['bpo'] 104 | if int(issue_number): 105 | body = f'bpo-{issue_number}: {body}' 106 | 107 | body = f'- {body}' 108 | text = textwrap_body(body, subsequent_indent=' ') 109 | prnt(text) 110 | prnt() 111 | prnt('**(For information about older versions, consult the HISTORY file.)**') 112 | 113 | new_contents = '\n'.join(buff) 114 | 115 | # Only write in `output` if the contents are different 116 | # This speeds up subsequent Sphinx builds 117 | try: 118 | previous_contents = Path(output).read_text(encoding='utf-8') 119 | except (FileNotFoundError, UnicodeError): 120 | previous_contents = None 121 | if new_contents != previous_contents: 122 | Path(output).write_text(new_contents, encoding='utf-8') 123 | else: 124 | print(output, 'is already up to date') 125 | -------------------------------------------------------------------------------- /src/blurb/_utils/text.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hashlib 5 | import itertools 6 | import textwrap 7 | 8 | TYPE_CHECKING = False 9 | if TYPE_CHECKING: 10 | from collections.abc import Iterable 11 | 12 | 13 | def textwrap_body(body: str | Iterable[str], *, subsequent_indent: str = '') -> str: 14 | """Wrap body text. 15 | 16 | Accepts either a string or an iterable of strings. 17 | (Iterable is assumed to be individual lines.) 18 | Returns a string. 19 | """ 20 | if isinstance(body, str): 21 | text = body 22 | else: 23 | text = '\n'.join(body).rstrip() 24 | 25 | # textwrap merges paragraphs, ARGH 26 | 27 | # step 1: remove trailing whitespace from individual lines 28 | # (this means that empty lines will just have \n, no invisible whitespace) 29 | lines = [] 30 | for line in text.split('\n'): 31 | lines.append(line.rstrip()) 32 | text = '\n'.join(lines) 33 | # step 2: break into paragraphs and wrap those 34 | paragraphs = text.split('\n\n') 35 | paragraphs2 = [] 36 | kwargs: dict[str, object] = {'break_long_words': False, 'break_on_hyphens': False} 37 | if subsequent_indent: 38 | kwargs['subsequent_indent'] = subsequent_indent 39 | dont_reflow = False 40 | for paragraph in paragraphs: 41 | # don't reflow bulleted / numbered lists 42 | dont_reflow = dont_reflow or paragraph.startswith(('* ', '1. ', '#. ')) 43 | if dont_reflow: 44 | initial = kwargs.get('initial_indent', '') 45 | subsequent = kwargs.get('subsequent_indent', '') 46 | if initial or subsequent: 47 | lines = [line.rstrip() for line in paragraph.split('\n')] 48 | indents = itertools.chain( 49 | itertools.repeat(initial, 1), 50 | itertools.repeat(subsequent), 51 | ) 52 | lines = [indent + line for indent, line in zip(indents, lines)] 53 | paragraph = '\n'.join(lines) 54 | paragraphs2.append(paragraph) 55 | else: 56 | # Why do we reflow the text twice? Because it can actually change 57 | # between the first and second reflows, and we want the text to 58 | # be stable. The problem is that textwrap.wrap is deliberately 59 | # dumb about how many spaces follow a period in prose. 60 | # 61 | # We're reflowing at 76 columns, but let's pretend it's 30 for 62 | # illustration purposes. If we give textwrap.wrap the following 63 | # text--ignore the line of 30 dashes, that's just to help you 64 | # with visualization: 65 | # 66 | # ------------------------------ 67 | # xxxx xxxx xxxx xxxx xxxx. xxxx 68 | # 69 | # The first textwrap.wrap will return this: 70 | # 'xxxx xxxx xxxx xxxx xxxx.\nxxxx' 71 | # 72 | # If we reflow it again, textwrap will rejoin the lines, but 73 | # only with one space after the period! So this time it'll 74 | # all fit on one line, behold: 75 | # ------------------------------ 76 | # xxxx xxxx xxxx xxxx xxxx. xxxx 77 | # and so it now returns: 78 | # 'xxxx xxxx xxxx xxxx xxxx. xxxx' 79 | # 80 | # textwrap.wrap supports trying to add two spaces after a peroid: 81 | # https://docs.python.org/3/library/textwrap.html#textwrap.TextWrapper.fix_sentence_endings 82 | # But it doesn't work all that well, because it's not smart enough 83 | # to do a really good job. 84 | # 85 | # Since blurbs are eventually turned into reST and rendered anyway, 86 | # and since the Zen says 'In the face of ambiguity, refuse the 87 | # temptation to guess', I don't sweat it. I run textwrap.wrap 88 | # twice, so it's stable, and this means occasionally it'll 89 | # convert two spaces to one space, no big deal. 90 | 91 | paragraph = '\n'.join( 92 | textwrap.wrap(paragraph.strip(), width=76, **kwargs) 93 | ).rstrip() 94 | paragraph = '\n'.join( 95 | textwrap.wrap(paragraph.strip(), width=76, **kwargs) 96 | ).rstrip() 97 | paragraphs2.append(paragraph) 98 | # don't reflow literal code blocks (I hope) 99 | dont_reflow = paragraph.endswith('::') 100 | if subsequent_indent: 101 | kwargs['initial_indent'] = subsequent_indent 102 | text = '\n\n'.join(paragraphs2).rstrip() 103 | if not text.endswith('\n'): 104 | text += '\n' 105 | return text 106 | 107 | 108 | def generate_nonce(body: str) -> str: 109 | digest = hashlib.md5(body.encode('utf-8')).digest() 110 | return base64.urlsafe_b64encode(digest)[0:6].decode('ascii') 111 | -------------------------------------------------------------------------------- /tests/test_blurb_file.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time_machine 3 | 4 | import blurb._blurb_file 5 | from blurb._blurb_file import BlurbError, Blurbs, sortable_datetime 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'news_entry, expected_section', 10 | ( 11 | ( 12 | 'Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst', 13 | 'Library', 14 | ), 15 | ( 16 | 'Misc/NEWS.d/next/Core_and_Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst', 17 | 'Core and Builtins', 18 | ), 19 | ( 20 | 'Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-55555.Pf_BI7.rst', 21 | 'Core and Builtins', 22 | ), 23 | ( 24 | 'Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-66666.2F1Byz.rst', 25 | 'Tools/Demos', 26 | ), 27 | ( 28 | 'Misc/NEWS.d/next/C_API/2023-03-27-22-09-07.gh-issue-77777.3SN8Bs.rst', 29 | 'C API', 30 | ), 31 | ( 32 | 'Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-88888.3SN8Bs.rst', 33 | 'C API', 34 | ), 35 | ), 36 | ) 37 | def test_load_next(news_entry, expected_section, fs): 38 | # Arrange 39 | fs.create_file(news_entry, contents='testing') 40 | blurbs = Blurbs() 41 | 42 | # Act 43 | blurbs.load_next(news_entry) 44 | 45 | # Assert 46 | metadata = blurbs[0][0] 47 | assert metadata['section'] == expected_section 48 | 49 | 50 | @pytest.mark.parametrize( 51 | 'news_entry, expected_path', 52 | ( 53 | ( 54 | 'Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst', 55 | 'root/Misc/NEWS.d/next/Library/2022-04-11-18-34-33.gh-issue-33333.pC7gnM.rst', 56 | ), 57 | ( 58 | 'Misc/NEWS.d/next/Core and Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst', 59 | 'root/Misc/NEWS.d/next/Core_and_Builtins/2023-03-17-12-09-45.gh-issue-44444.Pf_BI7.rst', 60 | ), 61 | ( 62 | 'Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-55555.2F1Byz.rst', 63 | 'root/Misc/NEWS.d/next/Tools-Demos/2023-03-21-01-27-07.gh-issue-55555.2F1Byz.rst', 64 | ), 65 | ( 66 | 'Misc/NEWS.d/next/C API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst', 67 | 'root/Misc/NEWS.d/next/C_API/2023-03-27-22-09-07.gh-issue-66666.3SN8Bs.rst', 68 | ), 69 | ), 70 | ) 71 | def test_extract_next_filename(news_entry, expected_path, fs, monkeypatch): 72 | # Arrange 73 | monkeypatch.setattr(blurb._blurb_file, 'root', 'root') 74 | fs.create_file(news_entry, contents='testing') 75 | blurbs = Blurbs() 76 | blurbs.load_next(news_entry) 77 | 78 | # Act 79 | path = blurbs._extract_next_filename() 80 | 81 | # Assert 82 | assert path == expected_path 83 | 84 | 85 | def test_parse(): 86 | # Arrange 87 | contents = '.. gh-issue: 123456\n.. section: IDLE\nHello world!' 88 | blurbs = Blurbs() 89 | 90 | # Act 91 | blurbs.parse(contents) 92 | 93 | # Assert 94 | metadata, body = blurbs[0] 95 | assert metadata['gh-issue'] == '123456' 96 | assert metadata['section'] == 'IDLE' 97 | assert body == 'Hello world!\n' 98 | 99 | 100 | @pytest.mark.parametrize( 101 | 'contents, expected_error', 102 | ( 103 | ( 104 | '', 105 | r"Blurb 'body' text must not be empty!", 106 | ), 107 | ( 108 | 'gh-issue: Hello world!', 109 | r"Blurb 'body' can't start with 'gh-'!", 110 | ), 111 | ( 112 | '.. gh-issue: 1\n.. section: IDLE\nHello world!', 113 | r"Invalid gh-issue number: '1' \(must be >= 32426\)", 114 | ), 115 | ( 116 | '.. bpo: one-two\n.. section: IDLE\nHello world!', 117 | r"Invalid bpo number: 'one-two'", 118 | ), 119 | ( 120 | '.. gh-issue: one-two\n.. section: IDLE\nHello world!', 121 | r"Invalid GitHub number: 'one-two'", 122 | ), 123 | ( 124 | '.. gh-issue: 123456\n.. section: Funky Kong\nHello world!', 125 | r"Invalid section 'Funky Kong'! You must use one of the predefined sections", 126 | ), 127 | ( 128 | '.. gh-issue: 123456\nHello world!', 129 | r"No 'section' specified. You must provide one!", 130 | ), 131 | ( 132 | '.. gh-issue: 123456\n.. section: IDLE\n.. section: IDLE\nHello world!', 133 | r"Blurb metadata sets 'section' twice!", 134 | ), 135 | ( 136 | '.. section: IDLE\nHello world!', 137 | r"'gh-issue:' or 'bpo:' must be specified in the metadata!", 138 | ), 139 | ), 140 | ) 141 | def test_parse_no_body(contents, expected_error): 142 | # Arrange 143 | blurbs = Blurbs() 144 | 145 | # Act / Assert 146 | with pytest.raises(BlurbError, match=expected_error): 147 | blurbs.parse(contents) 148 | 149 | 150 | @time_machine.travel('2025-01-07 16:28:41') 151 | def test_sortable_datetime(): 152 | assert sortable_datetime() == '2025-01-07-16-28-41' 153 | -------------------------------------------------------------------------------- /tests/test_add.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | import blurb._add 6 | from blurb._add import ( 7 | _blurb_template_text, 8 | _extract_issue_number, 9 | _extract_section_name, 10 | ) 11 | from blurb._template import sections as SECTIONS 12 | from blurb._template import template as blurb_template 13 | 14 | 15 | def test_valid_no_issue_number(): 16 | assert _extract_issue_number(None) is None 17 | res = _blurb_template_text(issue=None, section=None) 18 | lines = frozenset(res.splitlines()) 19 | assert '.. gh-issue:' not in lines 20 | assert '.. gh-issue: ' in lines 21 | 22 | 23 | @pytest.mark.parametrize( 24 | 'issue', 25 | ( 26 | # issue given by their number 27 | '12345', 28 | ' 12345 ', 29 | # issue given by their number and a 'GH-' prefix 30 | 'GH-12345', 31 | ' GH-12345 ', 32 | # issue given by their number and a 'gh-' prefix 33 | 'gh-12345', 34 | ' gh-12345 ', 35 | # issue given by their number and a '#' prefix 36 | '#12345', 37 | ' #12345 ', 38 | # issue given by their URL (no scheme) 39 | 'github.com/python/cpython/issues/12345', 40 | ' github.com/python/cpython/issues/12345 ', 41 | # issue given by their URL (with scheme) 42 | 'https://github.com/python/cpython/issues/12345', 43 | ' https://github.com/python/cpython/issues/12345 ', 44 | ), 45 | ) 46 | def test_valid_issue_number_12345(issue): 47 | actual = _extract_issue_number(issue) 48 | assert actual == 12345 49 | 50 | res = _blurb_template_text(issue=issue, section=None) 51 | lines = frozenset(res.splitlines()) 52 | assert '.. gh-issue:' not in lines 53 | assert '.. gh-issue: ' not in lines 54 | assert '.. gh-issue: 12345' in lines 55 | 56 | 57 | @pytest.mark.parametrize( 58 | 'issue', 59 | ( 60 | '', 61 | 'abc', 62 | 'Gh-123', 63 | 'gh-abc', 64 | 'gh- 123', 65 | 'gh -123', 66 | 'gh-', 67 | 'bpo-', 68 | 'bpo-12345', 69 | 'github.com/python/cpython/issues', 70 | 'github.com/python/cpython/issues/', 71 | 'github.com/python/cpython/issues/abc', 72 | 'github.com/python/cpython/issues/gh-abc', 73 | 'github.com/python/cpython/issues/gh-123', 74 | 'github.com/python/cpython/issues/1234?param=1', 75 | 'https://github.com/python/cpython/issues', 76 | 'https://github.com/python/cpython/issues/', 77 | 'https://github.com/python/cpython/issues/abc', 78 | 'https://github.com/python/cpython/issues/gh-abc', 79 | 'https://github.com/python/cpython/issues/gh-123', 80 | 'https://github.com/python/cpython/issues/1234?param=1', 81 | ), 82 | ) 83 | def test_invalid_issue_number(issue): 84 | error_message = re.escape(f'Invalid GitHub issue number: {issue}') 85 | with pytest.raises(SystemExit, match=error_message): 86 | _blurb_template_text(issue=issue, section=None) 87 | 88 | 89 | @pytest.mark.parametrize( 90 | 'invalid', 91 | ( 92 | 'gh-issue: ', 93 | 'gh-issue: 1', 94 | 'gh-issue', 95 | ), 96 | ) 97 | def test_malformed_gh_issue_line(invalid, monkeypatch): 98 | template = blurb_template.replace('.. gh-issue:', invalid) 99 | error_message = re.escape("Can't find gh-issue line in the template!") 100 | with monkeypatch.context() as cm: 101 | cm.setattr(blurb._add, 'template', template) 102 | with pytest.raises(SystemExit, match=error_message): 103 | _blurb_template_text(issue='1234', section=None) 104 | 105 | 106 | def _check_section_name(section_name, expected): 107 | actual = _extract_section_name(section_name) 108 | assert actual == expected 109 | 110 | res = _blurb_template_text(issue=None, section=section_name) 111 | res = res.splitlines() 112 | for section_name in SECTIONS: 113 | if section_name == expected: 114 | assert f'.. section: {section_name}' in res 115 | else: 116 | assert f'#.. section: {section_name}' in res 117 | assert f'.. section: {section_name}' not in res 118 | 119 | 120 | @pytest.mark.parametrize( 121 | ('section_name', 'expected'), 122 | [(name, name) for name in SECTIONS], 123 | ) 124 | def test_exact_names(section_name, expected): 125 | _check_section_name(section_name, expected) 126 | 127 | 128 | @pytest.mark.parametrize( 129 | ('section_name', 'expected'), 130 | [(name.lower(), name) for name in SECTIONS], 131 | ) 132 | def test_exact_names_lowercase(section_name, expected): 133 | _check_section_name(section_name, expected) 134 | 135 | 136 | @pytest.mark.parametrize( 137 | 'section', 138 | ( 139 | '', 140 | ' ', 141 | '\t', 142 | '\n', 143 | '\r\n', 144 | ' ', 145 | ), 146 | ) 147 | def test_empty_section_name(section): 148 | error_message = re.escape('Empty section name!') 149 | with pytest.raises(SystemExit, match=error_message): 150 | _extract_section_name(section) 151 | 152 | with pytest.raises(SystemExit, match=error_message): 153 | _blurb_template_text(issue=None, section=section) 154 | 155 | 156 | @pytest.mark.parametrize( 157 | 'section', 158 | [ 159 | # Wrong capitalisation 160 | 'C api', 161 | 'c API', 162 | 'LibrarY', 163 | # Invalid 164 | '_', 165 | '-', 166 | '/', 167 | 'invalid', 168 | 'Not a section', 169 | # Non-special names 170 | 'c?api', 171 | 'cXapi', 172 | 'C+API', 173 | # Super-strings 174 | 'Library and more', 175 | 'library3', 176 | 'librari', 177 | ], 178 | ) 179 | def test_invalid_section_name(section): 180 | error_message = rf"(?m)Invalid section name: '{re.escape(section)}'\n\n.+" 181 | with pytest.raises(SystemExit, match=error_message): 182 | _extract_section_name(section) 183 | 184 | with pytest.raises(SystemExit, match=error_message): 185 | _blurb_template_text(issue=None, section=section) 186 | -------------------------------------------------------------------------------- /src/blurb/_add.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import atexit 4 | import os 5 | import shlex 6 | import shutil 7 | import subprocess 8 | import sys 9 | import tempfile 10 | 11 | from blurb._blurb_file import BlurbError, Blurbs 12 | from blurb._cli import error, prompt 13 | from blurb._git import flush_git_add_files, git_add_files 14 | from blurb._template import sections, template 15 | 16 | TYPE_CHECKING = False 17 | if TYPE_CHECKING: 18 | from collections.abc import Sequence 19 | 20 | if sys.platform == 'win32': 21 | FALLBACK_EDITORS = ('notepad.exe',) 22 | else: 23 | FALLBACK_EDITORS = ('/etc/alternatives/editor', 'nano') 24 | 25 | 26 | def add(*, issue: str | None = None, section: str | None = None): 27 | """Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. 28 | 29 | Use -i/--issue to specify a GitHub issue number or link, e.g.: 30 | 31 | blurb add -i 12345 32 | # or 33 | blurb add -i https://github.com/python/cpython/issues/12345 34 | 35 | Use -s/--section to specify the section name (case-insensitive), e.g.: 36 | 37 | blurb add -s Library 38 | # or 39 | blurb add -s library 40 | 41 | The known sections names are defined as follows and 42 | spaces in names can be substituted for underscores: 43 | 44 | {sections} 45 | """ # fmt: skip 46 | 47 | handle, tmp_path = tempfile.mkstemp('.rst') 48 | os.close(handle) 49 | atexit.register(lambda: os.unlink(tmp_path)) 50 | 51 | text = _blurb_template_text(issue=issue, section=section) 52 | with open(tmp_path, 'w', encoding='utf-8') as file: 53 | file.write(text) 54 | 55 | args = _editor_args() 56 | args.append(tmp_path) 57 | 58 | while True: 59 | blurb = _add_blurb_from_template(args, tmp_path) 60 | if blurb is None: 61 | try: 62 | prompt('Hit return to retry (or Ctrl-C to abort)') 63 | except KeyboardInterrupt: 64 | print() 65 | return 66 | print() 67 | continue 68 | break 69 | 70 | path = blurb.save_next() 71 | git_add_files.append(path) 72 | flush_git_add_files() 73 | print('Ready for commit.') 74 | 75 | 76 | add.__doc__ = add.__doc__.format(sections='\n'.join(f'* {s}' for s in sections)) 77 | 78 | 79 | def _editor_args() -> list[str]: 80 | editor = _find_editor() 81 | 82 | # We need to be clever about EDITOR. 83 | # On the one hand, it might be a legitimate path to an 84 | # executable containing spaces. 85 | # On the other hand, it might be a partial command-line 86 | # with options. 87 | if shutil.which(editor): 88 | args = [editor] 89 | else: 90 | args = list(shlex.split(editor)) 91 | if not shutil.which(args[0]): 92 | raise SystemExit(f'Invalid GIT_EDITOR / EDITOR value: {editor}') 93 | return args 94 | 95 | 96 | def _find_editor() -> str: 97 | for var in 'GIT_EDITOR', 'EDITOR': 98 | editor = os.environ.get(var) 99 | if editor is not None: 100 | return editor 101 | for fallback in FALLBACK_EDITORS: 102 | if os.path.isabs(fallback): 103 | found_path = fallback 104 | else: 105 | found_path = shutil.which(fallback) 106 | if found_path and os.path.exists(found_path): 107 | return found_path 108 | error('Could not find an editor! Set the EDITOR environment variable.') 109 | 110 | 111 | def _blurb_template_text(*, issue: str | None, section: str | None) -> str: 112 | issue_number = _extract_issue_number(issue) 113 | section_name = _extract_section_name(section) 114 | 115 | text = template 116 | 117 | # Ensure that there is a trailing space after '.. gh-issue:' to make 118 | # filling in the template easier, unless an issue number was given 119 | # through the --issue command-line flag. 120 | issue_line = '.. gh-issue:' 121 | without_space = f'\n{issue_line}\n' 122 | if without_space not in text: 123 | raise SystemExit("Can't find gh-issue line in the template!") 124 | if issue_number is None: 125 | with_space = f'\n{issue_line} \n' 126 | text = text.replace(without_space, with_space) 127 | else: 128 | with_issue_number = f'\n{issue_line} {issue_number}\n' 129 | text = text.replace(without_space, with_issue_number) 130 | 131 | # Uncomment the section if needed. 132 | if section_name is not None: 133 | pattern = f'.. section: {section_name}' 134 | text = text.replace(f'#{pattern}', pattern) 135 | 136 | return text 137 | 138 | 139 | def _extract_issue_number(issue: str | None, /) -> int | None: 140 | if issue is None: 141 | return None 142 | issue = issue.strip() 143 | 144 | if issue.startswith(('GH-', 'gh-')): 145 | stripped = issue[3:] 146 | else: 147 | stripped = issue.removeprefix('#') 148 | try: 149 | if stripped.isdecimal(): 150 | return int(stripped) 151 | except ValueError: 152 | pass 153 | 154 | # Allow GitHub URL with or without the scheme 155 | stripped = issue.removeprefix('https://') 156 | stripped = stripped.removeprefix('github.com/python/cpython/issues/') 157 | try: 158 | if stripped.isdecimal(): 159 | return int(stripped) 160 | except ValueError: 161 | pass 162 | 163 | raise SystemExit(f'Invalid GitHub issue number: {issue}') 164 | 165 | 166 | def _extract_section_name(section: str | None, /) -> str | None: 167 | if section is None: 168 | return None 169 | 170 | section = section.strip() 171 | if not section: 172 | raise SystemExit('Empty section name!') 173 | 174 | matches = [] 175 | # Try an exact or lowercase match 176 | for section_name in sections: 177 | if section in {section_name, section_name.lower()}: 178 | matches.append(section_name) 179 | 180 | if not matches: 181 | section_list = '\n'.join(f'* {s}' for s in sections) 182 | raise SystemExit( 183 | f'Invalid section name: {section!r}\n\nValid names are:\n\n{section_list}' 184 | ) 185 | 186 | if len(matches) > 1: 187 | multiple_matches = ', '.join(f'* {m}' for m in sorted(matches)) 188 | raise SystemExit(f'More than one match for {section!r}:\n\n{multiple_matches}') 189 | 190 | return matches[0] 191 | 192 | 193 | def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | None: 194 | subprocess.run(args) 195 | 196 | failure = '' 197 | blurb = Blurbs() 198 | try: 199 | blurb.load(tmp_path) 200 | except BlurbError as e: 201 | failure = str(e) 202 | 203 | if not failure: 204 | assert len(blurb) # if parse_blurb succeeds, we should always have a body 205 | if len(blurb) > 1: 206 | failure = "Too many entries! Don't specify '..' on a line by itself." 207 | 208 | if failure: 209 | print() 210 | print(f'Error: {failure}') 211 | print() 212 | return None 213 | return blurb 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blurb 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/blurb.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/blurb) 4 | [![GitHub Actions](https://github.com/python/blurb/actions/workflows/test.yml/badge.svg)](https://github.com/python/blurb/actions) 5 | [![Codecov](https://codecov.io/gh/python/blurb/branch/main/graph/badge.svg)](https://codecov.io/gh/python/blurb) 6 | [![Python discussions](https://img.shields.io/badge/Discourse-join_chat-brightgreen.svg)](https://discuss.python.org/) 7 | 8 | ## Overview 9 | 10 | **blurb** is a tool designed to rid CPython core development 11 | of the scourge of `Misc/NEWS` conflicts. 12 | 13 | The core concept: split `Misc/NEWS` into many 14 | separate files that, when concatenated back together 15 | in sorted order, reconstitute the original `Misc/NEWS` file. 16 | After that, `Misc/NEWS` could be deleted from the CPython 17 | repo and thereafter rendered on demand (e.g. when building 18 | a release). When committing a change to CPython, the commit 19 | process will write out a new file that sorts into the correct place, 20 | using a filename unlikely to have a merge conflict. 21 | 22 | **blurb** is a single command with a number of subcommands. 23 | It's designed to be run inside a valid CPython (Git) repo, 24 | and automatically uses the correct file paths. 25 | 26 | You can install **blurb** from PyPI using `pip`. Alternatively, 27 | simply add `blurb` to a directory on your path. 28 | 29 | 30 | ## Files used by blurb 31 | 32 | **blurb** uses a new directory tree called `Misc/NEWS.d`. 33 | Everything it does is in there, except for possibly 34 | modifying `Misc/NEWS`. 35 | 36 | Under `Misc/NEWS.d` you'll find the following: 37 | 38 | * A single file for all news entries per previous revision, 39 | named for the exact version number, with the extension `.rst`. 40 | Example: `Misc/NEWS.d/3.6.0b2.rst`. 41 | 42 | * The `next` directory, which contains subdirectories representing 43 | the various `Misc/NEWS` categories. Inside these subdirectories 44 | are more `.rst` files with long, uninteresting, computer-generated 45 | names. Example: 46 | `Misc/NEWS.d/next/Library/2017-05-04-12-24-06.gh-issue-25458.Yl4gI2.rst` 47 | 48 | 49 | ## blurb subcommands 50 | 51 | Like many modern utilities, **blurb** has only one executable 52 | (called `blurb`), but provides a diverse set of functionality 53 | through subcommands. The subcommand is the first argument specified 54 | on the command-line. 55 | 56 | If you're a CPython contributor, you probably don't need to use 57 | anything except `blurb add` — and you don't even need to specify 58 | the `add` part. 59 | (If no subcommand is specified, **blurb** assumes you meant `blurb add`.) 60 | The other commands are only expected to be useful for CPython release 61 | managers. 62 | 63 | 64 | 65 | ### blurb help 66 | 67 | **blurb** is self-documenting through the `blurb help` subcommand. 68 | Run without any further arguments, it prints a list of all subcommands, 69 | with a one-line summary of the functionality of each. Run with a 70 | third argument, it prints help on that subcommand (e.g. `blurb help release`). 71 | 72 | 73 | ### blurb add 74 | 75 | `blurb add` adds a new `Misc/NEWS` entry for you. 76 | It opens a text editor on a template; you edit the 77 | file, save, and exit. **blurb** then stores the file 78 | in the correct place, and stages it in Git for you. 79 | 80 | The template for the `blurb add` message looks like this: 81 | 82 | # 83 | # Please enter the relevant GitHub issue number here: 84 | # 85 | .. gh-issue: 86 | 87 | # 88 | # Uncomment one of these "section:" lines to specify which section 89 | # this entry should go in in Misc/NEWS. 90 | # 91 | #.. section: Security 92 | #.. section: Core and Builtins 93 | #.. section: Library 94 | #.. section: Documentation 95 | #.. section: Tests 96 | #.. section: Build 97 | #.. section: Windows 98 | #.. section: macOS 99 | #.. section: IDLE 100 | #.. section: Tools/Demos 101 | #.. section: C API 102 | 103 | # Write your Misc/NEWS entry below. It should be a simple ReST paragraph. 104 | # Don't start with "- Issue #: " or "- gh-issue: " or that sort of stuff. 105 | ########################################################################### 106 | 107 | Here's how you interact with the file: 108 | 109 | * Add the GitHub issue number for this commit to the 110 | end of the `.. gh-issue:` line. 111 | The issue can also be specified via the ``-i`` / ``--issue`` option: 112 | 113 | ```shell 114 | $ blurb add -i 109198 115 | # or equivalently 116 | $ blurb add -i https://github.com/python/cpython/issues/109198 117 | ``` 118 | 119 | * Uncomment the line with the relevant `Misc/NEWS` section for this entry. 120 | For example, if this should go in the `Library` section, uncomment 121 | the line reading `#.. section: Library`. To uncomment, just delete 122 | the `#` at the front of the line. 123 | The section can also be specified via the ``-s`` / ``--section`` option: 124 | 125 | ```shell 126 | $ blurb add -s Library 127 | # or 128 | $ blurb add -s library 129 | ``` 130 | 131 | * Finally, go to the end of the file, and enter your `NEWS` entry. 132 | This should be a single paragraph of English text using 133 | simple reST markup. 134 | 135 | When `blurb add` gets a valid entry, it writes it to a file 136 | with the following format: 137 | 138 | Misc/NEWS.d/next/
/.gh-issue-..rst 139 | 140 | For example, a file added by `blurb add` might look like this:: 141 | 142 | Misc/NEWS.d/next/Library/2017-05-04-12-24-06.gh-issue-25458.Yl4gI2.rst 143 | 144 | `
` is the section provided in the commit message. 145 | 146 | `` is the current UTC time, formatted as 147 | `YYYY-MM-DD-hh-mm-ss`. 148 | 149 | `` is a hopefully-unique string of characters meant to 150 | prevent filename collisions. **blurb** creates this by computing 151 | the MD5 hash of the text, converting it to base64 (using the 152 | "urlsafe" alphabet), and taking the first 6 characters of that. 153 | 154 | 155 | This filename ensures several things: 156 | 157 | * All entries in `Misc/NEWS` will be sorted by time. 158 | 159 | * It is unthinkably unlikely that there'll be a conflict 160 | between the filenames generated for two developers committing, 161 | even if they commit in at the exact same second. 162 | 163 | 164 | Finally, `blurb add` stages the file in git for you. 165 | 166 | 167 | ### blurb merge 168 | 169 | `blurb merge` recombines all the files in the 170 | `Misc/NEWS.d` tree back into a single `NEWS` file. 171 | 172 | `blurb merge` accepts only a single command-line argument: 173 | the file to write to. By default, it writes to 174 | `Misc/NEWS` (relative to the root of your CPython checkout). 175 | 176 | Splitting and recombining the existing `Misc/NEWS` file 177 | doesn't recreate the previous `Misc/NEWS` exactly. This 178 | is because `Misc/NEWS` never used a consistent ordering 179 | for the "sections" inside each release, whereas `blurb merge` 180 | has a hard-coded preferred ordering for the sections. Also, 181 | **blurb** aggressively reflows paragraphs to < 78 columns, 182 | wheras the original hand-edited file occasionally had lines > 183 | 80 columns. Finally, **blurb** strictly uses `gh-issue-:` to 184 | specify issue numbers at the beginnings of entries, wheras 185 | the legacy approach to `Misc/NEWS` required using `Issue #:`. 186 | 187 | 188 | ### blurb release 189 | 190 | `blurb release` is used by the release manager as part of 191 | the CPython release process. It takes exactly one argument, 192 | the name of the version being released. 193 | 194 | Here's what it does under the hood: 195 | 196 | * Combines all recently-added NEWS entries from 197 | the `Misc/NEWS.d/next` directory into `Misc/NEWS.d/.rst`. 198 | * Runs `blurb merge` to produce an updated `Misc/NEWS` file. 199 | 200 | One hidden feature: if the version specified is `.`, `blurb release` 201 | uses the name of the directory CPython is checked out to. 202 | (When making a release I generally name the directory after the 203 | version I'm releasing, and using this shortcut saves me some typing.) 204 | 205 | 206 | 207 | ## The "next" directory 208 | 209 | You may have noticed that `blurb add` adds news entries to 210 | a directory called `next`, and `blurb release` combines those 211 | news entries into a single file named with the version. Why 212 | is that? 213 | 214 | First, it makes naming the next version a late-binding decision. 215 | If we are currently working on 3.6.5rc1, but there's a zero-day 216 | exploit and we need to release an emergency 3.6.5 final, we don't 217 | have to fix up a bunch of metadata. 218 | 219 | Second, it means that if you cherry-pick a commit forward or 220 | backwards, you automatically pick up the `NEWS` entry too. You 221 | don't need to touch anything up — the system will already do 222 | the right thing. If `NEWS` entries were already written to the 223 | final version directory, you'd have to move those around as 224 | part of the cherry-picking process. 225 | 226 | ## Copyright 227 | 228 | **blurb** is Copyright 2015-2018 by Larry Hastings. 229 | Licensed to the PSF under a contributor agreement. 230 | 231 | ## Changelog 232 | 233 | See [CHANGELOG.md](CHANGELOG.md). 234 | -------------------------------------------------------------------------------- /src/blurb/_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import os 5 | import re 6 | import sys 7 | 8 | import blurb 9 | 10 | TYPE_CHECKING = False 11 | if TYPE_CHECKING: 12 | from collections.abc import Callable 13 | from typing import NoReturn, TypeAlias 14 | 15 | CommandFunc: TypeAlias = Callable[..., None] 16 | 17 | 18 | subcommands: dict[str, CommandFunc] = {} 19 | readme_re = re.compile(r'This is \w+ version \d+\.\d+').match 20 | 21 | 22 | def initialise_subcommands() -> None: 23 | global subcommands 24 | 25 | from blurb._add import add 26 | from blurb._export import export 27 | from blurb._merge import merge 28 | from blurb._populate import populate 29 | from blurb._release import release 30 | 31 | subcommands = { 32 | 'version': version, 33 | 'help': help, 34 | 'add': add, 35 | 'export': export, 36 | 'merge': merge, 37 | 'populate': populate, 38 | 'release': release, 39 | # Make 'blurb --help/--version/-V' work. 40 | '--help': help, 41 | '--version': version, 42 | '-V': version, 43 | } 44 | 45 | 46 | def error(msg: str, /) -> NoReturn: 47 | raise SystemExit(f'Error: {msg}') 48 | 49 | 50 | def prompt(prompt: str, /) -> str: 51 | return input(f'[{prompt}> ') 52 | 53 | 54 | def require_ok(prompt: str, /) -> str: 55 | prompt = f'[{prompt}> ' 56 | while True: 57 | s = input(prompt).strip() 58 | if s == 'ok': 59 | return s 60 | 61 | 62 | def get_subcommand(subcommand: str, /) -> CommandFunc: 63 | fn = subcommands.get(subcommand) 64 | if not fn: 65 | error(f"Unknown subcommand: {subcommand}\nRun 'blurb help' for help.") 66 | return fn 67 | 68 | 69 | def version() -> None: 70 | """Print blurb version.""" 71 | print('blurb version', blurb.__version__) 72 | 73 | 74 | def help(subcommand: str | None = None) -> None: 75 | """Print help for subcommands. 76 | 77 | Prints the help text for the specified subcommand. 78 | If subcommand is not specified, prints one-line summaries for every command. 79 | """ 80 | 81 | if not subcommand: 82 | _blurb_help() 83 | raise SystemExit(0) 84 | 85 | fn = get_subcommand(subcommand) 86 | doc = fn.__doc__.strip() 87 | if not doc: 88 | error(f'help is broken, no docstring for {subcommand}') 89 | 90 | options = [] 91 | positionals = [] 92 | 93 | nesting = 0 94 | for name, p in inspect.signature(fn).parameters.items(): 95 | if p.kind == inspect.Parameter.KEYWORD_ONLY: 96 | short_option = name[0] 97 | if isinstance(p.default, bool): 98 | options.append(f' [-{short_option}|--{name}]') 99 | else: 100 | if p.default is None: 101 | metavar = f'{name.upper()}' 102 | else: 103 | metavar = f'{name.upper()}[={p.default}]' 104 | options.append(f' [-{short_option}|--{name} {metavar}]') 105 | elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: 106 | positionals.append(' ') 107 | has_default = p.default != inspect._empty 108 | if has_default: 109 | positionals.append('[') 110 | nesting += 1 111 | positionals.append(f'<{name}>') 112 | positionals.append(']' * nesting) 113 | 114 | parameters = ''.join(options + positionals) 115 | print(f'blurb {subcommand}{parameters}') 116 | print() 117 | print(doc) 118 | raise SystemExit(0) 119 | 120 | 121 | def _blurb_help() -> None: 122 | """Print default help for blurb.""" 123 | 124 | print('blurb version', blurb.__version__) 125 | print() 126 | print('Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.') 127 | print() 128 | print('Usage:') 129 | print(' blurb [subcommand] [options...]') 130 | print() 131 | 132 | # print list of subcommands 133 | summaries = [] 134 | longest_name_len = -1 135 | for name, fn in subcommands.items(): 136 | if name.startswith('-'): 137 | continue 138 | longest_name_len = max(longest_name_len, len(name)) 139 | if not fn.__doc__: 140 | error(f'help is broken, no docstring for {fn.__name__}') 141 | fields = fn.__doc__.lstrip().split('\n') 142 | if not fields: 143 | first_line = '(no help available)' 144 | else: 145 | first_line = fields[0] 146 | summaries.append((name, first_line)) 147 | summaries.sort() 148 | 149 | print('Available subcommands:') 150 | print() 151 | for name, summary in summaries: 152 | print(' ', name.ljust(longest_name_len), ' ', summary) 153 | 154 | print() 155 | print("If blurb is run without any arguments, this is equivalent to 'blurb add'.") 156 | 157 | 158 | def main() -> None: 159 | args = sys.argv[1:] 160 | 161 | if not args: 162 | args = ['add'] 163 | elif args[0] == '-h': 164 | # slight hack 165 | args[0] = 'help' 166 | 167 | subcommand = args[0] 168 | args = args[1:] 169 | 170 | initialise_subcommands() 171 | fn = get_subcommand(subcommand) 172 | 173 | # hack 174 | if fn in (help, version): 175 | raise SystemExit(fn(*args)) 176 | 177 | import blurb._merge 178 | 179 | blurb._merge.original_dir = os.getcwd() 180 | try: 181 | chdir_to_repo_root() 182 | 183 | # map keyword arguments to options 184 | # we only handle boolean options 185 | # and they must have default values 186 | short_options = {} 187 | long_options = {} 188 | kwargs = {} 189 | for name, p in inspect.signature(fn).parameters.items(): 190 | if p.kind == inspect.Parameter.KEYWORD_ONLY: 191 | if p.default is not None and not isinstance(p.default, (bool, str)): 192 | raise SystemExit( 193 | 'blurb command-line processing cannot handle ' 194 | f'options of type {type(p.default).__qualname__}' 195 | ) 196 | 197 | kwargs[name] = p.default 198 | short_options[name[0]] = name 199 | long_options[name] = name 200 | 201 | filtered_args = [] 202 | done_with_options = False 203 | consume_after = None 204 | 205 | def handle_option(s, dict): 206 | nonlocal consume_after 207 | name = dict.get(s, None) 208 | if not name: 209 | raise SystemExit(f'blurb: Unknown option for {subcommand}: "{s}"') 210 | 211 | value = kwargs[name] 212 | if isinstance(value, bool): 213 | kwargs[name] = not value 214 | else: 215 | consume_after = name 216 | 217 | for a in args: 218 | if consume_after: 219 | kwargs[consume_after] = a 220 | consume_after = None 221 | continue 222 | if done_with_options: 223 | filtered_args.append(a) 224 | continue 225 | if a.startswith('-'): 226 | if a == '--': 227 | done_with_options = True 228 | elif a.startswith('--'): 229 | handle_option(a[2:], long_options) 230 | else: 231 | for s in a[1:]: 232 | handle_option(s, short_options) 233 | continue 234 | filtered_args.append(a) 235 | 236 | if consume_after: 237 | raise SystemExit( 238 | f'Error: blurb: {subcommand} {consume_after} ' 239 | 'must be followed by an option argument' 240 | ) 241 | 242 | raise SystemExit(fn(*filtered_args, **kwargs)) 243 | except TypeError as e: 244 | # almost certainly wrong number of arguments. 245 | # count arguments of function and print appropriate error message. 246 | specified = len(args) 247 | required = optional = 0 248 | for p in inspect.signature(fn).parameters.values(): 249 | if p.default == inspect._empty: 250 | required += 1 251 | else: 252 | optional += 1 253 | total = required + optional 254 | 255 | if required <= specified <= total: 256 | # whoops, must be a real type error, reraise 257 | raise e 258 | 259 | how_many = f'{specified} argument' 260 | if specified != 1: 261 | how_many += 's' 262 | 263 | if total == 0: 264 | middle = 'accepts no arguments' 265 | else: 266 | if total == required: 267 | middle = 'requires' 268 | else: 269 | plural = '' if required == 1 else 's' 270 | middle = f'requires at least {required} argument{plural} and at most' 271 | middle += f' {total} argument' 272 | if total != 1: 273 | middle += 's' 274 | 275 | print( 276 | f'Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}.' 277 | ) 278 | print() 279 | print('usage: ', end='') 280 | help(subcommand) 281 | 282 | 283 | def chdir_to_repo_root() -> str: 284 | # find the root of the local CPython repo 285 | # note that we can't ask git, because we might 286 | # be in an exported directory tree! 287 | 288 | # we intentionally start in a (probably nonexistant) subtree 289 | # the first thing the while loop does is .., basically 290 | path = os.path.abspath('garglemox') 291 | while True: 292 | next_path = os.path.dirname(path) 293 | if next_path == path: 294 | raise SystemExit("You're not inside a CPython repo right now!") 295 | path = next_path 296 | 297 | os.chdir(path) 298 | 299 | def test_first_line(filename, test): 300 | if not os.path.exists(filename): 301 | return False 302 | with open(filename, encoding='utf-8') as file: 303 | lines = file.read().split('\n') 304 | if not (lines and test(lines[0])): 305 | return False 306 | return True 307 | 308 | if not ( 309 | test_first_line('README', readme_re) 310 | or test_first_line('README.rst', readme_re) 311 | ): 312 | continue 313 | 314 | if not test_first_line('LICENSE', 'A. HISTORY OF THE SOFTWARE'.__eq__): 315 | continue 316 | if not os.path.exists('Include/Python.h'): 317 | continue 318 | if not os.path.exists('Python/ceval.c'): 319 | continue 320 | 321 | break 322 | 323 | import blurb._blurb_file 324 | 325 | blurb._blurb_file.root = path 326 | return path 327 | -------------------------------------------------------------------------------- /src/blurb/_blurb_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | The format of a blurb file: 4 | 5 | ENTRY 6 | [ENTRY2 7 | ENTRY3 8 | ...] 9 | 10 | In other words, you may have one or more ENTRYs (entries) in a blurb file. 11 | 12 | The format of an ENTRY: 13 | 14 | METADATA 15 | BODY 16 | 17 | The METADATA section is optional. 18 | The BODY section is mandatory and must be non-empty. 19 | 20 | Format of the METADATA section: 21 | 22 | * Lines starting with ".." are metadata lines of the format: 23 | .. name: value 24 | * Lines starting with "#" are comments: 25 | # comment line 26 | * Empty and whitespace-only lines are ignored. 27 | * Trailing whitespace is removed. Leading whitespace is not removed 28 | or ignored. 29 | 30 | The first nonblank line that doesn't start with ".." or "#" automatically 31 | terminates the METADATA section and is the first line of the BODY. 32 | 33 | Format of the BODY section: 34 | 35 | * The BODY section should be a single paragraph of English text 36 | in ReST format. It should not use the following ReST markup 37 | features: 38 | * section headers 39 | * comments 40 | * directives, citations, or footnotes 41 | * Any features that require significant line breaks, 42 | like lists, definition lists, quoted paragraphs, line blocks, 43 | literal code blocks, and tables. 44 | Note that this is not (currently) enforced. 45 | * Trailing whitespace is stripped. Leading whitespace is preserved. 46 | * Empty lines between non-empty lines are preserved. 47 | Trailing empty lines are stripped. 48 | * The BODY mustn't start with "Issue #", "gh-", or "- ". 49 | (This formatting will be inserted when rendering the final output.) 50 | * Lines longer than 76 characters will be wordwrapped. 51 | * In the final output, the first line will have 52 | "- gh-issue-: " inserted at the front, 53 | and subsequent lines will have two spaces inserted 54 | at the front. 55 | 56 | To terminate an ENTRY, specify a line containing only "..". End of file 57 | also terminates the last ENTRY. 58 | 59 | ----------------------------------------------------------------------------- 60 | 61 | The format of a "next" file is exactly the same, except that we're storing 62 | four pieces of metadata in the filename instead of in the metadata section. 63 | Those four pieces of metadata are: section, gh-issue, date, and nonce. 64 | 65 | ----------------------------------------------------------------------------- 66 | 67 | In addition to the four conventional metadata (section, gh-issue, date, and nonce), 68 | there are two additional metadata used per-version: "release date" and 69 | "no changes". These may only be present in the metadata block in the *first* 70 | blurb in a blurb file. 71 | * "release date" is the day a particular version of Python was released. 72 | * "no changes", if present, notes that there were no actual changes 73 | for this version. When used, there are two more things that must be 74 | true about the the blurb file: 75 | * There should only be one entry inside the blurb file. 76 | * That entry's gh-issue number must be 0. 77 | 78 | """ 79 | 80 | from __future__ import annotations 81 | 82 | import os 83 | import re 84 | import time 85 | 86 | from blurb._template import sanitize_section, sections, unsanitize_section 87 | from blurb._utils.text import generate_nonce, textwrap_body 88 | 89 | root = None # Set by chdir_to_repo_root() 90 | lowest_possible_gh_issue_number = 32426 91 | 92 | 93 | class BlurbError(RuntimeError): 94 | pass 95 | 96 | 97 | class Blurbs(list): 98 | def parse( 99 | self, 100 | text: str, 101 | *, 102 | metadata: dict[str, str] | None = None, 103 | filename: str = 'input', 104 | ) -> None: 105 | """Parses a string. 106 | 107 | Appends a list of blurb ENTRIES to self, as tuples: (metadata, body) 108 | metadata is a dict. body is a string. 109 | """ 110 | 111 | metadata = metadata or {} 112 | body = [] 113 | in_metadata = True 114 | 115 | line_number = None 116 | 117 | def throw(s: str): 118 | raise BlurbError(f'Error in {filename}:{line_number}:\n{s}') 119 | 120 | def finish_entry() -> None: 121 | nonlocal body 122 | nonlocal in_metadata 123 | nonlocal metadata 124 | nonlocal self 125 | 126 | if not body: 127 | throw("Blurb 'body' text must not be empty!") 128 | text = textwrap_body(body) 129 | for naughty_prefix in ('- ', 'Issue #', 'bpo-', 'gh-', 'gh-issue-'): 130 | if re.match(naughty_prefix, text, re.I): 131 | throw(f"Blurb 'body' can't start with {naughty_prefix!r}!") 132 | 133 | no_changes = metadata.get('no changes') 134 | 135 | issue_keys = { 136 | 'gh-issue': 'GitHub', 137 | 'bpo': 'bpo', 138 | } 139 | for key, value in metadata.items(): 140 | # Iterate over metadata items in order. 141 | # We parsed the blurb file line by line, 142 | # so we'll insert metadata keys in the 143 | # order we see them. So if we issue the 144 | # errors in the order we see the keys, 145 | # we'll complain about the *first* error 146 | # we see in the blurb file, which is a 147 | # better user experience. 148 | if key in issue_keys: 149 | try: 150 | int(value) 151 | except (TypeError, ValueError): 152 | throw(f'Invalid {issue_keys[key]} number: {value!r}') 153 | 154 | if key == 'gh-issue' and int(value) < lowest_possible_gh_issue_number: 155 | throw( 156 | f'Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})' 157 | ) 158 | 159 | if key == 'section': 160 | if no_changes: 161 | continue 162 | if value not in sections: 163 | throw( 164 | f'Invalid section {value!r}! You must use one of the predefined sections.' 165 | ) 166 | 167 | if 'gh-issue' not in metadata and 'bpo' not in metadata: 168 | throw("'gh-issue:' or 'bpo:' must be specified in the metadata!") 169 | 170 | if 'section' not in metadata: 171 | throw("No 'section' specified. You must provide one!") 172 | 173 | self.append((metadata, text)) 174 | metadata = {} 175 | body = [] 176 | in_metadata = True 177 | 178 | for line_number, line in enumerate(text.split('\n')): 179 | line = line.rstrip() 180 | if in_metadata: 181 | if line.startswith('..'): 182 | line = line[2:].strip() 183 | name, colon, value = line.partition(':') 184 | assert colon 185 | name = name.lower().strip() 186 | value = value.strip() 187 | if name in metadata: 188 | throw(f'Blurb metadata sets {name!r} twice!') 189 | metadata[name] = value 190 | continue 191 | if line.startswith('#') or not line: 192 | continue 193 | in_metadata = False 194 | 195 | if line == '..': 196 | finish_entry() 197 | continue 198 | body.append(line) 199 | 200 | finish_entry() 201 | 202 | def load(self, filename: str, *, metadata: dict[str, str] | None = None) -> None: 203 | """Read a blurb file. 204 | 205 | Broadly equivalent to blurb.parse(open(filename).read()). 206 | """ 207 | with open(filename, encoding='utf-8') as file: 208 | text = file.read() 209 | self.parse(text, metadata=metadata, filename=filename) 210 | 211 | def __str__(self) -> str: 212 | output = [] 213 | add = output.append 214 | add_separator = False 215 | for metadata, body in self: 216 | if add_separator: 217 | add('\n..\n\n') 218 | else: 219 | add_separator = True 220 | if metadata: 221 | for name, value in sorted(metadata.items()): 222 | add(f'.. {name}: {value}\n') 223 | add('\n') 224 | add(textwrap_body(body)) 225 | return ''.join(output) 226 | 227 | def save(self, path: str) -> None: 228 | dirname = os.path.dirname(path) 229 | os.makedirs(dirname, exist_ok=True) 230 | 231 | text = str(self) 232 | with open(path, 'w', encoding='utf-8') as file: 233 | file.write(text) 234 | 235 | @staticmethod 236 | def _parse_next_filename(filename: str) -> dict[str, str]: 237 | """Returns a dict of blurb metadata from a parsed "next" filename.""" 238 | components = filename.split(os.sep) 239 | section, filename = components[-2:] 240 | section = unsanitize_section(section) 241 | assert section in sections, f'Unknown section {section}' 242 | 243 | fields = [x.strip() for x in filename.split('.')] 244 | assert len(fields) >= 4, ( 245 | f"Can't parse 'next' filename! filename {filename!r} fields {fields}" 246 | ) 247 | assert fields[-1] == 'rst' 248 | 249 | metadata = {'date': fields[0], 'nonce': fields[-2], 'section': section} 250 | 251 | for field in fields[1:-2]: 252 | for name in ('gh-issue', 'bpo'): 253 | _, got, value = field.partition(f'{name}-') 254 | if got: 255 | metadata[name] = value.strip() 256 | break 257 | else: 258 | assert False, f"Found unparsable field in 'next' filename: {field!r}" 259 | 260 | return metadata 261 | 262 | def load_next(self, filename: str) -> None: 263 | metadata = self._parse_next_filename(filename) 264 | o = type(self)() 265 | o.load(filename, metadata=metadata) 266 | assert len(o) == 1 267 | self.extend(o) 268 | 269 | def ensure_metadata(self) -> None: 270 | metadata, body = self[-1] 271 | assert 'section' in metadata 272 | for name, default in ( 273 | ('gh-issue', '0'), 274 | ('bpo', '0'), 275 | ('date', sortable_datetime()), 276 | ('nonce', generate_nonce(body)), 277 | ): 278 | if name not in metadata: 279 | metadata[name] = default 280 | 281 | def _extract_next_filename(self) -> str: 282 | """Changes metadata!""" 283 | self.ensure_metadata() 284 | metadata, body = self[-1] 285 | metadata['section'] = sanitize_section(metadata['section']) 286 | metadata['root'] = root 287 | if int(metadata['gh-issue']) > 0: 288 | path = '{root}/Misc/NEWS.d/next/{section}/{date}.gh-issue-{gh-issue}.{nonce}.rst'.format_map( 289 | metadata 290 | ) 291 | elif int(metadata['bpo']) > 0: 292 | # assume it's a GH issue number 293 | path = '{root}/Misc/NEWS.d/next/{section}/{date}.bpo-{bpo}.{nonce}.rst'.format_map( 294 | metadata 295 | ) 296 | for name in ('root', 'section', 'date', 'gh-issue', 'bpo', 'nonce'): 297 | del metadata[name] 298 | return path 299 | 300 | def save_next(self) -> str: 301 | assert len(self) == 1 302 | blurb = type(self)() 303 | metadata, body = self[0] 304 | metadata = dict(metadata) 305 | blurb.append((metadata, body)) 306 | filename = blurb._extract_next_filename() 307 | blurb.save(filename) 308 | return filename 309 | 310 | 311 | def sortable_datetime() -> str: 312 | return time.strftime('%Y-%m-%d-%H-%M-%S', time.localtime()) 313 | --------------------------------------------------------------------------------