Skip to content

Commit 4b39493

Browse files
authored
Support class-based components and callable signature detection (#56)
This PR represents a big set of changes to support class-based components, as described in #53. When merged, this resolves #53 . The `README.md` is updated to reflect these changes, all tests are updated, and there are very many new tests. For a basic description of these changes from a user's perspective, see [my comments to #53 here](#53 (comment)). There is also a new `CallableInfo` facility: a frozen dataclass that holds on to _just_ the information we need when inspecting a callable (using `inspect.signature()` now) to understand how to invoke it. We do this (rather than holding on to `inspect.Signature` instances directly) for cachability; `get_callable_info(c: Callable)` sits behind an `@lru_cache()`.
1 parent 28ba27c commit 4b39493

File tree

8 files changed

+650
-101
lines changed

8 files changed

+650
-101
lines changed

README.md

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -305,29 +305,30 @@ content and attributes. Use these like custom HTML elements in your templates.
305305
The basic form of all component functions is:
306306

307307
```python
308-
from typing import Any
308+
from typing import Any, Iterable
309+
from tdom import Node, html
309310

310-
def MyComponent(*children: Node, **attrs: Any) -> Template:
311-
# Build your template using the provided props
312-
return t"<div {attrs}>{children}</div>"
311+
def MyComponent(children: Iterable[Node], **attrs: Any) -> Node:
312+
return html(t"<div {attrs}>Cool: {children}</div>")
313313
```
314314

315315
To _invoke_ your component within an HTML template, use the special
316316
`<{ComponentName} ... />` syntax:
317317

318318
```python
319319
result = html(t"<{MyComponent} id='comp1'>Hello, Component!</{MyComponent}>")
320-
# <div id="comp1">Hello, Component!</div>
320+
# <div id="comp1">Cool: Hello, Component!</div>
321321
```
322322

323323
Because attributes are passed as keyword arguments, you can explicitly provide
324324
type hints for better editor support:
325325

326326
```python
327327
from typing import Any
328+
from tdom import Node, html
328329

329-
def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Template:
330-
return t'<a href="{href}" {attrs}>{text}: {data_value}</a>'
330+
def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Node:
331+
return html(t'<a href="{href}" {attrs}>{text}: {data_value}</a>')
331332

332333
result = html(t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />')
333334
# <a href="https://example.com" target="_blank">Example: 42</a>
@@ -336,26 +337,27 @@ result = html(t'<{Link} href="https://example.com" text="Example" data-value={42
336337
Note that attributes with hyphens (like `data-value`) are converted to
337338
underscores (`data_value`) in the function signature.
338339

339-
In addition to returning `Template`, component functions may also return any
340-
`Node` type found in [`tdom.nodes`](https://github.com/t-strings/tdom/blob/main/tdom/nodes.py):
340+
Component functions build children and can return _any_ type of value; the returned value will be treated exactly as if it were placed directly in a child position in the template.
341+
342+
Among other things, this means you can return a `Template` directly from a component function:
341343

342344
```python
343-
def Link(*, href: str, text: str) -> Node:
344-
return html(t'<a href="{href}">{text}</a>')
345+
def Greeting(name: str) -> Template:
346+
return t"<h1>Hello, {name}!</h1>"
345347

346-
result = html(t'<{Link} href="https://example.com" text="Example" />')
347-
# <a href="https://example.com">Example</a>
348+
result = html(t"<{Greeting} name='Alice' />")
349+
# <h1>Hello, Alice!</h1>
348350
```
349351

350-
You may also return an `Iterable[Node | Template]` if you want to return multiple
351-
elements; this is treated as implicitly wrapping the children in a `Fragment`:
352+
You may also return an iterable:
352353

353354
```python
355+
from typing import Iterable
356+
354357
def Items() -> Iterable[Template]:
355-
for item in ["first", "second"]:
356-
yield t'<li>{item}</li>'
358+
return [t"<li>first</li>", t"<li>second</li>"]
357359

358-
result = html(t'<ul><{Items} /></ul>')
360+
result = html(t"<ul><{Items} /></ul>")
359361
# <ul><li>first</li><li>second</li></ul>
360362
```
361363

@@ -369,6 +371,48 @@ result = html(t'<ul><{Items} /></ul>')
369371
# <ul><li>first</li><li>second</li></ul>
370372
```
371373

374+
This is not required, but it can make your intent clearer.
375+
376+
#### Class-based components
377+
378+
Component functions are great for simple use cases, but for more complex components
379+
you may want to use a class-based approach. Remember that the component
380+
invocation syntax (`<{ComponentName} ... />`) works with any callable. That includes
381+
the `__init__` method or `__call__` method of a class.
382+
383+
One particularly useful pattern is to build class-based components with dataclasses:
384+
385+
```python
386+
from dataclasses import dataclass, field
387+
from typing import Any, Iterable
388+
from tdom import Node, html
389+
390+
@dataclass
391+
class Card:
392+
children: Iterable[Node]
393+
title: str
394+
subtitle: str | None = None
395+
396+
def __call__(self) -> Node:
397+
return html(t"""
398+
<div class='card'>
399+
<h2>{self.title}</h2>
400+
{self.subtitle and t'<h3>{self.subtitle}</h3>'}
401+
<div class="content">{self.children}</div>
402+
</div>
403+
""")
404+
405+
result = html(t"<{Card} title='My Card' subtitle='A subtitle'><p>Card content</p></{Card}>")
406+
# <div class='card'>
407+
# <h2>My Card</h2>
408+
# <h3>A subtitle</h3>
409+
# <div class="content"><p>Card content</p></div>
410+
# </div>
411+
```
412+
413+
This approach allows you to encapsulate component logic and state within a class,
414+
making it easier to manage complex components.
415+
372416
#### SVG Support
373417

374418
TODO: say more about SVG support

docs/usage/components.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ a `Node`:
2929

3030
<!-- invisible-code-block: python
3131
from string.templatelib import Template
32-
from tdom import html, ComponentCallable, Node
33-
from typing import Iterable
32+
from tdom import html, Node
33+
from typing import Callable, Iterable
3434
-->
3535

3636
```python
@@ -61,7 +61,7 @@ If your template has children inside the component element, your component will
6161
receive them as `*children` positional arguments:
6262

6363
```python
64-
def Heading(*children: Node, title: str) -> Node:
64+
def Heading(children: Iterable[Node], title: str) -> Node:
6565
return html(t"<h1>{title}</h1><div>{children}</div>")
6666

6767
result = html(t'<{Heading} title="My Title">Child</{Heading}>')
@@ -97,7 +97,7 @@ driving:
9797
def DefaultHeading() -> Template:
9898
return t"<h1>Default Heading</h1>"
9999

100-
def Body(heading: str) -> Template:
100+
def Body(heading: Callable) -> Template:
101101
return t"<body><{heading} /></body>"
102102

103103
result = html(t"<{Body} heading={DefaultHeading} />")
@@ -116,7 +116,7 @@ def DefaultHeading() -> Template:
116116
def OtherHeading() -> Template:
117117
return t"<h1>Other Heading</h1>"
118118

119-
def Body(heading: ComponentCallable) -> Template:
119+
def Body(heading: Callable) -> Template:
120120
return html(t"<body><{heading} /></body>")
121121

122122
result = html(t"<{Body} heading={OtherHeading}></{Body}>")
@@ -135,7 +135,7 @@ def DefaultHeading() -> Template:
135135
def OtherHeading() -> Template:
136136
return t"<h1>Other Heading</h1>"
137137

138-
def Body(heading: ComponentCallable | None = None) -> Template:
138+
def Body(heading: Callable | None = None) -> Template:
139139
return t"<body><{heading if heading else DefaultHeading} /></body>"
140140

141141
result = html(t"<{Body} heading={OtherHeading}></{Body}>")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "uv_build"
44

55
[project]
66
name = "tdom"
7-
version = "0.1.6"
7+
version = "0.1.7"
88
description = "A 🤘 rockin' t-string HTML templating system for Python 3.14."
99
readme = "README.md"
1010
requires-python = ">=3.14"

tdom/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
from markupsafe import Markup, escape
22

33
from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
4-
from .processor import ComponentCallable, html
4+
from .processor import html
55

66
# We consider `Markup` and `escape` to be part of this module's public API
77

88
__all__ = [
99
"Comment",
10-
"ComponentCallable",
1110
"DocumentType",
1211
"Element",
1312
"escape",

tdom/callables.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import sys
2+
import typing as t
3+
from dataclasses import dataclass
4+
from functools import lru_cache
5+
6+
7+
@dataclass(slots=True, frozen=True)
8+
class CallableInfo:
9+
"""Information about a callable necessary for `tdom` to safely invoke it."""
10+
11+
id: int
12+
"""The unique identifier of the callable (from id())."""
13+
14+
named_params: frozenset[str]
15+
"""The names of the callable's named arguments."""
16+
17+
required_named_params: frozenset[str]
18+
"""The names of the callable's required named arguments."""
19+
20+
requires_positional: bool
21+
"""Whether the callable requires positional-only argument values."""
22+
23+
kwargs: bool
24+
"""Whether the callable accepts **kwargs."""
25+
26+
@classmethod
27+
def from_callable(cls, c: t.Callable) -> t.Self:
28+
"""Create a CallableInfo from a callable."""
29+
import inspect
30+
31+
sig = inspect.signature(c)
32+
named_params = []
33+
required_named_params = []
34+
requires_positional = False
35+
kwargs = False
36+
37+
for param in sig.parameters.values():
38+
match param.kind:
39+
case inspect.Parameter.POSITIONAL_ONLY:
40+
if param.default is param.empty:
41+
requires_positional = True
42+
case inspect.Parameter.POSITIONAL_OR_KEYWORD:
43+
named_params.append(param.name)
44+
if param.default is param.empty:
45+
required_named_params.append(param.name)
46+
case inspect.Parameter.VAR_POSITIONAL:
47+
# Does this necessarily mean it requires positional args?
48+
# Answer: No, but we have no way of knowing how many
49+
# positional args it *might* expect, so we have to assume
50+
# that it does.
51+
requires_positional = True
52+
case inspect.Parameter.KEYWORD_ONLY:
53+
named_params.append(param.name)
54+
if param.default is param.empty:
55+
required_named_params.append(param.name)
56+
case inspect.Parameter.VAR_KEYWORD:
57+
kwargs = True
58+
59+
return cls(
60+
id=id(c),
61+
named_params=frozenset(named_params),
62+
required_named_params=frozenset(required_named_params),
63+
requires_positional=requires_positional,
64+
kwargs=kwargs,
65+
)
66+
67+
@property
68+
def supports_zero_args(self) -> bool:
69+
"""Whether the callable can be called with zero arguments."""
70+
return not self.requires_positional and not self.required_named_params
71+
72+
73+
@lru_cache(maxsize=0 if "pytest" in sys.modules else 512)
74+
def get_callable_info(c: t.Callable) -> CallableInfo:
75+
"""Get the CallableInfo for a callable, caching the result."""
76+
return CallableInfo.from_callable(c)

0 commit comments

Comments
 (0)