unsloth/studio/backend/utils/release_notes.py
Daniel Han c3de39652e
Source the update popup's release notes from the GitHub releases (#8352)
* Source the update popup's release notes from the GitHub releases

CHANGELOG.md had to be hand-edited before every release, and it was keyed by
the PyPI backend version while the announcement itself lives on a release
tagged with the Studio version. The two drifted: the file carried three-bullet
stubs while the release page carried the write-up.

The newest published release is the source now, so the popup follows each new
release with no file to edit and no rebuild. Only the announcement is shown:
the install instructions, the generated What's Changed list, New Contributors,
the Full Changelog line and the appended build provenance are stripped out,
wherever in the body they were written.

* Strip an install block introduced by a paragraph, not just by a heading

v0.1.471-beta writes the same sentence v0.1.43-beta puts in a `###` heading
with no hashes in front of it, so _is_upgrade never saw it and the popup kept
a stale version pin and both install commands. A paragraph has no level, so
the block it opens runs to the next heading that is not one of the platform
headings holding its commands. Run over every published release body, exactly
one output changes and the removed lines are exactly that install block.

Also raise the releases page to the endpoint maximum of 100, which costs the
same single request, and fix three assertions that could not fail: the draft
case used a tag the tag filter rejects first, so the draft filter had no
coverage; no published body carries a provenance section, so asserting its
absence proved nothing; and "refresh" appears throughout the hook, so the
retry path could be deleted with the test still green.

The comment justifying the removed desktop fallback was wrong about what
latest.json's `notes` holds. It is the static download blurb the release
workflow writes, the same text every release, not this release's body.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Bound the rate-limit deadline, not just the first wait on it

The 15 minute cap was applied to the returned TTL while the raw reset epoch
went into _rate_limited_until, so the fetch after that TTL expired answered
from the uncapped deadline and blocked release notes, Retry included, for the
whole server-supplied interval.

GitHub says not to request again before X-RateLimit-Reset, so the reset still
wins over the back-off rather than being cut to 15 minutes, which would retry
into the limit. It is now held to the hour that the unauthenticated window
actually is, so a skewed or proxied header cannot park the popup.

* Record a deadline for every refusal, not just the primary rate limit

A secondary rate limit answers 403 or 429 without X-RateLimit-Remaining: 0, so
that branch returned a 15 minute TTL and left _rate_limited_until unset. Retry
saw no lockout, dropped the cached failure and requested straight back into the
limit, which is what GitHub warns against.

Every refusal now records a bounded deadline, in the order GitHub documents:
Retry-After, which is how a secondary limit states its wait, then the primary
limit's reset, then the plain back-off when the response says neither.

* Size the releases page against the read cap, and split platforms on a slash

A release entry carries its whole body and the newest ones run about 40 KiB,
so the endpoint's maximum of 100 puts the response near 4 MiB against a 2 MiB
cap. The fetch would then fail outright with the release it wanted sitting at
the top of the page it just threw away. Back to 30, which is about 1.2 MiB at
that rate, with the arithmetic recorded in a test that fails if the page grows
past what the cap allows.

_is_platform replaced a slash with a comma but not the spaces around it, so
neither "macOS / Linux / WSL" nor "macOS/Linux/WSL" matched and an install
block written that way would have kept its commands. The separators are now
read as one thing.

* Tighten the comments this change added

Comments only, no code. Mostly the test docstrings, which had grown into
several lines of bug narrative each and now lead with the rule and keep at
most a clause of what broke, and the blocks in release_notes.py that only
restated the line below them.

The reasons bought during review are kept, shorter: why the releases page is
30 and not the endpoint maximum, why the rate-limit deadline is bounded and
why GitHub's reset beats the back-off, why releases are ordered by
published_at, and why the install block is excised where it stands.

* Refuse a desktop tag that goes backwards for PR #8352

Both updater paths compare SemVer, so v0.1.60-beta published after
v0.1.527-beta reads as older and no client on the 5xx series is ever offered
it. Desktop builds have only shipped at four tags, v0.1.526-beta through
v0.1.61-beta, so this is the first time the tag numbering has stranded
anyone, and it strands everyone installed before Aug 10.

Check the new tag against the version in the published latest.json, which is
the file a running build actually reads, and refuse a release that would
strand its own users. Warns rather than fails when the manifest is
unreachable, so a network blip cannot block a release.

* Compare full SemVer in the tag guard, and drop a bare platform install block

The guard compared only the numeric triple, so v0.1.528 or v0.1.528-rc after
v0.1.528-beta read as equal and were refused, though both are newer and the
updater treats them that way. Compare full SemVer precedence instead.

Separately, a platform heading only ended an install block that an 'Updating'
heading or paragraph had already opened, so a release heading its commands
with a bare 'MacOS, Linux, WSL:' kept them. v0.1.0-beta and v0.1.41-beta do
that, and they are the only two of the 24 published bodies this changes; all
seven platform headings in that corpus head an install block.

* Resolve the published desktop version from the newest Studio release

The guard read /releases/latest/download/latest.json, but this repo also
publishes llama.cpp prebuilts as ordinary releases: 25 of them, non-draft and
non-prerelease, so any new one becomes GitHub's latest release. Those carry no
manifest, so the read would 404 and the guard would skip itself silently,
which is the same hazard release_notes.py already documents and avoids.

Take the newest non-draft Studio tag that actually ships a latest.json, and
authenticate with the workflow token rather than spending a shared quota.

* Tighten the comments the last two rounds added

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 00:28:01 -07:00

1260 lines
51 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Release notes for the update popup, taken from the newest GitHub release.
Only the announcement is shown: the install instructions, the generated "What's
Changed" and "New Contributors" lists, the "Full Changelog" line and the
appended build provenance are stripped, wherever they were written.
The fetch is lazy, cached, and skipped when update checks are off.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
from packaging.version import InvalidVersion, Version
from .update_status import DISABLE_ENV_VAR, RELEASE_NOTES_URL
# A release entry carries its whole body, about 40 KiB lately, so the endpoint's
# maximum of 100 would be near 4 MiB, twice the cap below, and the fetch would
# fail outright. 30 is about 1.2 MiB, and the newest release is at the top.
RELEASES_API_URL = "https://api.github.com/repos/unslothai/unsloth/releases?per_page=30"
RELEASES_URL_ENV_VAR = "UNSLOTH_RELEASES_URL"
RELEASES_TIMEOUT_SECONDS = 3
RELEASES_MAX_BYTES = 2 * 1024 * 1024
_RELEASES_CHUNK_BYTES = 64 * 1024
_RELEASES_MIN_READ_SECONDS = 0.05
RELEASES_SUCCESS_TTL_SECONDS = 30 * 60
RELEASES_FAILURE_TTL_SECONDS = 5 * 60
# Unauthenticated callers get 60 requests an hour per IP, so an address that has
# spent them backs off rather than retrying every 5 minutes. Used when the
# response carries no reset to wait for.
RELEASES_RATE_LIMITED_TTL_SECONDS = 15 * 60
# GitHub says not to request again before X-RateLimit-Reset, so its reset wins
# over the back-off above. The window is an hour, so a skewed or proxied header
# is held to that ceiling rather than trusted outright.
RELEASES_RATE_LIMIT_MAX_SECONDS = 60 * 60
RELEASE_NOTES_MAX_CHARS = 20_000
# The repo also publishes llama.cpp prebuilts (`b8475`), legacy month tags
# (`February-2026`) and `desktop-v...` drafts. Only a Studio version tag is an
# announcement the popup should show.
_RELEASE_TAG_PATTERN = re.compile(r"^v\d+(?:\.\d+)+")
# CommonMark needs a space, tab or line end after the hashes: a non-breaking
# space is text, a bare `##` an empty heading that still ends the section above.
_HEADING_PATTERN = re.compile(r"^ {0,3}(?P<hashes>#{1,6})(?:[ \t]+(?P<title>.*?))?[ \t]*$")
# A heading may close with its own run of hashes, which is not part of the title.
_CLOSING_SEQUENCE = re.compile(r"(?:^|[ \t])#+[ \t]*$")
# Inline markup the title carries but the words do not.
_TITLE_MARKUP = re.compile(r"[`*_~]|\[|\]\([^)]*\)|<[^>]*>")
_FULL_CHANGELOG_LINE = re.compile(r"^ {0,3}\*{0,2}full changelog\*{0,2}\s*:", re.IGNORECASE)
_FENCE_PATTERN = re.compile(r"^ {0,3}(?P<marker>`{3,}|~{3,})(?P<rest>.*)$")
# CommonMark type 1 HTML blocks: literal until a closing tag, which the spec
# says need not be the one that opened the block.
_RAW_HTML_OPEN = re.compile(r"^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)", re.IGNORECASE)
_RAW_HTML_CLOSE = re.compile(r"</(pre|script|style|textarea)\s*>", re.IGNORECASE)
# Types 3 to 5 are literal too, each ending on its own delimiter. Comments open
# mid-line, so they are handled separately.
_RAW_BLOCKS = (
(_RAW_HTML_OPEN, _RAW_HTML_CLOSE),
(re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")),
(re.compile(r"^ {0,3}<!\[CDATA\["), re.compile(r"\]\]>")),
# A declaration needs an uppercase letter, so `<!note` stays ordinary text.
(re.compile(r"^ {0,3}<![A-Z]"), re.compile(r">")),
)
# Type 6 runs to the next blank line, so `<details>` holds Markdown only after
# one. Open and close tags both start a block.
_HTML_BLOCK_OPEN = re.compile(r"^ {0,3}</?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)")
# Blocks that break into an open paragraph, closing it rather than continuing it.
_INTERRUPTS = re.compile(
r"^ {0,3}(?:#{1,6}([ \t]|$)|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$)"
)
# A definition is a block of its own but may not interrupt a paragraph.
_LINK_DEFINITION = re.compile(r"^ {0,3}\[(?:[^\[\]\\]|\\.)+\]:")
# Blocks that are not paragraph text, so a following underline is not setext.
_PARAGRAPH_TEXT = re.compile(r"^ {0,3}(?![-*+>]([ \t]|$)|\d{1,9}[.)]([ \t]|$))\S")
# A line of = or - under a paragraph line makes that line a heading.
_SETEXT_UNDERLINE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
# A quoted paragraph continues on unmarked lines, which belong to the quote.
_BLOCK_QUOTE = re.compile(r"^ {0,3}>")
_QUOTE_MARKER = re.compile(r"^ {0,3}>[ \t]?")
# A heading at an item's content column belongs to that item. The marker needs
# whitespace after it, so `2.0` is a version, not an item.
_LIST_ITEM = re.compile(r"^[ \t]*(?P<marker>[-*+]|\d{1,9}[.)])(?P<space>[ \t]+|$)")
_THEMATIC_BREAK = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
# Past this the content after a marker is indented code, so the item's content
# starts one column past the marker instead.
_MAX_ITEM_PADDING = 4
_HTML_BLOCK_TAGS = frozenset(
"""
address article aside base basefont blockquote body caption center col colgroup
dd details dialog dir div dl dt fieldset figcaption figure footer form frame
frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu
menuitem nav noframes ol optgroup option p param search section summary table
tbody td tfoot th thead title tr track ul
""".split()
)
# Type 7: any other complete tag alone on a line; it cannot interrupt a paragraph.
_HTML_ATTRIBUTE = (
r"""(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)"""
)
_HTML_TAG_ONLY_LINE = re.compile(
rf"^ {{0,3}}(?:<[a-zA-Z][a-zA-Z0-9-]*{_HTML_ATTRIBUTE}*\s*/?>|</[a-zA-Z][a-zA-Z0-9-]*\s*>)\s*$"
)
_COMMENT_BLOCK_OPEN = re.compile(r"^ {0,3}<!--")
_COMMENT_OPEN = "<!--"
_COMMENT_CLOSE = "-->"
# Stands in for a line the renderer hides: `#` is a block of its own, so list
# tracking never reads it as a marker or a lazy continuation.
_HIDDEN_BLOCK = "#"
_SAFE_VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.!+-]{0,63}$")
# Sections GitHub or the release workflow generates. Matched on the normalised
# title, so `## What's Changed in Unsloth-Zoo` and a curly apostrophe still
# match. Narrow rather than a substring sweep: "What changed in Gemma 4" is an
# announcement one apostrophe away.
_GENERATED_TITLES = frozenset({"what's changed", "whats changed", "new contributors"})
_GENERATED_PREFIXES = ("what's changed in ", "whats changed in ")
_GENERATED_SUFFIXES = ("zoo changes", "notebooks changes", "changelog")
# The install block, worded differently in almost every release ("Updating /
# installing Unsloth", "To update Studio", "Update Unsloth via `pip install`").
# Naming Unsloth or Studio separates those from "Updating models is now 2x
# faster", which is a change and not instructions.
_UPGRADE_PREFIXES = ("update", "updating", "to update", "how to update")
_UPGRADE_SUBJECTS = ("unsloth", "studio")
_UPGRADE_TITLES = frozenset({"update instructions", "install instructions"})
_PROVENANCE = "build provenance"
# Platform headings the install block splits its commands across, written as its
# siblings as often as its children, so level alone does not end the block. The
# separator is a slash or a comma, however it is spaced.
_PLATFORM_SEPARATOR = re.compile(r"\s*[/,]\s*")
_PLATFORM_TITLES = frozenset(
{
"macos",
"mac",
"macos, linux, wsl",
"macos linux wsl",
"linux",
"windows",
"wsl",
"docker",
"pip",
}
)
@dataclass(frozen = True)
class _ListState:
"""The open list items, innermost last, by the column their content starts."""
columns: tuple[int, ...] = ()
# True while the innermost item has had no content since its marker.
empty_item: bool = False
@dataclass(frozen = True)
class Release:
"""One published GitHub release."""
tag: str
name: str
body: str
html_url: str
published_at: str
@dataclass(frozen = True)
class ReleaseSource:
release: Release | None
source: str | None
error: str | None = None
@dataclass
class _ReleaseCacheEntry:
source: ReleaseSource
expires_at: float
_cache_condition = threading.Condition()
_remote_cache: _ReleaseCacheEntry | None = None
_remote_fetching = False
# Kept across TTL expiry so a 304 can answer without refetching the body.
_remote_etag: str | None = None
_remote_last_good: ReleaseSource | None = None
# Epoch seconds the rate limit resets at, while it is exhausted.
_rate_limited_until: float = 0.0
def reset_release_notes_cache() -> None:
"""Clear the in-process release cache. Intended for tests."""
global _remote_cache, _remote_fetching, _remote_etag, _remote_last_good, _rate_limited_until
with _cache_condition:
_remote_cache = None
_remote_fetching = False
_remote_etag = None
_remote_last_good = None
_rate_limited_until = 0.0
_cache_condition.notify_all()
def is_supported_version_query(version: str) -> bool:
"""Whether `version` is shaped like a version the popup could be offering.
The version is echoed back rather than used to select a release, so the UI
can drop a stale response. `latest`, `main` or a path is rejected outright."""
candidate = version.strip()
if not _SAFE_VERSION_PATTERN.match(candidate):
return False
return _parse_version(candidate) is not None
def _markdown_lines(text: str) -> list[str]:
"""``text`` split the way CommonMark ends lines.
str.splitlines also breaks on U+2028, U+2029, NEL, vertical tab and form
feed, none of which end a Markdown line: one in prose would otherwise turn
the text after it into a heading and cut the announcement there.
"""
return text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
@dataclass(frozen = True)
class Text:
"""A line that is not a heading, as the scanner read it."""
line: str
# A document-level `**Full Changelog**: ...` line, which GitHub appends.
is_full_changelog: bool = False
# First line of a document-level paragraph: an install block is introduced
# by one as often as by a heading, so this is where such a block can begin.
opens_paragraph: bool = False
@dataclass(frozen = True)
class Heading:
"""A heading the renderer would show, at document level."""
level: int
title: str
# The heading's own source lines, in order.
lines: tuple[str, ...]
# Lines already emitted as `Text` that turned out to be this heading. Only
# setext retracts: its title is the paragraph above the underline.
retract: int = 0
def strip_release_body(text: str) -> str:
"""The announcement in a release body, with the generated sections removed.
Each section is excised where it stands rather than the body being truncated
at the first one: maintainers write the install block second of twelve
sections as often as last, so truncating there loses the rest.
A section runs to the next heading at its own level or shallower, so its
subheadings go with it. The install block is the exception twice over: its
platform headings (`### Windows:`) are written as siblings as often as
children, so they keep the drop open, and it is introduced by an ordinary
paragraph as often as by a heading. A paragraph has no level, so the block
it opens runs to the next heading that is not a platform heading.
"""
kept: list[str] = []
# Level of the boilerplate heading being dropped, and whether it was the
# install block, whose platform siblings go with it.
drop_level: int | None = None
drop_upgrade = False
# Set instead when a paragraph, which has no level to compare against,
# opened the install block.
drop_prose = False
for event in scan_blocks(text):
if isinstance(event, Text):
if (
drop_level is None
and not drop_prose
and event.opens_paragraph
and _is_upgrade(_normalise_title(event.line))
):
drop_prose = True
if drop_level is None and not drop_prose and not event.is_full_changelog:
kept.append(event.line)
continue
# A setext heading is the paragraph above the underline, already kept
# line by line, so take it back. Inside a drop nothing was kept.
if event.retract and drop_level is None and not drop_prose:
del kept[len(kept) - event.retract :]
title = _normalise_title(event.title)
if drop_prose:
# Platform headings belong to the block however they were nested;
# any other heading is the announcement resuming.
if _is_platform(title):
continue
drop_prose = False
elif drop_level is not None:
# A platform heading belongs to the install block however it was
# nested, and a deeper heading is a subheading of the dropped one.
if (drop_upgrade and event.level >= drop_level and _is_platform(title)) or (
event.level > drop_level
):
continue
drop_level = None
drop_upgrade = False
# A bare "MacOS, Linux, WSL:" heading opens a block with no "Updating".
upgrade = _is_upgrade(title) or _is_platform(title)
if upgrade or _is_generated(title):
drop_level = event.level
drop_upgrade = upgrade
continue
kept.extend(event.lines)
return "\n".join(kept).strip("\n")
def scan_blocks(text: str):
"""Walk `text` as CommonMark blocks, yielding `Text` and `Heading` events.
Only headings the renderer would show are `Heading`: one inside a fence, a
comment, a raw HTML block or a list item arrives as ordinary text.
"""
# A Windows editor can leave a BOM on the first line, hiding a heading.
text = text.lstrip("")
open_fence: str | None = None
# Content column of the list item the open block belongs to, 0 at document
# level: a fence or HTML block is scoped to its container, so the item's end
# closes it. Only one of the three is ever open.
block_column = 0
in_comment = False
in_raw_html: int | None = None
in_html_block = False
after_paragraph = False
# The open paragraph a later underline turns into a heading: as the renderer
# reads it, and as it was written.
paragraph: list[str] = []
paragraph_source: list[str] = []
in_quote = False
quoted = False
lists = _ListState()
for line in _markdown_lines(text):
# The line as list tracking sees it: blank wherever nothing renders.
structural = ""
opened_block = False
in_block = open_fence is not None or in_html_block or in_raw_html is not None or in_comment
# A fence, comment or HTML block inside an item ends with the item, so a
# dedented line closes both, and none of them takes a lazy continuation.
# A raw block or comment there also ends on a blank line the item takes.
leaves = (
_indent_width(line) < block_column
if line.strip()
else in_raw_html is not None or in_comment
)
if in_block and block_column and leaves:
open_fence = None
in_html_block = False
in_raw_html = None
in_comment = False
block_column = 0
# The paragraph it could have continued is block content.
after_paragraph = False
# A fence written as an item's first content opens inside it, so an opener
# is read past a marker. Only an opener: fenced content is literal.
fence_line = line if open_fence else _item_content(line, after_paragraph)
# Raw HTML first: its contents are literal, so a fence in it is not one.
if in_raw_html is not None:
visible, in_raw_html = _strip_raw_html(line, in_raw_html)
elif in_html_block:
# A blank line is the only thing that ends a type 6 block.
in_html_block = line.strip() != ""
visible = ""
elif (fence := _FENCE_PATTERN.match(fence_line)) and not in_comment:
was_open = open_fence
open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest"))
opened_block = was_open is None and open_fence is not None
# Hidden from heading matching, but its indent still closes items.
visible = ""
structural = line
elif open_fence:
visible = ""
else:
# A block already open owns this line, so it is content.
hidden = in_comment or in_raw_html is not None
# A comment is an HTML block too, so it opens inside an item as a
# fence does: the opener is read past a marker on the same line.
block_open = (
not in_comment
and _COMMENT_BLOCK_OPEN.match(_item_content(line, after_paragraph)) is not None
)
# Commented-out sections are not rendered, so they are not releases.
visible, in_comment = _strip_comments(line, in_comment, block_open)
# The marker stays, so the item is still tracked. A comment blanks its
# own line, so the raw line is used: the block renders as nothing, but
# the item it is content of still opens.
source = line if block_open else visible
content = _item_content(source, after_paragraph)
marker = source[: len(source) - len(content)]
# Nor is anything inside a raw HTML block such as <pre>.
stripped, in_raw_html = _strip_raw_html(content, in_raw_html)
opened_block = in_raw_html is not None or (block_open and in_comment)
# Taken before the opener is hidden: it renders nothing, but its indent
# still closes items left of it and a marker on its line still opens
# one. Only those survive; what it hides is not Markdown.
if block_open or stripped != content:
if not hidden:
structural = _hidden_structure(line, marker)
visible = ""
else:
visible = marker + stripped
if visible.strip():
structural = visible
elif not hidden:
structural = _hidden_structure(line)
if stripped and _opens_html_block(stripped, after_paragraph):
in_html_block = True
opened_block = True
visible = ""
# A `##` inside a fenced block is sample markdown, not a real heading.
match = _HEADING_PATTERN.match(visible) if visible else None
# `1.0` over a line of dashes is the same heading written setext style.
setext = (
after_paragraph
and match is None
and paragraph != []
and _SETEXT_UNDERLINE.match(visible) is not None
and (visible.strip()[:1] == "-")
# Never a boundary in a list item: dedented the dashes are a break,
# at the content column the heading is nested.
and not lists.columns
)
if setext:
# The whole paragraph is the heading, already emitted line by line,
# so the caller takes those lines back. Dashes mean level 2.
yield Heading(
level = 2,
title = " ".join(paragraph),
lines = (*paragraph_source, line),
retract = len(paragraph_source),
)
paragraph = []
paragraph_source = []
after_paragraph = False
continue
# A dashed underline is not a list marker, so track lists after setext.
lazy_marker = _lazy_marker(structural, lists, after_paragraph, quoted)
lists = _open_lists(structural, lists, after_paragraph, quoted)
# Taken after the opener closed the items it is dedented out of, so the
# block belongs to the item it is really written inside.
if opened_block:
block_column = lists.columns[-1] if lists.columns else 0
elif open_fence is None and not in_html_block and in_raw_html is None and not in_comment:
block_column = 0
# At an open item's content column a heading is nested, not a boundary.
if lists.columns and _indent_width(visible) >= lists.columns[0]:
match = None
# The line at its own nesting level, past the container's indentation and
# any marker, so `- ## 2.0` reads as a heading.
column = lists.columns[-1] if lists.columns else 0
content = _strip_indent(visible, column)
if (item := _LIST_ITEM.match(content)) is not None:
content = content[item.end() :]
# Indented code is four spaces past the container, not past the margin.
indented_code = not after_paragraph and _indent_width(visible) - column >= 4
# An underline needs a paragraph open in its own container: a quote owns
# its own, and a row left of an open item is lazy text of that item.
underline = (
_SETEXT_UNDERLINE.match(visible) is not None
and after_paragraph
and not quoted
and _indent_width(visible) >= column
)
after_paragraph = (
# Read inside its container, so an empty item or a fence written as its
# content leaves no paragraph. A swallowed marker is text, not an item.
(bool(content.strip()) or lazy_marker)
and match is None
and _HEADING_PATTERN.match(content) is None
and _FENCE_PATTERN.match(content) is None
and not indented_code
and _INTERRUPTS.match(visible) is None
and (after_paragraph or _LINK_DEFINITION.match(visible) is None)
and not underline
)
# A quote owns every line of its paragraph; an empty quote holds none.
flush_left = visible.lstrip(" \t")
quote_line = _BLOCK_QUOTE.match(visible) is not None
in_quote = (
_may_be_lazy(_quote_content(visible))
if quote_line
else in_quote and _continues_paragraph(visible, column)
)
if quote_line:
# A quote line leaves only the quote's own paragraph open, if any.
after_paragraph = in_quote
# Whose paragraph the line below continues: a quote owns its own, so a
# marker outside the quote opens a block rather than continuing it.
quoted = quote_line or in_quote
# The lines a later underline turns into one heading: a paragraph opens
# on plain text and runs until something interrupts it.
continues = (
not _interrupts_paragraph(flush_left)
if paragraph
else _PARAGRAPH_TEXT.match(flush_left) is not None
)
# Only a document-level paragraph can become a setext heading.
in_paragraph = after_paragraph and not in_quote and not lists.columns and continues
# Nothing is accumulated yet, so this line is the paragraph's first.
opens_paragraph = in_paragraph and not paragraph
if in_paragraph:
paragraph = [*paragraph, visible.strip()]
paragraph_source = [*paragraph_source, line]
else:
paragraph = []
paragraph_source = []
if match is None:
yield Text(
line = line,
is_full_changelog = bool(
_FULL_CHANGELOG_LINE.match(visible) and not lists.columns and not quoted
),
opens_paragraph = opens_paragraph,
)
continue
# An empty heading ends the section above and, being neither generated nor
# an upgrade block, starts a kept one.
yield Heading(
level = len(match.group("hashes")),
title = match.group("title") or "",
lines = (line,),
)
def _normalise_title(title: str) -> str:
"""A heading title as its words, for comparison against the known ones."""
title = _CLOSING_SEQUENCE.sub("", title)
title = _TITLE_MARKUP.sub("", title)
# A curly apostrophe is the same word as a straight one.
title = title.replace("", "'")
return " ".join(title.split()).strip(" :.").lower()
def _is_generated(title: str) -> bool:
"""Whether `title` heads a section GitHub or the release workflow wrote."""
return (
title in _GENERATED_TITLES
or title.startswith(_GENERATED_PREFIXES)
or title.endswith(_GENERATED_SUFFIXES)
)
def _is_upgrade(title: str) -> bool:
"""Whether `title` heads install instructions or the build provenance."""
if _PROVENANCE in title or title in _UPGRADE_TITLES:
return True
if not title.startswith(_UPGRADE_PREFIXES):
return False
return any(subject in title for subject in _UPGRADE_SUBJECTS)
def _is_platform(title: str) -> bool:
"""Whether `title` is one of the install block's per-platform headings.
The platforms are separated by a slash as readily as by a comma, spaced or
not, so the separators are read as one thing.
"""
return _PLATFORM_SEPARATOR.sub(", ", title).strip(" ,") in _PLATFORM_TITLES
def get_release_notes(version: str, refresh: bool = False) -> dict[str, Any]:
"""Return the newest release's notes for the update popup.
`version` is echoed back, not used to select a release: the pip popup offers
a PyPI version (`2026.8.7`) and releases are tagged with the Studio version
(`v0.1.60-beta`), so no tag could match it.
`refresh` retries a cached failure, so the UI's retry is not stuck behind
the failure TTL once connectivity returns.
"""
version = version.strip()
if not is_supported_version_query(version):
return _notes_response(version = version, error = "Unsupported version.")
if os.environ.get(DISABLE_ENV_VAR) == "1":
return _notes_response(version = version)
remote = get_latest_release(refresh = refresh)
if remote.release is None:
return _notes_response(version = version, error = remote.error)
return _notes_response(
version = version,
markdown = strip_release_body(remote.release.body),
heading = remote.release.name or remote.release.tag,
tag = remote.release.tag,
html_url = remote.release.html_url,
source = remote.source,
error = remote.error,
)
def get_latest_release(refresh: bool = False) -> ReleaseSource:
"""The newest published release, using a small in-process TTL cache."""
global _remote_cache, _remote_fetching
if refresh:
# Only a cached failure is dropped, never a rate-limit lockout: retrying
# into one spends nothing and only delays the reset.
with _cache_condition:
rate_limited = _rate_limited_until > time.time()
if _remote_cache and _remote_cache.source.release is None and not rate_limited:
_remote_cache = None
# A caller waits only as long as a fetch may take, then answers without notes
# rather than holding a worker behind a stalled upstream.
deadline = time.monotonic() + RELEASES_TIMEOUT_SECONDS + 1
while True:
now = time.monotonic()
with _cache_condition:
if _remote_cache and _remote_cache.expires_at > now:
return _remote_cache.source
if not _remote_fetching:
_remote_fetching = True
break
if now >= deadline:
return ReleaseSource(
release = None,
source = None,
error = "Release notes are still loading.",
)
_cache_condition.wait(timeout = deadline - now)
try:
try:
source, ttl = _fetch_latest_release()
except Exception:
source = ReleaseSource(
release = None,
source = None,
error = "Could not fetch release notes.",
)
ttl = RELEASES_FAILURE_TTL_SECONDS
with _cache_condition:
_remote_cache = _ReleaseCacheEntry(source = source, expires_at = time.monotonic() + ttl)
return source
finally:
# Released here, not on the Exception path: stranding the single-flight
# flag on BaseException makes every later caller wait out the deadline.
with _cache_condition:
_remote_fetching = False
_cache_condition.notify_all()
def _fetch_latest_release() -> tuple[ReleaseSource, float]:
"""Fetch and select the newest release, with the TTL to cache it for."""
global _remote_etag, _remote_last_good, _rate_limited_until
now = time.time()
if _rate_limited_until > now:
return (
ReleaseSource(
release = None,
source = None,
error = "GitHub is rate limiting release note requests.",
),
_rate_limited_until - now,
)
url = os.environ.get(RELEASES_URL_ENV_VAR, "").strip() or RELEASES_API_URL
if not url.startswith(("http://", "https://")):
return (
ReleaseSource(release = None, source = None, error = "Invalid releases URL."),
RELEASES_FAILURE_TTL_SECONDS,
)
headers = {
"User-Agent": "unsloth-studio-update-check",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# Or a compressing proxy hands back bytes we would decode as notes.
"Accept-Encoding": "identity",
}
if _remote_etag:
headers["If-None-Match"] = _remote_etag
request = urllib.request.Request(url, headers = headers)
deadline = time.monotonic() + RELEASES_TIMEOUT_SECONDS
try:
with urllib.request.urlopen(request, timeout = RELEASES_TIMEOUT_SECONDS) as response:
chunks: list[bytes] = []
received = 0
while received <= RELEASES_MAX_BYTES:
remaining = deadline - time.monotonic()
if remaining <= 0:
return (
ReleaseSource(
release = None,
source = None,
error = "Release notes took too long to load.",
),
RELEASES_FAILURE_TTL_SECONDS,
)
# The socket timeout is per operation, so re-cap it each read.
_limit_read(response, remaining)
chunk = response.read1(_RELEASES_CHUNK_BYTES)
if not chunk:
break
chunks.append(chunk)
received += len(chunk)
body = b"".join(chunks)
etag = response.headers.get("ETag")
if len(body) > RELEASES_MAX_BYTES:
return (
ReleaseSource(
release = None,
source = None,
error = "Release notes response was too large.",
),
RELEASES_FAILURE_TTL_SECONDS,
)
payload = json.loads(body.decode("utf-8", errors = "replace"))
except urllib.error.HTTPError as error:
return _http_error_source(error)
except TimeoutError:
return (
ReleaseSource(
release = None,
source = None,
error = "Release notes took too long to load.",
),
RELEASES_FAILURE_TTL_SECONDS,
)
except OSError:
return (
ReleaseSource(
release = None,
source = None,
error = "Could not reach GitHub for release notes.",
),
RELEASES_FAILURE_TTL_SECONDS,
)
except (UnicodeError, json.JSONDecodeError):
return (
ReleaseSource(release = None, source = None, error = "Malformed release data."),
RELEASES_FAILURE_TTL_SECONDS,
)
release = select_release(payload)
if release is None:
return (
ReleaseSource(release = None, source = None, error = "No published release found."),
RELEASES_FAILURE_TTL_SECONDS,
)
source = ReleaseSource(release = release, source = "github")
_remote_etag = etag
_remote_last_good = source
return source, RELEASES_SUCCESS_TTL_SECONDS
def _http_error_source(error: urllib.error.HTTPError) -> tuple[ReleaseSource, float]:
"""The answer and TTL for an HTTP status GitHub refused the request with."""
global _rate_limited_until
if error.code == 304 and _remote_last_good is not None:
# Nothing changed, so the release already held still stands.
return _remote_last_good, RELEASES_SUCCESS_TTL_SECONDS
if error.code in (403, 429):
now = time.time()
# GitHub's order: Retry-After, which is how a secondary limit states its
# wait, then the primary limit's reset, then a plain back-off. Every one
# records a deadline, or Retry requests straight back into the limit.
after = _epoch_header(error.headers.get("Retry-After"))
reset = (
_epoch_header(error.headers.get("X-RateLimit-Reset"))
if error.headers.get("X-RateLimit-Remaining") == "0"
else None
)
if after is not None:
deadline = now + after
elif reset is not None:
deadline = reset
else:
deadline = now + RELEASES_RATE_LIMITED_TTL_SECONDS
# The deadline itself is bounded, not just the first wait on it: the next
# fetch answers from it, so capping only the TTL left a skewed header
# parking the popup for as long as it liked.
_rate_limited_until = min(deadline, now + RELEASES_RATE_LIMIT_MAX_SECONDS)
ttl = max(_rate_limited_until - now, 0.0)
return (
ReleaseSource(
release = None,
source = None,
error = "GitHub is rate limiting release note requests.",
),
ttl,
)
return (
ReleaseSource(release = None, source = None, error = "Could not fetch release notes."),
RELEASES_FAILURE_TTL_SECONDS,
)
def _epoch_header(value: str | None) -> float | None:
try:
return float((value or "").strip())
except ValueError:
return None
def select_release(payload: Any) -> Release | None:
"""The newest published release in a GitHub releases response.
Ordered by publication, never by tag: `v0.1.60-beta` was published after
`v0.1.527-beta`, so sorting the tags numerically picks the wrong one.
"""
if not isinstance(payload, list):
return None
newest: Release | None = None
for entry in payload:
if not isinstance(entry, dict) or entry.get("draft"):
continue
tag = entry.get("tag_name")
published = entry.get("published_at")
if not isinstance(tag, str) or not isinstance(published, str) or not published:
continue
if not _RELEASE_TAG_PATTERN.match(tag):
continue
candidate = Release(
tag = tag,
name = entry.get("name") if isinstance(entry.get("name"), str) else "",
body = entry.get("body") if isinstance(entry.get("body"), str) else "",
html_url = entry.get("html_url") if isinstance(entry.get("html_url"), str) else "",
published_at = published,
)
if newest is None or candidate.published_at > newest.published_at:
newest = candidate
return newest
def _limit_read(response: Any, remaining: float) -> None:
"""Cap the next socket read at the time left in the fetch budget."""
sock = getattr(getattr(response, "fp", None), "raw", None)
sock = getattr(sock, "_sock", None)
if sock is None:
return
try:
sock.settimeout(max(remaining, _RELEASES_MIN_READ_SECONDS))
except OSError:
pass
def _opens_fence(marker: str, rest: str) -> bool:
"""A backtick fence's info string may not contain a backtick."""
return marker[0] != "`" or "`" not in rest
def _next_fence_state(open_fence: str | None, marker: str, rest: str) -> str | None:
"""Track the open fence marker.
A closer must be the same character, at least as long, and carry nothing
after it. So neither a ``` sample nor a ```` line with trailing text ends
a ```` block early, while an opening fence may still have an info string.
Only spaces and tabs count as nothing: other Unicode whitespace is content.
"""
if open_fence is None:
return marker if _opens_fence(marker, rest) else None
closes = marker[0] == open_fence[0] and len(marker) >= len(open_fence)
if closes and not rest.strip(" \t"):
return None
return open_fence
def _code_span_ranges(line: str) -> list[tuple[int, int]]:
"""Code span bounds. A run of backticks closes only on a run of its length."""
# Collected once: rescanning per opener is quadratic on a line of distinct
# unmatched runs, and notes are reparsed on every request.
runs: list[tuple[int, int]] = []
index = 0
while index < len(line):
if line[index] != "`" or _is_escaped(line, index):
index += 1
continue
ticks = _run_length(line, index)
runs.append((index, ticks))
index += ticks
# A run closes only on a later run of its length, so one cursor per length.
by_length: dict[int, list[int]] = {}
for position, (_, ticks) in enumerate(runs):
by_length.setdefault(ticks, []).append(position)
spans: list[tuple[int, int]] = []
cursors: dict[int, int] = {}
current = 0
while current < len(runs):
start, ticks = runs[current]
same = by_length[ticks]
cursor = cursors.get(ticks, 0)
while cursor < len(same) and same[cursor] <= current:
cursor += 1
cursors[ticks] = cursor
if cursor >= len(same):
# Nothing closes this run, so it is literal text.
current += 1
continue
closer = same[cursor]
cursors[ticks] = cursor + 1
spans.append((start, runs[closer][0] + ticks))
current = closer + 1
return spans
def _run_length(line: str, index: int) -> int:
end = index
while end < len(line) and line[end] == "`":
end += 1
return end - index
def _is_escaped(line: str, index: int) -> bool:
slashes = 0
while index - 1 - slashes >= 0 and line[index - 1 - slashes] == "\\":
slashes += 1
return slashes % 2 == 1
def _strip_comments(line: str, in_comment: bool, block_open: bool) -> tuple[str, bool]:
"""Return the line with HTML-comment spans removed, and the trailing state.
Only a comment that starts a line opens a block. One written mid-sentence is
inline HTML and hides the rest of its own line at most, so a note mentioning
`<!--` cannot swallow later releases, and a delimiter in inline code hides
nothing. "Starts a line" is read inside the container, so the caller decides
`block_open` from the item's content.
"""
if in_comment:
close = line.find(_COMMENT_CLOSE)
# The closing line belongs to the block, tail included.
return ("", False) if close != -1 else ("", True)
if block_open:
# `<!-->` and `<!--->` are complete comments, so the closer may overlap
# the opener; searching past it would swallow every later release.
return ("", _COMMENT_CLOSE not in line)
visible: list[str] = []
index = 0
spans = _code_span_ranges(line)
# Spans are ordered and disjoint and openers only move forward, so the search
# resumes rather than restarts: restarting per opener is quadratic, and a long
# line of code spans is reparsed on every request.
cursor = 0
while index < len(line):
opening = line.find(_COMMENT_OPEN, index)
if opening == -1:
visible.append(line[index:])
break
while cursor < len(spans) and spans[cursor][1] <= opening:
cursor += 1
if cursor < len(spans) and spans[cursor][0] <= opening:
visible.append(line[index : spans[cursor][1]])
index = spans[cursor][1]
continue
visible.append(line[index:opening])
close = line.find(_COMMENT_CLOSE, opening + len(_COMMENT_OPEN))
if close == -1:
# Unterminated inline comment: it hides this line and no more.
break
index = close + len(_COMMENT_CLOSE)
return "".join(visible), False
def _hidden_structure(line: str, marker: str = "") -> str:
"""`line` as list tracking sees it once the renderer hides its text.
The block renders nothing but still sits at its own column, so it closes the
items to its left. Only the indentation survives: its contents are not
Markdown. `marker` is the part of the line opening the item it is content
of, which survives with it."""
if marker:
return marker + _HIDDEN_BLOCK
if not line.strip():
return ""
return line[: len(line) - len(line.lstrip(" \t"))] + _HIDDEN_BLOCK
def _indent_width(line: str) -> int:
"""Columns of leading whitespace, counting a tab to the next stop of four."""
width = 0
for char in line:
if char == " ":
width += 1
elif char == "\t":
width += 4 - width % 4
else:
break
return width
def _strip_indent(line: str, columns: int) -> str:
"""`line` with up to `columns` columns of leading whitespace removed."""
width = 0
index = 0
while index < len(line) and width < columns and line[index] in " \t":
width += 1 if line[index] == " " else 4 - width % 4
index += 1
return line[index:]
def _interrupts_paragraph(line: str) -> bool:
"""Whether `line` starts a block that can break into an open paragraph.
A quote marker always can. A list item only when it has content, and an
ordered one only when it starts at 1; anything else is paragraph text."""
if _BLOCK_QUOTE.match(line):
return True
item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
if item is None:
return False
marker = item.group("marker")
if not line[item.end() :].strip():
return False
return marker[-1] not in ".)" or marker[:-1] == "1"
def _item_content(line: str, after_paragraph: bool) -> str:
"""`line` read from the content column of a list item that opens on it.
A block written as an item's first content sits inside that item, so
``- ```` opens a fence. The padding is capped as `_open_lists` caps it, or
``- ```` would read as a fence rather than the indented code it is. A
swallowed marker opens no item, so its line is returned whole, as is one
four columns past its container. Ported to the frontend as `itemContent`
in markdown-list-columns.ts."""
if _indent_width(line) >= 4 or (after_paragraph and not _interrupts_paragraph(line)):
return line
item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
if item is None:
return line
padding = _indent_width(item.group("space"))
# Over-indented content starts one column past the marker.
over = padding - 1 if padding > _MAX_ITEM_PADDING else 0
return " " * over + line[item.end() :]
def _quote_content(line: str) -> str:
"""What a blockquote line holds, with its markers stripped."""
while (marker := _QUOTE_MARKER.match(line)) is not None:
line = line[marker.end() :]
return line
def _may_be_lazy(line: str) -> bool:
"""Whether `line` can continue a paragraph it is indented out of.
Only plain text can: a heading, fence, break or HTML block closes the item
instead. An underline may never be lazy, so `===` left of an open item is
more of the item's paragraph, and nor may a definition interrupt one. Three
dashes still close the item, as `_INTERRUPTS` reads them as a break."""
return (
_PARAGRAPH_TEXT.match(line) is not None
and _INTERRUPTS.match(line) is None
and _FENCE_PATTERN.match(line) is None
# Types 1 to 6 interrupt a paragraph; type 7 cannot, so it is excluded.
and not _opens_html_block(line, True)
)
def _continues_paragraph(line: str, column: int) -> bool:
"""Whether `line` reads as more of a paragraph open in its container.
Measured from `column`, where the container's content starts: four columns
past it the line is indented code, which may not interrupt a paragraph, so
indentation alone never closes the one above it."""
inner = _strip_indent(line, column)
return _indent_width(inner) >= 4 or _may_be_lazy(inner)
def _close_dedented(
columns: tuple[int, ...], line: str, indent: int, after_paragraph: bool
) -> tuple[int, ...]:
"""`columns` with every item `line` is written to the left of closed.
Read inside the container, not from the margin: lazy text of the item's own
paragraph leaves the item open rather than closing it."""
while columns and indent < columns[-1]:
outer = columns[-2] if len(columns) > 1 else 0
if after_paragraph and _continues_paragraph(line, outer):
break
columns = columns[:-1]
return columns
def _lazy_marker(line: str, state: _ListState, after_paragraph: bool, quoted: bool) -> bool:
"""Whether a marker-shaped `line` is really text of the paragraph above it.
Only a marker inside the paragraph's own item interrupts it; one to the left
closes that item. A quote owns its paragraph, so a marker written outside
the quote opens a list of its own."""
item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
columns = state.columns
return (
item is not None
and after_paragraph
and not quoted
and (not columns or _indent_width(line) >= columns[-1])
and not _interrupts_paragraph(line)
)
def _open_lists(
line: str,
state: _ListState,
after_paragraph: bool,
quoted: bool = False,
) -> _ListState:
"""The list items still open after `line`.
A dedented line closes an item unless it is a lazy paragraph continuation.
A new marker nests deeper and replaces a sibling. `quoted` marks a paragraph
the quote above owns, so a marker outside the quote opens a list of its own.
"""
columns = state.columns
if not line.strip():
# An item may begin with one blank line; later content is outside it.
return _ListState(columns[:-1] if state.empty_item else columns)
indent = _indent_width(line)
item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
empty = item is not None and not line[item.end() :].strip()
if _lazy_marker(line, state, after_paragraph, quoted):
# A lazy continuation or an underline, so the open items are untouched.
return state
columns = _close_dedented(columns, line, indent, after_paragraph)
# Four columns past its container the marker is code, or lazy text, so it
# opens no list of its own.
if item is None or indent - (columns[-1] if columns else 0) >= 4:
return _ListState(columns)
marker = item.group("marker")
padding = _indent_width(item.group("space"))
if padding == 0 or padding > _MAX_ITEM_PADDING:
# An empty or over-indented item still holds one column of content.
padding = 1
while columns and columns[-1] > indent:
columns = columns[:-1]
return _ListState((*columns, indent + len(marker) + padding), empty_item = empty)
def _opens_html_block(line: str, after_paragraph: bool) -> bool:
"""True if `line` starts a CommonMark type 6 or type 7 HTML block."""
match = _HTML_BLOCK_OPEN.match(line)
if match is not None and match.group(1).lower() in _HTML_BLOCK_TAGS:
return True
return not after_paragraph and _HTML_TAG_ONLY_LINE.match(line) is not None
def _strip_raw_html(line: str, open_block: int | None) -> tuple[str, int | None]:
"""Drop the parts of a line inside a raw block, and return the open block.
The state is the index of the open block in `_RAW_BLOCKS`, or None."""
if open_block is not None:
close = _RAW_BLOCKS[open_block][1].search(line)
return ("", None) if close else ("", open_block)
# A block only opens at the start of a line; mid-line tags are inline HTML.
for index, (opener, closer) in enumerate(_RAW_BLOCKS):
opening = opener.match(line)
if opening is None:
continue
rest = line[opening.end() :]
close = closer.search(rest)
return ("", None) if close else ("", index)
return line, None
def _parse_version(version: str) -> Version | None:
try:
return Version(version)
except InvalidVersion:
return None
def _close_open_fence(markdown: str) -> str:
"""Close a fence the truncation cut in half, so the rest still renders."""
open_fence: str | None = None
for line in _markdown_lines(markdown):
fence = _FENCE_PATTERN.match(line)
if fence:
open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest"))
return f"{markdown}\n{open_fence}" if open_fence else markdown
def _renders_visibly(markdown: str) -> bool:
"""Whether what is left of a release body renders anything at all."""
in_comment = False
for line in _markdown_lines(markdown):
opens_raw = any(opener.match(line) for opener, _ in _RAW_BLOCKS)
if not in_comment and (_FENCE_PATTERN.match(line) or opens_raw):
# A code block or raw HTML block renders even when it is empty.
return True
# No containers are tracked, so the opener is read at the margin. It does
# not matter: an item renders its marker whatever the block inside hides.
visible, in_comment = _strip_comments(
line, in_comment, _COMMENT_BLOCK_OPEN.match(line) is not None
)
if visible.strip():
return True
return False
def _notes_response(
*,
version: str,
markdown: str | None = None,
heading: str | None = None,
tag: str | None = None,
html_url: str | None = None,
source: str | None = None,
error: str | None = None,
) -> dict[str, Any]:
# A body that renders as nothing is an unannounced release, not empty notes.
if markdown and not _renders_visibly(markdown):
markdown = None
truncated = False
if markdown and len(markdown) > RELEASE_NOTES_MAX_CHARS:
markdown = _close_open_fence(markdown[:RELEASE_NOTES_MAX_CHARS].rstrip())
truncated = True
return {
# Echoed, so the UI can drop an answer to a version it has moved on from.
"version": version,
"markdown": markdown or None,
"heading": heading,
"tag": tag,
"html_url": html_url or None,
# False means the release published no notes; the UI links out.
"matched": bool(markdown),
"truncated": truncated,
"source": source if markdown else None,
"release_notes_url": RELEASE_NOTES_URL,
"error": error,
}