1b0158fc8d
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
747 lines
32 KiB
Python
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:]
|