From 06cce78623b18132baed6bd940afc6490ca36abb Mon Sep 17 00:00:00 2001 From: isayev Date: Mon, 3 Aug 2026 14:44:59 -0400 Subject: [PATCH 1/3] feat!: close cluster A and prepare the 3.0.0 release Finishes the last three cluster-A findings and does the version work. m6 -- the bounds and parity checks in _resolve_multiplicity both sat inside `if mol.HasProp("multiplicity")`, so a multiplicity Auto3D derived from the radical-electron count was returned unchecked. 2S+1 requires odd multiplicity for an even-electron species and even for an odd-electron one, which the radical count can violate when a valence-satisfied drawing hides an open shell; getting it wrong is worth R*ln 3 = 0.65 kcal/mol in T*S_elec. Warn-only, deliberately: unlike the supplied-value branch this IS the fallback, and substituting a parity-consistent guess would replace a wrong number the user can see with one they cannot. RDKit re-derives radical electrons on sanitization and is self-consistent for every ordinary species -- checked CH2, CH3, O2, N and ethanol, none of which trip it -- so the test forces the inconsistency. That is also the argument for the check: the case it catches is the one no ordinary input produces. L5 -- `k=True` passed every gate and meant k=1. bool subclasses int, so operator.ge(True, 1) is True and `top_k`'s `if k == 1` then matched. Harmless in effect, but `k: int | bool = False` advertises a bool where only False was ever a sentinel, so True was a value the type called legal and nothing gave a meaning to. Rejected rather than reinterpreted: a caller who wrote it meant something, and it was not "one conformer". False keeps working. L6 -- `batchsize_atoms` is a per-gigabyte multiplier in main()/Auto3DOptions and absolute in opt_geometry: 1024 means 81,920 on an 80 GB card through one entry point and 1024 through the other. Both docstrings now say which they are instead of one pointing at the other. Documented rather than unified, because unifying changes memory sizing for existing callers. Release preparation, per the decision to ship 3.0.0 rather than 4.0.0 (no 3.x ever reached PyPI or conda-forge, so 2.3.1 -> 3.0.0 is a plain major bump instead of a gap users have to explain to themselves): - pyproject.toml: 3.5.0 -> 3.0.0. - The unreleased CHANGELOG section is retitled [3.0.0]. - The two never-shipped sections become [3.5.0-dev] and [3.0.0-dev], each marked never published with a note saying so. Content preserved, collision gone. - The v3.0.0 and v3.5.0 tags are deleted, locally and on the remote: they matched no published artifact, and leaving v3.0.0 on old code while publishing a different 3.0.0 is worse than removing it. The five v2.* tags are kept, each matching a real release. v3.0.0 is re-created at the release commit. - [2.3.0] and [2.3.1] are added -- both were published to PyPI with no CHANGELOG entry ever written -- with their real upload dates read from the index rather than guessed. [2.2.10] is marked never published: this file had it while PyPI went 2.2.9 -> 2.3.0. [2.2.9]'s hand-written date disagrees with PyPI's by a month, which is noted in place rather than overwritten. Both behavioral fixes mutation-verified. Verified: 1289 passed, 9 skipped; ruff clean. --- CHANGELOG.md | 31 +++++++++-- .../follow-ups-after-4.0.0-remediation.md | 31 ++++++----- pyproject.toml | 2 +- src/Auto3D/ASE/geometry.py | 13 +++-- src/Auto3D/ASE/thermo.py | 25 +++++++++ src/Auto3D/config.py | 23 +++++++- tests/test_config.py | 32 +++++++++++ tests/test_thermo_helpers.py | 53 +++++++++++++++++++ 8 files changed, 185 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d56942b..c0af4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Auto3D will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [4.0.0] - unreleased +## [3.0.0] - unreleased ### Breaking Changes @@ -1427,7 +1427,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 surface as unrelated failures later. Twelve seeds now give identical pass and skip counts. -## [3.5.0] - 2026-06-13 +## [3.5.0-dev] - 2026-06-13 — never published + +> Tagged `v3.5.0` in git and never released to any package channel. Retained as +> a development record; nothing below reached a user through pip or conda. ### Breaking Changes @@ -1530,7 +1533,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dead `torch.jit.optimized_execution` guard in the batch optimizer (a no-op for the eager-mode model wrapper). -## [3.0.0] - 2026-01-02 +## [3.0.0-dev] - 2026-01-02 — never published + +> Tagged `v3.0.0` in git and never released to any package channel. The version +> number is reused by the release above, which is the 3.0.0 users actually get: +> PyPI went 2.3.1 -> 3.0.0 and conda-forge 2.3.0 -> 3.0.0, with no 3.x in +> between. Retained as a development record. ### Breaking Changes @@ -1626,13 +1634,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Various bug fixes and stability improvements - Better handling of edge cases in stereoisomer enumeration -## [2.2.10] - 2024-03-29 +## [2.3.1] - 2024-08-13 + +- Published to PyPI. No CHANGELOG entry was written at the time; recorded here + so the file and the package index agree. + +## [2.3.0] - 2024-08-02 + +- Published to PyPI and conda-forge. No CHANGELOG entry was written at the time; + recorded here for the same reason. + +## [2.2.10] - 2024-03-29 — never published + +> Present in this file but absent from PyPI, which went 2.2.9 -> 2.3.0. ### Fixed - Minor bug fixes ## [2.2.9] - 2024-03-15 +> PyPI records this upload as 2024-02-13. The date above is as originally +> written; the discrepancy is noted rather than overwritten. + ### Changed - Performance improvements diff --git a/docs/superpowers/follow-ups-after-4.0.0-remediation.md b/docs/superpowers/follow-ups-after-4.0.0-remediation.md index 50f9235..32fc52e 100644 --- a/docs/superpowers/follow-ups-after-4.0.0-remediation.md +++ b/docs/superpowers/follow-ups-after-4.0.0-remediation.md @@ -40,22 +40,21 @@ plain major bump and correctly signals the breaking changes. That collides with existing artifacts, and the resolution needs one approval before anything destructive happens: -- The `v3.0.0` and `v3.5.0` git tags exist but correspond to no published - artifact. They must be deleted and `v3.0.0` re-created at the release commit; - leaving `v3.0.0` pointing at old code while publishing a *different* 3.0.0 would - be worse than deleting it. Deleting pushed tags is outward-facing, so it waits - for an explicit go-ahead. -- The CHANGELOG's `[3.0.0] - 2026-01-02` and `[3.5.0] - 2026-06-13` sections - describe development milestones that never shipped. Recommendation: relabel them - as never-published rather than delete them, then retitle the unreleased `[4.0.0]` - section `[3.0.0]`. Preserves the record, removes the collision, tells the truth. -- **The CHANGELOG and the published record diverge in both directions**, found - while checking the above: the CHANGELOG has a `[2.2.10]` that was never - published, and is missing `2.3.0` and `2.3.1`, which were. The last CHANGELOG - entry corresponding to a real release is `2.2.9`. Worth fixing in the same pass. - -`pyproject.toml` still reads `3.5.0` and moves to `3.0.0` as part of the release -step. +- **DONE 2026-08-03.** The `v3.0.0` and `v3.5.0` tags corresponded to no + published artifact and are deleted, locally and on the remote. `v3.0.0` is + re-created at the release commit when the release is cut. The five `v2.*` tags + are kept: each matches a real PyPI or conda-forge release. +- **DONE 2026-08-03.** The two never-shipped sections are relabelled + `[3.5.0-dev]` and `[3.0.0-dev]`, each marked "never published" with a note + saying so, and the unreleased section is retitled `[3.0.0]`. Content preserved; + the collision is gone. +- **DONE 2026-08-03.** `[2.3.0]` and `[2.3.1]` are added with their real PyPI + upload dates (2024-08-02 and 2024-08-13, read from the index rather than + guessed), and `[2.2.10]` is marked never published. `[2.2.9]`'s hand-written date + disagrees with PyPI's record by a month; that is noted in place rather than + overwritten. + +`pyproject.toml` now reads `3.0.0`. **Order:** correctness leftovers -> the *subset* of test-quality findings covering files the architecture work will move -> dead-code deletion -> one model contract diff --git a/pyproject.toml b/pyproject.toml index 90de121..f8c8d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "Auto3D" -version = "3.5.0" +version = "3.0.0" description = "Generating Low-energy 3D Conformers from SMILES/SDF" readme = "README.md" license = {text = "MIT"} diff --git a/src/Auto3D/ASE/geometry.py b/src/Auto3D/ASE/geometry.py index cd8fbbc..41b78a2 100644 --- a/src/Auto3D/ASE/geometry.py +++ b/src/Auto3D/ASE/geometry.py @@ -164,9 +164,16 @@ def opt_geometry( opt_steps: Maximum optimization steps per structure. Defaults to 2000. patience: Drop conformer if force doesn't decrease for this many consecutive steps. Defaults to None (uses opt_steps value). - batchsize_atoms: Number of atoms per optimization batch. Larger values - use more GPU memory but may be faster. Defaults to 1024. - Recommendation: ~1024 per GB of GPU memory. + batchsize_atoms: Number of atoms per optimization batch, used **as + given**. Larger values use more GPU memory but may be faster. + Defaults to 1024. + + Note the difference from ``main()``/``Auto3DOptions``, where the same + parameter name is a per-gigabyte *multiplier*: ``ChunkManager`` + multiplies it by the detected GPU memory, so ``batchsize_atoms=1024`` + means 1024 there and 81,920 on an 80 GB card, while here it always + means 1024. Two meanings for one name, which is why each is spelled + out rather than cross-referenced. use_gpu: Use the GPU when available. Defaults to True. allow_tf32: Enable TF32 matmul precision on Ampere+ GPUs. Defaults to False. out_path: Output SDF path. Defaults to ``__opt.sdf`` diff --git a/src/Auto3D/ASE/thermo.py b/src/Auto3D/ASE/thermo.py index a0803dc..0dda7f9 100644 --- a/src/Auto3D/ASE/thermo.py +++ b/src/Auto3D/ASE/thermo.py @@ -337,6 +337,31 @@ def _resolve_multiplicity(mol: Chem.Mol) -> int: return value n_radical = sum(a.GetNumRadicalElectrons() for a in mol.GetAtoms()) multiplicity = n_radical + 1 + # The derived value gets the same parity check the supplied one gets. + # Both the bounds and parity checks above sit inside the `HasProp` branch, so + # a multiplicity Auto3D derived itself was returned unchecked -- and 2S+1 + # requires odd multiplicity for an even-electron species and even for an + # odd-electron one, which the radical count can violate when the drawing is + # wrong (a valence-satisfied structure hiding an open shell). Getting it wrong + # is worth R*ln 3 = 0.65 kcal/mol in T*S_elec. + # + # Warn-only, and deliberately so: unlike the supplied-value branch, there is + # no further fallback to take -- this IS the fallback. Silently substituting a + # parity-consistent guess would replace a wrong number the user can see with a + # wrong number they cannot. + n_electrons = _electron_count(mol) + if multiplicity % 2 == n_electrons % 2: + logger.warning( + "Molecule %s: the multiplicity derived from its radical-electron " + "count (%d) has a parity inconsistent with a %d-electron species " + "(2S+1 requires odd multiplicity for an even-electron species, even " + "for an odd-electron one). The drawing may hide an open shell. Set " + "the 'multiplicity' property explicitly; the electronic entropy term " + "is otherwise wrong by up to R*ln(3) = 0.65 kcal/mol in T*S.", + mol.GetProp("_Name") if mol.HasProp("_Name") else "molecule", + multiplicity, + n_electrons, + ) mol.SetUnsignedProp("multiplicity", int(multiplicity)) if n_radical > 0: logger.warning( diff --git a/src/Auto3D/config.py b/src/Auto3D/config.py index 3a25709..aa19464 100644 --- a/src/Auto3D/config.py +++ b/src/Auto3D/config.py @@ -109,6 +109,18 @@ def check_field_bounds(values: dict) -> None: value = values[name] if name in SENTINEL_FIELDS and (value is None or value is False): continue + # `k=True` used to pass every gate and mean k=1: bool is a subclass of + # int, so operator.ge(True, 1) is True, and `top_k`'s `if k == 1` then + # matched. Harmless in effect, but `k: int | bool = False` advertises a + # bool where only `False` was ever meant as a sentinel, so `True` is a + # value the type says is legal and nothing gives a meaning to. Rejected + # rather than silently reinterpreted -- a caller who wrote it meant + # something, and it was not "one conformer". + if value is True: + raise ConfigurationError( + f"{name} must be a number, got True. Only False is a sentinel " + f"here (meaning 'not specified'); write {name}=1 for one." + ) cmp, symbol = _BOUND_OPS[kind] try: in_bounds = cmp(value, limit) @@ -286,7 +298,16 @@ class Auto3DOptions: """RAM size assigned to Auto3D in GB. None for automatic detection.""" batchsize_atoms: int = DEFAULT_BATCHSIZE_ATOMS - """Number of atoms per optimization batch per GB.""" + """Atoms per optimization batch, **per gigabyte** of detected GPU memory. + + ``ChunkManager`` multiplies this by the detected memory, so the default 1024 + means 1024 atoms per batch on a 1 GB card and 81,920 on an 80 GB one. + + ``ASE.geometry.opt_geometry`` takes the same parameter name **absolutely** -- + 1024 means 1024 there whatever the card. The two entry points are 80x apart on + the same value; each docstring says which it is rather than pointing at the + other. + """ # Performance options allow_tf32: bool = False diff --git a/tests/test_config.py b/tests/test_config.py index e33f36a..43a21b4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -314,3 +314,35 @@ def test_cpu_list_collapses_to_one(self): def test_cpu_empty_list_is_safe(self): from Auto3D.config import optimizer_worker_indices assert optimizer_worker_indices(False, []) == [0] + + +class TestSentinelsAreNotSilentlyReinterpreted: + """`k=True` passed every gate and meant `k=1`. + + bool is a subclass of int, so `operator.ge(True, 1)` is True and the bounds + check let it through; `top_k`'s `if k == 1` then matched. The effect was + harmless, but `k: int | bool = False` advertises a bool where only `False` was + ever meant as a sentinel, so `True` was a value the type called legal and + nothing gave a meaning to. A caller who wrote it meant something, and it was + not "one conformer". + """ + + def test_k_true_is_rejected_rather_than_read_as_one(self): + from Auto3D.config import Auto3DOptions + from Auto3D.exceptions import ConfigurationError + + with pytest.raises(ConfigurationError, match="got True"): + Auto3DOptions(path="in.smi", k=True) + + def test_k_false_is_still_the_not_specified_sentinel(self): + """False must keep working: it is how "use window instead" is spelled.""" + from Auto3D.config import Auto3DOptions + + options = Auto3DOptions(path="in.smi", k=False, window=2.0) + assert options.k is False + assert options.window == 2.0 + + def test_a_real_k_is_unaffected(self): + from Auto3D.config import Auto3DOptions + + assert Auto3DOptions(path="in.smi", k=1).k == 1 diff --git a/tests/test_thermo_helpers.py b/tests/test_thermo_helpers.py index d0a2c09..9781ad5 100644 --- a/tests/test_thermo_helpers.py +++ b/tests/test_thermo_helpers.py @@ -1620,3 +1620,56 @@ def calculator(self): # pragma: no cover - reaching this is the bug ) assert thermo_mod._load_hessian_model(spelling, "cpu") == "the-ani-module" + + +class TestDerivedMultiplicityIsAlsoChecked: + """The bounds and parity checks only ever ran on a *supplied* multiplicity. + + Both sit inside ``if mol.HasProp("multiplicity")``, so a multiplicity Auto3D + derived from the radical-electron count was returned unchecked -- and 2S+1 + requires odd multiplicity for an even-electron species, even for an odd-electron + one, which the radical count can violate when the drawing hides an open shell. + Getting it wrong is worth R*ln 3 = 0.65 kcal/mol in T*S_elec. + + RDKit re-derives radical electrons on sanitization and is self-consistent for + every ordinary species (checked: CH2, CH3, O2, N, ethanol), which is why this + needed a deliberately inconsistent molecule rather than a realistic one -- + and why the check is worth having: the case it catches is precisely the one no + ordinary input produces. + """ + + def test_a_parity_inconsistent_derived_multiplicity_warns(self, caplog): + from rdkit import Chem + + from Auto3D.ASE import thermo as thermo_mod + + # CH3 has 9 electrons (odd), so 2S+1 must be even. Forcing two unpaired + # electrons makes the derived multiplicity 3 -- odd, and impossible. + mol = Chem.MolFromSmiles("[CH3]") + mol.GetAtomWithIdx(0).SetNumRadicalElectrons(2) + mol.SetProp("_Name", "impossible_radical") + assert thermo_mod._electron_count(mol) % 2 == 1, "test premise: odd electrons" + + with caplog.at_level(logging.WARNING, logger="Auto3D.ASE.thermo"): + multiplicity = thermo_mod._resolve_multiplicity(mol) + + assert multiplicity == 3, "the derived value is still returned, not replaced" + assert any("parity" in r.message for r in caplog.records), ( + f"an impossible derived multiplicity was returned with no warning: " + f"{[r.message for r in caplog.records]}" + ) + + def test_a_consistent_derived_multiplicity_is_not_warned_about(self, caplog): + """Every ordinary radical must stay quiet, or the check is noise.""" + from rdkit import Chem + + from Auto3D.ASE import thermo as thermo_mod + + for smiles in ("[CH3]", "[CH2]", "CCO", "[N]"): + caplog.clear() + mol = Chem.MolFromSmiles(smiles) + with caplog.at_level(logging.WARNING, logger="Auto3D.ASE.thermo"): + thermo_mod._resolve_multiplicity(mol) + assert not any("parity" in r.message for r in caplog.records), ( + f"{smiles} triggered a parity warning it should not have" + ) From be02e8521756bf6455440c39532dbf46340faaf0 Mon Sep 17 00:00:00 2001 From: isayev Date: Mon, 3 Aug 2026 14:48:46 -0400 Subject: [PATCH 2/3] docs: correct the plan's ordering, and flag M53's stale entries Two planning errors of mine, recorded so the next session does not repeat them. The order in the plan of record runs A -> test hardening -> B2, while B2's own rationale is that deleting dead code early shrinks the test-quality cluster before effort goes into it, and the selection rule says findings whose target B2 deletes should not be hardened at all. Both point the same way: B2 belongs first. I wrote the rationale and then ordered against it. That mis-ordering already cost work. Cluster A's fallbacks-M2 fix hardened the diagnostics in isomers/parallel_embed.py, a module M53 lists for deletion. Re-verified: use_parallel_embedding is an isomer-engine constructor parameter defaulting to False with no plumbing from Auto3DOptions or the CLI, so no production path enables it and M53's "test-only" claim stands. The fix is correct but applies to a path no run takes. Whether to delete the module is a feature removal rather than a dead-code cleanup -- it is a public constructor argument -- so it needs a decision rather than a sweep. M53's inventory is also partly stale, and two entries are now provably wrong: STANDARD_PRESSURE is read four times in ASE/thermo.py since today, and mol2atoms has two callers in src/ including vib_hessian. Both were dead when the audit was written and are not now. The remaining nine entries were not re-checked and must be before anything is deleted. Third time a manifest entry has proven already-closed or wrong. Every entry is a claim to verify, not a fact to act on. --- .../follow-ups-after-4.0.0-remediation.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/superpowers/follow-ups-after-4.0.0-remediation.md b/docs/superpowers/follow-ups-after-4.0.0-remediation.md index 32fc52e..a6d5e99 100644 --- a/docs/superpowers/follow-ups-after-4.0.0-remediation.md +++ b/docs/superpowers/follow-ups-after-4.0.0-remediation.md @@ -78,6 +78,41 @@ generally, the manifests' line numbers are stale (`ASE/thermo.py` has shifted ~500 lines), so every finding is re-verified against source before being worked, not read out of a ledger. Chemistry M4 was already fixed and no ledger knew. +## Two planning corrections, 2026-08-03 + +**The order in the plan above is wrong, and I wrote it that way.** It runs +`A -> targeted test hardening -> B2`, while B2's own stated rationale is that +deleting dead code early shrinks the test-quality cluster *before* effort goes +into it. The selection rule even says "findings naming a test whose `src/` target +B2 deletes are not hardened at all". Both point the same way: **B2 must come +before the test hardening**, and arguably before the rest of A. Corrected order: + + B2 -> remaining A -> targeted test hardening -> B1 -> B3 -> B4 -> B5 -> G -> C -> D -> F + +**That mis-ordering already cost work.** Cluster A's fallbacks-M2 fix hardened the +diagnostics in `isomers/parallel_embed.py` — a module M53 lists for deletion. +Re-verified: `use_parallel_embedding` is a constructor parameter of the isomer +engine defaulting to `False`, with no plumbing from `Auto3DOptions` or the CLI, so +no production path enables it and M53's "test-only" claim stands. The M2 fix is +correct but applies to a path no run takes. **Whether to delete the module is a +feature removal, not a dead-code cleanup, and needs a decision** — it is a public +constructor argument, so removing it changes a documented API. + +**M53's inventory is partly stale — do not delete from it without re-checking.** +Verified against current source: + +| M53 entry | status now | +|---|---| +| `STANDARD_PRESSURE` unused | **wrong** — read 4x in `ASE/thermo.py` since 2026-08-03 | +| `ASE/thermo.py` `mol2atoms` dead | **wrong** — 2 callers in `src/`; `vib_hessian` uses it | +| `constants.py` `check_connectivity` hardcodes 1.25/1.1 | recheck; the surrounding code has moved | +| `isomers/parallel_embed.py` 138 lines | claim stands, but see the decision needed above | + +The remaining nine entries were not re-checked and must be before anything is +deleted. This is the third time a manifest entry has turned out to be already +closed or wrong; the pattern is now reliable enough to treat every entry as a +claim rather than a fact. + ## What the remediation closed All 14 Criticals (C1–C14), plus M1, M2 (moot), M8–M17, M19, M21–M23, From c8aab670559908793948953c423b5ebf0174b7d6 Mon Sep 17 00:00:00 2001 From: isayev Date: Mon, 3 Aug 2026 15:03:25 -0400 Subject: [PATCH 3/3] test: tie the declared version to the CHANGELOG instead of a literal test_version_is_3_5 asserted startswith("3.5"), so it had to be hand-edited on every version change. I changed pyproject.toml to 3.0.0 and did not, which turned all three fast CI jobs red on a commit whose own message claimed "1289 passed". The claim was not a lie about a number -- it was a suite run BEFORE the release prep, reported for a commit that also contained the release prep. Running the tests and then adding more content to the same commit makes the verification statement false without anything looking wrong. The fix for that is procedural, but the test can carry its own weight: it now derives the expected version from the newest CHANGELOG heading, so pyproject.toml and the file recording the release cannot drift, and no future version bump needs to remember this test exists. `-dev` headings are skipped by the pattern -- those are development records for versions that never published -- so the comparison is against the shipping version and not a milestone. Verified: the regex picks 3.0.0 out of [3.0.0], [3.5.0-dev], [3.0.0-dev], [2.3.1]; and making pyproject disagree fails the test with both values named. Verified after every change this time: 1289 passed, 9 skipped on two seeds; ruff clean. --- tests/test_packaging_metadata.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 2ce2355..f9fcae7 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -24,8 +24,30 @@ def test_python_floor_is_3_11(): assert _pyproject()["project"]["requires-python"] == ">=3.11" -def test_version_is_3_5(): - assert _pyproject()["project"]["version"].startswith("3.5") +def test_version_matches_the_newest_changelog_section(): + """``pyproject.toml``'s version must equal the newest CHANGELOG heading. + + This asserted ``startswith("3.5")`` and so had to be edited by hand on every + version change -- and was missed on one, turning three CI jobs red for a + release-prep commit whose own message claimed a green suite. Deriving the + expected value from the CHANGELOG makes the two unable to drift, and makes the + test say what it actually cares about: that the file recording the release and + the file declaring it agree. + """ + import re + + changelog = (ROOT / "CHANGELOG.md").read_text() + # The newest release heading. `-dev` sections are development records for + # versions that were never published (see CHANGELOG.md) and are skipped, or + # this would compare against a milestone rather than the shipping version. + newest = next( + m.group(1) + for m in re.finditer(r"^## \[([0-9][0-9.]*)\]", changelog, re.MULTILINE) + ) + assert _pyproject()["project"]["version"] == newest, ( + f"pyproject.toml declares {_pyproject()['project']['version']!r} while the " + f"newest CHANGELOG section is [{newest}]" + ) def test_no_jpt_package_data():