feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
Files changed: - .gitea/workflows/release.yml - AGENTS.md - CHANGES.md - DEVELOPMENT.md - README.md - VERSION - docs/version-model.md - instructions/dev/version-parts.md - tools/CONTRACT.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/kb_state.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/version.py
This commit is contained in:
1 parent
b1883befc7
commit
d29d400dd3
21 files changed
+1063
-119
No files matched your search
+252
-19
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -42,7 +43,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import blocks, config
|
||||
|
||||
VERSION_FILENAME = "VERSION"
|
||||
CHANGES_FILENAME = "CHANGES.md"
|
||||
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
|
||||
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
||||
|
||||
PARTS = ("major", "minor", "patch")
|
||||
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
|
||||
|
||||
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
|
||||
# second ordering rule everywhere a version is compared - the release feed, the
|
||||
# migration chain, the compatibility check - to serve a workflow this stack does
|
||||
# not have.
|
||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
||||
# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
|
||||
# Deliberately not a general SemVer pre-release alphabet - one channel keeps the
|
||||
# ordering numeric and total. See "Candidates and releases" below.
|
||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
|
||||
|
||||
# The marker pair `bumps` inside a CHANGES.md entry: the machine-managed list of
|
||||
# every `--title` a candidate has collected across its bumps. Reuses
|
||||
# `blocks.open_marker`/`close_marker` (the same delimiter convention as a page
|
||||
# body's generated regions) but is **not** added to `blocks.BLOCKS` - that tuple
|
||||
# feeds `xref`, `cite` and the `unbalanced_markers` lint check, all of which are
|
||||
# about a page's body, and `CHANGES.md` is not a page. The region itself, and
|
||||
# its rendering, belong here instead.
|
||||
BUMPS_BLOCK_NAME = "bumps"
|
||||
_BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
|
||||
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
|
||||
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
|
||||
|
||||
# Written into a CHANGES.md entry whose version crosses a compatibility
|
||||
# boundary that needs no content migration. `docs verify` accepts it in place
|
||||
@@ -84,7 +97,7 @@ BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
||||
# A changelog entry that names a version. Entries predating versioning start
|
||||
# with a date instead and are deliberately not matched - they are history, not
|
||||
# a claim about which version the tree is.
|
||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
|
||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
|
||||
|
||||
|
||||
class VersionError(ValueError):
|
||||
@@ -92,23 +105,67 @@ class VersionError(ValueError):
|
||||
written to be shown to the user verbatim."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
@functools.total_ordering
|
||||
@dataclass(frozen=True)
|
||||
class Version:
|
||||
"""A stack version: `MAJOR.MINOR.PATCH`, optionally a running candidate
|
||||
(`-beta.N`) between two releases.
|
||||
|
||||
**Candidates and releases.** Between two releases the stack carries at
|
||||
most one running candidate rather than a fresh number per `bump` - see
|
||||
`instructions/dev/version-parts.md`. `VERSION` holds either a release
|
||||
(`beta is None`) or a candidate (`beta` is the bump count since the
|
||||
candidate's base was last raised). `base` strips the suffix; `bumped()`
|
||||
always returns a release-shaped `Version`, because it answers "what would
|
||||
the *next fixed* version be", never "what candidate comes next" - that
|
||||
answer needs `escalate()`, which also knows the last release to escalate
|
||||
against.
|
||||
|
||||
**Ordering** is `(major, minor, patch, released, beta)`, `released` sorting
|
||||
a real release after every candidate that shares its base - `4.4.0-beta.1
|
||||
< 4.4.0`. `order=True` on the dataclass cannot express this: `None` and
|
||||
`int` do not compare, and the ordering is inverted relative to field
|
||||
declaration order anyway. `functools.total_ordering` plus an explicit
|
||||
`__lt__` is the direct way to say what the ordering actually is.
|
||||
"""
|
||||
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
beta: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str) -> "Version":
|
||||
match = _SEMVER_RE.match(text or "")
|
||||
if not match:
|
||||
raise VersionError(
|
||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH"
|
||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
|
||||
"or MAJOR.MINOR.PATCH-beta.N"
|
||||
)
|
||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||
beta = int(match.group(4)) if match.group(4) is not None else None
|
||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), beta)
|
||||
|
||||
def __str__(self) -> str: # noqa: D105 - obvious
|
||||
return f"{self.major}.{self.minor}.{self.patch}"
|
||||
suffix = f"-beta.{self.beta}" if self.beta is not None else ""
|
||||
return f"{self.major}.{self.minor}.{self.patch}{suffix}"
|
||||
|
||||
def _sort_key(self) -> tuple[int, int, int, int, int]:
|
||||
return (self.major, self.minor, self.patch, 0 if self.is_prerelease else 1, self.beta or 0)
|
||||
|
||||
def __lt__(self, other: "Version") -> bool:
|
||||
if not isinstance(other, Version):
|
||||
return NotImplemented
|
||||
return self._sort_key() < other._sort_key()
|
||||
|
||||
@property
|
||||
def is_prerelease(self) -> bool:
|
||||
return self.beta is not None
|
||||
|
||||
@property
|
||||
def base(self) -> "Version":
|
||||
"""This version with any candidate suffix stripped - what it would be
|
||||
once fixed. A no-op on a version that is already a release."""
|
||||
return Version(self.major, self.minor, self.patch)
|
||||
|
||||
def bumped(self, part: str) -> "Version":
|
||||
if part == "major":
|
||||
@@ -127,6 +184,10 @@ class Version:
|
||||
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
||||
non-zero component, so it compares by all three - during `0.0.x`
|
||||
every release is a breaking one, which is what that range means.
|
||||
|
||||
Computed over major/minor/patch alone, i.e. over the **base**: a
|
||||
candidate's pre-release suffix carries no compatibility information of
|
||||
its own, it is the base that will be released that does.
|
||||
"""
|
||||
components = (self.major, self.minor, self.patch)
|
||||
for index, component in enumerate(components):
|
||||
@@ -135,6 +196,44 @@ class Version:
|
||||
return components
|
||||
|
||||
|
||||
def _stage_between(reference: Version, base: Version) -> Optional[str]:
|
||||
"""Which part `base` has escalated past `reference` on, or None if equal.
|
||||
|
||||
Both are release-shaped (no beta): `reference` is the last real release,
|
||||
`base` is a candidate's base. Exactly one of major/minor/patch differs,
|
||||
because `bumped()` always resets everything to the right of the part it
|
||||
raises - so the leftmost differing component *is* the stage.
|
||||
"""
|
||||
for part in PARTS:
|
||||
if getattr(reference, part) != getattr(base, part):
|
||||
return part
|
||||
return None
|
||||
|
||||
|
||||
def escalate(last_release: Optional[Version], current: Version, part: str) -> Version:
|
||||
"""The next candidate: `current` escalated by `part` against `last_release`,
|
||||
max-wins.
|
||||
|
||||
A running candidate never steps back down: bumping `--patch` on a MINOR
|
||||
candidate only advances its bump count (`beta`), it does not lower the
|
||||
base. `last_release=None` is the fresh-distribution edge case - a
|
||||
changelog with no versioned entry at all - where there is nothing to
|
||||
escalate against, so the candidate's base is simply `current` bumped by
|
||||
`part`; see instructions/dev/version-parts.md for why that is not an
|
||||
error.
|
||||
"""
|
||||
if part not in _STAGE_RANK:
|
||||
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
|
||||
reference = last_release if last_release is not None else (
|
||||
current.base if current.is_prerelease else current
|
||||
)
|
||||
old_stage = _stage_between(reference, current.base) if current.is_prerelease else None
|
||||
new_stage = part if old_stage is None else max(old_stage, part, key=_STAGE_RANK.get)
|
||||
new_base = reference.bumped(new_stage)
|
||||
new_beta = (current.beta + 1) if (current.is_prerelease and current.base == new_base) else 1
|
||||
return Version(new_base.major, new_base.minor, new_base.patch, new_beta)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateStatus:
|
||||
"""The answer `version check` reports. `state` is the actionable part:
|
||||
@@ -323,6 +422,23 @@ def top_changes_version(text: str) -> Optional[Version]:
|
||||
return Version.parse(match.group(1))
|
||||
|
||||
|
||||
def last_release(text: str) -> Optional[Version]:
|
||||
"""The newest entry that is a **release**, not a running candidate, or
|
||||
`None` if the changelog names no release at all yet.
|
||||
|
||||
Entries are inserted newest-first (see `insert_changes_entry`), so the
|
||||
first non-pre-release heading found scanning top-down is the last release
|
||||
- whether or not the very top entry is an open candidate sitting above it.
|
||||
A changelog with no versioned entry (a fresh distribution) answers `None`,
|
||||
which `escalate()` treats as its own edge case rather than an error.
|
||||
"""
|
||||
for match in _CHANGES_ENTRY_RE.finditer(text):
|
||||
version = Version.parse(match.group(1))
|
||||
if not version.is_prerelease:
|
||||
return version
|
||||
return None
|
||||
|
||||
|
||||
def changes_section(text: str, version: Version) -> Optional[str]:
|
||||
"""The body of one version's entry, heading included, ready to become
|
||||
release notes.
|
||||
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _bumps_block(titles: list[str]) -> str:
|
||||
lines = "\n".join(f"- {title}" for title in titles)
|
||||
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||
|
||||
|
||||
def _bump_titles(section: str) -> list[str]:
|
||||
match = _BUMPS_RE.search(section)
|
||||
if not match:
|
||||
return []
|
||||
return [
|
||||
line[2:].strip()
|
||||
for line in match.group(1).strip("\n").splitlines()
|
||||
if line.strip().startswith("- ")
|
||||
]
|
||||
|
||||
|
||||
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
||||
|
||||
Used for the breaking-change and no-migration lines, which - unlike the
|
||||
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
||||
restates it rather than growing a list nobody would read as history.
|
||||
"""
|
||||
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||
if pattern.search(section):
|
||||
return pattern.sub(line, section, count=1)
|
||||
anchor = section.find(_BUMPS_CLOSE)
|
||||
if anchor != -1:
|
||||
insert_at = section.find("\n", anchor)
|
||||
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
||||
else:
|
||||
insert_at = len(section)
|
||||
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
||||
|
||||
|
||||
def _entry_span(text: str) -> tuple[int, int]:
|
||||
"""Start/end offsets of the topmost entry, heading included."""
|
||||
match = re.search(r"^## ", text, re.MULTILINE)
|
||||
if not match:
|
||||
raise VersionError(f"{CHANGES_FILENAME} has no entry to update")
|
||||
start = match.start()
|
||||
following = re.search(r"^## ", text[start + 1:], re.MULTILINE)
|
||||
end = start + 1 + following.start() if following else len(text)
|
||||
return start, end
|
||||
|
||||
|
||||
def _update_open_candidate(
|
||||
text: str,
|
||||
version: Version,
|
||||
date: str,
|
||||
title: str,
|
||||
breaking_reason: Optional[str],
|
||||
no_migration_reason: Optional[str],
|
||||
) -> str:
|
||||
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||
`title` to its machine-managed bump list, and set the breaking/no-migration
|
||||
lines only where this call supplies them - see `insert_changes_entry`."""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
||||
|
||||
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
|
||||
|
||||
if breaking_reason:
|
||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||
if no_migration_reason:
|
||||
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
||||
|
||||
return text[:start] + section + text[end:]
|
||||
|
||||
|
||||
def insert_changes_entry(
|
||||
text: str,
|
||||
version: Version,
|
||||
@@ -351,18 +542,35 @@ def insert_changes_entry(
|
||||
no_migration_reason: Optional[str] = None,
|
||||
breaking_reason: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Add a heading for `version` above the newest existing entry.
|
||||
"""Open a new entry above the newest existing one, or - when the topmost
|
||||
entry is still an open candidate (a pre-release heading) - update that
|
||||
entry in place instead.
|
||||
|
||||
Only the skeleton: heading, date, author, and - when a compatibility
|
||||
boundary is crossed - the line saying what breaks, plus the line saying no
|
||||
content has to change where that applies. The entry's actual content is
|
||||
written afterwards by whoever made the change, which is also why `bump`
|
||||
refuses to invent a title.
|
||||
`version bump` always lands on a candidate (see `escalate`); only
|
||||
`version release` fixes one, and it edits the heading directly rather than
|
||||
through this path (`version_cmd.release_command`), which is what makes "is
|
||||
the topmost heading still a pre-release" the right test for "is a
|
||||
candidate still open" here.
|
||||
|
||||
The break comes first: it is what an operator reading the release notes has
|
||||
to act on, and the migration line only qualifies it.
|
||||
A fresh entry gets the skeleton only: heading, date, author, the
|
||||
machine-managed bump-title list (started with this one title, for a
|
||||
candidate), and - when a compatibility boundary is crossed - the line
|
||||
saying what breaks, plus the line saying no content has to change where
|
||||
that applies. The break comes first: it is what an operator reading the
|
||||
release notes has to act on, and the migration line only qualifies it. The
|
||||
entry's actual prose is written afterwards by whoever made the change,
|
||||
which is also why `bump` refuses to invent a title.
|
||||
"""
|
||||
top = top_changes_version(text)
|
||||
if top is not None and top.is_prerelease:
|
||||
return _update_open_candidate(
|
||||
text, version, date, title,
|
||||
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||
)
|
||||
|
||||
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||
if version.is_prerelease:
|
||||
lines += [_bumps_block([title]), ""]
|
||||
if breaking_reason:
|
||||
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
||||
if no_migration_reason:
|
||||
@@ -372,3 +580,28 @@ def insert_changes_entry(
|
||||
if anchor:
|
||||
return text[: anchor.start()] + entry + text[anchor.start():]
|
||||
return text.rstrip() + "\n\n---\n\n" + entry
|
||||
|
||||
|
||||
def release_entry(text: str, date: str, title: Optional[str] = None) -> str:
|
||||
"""Fix the topmost entry: strip its version's `-beta.N` suffix and write
|
||||
today's heading, keeping the previous title unless `title` overrides it.
|
||||
|
||||
Leaves the rest of the entry - the bump-title list included - untouched:
|
||||
it is the record of what happened across the candidate's life, and a
|
||||
release call has no reason to discard it. `version_cmd.release_command`
|
||||
is the only caller; it has already checked the topmost entry names a
|
||||
pre-release, so a non-pre-release version reaching here is a caller bug.
|
||||
"""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||
|
||||
current = Version.parse(heading_match.group(1))
|
||||
rest = heading_match.group(2) or ""
|
||||
_, _, existing_title = rest.partition(" - ")
|
||||
new_title = title if title is not None else existing_title
|
||||
|
||||
section = f"## {current.base} - {date} - {new_title}" + section[heading_match.end():]
|
||||
return text[:start] + section + text[end:]
|
||||
Reference in new issue
Block a user