# app/services/changelog_loader.py from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Any, List, Optional, Dict import yaml from flask import Blueprint, current_app, render_template @dataclass class ChangeItem: title: str details: List[str] @dataclass class VersionLog: version: str features: List[ChangeItem] refactors: List[ChangeItem] fixes: List[ChangeItem] notes: List[str] @dataclass class Changelog: unreleased: Dict[str, List[ChangeItem]] versions: List[VersionLog] def _coerce_items(items: Optional[List[Dict[str, Any]]]) -> List[ChangeItem]: out: List[ChangeItem] = [] for it in items or []: title = str(it.get("title", "")).strip() details = [str(d) for d in (it.get("details") or [])] out.append(ChangeItem(title=title, details=details)) return out def load_changelog(path: Path) -> Changelog: """ Load changelog.yaml and coerce into dataclasses. """ data = yaml.safe_load(path.read_text(encoding="utf-8")) unreleased = { "features": _coerce_items(data.get("unreleased", {}).get("features")), "refactors": _coerce_items(data.get("unreleased", {}).get("refactors")), "fixes": _coerce_items(data.get("unreleased", {}).get("fixes")), } versions: List[VersionLog] = [] for v in data.get("versions", []): versions.append( VersionLog( version=str(v.get("version")), features=_coerce_items(v.get("features")), refactors=_coerce_items(v.get("refactors")), fixes=_coerce_items(v.get("fixes")), notes=[str(n) for n in (v.get("notes") or [])], ) ) return Changelog(unreleased=unreleased, versions=versions) bp = Blueprint("changelog", __name__) @bp.route("/changelog") def view_changelog(): # Configurable path with sensible default at project root cfg_path = current_app.config.get("CHANGELOG_FILE") path = Path(cfg_path) if cfg_path else (Path(current_app.root_path).parent / "changelog.yaml") changelog = load_changelog(path) return render_template("changelog.html", changelog=changelog)