` tags.
63 | """
64 |
65 | def htmy(self, _: Context) -> Component:
66 | return html.ol(*(html.li(item) for item in self))
67 |
68 |
69 | class HexBytes(bytes):
70 | """
71 | `bytes` object that renders its individual bytes as hexadecimal strings,
72 | separated by spaces, in a `` tag.
73 | """
74 |
75 | def htmy(self, _: Context) -> Component:
76 | return html.p(*join_components(tuple(f"0x{b:X}" for b in self), " "))
77 | ```
78 |
79 | Now, let's render these components to see how they can be used:
80 |
81 | ```python
82 | async def render() -> None:
83 | renderer = Renderer()
84 | result = await renderer.render(
85 | html.div(
86 | HTMYDatetime,
87 | HTMYDatetime(2025, 2, 25),
88 | ULDict(one="First", two="Second", three="Third"),
89 | Coordinate((1, 6, 1)),
90 | OrderedList([Coordinate((1, 2, 3)), Coordinate((4, 5, 6))]),
91 | HexBytes(b"Hello!"),
92 | ),
93 | # Add an origin coordinate to the context for Coordinate to use.
94 | {"origin": (3, 1, 4)},
95 | )
96 | print(f"Result:\n{result}")
97 |
98 |
99 | asyncio.run(render())
100 | ```
101 |
102 | You can use these patterns to enhance your existing business objects with rendering capabilities, without affecting their original functionality in any way.
103 |
104 | The use of context -- and async support if you're using async tools like FastAPI -- makes these patterns even more powerful. Imagine, that you have a web application in which the client submits an `X-Variant` request header to tell the server how to render the response (typical scenario with HTMX), for example as a list item or a table row. If you add this information to the rendering context, your enhanced business objects can use this information to conditionally fetch more data and render themselves the way the client requested. (This is facilitated out of the box by [FastHX](https://volfpeter.github.io/fasthx/examples/htmy/) for example.)
105 |
106 | Here is the pseudo-code for the above scenario:
107 |
108 | ```python
109 | @dataclass
110 | class User:
111 | name: str
112 | email: str
113 | permissions: list[str] | None = None
114 |
115 | async def htmy(self, context: Context) -> Component:
116 | request_headers = context["request_headers"]
117 | variant = request_headers.get("X-Variant", "list-item")
118 | if variant == "list-item":
119 | return await self._htmy_li(context)
120 | elif variant == "table-row":
121 | return await self._htmy_tr(context)
122 | else:
123 | raise ValueError("Unknown variant")
124 |
125 | async def _htmy_li(self, context: Context) -> Component:
126 | return html.li(...)
127 |
128 | async def _htmy_tr(self, context: Context) -> Component:
129 | # Make sure permissions are loaded, the table row representation needs them.
130 | await self._load_permissions()
131 | return html.tr(...)
132 |
133 | async def _load_permissions(self) -> None:
134 | # Load user permissions and store them in self.permissions.
135 | ...
136 | ```
137 |
138 | Hopefully these examples give you some ideas on how you can efficiently integrate `htmy` into your application and business logic.
139 |
140 | Unleash your creativity, and have fun building your next web application! And of course join our [Discussion Board](https://github.com/volfpeter/htmy/discussions) to share your cool patterns and use-cases with the community.
141 |
142 | ## What is a component factory?
143 |
144 | So far we only talked about components, but often you do not need to create full-fledged `htmy` components, all you need is a function that accepts some arguments and returns a component. Such functions are called component factories.
145 |
146 | ```python
147 | def heading(text: str) -> Component:
148 | """Heading component factory."""
149 | return html.h1(text)
150 |
151 | def paragraph(text: str) -> Component:
152 | """Paragraph component factory."""
153 | return html.p(text)
154 |
155 | def section(title: str, text: str) -> Component:
156 | """
157 | This is not a component, just a factory that is evaluated to a component
158 | immediately when called. The renderer will only need to resolve the inner
159 | `div` and its children.
160 | """
161 | return html.div(
162 | heading(title), # Calling a component factory here.
163 | paragraph(text), # Calling a component factory here as well.
164 | )
165 | ```
166 |
167 | Of course, instance, class, and static methods, even properties or more advanced Python constructs like descriptors can also act as component factories, giving you a lot of flexibility in how you add `htmy` rendering support to your codebase.
168 |
169 | Component factories come with some advantages, mainly simplicity and somewhat better performance. The performance benefit comes from the fact these functions are executed instantly, and the `htmy` renderer only needs to resolve the resulting component tree, which will be smaller than the one that uses components for everything.
170 |
171 | Component factories come with some limitations and downsides though:
172 |
173 | - Often they can not be async, because they are called from sync code.
174 | - They have no access to the rendering context.
175 | - They can not act as context providers.
176 | - They are immediately evaluated, which can be undesirable if they create a large component tree.
177 |
178 | Note that when you create the component tree you want to render, you (almost) always "call" something with some arguments: either a component factory or an actual component class, the latter of which is just the instantiation of the component class (potentially an enhanced business object).
179 |
180 | There is one important detail you must pay attention to: if a component factory returns a component sequence, then it's up to you make sure the returned component sequence is correctly passed to the "parent" component or component factory, because for example `list[list[ComponentType]]` is not a valid component sequence, only `list[ComponentType]` is. List unpacking and the built-in `Fragment` component can help you avoid potential issues.
181 |
182 | It may be unnecessary to say, but you don't need to bother with the above issue if you use components, they can return component sequences and the renderer will deal with them, it's a standard use-case.
183 |
184 | ## When to use components, when to use component factories?
185 |
186 | There is no hard rule, but hopefully the previous sections gave you enough guidance to make an informed decision in every case. In general, if a component factory is enough, then it's often the better choice, but if you feel safer using only components, then that's just as good.
187 |
--------------------------------------------------------------------------------
/docs/examples/markdown.md:
--------------------------------------------------------------------------------
1 | # Markdown rendering
2 |
3 | The focus of this example is markdown rendering and customization. As such, all you need to follow along is `htmy`, which you can install with `pip install htmy`.
4 |
5 | There's one important thing to know about markdown in relation to this tutorial and the markdown support in `htmy`: markdown can include [HTML](https://daringfireball.net/projects/markdown/syntax#html) (well, XML). Looking at this from another perspective, most HTML/XML snippets can be parsed by markdown parsers without issues. This means that while the below examples work with text files with markdown syntax, those file could also contain plain HTML snippets with no "markdown" at all. You will start to see the full power of this concept by the end of this article.
6 |
7 | **Warning:** The `MD` component treats its input as trusted. If any part of the input comes from untrusted sources, ensure it is safely escaped (using for example `htmy.xml_format_string`)! Passing untrusted input to the `MD` component leads to XSS vulnerabilities.
8 |
9 | ## Essentials
10 |
11 | The entire example will consist of two files: `post.md` and `app.py` which should be located next to each other in the same directory.
12 |
13 | First we create a simple markdown file (`post.md`) which only contains standard markdown syntax, including headers, lists, code blocks:
14 |
15 | ````md
16 | # Essential reading
17 |
18 | ```python
19 | import this
20 | ```
21 |
22 | Also available [here](https://peps.python.org/pep-0020/).
23 |
24 | Inline `code` is **also** _fine_.
25 |
26 | # Lists
27 |
28 | ## Ordered
29 |
30 | 1. First
31 | 2. Second
32 | 3. Third
33 |
34 | ## Unordered
35 |
36 | - First
37 | - Second
38 | - Third
39 | ````
40 |
41 | Then we can create the most minimal version of `app.py` that will be responsible for rendering `post.md` as HTML. Keep in mind that `htmy` is an _async_ rendering engine, so we will need `asyncio` (specifically `asyncio.run()`) to run the renderer.
42 |
43 | ```python
44 | import asyncio
45 |
46 | from htmy import Renderer, md
47 |
48 |
49 | async def render_post() -> None:
50 | md_post = md.MD("post.md") # Create an htmy.md.MD component.
51 | rendered = await Renderer().render(md_post) # Render the MD component.
52 | print(rendered) # Print the result.
53 |
54 |
55 | if __name__ == "__main__":
56 | asyncio.run(render_post())
57 | ```
58 |
59 | That's it. You can now run `app.py` from the terminal with `python app.py`, and it will print out the generated HTML snippet. You can save the output to an HTML file, or even better, pipe the output of the script directly to a file with `python app.py > post.html` and just open the resulting HTML file in your browser.
60 |
61 | ## Customization
62 |
63 | In this section we will extend the above example by adding custom rendering rules that apply extra CSS classes to a couple of standard HTML elements. The extra styling will be done by [TailwindCSS](https://tailwindcss.com/), which means we will also need to set up a proper HTML page. If you're not familiar with TailwindCSS, don't worry, it is not required for understanding the `htmy` concepts.
64 |
65 | The `post.md` file can remain the same as above, but `app.py` will change quite a bit.
66 |
67 | First of all we need a few more imports (although some only for typing):
68 |
69 | ```python
70 | from htmy import Component, ComponentType, Context, PropertyValue, Renderer, etree, html, md
71 | ```
72 |
73 | Next we need a `Page` component that defines the base HTML structure of the webpage:
74 |
75 | ```python
76 | class Page:
77 | """Page component that creates the basic HTML layout."""
78 |
79 | def __init__(self, *children: ComponentType) -> None:
80 | """
81 | Arguments:
82 | *children: The page content.
83 | """
84 | self.children = children
85 |
86 | def htmy(self, context: Context) -> Component:
87 | return (
88 | html.DOCTYPE.html,
89 | html.html(
90 | html.head(
91 | # Some metadata
92 | html.title("Markdown example"),
93 | html.Meta.charset(),
94 | html.Meta.viewport(),
95 | # TailwindCSS import
96 | html.script(src="https://cdn.tailwindcss.com"),
97 | ),
98 | html.body(
99 | *self.children,
100 | class_="h-screen w-screen p-8",
101 | ),
102 | ),
103 | )
104 | ```
105 |
106 | We are getting close now, we just need to write our custom conversion rules / `htmy` component factories that will change certain tags that we encounter in the parsed markdown document:
107 |
108 | ```python
109 | class ConversionRules:
110 | """Conversion rules for some of the HTML elements we can encounter in parsed markdown documents."""
111 |
112 | @staticmethod
113 | def h1(*children: ComponentType, **properties: PropertyValue) -> ComponentType:
114 | """Rule for converting `h1` tags that adds some extra CSS classes to the tag."""
115 | properties["class"] = f"text-xl font-bold {properties.get('class', '')}"
116 | return html.h1(*children, **properties)
117 |
118 | @staticmethod
119 | def h2(*children: ComponentType, **properties: PropertyValue) -> ComponentType:
120 | """Rule for converting `h2` tags that adds some extra CSS classes to the tag."""
121 | properties["class"] = f"text-lg font-bold {properties.get('class', '')}"
122 | return html.h2(*children, **properties)
123 |
124 | @staticmethod
125 | def ol(*children: ComponentType, **properties: PropertyValue) -> ComponentType:
126 | """Rule for converting `ol` tags that adds some extra CSS classes to the tag."""
127 | properties["class"] = f"list-decimal list-inside {properties.get('class', '')}"
128 | return html.ol(*children, **properties)
129 |
130 | @staticmethod
131 | def ul(*children: ComponentType, **properties: PropertyValue) -> ComponentType:
132 | """Rule for converting `ul` tags that adds some extra CSS classes to the tag."""
133 | properties["class"] = f"list-disc list-inside {properties.get('class', '')}"
134 | return html.ul(*children, **properties)
135 | ```
136 |
137 | With the conversion rules in place, we can create our component converter by mapping tag names to conversion rules:
138 |
139 | ```python
140 | # Create an element converter and configure it to use the conversion rules
141 | # that are defined above on h1, h2, ol, and ul tags.
142 | md_converter = etree.ETreeConverter(
143 | {
144 | "h1": ConversionRules.h1,
145 | "h2": ConversionRules.h2,
146 | "ol": ConversionRules.ol,
147 | "ul": ConversionRules.ul,
148 | }
149 | )
150 | ```
151 |
152 | Finally we update our `render_post()` function from the previous example to make use of all the tools we implemented above:
153 |
154 | ```python
155 | async def render_post() -> None:
156 | md_post = md.MD( # Create an htmy.md.MD component.
157 | "post.md",
158 | converter=md_converter.convert, # And make it use our element converter's conversion method.
159 | )
160 | page = Page(md_post) # Wrap the post in a Page component.
161 | rendered = await Renderer().render(page) # Render the MD component.
162 | print(rendered) # Print the result.
163 | ```
164 |
165 | If you run the app with `python app.py` now, you will see that the result is a complete HTML page and the `h1`, `h2`, `ol`, and `ul` tags automatically get the custom styles that we add in our `ConversionRules`.
166 |
167 | ## Custom components in markdown
168 |
169 | In the example above, you may have noticed that while we only defined custom conversion rules for HTML tags, we could have done the same for another tag name, for example `"PostInfo"`. You can also have any XML in markdown files, for example ``. Obviously the browser will not know what to do with this tag if we blindly keep it, but with `htmy` we can process it in any way we want.
170 |
171 | Building on the code from the previous section, as an example, let's add this `PostInfo` tag to `post.md` and create a custom `htmy` component for it.
172 |
173 | Here's the updated `post.md` file:
174 |
175 | ````md
176 | # Essential reading
177 |
178 |
179 |
180 | ```python
181 | import this
182 | ```
183 |
184 | Also available [here](https://peps.python.org/pep-0020/).
185 |
186 | Inline `code` is **also** _fine_.
187 |
188 | # Lists
189 |
190 | ## Ordered
191 |
192 | 1. First
193 | 2. Second
194 | 3. Third
195 |
196 | ## Unordered
197 |
198 | - First
199 | - Second
200 | - Third
201 | ````
202 |
203 | Then we can create the `PostInfo` `htmy` component:
204 |
205 | ```python
206 | class PostInfo:
207 | """Component for post info rendering."""
208 |
209 | def __init__(self, author: str, published_at: str) -> None:
210 | self.author = author
211 | self.published_at = published_at
212 |
213 | def htmy(self, context: Context) -> Component:
214 | return html.p("By ", html.strong(self.author), " at ", html.em(self.published_at), ".")
215 | ```
216 |
217 | Note that the arguments of `PostInfo.__init__()` match what we have in the markdown file.
218 |
219 | All we need now is a conversion rule for the `PostInfo` tag, so we extend the previously created converter with this rule:
220 |
221 | ```python
222 | md_converter = etree.ETreeConverter(
223 | {
224 | "h1": ConversionRules.h1,
225 | "h2": ConversionRules.h2,
226 | "ol": ConversionRules.ol,
227 | "ul": ConversionRules.ul,
228 | "PostInfo": PostInfo,
229 | }
230 | )
231 | ```
232 |
233 | If you run the app now (with `python app.py`) and open the resulting HTML in a browser, you will see that `` was nicely converted to HTML by `htmy`.
234 |
--------------------------------------------------------------------------------
/htmy/core.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import enum
4 | import json
5 | from typing import TYPE_CHECKING, Any, ClassVar, cast
6 | from xml.sax.saxutils import escape as xml_escape
7 | from xml.sax.saxutils import quoteattr as xml_quoteattr
8 |
9 | if TYPE_CHECKING:
10 | from collections.abc import Callable
11 |
12 | from typing_extensions import Never, Self
13 |
14 | from .typing import Component, ComponentType, Context, ContextKey, ContextValue, T
15 |
16 | # -- Utility components
17 |
18 |
19 | class Fragment:
20 | """Fragment utility component that simply wraps some children components."""
21 |
22 | __slots__ = ("_children",)
23 |
24 | def __init__(self, *children: ComponentType) -> None:
25 | """
26 | Initialization.
27 |
28 | Arguments:
29 | *children: The wrapped children.
30 | """
31 | self._children = children
32 |
33 | def htmy(self, context: Context) -> Component:
34 | """Renders the component."""
35 | return self._children
36 |
37 |
38 | class WithContext(Fragment):
39 | """
40 | A simple, static context provider component.
41 | """
42 |
43 | __slots__ = ("_context",)
44 |
45 | def __init__(self, *children: ComponentType, context: Context) -> None:
46 | """
47 | Initialization.
48 |
49 | Arguments:
50 | *children: The children components to wrap in the given context.
51 | context: The context to make available to children components.
52 | """
53 | super().__init__(*children)
54 | self._context = context
55 |
56 | def htmy_context(self) -> Context:
57 | """Returns the context for child rendering."""
58 | return self._context
59 |
60 |
61 | # -- Context utilities
62 |
63 |
64 | class ContextAware:
65 | """
66 | Base class with utilities for safe context use.
67 |
68 | Features:
69 |
70 | - Register subclass instance in a context.
71 | - Load subclass instance from context.
72 | - Wrap components within a subclass instance context.
73 |
74 | Subclass instance registration:
75 |
76 | Direct subclasses are considered the "base context type". Subclass instances are
77 | registered in contexts under their own type and also under their "base context type".
78 |
79 | Example:
80 |
81 | ```python
82 | class ContextDataDefinition(ContextAware):
83 | # This is the "base context type", instances of this class and its subclasses
84 | # will always be registered under this type.
85 | ...
86 |
87 | class ContextDataImplementation(ContextDataDefinition):
88 | # Instances of this class will be registered under `ContextDataDefinition` (the
89 | # "base context type") and also under this type.
90 | ...
91 |
92 | class SpecializedContextDataImplementation(ContextDataImplementation):
93 | # Instances of this class will be registered under `ContextDataDefinition` (the
94 | # "base context type") and also under this type, but they will not be registered
95 | # under `ContextDataImplementation`, since that's not the base context type.
96 | ...
97 | ```
98 | """
99 |
100 | __slots__ = ()
101 |
102 | _base_context_type: ClassVar[type[ContextAware] | None] = None
103 |
104 | def __init_subclass__(cls) -> None:
105 | if cls.mro()[1] == ContextAware:
106 | cls._base_context_type = cls
107 |
108 | def in_context(self, *children: ComponentType) -> WithContext:
109 | """
110 | Creates a context provider component that renders the given children using this
111 | instance in its context.
112 | """
113 | return WithContext(*children, context=self.to_context())
114 |
115 | def to_context(self) -> Context:
116 | """
117 | Creates a context with this instance in it.
118 |
119 | See the context registration rules in the class documentation for more information.
120 | """
121 | result: dict[ContextKey, ContextValue] = {type(self): self}
122 | if self._base_context_type is not None:
123 | result[self._base_context_type] = self
124 |
125 | return result
126 |
127 | @classmethod
128 | def from_context(cls, context: Context, default: Self | None = None) -> Self:
129 | """
130 | Looks up an instance of this class from the given contexts.
131 |
132 | Arguments:
133 | context: The context the instance should be loaded from.
134 | default: The default to use if no instance was found in the context.
135 | """
136 | result = context[cls] if default is None else context.get(cls, default)
137 | if isinstance(result, cls):
138 | return result
139 |
140 | raise TypeError(f"Invalid context data type for {cls.__name__}.")
141 |
142 |
143 | # -- Formatting
144 |
145 |
146 | class SkipProperty(Exception):
147 | """Exception raised by property formatters if the property should be skipped."""
148 |
149 | ...
150 |
151 | @classmethod
152 | def format_property(cls, _: Any) -> Never:
153 | """Property formatter that raises a `SkipProperty` error regardless of the received value."""
154 | raise cls("skip-property")
155 |
156 |
157 | class Text(str):
158 | """Marker class for differentiating text content from other strings."""
159 |
160 | ...
161 |
162 |
163 | class SafeStr(Text):
164 | """
165 | String subclass whose instances shouldn't get escaped during rendering.
166 |
167 | Note: any operation on `SafeStr` instances will result in plain `str` instances which
168 | will be rendered normally. Make sure the `str` to `SafeStr` conversion (`SafeStr(my_string)`)
169 | takes when there's no string operation afterwards.
170 | """
171 |
172 | ...
173 |
174 |
175 | class XBool(enum.Enum):
176 | """
177 | Utility for the valid formatting of boolean XML (and HTML) attributes.
178 |
179 | See this article for more information:
180 | https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes
181 | """
182 |
183 | true = True
184 | false = False
185 |
186 | def format(self) -> str:
187 | """
188 | Raises `SkipProperty` for `XBool.false`, returns empty string for `XBool.true`.
189 | """
190 | if self is XBool.true:
191 | return ""
192 |
193 | raise SkipProperty()
194 |
195 |
196 | def xml_format_string(value: str) -> str:
197 | """Escapes `<`, `>`, and `&` characters in the given string, unless it's a `SafeStr`."""
198 | return value if isinstance(value, SafeStr) else xml_escape(value)
199 |
200 |
201 | class Formatter(ContextAware):
202 | """
203 | The default, context-aware property name and value formatter.
204 |
205 | The formatter supports both primitive and (many) complex values, such as lists,
206 | dictionaries, tuples, and sets. Complex values are JSON-serialized by default.
207 |
208 | Important: the default implementation looks up the formatter for a given value by checking
209 | its type, but it doesn't do this check with the base classes of the encountered type. For
210 | example the formatter will know how to format `datetime` object, but it won't know how to
211 | format a `MyCustomDatetime(datetime)` instance.
212 |
213 | One reason for this is efficiency: always checking the base classes of every single value is a
214 | lot of unnecessary calculation. The other reason is customizability: this way you could use
215 | subclassing for formatter selection, e.g. with `LocaleDatetime(datetime)`-like classes.
216 |
217 | Property name and value formatters may raise a `SkipProperty` error if a property should be skipped.
218 | """
219 |
220 | __slots__ = ("_default_formatter", "_name_formatter", "_value_formatters")
221 |
222 | def __init__(
223 | self,
224 | *,
225 | default_formatter: Callable[[Any], str] = str,
226 | name_formatter: Callable[[str], str] | None = None,
227 | ) -> None:
228 | """
229 | Initialization.
230 |
231 | Arguments:
232 | default_formatter: The default property value formatter to use if no formatter could
233 | be found for a given value.
234 | name_formatter: Optional property name formatter (for replacing the default name formatter).
235 | """
236 | super().__init__()
237 | self._default_formatter = default_formatter
238 | self._name_formatter = self._format_name if name_formatter is None else name_formatter
239 | self._value_formatters: dict[type, Callable[[Any], str]] = self._base_formatters()
240 |
241 | def add(self, key: type[T], formatter: Callable[[T], str]) -> Self:
242 | """Registers the given value formatter under the given key."""
243 | self._value_formatters[key] = formatter
244 | return self
245 |
246 | def format(self, name: str, value: Any) -> str:
247 | """
248 | Formats the given name-value pair.
249 |
250 | Returns an empty string if the property name or value should be skipped.
251 |
252 | See `SkipProperty` for more information.
253 | """
254 | try:
255 | return f"{self.format_name(name)}={xml_quoteattr(self.format_value(value))}"
256 | except SkipProperty:
257 | return ""
258 |
259 | def format_name(self, name: str) -> str:
260 | """
261 | Formats the given name.
262 |
263 | Raises:
264 | SkipProperty: If the property should be skipped.
265 | """
266 | return self._name_formatter(name)
267 |
268 | def format_value(self, value: Any) -> str:
269 | """
270 | Formats the given value.
271 |
272 | Arguments:
273 | value: The property value to format.
274 |
275 | Raises:
276 | SkipProperty: If the property should be skipped.
277 | """
278 | fmt = self._value_formatters.get(type(value), self._default_formatter)
279 | return fmt(value)
280 |
281 | def _format_name(self, name: str, /) -> str:
282 | """The default property name formatter."""
283 | no_replacement = "_" in {name[0], name[-1]}
284 | return name.strip("_") if no_replacement else name.replace("_", "-")
285 |
286 | def _base_formatters(self) -> dict[type, Callable[[Any], str]]:
287 | """Factory that creates the default value formatter mapping."""
288 | from datetime import date, datetime
289 |
290 | return {
291 | bool: lambda v: "true" if v else "false",
292 | date: lambda d: cast(date, d).isoformat(),
293 | datetime: lambda d: cast(datetime, d).isoformat(),
294 | dict: lambda v: json.dumps(v),
295 | list: lambda v: json.dumps(v),
296 | tuple: lambda v: json.dumps(v),
297 | set: lambda v: json.dumps(tuple(v)),
298 | XBool: lambda v: cast(XBool, v).format(),
299 | type(None): SkipProperty.format_property,
300 | }
301 |
--------------------------------------------------------------------------------
/docs/function-components.md:
--------------------------------------------------------------------------------
1 | # Function components
2 |
3 | The default and most flexible way to define an `htmy` component is to add a sync or async `htmy(self, context: Context) -> Component` method to a class, often to enhance a pre-existing business object with `htmy` rendering capabilities.
4 |
5 | However, in many cases, especially when you're not enhancing an existing class, this ends up being very verbose and requires a lot of boilerplate: you need to define a class, add the necessary properties, and finally implement the `htmy()` method. This is especially impractical when the component has no properties.
6 |
7 | Function components address these issues by allowing you to fully skip class creation and define the component simply as a function (well, or method, as we'll see later). This removes the need for any boilerplate, while also making the code more concise and easier to read.
8 |
9 | ## Function component types
10 |
11 | Fundamentally, there are two kinds of function components, both of which may of course be sync or async.
12 |
13 | The "classic" function component expects a properties and a context argument, and returns a `Component`: `def fc(props: Props, context: Context) -> Component`. This kind of function component is useful when the component requires properties and also uses the rendering context, for example to get access to the request object, the translation function, a style provider, etc..
14 |
15 | Often, components don't need properties, only access to the rendering context. This use-case is addressed by "context-only" function components, which only expect a context argument: `def context_only_fc(context: Context) -> Component`.
16 |
17 | You may ask what if a "component" only needs properties, but not the context? Or if it doesn't need either? The answer is these functions are not really components, rather just "component factories". You can find out more about them in the [Components guide](components-guide.md#what-is-a-component-factory).
18 |
19 | There is another question that naturally arises: can the instance methods of a class also be function components? The answer is of course yes, which means that in total there are four types of function components.
20 |
21 | - Functions with a properties and a context argument.
22 | - Functions with only a context argument.
23 | - Instance methods with a properties and a context argument.
24 | - Instance methods with only a context argument.
25 |
26 | ## Creating function components
27 |
28 | We've discussed the four types of function components and their signatures (protocol/interface definition) in the previous section, but such functions are not automatically components, because they do not have an `htmy()` method.
29 |
30 | To turn these functions into components, you need to decorate them with the `@component` decorator. Actually, since all four types of function components look different (remember that methods require the `self` argument as well), the `@component` decorator has one variant for each of them:
31 |
32 | - `@component` (and its `@component.function` alias) for functions with a properties and a context argument.
33 | - `@component.context_only` for functions with only a context argument.
34 | - `@component.method` for instance methods with a properties and a context argument.
35 | - `@component.context_only_method` for instance methods with only a context argument.
36 |
37 | _Technical note_: the `@component` decorators change the decorated function's signature. After the decorator is applied, the resulting component will be callable with only the function component's properties (if any), and the returned object will have the `htmy(context: Context) -> Component` method that the renderer will call with the context during rendering. As a result, the decorated function will only be executed when the component is rendered.
38 |
39 | If it sounded complicated and overly technical, don't worry, function components will feel trivial once you see them in action.
40 |
41 | ## Examples
42 |
43 | Before we dive into the actual components, let's import what we need and create a few utilities, just to have some data to work with. The examples assume that `htmy` is installed.
44 |
45 | ```python
46 | import asyncio
47 | from dataclasses import dataclass
48 | from typing import Callable
49 |
50 | from htmy import ComponentType, Context, Renderer, component, html
51 |
52 | @dataclass
53 | class User:
54 | """User model."""
55 |
56 | username: str
57 | email: str
58 | status: str
59 |
60 | users = [
61 | User("alice", "alice@example.ccm", "active"),
62 | User("bob", "bob@example.ccm", "pending"),
63 | User("charlie", "charlie@example.ccm", "archived"),
64 | User("dave", "dave@example.ccm", "active"),
65 | ]
66 |
67 | def css_provider(key: str) -> str:
68 | """A dummy style provider function."""
69 | return key
70 |
71 | renderer = Renderer(
72 | {
73 | # Add the style provider function to the default rendering context
74 | # so we can always use it in our components.
75 | "css": css_provider
76 | }
77 | )
78 | ```
79 |
80 | ### Functions
81 |
82 | First let's create a component that renders a user as a styled list item. The "properties" of this component is the user we want to render, and the context is used to get access to the style provider for styling.
83 |
84 | ```python
85 | @component
86 | def user_list_item(user: User, context: Context) -> ComponentType:
87 | """
88 | Function component that renders a user as a list item.
89 | """
90 | css: Callable[[str], str] = context["css"]
91 | return html.li(
92 | html.label(user.username),
93 | class_=css(user.status),
94 | )
95 | ```
96 |
97 | Next we create a component renders a list of users. This component is implemented similarly to the list item component, except here we use the `@component.function` decorator (which is just an alias for `@component`), and the decorated function is async, just to showcase that it also works.
98 |
99 | ```python
100 | @component.function # @component.function is just an alias for @component
101 | async def user_list(users: list[User], context: Context) -> ComponentType:
102 | """
103 | Function component that renders the given list of users.
104 | """
105 | css: Callable[[str], str] = context["css"]
106 | return html.ul(
107 | *(
108 | # Render each user using the user_list_item component.
109 | # Notice that we call the component with only its properties object (the user).
110 | user_list_item(user)
111 | for user in users
112 | ),
113 | class_=css("unordered-list"),
114 | )
115 | ```
116 |
117 | Finally, let's also create a context-only component. This will show a styled page with a heading and the list of users. The pattern is the same as before, but in this case the `@component.context_only` decorator is used and the function only accepts a context argument (no properties).
118 |
119 | ```python
120 | @component.context_only
121 | def users_page(context: Context) -> ComponentType:
122 | """
123 | Context-only function component that renders the users page.
124 | """
125 | css: Callable[[str], str] = context["css"]
126 | return html.div(
127 | html.h1("Users:", class_=css("heading")),
128 | # Render users using the user_list component.
129 | # Notice that we call the component with only its properties (the list of users).
130 | user_list(users),
131 | class_=css("page-layout"),
132 | )
133 | ```
134 |
135 | With all the components ready, we can now render the `users_page` component and have a look at the result:
136 |
137 | ```python
138 | rendered = asyncio.run(
139 | renderer.render(
140 | # Notice that we call the users_page component with no arguments,
141 | # since this component has no properties.
142 | users_page()
143 | )
144 | )
145 | print(rendered)
146 | ```
147 |
148 | It wasn't complicated, was it?
149 |
150 | ### Methods
151 |
152 | Having seen how to create and use function components, you probably have a very good idea of how method components work. The only difference is that we use method decorators and that we decorate instance methods.
153 |
154 | To reuse some code, we are going to subclass our existing `User` class and add a `profile_page()` and a context-only `table_row()` method component to the subclass. Normally, these methods would be in the `User` class, but using a subclass better suits this guide.
155 |
156 | It's important to know that method components can be added even to classes that are themselves components (meaning they have an `htmy()` method). The example below demonstrates this as well.
157 |
158 | ```python
159 | class EnhancedUser(User):
160 | """
161 | `User` subclass with some method components for user rendering.
162 | """
163 |
164 | @component.method
165 | def profile_page(self, navbar: html.nav, context: Context) -> ComponentType:
166 | """
167 | Method component that renders the user's profile page.
168 | """
169 | css: Callable[[str], str] = context["css"]
170 | return html.div(
171 | navbar,
172 | html.div(
173 | html.p("Username:"),
174 | html.p(self.username),
175 | html.p("Email:"),
176 | html.p(self.email),
177 | html.p("Status:"),
178 | html.p(self.status),
179 | class_=css("profile-card"),
180 | ),
181 | class_=css("page-with-navbar"),
182 | )
183 |
184 | @component.context_only_method
185 | def table_row(self, context: Context) -> ComponentType:
186 | """
187 | Context-only method component that renders the user as a table row.
188 | """
189 | css: Callable[[str], str] = context["css"]
190 | return html.tr(
191 | html.td(self.username, class_=css("primary")),
192 | html.td(self.email),
193 | html.td(self.status),
194 | )
195 |
196 | def htmy(self, context: Context) -> ComponentType:
197 | """
198 | Renders the user as a styled list item.
199 | """
200 | css: Callable[[str], str] = context["css"]
201 | return html.li(
202 | html.label(self.username),
203 | class_=css(self.status),
204 | )
205 | ```
206 |
207 | As you can see, method components work the same way as function componnts, except the decorated methods have the usual `self` argument, and `@component.method` and `@component.context_only_method` decorators are used instead of `@component` (`@component.function`) and `@component.context_only`.
208 |
209 | All that's left to do now is to create an instance of our new, `EnhancedUser` class, render its method components and the instance itself and see the result of our work.
210 |
211 | ```python
212 | emily = EnhancedUser(username="emily", email="emily@example.ccm", status="active")
213 |
214 | rendered = asyncio.run(
215 | renderer.render(
216 | html.div(
217 | # We call the user.profile_page component only with its properties.
218 | emily.profile_page(html.nav("Navbar")),
219 | # We call the user.table_row component with no arguments, since
220 | # this component has no properties.
221 | emily.table_row(),
222 | # EnhancedUser instances are also components, because they have an htmy() method.
223 | emily,
224 | )
225 | )
226 | )
227 | print(rendered)
228 | ```
229 |
230 | That's it!
231 |
--------------------------------------------------------------------------------
/htmy/renderer/default.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from asyncio import gather as asyncio_gather
4 | from collections import ChainMap, deque
5 | from inspect import isawaitable, iscoroutinefunction
6 | from typing import TYPE_CHECKING, TypeAlias
7 |
8 | from htmy.core import xml_format_string
9 | from htmy.typing import Context
10 | from htmy.utils import is_component_sequence
11 |
12 | from .context import RendererContext
13 |
14 | if TYPE_CHECKING:
15 | from collections.abc import Awaitable, Callable, Iterator
16 |
17 | from htmy.typing import Component, ComponentType, ContextProvider
18 |
19 |
20 | class _Node:
21 | """A single node in the linked list the renderer constructs to resolve a component tree."""
22 |
23 | __slots__ = ("component", "next")
24 |
25 | def __init__(self, component: ComponentType, next: _Node | None = None) -> None:
26 | """
27 | Initialization.
28 |
29 | Arguments:
30 | component: The component in this node.
31 | next: The next component in the list, if there is one.
32 | """
33 | self.component = component
34 | self.next = next
35 |
36 | def iter_nodes(self, *, include_self: bool = True) -> Iterator[_Node]:
37 | """
38 | Iterates over all following nodes.
39 |
40 | Arguments:
41 | include_self: Whether the node on which this method is called should also
42 | be included in the iterator.
43 | """
44 | current = self if include_self else self.next
45 | while current is not None:
46 | yield current
47 | current = current.next
48 |
49 |
50 | _NodeAndChildContext: TypeAlias = tuple[_Node, Context]
51 |
52 |
53 | class _ComponentRenderer:
54 | """
55 | `ComponentType` renderer that converts a component tree into a linked list of resolved (`str`) nodes.
56 | """
57 |
58 | __slots__ = ("_async_todos", "_sync_todos", "_root", "_string_formatter")
59 |
60 | def __init__(
61 | self,
62 | component: ComponentType,
63 | context: Context,
64 | *,
65 | string_formatter: Callable[[str], str],
66 | ) -> None:
67 | """
68 | Initialization.
69 |
70 | Arguments:
71 | component: The component to render.
72 | context: The base context to use for rendering the component.
73 | string_formatter: The string formatter to use.
74 | """
75 | self._async_todos: deque[_NodeAndChildContext] = deque()
76 | """Async node - context tuples that need to be rendered."""
77 | self._sync_todos: deque[_NodeAndChildContext] = deque()
78 | """
79 | Sync node - context tuples that need to be rendered (`node.component` is an `HTMYComponentType`).
80 | """
81 | self._string_formatter = string_formatter
82 | """The string formatter to use."""
83 |
84 | if isinstance(component, str):
85 | root = _Node(string_formatter(component), None)
86 | else:
87 | root = _Node(component, None)
88 | self._schedule_node(root, context)
89 | self._root = root
90 | """The root node in the linked list the renderer constructs."""
91 |
92 | async def _extend_context(self, component: ContextProvider, context: Context) -> Context:
93 | """
94 | Returns a new context from the given component and context.
95 |
96 | Arguments:
97 | component: A `ContextProvider` component.
98 | context: The current rendering context.
99 | """
100 | extra_context: Context | Awaitable[Context] = component.htmy_context()
101 | if isawaitable(extra_context):
102 | extra_context = await extra_context
103 |
104 | return (
105 | # Context must not be mutated. We can ignore that ChainMap expects mutable mappings.
106 | ChainMap(extra_context, context) # type: ignore[arg-type]
107 | if extra_context
108 | else context
109 | )
110 |
111 | def _process_node_result(self, parent_node: _Node, component: Component, context: Context) -> None:
112 | """
113 | Processes the result of a single node.
114 |
115 | Arguments:
116 | parent_node: The node that was resolved.
117 | component: The (awaited if async) result of `parent_node.component.htmy()`.
118 | context: The context that was used for rendering `parent_node.component`.
119 | """
120 | schedule_node = self._schedule_node
121 | string_formatter = self._string_formatter
122 | if hasattr(component, "htmy"):
123 | parent_node.component = component
124 | schedule_node(parent_node, context)
125 | elif isinstance(component, str):
126 | parent_node.component = string_formatter(component)
127 | elif is_component_sequence(component):
128 | if len(component) == 0:
129 | parent_node.component = ""
130 | return
131 |
132 | first_comp, *rest_comps = component
133 | if isinstance(first_comp, str):
134 | parent_node.component = string_formatter(first_comp)
135 | else:
136 | parent_node.component = first_comp
137 | schedule_node(parent_node, context)
138 |
139 | old_next = parent_node.next
140 | last: _Node = parent_node
141 | for c in rest_comps:
142 | if isinstance(c, str):
143 | node = _Node(string_formatter(c), old_next)
144 | else:
145 | node = _Node(c, old_next)
146 | schedule_node(node, context)
147 |
148 | last.next = node
149 | last = node
150 | else:
151 | raise ValueError(f"Invalid component type: {type(component)}")
152 |
153 | async def _process_async_node(self, node: _Node, context: Context) -> None:
154 | """
155 | Processes the given node. `node.component` must be an async component.
156 | """
157 | result = await node.component.htmy(context) # type: ignore[misc,union-attr]
158 | self._process_node_result(node, result, context)
159 |
160 | def _schedule_node(self, node: _Node, child_context: Context) -> None:
161 | """
162 | Schedules the given node for rendering with the given child context.
163 |
164 | `node.component` must be an `HTMYComponentType` (single component and not `str`).
165 | """
166 | component = node.component
167 | if component is None:
168 | pass # Just skip the node
169 | elif iscoroutinefunction(component.htmy): # type: ignore[union-attr]
170 | self._async_todos.append((node, child_context))
171 | else:
172 | self._sync_todos.append((node, child_context))
173 |
174 | async def run(self) -> str:
175 | """Runs the component renderer."""
176 | async_todos = self._async_todos
177 | sync_todos = self._sync_todos
178 | process_node_result = self._process_node_result
179 | process_async_node = self._process_async_node
180 |
181 | while sync_todos or async_todos:
182 | while sync_todos:
183 | node, child_context = sync_todos.pop()
184 | component = node.component
185 | if component is None:
186 | continue
187 |
188 | if hasattr(component, "htmy_context"): # isinstance() is too expensive.
189 | child_context = await self._extend_context(component, child_context) # type: ignore[arg-type]
190 |
191 | if iscoroutinefunction(component.htmy): # type: ignore[union-attr]
192 | async_todos.append((node, child_context))
193 | else:
194 | result: Component = component.htmy(child_context) # type: ignore[assignment,union-attr]
195 | process_node_result(node, result, child_context)
196 |
197 | if async_todos:
198 | current_async_todos = async_todos
199 | self._async_todos = async_todos = deque()
200 | await asyncio_gather(*(process_async_node(n, ctx) for n, ctx in current_async_todos))
201 |
202 | return "".join(node.component for node in self._root.iter_nodes() if node.component is not None) # type: ignore[misc]
203 |
204 |
205 | async def _render_component(
206 | component: Component,
207 | *,
208 | context: Context,
209 | string_formatter: Callable[[str], str],
210 | ) -> str:
211 | """Renders the given component with the given settings."""
212 | if hasattr(component, "htmy"):
213 | return await _ComponentRenderer(component, context, string_formatter=string_formatter).run()
214 | elif isinstance(component, str):
215 | return string_formatter(component)
216 | elif is_component_sequence(component):
217 | if len(component) == 0:
218 | return ""
219 |
220 | renderers = (_ComponentRenderer(c, context, string_formatter=string_formatter) for c in component)
221 | return "".join(await asyncio_gather(*(r.run() for r in renderers)))
222 | elif component is None:
223 | return ""
224 | else:
225 | raise ValueError(f"Invalid component type: {type(component)}")
226 |
227 |
228 | class Renderer:
229 | """
230 | The default renderer.
231 |
232 | It resolves component trees by converting them to a linked list of resolved component parts
233 | before combining them to the final string.
234 | """
235 |
236 | __slots__ = ("_default_context", "_string_formatter")
237 |
238 | def __init__(
239 | self,
240 | default_context: Context | None = None,
241 | *,
242 | string_formatter: Callable[[str], str] = xml_format_string,
243 | ) -> None:
244 | """
245 | Initialization.
246 |
247 | Arguments:
248 | default_context: The default context to use for rendering if `render()` doesn't
249 | receive a context.
250 | string_formatter: Callable that should be used to format plain strings. By default
251 | an XML-safe string formatter will be used.
252 | """
253 | self._default_context: Context = {} if default_context is None else default_context
254 | self._string_formatter = string_formatter
255 |
256 | async def render(self, component: Component, context: Context | None = None) -> str:
257 | """
258 | Renders the given component.
259 |
260 | Implements `htmy.typing.RendererType`.
261 |
262 | Arguments:
263 | component: The component to render.
264 | context: An optional rendering context.
265 |
266 | Returns:
267 | The rendered string.
268 | """
269 | # Create a new default context that also contains the renderer instance.
270 | # We must not put it in `self._default_context` because then the renderer
271 | # would keep a reference to itself.
272 | default_context = {**self._default_context, RendererContext: self}
273 | # Type ignore: ChainMap expects mutable mappings, but context mutation is not allowed so don't care.
274 | context = (
275 | default_context if context is None else ChainMap(context, default_context) # type: ignore[arg-type]
276 | )
277 | return await _render_component(component, context=context, string_formatter=self._string_formatter)
278 |
--------------------------------------------------------------------------------
/htmy/snippet.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 | from collections.abc import Iterator, Mapping
5 | from inspect import isawaitable
6 | from typing import TYPE_CHECKING
7 |
8 | from async_lru import alru_cache
9 |
10 | from .core import SafeStr, Text
11 | from .io import load_text_file
12 | from .typing import (
13 | Component,
14 | ComponentType,
15 | Context,
16 | TextProcessor,
17 | TextResolver,
18 | )
19 | from .utils import as_component_sequence, as_component_type, is_component_sequence
20 |
21 | if TYPE_CHECKING:
22 | from pathlib import Path
23 |
24 | # -- Components and utilities
25 |
26 |
27 | class Slots:
28 | """
29 | Utility that resolves slots in a string input to components.
30 |
31 | More technically, it splits a string into slot and non-slot parts, replaces the
32 | slot parts with the corresponding components (which may be component sequences)
33 | from the given slot mapping, and returns the resulting component sequence.
34 |
35 | The default slot placeholder is a standard XML/HTML comment of the following form:
36 | ``. Any number of whitespaces (including 0) are allowed in
37 | the placeholder, but the slot key must not contain any whitespaces. For details, see
38 | `Slots.slot_re`.
39 |
40 | Besides the pre-defined regular expressions in `Slots.slot_re`, any other regular
41 | expression can be used to identify slots as long as it meets the requirements described
42 | in `Slots.slots_re`.
43 |
44 | Implements: `htmy.typing.TextResolver`
45 | """
46 |
47 | __slots__ = ("_not_found", "_slot_mapping", "_slot_re")
48 |
49 | class slot_re:
50 | """
51 | Slot regular expressions.
52 |
53 | Requirements:
54 |
55 | - The regular expression must have exactly one capturing group that captures the slot key.
56 | """
57 |
58 | square_bracket = re.compile(r"")
59 | """
60 | Slot regular expression that matches slots defined as follows: ``.
61 |
62 | The slot key must not contain any whitespaces and there must not be any additional text
63 | in the XML/HTML comment. Any number of whitespaces (including 0) are allowed around the
64 | parts of the slot placeholder.
65 | """
66 | parentheses = re.compile(r"")
67 | """
68 | Slot regular expression that matches slots defined as follows: ``.
69 |
70 | The slot key must not contain any whitespaces and there must not be any additional text
71 | in the XML/HTML comment. Any number of whitespaces (including 0) are allowed around the
72 | parts of the slot placeholder.
73 | """
74 |
75 | # There are no defaults for angle bracket and curly braces, because
76 | # they may conflict with HTML and format strings.
77 |
78 | default = square_bracket
79 | """
80 | The default slot regular expression. Same as `Slots.slot_re.square_bracket`.
81 | """
82 |
83 | def __init__(
84 | self,
85 | slot_mapping: Mapping[str, Component],
86 | *,
87 | slot_re: re.Pattern[str] = slot_re.default,
88 | not_found: Component | None = None,
89 | ) -> None:
90 | """
91 | Initialization.
92 |
93 | Slot regular expressions are used to find slot keys in strings, which are then replaced
94 | with the corresponding component from the slot mapping. `slot_re` must have exactly one
95 | capturing group that captures the slot key. `Slots.slot_re` contains some predefined slot
96 | regular expressions, but any other regular expression can be used as long as it matches
97 | the capturing group requirement above.
98 |
99 | Arguments:
100 | slot_mapping: Slot mapping the maps slot keys to the corresponding component.
101 | slot_re: The slot regular expression that is used to find slot keys in strings.
102 | not_found: The component that is used to replace slot keys that are not found in
103 | `slot_mapping`. If `None` and the slot key is not found in `slot_mapping`,
104 | then a `KeyError` will be raised by `resolve()`.
105 | """
106 | self._slot_mapping = slot_mapping
107 | self._slot_re = slot_re
108 | self._not_found = not_found
109 |
110 | def resolve_text(self, text: str) -> Component:
111 | """
112 | Resolves the given string into components using the instance's slot regular expression
113 | and slot mapping.
114 |
115 | Arguments:
116 | text: The text to resolve.
117 |
118 | Returns:
119 | The component sequence the text resolves to.
120 |
121 | Raises:
122 | KeyError: If a slot key is not found in the slot mapping and `not_found` is `None`.
123 | """
124 | return tuple(self._resolve_text(text))
125 |
126 | def _resolve_text(self, text: str) -> Iterator[ComponentType]:
127 | """
128 | Generator that yields the slot and non-slot parts of the given string in order.
129 |
130 | Arguments:
131 | text: The text to resolve.
132 |
133 | Yields:
134 | The slot and non-slot parts of the given string.
135 |
136 | Raises:
137 | KeyError: If a slot key is not found in the slot mapping and `not_found` is `None`.
138 | """
139 | is_slot = False
140 | # The implementation requires that the slot regular expression has exactly one capturing group.
141 | for part in self._slot_re.split(text):
142 | if is_slot:
143 | resolved = self._slot_mapping.get(part, self._not_found)
144 | if resolved is None:
145 | raise KeyError(f"Component not found for slot: {part}")
146 |
147 | if is_component_sequence(resolved):
148 | yield from resolved
149 | else:
150 | # mypy complains that resolved may be a sequence, but that's not the case.
151 | yield resolved # type: ignore[misc]
152 | else:
153 | yield part
154 |
155 | is_slot = not is_slot
156 |
157 |
158 | class Snippet:
159 | """
160 | Component that renders text, which may be asynchronously loaded from a file.
161 |
162 | **Warning:** The component treats its input as trusted. If any part of the input comes from
163 | untrusted sources, ensure it is safely escaped (using for example `htmy.xml_format_string`)!
164 | Passing untrusted input to this component leads to XSS vulnerabilities.
165 |
166 | The entire snippet processing pipeline consists of the following steps:
167 |
168 | 1. The text content is loaded from a file or passed directly as a `Text` instance.
169 | 2. The text content is processed by a `TextProcessor` if provided.
170 | 3. The processed text is converted into a component (may be component sequence)
171 | by a `TextResolver`, for example `Slots`.
172 | 4. Every `str` children (produced by the steps above) is converted into a `SafeStr` for
173 | rendering.
174 |
175 | The pipeline above is a bit abstract, so here are some usage notes:
176 |
177 | - The text content of a snippet can be a Python format string template, in which case the
178 | `TextProcessor` can be a simple method that calls `str.format()` with the correct arguments.
179 | - Alternatively, a text processor can also be used to get only a substring -- commonly referred
180 | to as fragment in frameworks like Jinja -- of the original text.
181 | - The text processor is applied before the text resolver, which makes it possible to insert
182 | placeholders into the text (for example slots, like in this case:
183 | `..."{toolbar}...".format(toolbar="")`) that are then replaced with any
184 | `htmy.Component` by the `TextResolver` (for example `Slots`).
185 | - `TextResolver` can return plain `str` values, it is not necessary for it to convert strings
186 | to `SafeStr` to prevent unwanted escaping.
187 |
188 | Example:
189 |
190 | ```python
191 | from datetime import date
192 | from htmy import Snippet, Slots
193 |
194 | def text_processor(text: str, context: Context) -> str:
195 | return text.format(today=date.today())
196 |
197 | snippet = Snippet(
198 | "my-page.html",
199 | text_processor=text_processor,
200 | text_resolver=Slots(
201 | {
202 | "date-picker": MyDatePicker(class_="text-primary"),
203 | "Toolbar": MyPageToolbar(active_page="home"),
204 | ...
205 | }
206 | ),
207 | )
208 | ```
209 |
210 | In the above example, if `my-page.html` contains a `{today}` placeholder, it will be replaced
211 | with the current date. If it contains a `}` slot, then the `MyPageToolbar`
212 | `htmy` component instance will be rendered in its place, and the `` slot
213 | will be replaced with the `MyDatePicker` component instance.
214 | """
215 |
216 | __slots__ = ("_path_or_text", "_text_processor", "_text_resolver")
217 |
218 | def __init__(
219 | self,
220 | path_or_text: Text | str | Path,
221 | text_resolver: TextResolver | None = None,
222 | *,
223 | text_processor: TextProcessor | None = None,
224 | ) -> None:
225 | """
226 | Initialization.
227 |
228 | Arguments:
229 | path_or_text: The path from where the content should be loaded or a `Text`
230 | instance if this value should be rendered directly.
231 | text_resolver: An optional `TextResolver` (e.g. `Slots`) that converts the processed
232 | text into a component. If not provided, the text will be rendered as a `SafeStr`.
233 | text_processor: An optional `TextProcessor` that can be used to process the text
234 | content before rendering. It can be used for example for token replacement or
235 | string formatting.
236 | """
237 | self._path_or_text = path_or_text
238 | self._text_processor = text_processor
239 | self._text_resolver = text_resolver
240 |
241 | async def htmy(self, context: Context) -> Component:
242 | """Renders the component."""
243 | text = await self._get_text_content()
244 | if self._text_processor is not None:
245 | processed = self._text_processor(text, context)
246 | text = (await processed) if isawaitable(processed) else processed
247 |
248 | if self._text_resolver is None:
249 | return self._render_text(text, context)
250 |
251 | comps = as_component_sequence(self._text_resolver.resolve_text(text))
252 | return tuple(
253 | as_component_type(self._render_text(c, context)) if isinstance(c, str) else c for c in comps
254 | )
255 |
256 | async def _get_text_content(self) -> str:
257 | """Returns the plain text content that should be rendered."""
258 | path_or_text = self._path_or_text
259 |
260 | if isinstance(path_or_text, Text):
261 | return path_or_text
262 | else:
263 | return await Snippet._load_text_file(path_or_text)
264 |
265 | def _render_text(self, text: str, context: Context) -> Component:
266 | """
267 | Render function that takes the text that must be rendered and the current rendering context,
268 | and returns the corresponding component.
269 | """
270 | return SafeStr(text)
271 |
272 | @staticmethod
273 | @alru_cache()
274 | async def _load_text_file(path: str | Path) -> str:
275 | """Async text loader with an LRU cache."""
276 | return await load_text_file(path)
277 |
--------------------------------------------------------------------------------
/htmy/function_component.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from collections.abc import Callable, Coroutine
4 | from inspect import iscoroutinefunction
5 | from typing import Any, Protocol, TypeAlias, overload
6 |
7 | from .typing import AsyncComponent, Component, Context, SyncComponent
8 | from .typing import T as TProps
9 | from .typing import U as TSelf
10 |
11 | # -- Typing for "full" function components and context only method components.
12 |
13 | _SyncFunctionComponent: TypeAlias = Callable[[TProps, Context], Component]
14 | """
15 | Protocol definition for sync function components that have both a properties and a context argument.
16 | """
17 |
18 | _AsyncFunctionComponent: TypeAlias = Callable[[TProps, Context], Coroutine[Any, Any, Component]]
19 | """
20 | Protocol definition for async function components that have both a properties and a context argument.
21 | """
22 |
23 | # -- Typing for context-only function components.
24 |
25 | _ContextOnlySyncFunctionComponent: TypeAlias = Callable[[Context], Component]
26 | """
27 | Protocol definition for sync function components that only have a context argument.
28 | """
29 |
30 |
31 | class _DecoratedContextOnlySyncFunctionComponent(SyncComponent, Protocol):
32 | """
33 | Protocol definition for sync components that are also callable, and return a sync
34 | component when called.
35 | """
36 |
37 | def __call__(self) -> SyncComponent: ...
38 |
39 |
40 | _ContextOnlyAsyncFunctionComponent: TypeAlias = Callable[[Context], Coroutine[Any, Any, Component]]
41 | """
42 | Protocol definition for async function components that only have a context argument.
43 | """
44 |
45 |
46 | class _DecoratedContextOnlyAsyncFunctionComponent(SyncComponent, Protocol):
47 | """
48 | Protocol definition for async components that are also callable, and return an async
49 | component when called.
50 | """
51 |
52 | def __call__(self) -> SyncComponent: ...
53 |
54 |
55 | # -- Typing for "full" method components.
56 |
57 | _SyncMethodComponent: TypeAlias = Callable[[TSelf, TProps, Context], Component]
58 | """
59 | Protocol definition for sync method components that have both a properties and a context argument.
60 | """
61 |
62 | _AsyncMethodComponent: TypeAlias = Callable[[TSelf, TProps, Context], Coroutine[Any, Any, Component]]
63 | """
64 | Protocol definition for async method components that have both a properties and a context argument.
65 | """
66 |
67 |
68 | # -- Component decorators.
69 |
70 |
71 | class ComponentDecorators:
72 | """
73 | Function component decorators.
74 | """
75 |
76 | __slots__ = ()
77 |
78 | # -- Function component decorator.
79 |
80 | @overload
81 | def __call__(self, func: _SyncFunctionComponent[TProps]) -> Callable[[TProps], SyncComponent]: ...
82 |
83 | @overload
84 | def __call__(self, func: _AsyncFunctionComponent[TProps]) -> Callable[[TProps], AsyncComponent]: ...
85 |
86 | def __call__(
87 | self,
88 | func: _SyncFunctionComponent[TProps] | _AsyncFunctionComponent[TProps],
89 | ) -> Callable[[TProps], SyncComponent] | Callable[[TProps], AsyncComponent]:
90 | """
91 | Decorator that converts the decorated function into one that must be called with
92 | the function component's properties and returns a component instance.
93 |
94 | If used on an async function, the resulting component will also be async;
95 | otherwise it will be sync.
96 |
97 | Example:
98 |
99 | ```python
100 | @component
101 | def my_component(props: int, context: Context) -> Component:
102 | return html.p(f"Value: {props}")
103 |
104 | async def render() -> str:
105 | return await Renderer().render(
106 | my_component(42)
107 | )
108 | ```
109 |
110 | Arguments:
111 | func: The decorated function.
112 |
113 | Returns:
114 | A function that must be called with the function component's properties and
115 | returns a component instance. (Or loosly speaking, an `HTMYComponentType` which
116 | can be "instantiated" with the function component's properties.)
117 | """
118 |
119 | if iscoroutinefunction(func):
120 |
121 | def async_wrapper(props: TProps) -> AsyncComponent:
122 | # This function must be async, in case the renderer inspects it to decide how to handle it.
123 | async def component(context: Context) -> Component:
124 | return await func(props, context) # type: ignore[no-any-return]
125 |
126 | component.htmy = component # type: ignore[attr-defined]
127 | return component # type: ignore[return-value]
128 |
129 | return async_wrapper
130 | else:
131 |
132 | def sync_wrapper(props: TProps) -> SyncComponent:
133 | def component(context: Context) -> Component:
134 | return func(props, context) # type: ignore[return-value]
135 |
136 | component.htmy = component # type: ignore[attr-defined]
137 | return component # type: ignore[return-value]
138 |
139 | return sync_wrapper
140 |
141 | @overload
142 | def function(self, func: _SyncFunctionComponent[TProps]) -> Callable[[TProps], SyncComponent]: ...
143 |
144 | @overload
145 | def function(self, func: _AsyncFunctionComponent[TProps]) -> Callable[[TProps], AsyncComponent]: ...
146 |
147 | def function(
148 | self,
149 | func: _SyncFunctionComponent[TProps] | _AsyncFunctionComponent[TProps],
150 | ) -> Callable[[TProps], SyncComponent] | Callable[[TProps], AsyncComponent]:
151 | """
152 | Decorator that converts the decorated function into one that must be called with
153 | the function component's properties and returns a component instance.
154 |
155 | If used on an async function, the resulting component will also be async;
156 | otherwise it will be sync.
157 |
158 | This function is just an alias for `__call__()`.
159 |
160 | Example:
161 |
162 | ```python
163 | @component.function
164 | def my_component(props: int, context: Context) -> Component:
165 | return html.p(f"Value: {props}")
166 |
167 | async def render() -> str:
168 | return await Renderer().render(
169 | my_component(42)
170 | )
171 |
172 | Arguments:
173 | func: The decorated function.
174 |
175 | Returns:
176 | A function that must be called with the function component's properties and
177 | returns a component instance. (Or loosly speaking, an `HTMYComponentType` which
178 | can be "instantiated" with the function component's properties.)
179 | """
180 | return self(func)
181 |
182 | # -- Context-only function component decorator.
183 |
184 | @overload
185 | def context_only(
186 | self, func: _ContextOnlySyncFunctionComponent
187 | ) -> _DecoratedContextOnlySyncFunctionComponent: ...
188 |
189 | @overload
190 | def context_only(
191 | self, func: _ContextOnlyAsyncFunctionComponent
192 | ) -> _DecoratedContextOnlyAsyncFunctionComponent: ...
193 |
194 | def context_only(
195 | self,
196 | func: _ContextOnlySyncFunctionComponent | _ContextOnlyAsyncFunctionComponent,
197 | ) -> _DecoratedContextOnlySyncFunctionComponent | _DecoratedContextOnlyAsyncFunctionComponent:
198 | """
199 | Decorator that converts the decorated function into a component.
200 |
201 | If used on an async function, the resulting component will also be async;
202 | otherwise it will be sync.
203 |
204 | Example:
205 |
206 | ```python
207 | @component.context_only
208 | def my_component(ctx):
209 | return "Context only function component."
210 |
211 | async def render() -> str:
212 | return await Renderer().render(
213 | my_component()
214 | )
215 | ```
216 |
217 | Arguments:
218 | func: The decorated function.
219 |
220 | Returns:
221 | The created component.
222 | """
223 |
224 | def wrapper() -> SyncComponent | AsyncComponent:
225 | func.htmy = func # type: ignore[union-attr]
226 | return func # type: ignore[return-value]
227 |
228 | # This assignment adds support for context-only function components without call signature.
229 | wrapper.htmy = func # type: ignore[attr-defined]
230 | return wrapper # type: ignore[return-value]
231 |
232 | # -- Method component decorator.
233 |
234 | @overload
235 | def method(
236 | self, func: _SyncMethodComponent[TSelf, TProps]
237 | ) -> Callable[[TSelf, TProps], SyncComponent]: ...
238 |
239 | @overload
240 | def method(
241 | self, func: _AsyncMethodComponent[TSelf, TProps]
242 | ) -> Callable[[TSelf, TProps], AsyncComponent]: ...
243 |
244 | def method(
245 | self,
246 | func: _SyncMethodComponent[TSelf, TProps] | _AsyncMethodComponent[TSelf, TProps],
247 | ) -> Callable[[TSelf, TProps], SyncComponent] | Callable[[TSelf, TProps], AsyncComponent]:
248 | """
249 | Decorator that converts the decorated method into one that must be called with
250 | the method component's properties and returns a component instance.
251 |
252 | If used on an async method, the resulting component will also be async;
253 | otherwise it will be sync.
254 |
255 | Example:
256 |
257 | ```python
258 | @dataclass
259 | class MyBusinessObject:
260 | message: str
261 |
262 | @component.method
263 | def paragraph(self, props: int, context: Context) -> Component:
264 | return html.p(f"{self.message} {props}")
265 |
266 |
267 | async def render() -> str:
268 | return await Renderer().render(
269 | MyBusinessObject("Hi!").paragraph(42)
270 | )
271 | ```
272 |
273 | Arguments:
274 | func: The decorated method.
275 |
276 | Returns:
277 | A method that must be called with the method component's properties and
278 | returns a component instance. (Or loosly speaking, an `HTMYComponentType` which
279 | can be "instantiated" with the method component's properties.)
280 | """
281 | if iscoroutinefunction(func):
282 |
283 | def async_wrapper(self: TSelf, props: TProps) -> AsyncComponent:
284 | # This function must be async, in case the renderer inspects it to decide how to handle it.
285 | async def component(context: Context) -> Component:
286 | return await func(self, props, context) # type: ignore[no-any-return]
287 |
288 | component.htmy = component # type: ignore[attr-defined]
289 | return component # type: ignore[return-value]
290 |
291 | return async_wrapper
292 | else:
293 |
294 | def sync_wrapper(self: TSelf, props: TProps) -> SyncComponent:
295 | def component(context: Context) -> Component:
296 | return func(self, props, context) # type: ignore[return-value]
297 |
298 | component.htmy = component # type: ignore[attr-defined]
299 | return component # type: ignore[return-value]
300 |
301 | return sync_wrapper
302 |
303 | # -- Context-only function component decorator.
304 |
305 | @overload
306 | def context_only_method(
307 | self, func: _SyncFunctionComponent[TSelf]
308 | ) -> Callable[[TSelf], SyncComponent]: ...
309 |
310 | @overload
311 | def context_only_method(
312 | self, func: _AsyncFunctionComponent[TSelf]
313 | ) -> Callable[[TSelf], AsyncComponent]: ...
314 |
315 | def context_only_method(
316 | self,
317 | func: _SyncFunctionComponent[TSelf] | _AsyncFunctionComponent[TSelf],
318 | ) -> Callable[[TSelf], SyncComponent] | Callable[[TSelf], AsyncComponent]:
319 | """
320 | Decorator that converts the decorated method into one that must be called
321 | without any arguments and returns a component instance.
322 |
323 | If used on an async method, the resulting component will also be async;
324 | otherwise it will be sync.
325 |
326 | Example:
327 |
328 | ```python
329 | @dataclass
330 | class MyBusinessObject:
331 | message: str
332 |
333 | @component.context_only_method
334 | def paragraph(self, context: Context) -> Component:
335 | return html.p(f"{self.message} Goodbye!")
336 |
337 |
338 | async def render() -> str:
339 | return await Renderer().render(
340 | MyBusinessObject("Hello!").paragraph()
341 | )
342 | ```
343 |
344 | Arguments:
345 | func: The decorated method.
346 |
347 | Returns:
348 | A method that must be called without any arguments and returns a component instance.
349 | (Or loosly speaking, an `HTMYComponentType` which can be "instantiated" by calling
350 | the method.)
351 | """
352 | # A context only method component must be implemented in the same way as
353 | # a function component. The self argument replaces the props argument
354 | # and it is added automatically by Python when the method is called.
355 | # Even the type hint must be the same.
356 | # This implementation doesn't make the function itself a component though,
357 | # so the call signature is always necessary (unlike for context-only function
358 | # components).
359 | return self(func)
360 |
361 |
362 | component = ComponentDecorators()
363 | """
364 | Decorators for converting functions into components
365 |
366 | This is an instance of `ComponentDecorators`.
367 | """
368 |
--------------------------------------------------------------------------------