All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m43s
Add component dependency analyzer (shared/sx/deps.py) that walks component AST bodies to compute transitive dependency sets. sx_page() and sx_response() now send only the components each page needs instead of the entire registry. Changes: - New: shared/sx/deps.py — transitive_deps(), components_needed(), scan_components_from_sx(), compute_all_deps() - shared/sx/types.py — Add deps: set[str] field to Component - shared/sx/jinja_bridge.py — Compute deps on registration, add components_for_page() and css_classes_for_page() - shared/sx/helpers.py — sx_page() uses per-page bundle + hash, sx_response() passes source to components_for_request() for page-scoped component diffing - New: shared/sx/tests/test_deps.py — 15 tests covering AST scanning, transitive deps, circular refs, per-page bundling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
115 lines
3.3 KiB
Python
115 lines
3.3 KiB
Python
"""
|
|
Component dependency analysis.
|
|
|
|
Walks component AST bodies to compute transitive dependency sets.
|
|
A component's deps are all other components (~name references) it
|
|
can potentially render, including through control flow branches.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from .types import Component, Macro, Symbol
|
|
|
|
|
|
def _scan_ast(node: Any) -> set[str]:
|
|
"""Scan an AST node for ~component references.
|
|
|
|
Walks all branches of control flow (if/when/cond/case) to find
|
|
every component that *could* be rendered. Returns a set of
|
|
component names (with ~ prefix).
|
|
"""
|
|
refs: set[str] = set()
|
|
_walk(node, refs)
|
|
return refs
|
|
|
|
|
|
def _walk(node: Any, refs: set[str]) -> None:
|
|
"""Recursively walk an AST node collecting ~name references."""
|
|
if isinstance(node, Symbol):
|
|
if node.name.startswith("~"):
|
|
refs.add(node.name)
|
|
return
|
|
|
|
if isinstance(node, list):
|
|
for item in node:
|
|
_walk(item, refs)
|
|
return
|
|
|
|
if isinstance(node, dict):
|
|
for v in node.values():
|
|
_walk(v, refs)
|
|
return
|
|
|
|
# Literals (str, int, float, bool, None, Keyword) — no refs
|
|
return
|
|
|
|
|
|
def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
|
|
"""Compute transitive component dependencies for *name*.
|
|
|
|
Returns the set of all component names (with ~ prefix) that
|
|
*name* can transitively render, NOT including *name* itself.
|
|
"""
|
|
seen: set[str] = set()
|
|
|
|
def walk(n: str) -> None:
|
|
if n in seen:
|
|
return
|
|
seen.add(n)
|
|
val = env.get(n)
|
|
if isinstance(val, Component):
|
|
for dep in _scan_ast(val.body):
|
|
walk(dep)
|
|
elif isinstance(val, Macro):
|
|
for dep in _scan_ast(val.body):
|
|
walk(dep)
|
|
|
|
key = name if name.startswith("~") else f"~{name}"
|
|
walk(key)
|
|
return seen - {key}
|
|
|
|
|
|
def compute_all_deps(env: dict[str, Any]) -> None:
|
|
"""Compute and cache deps for all Component entries in *env*.
|
|
|
|
Mutates each Component's ``deps`` field in place.
|
|
"""
|
|
for key, val in env.items():
|
|
if isinstance(val, Component):
|
|
val.deps = transitive_deps(key, env)
|
|
|
|
|
|
def scan_components_from_sx(source: str) -> set[str]:
|
|
"""Extract component names referenced in SX source text.
|
|
|
|
Uses regex to find (~name patterns in serialized SX wire format.
|
|
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
|
|
"""
|
|
import re
|
|
return set(re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source))
|
|
|
|
|
|
def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
|
|
"""Compute the full set of component names needed for a page.
|
|
|
|
Scans *page_sx* for direct component references, then computes
|
|
the transitive closure over the component dependency graph.
|
|
Returns names with ~ prefix.
|
|
"""
|
|
# Direct refs from the page source
|
|
direct = {f"~{n}" for n in scan_components_from_sx(page_sx)}
|
|
|
|
# Transitive closure
|
|
all_needed: set[str] = set()
|
|
for name in direct:
|
|
all_needed.add(name)
|
|
val = env.get(name)
|
|
if isinstance(val, Component) and val.deps:
|
|
all_needed.update(val.deps)
|
|
else:
|
|
# deps not cached yet — compute on the fly
|
|
all_needed.update(transitive_deps(name, env))
|
|
|
|
return all_needed
|