Added changelog class
This commit is contained in:
332
ptlinux/changelog.py
Normal file
332
ptlinux/changelog.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user