Skip to content

changelog

Fetches and parses mujoco-mojo's CHANGELOG.md into structured version entries, for Dojo's "What's Changed" footer button and for scripts/verify_changelog_by_tag.py's pre-push check - one parser, so the two can't drift apart.

The changelog is always read live from GitHub; there is no local-file fallback, so a fetch failure is just an error the caller surfaces, the same way any other failed request already is.

LAST_SEEN_VERSION_FILE module-attribute

Python
LAST_SEEN_VERSION_FILE = (
    MUJOCO_MOJO_DIR / "last_seen_changelog_version.txt"
)

The latest changelog version the user has explicitly acknowledged (by closing the What's Changed modal on it). Read fresh on every GET /changelog/data request, not cached in memory, so a mark-seen write takes effect immediately - both for a later page load in the same Dojo process and across a full restart of it.

ChangelogEntry

Bases: BaseModel

One ## Version X.Y.Z (YYYY-MM-DD) section of the changelog.

bullets instance-attribute

Python
bullets: list[str]

Each top-level - bullet, its marker included, with any of its indented sub-bullets appended as-is - a small, self-contained (possibly nested) markdown list per entry.

version_tuple

Python
version_tuple(v: str) -> tuple[int, int, int]

The leading X.Y.Z as an int triple, for > comparison. A dev-build suffix (e.g. 2.6.11.dev3+g1234abc) is ignored past the prefix; a string with no such prefix degrades to (0, 0, 0) rather than raising, since this never needs to be exact, only ordered.

Source code in src/mujoco_mojo/utils/changelog.py
Python
def version_tuple(v: str) -> tuple[int, int, int]:
    """The leading `X.Y.Z` as an int triple, for `>` comparison. A dev-build suffix (e.g. `2.6.11.dev3+g1234abc`) is ignored past the prefix; a string with no such prefix degrades to `(0, 0, 0)` rather than raising, since this never needs to be exact, only ordered."""
    m = _VERSION_PREFIX_RE.match(v)
    if not m:
        return (0, 0, 0)
    return (int(m.group(1)), int(m.group(2)), int(m.group(3)))

parse_changelog

Python
parse_changelog(text: str) -> list[ChangelogEntry]

Splits text into one ChangelogEntry per ## Version X.Y.Z (date) heading, newest first (the file's own order). A malformed date (this repo's own CHANGELOG.md has one, a typo'd 5-digit year) does not fail the entry - it becomes date=None, with the version and bullets unaffected.

Source code in src/mujoco_mojo/utils/changelog.py
Python
def parse_changelog(text: str) -> list[ChangelogEntry]:
    """Splits `text` into one `ChangelogEntry` per `## Version X.Y.Z (date)` heading, newest first (the file's own order). A malformed date (this repo's own `CHANGELOG.md` has one, a typo'd 5-digit year) does not fail the entry - it becomes `date=None`, with the version and bullets unaffected."""
    headings = list(_HEADING_RE.finditer(text))
    entries: list[ChangelogEntry] = []
    for i, m in enumerate(headings):
        end = headings[i + 1].start() if i + 1 < len(headings) else len(text)
        raw_date = m.group("date").strip()
        entries.append(
            ChangelogEntry(
                version=m.group("version"),
                date=raw_date if _DATE_RE.match(raw_date) else None,
                bullets=_parse_bullets(text[m.end() : end]),
            )
        )
    return entries

fetch_and_parse_changelog

Python
fetch_and_parse_changelog(
    *, force_refresh: bool = False
) -> list[ChangelogEntry]

Fetches CHANGELOG_URL and parses it, reusing a successful result for _CACHE_TTL_SECONDS.

Synchronous by design, so it and everything it calls stay plain-function testable with no async test infra (this repo has none); call it from an async endpoint via starlette.concurrency.run_in_threadpool.

Raises on any failure - a network error, a timeout, a non-200 response, or a 200 that parses to zero entries (a captive-portal page would otherwise look like an empty changelog instead of a failure). A failure is not cached, so the next call retries.

Source code in src/mujoco_mojo/utils/changelog.py
Python
def fetch_and_parse_changelog(*, force_refresh: bool = False) -> list[ChangelogEntry]:
    """
    Fetches `CHANGELOG_URL` and parses it, reusing a successful result for `_CACHE_TTL_SECONDS`.

    Synchronous by design, so it and everything it calls stay plain-function testable with no async test infra (this repo has none); call it from an async endpoint via `starlette.concurrency.run_in_threadpool`.

    Raises on any failure - a network error, a timeout, a non-200 response, or a 200 that parses to zero entries (a captive-portal page would otherwise look like an empty changelog instead of a failure). A failure is not cached, so the next call retries.
    """
    global _cache
    now = time.monotonic()
    with _cache_lock:
        if (
            not force_refresh
            and _cache is not None
            and now - _cache[0] < _CACHE_TTL_SECONDS
        ):
            return _cache[1]
        entries = parse_changelog(_fetch_remote_text())
        if not entries:
            raise ValueError(
                "Remote changelog fetched successfully but parsed to zero entries"
            )
        _cache = (now, entries)
        return entries

read_last_seen_version

Python
read_last_seen_version() -> str | None

The version last written by write_last_seen_version, or None if the user has never acknowledged one (a first-ever launch of this feature, or a fresh ~/.mujoco-mojo).

Source code in src/mujoco_mojo/utils/changelog.py
Python
def read_last_seen_version() -> str | None:
    """The version last written by `write_last_seen_version`, or `None` if the user has never acknowledged one (a first-ever launch of this feature, or a fresh `~/.mujoco-mojo`)."""
    try:
        text = LAST_SEEN_VERSION_FILE.read_text(encoding="utf-8").strip()
    except FileNotFoundError:
        return None
    return text or None

write_last_seen_version

Python
write_last_seen_version(version: str) -> None

Persists version as acknowledged, so it (and everything older) stops being flagged is_new and stops triggering the modal's auto-open, on this and every future launch, until a genuinely newer version appears.

Source code in src/mujoco_mojo/utils/changelog.py
Python
def write_last_seen_version(version: str) -> None:
    """Persists `version` as acknowledged, so it (and everything older) stops being flagged `is_new` and stops triggering the modal's auto-open, on this and every future launch, until a genuinely newer version appears."""
    LAST_SEEN_VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
    LAST_SEEN_VERSION_FILE.write_text(version.strip() + "\n", encoding="utf-8")