Files
chemenu/tools/chemenu/version.py
T
torben 1b0158fc8d
CI / verify (push) Successful in 50s
Release / release (push) Successful in 38s
stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-12 18:23:58 +02:00

747 lines
32 KiB
Python

"""The stack's own version: what `VERSION` holds, how a release stamps an
instance, and when two versions are compatible.
The version describes the **stack** - `tools/`, `types/`, `instructions/`,
`AGENTS.md` and the contracts - never the wiki content sitting next to it in
the same repo. That split is the whole reason it is set explicitly rather than
derived from commit messages: `publish --message "ingest: ..."` writes content
commits into this same repo, so a conventional-commit reading would turn every
ingest into a release.
**Compatibility is read off the leftmost non-zero component**, the rule Cargo's
caret ranges use: `0.1.3 -> 0.1.4` is safe, `0.1.3 -> 0.2.0` is not, and from
`1.0.0` on the same rule reads as the familiar "MAJOR breaks". Stating it that
way is what lets the 0.x era carry the signal at all - under a rule keyed to
the MAJOR component alone, every 0.x release would be indistinguishable from
every other, which is exactly the signal update detection needs. Nothing about
the mechanism changes at 1.0.0.
What that component answers is **whether the new version is a drop-in
replacement**: whether an instance can copy the new machinery over itself with
no hand-work and still put the old version back afterwards. Whether *content*
must be migrated is a **second, independent question**. It is one way to fail
the first - but a renamed release feed, artefact, import name, flag or envvar
fails it too, with `kb/` untouched, which is why `--no-migration` exists at all:
boundary-crossing bumps that migrate nothing are a real case, not an escape
hatch. Hence two markers below rather than one - `BREAKING_CHANGE_MARKER`
records the break, `MIGRATION_NONE_MARKER` records the absence of the
migration. Which part a change earns stays a judgment call made before the
bump; this module only enforces that a crossing says what it costs.
Paths are resolved through `config.ROOT` at call time rather than at import,
because the tests (and `dist export`'s own fixtures) relocate the root.
"""
from __future__ import annotations
import functools
import json
import os
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from chemenu import blocks, config
VERSION_FILENAME = "VERSION"
CHANGES_FILENAME = "CHANGES.md"
# Written into an exported distribution by `dist export`, and committed with
# it: an instance has to keep knowing which stack it came from, or update
# detection has nothing to compare against. Machine-written, never hand-edited.
# Defined in `config.py`, not here - see that constant's own comment.
RELEASE_STAMP_FILENAME = config.RELEASE_STAMP_FILENAME
STAMP_SCHEMA = 1
# Where `version check` looks when neither the environment nor a release stamp
# names something else. A fork changes this line; an instance exported from a
# fork carries the fork's URL in its stamp and never reaches this default.
DEFAULT_UPDATE_URL = (
"https://gitea.nehmer.net/api/v1/repos/torben/chemenu/releases/latest"
)
UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
# Optional: only needed if the origin's release feed is not readable
# anonymously. Absent is the normal case, not a misconfiguration.
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
PARTS = ("major", "minor", "patch")
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
# `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
# of a migration document, so the exact string is a contract between the two.
MIGRATION_NONE_MARKER = "**Migration:** none required"
# Written into every CHANGES.md entry whose version crosses a compatibility
# boundary, migration or not: the swap is not drop-in, and the operator of an
# existing instance has to be told what stops working. `docs verify` checks the
# newest crossing carries it, so this string too is a contract between the two.
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+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
class VersionError(ValueError):
"""A version could not be read, parsed, or fetched. Carries a message
written to be shown to the user verbatim."""
@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 "
"or MAJOR.MINOR.PATCH-beta.N"
)
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
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":
return Version(self.major + 1, 0, 0)
if part == "minor":
return Version(self.major, self.minor + 1, 0)
if part == "patch":
return Version(self.major, self.minor, self.patch + 1)
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
@property
def compat_key(self) -> tuple[int, ...]:
"""The prefix up to and including the leftmost non-zero component.
Two versions are compatible exactly when this is equal. `0.1.3` and
`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):
if component:
return components[: index + 1]
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:
`migration` is not a louder `update`, it is a different instruction."""
local: Version
latest: Version
state: str # "current" | "update" | "migration" | "ahead"
release_url: Optional[str] = None
published_at: Optional[str] = None
@property
def headline(self) -> str:
if self.state == "current":
return f"Up to date: {self.local} is the latest release."
if self.state == "ahead":
return (
f"Local stack {self.local} is ahead of the latest release {self.latest} "
"- an unreleased tree."
)
if self.state == "migration":
return (
f"Update available: {self.local} -> {self.latest}. This crosses a "
"compatibility boundary - the release notes name the migration required."
)
return f"Update available: {self.local} -> {self.latest} (compatible)."
def compare(local: Version, latest: Version) -> str:
if latest == local:
return "current"
if latest < local:
return "ahead"
return "update" if latest.compat_key == local.compat_key else "migration"
def version_file() -> Path:
return config.ROOT / VERSION_FILENAME
def stamp_file() -> Path:
return config.ROOT / RELEASE_STAMP_FILENAME
def changes_file() -> Path:
return config.ROOT / CHANGES_FILENAME
def read_version() -> Version:
"""This tree's stack version. Raises rather than guessing: a stack with no
declared version cannot answer "is there an update", and a placeholder
would answer it wrongly."""
path = version_file()
if not path.is_file():
raise VersionError(
f"{VERSION_FILENAME} is missing - this tree declares no stack version"
)
return Version.parse(path.read_text(encoding="utf-8"))
def write_version(version: Version) -> None:
version_file().write_text(f"{version}\n", encoding="utf-8")
def read_stamp() -> Optional[dict]:
"""The release stamp, if this tree came from one. `None` is a normal
answer - a dev checkout has no stamp - so a malformed one is the only
case worth failing over."""
path = stamp_file()
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise VersionError(f"{RELEASE_STAMP_FILENAME} is not readable JSON: {exc}") from exc
if not isinstance(data, dict):
raise VersionError(f"{RELEASE_STAMP_FILENAME} does not contain a JSON object")
return data
def update_url(stamp: Optional[dict] = None) -> str:
"""Where to ask for the latest release: the environment overrides, then
the stamp this instance was exported with, then the compiled-in default."""
override = os.environ.get(UPDATE_URL_ENV, "").strip()
if override:
return override
if stamp:
from_stamp = str(stamp.get("update_url") or "").strip()
if from_stamp:
return from_stamp
return DEFAULT_UPDATE_URL
Fetcher = Callable[[str, Optional[str], float], bytes]
def _urlopen_fetch(url: str, token: Optional[str], timeout: float) -> bytes:
request = urllib.request.Request(url, headers={"Accept": "application/json"})
if token:
request.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - explicit https URL
return response.read()
def fetch_latest(
url: str,
token: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[Fetcher] = None,
) -> Version:
"""The version the release feed reports as latest.
The network call sits behind `fetcher` so every caller above this line -
and every test - can run without a network. This is the one place in
`wikitool` that talks to a remote host, and it is reached only from
`version check`, never implicitly from another command.
"""
fetch = fetcher or _urlopen_fetch
try:
payload = fetch(url, token, timeout)
except urllib.error.HTTPError as exc:
hint = ""
if exc.code in (401, 403):
hint = f" - the feed needs authentication; set ${UPDATE_TOKEN_ENV}"
elif exc.code == 404:
hint = " - no release published yet, or the URL names the wrong repository"
raise VersionError(f"{url} answered HTTP {exc.code}{hint}") from exc
except (urllib.error.URLError, OSError, TimeoutError) as exc:
raise VersionError(f"Could not reach {url}: {exc}") from exc
try:
data = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise VersionError(f"{url} did not answer with JSON: {exc}") from exc
if not isinstance(data, dict):
raise VersionError(f"{url} answered with JSON that is not an object")
tag = str(data.get("tag_name") or "").strip()
if not tag:
raise VersionError(f"{url} answered without a `tag_name` - not a release feed")
return Version.parse(tag)
def fetch_latest_release(
url: str,
token: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[Fetcher] = None,
) -> tuple[Version, Optional[str], Optional[str]]:
"""`fetch_latest` plus the two display fields a report wants: the release's
own page and its publication date."""
fetch = fetcher or _urlopen_fetch
captured: dict = {}
def capturing(u: str, t: Optional[str], to: float) -> bytes:
payload = fetch(u, t, to)
try:
parsed = json.loads(payload)
if isinstance(parsed, dict):
captured.update(parsed)
except (json.JSONDecodeError, UnicodeDecodeError):
pass
return payload
version = fetch_latest(url, token, timeout, capturing)
html_url = captured.get("html_url") or captured.get("url")
published = captured.get("published_at") or captured.get("created_at")
return version, (str(html_url) if html_url else None), (str(published) if published else None)
# --- CHANGES.md ------------------------------------------------------------
#
# The changelog is prose and stays the author's job. What is mechanical is the
# heading - which version, which date - and checking that the topmost one
# agrees with VERSION. Same split as `new`: the tool writes structure, the LLM
# writes the entry.
def top_changes_version(text: str) -> Optional[Version]:
"""The version named by the topmost versioned entry, or `None` when the
changelog has none. `None` is valid: a fresh distribution ships a changelog
with no entries at all, and this repo's own history predates versioning."""
match = _CHANGES_ENTRY_RE.search(text)
if not match:
return None
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.
The entry ends at the next `##` heading *of any kind*, not the next
versioned one: entries below `0.1.0` predate versioning and are headed by
a date, so terminating on a versioned heading would run the newest entry
all the way to the end of the file - which is exactly what it did.
"""
for match in _CHANGES_ENTRY_RE.finditer(text):
if Version.parse(match.group(1)) != version:
continue
rest = text[match.start():]
following = re.search(r"^## ", rest[1:], re.MULTILINE)
section = rest[: following.start() + 1] if following else rest
return section.rstrip().removesuffix("---").rstrip() + "\n"
return None
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
# ungraded titles (v5.0.0, ~1440 lines) - unreadable as a release announcement.
# Grading it at bump time, and letting a session regrade it before release,
# is the fix; see instructions/dev/version-parts.md § The candidate model.
IMPACT_LEVELS = ("high", "medium", "low")
DEFAULT_IMPACT = "medium"
_IMPACT_GROUP_RE = re.compile(r"^\*\*(High|Medium|Low) impact\*\*$", re.MULTILINE)
def _bumps_block(entries: list[tuple[str, str]]) -> str:
"""Render the bumps region from `(impact, title)` pairs.
Grouped under a `**High/Medium/Low impact**` heading, in that order, each
present only if it holds at least one title. **Except** when every entry
is `medium` (the default, and the only grade that existed before this):
rendered flat, with no heading at all, exactly as `version bump` always
wrote it. That keeps a single-bump patch entry, and every entry a build
that predates `--impact` ever wrote, byte-identical to what it was.
"""
if all(impact == DEFAULT_IMPACT for impact, _ in entries):
lines = "\n".join(f"- {title}" for _, title in entries)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
groups: dict[str, list[str]] = {level: [] for level in IMPACT_LEVELS}
for impact, title in entries:
groups[impact].append(title)
rendered = [
f"**{level.capitalize()} impact**\n" + "\n".join(f"- {title}" for title in groups[level])
for level in IMPACT_LEVELS
if groups[level]
]
return f"{_BUMPS_OPEN}\n" + "\n\n".join(rendered) + f"\n{_BUMPS_CLOSE}"
def bump_entries(section: str) -> list[tuple[str, str]]:
"""The bumps region parsed back into `(impact, title)` pairs, in rendered
order - the addressing `version regrade` and `version_cmd.release_command`
use.
A `**<Grade> impact**` heading sets the running grade for the `- ` lines
beneath it; a `- ` line with none above it - the shape every region had
before `--impact` existed, and the flat shape `_bumps_block` still writes
when every grade is `medium` - reads as `medium`. That is what makes an
old region parse the same as a new one that happens to grade everything
the same way.
"""
match = _BUMPS_RE.search(section)
if not match:
return []
entries: list[tuple[str, str]] = []
current = DEFAULT_IMPACT
for line in match.group(1).strip("\n").splitlines():
stripped = line.strip()
heading_match = _IMPACT_GROUP_RE.match(stripped)
if heading_match:
current = heading_match.group(1).lower()
continue
if stripped.startswith("- "):
entries.append((current, stripped[2:].strip()))
return entries
# The free-form paragraph `version release` requires above the changesets once
# a candidate collected more than one bump - see `summary_prose` and
# `version_cmd.release_command`. A number, not a quality judgement: it catches
# the empty and the one-line "TODO" case, nothing subtler.
SUMMARY_MIN_CHARS = 200
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
def summary_prose(section: str) -> str:
"""The text between the bumps region (or, for an entry with none, the
heading) and the first `### <bump title>` changeset heading - the
candidate's own summary of what it did, as opposed to the per-bump detail
below it.
"""
close = section.find(_BUMPS_CLOSE)
if close != -1:
start = close + len(_BUMPS_CLOSE)
else:
heading_match = _CHANGES_ENTRY_RE.match(section)
start = heading_match.end() if heading_match else 0
heading = _CHANGESET_HEADING_RE.search(section, start)
end = heading.start() if heading else len(section)
return section[start:end]
def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
"""Change the impact grade of one or more of the topmost entry's bump
titles, addressed by their 1-based position in `bump_entries`'s rendered
order.
All of `updates` are read against a **single** parse of the region, so
`{3: "high", 7: "high"}` in one call means "regrade these two against
today's list" - not "regrade #3, re-render, then regrade #7 against
whatever that produced". `version_cmd.regrade_command` is the only
caller; `version` must already equal the entry it addresses (the same
VERSION/newest-entry agreement every other write here requires).
"""
start, end = _entry_span(text)
section = text[start:end]
heading_match = _CHANGES_ENTRY_RE.match(section)
if not heading_match or Version.parse(heading_match.group(1)) != version:
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry does not name {version}")
entries = bump_entries(section)
if not entries:
raise VersionError(f"{version}'s {CHANGES_FILENAME} entry has no bump list to regrade")
out_of_range = sorted(i for i in updates if i < 1 or i > len(entries))
if out_of_range:
raise VersionError(
f"index/indices out of range (1-{len(entries)}): {', '.join(map(str, out_of_range))}"
)
new_entries = [
(updates.get(position, impact), title)
for position, (impact, title) in enumerate(entries, start=1)
]
new_section = _BUMPS_RE.sub(lambda _m: _bumps_block(new_entries), section, count=1)
return text[:start] + new_section + text[end:]
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.
Anchored just above the bumps region (not below it, as before Gitea #95):
with a graded, potentially 30-line list, the line an operator most needs
to act on stayed the deepest thing in the entry otherwise.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
if pattern.search(section):
return pattern.sub(line, section, count=1)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
return section[:anchor] + f"{line}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{line}\n"
def _clear_marker_line(section: str, marker: str) -> str:
"""Remove the one-line `marker ...` paragraph from `section`, if present.
The retraction counterpart to `_set_marker_line`. A candidate that
recorded `--no-migration` and later turns out to need one after all has no
other way to take that statement back - the line is machine-managed, and
invariant 1 forbids hand-editing it.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*\n?", re.MULTILINE)
return pattern.sub("", section, count=1)
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],
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`(impact, title)` to its machine-managed bump list, and set the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
the caller (`version_cmd.bump_command`), not here."""
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_entries(section) + [(impact, 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}")
elif migration_required:
section = _clear_marker_line(section, MIGRATION_NONE_MARKER)
return text[:start] + section + text[end:]
def insert_changes_entry(
text: str,
version: Version,
date: str,
title: str,
author: str,
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""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.
`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.
A fresh entry gets the skeleton only: heading, date, author, - when a
compatibility boundary is crossed - the line saying what breaks, plus the
line saying no content has to change where that applies, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than
below it (Gitea #95): it is what an operator reading the release notes has
to act on, the migration line only qualifies it, and neither should sit
beneath a list that can run to dozens of graded entries. The entry's
actual prose - the release summary, and each bump's own changeset - is
written afterwards by whoever made the change, which is also why `bump`
refuses to invent a title.
`migration_required` only has anything to retract on an already-open
candidate, so a fresh entry ignores it - there is no earlier
`--no-migration` line in a skeleton that was just opened.
"""
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,
migration_required=migration_required, impact=impact,
)
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason:
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
if version.is_prerelease:
lines += [_bumps_block([(impact, title)]), ""]
entry = "\n".join(lines) + "\n---\n\n"
anchor = re.search(r"^## ", text, re.MULTILINE)
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:]