diff --git a/.coveragerc b/.coveragerc index dcd3739..81ba1c7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,3 +10,4 @@ exclude_also = [run] omit = **/blurb/__main__.py + **/blurb/_version.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0dc0bab..e535eb6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,8 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: "3.x" - cache: pip - - uses: pre-commit/action@v3.0.1 + - uses: tox-dev/action-pre-commit-uv@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c6267e..a9d7511 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: hynek/build-and-inspect-python-package@v2 @@ -50,7 +51,6 @@ jobs: - name: Publish to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - attestations: true repository-url: https://test.pypi.org/legacy/ # Publish to PyPI on GitHub Releases. @@ -82,5 +82,3 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - attestations: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 276a00b..2802763 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,30 +14,28 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - cache: pip - - name: Install dependencies - run: | - python --version - python -m pip install -U pip - python -m pip install -U tox + - name: Install uv + uses: hynek/setup-cached-uv@v2 - name: Tox tests run: | - tox -e py + uvx --with tox-uv tox -e py - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: flags: ${{ matrix.python-version }} name: Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3628254..9b810a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.0.0 + +* Move 'blurb test' subcommand into test suite by @hugovk in https://github.com/python/blurb/pull/37 +* Add support for Python 3.14 by @ezio-melotti in https://github.com/python/blurb/pull/40 +* 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 +* Replace `safe_mkdir(path)` with `os.makedirs(path, exist_ok=True)` by @hugovk in https://github.com/python/blurb/pull/38 +* Test version handling functions by @hugovk in https://github.com/python/blurb/pull/36 +* CI: Lint and test via uv by @hugovk in https://github.com/python/blurb/pull/32 + ## 1.3.0 * Add support for Python 3.13 by @hugovk in https://github.com/python/blurb/pull/26 diff --git a/pyproject.toml b/pyproject.toml index 713dfb0..d6f0669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = [ "version", @@ -33,6 +34,7 @@ optional-dependencies.tests = [ "pyfakefs", "pytest", "pytest-cov", + "time-machine", ] urls.Changelog = "https://github.com/python/blurb/blob/main/CHANGELOG.md" urls.Homepage = "https://github.com/python/blurb" @@ -49,4 +51,4 @@ version-file = "src/blurb/_version.py" local_scheme = "no-local-version" [tool.pyproject-fmt] -max_supported_python = "3.13" +max_supported_python = "3.14" diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index 0af3ea9..b3998b5 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -57,7 +57,6 @@ import tempfile import textwrap import time -import unittest from . import __version__ @@ -140,7 +139,6 @@ def unsanitize_section(section): return _unsanitize_section.get(section, section) def next_filename_unsanitize_sections(filename): - s = filename for key, value in _unsanitize_section.items(): for separator in "/\\": key = f"{separator}{key}{separator}" @@ -268,11 +266,6 @@ def __exit__(self, *args): os.chdir(self.previous_cwd) -def safe_mkdir(path): - if not os.path.exists(path): - os.makedirs(path) - - def version_key(element): fields = list(element.split(".")) if len(fields) == 1: @@ -482,14 +475,14 @@ def finish_entry(): # we'll complain about the *first* error # we see in the blurb file, which is a # better user experience. - if key == "gh-issue" and int(value) < lowest_possible_gh_issue_number: - throw(f"The gh-issue number must be {lowest_possible_gh_issue_number} or above, not a PR number.") - if key in issue_keys: try: int(value) except (TypeError, ValueError): - throw(f"Invalid {issue_keys[key]} issue number! ({value!r})") + throw(f"Invalid {issue_keys[key]} number: {value!r}") + + if key == "gh-issue" and int(value) < lowest_possible_gh_issue_number: + throw(f"Invalid gh-issue number: {value!r} (must be >= {lowest_possible_gh_issue_number})") if key == "section": if no_changes: @@ -497,7 +490,10 @@ def finish_entry(): if value not in sections: throw(f"Invalid section {value!r}! You must use one of the predefined sections.") - if not 'section' in metadata: + if "gh-issue" not in metadata and "bpo" not in metadata: + throw("'gh-issue:' or 'bpo:' must be specified in the metadata!") + + if 'section' not in metadata: throw("No 'section' specified. You must provide one!") self.append((metadata, text)) @@ -557,7 +553,7 @@ def __str__(self): def save(self, path): dirname = os.path.dirname(path) - safe_mkdir(dirname) + os.makedirs(dirname, exist_ok=True) text = str(self) with open(path, "wt", encoding="utf-8") as file: @@ -639,42 +635,6 @@ def save_next(self): return filename -tests_run = 0 - -class TestParserPasses(unittest.TestCase): - directory = "tests/pass" - - def filename_test(self, filename): - b = Blurbs() - b.load(filename) - self.assertTrue(b) - if os.path.exists(filename + '.res'): - with open(filename + '.res', encoding='utf-8') as file: - expected = file.read() - self.assertEqual(str(b), expected) - - def test_files(self): - global tests_run - with pushd(self.directory): - for filename in glob.glob("*"): - if filename[-4:] == '.res': - self.assertTrue(os.path.exists(filename[:-4]), filename) - continue - self.filename_test(filename) - print(".", end="") - sys.stdout.flush() - tests_run += 1 - - -class TestParserFailures(TestParserPasses): - directory = "tests/fail" - - def filename_test(self, filename): - b = Blurbs() - with self.assertRaises(Exception): - b.load(filename) - - readme_re = re.compile(r"This is \w+ version \d+\.\d+").match def chdir_to_repo_root(): @@ -838,36 +798,6 @@ def _find_blurb_dir(): return None -@subcommand -def test(*args): - """ -Run unit tests. Only works inside source repo, not when installed. - """ - # unittest.main doesn't work because this isn't a module - # so we'll do it ourselves - - while (blurb_dir := _find_blurb_dir()) is None: - old_dir = os.getcwd() - os.chdir("..") - if old_dir == os.getcwd(): - # we reached the root and never found it! - sys.exit("Error: Couldn't find the root of your blurb repo!") - os.chdir(blurb_dir) - - print("-" * 79) - - for clsname, cls in sorted(globals().items()): - if clsname.startswith("Test") and isinstance(cls, type): - o = cls() - for fnname in sorted(dir(o)): - if fnname.startswith("test"): - fn = getattr(o, fnname) - if callable(fn): - fn() - print() - print(tests_run, "tests passed.") - - def find_editor(): for var in 'GIT_EDITOR', 'EDITOR': editor = os.environ.get(var) @@ -1175,12 +1105,12 @@ def populate(): Creates and populates the Misc/NEWS.d directory tree. """ os.chdir("Misc") - safe_mkdir("NEWS.d/next") + os.makedirs("NEWS.d/next", exist_ok=True) for section in sections: dir_name = sanitize_section(section) dir_path = f"NEWS.d/next/{dir_name}" - safe_mkdir(dir_path) + os.makedirs(dir_path, exist_ok=True) readme_path = f"NEWS.d/next/{dir_name}/README.rst" with open(readme_path, "wt", encoding="utf-8") as readme: readme.write(f"Put news entry ``blurb`` files for the *{section}* section in this directory.\n") @@ -1224,7 +1154,7 @@ def main(): fn = get_subcommand(subcommand) # hack - if fn in (help, test, version): + if fn in (help, version): sys.exit(fn(*args)) try: diff --git a/tests/test_blurb.py b/tests/test_blurb.py index 9ff3a8d..caf0f4e 100644 --- a/tests/test_blurb.py +++ b/tests/test_blurb.py @@ -1,5 +1,5 @@ import pytest -from pyfakefs.fake_filesystem import FakeFilesystem +import time_machine from blurb import blurb @@ -45,6 +45,116 @@ def test_unsanitize_section_changed(section, expected): assert unsanitized == expected +@pytest.mark.parametrize( + "body, subsequent_indent, expected", + ( + ( + "This is a test of the textwrap_body function with a string. It should wrap the text to 79 characters.", + "", + "This is a test of the textwrap_body function with a string. It should wrap\n" + "the text to 79 characters.\n", + ), + ( + [ + "This is a test of the textwrap_body function", + "with an iterable of strings.", + "It should wrap the text to 79 characters.", + ], + "", + "This is a test of the textwrap_body function with an iterable of strings. It\n" + "should wrap the text to 79 characters.\n", + ), + ( + "This is a test of the textwrap_body function with a string and subsequent indent.", + " ", + "This is a test of the textwrap_body function with a string and subsequent\n" + " indent.\n", + ), + ( + "This is a test of the textwrap_body function with a bullet list and subsequent indent. The list should not be wrapped.\n" + "\n" + "* Item 1\n" + "* Item 2\n", + " ", + "This is a test of the textwrap_body function with a bullet list and\n" + " subsequent indent. The list should not be wrapped.\n" + "\n" + " * Item 1\n" + " * Item 2\n", + ), + ), +) +def test_textwrap_body(body, subsequent_indent, expected): + assert blurb.textwrap_body(body, subsequent_indent=subsequent_indent) == expected + + +@time_machine.travel("2025-01-07") +def test_current_date(): + assert blurb.current_date() == "2025-01-07" + + +@time_machine.travel("2025-01-07 16:28:41") +def test_sortable_datetime(): + assert blurb.sortable_datetime() == "2025-01-07-16-28-41" + + +@pytest.mark.parametrize( + "version1, version2", + ( + ("2", "3"), + ("3.5.0a1", "3.5.0b1"), + ("3.5.0a1", "3.5.0rc1"), + ("3.5.0a1", "3.5.0"), + ("3.6.0b1", "3.6.0b2"), + ("3.6.0b1", "3.6.0rc1"), + ("3.6.0b1", "3.6.0"), + ("3.7.0rc1", "3.7.0rc2"), + ("3.7.0rc1", "3.7.0"), + ("3.8", "3.8.1"), + ), +) +def test_version_key(version1, version2): + # Act + key1 = blurb.version_key(version1) + key2 = blurb.version_key(version2) + + # Assert + assert key1 < key2 + + +def test_glob_versions(fs): + # Arrange + fake_version_blurbs = ( + "Misc/NEWS.d/3.7.0.rst", + "Misc/NEWS.d/3.7.0a1.rst", + "Misc/NEWS.d/3.7.0a2.rst", + "Misc/NEWS.d/3.7.0b1.rst", + "Misc/NEWS.d/3.7.0b2.rst", + "Misc/NEWS.d/3.7.0rc1.rst", + "Misc/NEWS.d/3.7.0rc2.rst", + "Misc/NEWS.d/3.9.0b1.rst", + "Misc/NEWS.d/3.12.0a1.rst", + ) + for fn in fake_version_blurbs: + fs.create_file(fn) + + # Act + versions = blurb.glob_versions() + + # Assert + assert versions == [ + "3.12.0a1", + "3.9.0b1", + "3.7.0", + "3.7.0rc2", + "3.7.0rc1", + "3.7.0b2", + "3.7.0b1", + "3.7.0a2", + "3.7.0a1", + ] + + def test_glob_blurbs_next(fs): # Arrange fake_news_entries = ( @@ -104,6 +214,22 @@ def test_glob_blurbs_sort_order(fs): assert filenames == expected +@pytest.mark.parametrize( + "version, expected", + ( + ("next", "next"), + ("3.12.0a1", "3.12.0 alpha 1"), + ("3.12.0b2", "3.12.0 beta 2"), + ("3.12.0rc2", "3.12.0 release candidate 2"), + ("3.12.0", "3.12.0 final"), + ("3.12.1", "3.12.1 final"), + ), +) +def test_printable_version(version, expected): + # Act / Assert + assert blurb.printable_version(version) == expected + + @pytest.mark.parametrize( "news_entry, expected_section", ( @@ -188,3 +314,68 @@ def test_version(capfd): # Assert captured = capfd.readouterr() assert captured.out.startswith("blurb version ") + + +def test_parse(): + # Arrange + contents = ".. gh-issue: 123456\n.. section: IDLE\nHello world!" + blurbs = blurb.Blurbs() + + # Act + blurbs.parse(contents) + + # Assert + metadata, body = blurbs[0] + assert metadata["gh-issue"] == "123456" + assert metadata["section"] == "IDLE" + assert body == "Hello world!\n" + + +@pytest.mark.parametrize( + "contents, expected_error", + ( + ( + "", + r"Blurb 'body' text must not be empty!", + ), + ( + "gh-issue: Hello world!", + r"Blurb 'body' can't start with 'gh-'!", + ), + ( + ".. gh-issue: 1\n.. section: IDLE\nHello world!", + r"Invalid gh-issue number: '1' \(must be >= 32426\)", + ), + ( + ".. bpo: one-two\n.. section: IDLE\nHello world!", + r"Invalid bpo number: 'one-two'", + ), + ( + ".. gh-issue: one-two\n.. section: IDLE\nHello world!", + r"Invalid GitHub number: 'one-two'", + ), + ( + ".. gh-issue: 123456\n.. section: Funky Kong\nHello world!", + r"Invalid section 'Funky Kong'! You must use one of the predefined sections", + ), + ( + ".. gh-issue: 123456\nHello world!", + r"No 'section' specified. You must provide one!", + ), + ( + ".. gh-issue: 123456\n.. section: IDLE\n.. section: IDLE\nHello world!", + r"Blurb metadata sets 'section' twice!", + ), + ( + ".. section: IDLE\nHello world!", + r"'gh-issue:' or 'bpo:' must be specified in the metadata!", + ), + ), +) +def test_parse_no_body(contents, expected_error): + # Arrange + blurbs = blurb.Blurbs() + + # Act / Assert + with pytest.raises(blurb.BlurbError, match=expected_error): + blurbs.parse(contents) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..4b5b3f3 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,36 @@ +import glob +import os + +import pytest + +from blurb.blurb import Blurbs, pushd + + +class TestParserPasses: + directory = "tests/pass" + + def filename_test(self, filename): + b = Blurbs() + b.load(filename) + assert b + if os.path.exists(filename + ".res"): + with open(filename + ".res", encoding="utf-8") as file: + expected = file.read() + assert str(b) == expected + + def test_files(self): + with pushd(self.directory): + for filename in glob.glob("*"): + if filename.endswith(".res"): + assert os.path.exists(filename[:-4]), filename + continue + self.filename_test(filename) + + +class TestParserFailures(TestParserPasses): + directory = "tests/fail" + + def filename_test(self, filename): + b = Blurbs() + with pytest.raises(Exception): + b.load(filename) diff --git a/tox.ini b/tox.ini index fa69718..e9c60df 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ requires = tox>=4.2 env_list = - py{313, 312, 311, 310, 39} + py{314, 313, 312, 311, 310, 39} [testenv] extras = @@ -17,9 +17,7 @@ commands = --cov-report term \ --cov-report xml \ {posargs} - blurb test blurb help blurb --version - {envpython} -I -m blurb test {envpython} -I -m blurb help {envpython} -I -m blurb version