├── htmy ├── py.typed ├── md │ ├── __init__.py │ ├── typing.py │ └── core.py ├── io.py ├── renderer │ ├── __init__.py │ ├── context.py │ ├── typing.py │ ├── baseline.py │ └── default.py ├── utils.py ├── __init__.py ├── error_boundary.py ├── tag.py ├── typing.py ├── etree.py ├── i18n.py ├── core.py ├── snippet.py └── function_component.py ├── tests ├── __init__.py ├── data │ ├── locale │ │ ├── en │ │ │ └── page │ │ │ │ ├── overview.json │ │ │ │ └── welcome.json │ │ └── hu │ │ │ └── page │ │ │ └── welcome.json │ ├── hello-world-snippet.html │ └── blog-post.md ├── utils.py ├── renderer │ ├── test_streaming_renderers.py │ ├── test_renderer_in_context.py │ ├── test_default_renderer.py │ └── test_renderer_comparison.py ├── conftest.py ├── test_formatter.py ├── test_i18n.py ├── test_main_components.py ├── test_function_component.py ├── test_snippet.py └── test_md.py ├── .github ├── funding.yml └── workflows │ ├── tests.yml │ ├── linters.yml │ ├── build-docs.yml │ └── publish.yml ├── docs ├── api │ ├── core.md │ ├── etree.md │ ├── html.md │ ├── i18n.md │ ├── typing.md │ ├── utils.md │ ├── snippet.md │ ├── renderer │ │ ├── default.md │ │ ├── typing.md │ │ └── baseline.md │ ├── function_component.md │ └── md.md ├── examples │ ├── index.md │ ├── internationalization.md │ ├── fastapi-htmx-tailwind-daisyui.md │ ├── snippet-slots-fastapi.md │ └── markdown.md ├── components-guide.md └── function-components.md ├── examples ├── internationalization │ ├── locale │ │ └── en │ │ │ └── page │ │ │ └── hello.json │ ├── __init__.py │ └── app.py ├── markdown_customization │ ├── __init__.py │ ├── post.md │ └── app.py ├── markdown_essentials │ ├── __init__.py │ ├── post.md │ └── app.py └── snippet-slots-fastapi │ ├── requirements.txt │ ├── centered.html │ ├── layout.html │ └── app.py ├── LICENSE ├── mkdocs.yaml ├── pyproject.toml └── .gitignore /htmy/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: volfpeter 2 | -------------------------------------------------------------------------------- /tests/data/locale/en/page/overview.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Overview" 3 | } 4 | -------------------------------------------------------------------------------- /tests/data/locale/hu/page/welcome.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Helló {name}" 3 | } 4 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | tests_root = Path(__file__).parent 4 | -------------------------------------------------------------------------------- /docs/api/core.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.core 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/etree.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.etree 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/html.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.html 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/i18n.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.i18n 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/typing.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.typing 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/utils.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.utils 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /examples/internationalization/locale/en/page/hello.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Hey {name}" 3 | } 4 | -------------------------------------------------------------------------------- /docs/api/snippet.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.snippet 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /examples/internationalization/__init__.py: -------------------------------------------------------------------------------- 1 | # Here only to silence mypy's "duplicate module" complaints. 2 | -------------------------------------------------------------------------------- /examples/markdown_customization/__init__.py: -------------------------------------------------------------------------------- 1 | # Here only to silence mypy's "duplicate module" complaints. 2 | -------------------------------------------------------------------------------- /examples/markdown_essentials/__init__.py: -------------------------------------------------------------------------------- 1 | # Here only to silence mypy's "duplicate module" complaints. 2 | -------------------------------------------------------------------------------- /docs/api/renderer/default.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.renderer.default 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/renderer/typing.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.renderer.typing 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /examples/snippet-slots-fastapi/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.115.0 2 | fasthx>=2.2.0 3 | htmy>=0.5.0 4 | uvicorn>=0.34.0 -------------------------------------------------------------------------------- /tests/data/locale/en/page/welcome.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Hello {name}", 3 | "message": "Welcome back." 4 | } 5 | -------------------------------------------------------------------------------- /docs/api/function_component.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.function_component 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /docs/api/renderer/baseline.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.renderer.baseline 2 | 3 | options: 4 | show_root_heading: true 5 | -------------------------------------------------------------------------------- /examples/snippet-slots-fastapi/centered.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /tests/data/hello-world-snippet.html: -------------------------------------------------------------------------------- 1 |
2 | Hello World! 3 | The quick brown fox jumps over the lazy dog. 4 |
5 | -------------------------------------------------------------------------------- /docs/api/md.md: -------------------------------------------------------------------------------- 1 | # ::: htmy.md.core 2 | 3 | options: 4 | show_root_heading: true 5 | 6 | # ::: htmy.md.typing 7 | 8 | options: 9 | show_root_heading: true 10 | -------------------------------------------------------------------------------- /htmy/md/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import MD as MD 2 | from .core import MarkdownParser as MarkdownParser 3 | from .typing import MarkdownMetadataDict as MarkdownMetadataDict 4 | from .typing import MarkdownParserFunction as MarkdownParserFunction 5 | from .typing import MarkdownRenderFunction as MarkdownRenderFunction 6 | from .typing import ParsedMarkdown as ParsedMarkdown 7 | -------------------------------------------------------------------------------- /examples/markdown_essentials/post.md: -------------------------------------------------------------------------------- 1 | # Essential reading 2 | 3 | ```python 4 | import this 5 | ``` 6 | 7 | Also available [here](https://peps.python.org/pep-0020/). 8 | 9 | Inline `code` is **also** _fine_. 10 | 11 | # Lists 12 | 13 | ## Ordered 14 | 15 | 1. First 16 | 2. Second 17 | 3. Third 18 | 19 | ## Unordered 20 | 21 | - First 22 | - Second 23 | - Third 24 | -------------------------------------------------------------------------------- /docs/examples/index.md: -------------------------------------------------------------------------------- 1 | Tutorials and examples for various features of the library. 2 | 3 | For complete, working examples, see the [examples folder](https://github.com/volfpeter/htmy/tree/main/examples) in the repo. 4 | 5 | **External examples:** 6 | 7 | - [lipsum-chat](https://github.com/volfpeter/lipsum-chat): A simple chat application using `FastAPI`, `htmx`, and `fasthx`. 8 | -------------------------------------------------------------------------------- /tests/data/blog-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown 3 | --- 4 | 5 | # Essential reading 6 | 7 | ```python 8 | import this 9 | ``` 10 | 11 | Also available [here](https://peps.python.org/pep-0020/). 12 | 13 | Inline `code` is **also** _fine_. 14 | 15 | # Lists 16 | 17 | ## Ordered 18 | 19 | 1. First 20 | 2. Second 21 | 3. Third 22 | 23 | ## Unordered 24 | 25 | - First 26 | - Second 27 | - Third 28 | -------------------------------------------------------------------------------- /examples/markdown_essentials/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from htmy import Renderer, md 4 | 5 | 6 | async def render_post() -> None: 7 | md_post = md.MD("post.md") # Create an htmy.md.MD component. 8 | rendered = await Renderer().render(md_post) # Render the MD component. 9 | print(rendered) # Print the result. 10 | 11 | 12 | if __name__ == "__main__": 13 | asyncio.run(render_post()) 14 | -------------------------------------------------------------------------------- /htmy/io.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from anyio import open_file as open_file 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | 10 | 11 | async def load_text_file(path: str | Path) -> str: 12 | """Loads the text content from the given path.""" 13 | async with await open_file(path, "r") as f: 14 | return await f.read() 15 | -------------------------------------------------------------------------------- /examples/markdown_customization/post.md: -------------------------------------------------------------------------------- 1 | # Essential reading 2 | 3 | 4 | 5 | ```python 6 | import this 7 | ``` 8 | 9 | Also available [here](https://peps.python.org/pep-0020/). 10 | 11 | Inline `code` is **also** _fine_. 12 | 13 | # Lists 14 | 15 | ## Ordered 16 | 17 | 1. First 18 | 2. Second 19 | 3. Third 20 | 21 | ## Unordered 22 | 23 | - First 24 | - Second 25 | - Third 26 | -------------------------------------------------------------------------------- /tests/renderer/test_streaming_renderers.py: -------------------------------------------------------------------------------- 1 | from htmy.renderer.typing import RendererType, is_streaming_renderer 2 | 3 | 4 | def test_is_streaming_renderer( 5 | baseline_renderer: RendererType, default_renderer: RendererType, streaming_renderer: RendererType 6 | ) -> None: 7 | assert is_streaming_renderer(streaming_renderer) 8 | assert is_streaming_renderer(baseline_renderer) 9 | assert not is_streaming_renderer(default_renderer) 10 | -------------------------------------------------------------------------------- /htmy/renderer/__init__.py: -------------------------------------------------------------------------------- 1 | from .baseline import Renderer as _BaselineRenderer 2 | from .default import Renderer as _DefaultRenderer 3 | from .typing import is_renderer as is_renderer 4 | from .typing import is_streaming_renderer as is_streaming_renderer 5 | 6 | Renderer = _DefaultRenderer 7 | """The default renderer.""" 8 | 9 | StreamingRenderer = _BaselineRenderer 10 | """The default streaming renderer.""" 11 | 12 | BaselineRenderer = _BaselineRenderer 13 | """The baseline renderer.""" 14 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from htmy.renderer import BaselineRenderer, Renderer, StreamingRenderer 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def default_renderer() -> Renderer: 8 | return Renderer() 9 | 10 | 11 | @pytest.fixture(scope="session") 12 | def baseline_renderer() -> BaselineRenderer: 13 | return BaselineRenderer() 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def streaming_renderer() -> StreamingRenderer: 18 | return StreamingRenderer() 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | Tests: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v5 18 | 19 | - name: Install the latest version of uv 20 | uses: astral-sh/setup-uv@v6 21 | with: 22 | enable-cache: true 23 | 24 | - name: Setup Python 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version-file: pyproject.toml 28 | 29 | - name: Install dependencies 30 | run: uv sync 31 | 32 | - name: Run tests 33 | run: uv run poe test 34 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | Linters: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v5 18 | 19 | - name: Install the latest version of uv 20 | uses: astral-sh/setup-uv@v6 21 | with: 22 | enable-cache: true 23 | 24 | - name: Setup Python 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version-file: pyproject.toml 28 | 29 | - name: Install dependencies 30 | run: uv sync 31 | 32 | - name: Run checks 33 | run: uv run poe check 34 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build documentation 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | jobs: 11 | deploy: 12 | environment: 13 | name: github-pages 14 | url: ${{ steps.deployment.outputs.page_url }} 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v5 18 | - uses: actions/configure-pages@v5 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.x 22 | - run: pip install zensical mkdocstrings[python] 23 | - run: zensical build --clean 24 | - uses: actions/upload-pages-artifact@v4 25 | with: 26 | path: site 27 | - uses: actions/deploy-pages@v4 28 | id: deployment 29 | -------------------------------------------------------------------------------- /htmy/md/typing.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict 3 | 4 | from htmy.typing import Component 5 | 6 | if TYPE_CHECKING: 7 | from typing_extensions import NotRequired 8 | else: 9 | from typing import Optional as NotRequired 10 | 11 | MarkdownMetadataDict: TypeAlias = dict[str, Any] 12 | 13 | 14 | class ParsedMarkdown(TypedDict): 15 | """Type definition for parsed markdown data.""" 16 | 17 | content: str 18 | metadata: NotRequired[MarkdownMetadataDict | None] 19 | 20 | 21 | MarkdownParserFunction: TypeAlias = Callable[[str], ParsedMarkdown] 22 | """Callable that converts a markdown string into a `ParsedMarkdown` object.""" 23 | 24 | MarkdownRenderFunction: TypeAlias = Callable[[Component, MarkdownMetadataDict | None], Component] 25 | """Renderer function definition for markdown data.""" 26 | -------------------------------------------------------------------------------- /tests/renderer/test_renderer_in_context.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from htmy import Context, component 4 | from htmy.renderer import BaselineRenderer, Renderer 5 | from htmy.renderer.context import RendererContext 6 | from htmy.renderer.typing import RendererType 7 | 8 | 9 | @pytest.mark.asyncio 10 | @pytest.mark.parametrize( 11 | "renderer", 12 | [ 13 | BaselineRenderer(), 14 | BaselineRenderer({RendererContext: "not-the-renderer"}), 15 | Renderer(), 16 | Renderer({RendererContext: "not-the-renderer"}), 17 | ], 18 | ) 19 | async def test_renderer_in_context(renderer: RendererType) -> None: 20 | @component.context_only 21 | def validate_context(context: Context) -> str: 22 | assert context[RendererContext] is renderer 23 | return "success" 24 | 25 | assert await renderer.render(validate_context()) == "success" 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | pypi-publish: 10 | name: Upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: release 13 | permissions: 14 | id-token: write 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v5 18 | 19 | - name: Install the latest version of uv 20 | uses: astral-sh/setup-uv@v6 21 | with: 22 | enable-cache: true 23 | 24 | - name: Setup Python 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version-file: pyproject.toml 28 | 29 | - name: Install dependencies 30 | run: uv sync 31 | 32 | - name: Build package 33 | run: uv build 34 | 35 | - name: Publish package distributions to PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | -------------------------------------------------------------------------------- /htmy/renderer/context.py: -------------------------------------------------------------------------------- 1 | from htmy.typing import Context 2 | 3 | from .typing import RendererType, is_renderer 4 | 5 | 6 | class RendererContext: 7 | """ 8 | Context key for storing, and utility for retrieving the renderer instance from the rendering context. 9 | """ 10 | 11 | @classmethod 12 | def from_context(cls, context: Context) -> RendererType: 13 | """ 14 | Returns the renderer instance from the given context. 15 | 16 | Arguments: 17 | context: The context the renderer instance should be loaded from. 18 | 19 | Raises: 20 | KeyError: If no renderer instance was found in the context. 21 | TypeError: If the value corresponding to `RendererContext` in the context is not a renderer. 22 | """ 23 | renderer = context[cls] 24 | 25 | if not is_renderer(renderer): 26 | raise TypeError("The value corresponding to RendererContext in the context must be a renderer.") 27 | 28 | return renderer 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Peter Volf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/renderer/test_default_renderer.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from htmy import Context, Slots, Snippet, Text, component 6 | from htmy.renderer.typing import RendererType 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_async_children_of_async_node( 11 | baseline_renderer: RendererType, 12 | default_renderer: RendererType, 13 | streaming_renderer: RendererType, 14 | ) -> None: 15 | @dataclass 16 | class Content: 17 | message: str 18 | 19 | async def htmy(self, ctx: Context) -> str: 20 | return self.message 21 | 22 | @component 23 | async def fc_content(message: str, ctx: Context) -> str: 24 | return message 25 | 26 | snippet = Snippet( 27 | Text(" "), 28 | Slots( 29 | { 30 | "content": Content("async slot content"), 31 | "fc-content": fc_content("async fc slot content"), 32 | } 33 | ), 34 | ) 35 | rendered = await baseline_renderer.render(snippet) 36 | assert rendered == "async slot content async fc slot content" 37 | 38 | rendered = await default_renderer.render(snippet) 39 | assert rendered == "async slot content async fc slot content" 40 | 41 | rendered = await streaming_renderer.render(snippet) 42 | assert rendered == "async slot content async fc slot content" 43 | -------------------------------------------------------------------------------- /examples/snippet-slots-fastapi/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Snippet with Slots 5 | 6 | 7 | 8 | 9 | 10 |
11 |

12 | Technologies: 13 | htmy, 18 | FastHX, 23 | FastAPI, 28 | TailwindCSSv4. 33 |

34 |
35 | 36 |
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /examples/internationalization/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from htmy import Component, Context, Renderer, html 5 | from htmy.i18n import I18n 6 | 7 | 8 | class TranslatedComponent: 9 | """Component with translated content.""" 10 | 11 | async def htmy(self, context: Context) -> Component: 12 | # Get the I18n instance from the rendering context. 13 | i18n = I18n.from_context(context) 14 | # Get the translated message. 15 | # The translation file can referenced with a dotted path. 16 | # The second argument is the requested key in the translation file. 17 | # Keyword arguments can be used for automatic string formatting. 18 | message = await i18n.get("page.hello", "message", name="Joe") 19 | # And return component's content. 20 | return html.p(message) 21 | 22 | 23 | base_folder = Path(__file__).parent 24 | """The folder where the app and all its content live.""" 25 | 26 | i18n = I18n(base_folder / "locale" / "en") 27 | """ 28 | The `I18n` instance that we can add to the rendering context. 29 | 30 | It takes translations from the `locale/en` folder. 31 | """ 32 | 33 | 34 | async def render_hello() -> None: 35 | rendered = await Renderer().render( 36 | # Render a TranslatedComponent. 37 | TranslatedComponent(), 38 | # Include the created I18n instance in the rendering context. 39 | i18n.to_context(), 40 | ) 41 | print(rendered) 42 | 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(render_hello()) 46 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from htmy import Formatter 6 | 7 | 8 | @pytest.fixture 9 | def formatter() -> Formatter: 10 | return Formatter() 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("data", "formatted_value"), 15 | ( 16 | ({}, "{}"), 17 | ([], "[]"), 18 | ((), "[]"), 19 | (set(), "[]"), 20 | ( 21 | {"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}}, 22 | '{"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}}', 23 | ), 24 | (["string", 3.14, {"key": "value"}], '["string", 3.14, {"key": "value"}]'), 25 | (("string", 3.14, {"key": "value"}), '["string", 3.14, {"key": "value"}]'), 26 | ({"c0ff33"}, '["c0ff33"]'), 27 | ), 28 | ) 29 | def test_complex_value_formatting(data: Any, formatted_value: str, formatter: Formatter) -> None: 30 | assert formatter.format_value(data) == formatted_value 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("data", "formatted_value"), 35 | ( 36 | ({}, '"{}"'), 37 | ([], '"[]"'), 38 | ((), '"[]"'), 39 | (set(), '"[]"'), 40 | ( 41 | {"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}}, 42 | '\'{"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}}\'', 43 | ), 44 | (["string", 3.14, {"key": "value"}], '\'["string", 3.14, {"key": "value"}]\''), 45 | (("string", 3.14, {"key": "value"}), '\'["string", 3.14, {"key": "value"}]\''), 46 | ({"c0ff33"}, "'[\"c0ff33\"]'"), 47 | ), 48 | ) 49 | def test_complex_property_formatting(data: Any, formatted_value: str, formatter: Formatter) -> None: 50 | assert formatter.format("property", data) == f"property={formatted_value}" 51 | -------------------------------------------------------------------------------- /htmy/renderer/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import AsyncIterator 4 | from typing import TYPE_CHECKING, Protocol 5 | 6 | if TYPE_CHECKING: 7 | from typing import Any, TypeGuard 8 | 9 | from htmy.typing import Component, Context 10 | 11 | 12 | class RendererType(Protocol): 13 | """Protocol definition for renderers.""" 14 | 15 | async def render(self, component: Component, context: Context | None = None) -> str: 16 | """ 17 | Renders the given component. 18 | 19 | Arguments: 20 | component: The component to render. 21 | context: An optional rendering context. 22 | 23 | Returns: 24 | The rendered string. 25 | """ 26 | ... 27 | 28 | 29 | def is_renderer(obj: Any | None) -> TypeGuard[RendererType]: 30 | """Type guard that checks if the given object is a renderer.""" 31 | # Just a basic check, don't waste time here. 32 | render: Any = getattr(obj, "render", None) 33 | return render is not None 34 | 35 | 36 | class StreamingRendererType(RendererType, Protocol): 37 | """Protocol definition for streaming renderers.""" 38 | 39 | def stream(self, component: Component, context: Context | None = None) -> AsyncIterator[str]: 40 | """ 41 | Async iterator that renders the given component. 42 | 43 | Arguments: 44 | component: The component to render. 45 | context: An optional rendering context. 46 | 47 | Yields: 48 | The rendered strings. 49 | """ 50 | ... 51 | 52 | 53 | def is_streaming_renderer(obj: Any | None) -> TypeGuard[StreamingRendererType]: 54 | """Type guard that checks if the given object is a streaming renderer.""" 55 | if not is_renderer(obj): 56 | return False 57 | 58 | # Just a basic check, don't waste time here. 59 | stream: Any = getattr(obj, "stream", None) 60 | return stream is not None 61 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: htmy 2 | repo_name: volfpeter/htmy 3 | repo_url: https://github.com/volfpeter/htmy 4 | 5 | theme: 6 | name: modern 7 | palette: 8 | # Palette toggle for automatic mode 9 | - media: "(prefers-color-scheme)" 10 | scheme: slate 11 | toggle: 12 | icon: lucide/sun-moon 13 | name: Switch to light mode 14 | # Palette toggle for light mode 15 | - media: "(prefers-color-scheme: light)" 16 | scheme: default 17 | toggle: 18 | icon: lucide/sun 19 | name: Switch to dark mode 20 | # Palette toggle for dark mode 21 | - media: "(prefers-color-scheme: dark)" 22 | scheme: slate 23 | toggle: 24 | icon: lucide/moon 25 | name: Switch to system preference 26 | features: 27 | - navigation.indexes 28 | - navigation.instant 29 | - navigation.tracking 30 | - search.suggest 31 | - search.highlight 32 | 33 | plugins: 34 | - search: 35 | lang: en 36 | - mkdocstrings: 37 | handlers: 38 | python: 39 | paths: [.] 40 | 41 | markdown_extensions: 42 | - pymdownx.highlight: 43 | anchor_linenums: true 44 | line_spans: __span 45 | pygments_lang_class: true 46 | - pymdownx.inlinehilite 47 | - pymdownx.snippets 48 | - pymdownx.superfences 49 | 50 | nav: 51 | - index.md 52 | - components-guide.md 53 | - function-components.md 54 | - Examples: 55 | - Examples: examples/index.md 56 | - examples/snippet-slots-fastapi.md 57 | - examples/fastapi-htmx-tailwind-daisyui.md 58 | - examples/markdown.md 59 | - examples/internationalization.md 60 | - API reference: 61 | - api/core.md 62 | - api/html.md 63 | - api/snippet.md 64 | - api/md.md 65 | - api/function_component.md 66 | - api/i18n.md 67 | - renderer: 68 | - api/renderer/default.md 69 | - api/renderer/baseline.md 70 | - api/renderer/typing.md 71 | - api/utils.md 72 | - api/typing.md 73 | - api/etree.md 74 | -------------------------------------------------------------------------------- /htmy/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from typing import TYPE_CHECKING, TypeGuard 5 | 6 | if TYPE_CHECKING: 7 | from .typing import Component, ComponentSequence, ComponentType 8 | 9 | 10 | def join_components( 11 | components: ComponentSequence, 12 | separator: ComponentType, 13 | pad: bool = False, 14 | ) -> Generator[ComponentType, None, None]: 15 | """ 16 | Joins the given components using the given separator. 17 | 18 | Arguments: 19 | components: The components to join. 20 | separator: The separator to use. 21 | pad: Whether to add a separator before the first and after the last components. 22 | """ 23 | if len(components) == 0: 24 | return 25 | 26 | if pad: 27 | yield separator 28 | 29 | components_iterator = iter(components) 30 | yield next(components_iterator) 31 | 32 | for component in components_iterator: 33 | yield separator 34 | yield component 35 | 36 | if pad: 37 | yield separator 38 | 39 | 40 | def join(*items: str | None, separator: str = " ") -> str: 41 | """ 42 | Joins the given strings with the given separator, skipping `None` values. 43 | """ 44 | return separator.join(i for i in items if i) 45 | 46 | 47 | def is_component_sequence(comp: Component) -> TypeGuard[ComponentSequence]: 48 | """Returns whether the given component is a component sequence.""" 49 | return isinstance(comp, (list, tuple)) 50 | 51 | 52 | def as_component_sequence(comp: Component) -> ComponentSequence: 53 | """Returns the given component as a component sequence.""" 54 | # mypy doesn't understand the `is_component_sequence` type guard. 55 | return comp if is_component_sequence(comp) else (comp,) # type: ignore[return-value] 56 | 57 | 58 | def as_component_type(comp: Component) -> ComponentType: 59 | """Returns the given component as a `ComponentType` (not sequence).""" 60 | from .core import Fragment 61 | 62 | # mypy doesn't understand the `is_component_sequence` type guard. 63 | return comp if not is_component_sequence(comp) else Fragment(*comp) # type: ignore[return-value] 64 | -------------------------------------------------------------------------------- /htmy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.10.1" 2 | 3 | from .core import ContextAware as ContextAware 4 | from .core import Formatter as Formatter 5 | from .core import Fragment as Fragment 6 | from .core import SafeStr as SafeStr 7 | from .core import SkipProperty as SkipProperty 8 | from .core import Text as Text 9 | from .core import WithContext as WithContext 10 | from .core import XBool as XBool 11 | from .core import xml_format_string as xml_format_string 12 | from .error_boundary import ErrorBoundary as ErrorBoundary 13 | from .function_component import component as component 14 | from .renderer import Renderer as Renderer 15 | from .renderer import StreamingRenderer as StreamingRenderer 16 | from .renderer.typing import RendererType as RendererType 17 | from .snippet import Slots as Slots 18 | from .snippet import Snippet as Snippet 19 | from .tag import Tag as Tag 20 | from .tag import TagWithProps as TagWithProps 21 | from .tag import wildcard_tag as wildcard_tag 22 | from .typing import AsyncComponent as AsyncComponent 23 | from .typing import AsyncContextProvider as AsyncContextProvider 24 | from .typing import Component as Component 25 | from .typing import ComponentSequence as ComponentSequence 26 | from .typing import ComponentType as ComponentType 27 | from .typing import Context as Context 28 | from .typing import ContextKey as ContextKey 29 | from .typing import ContextProvider as ContextProvider 30 | from .typing import ContextValue as ContextValue 31 | from .typing import HTMYComponentType as HTMYComponentType 32 | from .typing import MutableContext as MutableContext 33 | from .typing import Properties as Properties 34 | from .typing import PropertyValue as PropertyValue 35 | from .typing import StrictComponentType as StrictComponentType 36 | from .typing import SyncComponent as SyncComponent 37 | from .typing import SyncContextProvider as SyncContextProvider 38 | from .utils import as_component_sequence as as_component_sequence 39 | from .utils import as_component_type as as_component_type 40 | from .utils import is_component_sequence as is_component_sequence 41 | from .utils import join 42 | from .utils import join_components as join_components 43 | 44 | join_classes = join 45 | 46 | HTMY = Renderer 47 | """Deprecated alias for `Renderer`.""" 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "htmy" 3 | dynamic = ["version"] 4 | description = "Async, pure-Python server-side rendering engine." 5 | authors = [{ name = "Peter Volf", email = "do.volfp@gmail.com" }] 6 | license = "MIT" 7 | readme = "README.md" 8 | requires-python = ">=3.10" 9 | dependencies = [ 10 | "anyio>=4.7.0,<5", 11 | "async-lru>=2.0.5,<3", 12 | "markdown>=3.8,<4", 13 | ] 14 | 15 | [project.optional-dependencies] 16 | lxml = ["lxml>=6.0.0"] 17 | 18 | [dependency-groups] 19 | dev = [ 20 | "fastapi>=0.116.0", 21 | "fasthx>=3.0.0", 22 | "mkdocstrings[python]>=0.30.0", 23 | "mypy>=1.19.0,<2", 24 | "poethepoet>=0.38.0", 25 | "pytest>=9.0.2", 26 | "pytest-asyncio>=1.3.0", 27 | "pytest-random-order>=1.2.0", 28 | "ruff>=0.14.8,<0.15", 29 | "types-markdown>=3.8.0.20250809,<4", 30 | "typing-extensions>=4.12.2,<5", 31 | "types-lxml>=2025.3.30", 32 | "zensical>=0.0.11", 33 | ] 34 | 35 | [build-system] 36 | requires = ["pdm-backend"] 37 | build-backend = "pdm.backend" 38 | 39 | [tool.mypy] 40 | strict = true 41 | show_error_codes = true 42 | 43 | [tool.pdm.build] 44 | excludes = ["tests/", "examples/"] 45 | 46 | [tool.pdm.version] 47 | source = "file" 48 | path = "htmy/__init__.py" 49 | 50 | [tool.pyright] 51 | venvPath = "." 52 | venv = ".venv" 53 | 54 | [tool.pytest.ini_options] 55 | addopts = "--random-order" 56 | 57 | [tool.ruff] 58 | line-length = 108 59 | exclude = [ 60 | ".git", 61 | ".mypy_cache", 62 | ".pytest_cache", 63 | ".ruff_cache", 64 | ".venv", 65 | "dist", 66 | "docs", 67 | ] 68 | lint.select = [ 69 | "B", # flake8-bugbear 70 | "C", # flake8-comprehensions 71 | "E", # pycodestyle errors 72 | "F", # pyflakes 73 | "I", # isort 74 | "S", # flake8-bandit - we must ignore these rules in tests 75 | "W", # pycodestyle warnings 76 | ] 77 | 78 | [tool.ruff.lint.per-file-ignores] 79 | "tests/**/*" = ["S101"] # S101: use of assert detected 80 | 81 | [tool.poe.tasks] 82 | format = "ruff format --check ." 83 | format-fix = "ruff format ." 84 | 85 | lint = "ruff check ." 86 | lint-fix = "ruff . --fix" 87 | 88 | mypy = "mypy ." 89 | 90 | check.sequence = ["format", "lint", "mypy"] 91 | check.ignore_fail = "return_non_zero" 92 | 93 | test = "python -m pytest tests" 94 | 95 | serve-docs = "zensical serve" 96 | -------------------------------------------------------------------------------- /examples/snippet-slots-fastapi/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fasthx.htmy import HTMY, CurrentRequest 3 | 4 | from htmy import ComponentType, Context, Fragment, Slots, Snippet, html 5 | 6 | 7 | def layout(*children: ComponentType) -> Snippet: 8 | """ 9 | Creates a `Snippet` that's configured to render `layout.html` with the given children 10 | components replacing the `content` slot. 11 | """ 12 | return Snippet( 13 | "layout.html", # Path to the HTML snippet. 14 | Slots({"content": children}), # Render all children in the "content" slot. 15 | ) 16 | 17 | 18 | class Centered(Fragment): 19 | """Component that centers its children both vertically and horizontally.""" 20 | 21 | def htmy(self, context: Context) -> Snippet: 22 | return Snippet( 23 | "centered.html", # Path to the HTML snippet. 24 | Slots({"content": self._children}), # Render all children in the "content" slot. 25 | ) 26 | 27 | 28 | class RequestHeaders: 29 | """Component that displays all the headers in the current request.""" 30 | 31 | def htmy(self, context: Context) -> ComponentType: 32 | # Load the current request from the context. 33 | request = CurrentRequest.from_context(context) 34 | return html.div( 35 | html.h2("Request headers:", class_="text-lg font-semibold pb-2"), 36 | html.div( 37 | *( 38 | # Convert header name and value pairs to fragments. 39 | Fragment(html.label(name + ":"), html.label(value)) 40 | for name, value in request.headers.items() 41 | ), 42 | class_="grid grid-cols-[max-content_1fr] gap-2", 43 | ), 44 | ) 45 | 46 | 47 | def index_page(_: None) -> Snippet: 48 | """ 49 | Component factory that returns the index page. 50 | 51 | Note that this function is not an `htmy` component at all, just a 52 | component factory that `fasthx` decorators can resolve. It must 53 | accept a single argument (the return value of the route) and return 54 | the component(s) that should be rendered. 55 | """ 56 | return layout(Centered(RequestHeaders())) 57 | 58 | 59 | app = FastAPI() 60 | """The FastAPI application.""" 61 | 62 | htmy = HTMY() 63 | """ 64 | The `HTMY` instance (from `FastHX`) that takes care of component rendering 65 | through its route decorators. 66 | """ 67 | 68 | 69 | @app.get("/") 70 | @htmy.page(index_page) 71 | async def index() -> None: 72 | """The index route. It has no business logic, so it can remain empty.""" 73 | ... 74 | -------------------------------------------------------------------------------- /htmy/error_boundary.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from .core import SafeStr 6 | from .renderer.context import RendererContext 7 | from .utils import as_component_type 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Container 11 | 12 | from .typing import Component, ComponentType, Context 13 | 14 | 15 | class ErrorBoundary: 16 | """ 17 | Error boundary component for graceful error handling. 18 | 19 | If an error occurs during the rendering of the error boundary's subtree, 20 | the fallback component will be rendered instead. 21 | """ 22 | 23 | __slots__ = ("_children", "_errors", "_fallback") 24 | 25 | def __init__( 26 | self, 27 | *children: ComponentType, 28 | fallback: Component = None, 29 | errors: Container[type[Exception]] | None = None, 30 | ) -> None: 31 | """ 32 | Initialization. 33 | 34 | Arguments: 35 | *children: The wrapped children components. 36 | fallback: The fallback component to render in case an error occurs during children rendering. 37 | errors: An optional set of accepted error types. Only accepted errors are swallowed and rendered 38 | with the fallback. If an error is not in this set but one of its base classes is, then the 39 | error will still be accepted and the fallback rendered. By default all errors are accepted. 40 | """ 41 | self._children = children 42 | self._errors = errors 43 | self._fallback: Component = fallback 44 | 45 | async def htmy(self, context: Context) -> Component: 46 | renderer = RendererContext.from_context(context) 47 | 48 | try: 49 | result = await renderer.render(self._children, context) 50 | except Exception as e: 51 | result = await renderer.render(self._fallback_component(e), context) 52 | 53 | # We must convert the already rendered string to SafeStr to avoid additional XML escaping. 54 | return SafeStr(result) 55 | 56 | def _fallback_component(self, error: Exception) -> ComponentType: 57 | """ 58 | Returns the fallback component for the given error. 59 | 60 | Arguments: 61 | error: The error that occurred during the rendering of the error boundary's subtree. 62 | 63 | Raises: 64 | Exception: The received error if it's not accepted. 65 | """ 66 | if not (self._errors is None or any(e in self._errors for e in type(error).mro())): 67 | raise error 68 | 69 | return as_component_type(self._fallback) 70 | -------------------------------------------------------------------------------- /tests/test_i18n.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from htmy.i18n import I18n, I18nKeyError, I18nValueError 6 | 7 | from .utils import tests_root 8 | 9 | 10 | class TranslationFile: 11 | overview_page = "page.overview" 12 | welcome_page = "page.welcome" 13 | does_not_exist = "does.not.exist" 14 | 15 | 16 | hu_no_fallback = I18n(tests_root / "data" / "locale" / "hu") 17 | """I18n instance with no fallback.""" 18 | 19 | hu_with_en_fallback = I18n( 20 | tests_root / "data" / "locale" / "hu", # Take translations from locale/hu by default. 21 | tests_root / "data" / "locale" / "en", # Use locale/en as fallback. 22 | ) 23 | """I18n instance that can be used for fallback testing.""" 24 | 25 | 26 | @pytest.mark.asyncio 27 | @pytest.mark.parametrize( 28 | ("dotted_path", "key", "kwargs", "expected"), 29 | ( 30 | ( 31 | TranslationFile.welcome_page, 32 | "title", 33 | {}, 34 | "Helló {name}", # Translated 35 | ), 36 | ( 37 | TranslationFile.welcome_page, 38 | "title", 39 | {"name": "Joe"}, 40 | "Helló Joe", # Translated 41 | ), 42 | ( 43 | TranslationFile.welcome_page, 44 | "message", 45 | {}, 46 | "Welcome back.", # en fallback 47 | ), 48 | ( 49 | TranslationFile.welcome_page, 50 | "", 51 | {}, 52 | {"title": "Helló {name}"}, # Root, return dict 53 | ), 54 | ( 55 | TranslationFile.welcome_page, 56 | ".", 57 | {"name": "Joe"}, 58 | {"title": "Helló {name}"}, # Root, return dict 59 | ), 60 | (TranslationFile.overview_page, "title", {}, "Overview"), 61 | (TranslationFile.overview_page, "", {}, {"title": "Overview"}), 62 | ), 63 | ) 64 | async def test_i18n_with_fallback( 65 | dotted_path: str, key: str, kwargs: dict[str, Any], expected: Any 66 | ) -> None: 67 | result = await hu_with_en_fallback.get(dotted_path, key, **kwargs) 68 | assert result == expected 69 | 70 | 71 | @pytest.mark.asyncio 72 | @pytest.mark.parametrize( 73 | ("dotted_path", "key", "error"), 74 | ( 75 | # No fallback, but key would exist in fallback. 76 | (TranslationFile.welcome_page, "message", I18nKeyError), 77 | # Key doesn't exist. 78 | (TranslationFile.welcome_page, "some-key", I18nKeyError), 79 | # File does not exist. 80 | (TranslationFile.does_not_exist, "title", I18nValueError), 81 | ), 82 | ) 83 | async def test_i18n_missing_resource(dotted_path: str, key: str, error: type[Exception]) -> None: 84 | with pytest.raises(error): 85 | await hu_no_fallback.get(dotted_path, key) 86 | -------------------------------------------------------------------------------- /docs/examples/internationalization.md: -------------------------------------------------------------------------------- 1 | # Internationalization 2 | 3 | The focus of this example is using the built-in `I18n` utility for internationalization. All you need to follow the example is `htmy`, which you can install with `pip install htmy`. 4 | 5 | First of all, we must create some translation resources (plain JSON files). Let's do this by creating the `locale/en/page` folder structure and adding a `hello.json` in the `page` folder with the following content: `{ "message": "Hey {name}" }`. Notice the Python format string in the value for the `"message"` key, such strings can be automatically formatted by `I18n`, see the details in the docs and in the usage example below. 6 | 7 | Using `I18n` consists of only two steps: create an `I18n` instance, and include it in the rendering context so it can be accessed by components in their `htmy()` (render) method. 8 | 9 | With the translation resource in place, we can create the `app.py` file and implement our translated components like this: 10 | 11 | ```python 12 | import asyncio 13 | from pathlib import Path 14 | 15 | from htmy import Component, Context, Renderer, html 16 | from htmy.i18n import I18n 17 | 18 | 19 | class TranslatedComponent: 20 | """Component with translated content.""" 21 | 22 | async def htmy(self, context: Context) -> Component: 23 | # Get the I18n instance from the rendering context. 24 | i18n = I18n.from_context(context) 25 | # Get the translated message. 26 | # The translation file can referenced with a dotted path. 27 | # The second argument is the requested key in the translation file. 28 | # Keyword arguments can be used for automatic string formatting. 29 | message = await i18n.get("page.hello", "message", name="Joe") 30 | # And return component's content. 31 | return html.p(message) 32 | ``` 33 | 34 | Now that we have a component to render, we can create our `I18n` instance and write the async function that renders our content: 35 | 36 | ```python 37 | base_folder = Path(__file__).parent 38 | """The folder where the app and all its content live.""" 39 | 40 | i18n = I18n(base_folder / "locale" / "en") 41 | """ 42 | The `I18n` instance that we can add to the rendering context. 43 | 44 | It takes translations from the `locale/en` folder. 45 | """ 46 | 47 | 48 | async def render_hello() -> None: 49 | rendered = await Renderer().render( 50 | # Render a TranslatedComponent. 51 | TranslatedComponent(), 52 | # Include the created I18n instance in the rendering context. 53 | i18n.to_context(), 54 | ) 55 | print(rendered) 56 | ``` 57 | 58 | Finally we add the usual `asyncio` run call: 59 | 60 | ```python 61 | if __name__ == "__main__": 62 | asyncio.run(render_hello()) 63 | ``` 64 | 65 | With `app.py` and the `locale/en/page/hello.json` translation resource in place, we can finally run the application with `python app.py` and see the translated content in the result. That's it. 66 | -------------------------------------------------------------------------------- /htmy/tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from .core import Formatter, SafeStr 6 | from .utils import join_components 7 | 8 | if TYPE_CHECKING: 9 | from .typing import Component, ComponentSequence, ComponentType, Context, Properties, PropertyValue 10 | 11 | 12 | class _TagWithPropsImpl: 13 | __slots__ = ("name", "props") 14 | 15 | def __init__(self, name: str, props: Properties) -> None: 16 | self.name = name 17 | self.props = props 18 | 19 | def htmy(self, context: Context) -> ComponentType: 20 | formatter: Formatter = context.get(Formatter, _default_formatter) 21 | return SafeStr(f"<{self.name} {' '.join(formatter.format(n, v) for n, v in self.props.items())}/>") 22 | 23 | 24 | class _TagImpl: 25 | __slots__ = ("child_separator", "children", "name", "props") 26 | 27 | def __init__( 28 | self, 29 | name: str, 30 | props: Properties, 31 | children: ComponentSequence, 32 | child_separator: ComponentType, 33 | ) -> None: 34 | self.name = name 35 | self.props = props 36 | self.children = children 37 | self.child_separator = child_separator 38 | 39 | def htmy(self, context: Context) -> Component: 40 | formatter: Formatter = context.get(Formatter, _default_formatter) 41 | name = self.name 42 | return ( 43 | SafeStr(f"<{name} {' '.join(formatter.format(n, v) for n, v in self.props.items())}>"), 44 | *( 45 | self.children 46 | if self.child_separator is None 47 | else join_components(self.children, separator=self.child_separator, pad=True) 48 | ), 49 | SafeStr(f""), 50 | ) 51 | 52 | 53 | _default_formatter = Formatter() 54 | 55 | 56 | class TagWithProps: 57 | """ 58 | Creates a tag type that can have properties, but not children. 59 | 60 | Arguments: 61 | **props: Tag properties. 62 | """ 63 | 64 | __slots__ = ("name",) 65 | 66 | def __init__(self, name: str) -> None: 67 | self.name = name 68 | 69 | def __call__(self, **props: PropertyValue) -> ComponentType: 70 | return _TagWithPropsImpl(self.name, props) 71 | 72 | 73 | class Tag: 74 | __slots__ = ("child_separator", "name") 75 | 76 | def __init__(self, name: str, child_separator: ComponentType = "\n") -> None: 77 | self.name = name 78 | self.child_separator = child_separator 79 | 80 | def __call__(self, *children: ComponentType, **props: PropertyValue) -> ComponentType: 81 | return _TagImpl(self.name, props, children, self.child_separator) 82 | 83 | 84 | def wildcard_tag( 85 | *children: ComponentType, 86 | htmy_name: str, 87 | htmy_child_separator: ComponentType = None, 88 | **props: PropertyValue, 89 | ) -> ComponentType: 90 | """ 91 | Creates a tag that can have both children and properties, and whose tag name and 92 | child separator can be set. 93 | 94 | Arguments: 95 | *children: Children components. 96 | htmy_name: The tag name to use for this tag. 97 | htmy_child_separator: Optional separator component to add between children. 98 | **props: Tag properties. 99 | """ 100 | return _TagImpl(htmy_name, props, children, htmy_child_separator) 101 | -------------------------------------------------------------------------------- /htmy/typing.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Coroutine, Mapping, MutableMapping 2 | from typing import Any, Protocol, TypeAlias, TypeVar 3 | 4 | T = TypeVar("T") 5 | U = TypeVar("U") 6 | 7 | # -- Properties 8 | 9 | PropertyValue: TypeAlias = Any | None 10 | """Component/XML tag property value.""" 11 | 12 | Properties: TypeAlias = Mapping[str, PropertyValue] 13 | """Component/XML tag property mapping.""" 14 | 15 | # -- Context 16 | 17 | ContextKey: TypeAlias = Any 18 | """Context key.""" 19 | 20 | ContextValue: TypeAlias = Any 21 | """Context value.""" 22 | 23 | Context: TypeAlias = Mapping[ContextKey, ContextValue] 24 | """Context mapping.""" 25 | 26 | MutableContext: TypeAlias = MutableMapping[ContextKey, ContextValue] 27 | """ 28 | Mutable context mapping. 29 | 30 | It can be helpful when the created context should be marked as mutable for static type analysis 31 | (usually the created context is a plain `dict`). 32 | """ 33 | 34 | # -- Components 35 | 36 | 37 | class SyncComponent(Protocol): 38 | """Protocol definition for sync `htmy` components.""" 39 | 40 | def htmy(self, context: Context, /) -> "Component": 41 | """Renders the component.""" 42 | ... 43 | 44 | 45 | class AsyncComponent(Protocol): 46 | """Protocol definition for async `htmy` components.""" 47 | 48 | async def htmy(self, context: Context, /) -> "Component": 49 | """Renders the component.""" 50 | ... 51 | 52 | 53 | HTMYComponentType: TypeAlias = SyncComponent | AsyncComponent 54 | """Sync or async `htmy` component type.""" 55 | 56 | StrictComponentType: TypeAlias = HTMYComponentType | str 57 | """Type definition for a single component that's not `None`.""" 58 | 59 | ComponentType: TypeAlias = StrictComponentType | None 60 | """Type definition for a single component.""" 61 | 62 | # Omit strings from this type to simplify checks. 63 | ComponentSequence: TypeAlias = list[ComponentType] | tuple[ComponentType, ...] 64 | """Component sequence type.""" 65 | 66 | Component: TypeAlias = ComponentType | ComponentSequence 67 | """Component type: a single component or a sequence of components.""" 68 | 69 | 70 | # -- Context providers 71 | 72 | 73 | class SyncContextProvider(Protocol): 74 | """Protocol definition for sync context providers.""" 75 | 76 | def htmy_context(self) -> Context: 77 | """Returns a context for child rendering.""" 78 | ... 79 | 80 | 81 | class AsyncContextProvider(Protocol): 82 | """Protocol definition for async context providers.""" 83 | 84 | async def htmy_context(self) -> Context: 85 | """Returns a context for child rendering.""" 86 | ... 87 | 88 | 89 | ContextProvider: TypeAlias = SyncContextProvider | AsyncContextProvider 90 | """ 91 | Sync or async context provider type. 92 | 93 | Components can implement this protocol to add extra data to the rendering context 94 | of their entire component subtree (including themselves). 95 | """ 96 | 97 | # -- Text processors 98 | 99 | TextProcessor: TypeAlias = Callable[[str, Context], str | Coroutine[Any, Any, str]] 100 | """Callable type that expects a string and a context, and returns a processed string.""" 101 | 102 | 103 | class TextResolver(Protocol): 104 | """ 105 | Protocol definition for resolvers that convert a string to a component. 106 | """ 107 | 108 | def resolve_text(self, text: str) -> Component: 109 | """ 110 | Returns the resolved component for the given text. 111 | 112 | Arguments: 113 | text: The text to resolve. 114 | 115 | Raises: 116 | KeyError: If the text cannot be resolved to a component. 117 | """ 118 | ... 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # zensical documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # uv 163 | uv.lock 164 | 165 | # Place for dev notes and scripts 166 | /dev 167 | -------------------------------------------------------------------------------- /examples/markdown_customization/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from htmy import Component, ComponentType, Context, PropertyValue, Renderer, etree, html, md 4 | 5 | 6 | class Page: 7 | """Page component that creates the basic HTML layout.""" 8 | 9 | def __init__(self, *children: ComponentType) -> None: 10 | """ 11 | Arguments: 12 | *children: The page content. 13 | """ 14 | self.children = children 15 | 16 | def htmy(self, context: Context) -> Component: 17 | return ( 18 | html.DOCTYPE.html, 19 | html.html( 20 | html.head( 21 | # Some metadata 22 | html.title("Markdown example"), 23 | html.Meta.charset(), 24 | html.Meta.viewport(), 25 | # TailwindCSS import 26 | html.script(src="https://cdn.tailwindcss.com"), 27 | ), 28 | html.body( 29 | *self.children, 30 | class_="h-screen w-screen p-8", 31 | ), 32 | ), 33 | ) 34 | 35 | 36 | class PostInfo: 37 | """Component for post info rendering.""" 38 | 39 | def __init__(self, author: str, published_at: str) -> None: 40 | self.author = author 41 | self.published_at = published_at 42 | 43 | def htmy(self, context: Context) -> Component: 44 | return html.p("By ", html.strong(self.author), " at ", html.em(self.published_at), ".") 45 | 46 | 47 | class ConversionRules: 48 | """Conversion rules for some of the HTML elements we can encounter in parsed markdown documents.""" 49 | 50 | @staticmethod 51 | def h1(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 52 | """Rule for converting `h1` tags that adds some extra CSS classes to the tag.""" 53 | properties["class"] = f"text-xl font-bold {properties.get('class', '')}" 54 | return html.h1(*children, **properties) 55 | 56 | @staticmethod 57 | def h2(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 58 | """Rule for converting `h2` tags that adds some extra CSS classes to the tag.""" 59 | properties["class"] = f"text-lg font-bold {properties.get('class', '')}" 60 | return html.h2(*children, **properties) 61 | 62 | @staticmethod 63 | def ol(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 64 | """Rule for converting `ol` tags that adds some extra CSS classes to the tag.""" 65 | properties["class"] = f"list-decimal list-inside {properties.get('class', '')}" 66 | return html.ol(*children, **properties) 67 | 68 | @staticmethod 69 | def ul(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 70 | """Rule for converting `ul` tags that adds some extra CSS classes to the tag.""" 71 | properties["class"] = f"list-disc list-inside {properties.get('class', '')}" 72 | return html.ul(*children, **properties) 73 | 74 | 75 | # Create an element converter and configure it to use the conversion rules 76 | # that are defined above on h1, h2, ol, and ul tags. 77 | md_converter = etree.ETreeConverter( 78 | { 79 | "h1": ConversionRules.h1, 80 | "h2": ConversionRules.h2, 81 | "ol": ConversionRules.ol, 82 | "ul": ConversionRules.ul, 83 | "PostInfo": PostInfo, 84 | } 85 | ) 86 | 87 | 88 | async def render_post() -> None: 89 | md_post = md.MD( # Create an htmy.md.MD component. 90 | "post.md", 91 | converter=md_converter.convert, # And make it use our element converter's conversion method. 92 | ) 93 | page = Page(md_post) # Wrap the post in a Page component. 94 | rendered = await Renderer().render(page) # Render the MD component. 95 | print(rendered) # Print the result. 96 | 97 | 98 | if __name__ == "__main__": 99 | asyncio.run(render_post()) 100 | -------------------------------------------------------------------------------- /tests/renderer/test_renderer_comparison.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from htmy import ErrorBoundary, Fragment, component, html 8 | 9 | if TYPE_CHECKING: 10 | from htmy import Component, ComponentType, Context 11 | from htmy.renderer.typing import RendererType 12 | 13 | # -- Sync and async page. 14 | 15 | 16 | @component 17 | def page(content: ComponentType, context: Context) -> Component: 18 | return ( 19 | html.DOCTYPE.html, 20 | html.html( 21 | html.head( 22 | html.title("Test page"), 23 | html.Meta.charset(), 24 | html.Meta.viewport(), 25 | html.script(src="https://cdn.tailwindcss.com"), 26 | html.Link.css("https://cdn.jsdelivr.net/npm/daisyui@4.12.11/dist/full.min.css"), 27 | ), 28 | html.body( 29 | content, 30 | class_="h-screen w-screen", 31 | ), 32 | lang="en", 33 | ), 34 | ) 35 | 36 | 37 | @component 38 | async def a_page(content: ComponentType, context: Context) -> Component: 39 | return ( 40 | html.DOCTYPE.html, 41 | html.html( 42 | html.head( 43 | html.title("Test page"), 44 | html.Meta.charset(), 45 | html.Meta.viewport(), 46 | html.script(src="https://cdn.tailwindcss.com"), 47 | html.Link.css("https://cdn.jsdelivr.net/npm/daisyui@4.12.11/dist/full.min.css"), 48 | ), 49 | html.body( 50 | content, 51 | class_="h-screen w-screen", 52 | ), 53 | lang="en", 54 | ), 55 | ) 56 | 57 | 58 | # -- Utils 59 | 60 | 61 | class WrapAsync: 62 | def __init__(self, *children: ComponentType) -> None: 63 | self.children = children 64 | 65 | async def htmy(self, context: Context) -> Component: 66 | return self.children 67 | 68 | 69 | class Nested: 70 | def __init__(self, *children: ComponentType) -> None: 71 | self.children = children 72 | 73 | def htmy(self, context: Context) -> Component: 74 | return html.div( 75 | "Foo", 76 | html.div("bar"), 77 | Fragment( 78 | html.div( 79 | WrapAsync("Before error", html.div(*self.children), "After error"), 80 | ) 81 | ), 82 | ) 83 | 84 | 85 | def sync_async_divs(i: int) -> Fragment: 86 | return Fragment(html.div(f"Sync {i}", " ", "end"), WrapAsync(html.div("Async {i}", " ", "end"))) 87 | 88 | 89 | # -- Sync and async error components. 90 | 91 | 92 | class SyncError: 93 | def htmy(self, context: Context) -> Component: 94 | raise ValueError("sync-error-component") 95 | 96 | 97 | class AsyncError: 98 | async def htmy(self, context: Context) -> Component: 99 | raise ValueError("async-error-component") 100 | 101 | 102 | # -- Tests 103 | 104 | 105 | @pytest.mark.asyncio 106 | @pytest.mark.parametrize( 107 | ("component",), 108 | ( 109 | # -- Render a component sequence directly. 110 | ([Nested(sync_async_divs(i)) for i in range(100)],), 111 | # -- Render a larger, nested component tree. 112 | (page(Fragment(*[Nested(sync_async_divs(i)) for i in range(100)])),), 113 | # -- Error boundary 114 | (Nested(ErrorBoundary(Nested(SyncError()), fallback="Fallback to sync error.")),), 115 | (Nested(ErrorBoundary(Nested(AsyncError()), fallback="Fallback to async error.")),), 116 | ), 117 | ) 118 | async def test_renderers( 119 | *, 120 | component: Component, 121 | default_renderer: RendererType, 122 | baseline_renderer: RendererType, 123 | streaming_renderer: RendererType, 124 | ) -> None: 125 | default_renderer_result = await default_renderer.render(component) 126 | baseline_renderer_result = await baseline_renderer.render(component) 127 | streaming_renderer_result = await streaming_renderer.render(component) 128 | assert default_renderer_result == baseline_renderer_result 129 | assert streaming_renderer_result == baseline_renderer_result 130 | -------------------------------------------------------------------------------- /htmy/etree.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar 4 | from xml.sax.saxutils import unescape 5 | 6 | try: 7 | from lxml.etree import _Element as Element 8 | from lxml.etree import tostring as etree_to_string 9 | from lxml.html import fragment_fromstring as etree_from_string 10 | except ImportError: 11 | from xml.etree.ElementTree import Element # type: ignore[assignment] 12 | from xml.etree.ElementTree import fromstring as etree_from_string # type: ignore[assignment] 13 | from xml.etree.ElementTree import tostring as etree_to_string # type: ignore[no-redef] 14 | 15 | from .core import Fragment, SafeStr 16 | from .tag import wildcard_tag 17 | from .typing import ComponentType, Properties 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Callable, Generator, Mapping 21 | 22 | 23 | class ETreeConverter: 24 | """ 25 | Utility for converting XML strings to custom components. 26 | 27 | By default the converter uses the standard library's `xml.etree.ElementTree` 28 | module for string to element tree, and element tree to string conversion, 29 | but if `lxml` is installed, it will be used instead. 30 | 31 | Installing `lxml` is recommended for better performance and additional features, 32 | like performance and support for broken HTML fragments. **Important:** `lxml` is 33 | far more lenient and flexible than the standard library, so having it installed is 34 | not only a performance boost, but it may also slightly change the element conversion 35 | behavior in certain edge-cases! 36 | """ 37 | 38 | __slots__ = ("_rules",) 39 | 40 | _htmy_fragment: ClassVar[str] = "htmy_fragment" 41 | """ 42 | Placeholder tag name that's used to wrap possibly multi-root XML snippets into a valid 43 | XML document with a single root that can be processed by standard tools. 44 | """ 45 | 46 | def __init__(self, rules: Mapping[str, Callable[..., ComponentType]]) -> None: 47 | """ 48 | Initialization. 49 | 50 | Arguments: 51 | rules: Tag-name to component conversion rules. 52 | """ 53 | self._rules = rules 54 | 55 | def convert(self, element: str) -> ComponentType: 56 | """Converts the given (possibly multi-root) XML string to a component.""" 57 | if len(self._rules) == 0: 58 | return SafeStr(element) 59 | 60 | element = f"<{self._htmy_fragment}>{element}" 61 | return self.convert_element(etree_from_string(element)) # noqa: S314 # Only use XML strings from a trusted source. 62 | 63 | def convert_element(self, element: Element) -> ComponentType: 64 | """Converts the given `Element` to a component.""" 65 | rules = self._rules 66 | if len(rules) == 0: 67 | return SafeStr(etree_to_string(element, encoding="unicode")) 68 | 69 | tag: str = element.tag # type: ignore[assignment] 70 | component = Fragment if tag == self._htmy_fragment else rules.get(tag) 71 | children = self._convert_children(element) 72 | properties = self._convert_properties(element) 73 | 74 | return ( 75 | wildcard_tag(*children, htmy_name=tag, **properties) 76 | if component is None 77 | else component( 78 | *children, 79 | **properties, 80 | ) 81 | ) 82 | 83 | def _convert_properties(self, element: Element) -> Properties: 84 | """ 85 | Converts the attributes of the given `Element` to a `Properties` mapping. 86 | 87 | This method should not alter property names in any way. 88 | """ 89 | return {key: unescape(value) for key, value in element.items()} 90 | 91 | def _convert_children(self, element: Element) -> Generator[ComponentType, None, None]: 92 | """ 93 | Generator that converts all (text and `Element`) children of the given `Element` to a component. 94 | """ 95 | if text := self._process_text(element.text): 96 | yield text 97 | 98 | for child in element: 99 | yield self.convert_element(child) 100 | if tail := self._process_text(child.tail): 101 | yield tail 102 | 103 | def _process_text(self, escaped_text: str | None) -> str | None: 104 | """Processes a single XML-escaped text child.""" 105 | return unescape(escaped_text) if escaped_text else None 106 | -------------------------------------------------------------------------------- /htmy/md/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar 4 | 5 | from markdown import Markdown 6 | 7 | from htmy.core import ContextAware, SafeStr, Text 8 | from htmy.snippet import Snippet 9 | from htmy.typing import TextProcessor, TextResolver 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Callable 13 | from pathlib import Path 14 | 15 | from htmy.typing import Component, Context 16 | 17 | from .typing import MarkdownParserFunction, MarkdownRenderFunction, ParsedMarkdown 18 | 19 | 20 | class MarkdownParser(ContextAware): 21 | """ 22 | Context-aware markdown parser. 23 | 24 | By default, this class uses the `markdown` library with a sensible set of 25 | [extensions](https://python-markdown.github.io/extensions/) including code highlighting. 26 | """ 27 | 28 | __slots__ = ("_md",) 29 | 30 | _default: ClassVar[MarkdownParser | None] = None 31 | """The default instance or `None` if one hasn't been created already.""" 32 | 33 | @classmethod 34 | def default(cls) -> MarkdownParser: 35 | """ 36 | Returns the default instance. 37 | """ 38 | if cls._default is None: 39 | cls._default = MarkdownParser() 40 | 41 | return cls._default 42 | 43 | def __init__(self, md: MarkdownParserFunction | None = None) -> None: 44 | """ 45 | Initialization. 46 | 47 | Arguments: 48 | md: The parser function to use. 49 | """ 50 | super().__init__() 51 | self._md = md 52 | 53 | def parse(self, text: str) -> ParsedMarkdown: 54 | """ 55 | Returns the markdown data by parsing the given text. 56 | """ 57 | md = self._md 58 | if md is None: 59 | md = self._default_md() 60 | self._md = md 61 | 62 | return md(text) 63 | 64 | def _default_md(self) -> MarkdownParserFunction: 65 | """ 66 | Function that creates the default markdown parser. 67 | 68 | Returns: 69 | The default parser function. 70 | """ 71 | md = Markdown(extensions=("extra", "meta", "codehilite")) 72 | 73 | def parse(text: str) -> ParsedMarkdown: 74 | md.reset() 75 | parsed = md.convert(text) 76 | return {"content": parsed, "metadata": getattr(md, "Meta", None)} 77 | 78 | return parse 79 | 80 | 81 | class MD(Snippet): 82 | """ 83 | Component for reading, customizing, and rendering markdown documents. 84 | 85 | It supports all the processing utilities of `Snippet`, including `text_resolver` and 86 | `text_processor` for formatting, token replacement, and slot conversion to components. 87 | 88 | One note regarding slot conversion (`text_resolver`): it is executed before markdown parsing, 89 | and all string segments of the resulting component sequence are parsed individually by the 90 | markdown parser. As a consequence, you should only use slots in places where the preceding 91 | and following texts individually result in valid markdown. 92 | 93 | **Warning:** The component treats its input as trusted. If any part of the input comes from 94 | untrusted sources, ensure it is safely escaped (using for example `htmy.xml_format_string`)! 95 | Passing untrusted input to this component leads to XSS vulnerabilities. 96 | """ 97 | 98 | __slots__ = ( 99 | "_converter", 100 | "_renderer", 101 | ) 102 | 103 | def __init__( 104 | self, 105 | path_or_text: Text | str | Path, 106 | text_resolver: TextResolver | None = None, 107 | *, 108 | converter: Callable[[str], Component] | None = None, 109 | renderer: MarkdownRenderFunction | None = None, 110 | text_processor: TextProcessor | None = None, 111 | ) -> None: 112 | """ 113 | Initialization. 114 | 115 | Arguments: 116 | path_or_text: The path where the markdown file is located or a markdown `Text`. 117 | text_resolver: An optional `TextResolver` (e.g. `Slots`) that converts the processed 118 | text into a component. 119 | converter: Function that converts an HTML string (the parsed and processed markdown text) 120 | into a component. 121 | renderer: Function that gets the parsed and converted content and the metadata (if it exists) 122 | and turns them into a component. 123 | text_processor: An optional text processors that can be used to process the text 124 | content before rendering. It can be used for example for token replacement or 125 | string formatting. 126 | """ 127 | super().__init__(path_or_text, text_resolver, text_processor=text_processor) 128 | self._converter: Callable[[str], Component] = SafeStr if converter is None else converter 129 | self._renderer = renderer 130 | 131 | def _render_text(self, text: str, context: Context) -> Component: 132 | md = MarkdownParser.from_context(context, MarkdownParser.default()).parse(text) 133 | result = self._converter(md["content"]) 134 | return result if self._renderer is None else self._renderer(result, md.get("metadata", None)) 135 | -------------------------------------------------------------------------------- /htmy/i18n.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Mapping 3 | from pathlib import Path 4 | from typing import Any, ClassVar, overload 5 | 6 | from async_lru import alru_cache 7 | 8 | from .core import ContextAware 9 | from .io import open_file 10 | 11 | TranslationResource = Mapping[str, Any] 12 | """Translation resource type.""" 13 | 14 | 15 | class I18nError(Exception): ... 16 | 17 | 18 | class I18nKeyError(I18nError): ... 19 | 20 | 21 | class I18nValueError(I18nError): ... 22 | 23 | 24 | class I18n(ContextAware): 25 | """ 26 | Context-aware async internationalization utility. 27 | """ 28 | 29 | __slots__ = ("_fallback", "_path") 30 | 31 | _root_keys: ClassVar[frozenset[str]] = frozenset(("", ".")) 32 | """Special keys that represent the "root" object, i.e. the entire translation resource file.""" 33 | 34 | def __init__(self, path: str | Path, fallback: str | Path | None = None) -> None: 35 | """ 36 | Initialization. 37 | 38 | Arguments: 39 | path: Path to the root directory that contains the translation resources. 40 | fallback: Optional fallback path to use if `path` doesn't contain the required resources. 41 | """ 42 | self._path: Path = Path(path) if isinstance(path, str) else path 43 | self._fallback: Path | None = Path(fallback) if isinstance(fallback, str) else fallback 44 | 45 | @overload 46 | async def get(self, dotted_path: str, key: str) -> Any: ... 47 | 48 | @overload 49 | async def get(self, dotted_path: str, key: str, **kwargs: Any) -> str: ... 50 | 51 | async def get(self, dotted_path: str, key: str, **kwargs: Any) -> Any: 52 | """ 53 | Returns the translation resource at the given location. 54 | 55 | If keyword arguments are provided, it's expected that the referenced data 56 | is a [format string](https://docs.python.org/3/library/string.html#formatstrings) 57 | which can be fully formatted using the given keyword arguments. 58 | 59 | Arguments: 60 | dotted_path: A package-like (dot separated) path to the file that contains 61 | the required translation resource, relative to `path`. 62 | key: The key in the translation resource whose value is requested. Use dots to reference 63 | embedded attributes. 64 | 65 | Returns: 66 | The loaded value. 67 | 68 | Raises: 69 | I18nError: If the given translation resource is not found or invalid. 70 | """ 71 | try: 72 | return await self._resolve(self._path, dotted_path, key, **kwargs) 73 | except I18nError: 74 | if self._fallback is None: 75 | raise 76 | 77 | return await self._resolve(self._fallback, dotted_path, key, **kwargs) 78 | 79 | @classmethod 80 | async def _resolve(cls, root: Path, dotted_subpath: str, key: str, **kwargs: Any) -> Any: 81 | """ 82 | Resolves the given translation resource. 83 | 84 | Arguments: 85 | root: The root path to use. 86 | dotted_subpath: Subpath under `root` with dots as separators. 87 | key: The key in the translation resource. 88 | 89 | Returns: 90 | The resolved translation resource. 91 | 92 | Raises: 93 | I18nKeyError: If the translation resource doesn't contain the requested key. 94 | I18nValueError: If the translation resource is not found or its content is invalid. 95 | """ 96 | result = await load_translation_resource(resolve_json_path(root, dotted_subpath)) 97 | if key in cls._root_keys: 98 | return result 99 | 100 | for k in key.split("."): 101 | try: 102 | result = result[k] 103 | except KeyError as e: 104 | raise I18nKeyError(f"Key not found: {key}") from e 105 | 106 | if len(kwargs) > 0: 107 | if not isinstance(result, str): 108 | raise I18nValueError("Formatting is only supported for strings.") 109 | 110 | return result.format(**kwargs) 111 | 112 | return result 113 | 114 | 115 | @alru_cache() 116 | async def load_translation_resource(path: Path) -> TranslationResource: 117 | """ 118 | Loads the translation resource from the given path. 119 | 120 | Arguments: 121 | path: The path of the translation resource to load. 122 | 123 | Returns: 124 | The loaded translation resource. 125 | 126 | Raises: 127 | I18nValueError: If the translation resource is not a JSON dict. 128 | """ 129 | 130 | try: 131 | async with await open_file(path, "r") as f: 132 | content = await f.read() 133 | except FileNotFoundError as e: 134 | raise I18nValueError(f"Translation resource not found: {str(path)}") from e 135 | 136 | try: 137 | result = json.loads(content) 138 | except json.JSONDecodeError as e: 139 | raise I18nValueError("Translation resource decoding failed.") from e 140 | 141 | if isinstance(result, dict): 142 | return result 143 | 144 | raise I18nValueError("Only dict translation resources are allowed.") 145 | 146 | 147 | def resolve_json_path(root: Path, dotted_subpath: str) -> Path: 148 | """ 149 | Resolves the given dotted subpath relative to root. 150 | 151 | Arguments: 152 | root: The root path. 153 | dotted_subpath: Subpath under `root` with dots as separators. 154 | 155 | Returns: 156 | The resolved path. 157 | 158 | Raises: 159 | I18nValueError: If the given dotted path is invalid. 160 | """ 161 | *dirs, name = dotted_subpath.split(".") 162 | if not name: 163 | raise I18nValueError("Invalid path.") 164 | 165 | return root / Path(*dirs) / f"{name}.json" 166 | -------------------------------------------------------------------------------- /docs/examples/fastapi-htmx-tailwind-daisyui.md: -------------------------------------------------------------------------------- 1 | # FastAPI with HTMX, TailwindCSS, and DaisyUI 2 | 3 | First you must install all the necessary libraries (`FastAPI`, `uvicorn`, and `htmy`), for example like this: 4 | 5 | ```console 6 | $ pip install fastapi uvicorn htmy 7 | ``` 8 | 9 | You should be able to follow how components work and how the context can be used even without being familiar with [HTMX](https://htmx.org/), [TailwindCSS](https://tailwindcss.com/), and [DaisyUI](https://daisyui.com/), just ignore the styling and the `hx_*` attributes. But if you plan to play with this example, minimal familiarity with these tools will be very helpful. 10 | 11 | Now you should create an `app.py` file with this content: 12 | 13 | ```python 14 | from collections.abc import Awaitable, Callable 15 | from dataclasses import dataclass 16 | from typing import Annotated 17 | 18 | from fastapi import Depends, FastAPI, Request 19 | from fastapi.responses import HTMLResponse 20 | 21 | from htmy import Component, ComponentType, Context, Renderer, component, html, is_component_sequence 22 | 23 | 24 | @dataclass 25 | class User: 26 | """Some user data model for the application.""" 27 | 28 | name: str 29 | preferred_theme: str 30 | 31 | 32 | def make_htmy_context(request: Request) -> Context: 33 | """Creates the base htmy context for rendering.""" 34 | # The context will map the `Request` type to the current request and the User class 35 | # to the current user. This is similar to what the `ContextAware` utility does, but 36 | # simpler. With this context, components will be able to easily access the request 37 | # and the user if they need it. 38 | return {Request: request, User: User(name="Paul", preferred_theme="dark")} 39 | 40 | 41 | RendererFunction = Callable[[Component], Awaitable[HTMLResponse]] 42 | 43 | 44 | def render(request: Request) -> RendererFunction: 45 | """FastAPI dependency that returns an htmy renderer function.""" 46 | 47 | async def exec(component: Component) -> HTMLResponse: 48 | # Note that we add the result of `make_htmy_context()` as the default context to 49 | # the renderer. This way wherever this function is used for rendering in routes, 50 | # every rendered component will be able to access the current request and user. 51 | renderer = Renderer(make_htmy_context(request)) 52 | return HTMLResponse(await renderer.render(component)) 53 | 54 | return exec 55 | 56 | 57 | DependsRenderFunc = Annotated[RendererFunction, Depends(render)] 58 | 59 | 60 | @component 61 | def page(content: ComponentType, context: Context) -> Component: 62 | """ 63 | Page component that wraps the given `content` in the `` tag. 64 | 65 | This is just the base page layout component with all the necessary metadata and some styling. 66 | """ 67 | # Take the user from the context, so we can set the page theme (through DaisyUI). 68 | user: User = context[User] 69 | return ( 70 | html.DOCTYPE.html, 71 | html.html( 72 | html.head( 73 | # Some metadata 74 | html.title("Demo"), 75 | html.Meta.charset(), 76 | html.Meta.viewport(), 77 | # TailwindCSS and DaisyUI 78 | html.script(src="https://cdn.tailwindcss.com"), 79 | html.Link.css("https://cdn.jsdelivr.net/npm/daisyui@4.12.11/dist/full.min.css"), 80 | # HTMX 81 | html.script(src="https://unpkg.com/htmx.org@2.0.2"), 82 | ), 83 | html.body( 84 | content, 85 | data_theme=user.preferred_theme, 86 | class_="h-screen w-screen", 87 | ), 88 | lang="en", 89 | ), 90 | ) 91 | 92 | 93 | @component 94 | def center(content: Component, context: Context) -> Component: 95 | """Component that shows its content in the center of the available space.""" 96 | return html.div( 97 | *(content if is_component_sequence(content) else [content]), 98 | class_="flex flex-col w-full h-full items-center justify-center gap-4", 99 | ) 100 | 101 | 102 | @component 103 | def counter(value: int, context: Context) -> Component: 104 | """ 105 | Counter button with HTMX functionality. 106 | 107 | Whenever the button is clicked, a request will be sent to the server and the 108 | button will be re-rendered with the new value of the counter. 109 | """ 110 | # Attribute names will automatically be converted to "hx-*" and "class". 111 | return html.button( 112 | f"Click {value} times.", 113 | hx_trigger="click", 114 | hx_swap="outerHTML", 115 | hx_post=f"/counter-click?value={value}", 116 | class_="btn btn-primary", 117 | ) 118 | 119 | 120 | @component 121 | def welcome_message(props: None, context: Context) -> Component: 122 | """Welcome message component.""" 123 | # Take the request and the user from the context for use in the component. 124 | request: Request = context[Request] 125 | user: User = context[User] 126 | return center( 127 | ( 128 | html.h1(f'Welcome {user.name} at "{request.url.path}"!'), 129 | counter(0), 130 | ) 131 | ) 132 | 133 | 134 | app = FastAPI() 135 | 136 | 137 | @app.get("/") 138 | async def index(render: DependsRenderFunc) -> HTMLResponse: 139 | """The index page of the application.""" 140 | return await render(page(welcome_message(None))) 141 | 142 | 143 | @app.post("/counter-click") 144 | async def counter_click(value: int, render: DependsRenderFunc) -> HTMLResponse: 145 | """HTMX route that handles counter button clicks by re-rendering the button with the new value.""" 146 | return await render(counter(value + 1)) 147 | 148 | ``` 149 | 150 | Finally, you can run the application like this: 151 | 152 | ```console 153 | $ uvicorn app:app --reload 154 | ``` 155 | 156 | You can now open the application at `localhost:8000`. 157 | -------------------------------------------------------------------------------- /htmy/renderer/baseline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import ChainMap 4 | from inspect import isawaitable 5 | from typing import TYPE_CHECKING 6 | 7 | from htmy.core import xml_format_string 8 | from htmy.utils import is_component_sequence 9 | 10 | from .context import RendererContext 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import AsyncIterator, Awaitable, Callable 14 | 15 | from htmy.typing import Component, ComponentType, Context 16 | 17 | 18 | class Renderer: 19 | """ 20 | The baseline renderer that support both async streaming and rendering. 21 | 22 | Because of the simple, recursive implementation, this renderer is the easiest to reason about. 23 | Therefore it is useful for validating component correctness before bug reporting (if another 24 | renderer implementation fails), testing and debugging alternative implementations, and it can 25 | also serve as the baseline for benchmarking other renderer implementations. 26 | """ 27 | 28 | __slots__ = ("_default_context", "_string_formatter") 29 | 30 | def __init__( 31 | self, 32 | default_context: Context | None = None, 33 | *, 34 | string_formatter: Callable[[str], str] = xml_format_string, 35 | ) -> None: 36 | """ 37 | Initialization. 38 | 39 | Arguments: 40 | default_context: The default context to use for rendering if `render()` doesn't 41 | receive a context. 42 | string_formatter: Callable that should be used to format plain strings. By default 43 | an XML-safe string formatter will be used. 44 | """ 45 | self._default_context: Context = {} if default_context is None else default_context 46 | self._string_formatter = string_formatter 47 | 48 | async def render(self, component: Component, context: Context | None = None) -> str: 49 | """ 50 | Renders the given component. 51 | 52 | Implements `htmy.renderer.typing.RendererType`. 53 | 54 | Arguments: 55 | component: The component to render. 56 | context: An optional rendering context. 57 | 58 | Returns: 59 | The rendered string. 60 | """ 61 | chunks = [] 62 | async for chunk in self.stream(component, context): 63 | chunks.append(chunk) 64 | 65 | return "".join(chunks) 66 | 67 | async def stream(self, component: Component, context: Context | None = None) -> AsyncIterator[str]: 68 | """ 69 | Async iterator that renders the given component. 70 | 71 | Implements `htmy.renderer.typing.StreamingRendererType`. 72 | 73 | Arguments: 74 | component: The component to render. 75 | context: An optional rendering context. 76 | 77 | Yields: 78 | The rendered strings. 79 | """ 80 | # Create a new default context that also contains the renderer instance. 81 | # We must not put it in `self._default_context` because then the renderer 82 | # would keep a reference to itself. 83 | default_context = {**self._default_context, RendererContext: self} 84 | # Type ignore: ChainMap expects mutable mappings, but context mutation is not allowed so don't care. 85 | context = ( 86 | default_context if context is None else ChainMap(context, default_context) # type: ignore[arg-type] 87 | ) 88 | 89 | async for chunk in self._stream(component, context): 90 | yield chunk 91 | 92 | async def _stream(self, component: Component, context: Context) -> AsyncIterator[str]: 93 | """ 94 | Renders the given component with the given context. 95 | 96 | Arguments: 97 | component: The component to render. 98 | context: The current rendering context. 99 | 100 | Yields: 101 | String chunks of the rendered component. 102 | """ 103 | if isinstance(component, str): 104 | yield self._string_formatter(component) 105 | elif component is None: 106 | return 107 | elif is_component_sequence(component): 108 | for comp in component: 109 | if comp is not None: 110 | async for chunk in self._stream_one(comp, context): 111 | yield chunk 112 | else: 113 | # Sync or async htmy component. 114 | async for chunk in self._stream_one(component, context): # type: ignore[arg-type] 115 | yield chunk 116 | 117 | async def _stream_one(self, component: ComponentType, context: Context) -> AsyncIterator[str]: 118 | """ 119 | Renders a single component. 120 | 121 | Arguments: 122 | component: The component to render. 123 | context: The current rendering context. 124 | 125 | Yields: 126 | The rendered strings. 127 | """ 128 | if isinstance(component, str): 129 | yield self._string_formatter(component) 130 | elif component is None: 131 | return 132 | else: 133 | # Handle context providers 134 | child_context: Context = context 135 | if hasattr(component, "htmy_context"): # isinstance() is too expensive. 136 | extra_context: Context | Awaitable[Context] = component.htmy_context() 137 | if isawaitable(extra_context): 138 | extra_context = await extra_context 139 | 140 | if len(extra_context): 141 | # Context must not be mutated, so we can ignore that ChainMap expects mutable mappings. 142 | child_context = ChainMap(extra_context, context) # type: ignore[arg-type] 143 | 144 | children = component.htmy(child_context) 145 | if isawaitable(children): 146 | children = await children 147 | 148 | async for chunk in self._stream(children, child_context): 149 | yield chunk 150 | -------------------------------------------------------------------------------- /tests/test_main_components.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import Callable 3 | from datetime import date, datetime, timezone 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | from htmy import ( 9 | Component, 10 | Context, 11 | ErrorBoundary, 12 | Formatter, 13 | Tag, 14 | TagWithProps, 15 | WithContext, 16 | XBool, 17 | component, 18 | ) 19 | from htmy.renderer.typing import RendererType 20 | 21 | 22 | class Page: 23 | @staticmethod 24 | def page() -> Component: 25 | class DemoValueError(ValueError): ... 26 | 27 | class CustomTagFormatter(Formatter): 28 | def __init__( 29 | self, 30 | *, 31 | default_formatter: Callable[[Any], str] = str, 32 | name_formatter: Callable[[str], str] | None = None, 33 | ) -> None: 34 | super().__init__(default_formatter=default_formatter, name_formatter=name_formatter) 35 | self.add(int, lambda i: f"int:{i}") 36 | 37 | class ARaise: 38 | async def htmy(self, context: Context) -> Component: 39 | raise DemoValueError("Deliberate") 40 | 41 | div = Tag("div") 42 | 43 | h1 = Tag("h1", child_separator=None) 44 | 45 | a_h2 = Tag("a_h2", child_separator=None) 46 | 47 | a_main = Tag("a_main") 48 | 49 | img = TagWithProps("img") 50 | 51 | tp = TagWithProps("tp") 52 | 53 | class AsyncText: 54 | def __init__(self, value: str) -> None: 55 | self._value = value 56 | 57 | async def htmy(self, context: Context) -> Component: 58 | await asyncio.sleep(context["aio-sleep"]) 59 | return self._value 60 | 61 | @component 62 | def sync_fc(props: int, context: Context) -> Component: 63 | return f"sync_fc-{Formatter.from_context(context).format_value(props)}" 64 | 65 | @component 66 | async def async_fc(props: int, context: Context) -> Component: 67 | await asyncio.sleep(context["aio-sleep"]) 68 | return f"async_fc-{Formatter.from_context(context).format_value(props)}" 69 | 70 | return WithContext( 71 | None, 72 | a_main( 73 | img(src="/example.png"), 74 | tp(x="x1", y="y1", checked=XBool.true, required=XBool(True), value_skipped=XBool(False)), 75 | sync_fc(987321), 76 | async_fc(456), 77 | a_main( 78 | None, 79 | Formatter().in_context( 80 | div( 81 | AsyncText("sd df"), 82 | h1( 83 | AsyncText("sdfds"), 84 | created_at=datetime(2024, 10, 3, 4, 42, 2, 71, tzinfo=timezone.utc), 85 | on_day_=date(2024, 10, 3), 86 | ), 87 | dp_1=123, 88 | _class="w-full", 89 | none=None, 90 | ) 91 | ), 92 | None, 93 | ErrorBoundary( 94 | div( 95 | None, 96 | ARaise(), 97 | ), 98 | fallback=h1("Fallback after rendering error.", None), 99 | errors={TypeError, ValueError}, 100 | ), 101 | ), 102 | div(ErrorBoundary(ARaise(), fallback=None)), 103 | a_h2("something"), 104 | div(AsyncText("something else"), div(AsyncText("inner something else"))), 105 | p_1=123, 106 | p_2="fls", 107 | p_3=True, 108 | ), 109 | None, 110 | context={**CustomTagFormatter().to_context(), "aio-sleep": 1}, 111 | ) 112 | 113 | @staticmethod 114 | def rendered() -> str: 115 | return "\n".join( 116 | ( 117 | # \n-s in this tuple mark places where None children are ignored 118 | # but still slightly mess up the output. The reason for not fixing 119 | # these is because stricted None checks would impact performance. 120 | '', 121 | '', 122 | '', 123 | "sync_fc-int:987321", 124 | "async_fc-int:456", 125 | "\n", 126 | '
', 127 | "sd<fs> df", 128 | '

sdfds

', 129 | "
\n", 130 | "

Fallback after rendering error.

", 131 | "
", 132 | "
\n\n
", 133 | "something", 134 | "
", 135 | "something else", 136 | "
", 137 | "inner something else", 138 | "
", 139 | "
", 140 | "
", 141 | ) 142 | ) 143 | 144 | 145 | @pytest.mark.asyncio 146 | @pytest.mark.parametrize( 147 | ("page", "context", "expected"), 148 | ( 149 | (Page.page(), None, Page.rendered()), 150 | (Page.page(), None, Page.rendered()), 151 | ), 152 | ) 153 | async def test_complex_page_rendering( 154 | default_renderer: RendererType, 155 | baseline_renderer: RendererType, 156 | streaming_renderer: RendererType, 157 | page: Component, 158 | context: Context | None, 159 | expected: str, 160 | ) -> None: 161 | result = await default_renderer.render(page, context) 162 | assert result == expected 163 | 164 | result = await baseline_renderer.render(page, context) 165 | assert result == expected 166 | 167 | result = await streaming_renderer.render(page, context) 168 | assert result == expected 169 | -------------------------------------------------------------------------------- /tests/test_function_component.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from datetime import date 4 | from time import perf_counter 5 | 6 | import pytest 7 | 8 | from htmy import Component, ComponentType, Context, Renderer, component, html 9 | from htmy.renderer import BaselineRenderer 10 | 11 | async_delay = 0.16 12 | rendering_context: Context = {"date": date(2025, 3, 14)} 13 | message = "Hello!" 14 | date_string = rendering_context["date"].isoformat() 15 | date_and_message = f"

{date_string}: {message}

" 16 | 17 | # -- Function components. 18 | 19 | 20 | @component 21 | def sync_function_component(msg: str, ctx: Context) -> ComponentType: 22 | dt: date = ctx["date"] 23 | return html.p(dt.isoformat(), ": ", msg) 24 | 25 | 26 | @component 27 | async def async_function_component(msg: str, ctx: Context) -> ComponentType: 28 | await asyncio.sleep(async_delay) 29 | dt: date = ctx["date"] 30 | return html.p(dt.isoformat(), ": ", msg) 31 | 32 | 33 | # -- Function components using function alias. 34 | 35 | 36 | @component.function 37 | def sync_function_component_with_function_alias(msg: str, ctx: Context) -> ComponentType: 38 | dt: date = ctx["date"] 39 | return html.p(dt.isoformat(), ": ", msg) 40 | 41 | 42 | @component.function 43 | async def async_function_component_with_function_alias(msg: str, ctx: Context) -> ComponentType: 44 | await asyncio.sleep(async_delay) 45 | dt: date = ctx["date"] 46 | return html.p(dt.isoformat(), ": ", msg) 47 | 48 | 49 | # -- Context-only function components. 50 | 51 | 52 | @component.context_only 53 | def sync_context_only_function_component(ctx: Context) -> ComponentType: 54 | dt: date = ctx["date"] 55 | return html.p(dt.isoformat(), ": ", message) 56 | 57 | 58 | @component.context_only 59 | async def async_context_only_function_component(ctx: Context) -> ComponentType: 60 | await asyncio.sleep(async_delay) 61 | dt: date = ctx["date"] 62 | return html.p(dt.isoformat(), ": ", message) 63 | 64 | 65 | # -- Method components. 66 | 67 | 68 | @dataclass 69 | class Data: 70 | goodbye: str 71 | 72 | @component.method 73 | def sync_method_component(self, msg: str, ctx: Context) -> ComponentType: 74 | dt: date = ctx["date"] 75 | return html.p(dt.isoformat(), ": ", msg, " ", self.goodbye) 76 | 77 | @component.method 78 | async def async_method_component(self, msg: str, ctx: Context) -> ComponentType: 79 | await asyncio.sleep(async_delay) 80 | dt: date = ctx["date"] 81 | return html.p(dt.isoformat(), ": ", msg, " ", self.goodbye) 82 | 83 | @component.context_only_method 84 | def sync_context_only_method_component(self, ctx: Context) -> ComponentType: 85 | dt: date = ctx["date"] 86 | return html.p(dt.isoformat(), ": ", self.goodbye) 87 | 88 | @component.context_only_method 89 | async def async_context_only_method_component(self, ctx: Context) -> ComponentType: 90 | await asyncio.sleep(async_delay) 91 | dt: date = ctx["date"] 92 | return html.p(dt.isoformat(), ": ", self.goodbye) 93 | 94 | 95 | data = Data("Goodbye!") 96 | 97 | # -- Fixtures 98 | 99 | 100 | @pytest.fixture(scope="session") 101 | def renderer() -> Renderer: 102 | return Renderer(rendering_context) 103 | 104 | 105 | @pytest.fixture(scope="session") 106 | def baseline_renderer() -> BaselineRenderer: 107 | return BaselineRenderer(rendering_context) 108 | 109 | 110 | # -- Tests 111 | 112 | 113 | @pytest.mark.asyncio 114 | @pytest.mark.parametrize( 115 | ("comp", "expected", "min_duration"), 116 | ( 117 | # -- Function component. 118 | ( 119 | sync_function_component(message), 120 | date_and_message, 121 | 0, 122 | ), 123 | ( 124 | async_function_component(message), 125 | date_and_message, 126 | async_delay, 127 | ), 128 | # -- Function component with alias. 129 | ( 130 | sync_function_component_with_function_alias(message), 131 | date_and_message, 132 | 0, 133 | ), 134 | ( 135 | async_function_component_with_function_alias(message), 136 | date_and_message, 137 | async_delay, 138 | ), 139 | # -- Context-only function component with call signature. 140 | ( 141 | sync_context_only_function_component(), 142 | date_and_message, 143 | 0, 144 | ), 145 | ( 146 | async_context_only_function_component(), 147 | date_and_message, 148 | async_delay, 149 | ), 150 | # -- Context-only function component without call signature. 151 | ( 152 | sync_context_only_function_component, 153 | date_and_message, 154 | 0, 155 | ), 156 | ( 157 | async_context_only_function_component, 158 | date_and_message, 159 | async_delay, 160 | ), 161 | # -- Method component. 162 | ( 163 | data.sync_method_component(message), 164 | f"

{date_string}: {message} Goodbye!

", 165 | 0, 166 | ), 167 | ( 168 | data.async_method_component(message), 169 | f"

{date_string}: {message} Goodbye!

", 170 | async_delay, 171 | ), 172 | # -- Context only method component. 173 | ( 174 | data.sync_context_only_method_component(), 175 | f"

{date_string}: Goodbye!

", 176 | 0, 177 | ), 178 | ( 179 | data.async_context_only_method_component(), 180 | f"

{date_string}: Goodbye!

", 181 | async_delay, 182 | ), 183 | ), 184 | ) 185 | async def test_function_component( 186 | renderer: Renderer, 187 | baseline_renderer: BaselineRenderer, 188 | comp: Component, 189 | expected: str, 190 | min_duration: float, 191 | ) -> None: 192 | t_start = perf_counter() 193 | assert await renderer.render(comp) == expected 194 | # Test that async calls in async components are awaited. 195 | assert perf_counter() - t_start >= min_duration 196 | 197 | t_start = perf_counter() 198 | assert await baseline_renderer.render(comp) == expected 199 | # Test that async calls in async components are awaited. 200 | assert perf_counter() - t_start >= min_duration 201 | -------------------------------------------------------------------------------- /tests/test_snippet.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections.abc import Mapping 3 | from pathlib import Path 4 | from typing import cast 5 | 6 | import pytest 7 | 8 | from htmy import Component, ComponentType, Context, Renderer, SafeStr, Slots, Snippet, Text, html 9 | from htmy.typing import TextProcessor 10 | 11 | from .utils import tests_root 12 | 13 | _hello_world_format_snippet = "\n".join( 14 | ( 15 | "
", 16 | " Hello World!", 17 | " {message}", 18 | "
", 19 | "", 20 | ) 21 | ) 22 | 23 | _hello_world_snippet = _hello_world_format_snippet.format( 24 | message="The quick brown fox jumps over the lazy dog." 25 | ) 26 | 27 | 28 | def message_to_slot_text_processor(text: str, ctx: Context) -> str: 29 | # Replace {message} with some text and a message and a signature slot. 30 | return text.format( 31 | message=( 32 | "before message slot " 33 | "" 34 | " between slots " 35 | "" 36 | " after signature slot" 37 | ) 38 | ) 39 | 40 | 41 | def sync_text_processor(text: str, context: Context) -> str: 42 | assert isinstance(context, Mapping) 43 | return text.format(message="Filled by sync text processor.") 44 | 45 | 46 | async def async_text_processor(text: str, context: Context) -> str: 47 | assert isinstance(context, Mapping) 48 | return text.format(message="Filled by async text processor.") 49 | 50 | 51 | @pytest.mark.asyncio 52 | @pytest.mark.parametrize( 53 | ("path_or_text", "text_processor", "expected"), 54 | ( 55 | ("tests/data/hello-world-snippet.html", None, _hello_world_snippet), 56 | (tests_root / "data" / "hello-world-snippet.html", None, _hello_world_snippet), 57 | (Text(_hello_world_snippet), None, _hello_world_snippet), 58 | ( 59 | Text(_hello_world_format_snippet), 60 | sync_text_processor, 61 | _hello_world_format_snippet.format(message="Filled by sync text processor."), 62 | ), 63 | ( 64 | Text(_hello_world_format_snippet), 65 | async_text_processor, 66 | _hello_world_format_snippet.format(message="Filled by async text processor."), 67 | ), 68 | ), 69 | ) 70 | async def test_snippet( 71 | path_or_text: Text | str | Path, text_processor: TextProcessor, expected: str 72 | ) -> None: 73 | snippet = Snippet(path_or_text, text_processor=text_processor) 74 | rendered = await Renderer().render(snippet) 75 | assert isinstance(rendered, str) 76 | assert rendered == expected 77 | 78 | 79 | class _SlotTexts: 80 | slot_mapping: dict[str, Component] = { 81 | "s1": [html.p("slot-one-part-one", class_="s1"), html.p("slot-one-part-two", class_="s1")], 82 | "s2": html.p("slot-two-replacement", class_="s2"), 83 | } 84 | not_found_component = "not-found" 85 | full_slot_mapping: dict[str, Component] = { 86 | **slot_mapping, 87 | "s3": not_found_component, 88 | } 89 | 90 | base = ( 91 | "one two " 92 | " " 93 | ) 94 | 95 | split = ["one ", "s1", " two ", "s2", "", "s2", " ", "s3", ""] 96 | mapped_split = ( 97 | "one ", 98 | *cast(list[ComponentType], full_slot_mapping["s1"]), 99 | " two ", 100 | full_slot_mapping["s2"], 101 | "", 102 | full_slot_mapping["s2"], 103 | " ", 104 | full_slot_mapping["s3"], 105 | "", 106 | ) 107 | 108 | square_bracket = base.format(open="[", close="]") 109 | parentheses = base.format(open="(", close=")") 110 | 111 | 112 | @pytest.mark.parametrize( 113 | ("text", "split_re", "expected"), 114 | ( 115 | # -- Square bracket text 116 | (_SlotTexts.square_bracket, Slots.slot_re.default, _SlotTexts.split), # Test default 117 | (_SlotTexts.square_bracket, Slots.slot_re.square_bracket, _SlotTexts.split), 118 | (_SlotTexts.square_bracket, Slots.slot_re.parentheses, None), 119 | # -- Parentheses text 120 | (_SlotTexts.parentheses, Slots.slot_re.default, None), # Test default 121 | (_SlotTexts.parentheses, Slots.slot_re.square_bracket, None), 122 | (_SlotTexts.parentheses, Slots.slot_re.parentheses, _SlotTexts.split), 123 | ), 124 | ) 125 | def test_slots_re(text: str, split_re: re.Pattern[str], expected: list[str] | None) -> None: 126 | result = split_re.split(text) 127 | if expected is None: 128 | assert len(result) == 1 129 | assert result[0] == text 130 | else: 131 | assert result == expected 132 | 133 | 134 | def test_default_slot_re() -> None: 135 | assert Slots.slot_re.default is Slots.slot_re.square_bracket 136 | 137 | 138 | @pytest.mark.parametrize( 139 | ("slots", "text", "expected"), 140 | ( 141 | # -- Square bracket text 142 | ( 143 | Slots( 144 | _SlotTexts.slot_mapping, 145 | not_found=_SlotTexts.not_found_component, 146 | ), 147 | _SlotTexts.square_bracket, 148 | _SlotTexts.mapped_split, 149 | ), 150 | ( 151 | Slots( 152 | _SlotTexts.slot_mapping, 153 | slot_re=Slots.slot_re.square_bracket, 154 | not_found=_SlotTexts.not_found_component, 155 | ), 156 | _SlotTexts.square_bracket, 157 | _SlotTexts.mapped_split, 158 | ), 159 | ( 160 | Slots( 161 | _SlotTexts.slot_mapping, 162 | slot_re=Slots.slot_re.parentheses, 163 | ), 164 | _SlotTexts.square_bracket, 165 | None, 166 | ), 167 | # -- Parentheses text 168 | ( 169 | Slots( 170 | _SlotTexts.slot_mapping, 171 | ), 172 | _SlotTexts.parentheses, 173 | None, 174 | ), 175 | ( 176 | Slots( 177 | _SlotTexts.slot_mapping, 178 | slot_re=Slots.slot_re.square_bracket, 179 | ), 180 | _SlotTexts.parentheses, 181 | None, 182 | ), 183 | ( 184 | Slots( 185 | _SlotTexts.slot_mapping, 186 | not_found=_SlotTexts.not_found_component, 187 | slot_re=Slots.slot_re.parentheses, 188 | ), 189 | _SlotTexts.parentheses, 190 | _SlotTexts.mapped_split, 191 | ), 192 | ), 193 | ) 194 | def test_slots(slots: Slots, text: str, expected: list[str] | None) -> None: 195 | result = slots.resolve_text(text) 196 | if expected is None: 197 | assert isinstance(result, tuple) 198 | assert len(result) == 1 199 | assert result[0] == text 200 | else: 201 | assert result == expected 202 | 203 | 204 | @pytest.mark.asyncio 205 | async def test_snippet_with_text_processor_and_slots() -> None: 206 | slot_mapping = { 207 | "message": html.p("Hope you're having fun."), 208 | "signature": html.p("Cheers, htmy"), 209 | } 210 | snippet = Snippet( 211 | Text(_hello_world_format_snippet), 212 | Slots(slot_mapping), 213 | text_processor=message_to_slot_text_processor, 214 | ) 215 | formatted_text = message_to_slot_text_processor(_hello_world_format_snippet, {}) 216 | children = await snippet.htmy({}) 217 | assert isinstance(children, tuple) 218 | assert children == ( 219 | formatted_text[: formatted_text.index("before message slot ") + len("before message slot ")], 220 | slot_mapping["message"], 221 | " between slots ", 222 | slot_mapping["signature"], 223 | formatted_text[formatted_text.index(" after signature slot") :], 224 | ) 225 | assert isinstance(children[0], SafeStr) 226 | assert isinstance(children[2], SafeStr) 227 | assert isinstance(children[4], SafeStr) 228 | 229 | rendered = await Renderer().render(snippet) 230 | assert rendered == _hello_world_format_snippet.format( 231 | message=( 232 | "before message slot " 233 | "

Hope you're having fun.

" 234 | " between slots " 235 | "

Cheers, htmy

" 236 | " after signature slot" 237 | ) 238 | ) 239 | -------------------------------------------------------------------------------- /docs/examples/snippet-slots-fastapi.md: -------------------------------------------------------------------------------- 1 | # Slot rendering with `Snippet` 2 | 3 | The built-in `Snippet` component can appear a bit intimidating at first with its relatively abstract, but quite powerful text processing features (`TextResolved` and `TextProcessor`), but it's actually quite simple to use. This example demonstrates how you can use it together with `Slots` to render plain `.html` files and replace slots in them with `htmy` components dynamically. 4 | 5 | In the example, we will build a `FastAPI` application that will serve our components using the `FastHX` library. Let's start by installing the required dependencies: `pip install fastapi fasthx htmy uvicorn`. 6 | 7 | We will use TailwindCSS v4 for styling, but it will be loaded from a CDN, so we don't need any JavaScript tooling. Also, you don't need to be familiar with TailwindCSS to understand the example, just ignore the styling. 8 | 9 | One additional note, before we start coding: the `MD` component (for markdown rendering) supports all the features of `Snippet`, so you can directly use all the patterns in this example with markdown files and the `MD` component. 10 | 11 | Our project structure will look like this: 12 | 13 | - `layout.html`: The HTML snippet for the `layout` `htmy` component. 14 | - `centered.html`: The HTML snippet for the `Centered` `htmy` component. 15 | - `app.py`: All our `htmy` components and the `FastAPI` application. 16 | 17 | Let's start by creating the `layout.html` file. Layouts often require a deeply nested component structure, so it's a good idea to use `Snippet` for then with dynamic slot rendering, because it improves performance and you can write almost the entire HTML structure in native `.html` files (without custom syntax). 18 | 19 | ```html 20 | 21 | 22 | 23 | Snippet with Slots 24 | 25 | 26 | 27 | 28 | 29 |
30 |

31 | Technologies: 32 | htmy, 38 | FastHX, 44 | FastAPI, 50 | TailwindCSSv4. 56 |

57 |
58 | 59 |
60 |
61 | 62 | 63 | ``` 64 | 65 | As you can see, we have the basic HTML document definition and some static content in this file, including a `` marker (plain HTML comment), which will be resolved by `Snippet` to the correct `htmy` component during rendering. 66 | 67 | Next, we will create the `centered.html` file, which will be a lot simpler. Actually, it's so simple we shouldn't even use `Snippet` for it (a plain `htmy` component would be simpler and more efficient), but we will, just to showcase multiple `Snippet` usage patterns. 68 | 69 | ```html 70 |
71 | 72 |
73 | ``` 74 | 75 | This HTML file also contains a `` marker, but the slot's key (`content`) could be anything else. The important thing, as you'll see below, is that the `Slots()` instance of the `Snippet()` contains the right component for the right slot key. 76 | 77 | We have all the HTML we need for our `Snippet`s, so we can finally get started with the application in `app.py`. We will do it step by step, starting with the `layout` component (factory): 78 | 79 | ```python 80 | from fastapi import FastAPI 81 | from fasthx.htmy import HTMY, CurrentRequest 82 | 83 | from htmy import ComponentType, Context, Fragment, Slots, Snippet, html 84 | 85 | 86 | def layout(*children: ComponentType) -> Snippet: 87 | """ 88 | Creates a `Snippet` that's configured to render `layout.html` with the given children 89 | components replacing the `content` slot. 90 | """ 91 | return Snippet( 92 | "layout.html", # Path to the HTML snippet. 93 | Slots({"content": children}), # Render all children in the "content" slot. 94 | ) 95 | ``` 96 | 97 | In this case, `layout` is not even an `htmy` component, it's just a simple function that returns a `Snippet` that's configured to load the `layout.html` file we previously created, and render the given children components in place of the `content` slot. 98 | 99 | Now we can implement the `Centered` component. In this case we use a slightly different pattern, we subclass the component from the `Fragment` component: this way we get the `_children` property without having to write the `__init__(self, *children)` method. 100 | 101 | ```python 102 | class Centered(Fragment): 103 | """Component that centers its children both vertically and horizontally.""" 104 | 105 | def htmy(self, context: Context) -> Snippet: 106 | return Snippet( 107 | "centered.html", # Path to the HTML snippet. 108 | Slots({"content": self._children}), # Render all children in the "content" slot. 109 | ) 110 | ``` 111 | 112 | Unlike `layout`, this component only creates the configured `Snippet` instance during rendering (in the `htmy()` method). In this simple case there's not much difference between the two patterns, but `Centered` could technically have extra state, take values from `context`, execute business logic, and use the extra data to configure the `Snippet` instance. 113 | 114 | We create one more component (`RequestHeaders`), just to have something that's not built with `Snippet`. This component simply shows all the headers from the current request in a grid: 115 | 116 | ```python 117 | class RequestHeaders: 118 | """Component that displays all the headers in the current request.""" 119 | 120 | def htmy(self, context: Context) -> ComponentType: 121 | # Load the current request from the context. 122 | request = CurrentRequest.from_context(context) 123 | return html.div( 124 | html.h2("Request headers:", class_="text-lg font-semibold pb-2"), 125 | html.div( 126 | *( 127 | # Convert header name and value pairs to fragments. 128 | Fragment(html.label(name + ":"), html.label(value)) 129 | for name, value in request.headers.items() 130 | ), 131 | class_="grid grid-cols-[max-content_1fr] gap-2", 132 | ), 133 | ) 134 | ``` 135 | 136 | The final step before creating the FastAPI application is to create a function that returns the content of the index page: 137 | 138 | ```python 139 | def index_page(_: None) -> Snippet: 140 | """ 141 | Component factory that returns the index page. 142 | 143 | Note that this function is not an `htmy` component at all, just a 144 | component factory that `fasthx` decorators can resolve. It must 145 | accept a single argument (the return value of the route) and return 146 | the component(s) that should be rendered. 147 | """ 148 | return layout(Centered(RequestHeaders())) 149 | ``` 150 | 151 | `index_page()`, similarly to `layout()` is also not a component, just a function that returns a component. Specifically, it shows the `RequestHeaders` component, centered in the page. We don't really need this function, we could use `lambda _: layout(Centered(RequestHeaders()))` instead in the `FastHX` `page()` decorator, but the example is more readable and easier to follow this way. 152 | 153 | Finally, we everything is ready, we can create the FastAPI application itself: 154 | 155 | ```python 156 | app = FastAPI() 157 | """The FastAPI application.""" 158 | 159 | htmy = HTMY() 160 | """ 161 | The `HTMY` instance (from `FastHX`) that takes care of component rendering 162 | through its route decorators. 163 | """ 164 | 165 | 166 | @app.get("/") 167 | @htmy.page(index_page) 168 | async def index() -> None: 169 | """The index route. It has no business logic, so it can remain empty.""" 170 | ... 171 | 172 | ``` 173 | 174 | The `@htmy.page()` decorator takes care of rendering the result of the `index()` route with the component the `index_page()` function returns. The only thing that remains is to run the application with `python -m uvicorn app:app`, open http://127.0.0.1:8000 in the browser, and see the result of our work. 175 | -------------------------------------------------------------------------------- /tests/test_md.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | from collections.abc import Callable 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from htmy import ( 8 | Component, 9 | ComponentType, 10 | PropertyValue, 11 | Renderer, 12 | Slots, 13 | Text, 14 | as_component_sequence, 15 | etree, 16 | html, 17 | md, 18 | ) 19 | from htmy.renderer import BaselineRenderer 20 | from htmy.typing import TextProcessor 21 | 22 | from .utils import tests_root 23 | 24 | _blog_post_format_string = """--- 25 | title: Markdown 26 | --- 27 | 28 | # {title} 29 | 30 | ```python 31 | import this 32 | ``` 33 | 34 | Also available [here](https://peps.python.org/pep-0020/). 35 | {slot} 36 | Inline `code` is **also** _fine_. 37 | 38 | # Lists 39 | 40 | ## Ordered 41 | 42 | 1. First 43 | 2. Second 44 | 3. Third 45 | 46 | ## Unordered 47 | 48 | - First 49 | - Second 50 | - Third 51 | """ 52 | 53 | _blog_post = _blog_post_format_string.format(title="Essential reading", slot="") 54 | 55 | # Paragraphs in line "Also available..." line are on 1 line in the format string, because 56 | # the renderer simply concatenates strings when everythin is resolved, and there won't be 57 | # a new line of an actual slot is rendered there. 58 | _parsed_blog_post_format_string = """

{title}

59 |
import this
 60 | 
61 | 62 |

Also available here.

{slot}

Inline code is also fine.

63 |

Lists

64 |

Ordered

65 |
    66 |
  1. First
  2. 67 |
  3. Second
  4. 68 |
  5. Third
  6. 69 |
70 |

Unordered

71 | """ 76 | 77 | # See the comment of _parsed_blog_post_format_string for why slot is `\n` by default. 78 | _parsed_blog_post = _parsed_blog_post_format_string.format(title="Essential reading", slot="\n") 79 | 80 | 81 | class ConverterRules: 82 | h1_classes = "text-xl font-bold" 83 | ol_classes = "list-decimal" 84 | ul_classes = "list-disc" 85 | 86 | @classmethod 87 | def _inject_classes( 88 | cls, comp: Callable[..., ComponentType], class_: str 89 | ) -> Callable[..., ComponentType]: 90 | def wrapper(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 91 | properties["class"] = f"{class_} {properties.get('class', '')}" 92 | return comp(*children, **properties) 93 | 94 | return wrapper 95 | 96 | @classmethod 97 | def rules(cls) -> dict[str, Callable[..., ComponentType]]: 98 | return { 99 | "h1": cls._inject_classes(html.h1, cls.h1_classes), 100 | "ol": cls._inject_classes(html.ol, cls.ol_classes), 101 | "ul": cls._inject_classes(html.ul, cls.ul_classes), 102 | } 103 | 104 | 105 | _base_etree_converted_blogpost = """

Essential reading

106 |
import this
107 | 
108 | 109 |

Also available here.

110 |

Inline code is also fine.

111 |

Lists

112 |

Ordered

113 |
    {extra_separator} 114 |
  1. First
  2. {extra_separator} 115 |
  3. Second
  4. {extra_separator} 116 |
  5. Third
  6. {extra_separator} 117 |
118 |

Unordered

119 | """ 124 | 125 | _etree_converted_blogpost = _base_etree_converted_blogpost.format( 126 | h1_attrs="", ol_attrs="", ul_attrs="", extra_separator="" 127 | ) 128 | _etree_converted_blogpost_with_extra_classes = _base_etree_converted_blogpost.format( 129 | h1_attrs=f'class="{ConverterRules.h1_classes} "', 130 | ol_attrs=f'class="{ConverterRules.ol_classes} "', 131 | ul_attrs=f'class="{ConverterRules.ul_classes} "', 132 | extra_separator="\n\n", 133 | ) 134 | 135 | 136 | def _md_renderer(children: Component, metadata: md.MarkdownMetadataDict | None) -> Component: 137 | assert isinstance(metadata, dict) 138 | title = metadata.get("title") 139 | assert isinstance(title, list) # Items in the parsed metadata are an array. 140 | assert len(title) == 1 141 | return html.div(html.h1(title[0]), *as_component_sequence(children)) 142 | 143 | 144 | @pytest.mark.asyncio 145 | async def test_md_with_slot() -> None: 146 | md_component = md.MD( 147 | Text(_blog_post_format_string), 148 | Slots({"comment": html.p("Comment slot resolved.")}), 149 | text_processor=lambda text, _: text.format( 150 | title="Essential reading", slot="" 151 | ), 152 | ) 153 | rendered = await Renderer().render(md_component) 154 | assert rendered == _parsed_blog_post_format_string.format( 155 | title="Essential reading", 156 | slot="

Comment slot resolved.

", 157 | ) 158 | 159 | 160 | @pytest.mark.asyncio 161 | @pytest.mark.parametrize( 162 | ("path_or_text", "text_processor", "expected"), 163 | ( 164 | ("tests/data/blog-post.md", None, _parsed_blog_post), 165 | (tests_root / "data" / "blog-post.md", None, _parsed_blog_post), 166 | (Text(_blog_post), None, _parsed_blog_post), 167 | ( 168 | Text(_blog_post_format_string), 169 | lambda text, _: text.format(title="Essential reading", slot=""), 170 | _parsed_blog_post, 171 | ), 172 | ), 173 | ) 174 | async def test_parsing( 175 | path_or_text: Text | str | Path, text_processor: TextProcessor, expected: str 176 | ) -> None: 177 | md_component = md.MD(path_or_text, text_processor=text_processor) 178 | rendered = await Renderer().render(md_component) 179 | assert isinstance(rendered, str) 180 | assert rendered == expected 181 | 182 | rendered = await BaselineRenderer().render(md_component) 183 | assert isinstance(rendered, str) 184 | assert rendered == expected 185 | 186 | 187 | @pytest.mark.asyncio 188 | @pytest.mark.parametrize( 189 | ("path_or_text", "components", "text_processor", "expected"), 190 | ( 191 | ("tests/data/blog-post.md", {}, None, _parsed_blog_post), 192 | (tests_root / "data" / "blog-post.md", {}, None, _parsed_blog_post), 193 | (Text(_blog_post), {}, None, _parsed_blog_post), 194 | ( 195 | "tests/data/blog-post.md", 196 | {"invalid": lambda _: ""}, 197 | None, 198 | _etree_converted_blogpost, 199 | ), 200 | ( 201 | tests_root / "data" / "blog-post.md", 202 | {"invalid": lambda _: ""}, 203 | None, 204 | _etree_converted_blogpost, 205 | ), 206 | ( 207 | Text(_blog_post), 208 | {"invalid": lambda _: ""}, 209 | None, 210 | _etree_converted_blogpost, 211 | ), 212 | ( 213 | "tests/data/blog-post.md", 214 | ConverterRules.rules(), 215 | None, 216 | _etree_converted_blogpost_with_extra_classes, 217 | ), 218 | ( 219 | tests_root / "data" / "blog-post.md", 220 | ConverterRules.rules(), 221 | None, 222 | _etree_converted_blogpost_with_extra_classes, 223 | ), 224 | (Text(_blog_post), ConverterRules.rules(), None, _etree_converted_blogpost_with_extra_classes), 225 | ( 226 | Text(_blog_post_format_string), 227 | ConverterRules.rules(), 228 | lambda text, _: text.format(title="Essential reading", slot=""), 229 | _etree_converted_blogpost_with_extra_classes, 230 | ), 231 | ), 232 | ) 233 | async def test_parsing_and_conversion( 234 | path_or_text: Text | str | Path, 235 | components: dict[str, Callable[..., ComponentType]], 236 | text_processor: TextProcessor, 237 | expected: str, 238 | ) -> None: 239 | converter = etree.ETreeConverter(components) 240 | md_component = md.MD(path_or_text, converter=converter.convert, text_processor=text_processor) 241 | rendered = await Renderer().render(md_component) 242 | assert rendered == expected 243 | 244 | rendered = await BaselineRenderer().render(md_component) 245 | assert rendered == expected 246 | 247 | md_component_with_renderer = md.MD( 248 | path_or_text, converter=converter.convert, renderer=_md_renderer, text_processor=text_processor 249 | ) 250 | rendered = await Renderer().render(md_component_with_renderer) 251 | assert rendered == "\n".join( 252 | ( 253 | "
", 254 | "

Markdown

", 255 | expected, 256 | "
", 257 | ) 258 | ) 259 | 260 | rendered = await BaselineRenderer().render(md_component_with_renderer) 261 | assert rendered == "\n".join( 262 | ( 263 | "
", 264 | "

Markdown

", 265 | expected, 266 | "
", 267 | ) 268 | ) 269 | -------------------------------------------------------------------------------- /docs/components-guide.md: -------------------------------------------------------------------------------- 1 | # Components guide 2 | 3 | ## What is a component? 4 | 5 | Every object with a sync or async `htmy(context: Context) -> Component` method is an `htmy` component (technically an `HTMYComponentType`). Strings are also components, as well as lists or tuples of `HTMYComponentType` or string objects. 6 | 7 | Using the `htmy()` method name enables the conversion of any of your pre-existing business objects and Python utilities -- from `TypedDicts`s or `pydantic` models to ORM classes, and even advanced constructs like descriptors -- into components without the fear of name collision or compatibility issues with other tools. 8 | 9 | (Note: while many code examples in the documentation use `dataclasses` to create components, the only reason for this is that `dataclasses` save a lot of boilerplate code and make the examples more readable.) 10 | 11 | With the technical details out of the way, let's see some examples with built-in Python types: 12 | 13 | ```python 14 | import asyncio 15 | from datetime import datetime 16 | 17 | from htmy import Component, ComponentType, Context, Renderer, html, join_components 18 | 19 | 20 | class HTMYDatetime(datetime): 21 | """ 22 | Datetime subclass that's also a component thanks to its `htmy()` classmethod. 23 | 24 | The class itself is the component. Rendering either the class or an instance 25 | of it creates a `

` tag with the *current* date and time information in it. 26 | """ 27 | 28 | @classmethod 29 | def htmy(cls, _: Context) -> Component: 30 | return html.p("The current date and time is: ", cls.now().isoformat()) 31 | 32 | 33 | class ULDict(dict[str, ComponentType]): 34 | """ 35 | Dictionary that maps string keys to `htmy` components. 36 | 37 | Instances of this dictionary are `htmy` components, that render the items in 38 | the dictionary as `

  • ` tags inside a `
      ` tag. 39 | """ 40 | 41 | def htmy(self, _: Context) -> Component: 42 | return html.ul(*(html.li(k, ": ", v) for k, v in self.items())) 43 | 44 | 45 | class Coordinate(tuple[float, float, float]): 46 | """ 47 | Tuple that represents a 3D coordinate. 48 | 49 | During rendering, an origin coordinate is loaded from the rendering context, 50 | and the calculated absolute coordinate will be rendered as a `

      ` tag. 51 | """ 52 | 53 | def htmy(self, context: Context) -> Component: 54 | origin: tuple[float, float, float] = context["origin"] 55 | return html.p(f"Coordinates: ({self[0] + origin[0]}, {self[1] + origin[1]}, {self[2] + origin[2]})") 56 | 57 | 58 | class OrderedList(list[ComponentType]): 59 | """ 60 | List of `htmy` components. 61 | 62 | Instances are rendered as an `

        ` tag with the list items inside, wrapped by `
      1. ` 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 | --------------------------------------------------------------------------------