diff --git a/bengal/assets/manifest.py b/bengal/assets/manifest.py
index c99579556..fcfc93a65 100644
--- a/bengal/assets/manifest.py
+++ b/bengal/assets/manifest.py
@@ -81,7 +81,7 @@ def _isoformat(timestamp: float | None) -> str | None:
return datetime.fromtimestamp(timestamp, tz=UTC).isoformat().replace("+00:00", "Z")
-@dataclass(slots=True)
+@dataclass(frozen=True, slots=True)
class AssetManifestEntry:
"""
Manifest entry for a single logical asset.
diff --git a/bengal/build/provenance/filter.py b/bengal/build/provenance/filter.py
index da15207d4..ccf1413f3 100644
--- a/bengal/build/provenance/filter.py
+++ b/bengal/build/provenance/filter.py
@@ -43,7 +43,7 @@
from bengal.protocols.core import PageLike
-@dataclass
+@dataclass(frozen=True, slots=True)
class ProvenanceFilterResult:
"""Result of provenance-based filtering."""
diff --git a/bengal/build/provenance/types.py b/bengal/build/provenance/types.py
index 448cc2c38..2361ee984 100644
--- a/bengal/build/provenance/types.py
+++ b/bengal/build/provenance/types.py
@@ -122,7 +122,7 @@ def summary(self) -> str:
return f"Provenance({', '.join(parts)}) → {self.combined_hash}"
-@dataclass
+@dataclass(frozen=True, slots=True)
class ProvenanceRecord:
"""
Stored provenance record with metadata.
diff --git a/bengal/cache/build_cache/autodoc_content_cache.py b/bengal/cache/build_cache/autodoc_content_cache.py
index 66bc2d0cb..1c68e3f42 100644
--- a/bengal/cache/build_cache/autodoc_content_cache.py
+++ b/bengal/cache/build_cache/autodoc_content_cache.py
@@ -32,7 +32,7 @@
logger = get_logger(__name__)
-@dataclass
+@dataclass(frozen=True, slots=True)
class CachedModuleInfo:
"""Cached parsed module data.
diff --git a/bengal/cache/build_cache/fingerprint.py b/bengal/cache/build_cache/fingerprint.py
index 38493592e..7da243101 100644
--- a/bengal/cache/build_cache/fingerprint.py
+++ b/bengal/cache/build_cache/fingerprint.py
@@ -33,7 +33,7 @@
logger = get_logger(__name__)
-@dataclass
+@dataclass(frozen=True, slots=True)
class FileFingerprint:
"""
Fast file change detection using mtime + size, with optional hash verification.
diff --git a/bengal/cache/utils/stats.py b/bengal/cache/utils/stats.py
index 75ae7472f..ad6297b8e 100644
--- a/bengal/cache/utils/stats.py
+++ b/bengal/cache/utils/stats.py
@@ -19,12 +19,24 @@
from __future__ import annotations
import json
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
if TYPE_CHECKING:
from collections.abc import Callable
+class TaxonomyStatsDict(TypedDict):
+ """Statistics for taxonomy-type caches."""
+
+ total_tags: int
+ valid_tags: int
+ invalid_tags: int
+ total_unique_pages: int
+ total_page_tag_pairs: int
+ avg_tags_per_page: float
+ cache_size_bytes: NotRequired[int]
+
+
def compute_validity_stats[V](
entries: dict[str, V],
is_valid: Callable[[V], bool],
@@ -106,7 +118,7 @@ def compute_taxonomy_stats(
is_valid: Callable[[Any], bool],
get_page_paths: Callable[[Any], list[str]],
serialize: Callable[[Any], dict[str, Any]] | None = None,
-) -> dict[str, Any]:
+) -> TaxonomyStatsDict:
"""
Compute statistics for taxonomy-type caches.
diff --git a/bengal/cli/dashboard/widgets/phase_plan.py b/bengal/cli/dashboard/widgets/phase_plan.py
index ae757b795..a1c8842ce 100644
--- a/bengal/cli/dashboard/widgets/phase_plan.py
+++ b/bengal/cli/dashboard/widgets/phase_plan.py
@@ -29,7 +29,7 @@
from textual.app import ComposeResult
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class BuildPhase:
"""
Represents a build phase with status.
diff --git a/bengal/config/build_options_resolver.py b/bengal/config/build_options_resolver.py
index d8b6dface..96957fa13 100644
--- a/bengal/config/build_options_resolver.py
+++ b/bengal/config/build_options_resolver.py
@@ -41,7 +41,7 @@
from bengal.orchestration.build.options import BuildOptions
-@dataclass
+@dataclass(frozen=True, slots=True)
class CLIFlags:
"""
Flags explicitly passed via CLI (None = not passed).
diff --git a/bengal/content/sources/notion.py b/bengal/content/sources/notion.py
index 6088c0bf9..51399e03f 100644
--- a/bengal/content/sources/notion.py
+++ b/bengal/content/sources/notion.py
@@ -390,77 +390,78 @@ def _blocks_to_markdown(self, blocks: list[dict[str, Any]]) -> str:
block_type = block.get("type")
block_data = block.get(str(block_type) if block_type else "", {})
- if block_type == "paragraph":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(text)
-
- elif block_type == "heading_1":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"# {text}")
-
- elif block_type == "heading_2":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"## {text}")
-
- elif block_type == "heading_3":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"### {text}")
-
- elif block_type == "bulleted_list_item":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"- {text}")
-
- elif block_type == "numbered_list_item":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"1. {text}")
-
- elif block_type == "to_do":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- checked = "x" if block_data.get("checked") else " "
- lines.append(f"- [{checked}] {text}")
-
- elif block_type == "toggle":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"{text}
")
- # Note: nested blocks not handled in this simple implementation
- lines.append(" ")
-
- elif block_type == "code":
- code = self._rich_text_to_md(block_data.get("rich_text", []))
- lang = block_data.get("language", "")
- lines.append(f"```{lang}")
- lines.append(code)
- lines.append("```")
-
- elif block_type == "quote":
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"> {text}")
-
- elif block_type == "callout":
- icon = block_data.get("icon", {}).get("emoji", "💡")
- text = self._rich_text_to_md(block_data.get("rich_text", []))
- lines.append(f"> {icon} {text}")
-
- elif block_type == "divider":
- lines.append("---")
-
- elif block_type == "image":
- image_data = block_data
- url = image_data.get("external", {}).get("url") or image_data.get("file", {}).get(
- "url"
- )
- caption = self._rich_text_to_md(image_data.get("caption", []))
- if url:
- lines.append(f"")
+ match block_type:
+ case "paragraph":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(text)
+
+ case "heading_1":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"# {text}")
+
+ case "heading_2":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"## {text}")
+
+ case "heading_3":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"### {text}")
+
+ case "bulleted_list_item":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"- {text}")
+
+ case "numbered_list_item":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"1. {text}")
+
+ case "to_do":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ checked = "x" if block_data.get("checked") else " "
+ lines.append(f"- [{checked}] {text}")
+
+ case "toggle":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"{text}
")
+ # Note: nested blocks not handled in this simple implementation
+ lines.append(" ")
+
+ case "code":
+ code = self._rich_text_to_md(block_data.get("rich_text", []))
+ lang = block_data.get("language", "")
+ lines.append(f"```{lang}")
+ lines.append(code)
+ lines.append("```")
+
+ case "quote":
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"> {text}")
+
+ case "callout":
+ icon = block_data.get("icon", {}).get("emoji", "💡")
+ text = self._rich_text_to_md(block_data.get("rich_text", []))
+ lines.append(f"> {icon} {text}")
+
+ case "divider":
+ lines.append("---")
+
+ case "image":
+ image_data = block_data
+ url = image_data.get("external", {}).get("url") or image_data.get(
+ "file", {}
+ ).get("url")
+ caption = self._rich_text_to_md(image_data.get("caption", []))
+ if url:
+ lines.append(f"")
- elif block_type == "bookmark":
- url = block_data.get("url", "")
- caption = self._rich_text_to_md(block_data.get("caption", []))
- lines.append(f"[{caption or url}]({url})")
+ case "bookmark":
+ url = block_data.get("url", "")
+ caption = self._rich_text_to_md(block_data.get("caption", []))
+ lines.append(f"[{caption or url}]({url})")
- elif block_type == "equation":
- expression = block_data.get("expression", "")
- lines.append(f"$$\n{expression}\n$$")
+ case "equation":
+ expression = block_data.get("expression", "")
+ lines.append(f"$$\n{expression}\n$$")
# Add blank line between blocks
lines.append("")
@@ -524,56 +525,57 @@ def _extract_properties(self, page: dict[str, Any]) -> dict[str, Any]:
prop = properties[notion_prop]
prop_type = prop.get("type")
- if prop_type == "title":
- frontmatter[fm_key] = self._rich_text_to_md(prop.get("title", []))
+ match prop_type:
+ case "title":
+ frontmatter[fm_key] = self._rich_text_to_md(prop.get("title", []))
- elif prop_type == "rich_text":
- frontmatter[fm_key] = self._rich_text_to_md(prop.get("rich_text", []))
+ case "rich_text":
+ frontmatter[fm_key] = self._rich_text_to_md(prop.get("rich_text", []))
- elif prop_type == "date":
- date_obj = prop.get("date")
- if date_obj:
- frontmatter[fm_key] = date_obj.get("start")
+ case "date":
+ date_obj = prop.get("date")
+ if date_obj:
+ frontmatter[fm_key] = date_obj.get("start")
- elif prop_type == "multi_select":
- frontmatter[fm_key] = [opt["name"] for opt in prop.get("multi_select", [])]
+ case "multi_select":
+ frontmatter[fm_key] = [opt["name"] for opt in prop.get("multi_select", [])]
- elif prop_type == "select":
- select_obj = prop.get("select")
- if select_obj:
- frontmatter[fm_key] = select_obj.get("name")
+ case "select":
+ select_obj = prop.get("select")
+ if select_obj:
+ frontmatter[fm_key] = select_obj.get("name")
- elif prop_type == "checkbox":
- frontmatter[fm_key] = prop.get("checkbox", False)
+ case "checkbox":
+ frontmatter[fm_key] = prop.get("checkbox", False)
- elif prop_type == "number":
- frontmatter[fm_key] = prop.get("number")
+ case "number":
+ frontmatter[fm_key] = prop.get("number")
- elif prop_type == "url":
- frontmatter[fm_key] = prop.get("url")
+ case "url":
+ frontmatter[fm_key] = prop.get("url")
- elif prop_type == "email":
- frontmatter[fm_key] = prop.get("email")
+ case "email":
+ frontmatter[fm_key] = prop.get("email")
- elif prop_type == "phone_number":
- frontmatter[fm_key] = prop.get("phone_number")
+ case "phone_number":
+ frontmatter[fm_key] = prop.get("phone_number")
- elif prop_type == "people":
- people = prop.get("people", [])
- frontmatter[fm_key] = [p.get("name", p.get("id")) for p in people]
+ case "people":
+ people = prop.get("people", [])
+ frontmatter[fm_key] = [p.get("name", p.get("id")) for p in people]
- elif prop_type == "files":
- files = prop.get("files", [])
- urls = []
- for f in files:
- url = f.get("external", {}).get("url") or f.get("file", {}).get("url")
- if url:
- urls.append(url)
- frontmatter[fm_key] = urls
+ case "files":
+ files = prop.get("files", [])
+ urls = []
+ for f in files:
+ url = f.get("external", {}).get("url") or f.get("file", {}).get("url")
+ if url:
+ urls.append(url)
+ frontmatter[fm_key] = urls
- elif prop_type == "status":
- status_obj = prop.get("status")
- if status_obj:
- frontmatter[fm_key] = status_obj.get("name")
+ case "status":
+ status_obj = prop.get("status")
+ if status_obj:
+ frontmatter[fm_key] = status_obj.get("name")
return frontmatter
diff --git a/bengal/content_types/templates.py b/bengal/content_types/templates.py
index 0891f2995..d94423d19 100644
--- a/bengal/content_types/templates.py
+++ b/bengal/content_types/templates.py
@@ -23,7 +23,7 @@
logger = get_logger(__name__)
#: Page type literals for template resolution
-PageType = Literal["home", "list", "single"]
+type PageType = Literal["home", "list", "single"]
def classify_page(page: PageLike) -> PageType:
diff --git a/bengal/core/author.py b/bengal/core/author.py
index acba36a7c..8eab29b2f 100644
--- a/bengal/core/author.py
+++ b/bengal/core/author.py
@@ -48,7 +48,18 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any
+from typing import Any, TypedDict
+
+
+class AuthorDict(TypedDict):
+ """Serialized form of Author for template context."""
+
+ name: str
+ email: str
+ bio: str
+ avatar: str
+ url: str
+ social: dict[str, str]
@dataclass(frozen=True, slots=True)
@@ -129,7 +140,7 @@ def mastodon(self) -> str:
"""Shortcut for Mastodon handle."""
return self.social.get("mastodon", "")
- def to_dict(self) -> dict[str, Any]:
+ def to_dict(self) -> AuthorDict:
"""
Convert to dictionary for template context or serialization.
diff --git a/bengal/core/version.py b/bengal/core/version.py
index ac0e13b2e..378f476cd 100644
--- a/bengal/core/version.py
+++ b/bengal/core/version.py
@@ -62,7 +62,7 @@
import threading
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any
+from typing import Any, TypedDict
@dataclass
@@ -158,6 +158,18 @@ class GitVersionConfig:
parallel_builds: int = 4
+class VersionDict(TypedDict):
+ """Serialized form of Version for template context."""
+
+ id: str
+ label: str
+ latest: bool
+ deprecated: bool
+ url_prefix: str
+ release_date: str | None
+ end_of_life: str | None
+
+
@dataclass
class Version:
"""
@@ -219,7 +231,7 @@ def url_prefix(self) -> str:
return ""
return f"/{self.id}"
- def to_dict(self) -> dict[str, Any]:
+ def to_dict(self) -> VersionDict:
"""
Convert to dictionary for template context.
diff --git a/bengal/effects/tracer.py b/bengal/effects/tracer.py
index af6e0bd73..d5ec65fca 100644
--- a/bengal/effects/tracer.py
+++ b/bengal/effects/tracer.py
@@ -20,10 +20,18 @@
from collections import defaultdict
from contextlib import suppress
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, TypedDict
from bengal.effects.effect import Effect
+
+class EffectTracerDict(TypedDict):
+ """Serialized form of EffectTracer state."""
+
+ effects: list[dict[str, Any]]
+ fingerprints: dict[str, str]
+
+
if TYPE_CHECKING:
from bengal.snapshots.types import SiteSnapshot
@@ -305,7 +313,7 @@ def get_changed_files(self, root_path: Path) -> set[Path]:
# --- Persistence ---
- def to_dict(self) -> dict[str, Any]:
+ def to_dict(self) -> EffectTracerDict:
"""Serialize tracer state to dict."""
with self._lock:
return {
diff --git a/bengal/errors/context.py b/bengal/errors/context.py
index 54dd31896..7cf5292d4 100644
--- a/bengal/errors/context.py
+++ b/bengal/errors/context.py
@@ -148,7 +148,7 @@ def should_aggregate(self) -> bool:
return self in (ErrorSeverity.ERROR, ErrorSeverity.WARNING)
-@dataclass
+@dataclass(frozen=True, slots=True)
class RelatedFile:
"""
A file related to an error for debugging context.
@@ -171,7 +171,7 @@ def __str__(self) -> str:
return f"{self.role}: {path_str}"
-@dataclass
+@dataclass(frozen=True, slots=True)
class ErrorDebugPayload:
"""
Machine-parseable debug context for AI troubleshooting.
diff --git a/bengal/errors/dev_server.py b/bengal/errors/dev_server.py
index 473d55a8c..26a063af0 100644
--- a/bengal/errors/dev_server.py
+++ b/bengal/errors/dev_server.py
@@ -77,7 +77,7 @@
)
-@dataclass
+@dataclass(frozen=True, slots=True)
class FileChange:
"""
Record of a file change that may have caused an error.
diff --git a/bengal/errors/exceptions.py b/bengal/errors/exceptions.py
index 4e36b99f9..945e1f918 100644
--- a/bengal/errors/exceptions.py
+++ b/bengal/errors/exceptions.py
@@ -68,7 +68,7 @@
from __future__ import annotations
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
if TYPE_CHECKING:
from bengal.errors.codes import ErrorCode
@@ -80,6 +80,21 @@
from bengal.protocols.build import BuildPhase
+class ErrorDict(TypedDict):
+ """Serialized form of BengalError for JSON output."""
+
+ type: str
+ message: str
+ code: str | None
+ file_path: str | None
+ line_number: int | None
+ suggestion: str | None
+ build_phase: str | None
+ severity: str | None
+ related_files: list[str]
+ debug_payload: NotRequired[dict[str, Any]]
+
+
class BengalError(Exception):
"""
Base exception for all Bengal errors.
@@ -196,7 +211,7 @@ def _format_message(self) -> str:
return "\n".join(parts)
- def to_dict(self) -> dict[str, Any]:
+ def to_dict(self) -> ErrorDict:
"""
Convert to dictionary for JSON serialization.
diff --git a/bengal/errors/handlers.py b/bengal/errors/handlers.py
index 687a7d305..462e36536 100644
--- a/bengal/errors/handlers.py
+++ b/bengal/errors/handlers.py
@@ -64,7 +64,7 @@
logger = get_logger(__name__)
-@dataclass
+@dataclass(frozen=True, slots=True)
class ContextAwareHelp:
"""
Container for context-aware error help information.
diff --git a/bengal/errors/session.py b/bengal/errors/session.py
index 9f15c93a6..789e492ba 100644
--- a/bengal/errors/session.py
+++ b/bengal/errors/session.py
@@ -81,7 +81,7 @@
from bengal.errors.codes import ErrorCode
-@dataclass
+@dataclass(frozen=True, slots=True)
class ErrorOccurrence:
"""
Record of a single error occurrence.
@@ -104,7 +104,7 @@ class ErrorOccurrence:
build_phase: str | None = None
-@dataclass
+@dataclass(frozen=True, slots=True)
class ErrorPattern:
"""
Aggregated pattern for similar errors.
diff --git a/bengal/errors/suggestions.py b/bengal/errors/suggestions.py
index d93acee10..91bd31416 100644
--- a/bengal/errors/suggestions.py
+++ b/bengal/errors/suggestions.py
@@ -83,7 +83,7 @@
from typing import Any
-@dataclass
+@dataclass(frozen=True, slots=True)
class ActionableSuggestion:
"""
A structured, actionable suggestion for fixing an error.
diff --git a/bengal/errors/traceback/config.py b/bengal/errors/traceback/config.py
index 10eb732e1..3f98f90b7 100644
--- a/bengal/errors/traceback/config.py
+++ b/bengal/errors/traceback/config.py
@@ -90,7 +90,7 @@ class TracebackStyle(Enum):
from bengal.errors.traceback.renderer import TracebackRenderer
-@dataclass
+@dataclass(frozen=True, slots=True)
class TracebackConfig:
"""
Configuration for traceback display and Rich installation.
diff --git a/bengal/errors/traceback/renderer.py b/bengal/errors/traceback/renderer.py
index 13f233943..6e01d4f5c 100644
--- a/bengal/errors/traceback/renderer.py
+++ b/bengal/errors/traceback/renderer.py
@@ -64,7 +64,7 @@
logger = get_logger(__name__)
-@dataclass
+@dataclass(frozen=True, slots=True)
class TracebackRenderer:
"""
Base class for traceback renderers.
diff --git a/bengal/health/link_registry.py b/bengal/health/link_registry.py
index 8ccf51a28..06a56cf2c 100644
--- a/bengal/health/link_registry.py
+++ b/bengal/health/link_registry.py
@@ -30,7 +30,7 @@
from bengal.protocols import SiteLike
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class LinkRegistry:
"""
Immutable registry of all valid link targets in a site.
diff --git a/bengal/health/types.py b/bengal/health/types.py
index 79527db84..90aeeb2bc 100644
--- a/bengal/health/types.py
+++ b/bengal/health/types.py
@@ -27,7 +27,7 @@
# =============================================================================
# Status literal type matching CheckStatus enum
-CheckStatusLiteral = Literal["success", "info", "suggestion", "warning", "error"]
+type CheckStatusLiteral = Literal["success", "info", "suggestion", "warning", "error"]
class CheckResultDict(TypedDict, total=False):
diff --git a/bengal/orchestration/build/inputs.py b/bengal/orchestration/build/inputs.py
index d5cc4eca9..d2ea6ea92 100644
--- a/bengal/orchestration/build/inputs.py
+++ b/bengal/orchestration/build/inputs.py
@@ -22,7 +22,7 @@
from bengal.server.build_executor import BuildRequest
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class BuildInput:
"""
Complete, serializable record of all inputs to a build.
diff --git a/bengal/orchestration/render/output_collector_diagnostics.py b/bengal/orchestration/render/output_collector_diagnostics.py
index e5b5ec38c..1ef8dff34 100644
--- a/bengal/orchestration/render/output_collector_diagnostics.py
+++ b/bengal/orchestration/render/output_collector_diagnostics.py
@@ -48,7 +48,7 @@ class OutputCollectorSource(Enum):
}
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class OutputCollectorDiagnostic:
"""Structured diagnostic for output_collector propagation failure."""
diff --git a/bengal/output/icons.py b/bengal/output/icons.py
index b4d836fc4..796685220 100644
--- a/bengal/output/icons.py
+++ b/bengal/output/icons.py
@@ -25,7 +25,7 @@
from dataclasses import dataclass
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class IconSet:
"""
Icon set for CLI output.
diff --git a/bengal/parsing/backends/patitas/directives/builtins/misc.py b/bengal/parsing/backends/patitas/directives/builtins/misc.py
index 5dbf61956..d61e0be8f 100644
--- a/bengal/parsing/backends/patitas/directives/builtins/misc.py
+++ b/bengal/parsing/backends/patitas/directives/builtins/misc.py
@@ -52,7 +52,7 @@ class SiteContext(Protocol):
current_language: str | None
-SiteContextGetter = Callable[[], tuple[SiteContext | None, Any | None]]
+type SiteContextGetter = Callable[[], tuple[SiteContext | None, Any | None]]
# =============================================================================
diff --git a/bengal/parsing/backends/patitas/directives/builtins/navigation.py b/bengal/parsing/backends/patitas/directives/builtins/navigation.py
index c524c6629..d758e5071 100644
--- a/bengal/parsing/backends/patitas/directives/builtins/navigation.py
+++ b/bengal/parsing/backends/patitas/directives/builtins/navigation.py
@@ -62,7 +62,7 @@ class PageContext(Protocol):
# Type alias for page context getter
-PageContextGetter = Callable[[], PageContext | None]
+type PageContextGetter = Callable[[], PageContext | None]
# =============================================================================
diff --git a/bengal/parsing/backends/patitas/renderers/html.py b/bengal/parsing/backends/patitas/renderers/html.py
index cabffab3c..4d55459de 100644
--- a/bengal/parsing/backends/patitas/renderers/html.py
+++ b/bengal/parsing/backends/patitas/renderers/html.py
@@ -343,19 +343,20 @@ def _extract_plain_text(self, children: Sequence[Inline]) -> str:
"""
parts: list[str] = []
for child in children:
- if isinstance(child, Text):
- content = child.content
- # Apply text transformer if present (e.g., variable substitution)
- if self._text_transformer:
- content = self._text_transformer(content)
- parts.append(content)
- elif isinstance(child, CodeSpan):
- parts.append(child.code)
- elif isinstance(child, (Emphasis, Strong, Strikethrough, Link)):
- parts.append(self._extract_plain_text(child.children))
- elif isinstance(child, Math):
- parts.append(child.content)
- # Skip: Image, LineBreak, SoftBreak, HtmlInline, Role, FootnoteRef
+ match child:
+ case Text():
+ content = child.content
+ # Apply text transformer if present (e.g., variable substitution)
+ if self._text_transformer:
+ content = self._text_transformer(content)
+ parts.append(content)
+ case CodeSpan():
+ parts.append(child.code)
+ case Emphasis() | Strong() | Strikethrough() | Link():
+ parts.append(self._extract_plain_text(child.children))
+ case Math():
+ parts.append(child.content)
+ # Skip: Image, LineBreak, SoftBreak, HtmlInline, Role, FootnoteRef
return "".join(parts)
def _get_unique_slug(self, text: str) -> str:
diff --git a/bengal/parsing/backends/patitas/renderers/inline.py b/bengal/parsing/backends/patitas/renderers/inline.py
index d26dfd937..4268e9986 100644
--- a/bengal/parsing/backends/patitas/renderers/inline.py
+++ b/bengal/parsing/backends/patitas/renderers/inline.py
@@ -37,7 +37,7 @@
)
# Type alias for inline render handler
-InlineHandler = Callable[[Any, StringBuilder, Callable], None]
+type InlineHandler = Callable[[Any, StringBuilder, Callable], None]
def render_text(node: Text, sb: StringBuilder, render_children: Callable) -> None:
diff --git a/bengal/rendering/highlighting/theme_resolver.py b/bengal/rendering/highlighting/theme_resolver.py
index a5a767ed3..bf899355e 100644
--- a/bengal/rendering/highlighting/theme_resolver.py
+++ b/bengal/rendering/highlighting/theme_resolver.py
@@ -28,7 +28,7 @@
]
# Type alias for CSS class style
-CssClassStyle = Literal["semantic", "pygments"]
+type CssClassStyle = Literal["semantic", "pygments"]
# Mapping from Bengal site palettes to Rosettes syntax themes.
# When syntax_highlighting.theme is "auto", we look up the site's
diff --git a/bengal/rendering/plugins/cross_references.py b/bengal/rendering/plugins/cross_references.py
index 80a819026..7bf4787e5 100644
--- a/bengal/rendering/plugins/cross_references.py
+++ b/bengal/rendering/plugins/cross_references.py
@@ -36,7 +36,7 @@
logger = get_logger(__name__)
# Type alias for cross-version dependency callback
-CrossVersionTracker = Callable[[Path, str, str], None]
+type CrossVersionTracker = Callable[[Path, str, str], None]
__all__ = ["CrossReferencePlugin"]
diff --git a/bengal/rendering/template_functions/navigation/models.py b/bengal/rendering/template_functions/navigation/models.py
index f31f3e8d2..bac310aa8 100644
--- a/bengal/rendering/template_functions/navigation/models.py
+++ b/bengal/rendering/template_functions/navigation/models.py
@@ -30,7 +30,7 @@
from typing import Any
-@dataclass(slots=True)
+@dataclass(frozen=True, slots=True)
class BreadcrumbItem:
"""
Single breadcrumb in navigation trail.
@@ -68,7 +68,7 @@ def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default)
-@dataclass(slots=True)
+@dataclass(frozen=True, slots=True)
class PaginationItem:
"""
Single page in pagination.
@@ -110,7 +110,7 @@ def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default)
-@dataclass(slots=True)
+@dataclass(frozen=True, slots=True)
class PaginationInfo:
"""
Complete pagination data structure.
@@ -149,7 +149,7 @@ def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default)
-@dataclass(slots=True)
+@dataclass(frozen=True, slots=True)
class TocGroupItem:
"""
Grouped TOC item for collapsible sections.
@@ -196,7 +196,7 @@ def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default)
-@dataclass(slots=True)
+@dataclass(frozen=True, slots=True)
class AutoNavItem:
"""
Auto-discovered navigation item.
diff --git a/bengal/server/asgi_app.py b/bengal/server/asgi_app.py
index f37cb40cf..bcefb823f 100644
--- a/bengal/server/asgi_app.py
+++ b/bengal/server/asgi_app.py
@@ -26,13 +26,13 @@
from pathlib import Path
# ASGI app type: async (scope, receive, send) -> None
-ASGIApp = Callable[..., Any]
+type ASGIApp = Callable[..., Any]
# Type for request callback: (method, path, status, duration_ms) -> None
-RequestCallback = Callable[[str, str, int, float], None]
+type RequestCallback = Callable[[str, str, int, float], None]
# Getter for lazy callback resolution (set after backend creation)
-RequestCallbackGetter = Callable[[], RequestCallback | None]
+type RequestCallbackGetter = Callable[[], RequestCallback | None]
def create_bengal_dev_app(
diff --git a/bengal/server/reload_controller.py b/bengal/server/reload_controller.py
index 391ded31a..b7bc82bd9 100644
--- a/bengal/server/reload_controller.py
+++ b/bengal/server/reload_controller.py
@@ -86,7 +86,7 @@ class HashEntry:
digest: str
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class SnapshotEntry:
"""
Immutable file metadata for snapshot comparison.
diff --git a/bengal/themes/config.py b/bengal/themes/config.py
index 6c266bca4..812f0fd8b 100644
--- a/bengal/themes/config.py
+++ b/bengal/themes/config.py
@@ -49,7 +49,7 @@
logger = get_logger(__name__)
-@dataclass
+@dataclass(frozen=True, slots=True)
class FeatureFlags:
"""
Feature flags organized by category.
@@ -142,7 +142,7 @@ def from_dict(cls, data: dict[str, Any]) -> FeatureFlags:
)
-@dataclass
+@dataclass(frozen=True, slots=True)
class AppearanceConfig:
"""
Appearance configuration for theme mode and color palette.
@@ -191,7 +191,7 @@ def from_dict(cls, data: dict[str, Any]) -> AppearanceConfig:
)
-@dataclass
+@dataclass(frozen=True, slots=True)
class IconConfig:
"""
Icon library configuration with semantic aliases.
@@ -254,7 +254,7 @@ def to_dict(self) -> dict[str, Any]:
}
-@dataclass
+@dataclass(frozen=True, slots=True)
class HeaderConfig:
"""
Header layout and behavior configuration.
@@ -316,7 +316,7 @@ def to_dict(self) -> dict[str, Any]:
}
-@dataclass
+@dataclass(frozen=True, slots=True)
class ThemeConfig:
"""
Complete theme configuration loaded from theme.yaml.
diff --git a/bengal/themes/swizzle.py b/bengal/themes/swizzle.py
index aaacdc62c..70618236f 100644
--- a/bengal/themes/swizzle.py
+++ b/bengal/themes/swizzle.py
@@ -85,7 +85,7 @@ class ModificationStatus(Enum):
CHECKSUM_ERROR = "checksum_error" # Could not compute checksum
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class SwizzleRecord:
"""
Immutable record of a swizzled template's provenance.
diff --git a/bengal/themes/tokens.py b/bengal/themes/tokens.py
index ee49e59dd..7173be11c 100644
--- a/bengal/themes/tokens.py
+++ b/bengal/themes/tokens.py
@@ -98,7 +98,7 @@ def surface(self) -> str: ...
def background(self) -> str: ...
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class BengalPalette:
"""
Bengal color palette with semantic color tokens.
@@ -166,7 +166,7 @@ class BengalPalette:
BENGAL_PALETTE = BengalPalette()
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class BengalMascots:
"""
Bengal brand mascots and status icons for terminal output.
@@ -235,7 +235,7 @@ class BengalMascots:
BENGAL_MASCOT = BengalMascots()
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class PaletteVariant:
"""
Named color palette variant for theming.
diff --git a/bengal/utils/concurrency/workers.py b/bengal/utils/concurrency/workers.py
index ff22a35a8..a69639287 100644
--- a/bengal/utils/concurrency/workers.py
+++ b/bengal/utils/concurrency/workers.py
@@ -77,7 +77,7 @@ class Environment(Enum):
PRODUCTION = "production"
-@dataclass(frozen=True)
+@dataclass(frozen=True, slots=True)
class WorkloadProfile:
"""Tuning profile for a workload type.
diff --git a/changelog.d/py314-pattern-modernization.changed.md b/changelog.d/py314-pattern-modernization.changed.md
new file mode 100644
index 000000000..4372bdd65
--- /dev/null
+++ b/changelog.d/py314-pattern-modernization.changed.md
@@ -0,0 +1 @@
+Adopt Python 3.14+ patterns: freeze 38 dataclasses with `frozen=True, slots=True`, convert 10 type aliases to PEP 695 `type` syntax, replace 3 if/elif dispatch chains with `match/case`, and add 5 TypedDict definitions for `to_dict()` return types.