mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 15:53:46 +00:00
* 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>
1260 lines
51 KiB
Python
1260 lines
51 KiB
Python
# 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}(?|\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,
|
||
}
|