Files
pristontale-eu-linux/ptlinux/changelog.py
2026-08-31 23:11:21 +02:00

333 lines
9.1 KiB
Python

"""Official patch changelog support for the native Linux dashboard."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import json
import math
import os
from pathlib import Path
import re
from typing import Callable, Protocol
from urllib.request import Request, urlopen
PATCHES_ENDPOINT = "https://pristontale.eu/api/changelog/patches"
PATCH_NEWS_URL = "https://pristontale.eu/news#"
MAX_PATCHES = 10
MAX_PATCH_BYTES = 2 * 1024 * 1024
MAX_PATCH_SECTIONS = 32
MAX_PATCH_CHANGES = 200
_MAX_PATCH_CANDIDATES = 100
_SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
class ChangelogError(RuntimeError):
"""Raised when the official patch feed cannot be parsed safely."""
class ChangelogResponse(Protocol):
"""The minimal response interface used by the changelog client."""
def read(self, size: int = -1) -> bytes: ...
def __enter__(self) -> ChangelogResponse: ...
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object | None,
) -> bool | None: ...
ChangelogOpener = Callable[..., ChangelogResponse]
@dataclass(frozen=True)
class PatchChange:
text: str
type_key: str
@property
def tag(self) -> str:
return patch_change_tag(self.type_key)
@dataclass(frozen=True)
class PatchSection:
title: str
items: tuple[PatchChange, ...]
@dataclass(frozen=True)
class PatchNote:
id: int
slug: str
title: str
version: str | None
tags: tuple[str, ...]
summary: str
sections: tuple[PatchSection, ...]
published_at: str | None
@property
def url(self) -> str:
return f"{PATCH_NEWS_URL}{self.slug}"
def patch_change_tag(type_key: str) -> str:
"""Turn API keys such as ``new-feature`` into compact UI tags."""
label = " ".join(type_key.replace("_", "-").split("-"))
return label.upper() if label else "CHANGE"
def fetch_patch_notes(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: ChangelogOpener = urlopen,
) -> tuple[PatchNote, ...]:
"""Fetch the ten latest patches, falling back to the last valid cache."""
request = Request(
PATCHES_ENDPOINT,
headers={"User-Agent": "PT-Linux/0.6 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_PATCH_BYTES + 1)
if len(payload) > MAX_PATCH_BYTES:
raise ChangelogError("official patch response exceeds the 2 MiB limit")
patches = _parse_patch_notes(payload)
_write_patch_cache(cache_path, patches)
return patches
except (OSError, ChangelogError, UnicodeDecodeError, json.JSONDecodeError):
return read_cached_patch_notes(cache_path)
def read_cached_patch_notes(cache_path: Path) -> tuple[PatchNote, ...]:
"""Read the last valid patch feed without performing network I/O."""
try:
value = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return ()
if not isinstance(value, list):
return ()
patches: list[PatchNote] = []
for item in value[:MAX_PATCHES]:
patch = _patch_note_from_cache(item)
if patch is not None:
patches.append(patch)
return tuple(patches)
def _parse_patch_notes(payload: bytes) -> tuple[PatchNote, ...]:
value = json.loads(payload.decode("utf-8-sig"))
if isinstance(value, dict):
value = value.get("data")
if not isinstance(value, list):
raise ChangelogError("official patch response is not a patch list")
parsed: list[tuple[float, int, PatchNote]] = []
for item in value[:_MAX_PATCH_CANDIDATES]:
result = _parse_patch_note(item)
if result is not None:
timestamp, patch = result
parsed.append((timestamp, patch.id, patch))
parsed.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
patches = tuple(entry[2] for entry in parsed[:MAX_PATCHES])
if not patches:
raise ChangelogError("official patch response contains no usable patches")
return patches
def _parse_patch_note(value: object) -> tuple[float, PatchNote] | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
version = _clean_text(value.get("version"), 64, allow_none=True)
if value.get("version") is not None and version is None:
return None
timestamp = _timestamp(value.get("date"))
patch = PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=_parse_sections(value.get("changes")),
published_at=_format_timestamp(timestamp),
)
return (timestamp if timestamp is not None else -1.0, patch)
def _parse_tags(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
tags: list[str] = []
seen: set[str] = set()
for item in value[:16]:
tag = _clean_text(item, 48)
if tag is None or tag.casefold() in seen:
continue
seen.add(tag.casefold())
tags.append(tag)
return tuple(tags)
def _parse_sections(value: object) -> tuple[PatchSection, ...]:
if not isinstance(value, dict):
return ()
sections_value = value.get("sections")
if not isinstance(sections_value, list):
return ()
sections: list[PatchSection] = []
changes_seen = 0
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
title = _clean_text(section_value.get("title"), 100) or "Changes"
items_value = section_value.get("items")
if not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if changes_seen >= MAX_PATCH_CHANGES:
break
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is None:
continue
items.append(
PatchChange(
text=text,
type_key=type_key or _type_key_from_title(title),
)
)
changes_seen += 1
if items:
sections.append(PatchSection(title=title, items=tuple(items)))
if changes_seen >= MAX_PATCH_CHANGES:
break
return tuple(sections)
def _patch_note_from_cache(value: object) -> PatchNote | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
version = _clean_text(value.get("version"), 64, allow_none=True)
published_at = _clean_text(value.get("published_at"), 64, allow_none=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
sections_value = value.get("sections")
sections: list[PatchSection] = []
if isinstance(sections_value, list):
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
section_title = _clean_text(section_value.get("title"), 100)
items_value = section_value.get("items")
if section_title is None or not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is not None and type_key is not None:
items.append(PatchChange(text=text, type_key=type_key))
if items:
sections.append(PatchSection(section_title, tuple(items)))
return PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=tuple(sections),
published_at=published_at,
)
def _clean_text(
value: object,
maximum_length: int,
*,
allow_empty: bool = False,
allow_none: bool = False,
) -> str | None:
if value is None and allow_none:
return None
if not isinstance(value, str):
return None
text = " ".join(value.split())
if len(text) > maximum_length or (not text and not allow_empty):
return None
return text
def _timestamp(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
timestamp = float(value)
return timestamp if math.isfinite(timestamp) else None
def _format_timestamp(value: float | None) -> str | None:
if value is None:
return None
try:
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
except (OverflowError, OSError, ValueError):
return None
def _type_key_from_title(title: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "change"
def _write_patch_cache(path: Path, patches: tuple[PatchNote, ...]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(
json.dumps([asdict(patch) for patch in patches], indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)