Kida can statically analyze compiled templates to extract dependency information, determine output purity, and recommend caching strategies — all without rendering. No other Python template engine provides this capability.
What Analysis Provides
Every compiled template exposes these analysis results:
| Capability | Method | Returns |
|---|---|---|
| Context dependencies | template.required_context() |
Top-level variable names the template accesses |
| Full dependency paths | template.depends_on() |
Dotted paths likepage.title, site.pages |
| Block metadata | template.block_metadata() |
Per-block purity, dependencies, cache scope |
| Full metadata | template.template_metadata() |
Complete analysis including inheritance info |
| Cache check | template.is_cacheable("nav") |
Whether a block can be safely cached |
| Context validation | template.validate_context(ctx) |
Missing variable names before rendering |
Quick Start
from kida import Environment, DictLoader
env = Environment(loader=DictLoader({
"page.html": """
{% extends "base.html" %}
{% block title %}{{ page.title }}{% end %}
{% block nav %}
<nav>{% for item in site.menu %}<a href="{{ item.url }}">{{ item.label }}</a>{% end %}</nav>
{% end %}
{% block content %}{{ page.content }}{% end %}
""",
"base.html": """
<html>
<head><title>{% block title %}{% end %}</title></head>
<body>{% block nav %}{% end %}{% block content %}{% end %}</body>
</html>
""",
}))
template = env.get_template("page.html")
Check What Variables a Template Needs
>>> template.required_context()
frozenset({'page', 'site'})
Validate Context Before Rendering
>>> template.validate_context({"page": page_obj})
['site'] # 'site' is missing
>>> template.validate_context({"page": page_obj, "site": site_obj})
[] # all required variables present
Inspect Block-Level Metadata
>>> meta = template.block_metadata()
>>> nav = meta["nav"]
>>> nav.depends_on
frozenset({'site.menu'})
>>> nav.is_pure
'pure'
>>> nav.cache_scope
'site'
>>> nav.is_cacheable()
True
Determine Caching Strategy
>>> template.is_cacheable("nav") # site-wide cache
True
>>> template.is_cacheable("content") # page-specific, still cacheable
True
>>> template.is_cacheable() # all blocks cacheable?
True
Analysis Concepts
Shape Profiles
Use the opt-in shape profiler when tooling needs deterministic structural facts without asking Kida to decide whether a component should be extracted or flattened:
from kida.analysis import profile_source, profile_template
source_report = profile_source(source, name="components/card.kida")
compiled_report = profile_template(template)
for profile in source_report.profiles:
print(profile.kind, profile.name, profile.span, profile.facts.node_count)
payload = source_report.to_dict() # JSON-compatible kida.shape-profiles v1
Reports contain one whole-template profile followed by source-ordered profiles
for{% def %}, {% region %}, {% block %}, and {% fragment %}owners.
Each immutableShapeFactsrecord keeps node and source volume, depth, branches,
loops, dynamic expressions and density, component calls, slots, literal HTML
attributes, repeated AST shapes, conservative context dependencies, and a
normalized structural fingerprint as separate facts. There is deliberately no
combined score, severity, suggestion, or automatic edit.
Every profile has an exactSourceSpan. profile_template()re-analyzes the
compiled template's retained source, so a fresh compilation and a bytecode-cache
hit describe the same source shape instead of optimization artifacts. This is
an opt-in analysis cost and does not change rendering or default compilation.
The shape-profile surface is programmatic only. It does not add a CLI mode,
directory/multi-root discovery, or a rootkidaexport. Callers can serialize
report.to_dict() with the standard jsonmodule when they need stable
machine-readable facts.
Extraction Candidate Advice
Tooling can opt into conservative component-boundary advice for one source or compiled template:
from kida.analysis import advise_extraction_source, advise_extraction_template
source_report = advise_extraction_source(source, name="messages.kida")
compiled_report = advise_extraction_template(template)
for diagnostic in source_report.diagnostics:
print(diagnostic.code, diagnostic.span, dict(diagnostic.metadata))
K-MOD-102 is an informational, conservative Diagnostic. Kida emits it only
when several independent signals converge: a substantial iterated region or
normalized repeated siblings, owned interaction/accessibility structure, and a
small lexical input boundary. A loop, repeated markup, template size, or branch
count alone is not enough. Existing defs, regions, blocks, and call boundaries
are not re-recommended.
Each finding has an exact primary span, contributing signals, tentative props,
possible slots, context and component dependencies, provide/consume
dependencies, and related locations for repeated siblings. Metadata list values
are deterministic compact JSON strings. The result deliberately has no combined
score and nosafe_edit: component ownership and reuse remain human decisions.
This API is single-source and opt-in. It does not changeDiagnosticOptions,
diagnose_source(), kida check, default compilation, suppression syntax, or
rendering. Directory and multi-root orchestration belong to a later adapter
contract.
Multi-Root Encapsulation Advice
Explicitly owned roots can be analyzed as one deterministic component call graph:
from pathlib import Path
from kida.inspection import TemplateRoot, advise_encapsulation_roots
report = advise_encapsulation_roots(
(
TemplateRoot("app", Path("templates")),
TemplateRoot("adapter", Path("adapter-templates")),
)
)
The report preserves K-MOD-102 extraction candidates and adds K-MOD-103
for exact pass-through components. Flatten advice requires one same-owner
caller, one downstream component, an identical typed/defaulted prop interface,
at most one identically forwarded slot, and no owned markup or behavior. Reuse,
zero local callers, cross-root callers or downstream components, markup,
control flow, accessibility structure, context policy, changed defaults,
renamed/narrowed props, or a different slot surface suppress the finding.
Every flatten finding includes an exact definition span, the only caller and downstream definition as related locations, contributing signals, owner/source facts, and forwarded props/slots. It is informational and conservative, has no safe edit, and explicitly asks the reviewer to preserve documented public, product, test, and adapter boundaries.
This programmatic API is opt-in. It does not add a CLI flag or change default
kida checkbehavior. Root ownership is explicit; Kida does not infer package
or adapter semantics from ambient imports.
Adapter Advice Context
Framework adapters can translate their own route, response, or component facts into immutable context attached to exact profile spans:
from kida.analysis import AdviceContext, profile_source
from kida.inspection import TemplateRoot, advise_encapsulation_roots
profile = profile_source(source, name="app/messages.kida")
response_block = next(item for item in profile.profiles if item.kind == "block")
context = AdviceContext(
response_block.span,
(
("consumer_context", "iterated"),
("preserve_boundary", True),
("response_boundary", True),
("role", "framework-response"),
),
)
report = advise_encapsulation_roots(roots, context=(context,))
The recognized generic facts are consumer_context ("iterated"or
"repeated"), boolean preserve_boundary and response_boundary, descriptive
role, and visibility ("package" or "public"). An iterated response block
stays intact while nested extraction candidates can become visible. Visibility
and preservation facts suppress flatten advice only for an exact definition
span.
Unknown facts and values are ignored; a context containing only unknown facts produces the same report as no adapter context. Kida does not interpret framework modifier names or import framework packages. Facts are sorted, invocation-local, and represented in ordinary diagnostic metadata, so no new schema or shared registry is introduced. See the adapter advice-context contract for the complete matching and compatibility rules.
Evidence-Driven Encapsulation Loop
Humans and coding agents can use the same supported loop without a specialized Kida orchestration layer:
- Run
advise_encapsulation_roots()and consume each finding's code, exact span, confidence, metadata, and related locations. - Choose extraction, pass-through inlining, or intentional preservation from that evidence; Kida does not make the ownership decision.
- Run
diagnose_roots(..., options=DiagnosticOptions(validate_calls=True))to validate the resulting props and slots. - Render the same context before and after, including named response blocks.
- Re-run advice and record remaining findings, false positives, false negatives, context effects, and analysis cost.
The runnable
examples/encapsulation_loop
commits five before/after cases and deterministic calibration JSON: a growing
route, a healthy large layout, a pass-through micro-component, an
adapter-preserved response boundary, and multiple explicit roots. The replay
proves render and call-validation parity while leaving ambiguous edits to the
consumer. Optional--measureoutput stays outside the stable snapshot.
uv run python examples/encapsulation_loop/app.py
uv run python examples/encapsulation_loop/app.py --measure --rounds 5
Dependencies
The dependency walker extracts every context variable path a template accesses.
Results are conservative: they may include paths not taken at runtime (e.g.,
both branches of an{% if %}) but never miss a path that is used.
>>> template.depends_on()
frozenset({'page.title', 'page.content', 'site.menu'})
required_context()extracts just the top-level names:
>>> template.required_context()
frozenset({'page', 'site'})
Purity
A block is pure if its output is deterministic given the same inputs.
Pure blocks can be cached safely. Impure blocks (usingrandom, shuffle,
or similar) must be re-rendered each time.
| Purity | Meaning | Cacheable? |
|---|---|---|
"pure" |
Deterministic output | Yes |
"impure" |
Uses non-deterministic functions | No |
"unknown" |
Cannot determine statically | Treat as impure |
Cache Scope
Cache scope tells you how broadly a block's output can be reused:
| Scope | Meaning | Example |
|---|---|---|
"site" |
Same output for every page | Navigation, footer |
"page" |
Varies per page but stable for a given page | Content, title |
"none" |
Cannot cache (impure block) | Random quotes |
"unknown" |
Cannot determine | Mixed dependencies |
Landmarks
Kida detects HTML5 landmark elements (<nav>, <main>, <header>, <footer>,
<aside>) in block output and uses them to infer the block's role:
>>> meta["nav"].emits_landmarks
frozenset({'nav'})
>>> meta["nav"].inferred_role
'navigation'
Context Validation
validate_context()is designed for build systems, SSR frameworks, and testing
pipelines that need to catch missing variables before rendering:
template = env.get_template("email.html")
missing = template.validate_context(user_context)
if missing:
raise ValueError(f"Missing template variables: {missing}")
result = template.render(**user_context)
This runs dependency analysis (cached after first call) and compares required top-level variable names against the provided context keys plus environment globals. It returns a sorted list of missing names, or an empty list if everything is present.
Dotted Context Contracts
Frameworks that know more than top-level keys can compare a route or handler contract against Kida's dotted dependency paths:
from kida.analysis import check_context_contract
issues = check_context_contract(
template,
provided={"page.title", "page.author.name"},
globals={"csrf_token"},
optional={"flash.message"},
)
for issue in issues:
print(issue.code, issue.path, issue.message)
provided, globals, and optionalcan be dotted-path iterables or nested
mappings. This checker is deliberately route-agnostic: frameworks supply the
contract shape, and Kida reports only whether template dependency paths are
covered. Enablecheck_extra=Truewhen a narrow contract should also warn about
provided paths the template does not read.
K-CTX-001 and K-CTX-002 are registered in the public ErrorCodeenum;
ContextContractIssue.coderemains the same string-valued field for backward
compatibility.
Literal Attribute Extraction
Framework adapters and CI reports can inspect literal HTML attributes without parsing Kida's AST themselves:
from kida.analysis import extract_literal_attributes
attrs = extract_literal_attributes(
template,
prefixes=("data-", "hx-"),
)
for attr in attrs:
print(attr.tag, attr.name, attr.value, attr.lineno)
This reports only attributes visible in static template text. Dynamic attributes
such as{{ attrs }}, xmlattr, or helper-generated markup are intentionally
not inferred. Kida provides the generic facts; frameworks decide whether a
literaldata-*, hx-*, id, or other attribute has route-specific meaning.
Escape Audit
Useaudit_escaping()to inventory where a template outputs escaped values,
where autoescape is disabled, and where filters intentionally produce trusted
markup:
from kida.analysis import audit_escaping
for finding in audit_escaping(template):
print(finding.code, finding.kind, finding.expression, finding.message)
Findings are static diagnostics only. They do not change rendering and do not
prove user input was sanitized.| safefindings include the optional
reason=text when present; missing reasons get a suggestion so code review and
CI reports can point at explicit trust boundaries. CodesK-ESC-001through
K-ESC-005 are registered in the public ErrorCodeenum without changing the
existing string-valuedEscapeAuditFinding.codefield.
Privacy Lint
Uselint_privacy()to catch likely private data exposure before templates or
report fixtures are published:
from kida.analysis import lint_privacy
for finding in lint_privacy(template):
print(finding.code, finding.kind, finding.path, finding.message)
The first version is intentionally narrow. It reports sensitive-looking context
paths, secret-like string literals without echoing their values,| safeon
sensitive-looking values, broad debug context output, and dynamic template names
that a framework policy cannot statically allowlist. CodesK-PRI-001through
K-PRI-005 are registered in the public ErrorCodeenum; findings retain their
existing string-valuedcodefield.
Call-Site Validation
Kida can validate{% def %}call sites at compile time, catching parameter errors
before any template is rendered:
from kida import Environment
env = Environment(validate_calls=True)
template = env.from_string("""
{% def button(text: str, url: str, style="primary") %}
<a href="{{ url }}" class="btn btn-{{ style }}">{{ text }}</a>
{% end %}
{{ button(text="Save", urll="/save") }}
""")
# UserWarning: Call to 'button' at <string>:6 — unknown params: urll; missing required: url
What It Checks
| Issue | Example |
|---|---|
| Unknown params | Callingbutton(labl="X") when param is label |
| Missing required | Callingbutton() when texthas no default |
*args / **kwargsrelaxation |
Definitions with*args or **kwargssuppress unknown-param warnings |
Programmatic API
For build systems and CI pipelines, use theBlockAnalyzerdirectly:
from kida import Environment, DictLoader
from kida.analysis import BlockAnalyzer
env = Environment(
loader=DictLoader({"page.html": src}),
preserve_ast=True,
)
template = env.get_template("page.html")
analyzer = BlockAnalyzer()
issues = analyzer.validate_calls(template._optimized_ast)
for issue in issues:
if not issue.is_valid:
print(f"{issue.def_name} at line {issue.lineno}: "
f"unknown={issue.unknown_params}, "
f"missing={issue.missing_required}")
CallValidation
| Field | Type | Description |
|---|---|---|
def_name |
str |
Name of the called{% def %} |
lineno |
int |
Line number of the call site |
col_offset |
int |
Column offset of the call site |
unknown_params |
tuple[str, ...] |
Keyword args not in the definition |
missing_required |
tuple[str, ...] |
Required params not provided |
duplicate_params |
tuple[str, ...] |
Params passed more than once; parser-produced ASTs reject these before validation |
| Property | Description |
|---|---|
is_valid |
Trueif no issues were found |
Configuration
UseAnalysisConfigto customize analysis behavior for your framework:
from kida import AnalysisConfig
from kida.analysis import BlockAnalyzer
config = AnalysisConfig(
# Variables indicating page-specific scope
page_prefixes=frozenset({"post.", "post", "article.", "article"}),
# Variables indicating site-wide scope
site_prefixes=frozenset({"settings.", "settings", "global."}),
# Additional functions your framework guarantees are pure
extra_pure_functions=frozenset({"asset_url", "t", "current_lang"}),
# Filters that produce non-deterministic output
extra_impure_filters=frozenset({"random_choice"}),
)
analyzer = BlockAnalyzer(config=config)
Kida ships with a default config (DEFAULT_CONFIG) that includes common SSG
pure functions likeasset_url, t, canonical_url, etc.
Historical Case Study: Bengal Static Site Generator
Bengal uses Kida's analysis API to implement smart incremental builds:
- 1
Compile all templates
Enable AST preservation when compiling.
- 2
Analyze each template
Get block metadata for each compiled template.
- 3
Identify site-cacheable blocks
Find nav, footer, sidebar using
cache_scope == "site". - 4
Cache site-scoped blocks
Cache once per build, reuse across all pages.
- 5
Re-render page-scoped blocks only
Only when page content changes.
- 6
Track dependencies
Invalidate caches when upstream data changes.
This reduces full-site rebuild time by 40-60% for sites with shared navigation and footer blocks.
Case Study: Chirp Web Framework
Chirp uses Kida's introspection and block APIs for dynamic web apps:
- 1
Composition planning
Use
template_metadata()to discover blocks and inheritance before rendering. - 2
Block validation
Call
validate_block_exists(env, template, block)beforerender_block()to avoid KeyError. - 3
Fragment rendering
Use
render_block()for HTMX partial responses and Turbo Stream updates. - 4
Layout assembly
Use
render_with_blocks()to inject pre-rendered content into layout templates. - 5
Adapter pattern
KidaAdapterimplements Chirp'sTemplateAdapterinterface, wrapping all Kida APIs.
See Framework Integration for the full adapter pattern and API usage.
API Reference
Template Methods
| Method | Signature | Description |
|---|---|---|
required_context() |
() -> frozenset[str] |
Top-level variable names needed |
depends_on() |
() -> frozenset[str] |
All dotted dependency paths |
validate_context() |
(context: dict) -> list[str] |
Missing variable names |
check_context_contract() |
(template, provided, ...) -> list[ContextContractIssue] |
Dotted context contract diagnostics |
extract_literal_attributes() |
(template_or_ast, names=..., prefixes=...) -> list[LiteralAttribute] |
Literal HTML attributes with source locations |
audit_escaping() |
(template_or_ast, include_output_sites=True) -> list[EscapeAuditFinding] |
Static escape and trusted-markup findings |
lint_privacy() |
(template_or_ast) -> list[PrivacyFinding] |
Sensitive path, secret literal, and broad-output findings |
block_metadata() |
() -> dict[str, BlockMetadata] |
Per-block analysis results |
template_metadata() |
() -> TemplateMetadata | None |
Full template analysis |
is_cacheable() |
(block_name: str | None) -> bool |
Cache safety check |
list_blocks() |
() -> list[str] |
Block names in template |
BlockMetadata
| Field | Type | Description |
|---|---|---|
name |
str |
Block identifier |
modifiers |
tuple[BlockModifierMetadata, ...] |
Ordered literal framework metadata with source locations |
depends_on |
frozenset[str] |
Context paths accessed |
is_pure |
"pure" | "impure" | "unknown" |
Determinism classification |
cache_scope |
"site" | "page" | "none" | "unknown" |
Recommended cache level |
emits_html |
bool |
Whether block produces output |
emits_landmarks |
frozenset[str] |
HTML5 landmarks detected |
inferred_role |
str |
Heuristic role classification |
is_region |
bool |
True if block is a{% region %} |
region_params |
tuple[str, ...] |
Parameter names (regions only) |
BlockMetadata.get_modifier(name)returns the matching immutable
BlockModifierMetadata or None. Each modifier exposes name, its typed
scalarvalue, and the one-based lineno / zero-based col_offsetwhere its
name begins.
TemplateMetadata
| Field | Type | Description |
|---|---|---|
name |
str | None |
Template identifier |
extends |
str | None |
Parent template name |
blocks |
dict[str, BlockMetadata] |
All block metadata |
top_level_depends_on |
frozenset[str] |
Dependencies outside blocks |
| Method | Description |
|---|---|
all_dependencies() |
Union of all block and top-level dependencies |
get_block(name) |
Get metadata for a specific block |
regions() |
Return only region-typed blocks (for OOB discovery) |
cacheable_blocks() |
List of blocks whereis_cacheable()is True |
site_cacheable_blocks() |
List of blocks withcache_scope == "site" |
CallValidation
| Field | Type | Description |
|---|---|---|
def_name |
str |
Name of the called{% def %} |
lineno |
int |
Line number of the call site |
col_offset |
int |
Column offset of the call site |
unknown_params |
tuple[str, ...] |
Keyword args not in the definition |
missing_required |
tuple[str, ...] |
Required params not provided |
duplicate_params |
tuple[str, ...] |
Params passed more than once |
| Property | Type | Description |
|---|---|---|
is_valid |
bool |
Trueif no issues were found |
AnalysisConfig
| Field | Type | Default | Description |
|---|---|---|---|
page_prefixes |
frozenset[str] |
{"page.", "page", ...} |
Page-scope variable prefixes |
site_prefixes |
frozenset[str] |
{"site.", "site", ...} |
Site-scope variable prefixes |
extra_pure_functions |
frozenset[str] |
frozenset() |
Additional pure function names |
extra_impure_filters |
frozenset[str] |
frozenset() |
Additional impure filter names |
Integration with Frameworks
Frameworks like Chirp use Kida templates and run contract validation (chirp check) to verify that hx-post, hx-get, and action URLs in templates match registered routes. Dynamic URLs built with ~ or {{ var }}are correctly skipped — only literal URLs are validated.