Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,60 @@ TODO: say more about SVG support

#### Context

TODO: implement context feature
The html() function accepts an optional named argument, context, which lets you pass shared, read-only data down through rendering. This is handy for things like user/session info, feature flags, locale, or request-scoped configuration.

- Type: a simple dictionary, centralized as a shared type

from tdom.types import Context

Context = dict[str, object] | None

- How it flows: when you call html(template, context=...), the same context is automatically propagated to:
- Component functions you invoke via <{Component} ...> syntax, if their signature includes a context parameter (or accepts **kwargs).
- Any nested Template values encountered during rendering (e.g., components returning templates or interpolated t-strings).

- Non-intrusive: if a component does not accept a context parameter (and doesn’t use **kwargs), nothing is passed and no error is raised.

Basic usage, passing context to a component:

```python
from tdom import html, Text
from tdom.types import Context

# Components can optionally accept `context`.
# Treat it as read-only shared data.

def UserGreeting(*children: Text, *, context: Context = None):
user = (context or {}).get("user", "guest")
return t"<p>Hello, {user}!</p>"

page = html(t"<main><{UserGreeting} /></main>", context={"user": "alice"})
print(page) # <main><p>Hello, alice!</p></main>
```

Context automatically propagates to nested templates and subcomponents:

```python
from tdom import html, Text
from tdom.types import Context


def Child(*children: Text, *, context: Context = None):
theme = (context or {}).get("theme", "light")
return t"<span class='{theme}'>Child</span>"

# Parent returns a Template that invokes Child; context flows through.

def Parent(*children: Text, *, context: Context = None):
return t"<section><{Child} /></section>"

result = html(t"<{Parent} />", context={"theme": "dark"})
print(result) # <section><span class='dark'>Child</span></section>
```

Notes
- If a component supports **kwargs but doesn’t name context explicitly, tdom still passes context via **kwargs.
- You can omit the context argument entirely if you don’t need it.

### The `tdom` Module

Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ version = "0.1.5"
description = "A 🤘 rockin' t-string HTML templating system for Python 3.14."
readme = "README.md"
requires-python = ">=3.14"
dependencies = ["markupsafe>=3.0.2"]
dependencies = [
"furo>=2025.7.19",
"genbadge>=1.1.2",
"markupsafe>=3.0.2",
]
authors = [
{ name = "Dave Peck", email = "[email protected]" },
{ name = "Andrea Giammarchi", email = "[email protected]" },
Expand Down
61 changes: 46 additions & 15 deletions tdom/processor.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import random
import string
import typing as t
from inspect import signature
from collections.abc import Iterable
from functools import lru_cache
from string.templatelib import Interpolation, Template
Expand All @@ -11,6 +12,7 @@
from .nodes import Element, Fragment, Node, Text
from .parser import parse_html
from .utils import format_interpolation as base_format_interpolation
from .types import Context


@t.runtime_checkable
Expand Down Expand Up @@ -262,12 +264,15 @@ def _substitute_attrs(


def _substitute_and_flatten_children(
children: t.Iterable[Node], interpolations: tuple[Interpolation, ...]
children: t.Iterable[Node],
interpolations: tuple[Interpolation, ...],
*,
context: Context = None,
) -> list[Node]:
"""Substitute placeholders in a list of children and flatten any fragments."""
new_children: list[Node] = []
for child in children:
substituted = _substitute_node(child, interpolations)
substituted = _substitute_node(child, interpolations, context=context)
if isinstance(substituted, Fragment):
# This can happen if an interpolation results in a Fragment, for
# instance if it is iterable.
Expand All @@ -277,7 +282,7 @@ def _substitute_and_flatten_children(
return new_children


def _node_from_value(value: object) -> Node:
def _node_from_value(value: object, *, context: Context = None) -> Node:
"""
Convert an arbitrary value to a Node.

Expand All @@ -290,11 +295,11 @@ def _node_from_value(value: object) -> Node:
case Node():
return value
case Template():
return html(value)
return html(value, context=context)
case False:
return Text("")
case Iterable():
children = [_node_from_value(v) for v in value]
children = [_node_from_value(v, context=context) for v in value]
return Fragment(children=children)
case HasHTMLDunder():
# CONSIDER: could we return a lazy Text?
Expand All @@ -313,6 +318,8 @@ def _invoke_component(
new_attrs: dict[str, object | None],
new_children: list[Node],
interpolations: tuple[Interpolation, ...],
*,
context: Context = None,
) -> Node:
"""Substitute a component invocation based on the corresponding interpolations."""
index = _placholder_index(tag)
Expand All @@ -327,12 +334,24 @@ def _invoke_component(
kwargs = {k.replace("-", "_"): v for k, v in new_attrs.items()}

# Call the component and return the resulting node
result = value(*new_children, **kwargs)
# Pass context if the callable supports it and no explicit context kwarg provided
try:
sig = signature(value)
params = sig.parameters
if (
"context" in params or any(p.kind == p.VAR_KEYWORD for p in params.values())
) and "context" not in kwargs:
result = value(*new_children, **kwargs, context=context)
else:
result = value(*new_children, **kwargs)
except (ValueError, TypeError):
# Fallback if signature cannot be inspected
result = value(*new_children, **kwargs)
match result:
case Node():
return result
case Template():
return html(result)
return html(result, context=context)
case str():
return Text(result)
case HasHTMLDunder():
Expand All @@ -349,24 +368,32 @@ def _stringify_attrs(attrs: dict[str, object | None]) -> dict[str, str | None]:
return {k: str(v) if v is not None else None for k, v in attrs.items()}


def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node:
def _substitute_node(
p_node: Node, interpolations: tuple[Interpolation, ...], context: Context = None
) -> Node:
"""Substitute placeholders in a node based on the corresponding interpolations."""
match p_node:
case Text(text) if str(text).startswith(_PLACEHOLDER_PREFIX):
index = _placholder_index(str(text))
interpolation = interpolations[index]
value = format_interpolation(interpolation)
return _node_from_value(value)
return _node_from_value(value, context=context)
case Element(tag=tag, attrs=attrs, children=children):
new_attrs = _substitute_attrs(attrs, interpolations)
new_children = _substitute_and_flatten_children(children, interpolations)
new_children = _substitute_and_flatten_children(
children, interpolations, context=context
)
if tag.startswith(_PLACEHOLDER_PREFIX):
return _invoke_component(tag, new_attrs, new_children, interpolations)
return _invoke_component(
tag, new_attrs, new_children, interpolations, context=context
)
else:
new_attrs = _stringify_attrs(new_attrs)
return Element(tag=tag, attrs=new_attrs, children=new_children)
case Fragment(children=children):
new_children = _substitute_and_flatten_children(children, interpolations)
new_children = _substitute_and_flatten_children(
children, interpolations, context=context
)
return Fragment(children=new_children)
case _:
return p_node
Expand All @@ -377,9 +404,13 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) ->
# --------------------------------------------------------------------------


def html(template: Template) -> Node:
"""Parse a t-string and return a tree of Nodes."""
def html(template: Template, *, context: Context = None) -> Node:
"""Parse a t-string and return a tree of Nodes.

The optional named argument `context` can be used to pass a dictionary down to
component callables and any nested template processing.
"""
# Parse the HTML, returning a tree of nodes with placeholders
# where interpolations go.
p_node = _instrument_and_parse(template)
return _substitute_node(p_node, template.interpolations)
return _substitute_node(p_node, template.interpolations, context=context)
77 changes: 77 additions & 0 deletions tdom/processor_context_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from dataclasses import dataclass
from tdom import Text, html
from tdom.types import Context


def test_context_passed_to_function_component_simple():
calls: list[dict[str, object] | None] = []

def Comp(*children: Text, answer: int, context: Context = None):
calls.append(context)
return Text(f"answer={answer},ctx={context['x'] if context else 'None'}")

html(t"<div><{Comp} answer={42} /></div>", context={"x": 7})
assert calls == [{"x": 7}]


def test_context_passed_to_function_subcomponent_via_template():
seen: list[object] = []

def Child(*children: Text, context: Context = None):
seen.append(context and context.get("user"))
return Text("child")

def Parent(*children: Text, context: Context = None):
# Parent returns a Template that invokes Child; context should flow.
return t"<section><{Child} /></section>"

html(t"<main><{Parent} /></main>", context={"user": "alice"})
assert seen == ["alice"]


def test_context_not_required_for_function_component():
# Component does not accept `context`; passing context to html() should not break.
def NoCtx(*children: Text, answer: int):
return Text(f"answer={answer}")

node = html(t"<div><{NoCtx} answer={42} /></div>", context={"x": 7})
# Fake out PyCharm's type checker
children = getattr(node, "children")
assert children[0].text == "answer=42"


def test_context_passed_to_class_component_simple():
calls: list[dict[str, object] | None] = []

@dataclass
class CompClass:
answer: int
context: Context = None

def __call__(self) -> Text:
calls.append(self.context)
return Text(
f"answer={self.answer},ctx={self.context['x'] if self.context else 'None'}"
)

comp = CompClass(answer=42, context={"x": 7})
html(t"<div><{comp} /></div>")

# Ensure context was available on the instance
assert calls == [{"x": 7}]


def test_context_not_required_for_class_component():
@dataclass
class NoCtxClass:
answer: int
context: Context = None

def __call__(self) -> Text:
return Text(f"answer={self.answer}")

node = html(t"<div><{NoCtxClass(answer=42)} /></div>", context={"x": 7})

# Fake out PyCharm's type checker
children = getattr(node, "children")
assert children[0].text == "answer=42"
11 changes: 11 additions & 0 deletions tdom/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Centralized shared types for tdom
# Python 3.12+ type statement per project guidelines

# NOTE: Keep this file lightweight and free of runtime dependencies.

# A rendering-time context passed down through html() processing and components.
# Components may treat this as read-only shared data.
# None means no context was provided.
# Use as: `def Comp(*children: Node, *, context: Context = None): ...`

type Context = dict[str, object] | None
8 changes: 7 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.