From 3ad30969f18060ba7d0a38f6d9a24056b6c1ed41 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 21 Sep 2026 23:39:31 -0400 Subject: [PATCH] feat(util): have bump_version.py keep CITATION.cff in step with the bump Requested by the owner: "we have version cited in CITATION.cff, might need to have the version_bump.py handle that as well". * `util/bump_version.py` now rewrites `version` and `date-released` in `CITATION.cff` alongside `__version__` in `pcapkit/__init__.py`. Nothing else in the repository maintains that file, so every bump left it naming the previous release -- and GitHub renders it as the "Cite this repository" button. * The rewrite is line-oriented, so the comment header, key ordering and each field's existing quoting survive; `cff-version` and a `references` entry's own `version` are anchored out at column zero. An absent file is reported on stderr and skipped rather than failing the vendor cron before its commit; a file present with no `version` field raises, before anything is written. * The script gains a `main()` guard. It previously ran the bump at import, which is why it had no testable surface at all. * Fixed while here: the `import pcapkit` fallback in the version reader returned `"1.5.0b4'\n"`, quote and newline included, which `packaging` rejects. That path had never worked; CI never reaches it because the package is installed first. * New `tests/project/test_bump_version.py`, 32 tests against a temporary CFF fixture -- the repository's own file is only ever read, by a gate asserting it still names the packaged version. `tests/project` 96 passed, tier guard and docstring contract green. The rewrite of `pcapkit/__init__.py` and `conda/build` is byte-for-byte identical to before, and the produced CFF passes `cffconvert --validate`. --- CHANGELOG.md | 1 + docs/source/changelog/1.5.0.rst | 30 ++ tests/project/test_bump_version.py | 517 +++++++++++++++++++++++++++++ util/bump_version.py | 476 +++++++++++++++++++++++--- 4 files changed, 971 insertions(+), 53 deletions(-) create mode 100644 tests/project/test_bump_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1fc87867..47958f7660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ This is the resolution of #548, which reported `TransType.L2TP` (115) as registe - **Fixed** -- documentation. `mptcp_dss_ack_selector`'s note said a corrected field-width lambda "would not have worked" and that fixing it belonged to `pcapkit.corekit.fields.numbers`, which is exactly where #598 then fixed it; the same paragraph sat in `test_tcp_mptcp_length_arithmetic_unit.py`'s module docstring, whose other stale claim was that MP_JOIN "cannot be built through the public `TCP()` constructor at all", true only until #587. A callable-length `NumberField` packs and unpacks both DSS widths now, and wire *absence* was never the obstacle either: `MPTCPDSS.ssn`, `dl_len` and `checksum` have always been `ConditionalField` on the sibling `M` flag, so the class already relied on that wrapper to keep a field off the wire. The `SwitchField` form is kept for the narrower reason the note now gives -- `ConditionalField`'s `length` forwards to the wrapped field without consulting the condition, so it is safe here only because `Schema.pack` and `Schema.unpack` special-case that wrapper by name, whereas a `SwitchField` always resolves to a concrete field. Replacing it would be a behaviour change and is not made (#603). - **Changed** -- the README is a landing page now, and Markdown rather than reStructuredText. `README.rst` (424 lines) became `README.md` (103), keeping what a reader arriving from PyPI or a search result actually needs -- what the library is, why it exists rather than Scapy or DPKT, how to install it, a worked example, and where the documentation lives -- and dropping the technical detail the documentation already carried. **Module Structure**, **Engine Comparison**, **Engine support by Python version**, **Test Environment**, **Test Results** and **Installation Notes** were each already duplicated in `docs/source/index.rst`, in a fuller form, so they are linked rather than restated. Two blocks existed nowhere else and moved rather than going: **Testing** is now `docs/source/testing.rst`, registered in the index toctree, and the `pipenv` and `make setup` local development block joined the Installation section of `docs/source/index.rst`. Requested by the project owner, and a deliberate exception to the convention that documentation here is reStructuredText -- for the README only, since it is the one documentation file whose renderers are GitHub and PyPI rather than Sphinx. Accordingly `setup.py` reads `README.md` and declares its content type as `text/markdown`, and the `include README.md` line in `MANIFEST.in` is now the only thing that puts the README in a source distribution, because `global-include *.rst` no longer matches it -- which matters, since `setup.py` reads the file unguarded and an sdist without it cannot be installed. Verified with `twine check --strict` against a built sdist and wheel, both of which pass. The rename's own references moved with it, since a change that renames a file owns the references to it: `examples/benchmark/Dockerfile` copies `README.md` -- a literal `COPY` of the old name would have failed the layer outright and taken `make bench`, `make bench-quick` and `run.sh` with it -- and the benchmark harness prose that named the root README as the destination of its generated tables now names `docs/source/index.rst`, which is where those tables went. That covers the `Makefile` comment, `report.py`, `test_harness.py`, `run.sh` and the suite's own README, including the one place whose stated reason had inverted: the emitted markup is kept parseable by plain docutils, which is now a conservative choice rather than a hard requirement, because the page it lands on is rendered by Sphinx. `examples/benchmark/benchmark.py` still says `README.rst` and is left alone, because it means the benchmark suite's own README in the same directory, not the project's. - **Changed** -- `CODE_OF_CONDUCT.md` moves from Contributor Covenant 1.4 to Contributor Covenant 3.0, at the maintainer's request. The text is the canonical 3.0 Markdown fetched from https://www.contributor-covenant.org/version/3/0/code_of_conduct/code_of_conduct.md rather than a transcription, so the pledge, the encouraged and restricted behaviours and the scope are unaltered. Three things needed deciding rather than copying. 3.0 ships two `[NOTE` placeholders an adopter must fill: the reporting channel, which now names `jarryshaw@icloud.com` -- the same contact 1.4 carried and the one `SECURITY.md` already points at as its email fallback -- plus GitHub's report-abuse form for the case a single-maintainer project cannot otherwise cover, a report about the maintainer; and the enforcement section, whose placeholder is an instruction to the adopter and is removed. 3.0 then assigns enforcement throughout to plural "Community Moderators" (and once, inconsistently, to "Community Managers"), which this repository does not have, so all eight occurrences become the singular maintainer. The four-rung ladder -- Warning, Temporarily Limited Activities, Temporary Suspension, Permanent Ban -- is offered as a suggestion and is **kept**, because each rung maps onto a lever one person actually holds on GitHub: a private message, a locked thread, an interaction limit or block, a permanent block. Finally, 3.0 is licensed CC BY-SA 4.0 where 1.4's attribution paragraph carried no licence notice at all, so the attribution now names version 3.0, links the permanent `version/3/0/` URL, carries the CC BY-SA 4.0 notice and link, indicates that changes were made as BY requires, and says explicitly that the share-alike term covers this document only -- the code remains BSD-3-Clause and `LICENSE` is untouched. Rendering was checked against GitHub's own Markdown API rather than assumed: the ladder comes back as four list items each nesting three, which is what #613 had to repair in the 1.4 file when a stray list marker collapsed the whole document into one nested item (#624). +- **Fixed** -- `util/bump_version.py` left `CITATION.cff` naming the previous release. Nothing else in the repository maintains that file -- no workflow, hook or packaging file mentions it -- so every bump since it landed in #615 would have stranded the `version` and `date-released` it renders as GitHub's "Cite this repository" button and that citation managers, Zenodo and dependency inventories read directly. Both fields now move with `__version__`. They have the same standing, since the file's own header says both describe the newest *published* release, and moving only one would assert that 1.5.0b5 was released on the day 1.5.0b4 was; the date is taken in UTC, because seven of the thirty most recent bumps were made late evening in US-Eastern where a local date is a day behind the publish it describes. That the two are the same day at all is measured rather than assumed: the bump is what triggers `create-release.yml`, the median gap to the PyPI upload is three minutes, and the UTC calendar dates agree 30 times out of 30. The rewrite is line-oriented, so the comment header, key ordering and each field's existing quoting survive -- `cff-version` and a `references` entry's own `version` are anchored out at column zero -- and the result is checked with `cffconvert --validate`. An absent file is reported on stderr and skipped rather than failing the vendor cron before its `git commit`, which would discard the whole registry crawl for the sake of a documentation file; a file present with no `version` field raises instead, before anything is written, because rewriting nothing while reporting success is the staleness this fixes. Two things came with it. The script gains a `main()` guard, having previously run the entire bump at import, which is why it had no testable surface; and the `import pcapkit` fallback in its version reader, which returned `"1.5.0b4'\n"` -- closing quote and newline included, which `packaging` rejects -- is fixed, a path that had never worked and went unnoticed because the only caller installs the package first. A new gate asserts the committed file still names the packaged version, covering the version changes made by hand, which never run this script at all -- 40 of the 159 commits that have moved `__version__` on `main`, a quarter over the project's life and 11 of the most recent 25 (#625). Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` and `1.5.0b2` (both 2026-09-18) and `1.5.0b3` (2026-09-19), all published as prereleases and so resolved only by `pip install --pre`. `1.5.0b1` half-shipped: the tag, the GitHub release and the Conda deployments landed, but PyPI rejected the wheel because `twine check` found a Sphinx-only `:mod:` role in `README.rst`, which `pyproject.toml` declares as the dynamic long description. `1.5.0b2` is what reshipped it -- the release workflow is version-driven, so an existing version cannot republish -- and `1.5.0b3` followed the CI change that stops a TestPyPI outage from costing a release its wheels (#497, #498). diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index d827348f04..4a87faa408 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -1004,6 +1004,36 @@ pull requests between #326 and #509. own Markdown API rather than assumed: the ladder comes back as four list items each nesting three, which is what #613 had to repair in the 1.4 file when a stray list marker collapsed the whole document into one nested item (#624). +* **Fixed** -- ``util/bump_version.py`` left ``CITATION.cff`` naming the previous + release. Nothing else in the repository maintains that file -- no workflow, hook + or packaging file mentions it -- so every bump since it landed in #615 would have + stranded the ``version`` and ``date-released`` it renders as GitHub's "Cite this + repository" button and that citation managers, Zenodo and dependency inventories + read directly. Both fields now move with ``__version__``. They have the same + standing, since the file's own header says both describe the newest *published* + release, and moving only one would assert that 1.5.0b5 was released on the day + 1.5.0b4 was; the date is taken in UTC, because seven of the thirty most recent + bumps were made late evening in US-Eastern where a local date is a day behind the + publish it describes. That the two are the same day at all is measured rather + than assumed: the bump is what triggers ``create-release.yml``, the median gap to + the PyPI upload is three minutes, and the UTC calendar dates agree 30 times out + of 30. The rewrite is line-oriented, so the comment header, key ordering and each + field's existing quoting survive -- ``cff-version`` and a ``references`` entry's + own ``version`` are anchored out at column zero -- and the result is checked with + ``cffconvert --validate``. An absent file is reported on stderr and skipped + rather than failing the vendor cron before its ``git commit``, which would + discard the whole registry crawl for the sake of a documentation file; a file + present with no ``version`` field raises instead, before anything is written, + because rewriting nothing while reporting success is the staleness this fixes. + Two things came with it. The script gains a ``main()`` guard, having previously + run the entire bump at import, which is why it had no testable surface; and the + ``import pcapkit`` fallback in its version reader, which returned ``"1.5.0b4'\n"`` + -- closing quote and newline included, which ``packaging`` rejects -- is fixed, + a path that had never worked and went unnoticed because the only caller installs + the package first. A new gate asserts the committed file still names the packaged + version, covering the version changes made by hand, which never run this script at + all -- 40 of the 159 commits that have moved ``__version__`` on ``main``, a quarter + over the project's life and 11 of the most recent 25 (#625). Preceded by ``1.5.0a1`` (2026-09-15), ``1.5.0b1`` and ``1.5.0b2`` (both 2026-09-18) and ``1.5.0b3`` (2026-09-19), all published as prereleases and so diff --git a/tests/project/test_bump_version.py b/tests/project/test_bump_version.py new file mode 100644 index 0000000000..aaa3ac45d2 --- /dev/null +++ b/tests/project/test_bump_version.py @@ -0,0 +1,517 @@ +# -*- coding: utf-8 -*- +"""Tests for :file:`util/bump_version.py`, the version bump the vendor cron runs. + +The script moves ``__version__`` in :file:`pcapkit/__init__.py` on and, since the +owner asked for it -- *"we have version cited in CITATION.cff, might need to have +the version_bump.py handle that as well"* -- the ``version`` and ``date-released`` +fields of :file:`CITATION.cff` with it. + +Why the citation half is tested against a fixture +------------------------------------------------- + +Every test here builds a minimal Citation File Format 1.2.0 document in a +temporary directory. None of them touches the repository's own +:file:`CITATION.cff`, for the plain reason that a test which rewrites a tracked +file passes once and then fails, having left the version bumped in the working +tree. The fixture also lets the awkward shapes be tested at all -- a file with no +``date-released``, a file with no ``version``, CRLF endings, a nested ``version`` +belonging to a ``references`` entry -- none of which the real file has and none of +which it should be contorted into having. + +The exception is :class:`RepositoryCitationTests`, which reads the real file but +only ever *reads* it. That one is a gate rather than a unit test: it fails when +:file:`CITATION.cff` has drifted from ``pcapkit.__version__``, which is what the +version changes made by hand would otherwise do, since they never run this script +at all. It reads :file:`pcapkit/__init__.py` as text rather than importing +:mod:`pcapkit`, because the question is what the file in the repository says, and +an installed copy elsewhere on ``sys.path`` would answer a different one. + +What is deliberately not tested +------------------------------- + +:func:`~bump_version.main` is never called. It ends by writing ``conda/build`` +relative to the working directory and by resolving +:file:`pcapkit/__init__.py` through ``pcapkit.__path__``, so calling it in-process +would rewrite the checkout running the tests. The pieces it wires together are +each tested directly instead, and the ordering property that matters -- that the +one fatal outcome is reached before any file is written -- is structural: +:func:`~bump_version.plan_citation` takes text and returns text, so it has nothing +to write with. + +""" + +from __future__ import annotations + +import contextlib +import datetime +import importlib.util +import io +import os +import pathlib +import re +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[2] + +#: A minimal document that ``cffconvert --validate`` accepts, carrying the two +#: traps the rewrite has to survive: ``cff-version`` at column zero, which shares +#: the ``version`` suffix but must never move, and an indented ``version`` under a +#: ``references`` entry, which belongs to that reference and not to this software. +FIXTURE = """\ +# A comment that has to survive the rewrite. +cff-version: 1.2.0 +message: If you use this software, please cite it as below. +title: Fixture +abstract: A citation file that exists only to be rewritten. +type: software +authors: + - given-names: Ada + family-names: Lovelace +license: BSD-3-Clause +version: 1.5.0b4 +date-released: '2026-09-20' +keywords: + - fixture +references: + - type: software + title: Some Dependency + version: 9.9.9 + authors: + - name: Anonymous +""" + + +def _load_script(): + """Load :file:`util/bump_version.py` as a module. + + ``util/`` is a directory of scripts rather than a package, so there is no + import path to it. Loading it by location is safe because the bump runs under + a ``__main__`` guard -- which it did not, before the change these tests cover, + when importing the module rewrote two files as a side effect. + + """ + path = ROOT / 'util' / 'bump_version.py' + spec = importlib.util.spec_from_file_location('bump_version', path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +bump_version = _load_script() + + +class CitationTreeMixin: + """Gives each test a scratch directory and a fixture citation file.""" + + def setUp(self) -> None: + tmpdir = tempfile.TemporaryDirectory(prefix='pcapkit-bump-version-') + self.addCleanup(tmpdir.cleanup) + self.tmp_path = pathlib.Path(tmpdir.name) + + def write_citation(self, text: str = FIXTURE, name: str = 'CITATION.cff') -> pathlib.Path: + """Write ``text`` to the scratch directory and return its path.""" + path = self.tmp_path / name + path.write_text(text, encoding='utf-8') + return path + + +class CitationVersionTests(CitationTreeMixin, unittest.TestCase): + """The ``version`` field, which is the field the owner asked about.""" + + def test_the_version_field_moves_to_the_new_release(self) -> None: + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + self.assertIn('\nversion: 1.5.0b5\n', rewritten, + f'expected the bumped version at column zero, got ' + f'{[ln for ln in rewritten.splitlines() if ln.startswith("version:")]!r}') + self.assertNotIn('\nversion: 1.5.0b4\n', rewritten) + + def test_the_format_version_is_not_mistaken_for_the_release_version(self) -> None: + # ``cff-version`` names the Citation File Format revision, not the + # software's. It sits at column zero and ends in ``version:``, so a + # substring match rather than a prefix match would rewrite it and produce + # a file no validator accepts. + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + self.assertIn('cff-version: 1.2.0', rewritten, + 'the Citation File Format version was rewritten as if it were ' + 'the software version') + + def test_an_indented_version_key_belongs_to_its_own_reference(self) -> None: + # ``references[0].version`` is the cited dependency's version. It is + # indented, so anchoring at column zero is what leaves it alone. + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + self.assertIn(' version: 9.9.9', rewritten, + "a reference's own version was overwritten with this project's") + + def test_a_file_with_no_version_field_is_refused(self) -> None: + # A file that is *present* and has no field to update means the shape has + # changed underneath the script. Rewriting nothing while reporting success + # is the staleness this change exists to stop, so it raises instead. + without = '\n'.join(line for line in FIXTURE.splitlines() + if not line.startswith('version:')) + '\n' + + with self.assertRaises(bump_version.CitationFieldError) as error: + bump_version.plan_citation(without, '1.5.0b5', '2026-09-22') + + self.assertIn('version:', str(error.exception)) + + def test_a_space_before_the_colon_is_refused_rather_than_missed(self) -> None: + # ``version : 1.5.0b4`` is legal YAML that this rewrite does not recognise, + # and the refusal is what makes that safe: it fails loudly instead of + # passing over the file having silently changed nothing. + spaced = FIXTURE.replace('version: 1.5.0b4', 'version : 1.5.0b4') + self.assertIn('version : 1.5.0b4', spaced, 'the fixture edit did not apply') + + with self.assertRaises(bump_version.CitationFieldError): + bump_version.plan_citation(spaced, '1.5.0b5', '2026-09-22') + + def test_the_refusal_is_a_runtime_error(self) -> None: + # Anything already catching the ``cannot find version`` RuntimeError this + # script has always raised keeps catching this one. + self.assertTrue(issubclass(bump_version.CitationFieldError, RuntimeError)) + + +class CitationDateTests(CitationTreeMixin, unittest.TestCase): + """The ``date-released`` field, which is the judgement call rather than the ask.""" + + def test_the_release_date_moves_with_the_version(self) -> None: + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + self.assertIn("date-released: '2026-09-22'", rewritten) + self.assertNotIn("date-released: '2026-09-20'", rewritten) + + def test_the_release_date_defaults_to_today_in_utc(self) -> None: + # Not ``date.today()``. Seven of the thirty most recent bumps were made + # late evening in US-Eastern, where the local date is a day behind the + # publish the field is describing. + opened = datetime.datetime.now(datetime.timezone.utc).date() + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5') + closed = datetime.datetime.now(datetime.timezone.utc).date() + + # Both bounds, so a run straddling UTC midnight does not flake. + wanted = {f"date-released: '{day.isoformat()}'" for day in (opened, closed)} + self.assertTrue( + any(candidate in rewritten for candidate in wanted), + f'expected one of {sorted(wanted)}, got ' + f'{[ln for ln in rewritten.splitlines() if ln.startswith("date-released:")]!r}', + ) + + # Where the runner's local date differs from UTC -- which is the case the + # choice of clock exists for -- the local one must not be what was written. + local = datetime.datetime.now().date() + if local not in (opened, closed): + self.assertNotIn(f"date-released: '{local.isoformat()}'", rewritten, + 'the local date was written where UTC was required') + + def test_a_file_without_a_release_date_does_not_gain_one(self) -> None: + # The field is optional in Citation File Format 1.2.0. Inventing one would + # assert a release date the repository never recorded. + without = '\n'.join(line for line in FIXTURE.splitlines() + if not line.startswith('date-released:')) + '\n' + + rewritten = bump_version.plan_citation(without, '1.5.0b5', '2026-09-22') + + self.assertNotIn('date-released', rewritten) + self.assertIn('\nversion: 1.5.0b5\n', rewritten) + + def test_the_rewritten_date_is_a_string_and_not_a_yaml_date(self) -> None: + # The quoting is why. Bare ``2026-09-22`` loads as a ``datetime.date``, + # which is the cast the field was quoted to prevent in the first place. + try: + import yaml + except ImportError: # pragma: no cover + self.skipTest('PyYAML is not in the test extra; the textual form is ' + 'asserted by test_the_release_date_moves_with_the_version') + + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + loaded = yaml.safe_load(rewritten) + + self.assertIsInstance(loaded['date-released'], str) + self.assertIsInstance(loaded['version'], str) + self.assertEqual(loaded['version'], '1.5.0b5') + self.assertEqual(loaded['cff-version'], '1.2.0') + + +class CitationFormattingTests(CitationTreeMixin, unittest.TestCase): + """Everything the rewrite must leave exactly as it found it.""" + + def test_only_the_two_fields_change(self) -> None: + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + before = FIXTURE.splitlines() + after = rewritten.splitlines() + self.assertEqual(len(before), len(after), 'the line count moved') + + moved = [(a, b) for a, b in zip(before, after) if a != b] + self.assertEqual(moved, [ + ('version: 1.5.0b4', 'version: 1.5.0b5'), + ("date-released: '2026-09-20'", "date-released: '2026-09-22'"), + ], f'unexpected lines moved: {moved!r}') + + def test_the_comment_header_survives(self) -> None: + # The real file opens with sixteen lines explaining why it omits ``doi`` + # and ``orcid``. A YAML load-and-dump round trip would drop all of them, + # which is why the rewrite is line-oriented. + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + self.assertTrue(rewritten.startswith('# A comment that has to survive'), + f'lost the comment header: {rewritten[:60]!r}') + + def test_existing_quoting_is_preserved_for_each_field(self) -> None: + # The committed file spells one of each: ``version`` bare and + # ``date-released`` quoted. Preserving the style keeps the automated + # commit's diff down to the value that actually moved. + rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5', '2026-09-22') + + self.assertIn('\nversion: 1.5.0b5\n', rewritten, 'a bare value gained quotes') + self.assertIn("\ndate-released: '2026-09-22'\n", rewritten, + 'a quoted value lost its quotes') + + def test_a_quoted_version_keeps_its_quotes(self) -> None: + quoted = FIXTURE.replace('version: 1.5.0b4', "version: '1.5.0b4'") + + rewritten = bump_version.plan_citation(quoted, '1.5.0b5', '2026-09-22') + + self.assertIn("\nversion: '1.5.0b5'\n", rewritten) + + def test_a_double_quoted_value_keeps_the_same_quote_character(self) -> None: + quoted = FIXTURE.replace("date-released: '2026-09-20'", + 'date-released: "2026-09-20"') + + rewritten = bump_version.plan_citation(quoted, '1.5.0b5', '2026-09-22') + + self.assertIn('\ndate-released: "2026-09-22"\n', rewritten) + + def test_a_value_with_a_trailing_comment_loses_the_comment(self) -> None: + # An acknowledged limit rather than a defect. Telling an inline ``#`` + # comment from a ``#`` inside a quoted scalar needs the YAML round trip this + # rewrite exists to avoid; neither line in the real file carries one, all of + # its commentary being whole-line; and the loss would be visible in the + # diff. Pinned here so the behaviour stays a decision rather than becoming + # a surprise. + commented = FIXTURE.replace('version: 1.5.0b4', + 'version: 1.5.0b4 # from pcapkit/__init__.py') + + rewritten = bump_version.plan_citation(commented, '1.5.0b5', '2026-09-22') + + self.assertIn('\nversion: 1.5.0b5\n', rewritten) + self.assertNotIn('# from pcapkit/__init__.py', rewritten) + + def test_crlf_line_endings_survive(self) -> None: + rewritten = bump_version.plan_citation( + FIXTURE.replace('\n', '\r\n'), '1.5.0b5', '2026-09-22') + + self.assertIn('\r\nversion: 1.5.0b5\r\n', rewritten) + self.assertNotIn('version: 1.5.0b5\n\r', rewritten) + self.assertEqual(rewritten.count('\r\n'), rewritten.count('\n'), + 'a bare newline was introduced into a CRLF file') + + def test_a_missing_final_newline_is_not_added(self) -> None: + # ``version`` is the last line here, so a rewrite that appended a + # terminator would change a byte nobody asked it to. + truncated = 'cff-version: 1.2.0\nversion: 1.5.0b4' + + rewritten = bump_version.plan_citation(truncated, '1.5.0b5', '2026-09-22') + + self.assertEqual(rewritten, 'cff-version: 1.2.0\nversion: 1.5.0b5') + + +class AbsentCitationFileTests(CitationTreeMixin, unittest.TestCase): + """The absent-file case, which is a reported skip rather than a failure. + + Not fatal on purpose. :file:`.github/workflows/cron-vendor.yml` runs the script + under ``bash -e`` *before* its ``git commit``, so a non-zero exit there discards + the whole vendor crawl the run existed to produce and the release that would + have followed. Announced rather than swallowed, because a bump that quietly + skips a file it maintains is how the staleness starts. + + """ + + def test_an_absent_file_is_reported_and_skipped(self) -> None: + missing = self.tmp_path / 'CITATION.cff' + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + text = bump_version.read_citation(missing) + + self.assertIsNone(text) + self.assertIn('is absent', stderr.getvalue()) + self.assertIn(str(missing), stderr.getvalue()) + + def test_an_absent_file_is_not_silent(self) -> None: + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + bump_version.read_citation(self.tmp_path / 'CITATION.cff') + + self.assertNotEqual(stderr.getvalue().strip(), '', + 'an absent citation file was skipped without a word') + + def test_a_present_file_is_returned_verbatim(self) -> None: + path = self.write_citation() + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + text = bump_version.read_citation(path) + + self.assertEqual(text, FIXTURE) + self.assertEqual(stderr.getvalue(), '', f'unexpected notice: {stderr.getvalue()!r}') + + def test_a_file_without_a_release_date_is_reported(self) -> None: + without = '\n'.join(line for line in FIXTURE.splitlines() + if not line.startswith('date-released:')) + '\n' + path = self.write_citation(without) + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + text = bump_version.read_citation(path) + + self.assertEqual(text, without) + self.assertIn('date-released', stderr.getvalue()) + + +class VersionBumpTests(unittest.TestCase): + """:func:`~bump_version.bump`, whose behaviour the change leaves alone.""" + + def test_a_prerelease_bumps_its_counter(self) -> None: + self.assertEqual(bump_version.bump('1.5.0b4'), '1.5.0b5') + self.assertEqual(bump_version.bump('1.5.0a1'), '1.5.0a2') + self.assertEqual(bump_version.bump('1.5.0rc1'), '1.5.0rc2') + + def test_a_development_release_bumps_its_counter(self) -> None: + self.assertEqual(bump_version.bump('1.5.0.dev1'), '1.5.0.dev2') + + def test_a_post_release_bumps_its_counter(self) -> None: + self.assertEqual(bump_version.bump('1.3.5.post42'), '1.3.5.post43') + + def test_a_final_release_gains_post1(self) -> None: + self.assertEqual(bump_version.bump('1.4.1'), '1.4.1.post1') + + def test_every_bumped_version_carries_a_non_numeric_suffix(self) -> None: + # This is what makes leaving ``version`` unquoted safe in YAML: a bare + # ``2.0`` would load as a float, and no value this function returns can. + for current in ('1.5.0b4', '1.5.0a1', '1.5.0rc1', '1.5.0.dev1', + '1.3.5.post42', '1.4.1', '2.0', '3'): + bumped = bump_version.bump(current) + self.assertRegex(bumped, r'[a-z]', + f'{current!r} bumped to {bumped!r}, which YAML would ' + f'read as a number rather than a string') + + +class InitRewriteTests(CitationTreeMixin, unittest.TestCase): + """:file:`pcapkit/__init__.py`, which must still be rewritten exactly as before.""" + + def write_init(self, version: str = '1.5.0b4') -> pathlib.Path: + """Write a stand-in :file:`pcapkit/__init__.py` and return its path.""" + package = self.tmp_path / 'pcapkit' + package.mkdir(exist_ok=True) + path = package / '__init__.py' + path.write_text( + '# -*- coding: utf-8 -*-\n' + "__all__ = ['Foo']\n" + '\n' + '#: version number\n' + f'__version__ = {version!r}\n', + encoding='utf-8', + ) + return path + + def test_the_version_assignment_is_replaced(self) -> None: + path = self.write_init() + + bump_version.rewrite_init(str(path), '1.5.0b5') + + self.assertEqual( + path.read_text(encoding='utf-8'), + '# -*- coding: utf-8 -*-\n' + "__all__ = ['Foo']\n" + '\n' + '#: version number\n' + "__version__ = '1.5.0b5'\n", + ) + + def test_nothing_but_the_assignment_moves(self) -> None: + path = self.write_init() + before = path.read_text(encoding='utf-8').splitlines() + + bump_version.rewrite_init(str(path), '1.5.0b5') + + after = path.read_text(encoding='utf-8').splitlines() + self.assertEqual(before[:-1], after[:-1]) + + def test_the_fallback_reader_strips_the_closing_quote_and_newline(self) -> None: + # The fallback ran ``line.split('=')[1].strip(" '")``, which stops at the + # trailing newline and so returned ``"1.5.0b4'\n"`` -- quote and newline + # included -- which ``packaging`` rejects as an invalid version. The path + # had never worked; nothing noticed because the only caller installs the + # package first, so ``import pcapkit`` always succeeds and the fallback is + # unreachable in CI. Forced here by making that import fail. + self.write_init() + cwd = os.getcwd() + self.addCleanup(os.chdir, cwd) + os.chdir(self.tmp_path) + + with mock.patch.dict('sys.modules', {'pcapkit': None}): + version = bump_version.current_version() + + self.assertEqual(version, '1.5.0b4') + # And the value is usable, which is the part that was broken. + self.assertEqual(bump_version.bump(version), '1.5.0b5') + + def test_the_fallback_still_complains_when_there_is_no_version(self) -> None: + package = self.tmp_path / 'pcapkit' + package.mkdir(exist_ok=True) + (package / '__init__.py').write_text('# nothing here\n', encoding='utf-8') + cwd = os.getcwd() + self.addCleanup(os.chdir, cwd) + os.chdir(self.tmp_path) + + with mock.patch.dict('sys.modules', {'pcapkit': None}): + with self.assertRaises(RuntimeError) as error: + bump_version.current_version() + + self.assertIn('cannot find version', str(error.exception)) + + +class RepositoryCitationTests(unittest.TestCase): + """A gate: the committed :file:`CITATION.cff` must name the packaged version. + + This is the half the script cannot guarantee on its own. Of the 159 commits + that have moved ``__version__`` on ``main``, 40 are hand-authored and so never + ran :file:`util/bump_version.py` -- a quarter over the project's life, and 11 + of the most recent 25, which is where ``1.4.0``, ``1.4.1``, ``1.5.0a1`` and the + ``1.5.0b1`` through ``b3`` run all came from. Without a check here the citation + metadata would still go stale through exactly those releases. + + """ + + def test_the_citation_file_names_the_packaged_version(self) -> None: + citation = ROOT / 'CITATION.cff' + init = ROOT / 'pcapkit' / '__init__.py' + if not citation.is_file() or not init.is_file(): + self.skipTest('CITATION.cff is not shipped in the source distribution') + + declared = re.search(r"^__version__ = '([^']+)'", + init.read_text(encoding='utf-8'), re.MULTILINE) + self.assertIsNotNone(declared, f'no __version__ assignment in {init}') + + cited = re.search(r'^version:[ \t]*[\'"]?([^\'"\s]+)', + citation.read_text(encoding='utf-8'), re.MULTILINE) + self.assertIsNotNone(cited, f'no top-level version field in {citation}') + + assert declared is not None and cited is not None + self.assertEqual( + cited.group(1), declared.group(1), + f'CITATION.cff cites {cited.group(1)} but pcapkit/__init__.py declares ' + f'{declared.group(1)}. util/bump_version.py keeps these in step when it ' + f'runs; a hand-made version bump has to update both.', + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/util/bump_version.py b/util/bump_version.py index d113d7ca90..bc587c679d 100644 --- a/util/bump_version.py +++ b/util/bump_version.py @@ -1,61 +1,431 @@ # -*- coding: utf-8 -*- +"""Bump ``pcapkit.__version__``, and the metadata that has to move with it. +This is the script the ``Vendor Update`` workflow runs after a registry crawl has +changed something under :mod:`pcapkit.const`. It reads the current version, works +out the next one under :pep:`440`, and writes it back. + +Where it is invoked from, and why that matters +---------------------------------------------- + +Exactly one caller: the ``Bump Version`` step of +:file:`.github/workflows/cron-vendor.yml`, between the crawl and the ``git +commit``. Completion of that workflow is itself the ``workflow_run`` trigger of +:file:`.github/workflows/create-release.yml`, which reads ``pcapkit.__version__`` +back out of the tree, tags ``v``, and publishes to PyPI and Anaconda. + +So in this repository a bump is not a preparatory step that a release later +follows -- the bump *is* what causes the release, minutes later and with no human +in between. That fact decides what this script is responsible for updating, and +it is the reason ``date-released`` below is in scope rather than left to a +release step that does not exist. + +``CITATION.cff`` +---------------- + +:file:`CITATION.cff` records ``version`` and ``date-released``. GitHub renders it +as the repository's *Cite this repository* button, and citation managers, Zenodo +and dependency inventories read it directly, so a wrong number there propagates +into papers and into bills of materials. Nothing else in the repository maintains +the file: no workflow writes it, and there is no release step that could. Left +out of this script it would state the *previous* release forever, which is worse +than having no citation file at all, because a stale one is still consumed. + +Both fields move together, and the reason is that they have the same standing. The +file's own header says ``version`` and ``date-released`` both "describe the newest +*published* release", so at the moment of a bump both are equally anticipatory -- +there is no principled line that lets one move and pins the other. Moving only +``version`` would leave the pair asserting something plainly false and leave it +there indefinitely: "1.5.0b5, released on the day 1.5.0b4 was". + +Measured over the thirty most recent releases, the bump commit's UTC calendar date +equals the PyPI upload's UTC calendar date **thirty times out of thirty**, median +gap three minutes, and no bump in the project's history has taken more than a day +to publish. The weekly cron fires at 10:05 UTC and publishes at 10:07, twelve +hours clear of a date boundary. Hence UTC via :func:`datetime.datetime.now` with an +explicit timezone, not :meth:`datetime.date.today`: seven of those thirty bumps +were made late evening in US-Eastern, where a naive local date would have been a +day behind the publish it was describing. + +The residual risk is stated rather than hidden. Roughly one bump in twelve +historically never reached PyPI at all -- eight consecutive weekly bumps between +2024-04-13 and 2024-06-15, and ``1.5.0b1`` -- and for those this script writes an +optimistic date for a release that never happened. That is not an argument for +leaving the field stale, since a stale date is wrong in *every* one of the other +eleven cases; it is an argument for eventually anchoring both fields to +:file:`.github/workflows/create-release.yml`, which runs only when a publish is +actually going out. Until then this is the better of the two available answers, and +the ``version`` half is guarded by a test that fails when the file drifts from +``pcapkit.__version__`` -- which also covers the version changes made by hand, +which never run this script at all. Those are 40 of the 159 commits that have +moved ``__version__`` on ``main``, a quarter over the project's life and a rising +share lately: 11 of the most recent 25. + +The rewrite is line-oriented rather than a :mod:`yaml` load-and-dump. A round +trip through a YAML library would reorder keys, normalise quoting, and drop the +sixteen lines of comment that explain why the file omits ``doi`` and ``orcid`` -- +none of which is a change anyone asked for. Matching ``^version:`` and +``^date-released:`` at column zero is enough to identify the two top-level keys, +since every nested key in the file is indented and ``cff-version`` does not +begin with ``version``. + +Each field keeps whatever quoting it already had. The file as committed spells one +of each -- ``version: 1.5.0b4`` bare and ``date-released: '2026-09-20'`` quoted, +the latter deliberately, so that a validator reads a string rather than casting it +to a YAML date object. Preserving the style rather than normalising it keeps the +automated commit's diff down to the value that actually moved, and leaves the +choice of style where it belongs. An unquoted version is safe here because every +string :func:`bump` returns carries a ``.devN`` / ``aN`` / ``bN`` / ``rcN`` / +``.postN`` suffix, so none of them can be misread as a YAML number the way a bare +``2.0`` would be. + +One acknowledged limit: an inline ``#`` comment after either value would not +survive the rewrite. Neither line carries one, all of this file's commentary being +whole-line, and parsing far enough to know the difference is the YAML round trip +this approach exists to avoid. The loss would be visible in the diff. + +When the file is not there +-------------------------- + +A missing :file:`CITATION.cff` is reported on :data:`sys.stderr` and then skipped. +It is deliberately **not** fatal, and the reason is where this script runs. The +``Bump Version`` step executes under ``bash -e``, *before* the workflow's ``git +commit``, so a non-zero exit there does not merely skip the citation update -- it +fails the step, discards the whole vendor crawl that the run existed to produce, +and the release that would have followed never happens. Trading a published +release and a week of registry updates for a missing documentation file is the +wrong way round. It is announced rather than swallowed, because a bump that +quietly does not update a file it is supposed to maintain is how this class of +staleness starts. + +A file that *is* present and carries no ``version`` field is a different case and +does raise :exc:`CitationFieldError`. There the script is in exactly the situation +it was written for, and finding nothing to update means the file's shape has +changed underneath it -- an outcome indistinguishable from success if it were +allowed to pass. Unlike an absent file that can happen by accident of packaging, +this one requires somebody to have edited :file:`CITATION.cff`, so failing on it +does not put the release automation at the mercy of the environment. The check +runs before anything is written, so the fatal case leaves the tree untouched +rather than half-bumped. + +``date-released`` is optional in Citation File Format 1.2.0, so a file without one +keeps not having one; the absence is reported and no date is invented. + +What is deliberately not changed +-------------------------------- + +:file:`conda/build` is still written relative to the working directory, as it +always was, rather than relative to the repository root the way :data:`CITATION` +is. The two disagree, and the root-relative form is the better of them -- it is +what :file:`util/changelog_md.py` documents and does -- but moving +:file:`conda/build` is a change in behaviour for every existing caller and +belongs in its own review rather than riding along with this one. + +""" + +from __future__ import annotations + +import argparse +import datetime import os -from typing import cast +import pathlib +import sys +from typing import TYPE_CHECKING, cast from packaging.version import Version -try: - import pcapkit - version = pcapkit.__version__ -except ImportError: - version = '' +if TYPE_CHECKING: + from typing import Optional, Sequence + +__all__ = ['CitationFieldError', 'bump', 'read_citation', 'plan_citation', 'main'] + +#: Repository root, taken from this file's location rather than the working +#: directory, so the script finds the same :file:`CITATION.cff` run from +#: anywhere. This is the spelling :file:`util/changelog_md.py` uses and for the +#: same reason. +ROOT = pathlib.Path(__file__).resolve().parent.parent + +#: Citation metadata, in Citation File Format 1.2.0. Its ``version`` and +#: ``date-released`` fields are the ones this script keeps in step with +#: ``pcapkit.__version__``. +CITATION = ROOT / 'CITATION.cff' + +#: Top-level ``CITATION.cff`` key naming the released version. Matched at column +#: zero: every nested key in the file is indented, and ``cff-version`` -- the +#: format version, which must *not* move -- does not begin with this prefix. +VERSION_KEY = 'version:' + +#: Top-level ``CITATION.cff`` key naming the release date. Optional in the +#: format, so its absence is reported rather than filled in. +RELEASED_KEY = 'date-released:' + + +def _restyle(key: str, body: str, value: str) -> str: + """Re-emit ``key`` with ``value``, in the quoting style ``body`` already used. + + Args: + key: Field name including its colon. + body: The existing line, newline already stripped. + value: Replacement value. + + Returns: + The rewritten line, without a terminator. + + """ + current = body[len(key):].strip() + + quote = '' + if len(current) > 1 and current[0] in ('"', "'") and current[-1] == current[0]: + quote = current[0] + + return f'{key} {quote}{value}{quote}' + + +class CitationFieldError(RuntimeError): + """Raised when :file:`CITATION.cff` exists but has no ``version`` field. + + Subclasses :exc:`RuntimeError` rather than defining a new hierarchy, to match + the ``cannot find version`` failure this script already raised for the same + class of problem in :file:`pcapkit/__init__.py`. + + """ + + +def current_version() -> str: + """Read the version that is about to be bumped. + + Prefers the installed package, which is what the ``Vendor Update`` workflow + has, and falls back to scraping :file:`pcapkit/__init__.py` out of the working + directory when :mod:`pcapkit` cannot be imported. + + The fallback strips whitespace before quotes rather than both at once. The + previous spelling, ``strip(" '")``, stopped at the trailing newline and so + returned ``"1.5.0b4'\\n"`` -- quote and newline included -- which + :class:`~packaging.version.Version` rejects outright with + :exc:`~packaging.version.InvalidVersion`. The path had therefore never worked; + it went unnoticed because the only caller installs the package first, so + :mod:`pcapkit` always imports and the fallback is never reached in CI. + + Returns: + The current version string. + + Raises: + RuntimeError: If neither source yields a version. + + """ + try: + import pcapkit + return pcapkit.__version__ + except ImportError: + version = '' + + path = os.path.join('pcapkit', '__init__.py') + with open(path, 'r', encoding='utf-8') as file: + for line in file: + if line.startswith('__version__'): + version = line.split('=')[1].strip().strip('\'"') + + if not version: + raise RuntimeError('cannot find version') + + return version + + +def bump(version: str) -> str: + """Work out the next version under :pep:`440`. + + A development release bumps its ``devN`` counter, a pre-release its ``aN`` / + ``bN`` / ``rcN`` counter, a post-release its ``postN`` counter, and a final + release gains ``.post1``. Every result therefore carries a non-numeric + suffix, which is worth knowing when reading it back out of YAML. + + Args: + version: The current version string. - path = os.path.join('pcapkit', '__init__.py') - with open(path, 'r', encoding='utf-8') as file: - for line in file: + Returns: + The bumped version string. + + """ + ver_obj = Version(version) + base_version = ver_obj.base_version + + if ver_obj.is_devrelease: + dev = cast('int', ver_obj.dev) + return base_version + '.dev' + str(dev + 1) + + if ver_obj.is_prerelease: + pre = cast('tuple[str, int]', ver_obj.pre) + return base_version + pre[0] + str(pre[1] + 1) + + if ver_obj.is_postrelease: + post = cast('int', ver_obj.post) + return base_version + '.post' + str(post + 1) + + return base_version + '.post1' + + +def init_path() -> str: + """Locate the :file:`pcapkit/__init__.py` to rewrite. + + Prefers the installed package's copy, which under an editable install is the + working tree's own file, and falls back to the working directory. + + Returns: + Path to the file carrying ``__version__``. + + """ + try: + import pcapkit + return os.path.join(pcapkit.__path__[0], '__init__.py') + except ImportError: + return os.path.join('pcapkit', '__init__.py') + + +def rewrite_init(path: str, new_ver: str) -> None: + """Write ``new_ver`` into the ``__version__`` assignment at ``path``. + + Args: + path: File carrying the ``__version__`` assignment. + new_ver: Version string to record. + + """ + contents = [] # type: list[str] + with open(path, 'r', encoding='utf-8') as in_file: + for line in in_file: if line.startswith('__version__'): - version = line.split('=')[1].strip(" '") - - if not version: - raise RuntimeError('cannot find version') - -# parse version and bump version -ver_obj = Version(version) -base_version = ver_obj.base_version - -if ver_obj.is_devrelease: - dev = cast('int', ver_obj.dev) - new_ver = base_version + '.dev' + str(dev + 1) - -elif ver_obj.is_prerelease: - pre = cast('tuple[str, int]', ver_obj.pre) - new_ver = base_version + pre[0] + str(pre[1] + 1) - -elif ver_obj.is_postrelease: - post = cast('int', ver_obj.post) - new_ver = base_version + '.post' + str(post + 1) - -else: - new_ver = base_version + '.post1' - -# write to file -try: - import pcapkit - path = os.path.join(pcapkit.__path__[0], '__init__.py') -except ImportError: - path = os.path.join('pcapkit', '__init__.py') - -contents = [] # type: list[str] -with open(path, 'r', encoding='utf-8') as in_file: - for line in in_file: - if line.startswith('__version__'): - line = f'__version__ = {new_ver!r}' - contents.append(line) - -with open (path, 'w', encoding='utf-8') as out_file: - out_file.writelines(contents) - out_file.write('\n') - -with open(os.path.join('conda', 'build'), 'w') as build: - build.write('0') + line = f'__version__ = {new_ver!r}' + contents.append(line) + + with open(path, 'w', encoding='utf-8') as out_file: + out_file.writelines(contents) + out_file.write('\n') + + +def plan_citation(text: str, version: str, released: Optional[str] = None) -> str: + """Return ``text`` with its ``version`` and ``date-released`` fields moved on. + + Nothing is written; the caller decides that, which is what lets the failure + below happen before the rest of the bump has touched the tree. Every line + other than the two keys is carried through byte for byte, including the + comment header and each line's own terminator, so a file with CRLF endings or + without a final newline comes back the way it went in. + + Args: + text: Current contents of the citation file. + version: Version string to record. + released: Release date as ``YYYY-MM-DD``, defaulting to today in UTC. + Only written if the file already carries a ``date-released`` field; + the field is optional in the format and is not invented. + + Returns: + The new contents. + + Raises: + CitationFieldError: If ``text`` carries no top-level ``version`` field, + which means the file's shape has changed and this script would + otherwise rewrite nothing while reporting success. + + """ + if released is None: + released = datetime.datetime.now(datetime.timezone.utc).date().isoformat() + + found = False + contents = [] # type: list[str] + + for line in text.splitlines(keepends=True): + body = line.rstrip('\r\n') + ending = line[len(body):] + + if body.startswith(VERSION_KEY): + contents.append(_restyle(VERSION_KEY, body, version) + ending) + found = True + elif body.startswith(RELEASED_KEY): + contents.append(_restyle(RELEASED_KEY, body, released) + ending) + else: + contents.append(line) + + if not found: + raise CitationFieldError( + f'no top-level {VERSION_KEY!r} field in the citation file; it must appear ' + f'at column zero, so a nested or differently spaced spelling reaches here ' + f'too. Either restore the field or drop CITATION.cff, rather than leaving ' + f'a file this script cannot keep in step with pcapkit.__version__' + ) + + return ''.join(contents) + + +def read_citation(path: pathlib.Path) -> Optional[str]: + """Read the citation file, reporting on :data:`sys.stderr` what is not there. + + Split out from :func:`main` so that the two absences below are reachable from a + test without a bump running, and so that reading happens before any of the + bump's writes. + + Args: + path: Citation file to read. + + Returns: + The file's contents, or :obj:`None` if it does not exist -- in which case a + notice has been printed and the caller should carry on without it. See the + module docstring for why an absent file is not fatal. + + """ + if not path.is_file(): + print(f'{path} is absent, so the citation metadata still names an older ' + f'release. Nothing else in the repository maintains that file -- ' + f'restore it, or record the version there by hand', file=sys.stderr) + return None + + text = path.read_text(encoding='utf-8') + + if not any(line.startswith(RELEASED_KEY) for line in text.splitlines()): + print(f'{path} carries no {RELEASED_KEY!r} field, so no release date was ' + f'written; the field is optional in Citation File Format 1.2.0 and one ' + f'is not invented here', file=sys.stderr) + + return text + + +def main(argv: Optional[Sequence[str]] = None) -> int: + """Command line entry point. + + Args: + argv: Argument list, defaulting to :data:`sys.argv`. + + Returns: + ``0``; failures raise rather than returning a code, because every caller + runs the script under a shell that stops on a non-zero exit. + + """ + parser = argparse.ArgumentParser( + prog='bump_version.py', + description='Bump pcapkit.__version__ and the citation metadata that tracks it.', + ) + parser.add_argument( + '--citation', type=pathlib.Path, default=CITATION, + help='citation file to keep in step (default: %(default)s)', + ) + args = parser.parse_args(argv) + + new_ver = bump(current_version()) + + # Read and planned before anything is written, so the one fatal outcome -- a + # citation file with no ``version`` field -- leaves the tree untouched instead + # of half-bumped. + text = read_citation(args.citation) + planned = None if text is None else plan_citation(text, new_ver) + + rewrite_init(init_path(), new_ver) + + if planned is not None: + args.citation.write_text(planned, encoding='utf-8') + + with open(os.path.join('conda', 'build'), 'w') as build: + build.write('0') + + return 0 + + +if __name__ == '__main__': + sys.exit(main())