├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── docs ├── .DS_Store ├── CNAME ├── README.md ├── advanced.md ├── assets │ ├── extra.css │ └── markupy.svg ├── django.md ├── elements.md ├── flask.md ├── html2markupy.md ├── reusability.md └── starlette.md ├── examples ├── django │ ├── db.sqlite3 │ ├── manage.py │ ├── markupyapp │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates │ │ │ └── markupyapp │ │ │ │ └── base.html │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ └── markupyproject │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py └── starlette │ └── html_response.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── benchmark_big_table.py ├── docs.sh ├── fix.sh ├── performance.py ├── release.sh └── test.sh ├── src ├── markupy │ ├── __init__.py │ ├── _private │ │ ├── attributes │ │ │ ├── __init__.py │ │ │ ├── attribute.py │ │ │ ├── handlers.py │ │ │ ├── html.py │ │ │ └── store.py │ │ ├── html_to_markupy │ │ │ ├── __init__.py │ │ │ ├── cli.py │ │ │ └── parser.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── component.py │ │ │ ├── element.py │ │ │ ├── fragment.py │ │ │ └── view.py │ ├── attributes.py │ ├── elements.py │ ├── exceptions.py │ └── py.typed └── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_attribute_handler.py │ ├── test_attributes.py │ ├── test_attributes_dict.py │ ├── test_attributes_kwargs.py │ ├── test_attributes_obj.py │ ├── test_attributes_selector.py │ ├── test_children.py │ ├── test_cli.py │ ├── test_component.py │ ├── test_django.py │ ├── test_element.py │ ├── test_flask.py │ ├── test_fragment.py │ ├── test_starlette.py │ ├── test_template.py │ └── test_view.py └── uv.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | ruff: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: astral-sh/setup-uv@v2 14 | - run: uv python install 15 | - run: uv sync --all-extras --dev 16 | - run: uv run ruff check --output-format=github ./src 17 | - run: uv run ruff format --diff ./src 18 | 19 | pytest: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: astral-sh/setup-uv@v2 27 | - run: uv python install ${{ matrix.python-version }} 28 | - run: uv sync --all-extras --dev 29 | - run: uv run pytest 30 | 31 | mypy: 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: astral-sh/setup-uv@v2 39 | - run: uv python install ${{ matrix.python-version }} 40 | - run: uv sync --all-extras --dev 41 | - run: uv run mypy src 42 | 43 | pyright: 44 | runs-on: ubuntu-latest 45 | strategy: 46 | matrix: 47 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: astral-sh/setup-uv@v2 51 | - run: uv python install ${{ matrix.python-version }} 52 | - run: uv sync --all-extras --dev 53 | - run: uv run pyright 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | dist/ 4 | site/ 5 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Guillaume Granger 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. -------------------------------------------------------------------------------- /docs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/docs/.DS_Store -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | markupy.witiz.com -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Overview 6 | 7 | markupy is a plain Python alternative to traditional templates engines for generating HTML code. 8 | 9 | **Writing this code in Python with markupy...** 10 | 11 | ```python 12 | # Import "elements" like they were regular Python objects 13 | from markupy.elements import A, Body, Head, Html, Li, P, Title, Ul 14 | 15 | menu = [("Home", "/"), ("About us", "/about"), ("Contact", "/contact")] 16 | print( 17 | Html[ 18 | Head[Title["My website"]], 19 | Body[ 20 | P["Table of contents:"], 21 | Ul(".menu")[(Li[A(href=url)[title]] for title, url in menu)], 22 | ], 23 | ] 24 | ) 25 | ``` 26 | 27 | **...will generate this HTML:** 28 | 29 | ```html 30 | 31 | 32 | 33 | My website 34 | 35 | 36 |

Table of contents:

37 | 42 | 43 | 44 | ``` 45 | 46 | Seems interesting? Try it by yourself with our [online html2markupy converter](https://html2markupy.witiz.com). 47 | 48 | ## Motivation 49 | 50 | Like most Python web developers, we have relied on template engines (Jinja, Django, ...) since forever to generate HTML on the server side. Although this is fine for simple needs, when your site grows bigger, you might start facing some issues: 51 | 52 | - More an more Python code get put into unreadable and untestable macros 53 | 54 | - Extends and includes make it very hard to track required parameters 55 | 56 | - Templates are very permissive regarding typing making it more error prone 57 | 58 | If this is you struggling with templates, then you should definitely give markupy a try! 59 | 60 | ## Inspiration 61 | 62 | markupy started as a fork of [htpy](https://htpy.dev). Even though the two projects are still conceptually very similar, we started markupy in order to support a slightly different syntax to optimize readability, reduce risk of conflicts with variables, and better support for non native html attributes syntax. On top of that, markupy provides a first class support for class based components. 63 | 64 | ## Key Features 65 | 66 | - **Leverage static types:** Use [mypy](https://mypy.readthedocs.io/en/stable/) or [pyright](https://github.com/microsoft/pyright) to type check your code. 67 | 68 | - **Great debugging:** Avoid cryptic stack traces from templates. Use your favorite Python debugger. 69 | 70 | - **Easy to extend:** There is no special way to define template tags/filters. Just call regular functions. 71 | 72 | - **Works with existing Python web framework:** Works great with Django, Flask or any other Python web framework! 73 | 74 | - **Works great with htmx:** markupy makes for a great experience when writing server rendered partials/components. 75 | 76 | - **Create reusable components:** Define components, snippets, complex layouts/pages as regular Python variables or functions. 77 | 78 | - **Familiar concepts from React:** React helped make it popular writing HTML with a programming language. markupy uses a lot of similar constructs. 79 | 80 | - **Customizable:** Use or create 3rd party libraries to leverage the power of markupy 81 | 82 | ## Philosophy 83 | 84 | markupy generates HTML elements and attributes and provide a few helpers. 85 | 86 | markupy does not enforce any particular pattern or style to organize 87 | your pages, components and layouts. That does not mean that markupy cannot be used 88 | to build sophisticated web pages or applications. 89 | 90 | Rather the opposite: you are encouraged the leverage the power of Python to 91 | structure your project. Use modules, classes, functions, decorators, list 92 | comprehension, generators, conditionals, static typing and any other feature of 93 | Python to organize your components. This gives you a lot of power and makes markupy 94 | scale from a small one file project to bigger applications. 95 | 96 | 97 | ## Installation 98 | 99 | [markupy is available on PyPI](https://pypi.org/project/markupy/). You may install the latest version using pip: 100 | 101 | ``` 102 | pip install markupy 103 | ``` 104 | 105 | ## Documentation 106 | 107 | The full documentation is available at [markupy.witiz.com](https://markupy.witiz.com): 108 | 109 | - [Mastering elements](https://markupy.witiz.com/elements/) 110 | - [Advanced usage](https://markupy.witiz.com/advanced/) 111 | - [Reusability with Fragments and Components](https://markupy.witiz.com/reusability/) 112 | - [Integrating with Django](https://markupy.witiz.com/django/) 113 | - [Integrating with Flask](https://markupy.witiz.com/flask/) 114 | - [Integrating with Starlette](https://markupy.witiz.com/starlette/) 115 | - [html2markupy](https://markupy.witiz.com/html2markupy/) -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced usage 2 | 3 | ## Loops and conditions 4 | 5 | ### Looping / iterating over content 6 | 7 | You can pass any iterable such as `list`, `tuple` or `generator` to generate multiple children: 8 | 9 | ```python title="Iterate over a generator" 10 | >>> from markupy.elements import Ul, Li 11 | >>> print(Ul[(Li[letter] for letter in "abc")]) 12 | 13 | ``` 14 | 15 | 16 | A `list` can be used similar to a [JSX fragment](https://react.dev/reference/react/Fragment): 17 | 18 | ```python title="Render a list of child elements" 19 | >>> from markupy.elements import Div, Img 20 | >>> my_images = [Img(src="a.jpg"), Img(src="b.jpg")] 21 | >>> print(Div[my_images]) 22 |
23 | ``` 24 | ### Conditional rendering 25 | 26 | Children that evaluate to `True`, `False` and `None` will not be rendered. 27 | Python's `and` and `or` operators will [short-circuit](https://docs.python.org/3/library/stdtypes.html#boolean-operations-and-or-not). 28 | You can use this to conditionally render content with inline `and` and `or`. 29 | 30 | ```python title="Conditional rendering with a value that may be None" 31 | 32 | >>> from markupy.elements import Div, Strong 33 | 34 | # No tag will be rendered since error is None 35 | >>> error = None 36 | >>> print(Div[error and Strong[error]]) 37 |
38 | 39 | >>> error = "Email address is invalid." 40 | >>> print(Div[error and Strong[error]]) 41 |
Email address is invalid.
42 | 43 | # Inline if/else can also be used: 44 | >>> print(Div[Strong[error] if error else None]) 45 |
Email address is invalid.
46 | ``` 47 | 48 | ```python title="Conditional rendering based on a bool variable" 49 | >>> from markupy.elements import Div 50 | 51 | >>> is_allowed = True 52 | >>> print(Div[is_allowed and "Access granted!"]) 53 |
Access granted!
54 | >>> print(Div[is_allowed or "Access denied!"]) 55 |
56 | 57 | >>> is_allowed = False 58 | >>> print(Div[is_allowed and "Access granted!"]) 59 |
60 | >>> print(Div[is_allowed or "Access denied!"]) 61 |
Access denied
62 | ``` 63 | 64 | ## String escaping 65 | 66 | ### Element content escaping 67 | 68 | Element contents are automatically escaped to avoid [XSS vulnerabilities](https://owasp.org/www-community/attacks/xss/). 69 | 70 | ```python title="String escaping in action" 71 | >>> from markupy.elements import H1 72 | >>> user_supplied_name = "l33t " 73 | >>> print(H1[f"hello {user_supplied_name}"]) 74 |

hello l33t </h1>

75 | ``` 76 | 77 | !!! warning "An exception for `script` and `style` tags" 78 | 79 | Script and style tags are special because they usually expect their content to be respectively javascript and css code. In order for code to work properly, `Script` and `Style` child nodes will not be automatically escaped. Keep in mind that you will need to escape sensitive values yourself inside these 2 tags. 80 | 81 | 82 | If you have HTML markup that you want to insert without further escaping, wrap 83 | it in `Markup` from the [markupsafe](https://markupsafe.palletsprojects.com/) 84 | library. markupsafe is a dependency of markupy and is automatically installed: 85 | 86 | ```python title="Injecting markup" 87 | >>> from markupy.elements import Div 88 | >>> from markupsafe import Markup 89 | >>> print(Div[Markup("")]) 90 |
91 | ``` 92 | 93 | If you are generating [Markdown](https://pypi.org/project/Markdown/) and want to insert it into an element, use `Markup`: 94 | 95 | ```python title="Injecting generated markdown" 96 | >>> from markdown import markdown 97 | >>> from markupsafe import Markup 98 | >>> from markupy.elements import Div 99 | >>> print(Div[Markup(markdown('# Hi'))]) 100 |

Hi

101 | ``` 102 | 103 | ### Element attributes escaping 104 | 105 | Attributes are always escaped. This makes it possible to pass arbitrary HTML 106 | fragments or scripts as attributes. The output may look a bit obfuscated since 107 | all unsafe characters are escaped but the browser will interpret it correctly: 108 | 109 | ```python 110 | >>> from markupy.elements import Button 111 | >>> print(Button(id="example", onclick="let name = 'bob'; alert('hi' + name);")["Say hi"]) 112 | 113 | ``` 114 | 115 | In the browser, the parsed attribute as returned by 116 | `document.getElementById("example").getAttribute("onclick")` will be the 117 | original string `let name = 'bob'; alert('hi' + name);`. 118 | 119 | Escaping will happen whether or not the value is wrapped in `markupsafe.Markup` 120 | or not. This may seem confusing at first but is useful when embedding HTML 121 | snippets as attributes: 122 | 123 | ```python title="Escaping of Markup" 124 | >>> from markupy.elements import Ul 125 | >>> from markupsafe import Markup 126 | >>> # This markup may come from another library/template engine 127 | >>> some_markup = Markup("""
  • """) 128 | >>> print(Ul(dataTemplate=some_markup)) 129 | 130 | ``` 131 | 132 | ## Special elements 133 | 134 | ### Custom elements / Web components 135 | 136 | [Custom elements / web components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) are HTML elements that contains at least one dash (`-`). Since `-` cannot be used in Python identifiers, here's how you'd write them in markupy: 137 | 138 | ```python title="Custom elements with CapitalizedCase syntax" 139 | >>> from markupy.elements import MyCustomElement 140 | >>> print(MyCustomElement['hi!']) 141 | hi! 142 | ``` 143 | 144 | ### HTML doctype 145 | 146 | The [HTML5 doctype](https://developer.mozilla.org/en-US/docs/Glossary/Doctype) is automatically prepended to the `` tag: 147 | 148 | ```python 149 | >>> from markupy.elements import Html 150 | >>> print(Html) 151 | 152 | ``` 153 | 154 | ### HTML comments 155 | 156 | Since the Python code is the source of the HTML generation, to add a comment to 157 | the code, most of the time regular Python comments (`#`) are used. 158 | 159 | If you want to emit HTML comments that will be visible in the browser, you need to initialize a special element whose name is `_`: 160 | 161 | ```python 162 | >>> from markupy.elements import Div, _ 163 | >>> print(Div[_["This is a HTML comment"]]) 164 |
    165 | ``` 166 | 167 | Given that a comment is a `Element`, you can wrap other elements as children: 168 | 169 | ```python 170 | >>> from markupy.elements import Div, Strong, _ 171 | >>> print(Div[_["This is a HTML comment", Strong["Hidden text"]]]) 172 |
    173 | ``` 174 | 175 | 176 | ## Advanced attributes 177 | 178 | ### Boolean attributes 179 | 180 | In HTML, boolean attributes such as `disabled` are considered "true" when they 181 | exist. Specifying an attribute as `True` will make it appear (without a value). 182 | `False` will make it hidden. This is useful and brings the semantics of `bool` to 183 | HTML. 184 | 185 | ```python title="True bool attribute" 186 | >>> from markupy.elements import Button 187 | >>> print(Button(disabled=True)) 188 | 189 | ``` 190 | 191 | ```python title="False bool attribute" 192 | >>> from markupy.elements import Button 193 | >>> print(Button(disabled=False)) 194 | 195 | ``` 196 | 197 | ### 3rd party attributes libraries 198 | 199 | The builtin `markupy.attributes` module provides a complete list of HTML5 attributes. 200 | In addition, markupy is exposing all the required APIs for 3rd party libraries to implement attributes helpers specific to any framework or library. If you are interested in developping your own `markupy` addons, you might be interested in "[Attribute Handlers](#attribute-handlers)". 201 | 202 | We will list here any package we might be aware of. 203 | 204 | - [markupy_htmx](https://github.com/witiz/markupy_htmx): Provides convenient Python methods to build HTMX attributes 205 | 206 | ### Attribute Handlers 207 | 208 | Attribute Handlers are a powerful way to extend behaviour of attributes. Most users will not benefit from using them directly, but it can be useful in some cases or for library maintainers. 209 | 210 | An attribute handler is a user-defined function that gets called every time an attribute is set on an element. This allows to intercept changes and modify the attribute on the fly if needed before it is persisted. 211 | 212 | Let's show a concrete example. Let's say you want all boolean attributes value to be toggled from True to False and vice versa (don't do that for real, your users might be really upset at you). 213 | 214 | We'll start by implementing an attribute handler for that. It's a function that you can name howerver you like, although you must respect its signature (parameters and return types): 215 | 216 | ```python 217 | from markupy import Attribute 218 | 219 | def liar_attribute_handler(old: Attribute | None, new: Attribute) -> Attribute | None: 220 | if isinstance(new.value, bool): 221 | # here, we toggle the attribute value if it's a boolean 222 | new.value = not new.value 223 | # do not "return new" to let other potential handlers do their job 224 | return None 225 | ``` 226 | 227 | Let's detail the parameters and return value of an handler: 228 | 229 | - `old`: the previous/current instance of the attribute. It can be `None` if the attribute has not been set previously for a given `Element`. Otherwise, it will be an instance of `Attribute`, a very lightweight object with only 2 properties: `name` and `value`. 230 | - `new`: the instance of the `Attribute` that is about to be updated. Its `value` property is mutable so you can update it in place. 231 | - return type depends on what to do next: 232 | - returning `None` tells `markupy` to continue processing other registered handlers before persisting. Handlers are processed in the reverse order of registration (most recent first). 233 | - returning an instance of `Attribute` instructs `markupy` to either: 234 | - stop processing handlers and persist immediately the returned instance if `new` is returned 235 | - restart a handlers chain processing if a new instance of `Attribute(name, value)` is returned 236 | 237 | Now that our handler is defined, we need to register it. This can be done in 2 different ways: 238 | 239 | ```python title="Usage of attribute_handlers.register() method" 240 | from markupy import attribute_handlers 241 | 242 | attribute_handlers.register(liar_attribute_handler) 243 | ``` 244 | 245 | ```python title="Usage of @attribute_handlers.register decorator" 246 | from markupy import Attribute, attribute_handlers 247 | 248 | @attribute_handlers.register 249 | def liar_attribute_handler(old: Attribute | None, new: Attribute) -> Attribute | None: 250 | ... 251 | ``` 252 | 253 | And that's it. Now we can try to assign boolean attributes to any element and see what happens: 254 | 255 | ```python 256 | from markupy import elements as el 257 | 258 | print(el.Input(disabled=True)) # 259 | print(el.Input(disabled=False)) # 260 | ``` 261 | 262 | ## Streaming / Iterating of the Output 263 | 264 | Iterating over a markupy element will yield the resulting contents in chunks as 265 | they are rendered: 266 | 267 | ```python 268 | >>> from markupy.elements import Ul, Li 269 | >>> for chunk in Ul[Li["a"], Li["b"]]: 270 | ... print(f"got a chunk: {chunk!r}") 271 | ... 272 | got a chunk: '' 280 | ``` 281 | 282 | !!! note 283 | 284 | This feature can be leveraged to stream HTML contents by returning a generator instead of a fully generated str. How to integrate this is heavily depending on which framework you are using to power your website. 285 | -------------------------------------------------------------------------------- /docs/assets/extra.css: -------------------------------------------------------------------------------- 1 | :root>* { 2 | --md-primary-fg-color: #306998; 3 | --md-primary-fg-color--light: #306998; 4 | --md-primary-fg-color--dark: #306998; 5 | } -------------------------------------------------------------------------------- /docs/assets/markupy.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 14 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 68 | 72 | 76 | 80 | 84 | 88 | 92 | 96 | 100 | 104 | 108 | 112 | 113 | -------------------------------------------------------------------------------- /docs/django.md: -------------------------------------------------------------------------------- 1 | # Integrating with Django 2 | 3 | ## Returning a markupy Response 4 | 5 | markupy elements can be passed directly to `HttpResponse`: 6 | 7 | ```py title="views.py" 8 | from django.http import HttpResponse 9 | from markupy.elements import H1 10 | 11 | def my_view(request): 12 | return HttpResponse(H1["Hi Django!"]) 13 | ``` 14 | 15 | ## Using markupy as part of an existing Django template 16 | 17 | markupy elements are marked as "safe" and can be injected directly into Django 18 | templates. This can be useful if you want to start using markupy gradually in an 19 | existing template based Django project: 20 | 21 | ```html title="base.html" 22 | 23 | 24 | My Django Site 25 | 26 | 27 | {{ content }} 28 | 29 | 30 | ``` 31 | 32 | ```py title="views.py" 33 | from django.shortcuts import render 34 | 35 | from markupy.elements import H1 36 | 37 | 38 | def index(request): 39 | return render(request, "base.html", { 40 | "content": H1["Welcome to my site!"], 41 | }) 42 | ``` 43 | 44 | ## Render a Django Form 45 | 46 | CSRF token, form widgets and errors can be directly used within markupy elements: 47 | 48 | ```py title="forms.py" 49 | from django import forms 50 | 51 | 52 | class MyForm(forms.Form): 53 | name = forms.CharField() 54 | ``` 55 | 56 | ```py title="views.py" 57 | from django.http import HttpRequest, HttpResponse 58 | 59 | from .components import my_form_page, my_form_success_page 60 | from .forms import MyForm 61 | 62 | 63 | def my_form(request: HttpRequest) -> HttpResponse: 64 | form = MyForm(request.POST or None) 65 | if form.is_valid(): 66 | return HttpResponse(my_form_success_page()) 67 | 68 | return HttpResponse(my_form_page(request, my_form=form)) 69 | 70 | ``` 71 | 72 | ```py title="components.py" 73 | from django.http import HttpRequest 74 | from django.template.backends.utils import csrf_input 75 | 76 | from markupy import View 77 | from markupy.elements import Body, Button, Form, H1, Head, Html, Title 78 | 79 | from .forms import MyForm 80 | 81 | 82 | def base_page(title: str, content: View) -> View: 83 | return Html[ 84 | Head[Title[title]], 85 | Body[content], 86 | ] 87 | 88 | 89 | def my_form_page(request: HttpRequest, *, form: MyForm) -> View: 90 | return base_page( 91 | "My form", 92 | form(method="post")[ 93 | csrf_input(request), 94 | form.errors, 95 | form["name"], 96 | Button["Submit!"], 97 | ], 98 | ) 99 | 100 | 101 | def my_form_success_page() -> View: 102 | return base_page( 103 | "Success!", 104 | H1["Success! The form was valid!"], 105 | ) 106 | ``` 107 | 108 | ## Implement Custom Form Widgets With markupy 109 | 110 | You can implement a custom form widget directly with markupy like this: 111 | 112 | ```py title="widgets.py" 113 | from django.forms import widgets 114 | 115 | from markupy.elements import SlInput 116 | 117 | 118 | class ShoelaceInput(widgets.Widget): 119 | """ 120 | A form widget using Shoelace's element. 121 | More info: https://shoelace.style/components/input 122 | """ 123 | 124 | def render(self, name, value, attrs=None, renderer=None): 125 | return str(SlInput(attrs, name=name, value=value)) 126 | ``` 127 | -------------------------------------------------------------------------------- /docs/elements.md: -------------------------------------------------------------------------------- 1 | # Mastering elements 2 | 3 | ## Importing elements 4 | 5 | HTML elements are imported directly from the `markupy.elements` module as their name using the CapitalizedCase syntax. Although HTML elements are usually spelled in lower case, using CapitalizedCase in markupy avoids naming conflicts with your own variables and makes it easier to distinguish markupy tags vs other parts of your code. 6 | 7 | ```python title="Importing elements" 8 | >>> from markupy.elements import Div 9 | >>> print(Div) 10 |
    11 | ``` 12 | 13 | You can import elements individually as shown above, or you can use an alias to dynamically invoke elements as you need them: 14 | 15 | ```python title="Importing elements with alias" 16 | >>> from markupy.elements as el 17 | >>> print(el.Div) 18 |
    19 | >>> print(el.Input) 20 | 21 | ``` 22 | 23 | ## Element content 24 | 25 | Content of elements is specified using square brackets `[]` syntax on an element. 26 | Let's take our example from above and specify content for our `div`: 27 | 28 | ```python title="Div content" 29 | >>> from markupy.elements import Div 30 | >>> print(Div["Hello World!"]) 31 |
    Hello World!
    32 | ``` 33 | 34 | Content can be strings, ints, lists, other elements, etc... 35 | Basically, whatever can be iterated and/or stringified is a valid content. 36 | And you are not limited to just one child, can be as meny as you want: 37 | 38 | ```python title="Nested elements" 39 | >>> from markupy.elements import Div, H1 40 | >>> print(Div[H1["Lorem ipsum"], "Hello World!"]) 41 |

    Lorem ipsum

    Hello World!
    42 | ``` 43 | 44 | !!! note "Don't forget to close your tags" 45 | 46 | Another main advantage of the markupy syntax over raw HTML is that you don't have to repeat the tag name to close an element. Of course you still need to close your tags with a closing bracket `]` but this is much more straightforward and your IDE should help you matching/indenting them fairly easily. 47 | 48 | 49 | ## Element attributes 50 | 51 | HTML attributes are specified by using parenthesis `()` syntax on an element. 52 | 53 | ```python title="Element attributes" 54 | >>> from markupy.elements import Div 55 | >>> print(Div(id="container", style="color:red")) 56 |
    57 | ``` 58 | 59 | They can be specified in different ways. 60 | 61 | ### Elements without attributes 62 | 63 | For elements that you do not want attributes, they can be specified by just the element itself: 64 | 65 | ```python 66 | >>> from markupy.elements import Hr 67 | >>> print(Hr) 68 |
    69 | ``` 70 | 71 | ### Keyword attributes 72 | 73 | Attributes can be specified via keyword arguments, also known as kwargs: 74 | 75 | ```python 76 | >>> from markupy.elements import Img 77 | >>> print(Img(src="picture.jpg")) 78 | 79 | ``` 80 | 81 | In Python, some names such as `class` and `for` are reserved and cannot be used as keyword arguments. Instead, they can be specified as `class_` or `for_` when using keyword arguments: 82 | 83 | ```python 84 | >>> from markupy.elements import Label 85 | >>> print(Label(for_="myfield")) 86 | 87 | ``` 88 | 89 | Attributes that contains dashes `-` can be specified by using underscores: 90 | 91 | ```python 92 | >>> from markupy.elements import Form 93 | >>> print(Form(hx_post="/foo")) 94 |
    95 | ``` 96 | 97 | ### Selector string shorthand for id and class 98 | 99 | Defining `id` and `class` attributes is common when writing HTML. A string shorthand 100 | that looks like a CSS selector can be used to quickly define id and classes: 101 | 102 | ```python title="Define id" 103 | >>> from markupy.elements import Div 104 | >>> print(Div("#myid")) 105 |
    106 | ``` 107 | 108 | ```python title="Define multiple classes" 109 | >>> from markupy.elements import Div 110 | >>> print(Div(".foo.bar")) 111 |
    112 | ``` 113 | 114 | ```python title="Combining both id and classes" 115 | >>> from markupy.elements import Div 116 | >>> print(Div("#myid.foo.bar")) 117 |
    118 | ``` 119 | 120 | !!! warning "Selector string format" 121 | 122 | The selector string should begin with the `#id` if present, then followed by `.classes` definition. 123 | 124 | ### Dict attributes 125 | 126 | Attributes can also be specified as a `dict`. This is useful when using 127 | attributes that are reserved Python keywords (like `for` or `class`), when the 128 | attribute name contains special characters or when you want to define attributes 129 | dynamically. 130 | 131 | ```python title="Using Alpine.js with @-syntax (shorthand for x-on)" 132 | >>> from markupy.elements import Button 133 | >>> print(Button({"@click.shift": "addToSelection()"})) 134 | 135 | ``` 136 | 137 | ```python title="Using an attribute with a reserved keyword" 138 | >>> from markupy.elements import Label 139 | >>> print(Label({"for": "myfield"})) 140 | 141 | ``` 142 | 143 | ### Tuple attributes 144 | 145 | Attributes can be defined as tuples like so: 146 | 147 | ```python title="Attributes as tuples" 148 | >>> from markupy.elements import Button 149 | >>> print(Button(("class", "btn btn-primary"), ("disabled", True))) 150 | 151 | ``` 152 | 153 | 154 | ### Object attributes 155 | 156 | Finally there is one last way to define attributes and it is very powerful, it is called "object attributes", athough it's very transparent as a user since you're only ever calling functions that build those objects for you. 157 | 158 | ```python title="Using object attributes" 159 | >>> from markupy import attributes as at 160 | >>> from markupy.elements import Input 161 | >>> print(Input(at.id("myid"), at.tabindex(3), at.disabled(True))) 162 | 163 | ``` 164 | 165 | There are multiple benefits of defining attributes this way: 166 | 167 | - Suggestion: your IDE will suggest what attributes you can use 168 | - Type hinting: attributes all have their own type (`disabled` is `bool`, `maxlength` is `int`, etc...) 169 | - Autocompletion: for attributes that take pre-definied set of values, you will be able to autocomplete them, avoiding the risk of forgetting or mistyping the correct values 170 | - Helper functions for some attributes like `class_()` that can take multiple input types (`str`, `list`, `dict`) for commodity 171 | 172 | Finally, custom object attributes can be defined in several ways: 173 | 174 | - If attribute is a valid python identifier, just do `at.foo_bar("baz")` 175 | - Otherwise, you can pass any arbitrary string by constructing an attribute object and pass it a name and a value: `Attribute("@foo:bar", "baz")` 176 | 177 | 178 | ### Combining different types of attributes 179 | 180 | Attributes via id/class selector shorthand, dictionary, tuple, object and keyword attributes can be combined and used simultaneously: 181 | 182 | ```python title="Specifying attribute via multiple arguments" 183 | >>> from markupy import attributes as attr 184 | >>> from markupy.elements import Label 185 | >>> print(Label("#myid.foo.bar", {"for": "somefield"}, ("disabled", True), at.tabindex(-1), name="myname")) 186 | 187 | ``` 188 | 189 | !!! warning "Order is important" 190 | 191 | When combining multiple attribute definition methods, it's important to respect the order between them: 192 | 193 | 1. **selector id/class string** (optional, at most one) 194 | 3. **args attributes** such as dict, tuple or `Attribute` instance (optional, unlimited) 195 | 4. **kwargs attributes** (optional, unlimited) -------------------------------------------------------------------------------- /docs/flask.md: -------------------------------------------------------------------------------- 1 | # Integrating with Flask 2 | 3 | ## Basic integration 4 | 5 | Rendering markupy elements or components in flask is as easy as return a stringified instance from your routes. 6 | 7 | ```python 8 | from flask import Flask 9 | from markupy import Component, View 10 | from markupy.elements import H1 11 | 12 | app = Flask(__name__) 13 | 14 | @app.route("/page") 15 | def page(): 16 | return str(H1["Page with element"]) 17 | 18 | class MyComponent(Component): 19 | def render(self) -> View: 20 | return H1["Page with component"] 21 | 22 | @app.route("/component") 23 | def component(): 24 | return str(MyComponent()) 25 | ``` 26 | 27 | ## Avoid casting to str by subclassing Flask 28 | 29 | As you saw previously, since Flask doesn't know about our elements or components, we need to convert them to `str` before returning them. 30 | 31 | You can avoid that by subclassing `Flask` and overriding the `make_response` method: 32 | 33 | ```python 34 | from flask import Flask 35 | from markupy import View 36 | 37 | class MarkupyFlask(Flask): 38 | # Here we override make_response to be able to return View instances 39 | # from our routes directly without having to cast them to str() 40 | def make_response(self, rv): 41 | if isinstance(rv, View): 42 | rv = str(rv) 43 | return super().make_response(rv) 44 | ``` 45 | 46 | !!! note 47 | 48 | Here we check if our object to be rendered is a subclass of `markupy.View`, which is the base class for all markupy `Element`, `Fragment` and `Component`. 49 | 50 | And then our previous example becomes like this (basically we instantiate MarkupyFlask instead of Flask previously and do not need the calls to `str` anymore): 51 | 52 | ```python 53 | 54 | from my_flask import MarkupyFlask 55 | from markupy import Component, View 56 | from markupy.elements import H1 57 | 58 | app = MarkupyFlask(__name__) 59 | 60 | @app.route("/page") 61 | def page(): 62 | return H1["Hello!"] 63 | 64 | class MyComponent(Component): 65 | def render(self) -> View: 66 | return H1["Hello!"] 67 | 68 | @app.route("/component") 69 | def component(): 70 | return MyComponent() 71 | ``` 72 | 73 | ## Streaming HTML 74 | 75 | Given that markupy elements and components are iterables, you can leverage the power of python generators to stream the response instead of sending it all at once. 76 | 77 | Flask supports streaming out of the box ([see docs](https://flask.palletsprojects.com/en/3.0.x/patterns/streaming/)). 78 | 79 | !!! note 80 | 81 | The examples below are returning very small and simple content, please be aware that you will only benefit from streaming for large contents. 82 | 83 | ### Streaming by returning a generator 84 | 85 | ```python 86 | from flask import Flask 87 | from markupy import Component, View 88 | from markupy.elements import H1 89 | 90 | app = Flask(__name__) 91 | 92 | @app.route("/page") 93 | def page(): 94 | return iter(H1["Streaming element"]) 95 | 96 | class MyComponent(Component): 97 | def render(self) -> View: 98 | return H1["Streaming component"] 99 | 100 | @app.route("/component") 101 | def component(): 102 | return iter(MyComponent()) 103 | ``` 104 | 105 | ### Streaming by subclassing Flask 106 | 107 | Same as above, if you prefer a cleaner syntax that will apply streaming to all your routes, we can adapt our `Flask` subclass: 108 | 109 | ```python 110 | from flask import Flask 111 | from markupy import View 112 | 113 | class MarkupyStreamFlask(Flask): 114 | # Here we override make_response to be able to stream View instances 115 | # from our routes directly when returning them 116 | def make_response(self, rv): 117 | if isinstance(rv, View): 118 | rv = iter(rv) 119 | return super().make_response(rv) 120 | ``` 121 | 122 | And then in your routes: 123 | 124 | ```python 125 | @app.route("/page") 126 | def page(): 127 | return H1["Hello!"] 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/html2markupy.md: -------------------------------------------------------------------------------- 1 | # Converting HTML to markupy 2 | 3 | Maybe you already have a bunch of HTML, or templates that you would like to migrate to markupy. 4 | We got you covered in multiple ways. 5 | 6 | ## Converting online with the html2markupy website 7 | 8 | The simplest way to experiment with markupy and convert your HTML snippets is to use the [online html2markupy converter](https://html2markupy.witiz.com). 9 | 10 | The app is powered by markupy itself so you will get the exact same result as the one provided by the below method. 11 | 12 | ## Converting locally with the built-in html2markupy command 13 | 14 | 15 | The utility command `html2markupy` ships with `markupy`, and can be used to transform existing html into Python code (markupy!). 16 | 17 | ```bash 18 | $ html2markupy -h 19 | usage: html2markupy [-h] [--selector | --no-selector] [--dict-attrs | --no-dict-attrs] [--el-prefix | --no-el-prefix] [input] 20 | 21 | positional arguments: 22 | input input HTML from file or stdin 23 | 24 | options: 25 | -h, --help show this help message and exit 26 | --selector, --no-selector 27 | Use the selector #id.class syntax instead of explicit `id` and `class_` attributes (default: True) 28 | --dict-attrs, --no-dict-attrs 29 | Prefer dict attributes (default: False) 30 | --el-prefix, --no-el-prefix 31 | Output mode for imports of markupy elements (default: False) 32 | ``` 33 | 34 | Lets say you have an existing HTML file: 35 | 36 | ```html title="index.html" 37 | 38 | 39 | 40 | html2markupy 41 | 42 | 43 |
    44 |

    Welcome to html2markupy!

    45 |
    46 |
    47 | Discover a powerful way to build your HTML pages and components in Python! 48 |
    49 | 52 | 53 | 54 | ``` 55 | 56 | Now, if you run the command, it outputs the corresponding Python code (markupy). 57 | 58 | ```bash 59 | $ html2markupy index.html 60 | ``` 61 | 62 | ```python 63 | from markupy.elements import A,Body,Footer,H1,Head,Header,Html,Main,Title 64 | Html(lang="en")[Head[Title["html2markupy"]],Body[Header[H1(".heading")["Welcome to html2markupy!"]],Main("#container")["Discover a powerful way to build your HTML pages and components in Python!"],Footer["Powered by",A(href="https://markupy.witiz.com")["markupy"]]]] 65 | ``` 66 | 67 | ### Piping Input/Stdin Stream 68 | 69 | You can also pipe input to markupy: 70 | 71 | ```bash 72 | $ cat index.html | html2markupy 73 | ``` 74 | 75 | This can be combined with other workflows in the way that you find most suitable. 76 | For example, you might pipe from your clipboard to markupy, and optionally direct the output to a file. 77 | 78 | 79 | ### Formatting the Output 80 | 81 | `html2markupy` is by default providing an unformatted output, but you can easily combine it with your preferred formatter (must be installed separately). Below is an example formatting with ruff: 82 | 83 | ```bash 84 | $ html2markupy index.html | ruff format - 85 | ``` 86 | 87 | ### Command Options 88 | 89 | Say you have the following HTML snippet. 90 | 91 | ```html title="example.html" 92 |
    93 | Home 94 |
    95 | ``` 96 | 97 | You can adapt the markupy conversion with a couple of options. 98 | 99 | #### Imports management 100 | 101 | Some people prefer to `from markupy import elements as el` instead of importing individual elements `from markupy.elements`. 102 | If this is you, you can use the `--el-prefix` option to get corresponding output when using `html2markupy`. 103 | 104 | 105 | === "--no-el-prefix (default)" 106 | 107 | ```python 108 | from markupy.elements import A, Section 109 | 110 | Section("#main-section.container")[ 111 | A(".btn.btn-primary", href="/index")["Home"] 112 | ] 113 | ``` 114 | 115 | === "--el-prefix" 116 | 117 | ```python 118 | from markupy import elements as el 119 | 120 | el.Section("#main-section.container")[ 121 | el.A(".btn.btn-primary", href="/index")["Home"] 122 | ] 123 | ``` 124 | 125 | 126 | #### Explicit `id` and `class` kwargs 127 | 128 | If you prefer the explicit `id="id", class_="class"` kwargs syntax over the default markupy shorthand `#id.class` syntax, you can get it by passing the `--no-selector` flag. 129 | 130 | === "--selector (default)" 131 | 132 | ```python 133 | from markupy.elements import A, Section 134 | 135 | Section("#main-section.container")[ 136 | A(".btn.btn-primary", href="/index")["Home"] 137 | ] 138 | ``` 139 | 140 | === "--no-selector" 141 | 142 | ```python 143 | from markupy.elements import A, Section 144 | 145 | Section(id="main-section" class_="container")[ 146 | A(class_="btn btn-primary", href="/index")["Home"] 147 | ] 148 | ``` 149 | 150 | #### Attributes as dict vs arguments 151 | 152 | The `--dict-args` flag lets you declare attributes as a dictionary instead of the default python arguments. 153 | 154 | === "--no-dict-args (default)" 155 | 156 | ```python 157 | from markupy.elements import A, Section 158 | 159 | Section("#main-section.container")[ 160 | A(".btn.btn-primary", href="/index")["Home"] 161 | ] 162 | ``` 163 | 164 | === "--dict-args" 165 | 166 | ```python 167 | from markupy.elements import A, Section 168 | 169 | Section("#main-section.container")[ 170 | A(".btn.btn-primary", {"href": "/index"})["Home"] 171 | ] 172 | ``` -------------------------------------------------------------------------------- /docs/reusability.md: -------------------------------------------------------------------------------- 1 | # Reusability with Fragments and Components 2 | 3 | ## Fragments 4 | 5 | Fragments allow you to wrap a group of nodes (not necessarily elements) so that they can be rendered without a wrapping element. 6 | 7 | ```python 8 | >>> from markupy.elements import P, I, Fragment 9 | >>> content = Fragment["Hello ", None, I["world!"]] 10 | >>> print(content) 11 | Hello world! 12 | 13 | >>> print(P[content]) 14 |

    Hello world!

    15 | ``` 16 | 17 | ## Components 18 | 19 | Although markupy intend to remain a generic library to allow you generate HTML, it also provides a powerful support for components in order to build reusable chunks of HTML. 20 | 21 | ### Building your first component 22 | 23 | Let's start by creating a component that renders a [Boostrap card](https://getbootstrap.com/docs/5.3/components/card/). 24 | 25 | #### Components as functions 26 | 27 | Building a function component is a simple as returning elements from a regular python function: 28 | 29 | ```python 30 | def card_component(title:str, content:str) -> View: 31 | return Div(".card")[ 32 | Div(".card-body")[ 33 | H5(".card-title")[ 34 | title 35 | ], 36 | P(".card-text")[ 37 | content 38 | ], 39 | ] 40 | ] 41 | ``` 42 | 43 | !!! note 44 | 45 | In the rest of the documentation, we will mostly focus on class based components that offer more flexibility with the ability to inherit each other but after all, it's also a matter of taste so feel free to experiment and find what works best for you. 46 | 47 | 48 | #### Components as classes 49 | 50 | Building a class component is done by subclassing the built-in `Component` abstract class and implementing the one required `render()` instance method that defines your component structure. 51 | 52 | ```python 53 | from markupy import Component, View 54 | from markupy.elements import Div, H5, P 55 | 56 | class CardComponent(Component): 57 | def render(self) -> View: 58 | return Div(".card")[ 59 | Div(".card-body")[ 60 | H5(".card-title")[ 61 | "Card title" 62 | ], 63 | P(".card-text")[ 64 | "This is my card's content." 65 | ], 66 | ] 67 | ] 68 | ``` 69 | 70 | And then to generate the actual HTML for this component, you just need to instantiate it and make it into a `str`: 71 | 72 | ```python 73 | >>> str(CardComponent()) 74 | ``` 75 | 76 | Note that the component `render()` method needs to return a `View`, which means it can be any of an `Element`, `Fragment` or another `Component`. 77 | 78 | See how this can save you from repeating a lot of code? 79 | But we're not there yet, because right now our card always has the same title and content. 80 | Time to keep improving our component. 81 | 82 | ### Pass data to a class component with constructor 83 | 84 | Let's make our card data dynamic by adding a constructor to our component. Let's say our card is in charge of displaying a `Post` object: 85 | 86 | ```python 87 | from markupy import Component, View 88 | from markupy.elements import Div, H5, P 89 | from my_models import Post 90 | 91 | class PostCardComponent(Component): 92 | def __init__(self, *, post: Post) -> None: 93 | super().__init__() 94 | self.post = post 95 | 96 | def render(self) -> View: 97 | return Div(".card")[ 98 | Div(".card-body")[ 99 | H5(".card-title")[ 100 | self.post.title 101 | ], 102 | P(".card-text")[ 103 | self.post.description 104 | ], 105 | ] 106 | ] 107 | ``` 108 | 109 | ### Components in components 110 | 111 | Usually, cards are displayed as part of a collection. Let's say we have a blog that is managing a list of posts, let's create a new component that would be in charge of displaying a list of cards: 112 | 113 | ```python 114 | from markupy import Component, View 115 | from markupy.elements import Div, H5, P 116 | from my_models import Post 117 | 118 | class PostCardListComponent(Component): 119 | def __init__(self, *, posts: list[Post]) -> None: 120 | super().__init__() 121 | self.posts = posts 122 | 123 | def render(self) -> View: 124 | return Div(".card-group")[ 125 | (PostCardComponent(post=post) for post in self.posts) 126 | ] 127 | ``` 128 | 129 | And that's it, we are looping over a list of posts to generate card components that are added as children of another component. Displaying a list of posts as cards is now super easy: 130 | 131 | ```python 132 | >>> print(PostCardListComponent(posts=my_posts)) 133 | ``` 134 | 135 | ### Passing children to components 136 | 137 | Content can be assigned to component the same way we are doing for Fragments or Elements. 138 | To tell your component where such content needs to be injected when rendering, you need to call the `self.render_content()` reserved method: 139 | 140 | ```python 141 | from markupy import elements as el 142 | from markupy import Component, View 143 | 144 | class Title(Component): 145 | def __init__(self, id: str) -> None: 146 | super().__init__() 147 | self.id = id 148 | 149 | def render(self) -> View: 150 | return el.H1(".title.header", id=self.id)[self.render_content()] 151 | ``` 152 | 153 | Then to use this component: 154 | 155 | ```python 156 | >>> print(Title(id="headline")["hello ", el.I(".star.icon")]) 157 | ``` 158 | 159 | This will render as: 160 | 161 | ```html 162 |

    163 | hello 164 |

    165 | ``` 166 | 167 | ### Dataclasses components 168 | 169 | Components can also be defined as `dataclass`, which allows for a more compact syntax. 170 | Here's for example what the component above would look like with `@dataclass`: 171 | 172 | ```python 173 | from dataclasses import dataclass 174 | from markupy import elements as el 175 | from markupy import Component, View 176 | 177 | @dataclass 178 | class Title(Component): 179 | id: str 180 | 181 | def render(self) -> View: 182 | return el.H1(".title.header", id=self.id)[self.render_content()] 183 | ``` 184 | 185 | ## Using components to define layouts 186 | 187 | Another very interesting use for components is to define your pages layouts. 188 | 189 | ### Implementing a basic layout 190 | 191 | Below is a very basic layout that specifies a default head and body, with some placeholders that we can implement when inheriting this layout. 192 | 193 | ```python 194 | from markupy import Component, View 195 | from markupy.elements import H1, Body, Footer, Head, Header, Html, Main, Title 196 | 197 | class BaseLayout(Component): 198 | def render_title(self) -> str: 199 | return "My website" 200 | 201 | def render_main(self) -> View: 202 | return None 203 | 204 | def render(self) -> View: 205 | return Html[ 206 | Head[ 207 | Title[self.render_title()], 208 | ], 209 | Body[ 210 | Header(".container")[H1["Welcome!"]], 211 | Main(".container")[self.render_main()], 212 | Footer(".container")["© My Company"], 213 | ], 214 | ] 215 | ``` 216 | 217 | !!! note 218 | 219 | Here we defined the placeholders as instance methods called render_*. This is just a convention and nothing is enforced in naming them. 220 | 221 | 222 | ### Extending a layout to implement a page 223 | 224 | Then when we need to define a specific page, we need to subclass the layout an override the needed placeholders: 225 | 226 | ```python 227 | from markupy import Fragment, View 228 | from markupy.elements import H2 229 | from my_components import PostCardListComponent 230 | from my_models import Post 231 | 232 | class BlogPage(BaseLayout): 233 | def __init__(self, *, posts:list[Post]) -> None: 234 | super().__init__() 235 | self.posts = posts 236 | 237 | def render_title(self) -> str: 238 | return f"Blog | {super().render_title()}" 239 | 240 | def render_main(self) -> View: 241 | return Fragment[ 242 | H2["Blog posts"], 243 | PostCardListComponent(posts=self.posts) 244 | ] 245 | ``` 246 | 247 | !!! note 248 | 249 | Notice in the `render_title` how we can only partially replace the content from the inherited layout (here we are preprending the default page title with the `Blog | ` value.) 250 | 251 | As usual, generating HTML for the page is just a matter of instanciating and converting to `str`: 252 | 253 | ```python 254 | >>> str(BlogPage(posts=my_posts)) 255 | ``` -------------------------------------------------------------------------------- /docs/starlette.md: -------------------------------------------------------------------------------- 1 | # Integrating with Starlette 2 | 3 | markupy can be used with Starlette to generate HTML. Since FastAPI is built upon Starlette, markupy can also be used with FastAPI. 4 | 5 | To return HTML contents, pass a markupy element to Starlette's `HTMLResponse`: 6 | 7 | ```python 8 | --8<-- "examples/starlette/html_response.py" 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/django/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/examples/django/db.sqlite3 -------------------------------------------------------------------------------- /examples/django/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'markupyproject.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /examples/django/markupyapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/examples/django/markupyapp/__init__.py -------------------------------------------------------------------------------- /examples/django/markupyapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /examples/django/markupyapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MarkupyappConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'markupyapp' 7 | -------------------------------------------------------------------------------- /examples/django/markupyapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/examples/django/markupyapp/migrations/__init__.py -------------------------------------------------------------------------------- /examples/django/markupyapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /examples/django/markupyapp/templates/markupyapp/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | My Django Site 5 | 6 | 7 | 8 | {{ content }} 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/django/markupyapp/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /examples/django/markupyapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("", views.index, name="index"), 7 | path("template", views.template, name="index"), 8 | ] 9 | -------------------------------------------------------------------------------- /examples/django/markupyapp/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.shortcuts import render 3 | 4 | from markupy.elements import H1, Body, Html 5 | 6 | 7 | def index(request): 8 | return HttpResponse(Html[Body[H1["Hi Django!"]]]) 9 | 10 | 11 | def template(request): 12 | context = { 13 | "content": H1["This is markupy title!"], 14 | } 15 | return render(request, "markupyapp/base.html", context) 16 | -------------------------------------------------------------------------------- /examples/django/markupyproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/examples/django/markupyproject/__init__.py -------------------------------------------------------------------------------- /examples/django/markupyproject/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for markupyproject project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'markupyproject.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /examples/django/markupyproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for markupyproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-kn1d$vjp(biapqm499(g05icdin*z8b%2zm5#u8%kx6ul&fuz8" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "markupyapp.apps.MarkupyappConfig", 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "markupyproject.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.request", 63 | "django.contrib.auth.context_processors.auth", 64 | "django.contrib.messages.context_processors.messages", 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = "markupyproject.wsgi.application" 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/5.2/ref/settings/#databases 75 | 76 | DATABASES = { 77 | "default": { 78 | "ENGINE": "django.db.backends.sqlite3", 79 | "NAME": BASE_DIR / "db.sqlite3", 80 | } 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 90 | }, 91 | { 92 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 99 | }, 100 | ] 101 | 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/5.2/topics/i18n/ 105 | 106 | LANGUAGE_CODE = "en-us" 107 | 108 | TIME_ZONE = "UTC" 109 | 110 | USE_I18N = True 111 | 112 | USE_TZ = True 113 | 114 | 115 | # Static files (CSS, JavaScript, Images) 116 | # https://docs.djangoproject.com/en/5.2/howto/static-files/ 117 | 118 | STATIC_URL = "static/" 119 | 120 | # Default primary key field type 121 | # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field 122 | 123 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 124 | -------------------------------------------------------------------------------- /examples/django/markupyproject/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for markupyproject project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import include, path 20 | 21 | urlpatterns = [ 22 | path("markupy/", include("markupyapp.urls")), 23 | path("admin/", admin.site.urls), 24 | ] 25 | -------------------------------------------------------------------------------- /examples/django/markupyproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for markupyproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'markupyproject.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /examples/starlette/html_response.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | from starlette.requests import Request 3 | from starlette.responses import HTMLResponse 4 | from starlette.routing import Route 5 | 6 | from markupy.elements import H1, Body, Html 7 | 8 | 9 | async def index(request: Request) -> HTMLResponse: 10 | return HTMLResponse(Html[Body[H1["Hi Starlette!"]]]) 11 | 12 | 13 | # Run it with `uv run uvicorn html_response:app`` 14 | app = Starlette( 15 | routes=[Route("/", index)], 16 | ) 17 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: markupy 2 | site_url: https://markupy.witiz.com 3 | repo_url: https://github.com/witiz/markupy 4 | theme: 5 | name: material 6 | palette: 7 | primary: blue 8 | features: 9 | - content.code.copy 10 | - toc.integrate 11 | icon: 12 | logo: material/code-tags 13 | logo: assets/markupy.svg 14 | nav: 15 | - README.md 16 | - elements.md 17 | - advanced.md 18 | - reusability.md 19 | - django.md 20 | - flask.md 21 | - starlette.md 22 | - html2markupy.md 23 | extra: 24 | analytics: 25 | provider: google 26 | property: G-NWHXG5WE1Q 27 | extra_css: 28 | - assets/extra.css 29 | markdown_extensions: 30 | - admonition 31 | - pymdownx.highlight: 32 | anchor_linenums: true 33 | line_spans: __span 34 | pygments_lang_class: true 35 | - pymdownx.inlinehilite 36 | - pymdownx.snippets 37 | - pymdownx.superfences 38 | - pymdownx.tabbed: 39 | alternate_style: true 40 | - attr_list 41 | - md_in_html 42 | - toc: 43 | permalink: true 44 | - tables -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | version = "2.5.0" 3 | name = "markupy" 4 | description = "markupy - HTML in Python" 5 | authors = [ 6 | { name="witiz", email="markupy@witiz.com" }, 7 | ] 8 | readme = "docs/README.md" 9 | 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "License :: OSI Approved :: MIT License", 13 | "Intended Audience :: Developers", 14 | "Development Status :: 5 - Production/Stable", 15 | "Topic :: Text Processing :: Markup :: HTML", 16 | "Operating System :: OS Independent", 17 | "Typing :: Typed", 18 | ] 19 | requires-python = ">=3.10" 20 | dependencies = [ 21 | "markupsafe>=2.0.0", 22 | "typing-extensions>=4.12.2", 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://markupy.witiz.com" 27 | html2markupy = "https://html2markupy.witiz.com" 28 | Repository = "https://github.com/witiz/markupy" 29 | Issues = "https://github.com/witiz/markupy/issues" 30 | 31 | [project.scripts] 32 | html2markupy = "markupy._private.html_to_markupy.cli:main" 33 | 34 | [build-system] 35 | requires = ["uv_build"] 36 | build-backend = "uv_build" 37 | 38 | [tool.uv] 39 | dev-dependencies = [ 40 | "pytest>=8.3.3", 41 | "jinja2>=3.1.4", 42 | "django>=5.1.1", 43 | "htpy>=24.9.1", 44 | "mkdocs-material>=9.5.34", 45 | "mypy>=1.11.2", 46 | "ruff>=0.6.5", 47 | "pyright>=1.1.381", 48 | "starlette>=0.46.1", 49 | "uvicorn>=0.34.0", 50 | "httpx>=0.28.1", 51 | "pytest-django>=4.11.1", 52 | "flask>=3.1.0", 53 | ] 54 | 55 | [tool.mypy] 56 | strict = true 57 | files = "src/**/*.py" 58 | exclude = ["examples", "scripts"] 59 | 60 | [tool.pyright] 61 | include = ["src"] 62 | strict = ["src"] 63 | -------------------------------------------------------------------------------- /scripts/benchmark_big_table.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import django 4 | from django.conf import settings 5 | from django.template import Context 6 | from django.template import Template as DjangoTemplate 7 | from htpy import table, tbody, td, th, thead, tr 8 | from jinja2 import Template as JinjaTemplate 9 | 10 | from markupy.elements import Table, Tbody, Td, Th, Thead, Tr 11 | 12 | settings.configure( 13 | TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates"}] 14 | ) 15 | django.setup() 16 | 17 | django_jinja_template = """ 18 | 19 | 20 | 21 | {% for row in rows %} 22 | 23 | {% endfor %} 24 | 25 |
    Row #
    {{ row }}
    26 | """ 27 | 28 | rows = list(range(50_000)) 29 | 30 | 31 | def render_markupy() -> str: 32 | return str( 33 | Table[ 34 | Thead[Tr[Th["Row #"]]], 35 | Tbody[(Tr[Td[row]] for row in rows)], 36 | ] 37 | ) 38 | 39 | 40 | def render_markupy_attr() -> str: 41 | return str( 42 | Table[ 43 | Thead[Tr[Th["Row #"]]], 44 | Tbody[ 45 | ( 46 | Tr(".row")[ 47 | Td(f"#id-{row}.foo.bar", {"hello": "world"}, data_value=row)[ 48 | row 49 | ] 50 | ] 51 | for row in rows 52 | ) 53 | ], 54 | ] 55 | ) 56 | 57 | 58 | def render_htpy() -> str: 59 | return str( 60 | table[ 61 | thead[tr[th["Row #"]]], 62 | tbody[(tr[td[row]] for row in rows)], 63 | ] 64 | ) 65 | 66 | 67 | def render_htpy_attr() -> str: 68 | return str( 69 | table[ 70 | thead[tr[th["Row #"]]], 71 | tbody[ 72 | ( 73 | tr(".row")[ 74 | td(f"#id-{row}.foo.bar", {"hello": "world"}, data_value=row)[ 75 | row 76 | ] 77 | ] 78 | for row in rows 79 | ) 80 | ], 81 | ] 82 | ) 83 | 84 | 85 | def render_django() -> str: 86 | return DjangoTemplate(django_jinja_template).render(Context({"rows": rows})) 87 | 88 | 89 | def render_jinja() -> str: 90 | return JinjaTemplate(django_jinja_template).render(rows=rows) 91 | 92 | 93 | tests = [ 94 | render_markupy, 95 | render_markupy_attr, 96 | render_htpy, 97 | render_htpy_attr, 98 | render_django, 99 | render_jinja, 100 | ] 101 | 102 | for func in tests: 103 | start = time.perf_counter() 104 | func() 105 | result = time.perf_counter() - start 106 | print(f"{func.__name__}: {result} seconds") 107 | -------------------------------------------------------------------------------- /scripts/docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | . ${BASH_SOURCE%/*}/test.sh 6 | 7 | echo ---- docs deploy ---- 8 | uv run mkdocs gh-deploy --force 9 | -------------------------------------------------------------------------------- /scripts/fix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | uv run ruff format . 6 | uv run ruff check --fix . -------------------------------------------------------------------------------- /scripts/performance.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | 3 | from markupy import View 4 | from markupy.elements import Table, Tbody, Td, Th, Thead, Tr 5 | 6 | rows = list(range(50_000)) 7 | 8 | 9 | def render() -> View: 10 | return Table[ 11 | Thead[Tr[Th["Row #"]]], 12 | Tbody[(Tr(".row")[Td(data_value=row)[row]] for row in rows)], 13 | ] 14 | 15 | 16 | with cProfile.Profile() as pr: 17 | str(render()) 18 | # Results can be wiewed with snakeviz 19 | # uvx snakeviz output.prof 20 | pr.dump_stats("output.prof") 21 | # pr.print_stats(sort="tottime") 22 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | . ${BASH_SOURCE%/*}/test.sh 6 | 7 | . ${BASH_SOURCE%/*}/docs.sh 8 | 9 | echo --- pypi publish ---- 10 | uv publish -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | echo ---- ruff format ---- 6 | uv run ruff format --check src 7 | echo 8 | 9 | echo ----- ruff lint ----- 10 | uv run ruff check src 11 | echo 12 | 13 | echo ------ pytest ------- 14 | uv run pytest 15 | echo 16 | 17 | echo ------- mypy -------- 18 | uv run mypy src 19 | echo 20 | 21 | echo ----- pyright ------- 22 | uv run pyright 23 | echo 24 | 25 | echo ====== SUCCESS ====== -------------------------------------------------------------------------------- /src/markupy/__init__.py: -------------------------------------------------------------------------------- 1 | from ._private.attributes import Attribute, attribute_handlers 2 | from ._private.html_to_markupy import html_to_markupy 3 | from ._private.views import Component, View 4 | from ._private.views import Fragment as _Fragment 5 | 6 | __all__ = [ 7 | "Attribute", 8 | "Component", 9 | "Fragment", 10 | "View", 11 | "attribute_handlers", 12 | "html_to_markupy", 13 | ] 14 | 15 | Fragment = _Fragment() 16 | -------------------------------------------------------------------------------- /src/markupy/_private/attributes/__init__.py: -------------------------------------------------------------------------------- 1 | from .attribute import Attribute 2 | from .handlers import attribute_handlers 3 | from .store import AttributeStore 4 | 5 | __all__ = ["attribute_handlers", "Attribute", "AttributeStore"] 6 | -------------------------------------------------------------------------------- /src/markupy/_private/attributes/attribute.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Any, TypeAlias 3 | 4 | from markupsafe import escape 5 | 6 | from markupy.exceptions import MarkupyError 7 | 8 | 9 | @lru_cache(maxsize=1000) 10 | def is_valid_key(key: Any) -> bool: 11 | # Check for invalid chars (like <>, newline/spaces, upper case) 12 | return ( 13 | isinstance(key, Attribute.Name) 14 | and key != "" 15 | # ensure no special chars 16 | and key == escape(str(key)) 17 | # ensure no newlines/spaces/tabs and lowercase 18 | and key == "".join(key.lower().split()) 19 | ) 20 | 21 | 22 | def is_valid_value(value: Any) -> bool: 23 | return isinstance(value, Attribute.Value) 24 | 25 | 26 | class Attribute: 27 | __slots__ = ("_name", "_value") 28 | 29 | Name: TypeAlias = str 30 | Value: TypeAlias = None | bool | str | int | float 31 | _name: Name 32 | _value: Value 33 | 34 | def __init__(self, name: str, value: Value) -> None: 35 | # name is immutable 36 | # reason is to avoid, when a handler returns None, having following handlers 37 | # receiving old and new instances with different names 38 | if not is_valid_key(name): 39 | raise MarkupyError(f"Attribute `{name!r}` has invalid name") 40 | self._name = name 41 | # value is mutable 42 | self.value = value 43 | 44 | @property 45 | def name(self) -> str: 46 | return self._name 47 | 48 | @property 49 | def value(self) -> Value: 50 | return self._value 51 | 52 | @value.setter 53 | def value(self, value: Value) -> None: 54 | if not is_valid_value(value): 55 | raise MarkupyError(f"Attribute `{self.name}` has invalid value {value!r}") 56 | self._value = value 57 | 58 | def __str__(self) -> str: 59 | if self.value is None or self.value is False: 60 | # Discard False and None valued attributes for all attributes 61 | return "" 62 | elif self.value is True: 63 | return self.name 64 | return f'{self.name}="{escape(str(self.value))}"' 65 | 66 | def __repr__(self) -> str: 67 | return f"" 68 | -------------------------------------------------------------------------------- /src/markupy/_private/attributes/handlers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from typing import Callable, TypeAlias 3 | 4 | from markupy.exceptions import MarkupyError 5 | 6 | from .attribute import Attribute 7 | 8 | # We prefer this signature over (name:str, old_value:Attribute.Value, new_value:Attribute.Value) 9 | # for several reasons: 10 | # - avoid exposing Attribute.Value type that is too low level 11 | # - allows to differentiate between an attribute that has never been instanciated vs 12 | # an attribute that has already been instanciated with a None value 13 | AttributeHandler: TypeAlias = Callable[[Attribute | None, Attribute], Attribute | None] 14 | 15 | 16 | class AttributeHandlerRegistry(dict[AttributeHandler, None]): 17 | def register(self, handler: AttributeHandler) -> AttributeHandler: 18 | """Registers the handler and returns it unchanged (so usable as a decorator).""" 19 | if handler in self: 20 | raise MarkupyError(f"Handler {handler.__name__} is already registered.") 21 | self[handler] = None 22 | return handler # Important for decorator usage 23 | 24 | def unregister(self, handler: AttributeHandler) -> None: 25 | self.pop(handler, None) 26 | 27 | def __iter__(self) -> Iterator[AttributeHandler]: 28 | yield from reversed(self.keys()) 29 | 30 | 31 | attribute_handlers = AttributeHandlerRegistry() 32 | -------------------------------------------------------------------------------- /src/markupy/_private/attributes/html.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from typing import Callable, Literal 3 | 4 | from . import Attribute 5 | from .store import python_to_html_key 6 | 7 | # Special functions 8 | 9 | 10 | def getattr(name: str) -> Callable[[Attribute.Value], Attribute]: 11 | return lambda value: Attribute(python_to_html_key(name), value) 12 | 13 | 14 | # Html Attributes 15 | 16 | 17 | def accept(*value: str) -> Attribute: 18 | return Attribute("accept", ",".join(value)) 19 | 20 | 21 | def accept_charset(value: str) -> Attribute: 22 | return Attribute("accept-charset", value) 23 | 24 | 25 | def accesskey(value: str) -> Attribute: 26 | return Attribute("accesskey", value) 27 | 28 | 29 | def action(value: str) -> Attribute: 30 | return Attribute("action", value) 31 | 32 | 33 | def align(value: str) -> Attribute: 34 | return Attribute("align", value) 35 | 36 | 37 | def allow(value: str) -> Attribute: 38 | return Attribute("allow", value) 39 | 40 | 41 | def alt(value: str) -> Attribute: 42 | return Attribute("alt", value) 43 | 44 | 45 | def as_(value: str) -> Attribute: 46 | return Attribute("as", value) 47 | 48 | 49 | def async_(value: bool = True) -> Attribute: 50 | return Attribute("async", value) 51 | 52 | 53 | def autocapitalize( 54 | value: Literal["off", "none", "on", "sentences", "words", "characters"], 55 | ) -> Attribute: 56 | return Attribute("autocapitalize", value) 57 | 58 | 59 | def autocomplete(value: Literal["on", "off"]) -> Attribute: 60 | return Attribute("autocomplete", value) 61 | 62 | 63 | def autofocus(value: bool = True) -> Attribute: 64 | return Attribute("autofocus", value) 65 | 66 | 67 | def autoplay(value: bool = True) -> Attribute: 68 | return Attribute("autoplay", value) 69 | 70 | 71 | def background(value: str) -> Attribute: 72 | return Attribute("background", value) 73 | 74 | 75 | def bgcolor(value: str) -> Attribute: 76 | return Attribute("bgcolor", value) 77 | 78 | 79 | def border(value: str) -> Attribute: 80 | return Attribute("border", value) 81 | 82 | 83 | def capture(value: str) -> Attribute: 84 | return Attribute("capture", value) 85 | 86 | 87 | def charset(value: str) -> Attribute: 88 | return Attribute("charset", value) 89 | 90 | 91 | def checked(value: bool = True) -> Attribute: 92 | return Attribute("checked", value) 93 | 94 | 95 | def cite(value: str) -> Attribute: 96 | return Attribute("cite", value) 97 | 98 | 99 | def class_(value: str | Iterable[str] | Mapping[str, bool]) -> Attribute: 100 | classes: list[str] 101 | if isinstance(value, str): 102 | classes = [value] 103 | elif isinstance(value, Mapping): 104 | classes = [k for k, v in value.items() if v] # type: ignore[unused-ignore] 105 | else: 106 | classes = list(value) 107 | return Attribute("class", " ".join(classes)) 108 | 109 | 110 | def color(value: str) -> Attribute: 111 | return Attribute("color", value) 112 | 113 | 114 | def cols(value: int) -> Attribute: 115 | return Attribute("cols", value) 116 | 117 | 118 | def colspan(value: int) -> Attribute: 119 | return Attribute("colspan", value) 120 | 121 | 122 | def content(value: str) -> Attribute: 123 | return Attribute("content", value) 124 | 125 | 126 | def contenteditable(value: Literal["true", "false", ""]) -> Attribute: 127 | return Attribute("contenteditable", value) 128 | 129 | 130 | def controls(value: bool = True) -> Attribute: 131 | return Attribute("controls", value) 132 | 133 | 134 | def coords(value: str) -> Attribute: 135 | return Attribute("coords", value) 136 | 137 | 138 | def crossorigin(value: Literal["anonymous", "use-credentials"]) -> Attribute: 139 | return Attribute("crossorigin", value) 140 | 141 | 142 | def csp(value: str) -> Attribute: 143 | return Attribute("csp", value) 144 | 145 | 146 | def data(value: str) -> Attribute: 147 | return Attribute("data", value) 148 | 149 | 150 | def datetime(value: str) -> Attribute: 151 | return Attribute("datetime", value) 152 | 153 | 154 | def decoding(value: Literal["sync", "async", "auto"]) -> Attribute: 155 | return Attribute("decoding", value) 156 | 157 | 158 | def default(value: bool = True) -> Attribute: 159 | return Attribute("default", value) 160 | 161 | 162 | def defer(value: bool = True) -> Attribute: 163 | return Attribute("defer", value) 164 | 165 | 166 | def dir(value: Literal["ltr", "rtl", "auto"]) -> Attribute: 167 | return Attribute("dir", value) 168 | 169 | 170 | def dirname(value: str) -> Attribute: 171 | return Attribute("dirname", value) 172 | 173 | 174 | def disabled(value: bool = True) -> Attribute: 175 | return Attribute("disabled", value) 176 | 177 | 178 | def download(value: str) -> Attribute: 179 | return Attribute("download", value) 180 | 181 | 182 | def draggable(value: Literal["true", "false", "auto"]) -> Attribute: 183 | return Attribute("draggable", value) 184 | 185 | 186 | def enctype( 187 | value: Literal[ 188 | "application/x-www-form-urlencoded", "multipart/form-data", "text/plain" 189 | ], 190 | ) -> Attribute: 191 | return Attribute("enctype", value) 192 | 193 | 194 | def enterkeyhint( 195 | value: Literal["enter", "done", "go", "next", "previous", "search", "send"], 196 | ) -> Attribute: 197 | return Attribute("enterkeyhint", value) 198 | 199 | 200 | def elementtiming(value: str) -> Attribute: 201 | return Attribute("elementtiming", value) 202 | 203 | 204 | def for_(value: str) -> Attribute: 205 | return Attribute("for", value) 206 | 207 | 208 | def form(value: str) -> Attribute: 209 | return Attribute("form", value) 210 | 211 | 212 | def formaction(value: str) -> Attribute: 213 | return Attribute("formaction", value) 214 | 215 | 216 | def formenctype( 217 | value: Literal[ 218 | "application/x-www-form-urlencoded", "multipart/form-data", "text/plain" 219 | ], 220 | ) -> Attribute: 221 | return Attribute("formenctype", value) 222 | 223 | 224 | def formmethod(value: Literal["get", "post"]) -> Attribute: 225 | return Attribute("formmethod", value) 226 | 227 | 228 | def formnovalidate(value: bool = True) -> Attribute: 229 | return Attribute("formnovalidate", value) 230 | 231 | 232 | def formtarget(value: str) -> Attribute: 233 | return Attribute("formtarget", value) 234 | 235 | 236 | def headers(value: str) -> Attribute: 237 | return Attribute("headers", value) 238 | 239 | 240 | def height(value: int | str) -> Attribute: 241 | return Attribute("height", value) 242 | 243 | 244 | def hidden(value: bool | Literal["until-found"] = True) -> Attribute: 245 | return Attribute("hidden", value) 246 | 247 | 248 | def high(value: str) -> Attribute: 249 | return Attribute("high", value) 250 | 251 | 252 | def href(value: str) -> Attribute: 253 | return Attribute("href", value) 254 | 255 | 256 | def hreflang(value: str) -> Attribute: 257 | return Attribute("hreflang", value) 258 | 259 | 260 | def http_equiv( 261 | value: Literal[ 262 | "content-security-policy", "content-type", "default-style", "refresh" 263 | ], 264 | ) -> Attribute: 265 | return Attribute("http-equiv", value) 266 | 267 | 268 | def id(value: str) -> Attribute: 269 | return Attribute("id", value) 270 | 271 | 272 | def inputmode( 273 | value: Literal[ 274 | "none", "text", "decimal", "numeric", "tel", "search", "email", "url" 275 | ], 276 | ) -> Attribute: 277 | return Attribute("inputmode", value) 278 | 279 | 280 | def integrity(value: str) -> Attribute: 281 | return Attribute("integrity", value) 282 | 283 | 284 | def ismap(value: bool = True) -> Attribute: 285 | return Attribute("ismap", value) 286 | 287 | 288 | def itemprop(value: str) -> Attribute: 289 | return Attribute("itemprop", value) 290 | 291 | 292 | def kind( 293 | value: Literal["subtitles", "captions", "descriptions", "chapters", "metadata"], 294 | ) -> Attribute: 295 | return Attribute("kind", value) 296 | 297 | 298 | def label(value: str) -> Attribute: 299 | return Attribute("label", value) 300 | 301 | 302 | def lang(value: str) -> Attribute: 303 | return Attribute("lang", value) 304 | 305 | 306 | def loading(value: Literal["eager", "lazy"]) -> Attribute: 307 | return Attribute("loading", value) 308 | 309 | 310 | def list_(value: str) -> Attribute: 311 | return Attribute("list", value) 312 | 313 | 314 | def loop(value: bool = True) -> Attribute: 315 | return Attribute("loop", value) 316 | 317 | 318 | def low(value: str) -> Attribute: 319 | return Attribute("low", value) 320 | 321 | 322 | def max(value: int | float | str) -> Attribute: 323 | return Attribute("max", value) 324 | 325 | 326 | def maxlength(value: int) -> Attribute: 327 | return Attribute("maxlength", value) 328 | 329 | 330 | def media(value: str) -> Attribute: 331 | return Attribute("media", value) 332 | 333 | 334 | def method(value: Literal["get", "post"]) -> Attribute: 335 | return Attribute("method", value) 336 | 337 | 338 | def min(value: int | float | str) -> Attribute: 339 | return Attribute("min", value) 340 | 341 | 342 | def minlength(value: int) -> Attribute: 343 | return Attribute("minlength", value) 344 | 345 | 346 | def multiple(value: bool = True) -> Attribute: 347 | return Attribute("multiple", value) 348 | 349 | 350 | def muted(value: bool = True) -> Attribute: 351 | return Attribute("muted", value) 352 | 353 | 354 | def name(value: str) -> Attribute: 355 | return Attribute("name", value) 356 | 357 | 358 | def novalidate(value: bool = True) -> Attribute: 359 | return Attribute("novalidate", value) 360 | 361 | 362 | def onabort(value: str) -> Attribute: 363 | return Attribute("onabort", value) 364 | 365 | 366 | def onautocomplete(value: str) -> Attribute: 367 | return Attribute("onautocomplete", value) 368 | 369 | 370 | def onautocompleteerror(value: str) -> Attribute: 371 | return Attribute("onautocompleteerror", value) 372 | 373 | 374 | def onblur(value: str) -> Attribute: 375 | return Attribute("onblur", value) 376 | 377 | 378 | def oncancel(value: str) -> Attribute: 379 | return Attribute("oncancel", value) 380 | 381 | 382 | def oncanplay(value: str) -> Attribute: 383 | return Attribute("oncanplay", value) 384 | 385 | 386 | def oncanplaythrough(value: str) -> Attribute: 387 | return Attribute("oncanplaythrough", value) 388 | 389 | 390 | def onchange(value: str) -> Attribute: 391 | return Attribute("onchange", value) 392 | 393 | 394 | def onclick(value: str) -> Attribute: 395 | return Attribute("onclick", value) 396 | 397 | 398 | def onclose(value: str) -> Attribute: 399 | return Attribute("onclose", value) 400 | 401 | 402 | def oncontextmenu(value: str) -> Attribute: 403 | return Attribute("oncontextmenu", value) 404 | 405 | 406 | def oncuechange(value: str) -> Attribute: 407 | return Attribute("oncuechange", value) 408 | 409 | 410 | def ondblclick(value: str) -> Attribute: 411 | return Attribute("ondblclick", value) 412 | 413 | 414 | def ondrag(value: str) -> Attribute: 415 | return Attribute("ondrag", value) 416 | 417 | 418 | def ondragend(value: str) -> Attribute: 419 | return Attribute("ondragend", value) 420 | 421 | 422 | def ondragenter(value: str) -> Attribute: 423 | return Attribute("ondragenter", value) 424 | 425 | 426 | def ondragexit(value: str) -> Attribute: 427 | return Attribute("ondragexit", value) 428 | 429 | 430 | def ondragleave(value: str) -> Attribute: 431 | return Attribute("ondragleave", value) 432 | 433 | 434 | def ondragover(value: str) -> Attribute: 435 | return Attribute("ondragover", value) 436 | 437 | 438 | def ondragstart(value: str) -> Attribute: 439 | return Attribute("ondragstart", value) 440 | 441 | 442 | def ondrop(value: str) -> Attribute: 443 | return Attribute("ondrop", value) 444 | 445 | 446 | def ondurationchange(value: str) -> Attribute: 447 | return Attribute("ondurationchange", value) 448 | 449 | 450 | def onemptied(value: str) -> Attribute: 451 | return Attribute("onemptied", value) 452 | 453 | 454 | def onended(value: str) -> Attribute: 455 | return Attribute("onended", value) 456 | 457 | 458 | def onerror(value: str) -> Attribute: 459 | return Attribute("onerror", value) 460 | 461 | 462 | def onfocus(value: str) -> Attribute: 463 | return Attribute("onfocus", value) 464 | 465 | 466 | def onformdata(value: str) -> Attribute: 467 | return Attribute("onformdata", value) 468 | 469 | 470 | def oninput(value: str) -> Attribute: 471 | return Attribute("oninput", value) 472 | 473 | 474 | def oninvalid(value: str) -> Attribute: 475 | return Attribute("oninvalid", value) 476 | 477 | 478 | def onkeydown(value: str) -> Attribute: 479 | return Attribute("onkeydown", value) 480 | 481 | 482 | def onkeypress(value: str) -> Attribute: 483 | return Attribute("onkeypress", value) 484 | 485 | 486 | def onkeyup(value: str) -> Attribute: 487 | return Attribute("onkeyup", value) 488 | 489 | 490 | def onload(value: str) -> Attribute: 491 | return Attribute("onload", value) 492 | 493 | 494 | def onloadeddata(value: str) -> Attribute: 495 | return Attribute("onloadeddata", value) 496 | 497 | 498 | def onloadedmetadata(value: str) -> Attribute: 499 | return Attribute("onloadedmetadata", value) 500 | 501 | 502 | def onloadstart(value: str) -> Attribute: 503 | return Attribute("onloadstart", value) 504 | 505 | 506 | def onmousedown(value: str) -> Attribute: 507 | return Attribute("onmousedown", value) 508 | 509 | 510 | def onmouseenter(value: str) -> Attribute: 511 | return Attribute("onmouseenter", value) 512 | 513 | 514 | def onmouseleave(value: str) -> Attribute: 515 | return Attribute("onmouseleave", value) 516 | 517 | 518 | def onmousemove(value: str) -> Attribute: 519 | return Attribute("onmousemove", value) 520 | 521 | 522 | def onmouseout(value: str) -> Attribute: 523 | return Attribute("onmouseout", value) 524 | 525 | 526 | def onmouseover(value: str) -> Attribute: 527 | return Attribute("onmouseover", value) 528 | 529 | 530 | def onmouseup(value: str) -> Attribute: 531 | return Attribute("onmouseup", value) 532 | 533 | 534 | def onpause(value: str) -> Attribute: 535 | return Attribute("onpause", value) 536 | 537 | 538 | def onplay(value: str) -> Attribute: 539 | return Attribute("onplay", value) 540 | 541 | 542 | def onplaying(value: str) -> Attribute: 543 | return Attribute("onplaying", value) 544 | 545 | 546 | def onprogress(value: str) -> Attribute: 547 | return Attribute("onprogress", value) 548 | 549 | 550 | def onratechange(value: str) -> Attribute: 551 | return Attribute("onratechange", value) 552 | 553 | 554 | def onreset(value: str) -> Attribute: 555 | return Attribute("onreset", value) 556 | 557 | 558 | def onresize(value: str) -> Attribute: 559 | return Attribute("onresize", value) 560 | 561 | 562 | def onscroll(value: str) -> Attribute: 563 | return Attribute("onscroll", value) 564 | 565 | 566 | def onsecuritypolicyviolation(value: str) -> Attribute: 567 | return Attribute("onsecuritypolicyviolation", value) 568 | 569 | 570 | def onseeked(value: str) -> Attribute: 571 | return Attribute("onseeked", value) 572 | 573 | 574 | def onseeking(value: str) -> Attribute: 575 | return Attribute("onseeking", value) 576 | 577 | 578 | def onselect(value: str) -> Attribute: 579 | return Attribute("onselect", value) 580 | 581 | 582 | def onslotchange(value: str) -> Attribute: 583 | return Attribute("onslotchange", value) 584 | 585 | 586 | def onstalled(value: str) -> Attribute: 587 | return Attribute("onstalled", value) 588 | 589 | 590 | def onsubmit(value: str) -> Attribute: 591 | return Attribute("onsubmit", value) 592 | 593 | 594 | def onsuspend(value: str) -> Attribute: 595 | return Attribute("onsuspend", value) 596 | 597 | 598 | def ontimeupdate(value: str) -> Attribute: 599 | return Attribute("ontimeupdate", value) 600 | 601 | 602 | def ontoggle(value: str) -> Attribute: 603 | return Attribute("ontoggle", value) 604 | 605 | 606 | def onvolumechange(value: str) -> Attribute: 607 | return Attribute("onvolumechange", value) 608 | 609 | 610 | def onwaiting(value: str) -> Attribute: 611 | return Attribute("onwaiting", value) 612 | 613 | 614 | def onwheel(value: str) -> Attribute: 615 | return Attribute("onwheel", value) 616 | 617 | 618 | def open(value: bool = True) -> Attribute: 619 | return Attribute("open", value) 620 | 621 | 622 | def optimum(value: str) -> Attribute: 623 | return Attribute("optimum", value) 624 | 625 | 626 | def pattern(value: str) -> Attribute: 627 | return Attribute("pattern", value) 628 | 629 | 630 | def ping(value: str) -> Attribute: 631 | return Attribute("ping", value) 632 | 633 | 634 | def placeholder(value: str) -> Attribute: 635 | return Attribute("placeholder", value) 636 | 637 | 638 | def playsinline(value: bool = True) -> Attribute: 639 | return Attribute("playsinline", value) 640 | 641 | 642 | def popover(value: Literal["auto", "manual"]) -> Attribute: 643 | return Attribute("popover", value) 644 | 645 | 646 | def poster(value: str) -> Attribute: 647 | return Attribute("poster", value) 648 | 649 | 650 | def preload(value: Literal["auto", "metadata", "none"]) -> Attribute: 651 | return Attribute("preload", value) 652 | 653 | 654 | def readonly(value: bool = True) -> Attribute: 655 | return Attribute("readonly", value) 656 | 657 | 658 | def referrerpolicy( 659 | value: Literal[ 660 | "no-referrer", 661 | "no-referrer-when-downgrade", 662 | "origin", 663 | "origin-when-cross-origin", 664 | "same-origin", 665 | "strict-origin", 666 | "strict-origin-when-cross-origin", 667 | "unsafe-url", 668 | ], 669 | ) -> Attribute: 670 | return Attribute("referrerpolicy", value) 671 | 672 | 673 | def rel( 674 | value: Literal[ 675 | "alternate", 676 | "author", 677 | "bookmark", 678 | "canonical", 679 | "dns-prefetch", 680 | "external", 681 | "help", 682 | "icon", 683 | "license", 684 | "manifest", 685 | "modulepreload", 686 | "next", 687 | "nofollow", 688 | "noopener", 689 | "noreferrer", 690 | "opener", 691 | "pingback", 692 | "preconnect", 693 | "prefetch", 694 | "preload", 695 | "prev", 696 | "search", 697 | "stylesheet", 698 | "tag", 699 | ], 700 | ) -> Attribute: 701 | return Attribute("rel", value) 702 | 703 | 704 | def required(value: bool = True) -> Attribute: 705 | return Attribute("required", value) 706 | 707 | 708 | def reversed(value: bool = True) -> Attribute: 709 | return Attribute("reversed", value) 710 | 711 | 712 | def role(value: str) -> Attribute: 713 | return Attribute("role", value) 714 | 715 | 716 | def rows(value: int) -> Attribute: 717 | return Attribute("rows", value) 718 | 719 | 720 | def rowspan(value: int) -> Attribute: 721 | return Attribute("rowspan", value) 722 | 723 | 724 | def sandbox( 725 | *value: Literal[ 726 | "allow-forms", 727 | "allow-modals", 728 | "allow-orientation-lock", 729 | "allow-pointer-lock", 730 | "allow-popups", 731 | "allow-presentation", 732 | "allow-same-origin", 733 | "allow-scripts", 734 | "allow-top-navigation", 735 | "allow-downloads", 736 | "allow-top-navigation-by-user-activation", 737 | ], 738 | ) -> Attribute: 739 | return Attribute("sandbox", " ".join(value)) 740 | 741 | 742 | def scope(value: Literal["col", "row", "colgroup", "rowgroup"]) -> Attribute: 743 | return Attribute("scope", value) 744 | 745 | 746 | def selected(value: bool = True) -> Attribute: 747 | return Attribute("selected", value) 748 | 749 | 750 | def shape(value: Literal["default", "rect", "circle", "poly"]) -> Attribute: 751 | return Attribute("shape", value) 752 | 753 | 754 | def size(value: int) -> Attribute: 755 | return Attribute("size", value) 756 | 757 | 758 | def sizes(*value: str) -> Attribute: 759 | return Attribute("sizes", ",".join(value)) 760 | 761 | 762 | def slot(value: str) -> Attribute: 763 | return Attribute("slot", value) 764 | 765 | 766 | def span(value: int) -> Attribute: 767 | return Attribute("span", value) 768 | 769 | 770 | def spellcheck(value: Literal["true", "false"] | bool = True) -> Attribute: 771 | return Attribute("spellcheck", value) 772 | 773 | 774 | def src(value: str) -> Attribute: 775 | return Attribute("src", value) 776 | 777 | 778 | def srcdoc(value: str) -> Attribute: 779 | return Attribute("srcdoc", value) 780 | 781 | 782 | def srclang(value: str) -> Attribute: 783 | return Attribute("srclang", value) 784 | 785 | 786 | def srcset(*value: str) -> Attribute: 787 | return Attribute("srcset", ",".join(value)) 788 | 789 | 790 | def start(value: str) -> Attribute: 791 | return Attribute("start", value) 792 | 793 | 794 | def step(value: int | float | Literal["any"]) -> Attribute: 795 | return Attribute("step", value) 796 | 797 | 798 | def style(value: str) -> Attribute: 799 | return Attribute("style", value) 800 | 801 | 802 | def tabindex(value: int) -> Attribute: 803 | return Attribute("tabindex", value) 804 | 805 | 806 | def target( 807 | value: Literal["_self", "_blank", "_parent", "_top"] | str, 808 | ) -> Attribute: 809 | return Attribute("target", value) 810 | 811 | 812 | def title(value: str) -> Attribute: 813 | return Attribute("title", value) 814 | 815 | 816 | def translate(value: Literal["yes", "no"]) -> Attribute: 817 | return Attribute("translate", value) 818 | 819 | 820 | def type_( 821 | value: Literal[ 822 | "button", 823 | "checkbox", 824 | "color", 825 | "date", 826 | "datetime-local", 827 | "email", 828 | "file", 829 | "hidden", 830 | "image", 831 | "month", 832 | "number", 833 | "password", 834 | "radio", 835 | "range", 836 | "reset", 837 | "search", 838 | "submit", 839 | "tel", 840 | "text", 841 | "time", 842 | "url", 843 | "week", 844 | ], 845 | ) -> Attribute: 846 | return Attribute("type", value) 847 | 848 | 849 | def usemap(value: str) -> Attribute: 850 | return Attribute("usemap", value) 851 | 852 | 853 | def value(value: str | int | float) -> Attribute: 854 | return Attribute("value", value) 855 | 856 | 857 | def virtualkeyboardpolicy( 858 | value: Literal["auto", "manual"] = "auto", 859 | ) -> Attribute: 860 | return Attribute("virtualkeyboardpolicy", value) 861 | 862 | 863 | def writingsuggestions(value: bool = True) -> Attribute: 864 | return Attribute("writingsuggestions", value) 865 | 866 | 867 | def width(value: int | str) -> Attribute: 868 | return Attribute("width", value) 869 | 870 | 871 | def wrap(value: Literal["hard", "soft", "off"]) -> Attribute: 872 | return Attribute("wrap", value) 873 | -------------------------------------------------------------------------------- /src/markupy/_private/attributes/store.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from functools import lru_cache 3 | 4 | from markupy.exceptions import MarkupyError 5 | 6 | from .attribute import Attribute 7 | from .handlers import attribute_handlers 8 | 9 | 10 | @attribute_handlers.register 11 | def default_attribute_handler( 12 | old: Attribute | None, new: Attribute 13 | ) -> Attribute | None: 14 | if old is None or old.value is None or old.value == new.value: 15 | # Prefer returning None instead of new here for multiple reasons: 16 | # - better performance 17 | # - do not rely on presence of handler to persist attributes 18 | # Attribute redefinition is allowed if values are equal 19 | # this is useful when bundling attrs into "traits" tuples 20 | # that may overlap with each other 21 | return None 22 | elif new.value is None: 23 | new.value = old.value 24 | return None 25 | elif new.name == "class": 26 | # For class, attribute redefinition is allowed: 27 | # new values get appended to old values and then deduplicated 28 | merged = f"{old.value} {new.value}" 29 | new.value = " ".join(dict.fromkeys(merged.split())) 30 | return None 31 | 32 | raise MarkupyError(f"Invalid attempt to redefine attribute `{new.name}`") 33 | 34 | 35 | @lru_cache(maxsize=1000) 36 | def python_to_html_key(key: str) -> str: 37 | if not key.isidentifier(): 38 | # Might happen when using the **{} syntax 39 | raise MarkupyError(f"Attribute `{key}` has invalid name") 40 | if key == "_": 41 | # Preserve single underscore for hyperscript compatibility 42 | return key 43 | # Trailing underscore "_" is meaningless and is used to escape protected 44 | # keywords that might be used as attr keys such as class_ and for_ 45 | # Underscores become dashes 46 | return key.removesuffix("_").replace("_", "-") 47 | 48 | 49 | class AttributeStore(dict[str, Attribute]): 50 | __slots__ = () 51 | 52 | def __setitem__(self, key: str, new: Attribute) -> None: 53 | old = self[key] if key in self else None 54 | for handler in attribute_handlers: 55 | if attribute := handler(old, new): 56 | if attribute is new: 57 | # stop the handler chain 58 | new = attribute 59 | break 60 | else: 61 | # restart a handler chain (beware of infinite loops!) 62 | return self.add(attribute) 63 | 64 | super().__setitem__(key, new) 65 | 66 | def __str__(self) -> str: 67 | return " ".join(filter(None, map(str, self.values()))) 68 | 69 | def add_selector(self, selector: str) -> None: 70 | if selector := selector.replace(".", " ").strip(): 71 | if "#" in selector[1:]: 72 | raise MarkupyError( 73 | "Id must be defined only once and must be in first position of selector" 74 | ) 75 | if selector.startswith("#"): 76 | rawid, *classes = selector.split() 77 | if id := rawid[1:]: 78 | self.add(Attribute("id", id)) 79 | else: 80 | classes = selector.split() 81 | 82 | if classes: 83 | self.add(Attribute("class", " ".join(classes))) 84 | 85 | def add_dict( 86 | self, 87 | dct: Mapping[Attribute.Name, Attribute.Value], 88 | *, 89 | rewrite_keys: bool = False, 90 | ) -> None: 91 | for key, value in dct.items(): 92 | name = python_to_html_key(key) if rewrite_keys else key 93 | self.add(Attribute(name, value)) 94 | 95 | def add_tuple(self, attr: tuple[Attribute.Name, Attribute.Value]) -> None: 96 | name, value = attr 97 | self.add(Attribute(name, value)) 98 | 99 | def add(self, attr: Attribute) -> None: 100 | self[attr.name] = attr 101 | -------------------------------------------------------------------------------- /src/markupy/_private/html_to_markupy/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import to_markupy as html_to_markupy 2 | 3 | __all__ = ["html_to_markupy"] 4 | -------------------------------------------------------------------------------- /src/markupy/_private/html_to_markupy/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from .parser import to_markupy 5 | 6 | 7 | def main() -> None: 8 | parser = argparse.ArgumentParser(prog="html2markupy") 9 | 10 | parser.add_argument( 11 | "--selector", 12 | action=argparse.BooleanOptionalAction, 13 | help="Use the selector #id.class syntax instead of explicit `id` and `class_` attributes", 14 | default=True, 15 | ) 16 | parser.add_argument( 17 | "--dict-attrs", 18 | action=argparse.BooleanOptionalAction, 19 | help="Prefer dict attributes", 20 | default=False, 21 | ) 22 | parser.add_argument( 23 | "--el-prefix", 24 | action=argparse.BooleanOptionalAction, 25 | help="Output mode for imports of markupy elements", 26 | default=False, 27 | ) 28 | parser.add_argument( 29 | "input", 30 | type=argparse.FileType("r"), 31 | nargs="?", 32 | default=sys.stdin, 33 | help="input HTML from file or stdin", 34 | ) 35 | 36 | args = parser.parse_args() 37 | try: 38 | html = args.input.read() 39 | except KeyboardInterrupt: 40 | print("\nInterrupted", file=sys.stderr) 41 | sys.exit(1) 42 | 43 | use_selector: bool = args.selector 44 | use_dict: bool = args.dict_attrs 45 | use_import_el: bool = args.el_prefix 46 | 47 | print( 48 | to_markupy( 49 | html, 50 | use_selector=use_selector, 51 | use_dict=use_dict, 52 | use_import_el=use_import_el, 53 | ) 54 | ) 55 | -------------------------------------------------------------------------------- /src/markupy/_private/html_to_markupy/parser.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from html.parser import HTMLParser 3 | from json import dumps as json_dumps 4 | from keyword import iskeyword 5 | from re import sub as re_sub 6 | from typing import Iterator 7 | 8 | from markupsafe import escape 9 | 10 | from ...exceptions import MarkupyError 11 | from ..views.element import SPECIAL_ELEMENTS, VoidElement 12 | 13 | VOID_ELEMENTS: set[str] = { 14 | name for name, cls in SPECIAL_ELEMENTS.items() if cls is VoidElement 15 | } 16 | 17 | 18 | def _is_void_element(name: str) -> bool: 19 | return name in VOID_ELEMENTS 20 | 21 | 22 | # https://html.spec.whatwg.org/multipage/indices.html#attributes-3 23 | BOOLEAN_ATTRIBUTES: set[str] = { 24 | "allowfullscreen", 25 | "async", 26 | "autofocus", 27 | "autoplay", 28 | "checked", 29 | "controls", 30 | "default", 31 | "defer", 32 | "disabled", 33 | "formnovalidate", 34 | "inert", 35 | "ismap", 36 | "itemscope", 37 | "loop", 38 | "multiple", 39 | "muted", 40 | "nomodule", 41 | "novalidate", 42 | "open", 43 | "playsinline", 44 | "readonly", 45 | "required", 46 | "reversed", 47 | "selected", 48 | } 49 | 50 | 51 | def _is_boolean_attribute(name: str) -> bool: 52 | return name in BOOLEAN_ATTRIBUTES 53 | 54 | 55 | def _format_attribute_key(key: str) -> str | None: 56 | pykey = key.replace("-", "_") 57 | if pykey.isidentifier(): 58 | if iskeyword(pykey): 59 | # Escape python reserved keywords 60 | return f"{pykey}_" 61 | return pykey 62 | return None 63 | 64 | 65 | def _format_attribute_value(value: str | None) -> str: 66 | if value is None: 67 | return "True" 68 | return json_dumps(value) 69 | 70 | 71 | def _format_attrs_dict(attrs: Mapping[str, str | None]) -> str: 72 | return "{" + ",".join(f'"{key}":{value}' for key, value in attrs.items()) + "}" 73 | 74 | 75 | # Process attrs as they are received from the html parser 76 | def _format_attrs( 77 | attrs: list[tuple[str, str | None]], *, use_selector: bool, use_dict: bool 78 | ) -> str | None: 79 | if not attrs: 80 | return None 81 | 82 | arguments: list[str] = [] 83 | 84 | selector: str = "" 85 | attrs_kwargs: list[str] = list() 86 | attrs_dict: dict[str, str | None] = dict() 87 | 88 | for key, value in attrs: 89 | if _is_boolean_attribute(key): 90 | value = None 91 | elif not value: 92 | continue 93 | elif key == "id" and use_selector and "{" not in value: 94 | selector = f"#{value}{selector}" 95 | continue 96 | elif key == "class" and use_selector and "{" not in value: 97 | selector = f"{selector}.{'.'.join(value.split())}" 98 | continue 99 | 100 | if use_dict: 101 | attrs_dict[key] = _format_attribute_value(value) 102 | else: 103 | if py_key := _format_attribute_key(key): 104 | attrs_kwargs.append(f"{py_key}={_format_attribute_value(value)}") 105 | else: 106 | attrs_dict[key] = _format_attribute_value(value) 107 | 108 | if selector: 109 | arguments.append(_format_attribute_value(selector)) 110 | if attrs_dict: 111 | arguments.append(_format_attrs_dict(attrs_dict)) 112 | if attrs_kwargs: 113 | arguments += attrs_kwargs 114 | 115 | if len(arguments) > 0: 116 | return f"({','.join(arguments)})" 117 | 118 | return None 119 | 120 | 121 | class Stack: 122 | def __init__(self) -> None: 123 | self._list: list[str] = [] 124 | 125 | def __len__(self) -> int: 126 | return len(self._list) 127 | 128 | def push(self, item: str) -> None: 129 | if not item: 130 | raise MarkupyError("Can't push None or empty string into stack") 131 | self._list.append(item) 132 | 133 | def peek(self) -> str | None: 134 | if len(self) > 0: 135 | return self._list[-1] 136 | return None 137 | 138 | def pop(self) -> str | None: 139 | if len(self) > 0: 140 | return self._list.pop() 141 | return None 142 | 143 | def __iter__(self) -> Iterator[str]: 144 | yield from self._list 145 | 146 | 147 | class MarkupyParser(HTMLParser): 148 | def __init__( 149 | self, *, use_selector: bool, use_dict: bool, use_import_el: bool 150 | ) -> None: 151 | self.count_top_level: int = 0 152 | self.code_stack: Stack = Stack() 153 | self.unclosed_stack: Stack = Stack() 154 | self.imports: set[str] = set() 155 | self.use_import_el: bool = use_import_el 156 | self.use_dict: bool = use_dict 157 | self.use_selector: bool = use_selector 158 | super().__init__() 159 | 160 | def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: 161 | # print("Encountered a start tag:", tag, attrs) 162 | if len(self.unclosed_stack) == 0: 163 | self.count_top_level += 1 164 | if not _is_void_element(tag): 165 | self.unclosed_stack.push(tag) 166 | 167 | markupy_tag = "".join(map(lambda x: x.capitalize(), tag.split("-"))) 168 | if self.use_import_el: 169 | markupy_tag = f"el.{markupy_tag}" 170 | 171 | self.imports.add(markupy_tag) 172 | self.code_stack.push(markupy_tag) 173 | if attributes_str := _format_attrs( 174 | attrs, use_selector=self.use_selector, use_dict=self.use_dict 175 | ): 176 | self.code_stack.push(attributes_str) 177 | if _is_void_element(tag): 178 | self.code_stack.push(",") 179 | else: 180 | self.code_stack.push("[") 181 | 182 | def handle_endtag(self, tag: str) -> None: 183 | # print("Encountered an end tag :", tag) 184 | if not _is_void_element(tag): 185 | last_open_tag = self.unclosed_stack.pop() 186 | if last_open_tag is None: 187 | raise MarkupyError(f"Unexpected closing tag ``") 188 | elif tag != "endblock" and tag != last_open_tag: 189 | raise MarkupyError( 190 | f"Invalid closing tag ``, expected ``" 191 | ) 192 | elif tag == "endblock" and not last_open_tag.startswith("block-"): 193 | raise MarkupyError( 194 | f"Invalid template `endblock`, expected ``" 195 | ) 196 | 197 | if self.code_stack.peek() == ",": 198 | self.code_stack.pop() 199 | if self.code_stack.peek() == "[": 200 | self.code_stack.pop() 201 | elif not _is_void_element(tag): 202 | self.code_stack.push("]") 203 | self.code_stack.push(",") 204 | 205 | def handle_data(self, data: str) -> None: 206 | # print("Encountered some data :", data) 207 | for line in data.splitlines(): 208 | # Strip newlines, leading/traing spaces and redundant spaces 209 | line = " ".join(line.split()) 210 | if not line: 211 | continue 212 | if len(self.unclosed_stack) == 0: 213 | self.count_top_level += 1 214 | self.code_stack.push(json_dumps(line)) 215 | self.code_stack.push(",") 216 | 217 | def output_imports(self) -> str: 218 | str_markupy_imports: str = "" 219 | str_markupy_tag_imports: str = "" 220 | markupy_imports: set[str] = set() 221 | if self.count_top_level > 1: 222 | markupy_imports.add("Fragment") 223 | if self.imports: 224 | if self.use_import_el: 225 | markupy_imports.add("elements as el") 226 | # return "from markupy import tag\n" 227 | else: 228 | str_markupy_tag_imports = ( 229 | f"from markupy.elements import {','.join(sorted(self.imports))}\n" 230 | ) 231 | if markupy_imports: 232 | str_markupy_imports = ( 233 | f"from markupy import {','.join(sorted(markupy_imports))}\n" 234 | ) 235 | return str_markupy_imports + str_markupy_tag_imports 236 | 237 | def output_code(self) -> str: 238 | code = "".join(self.code_stack).strip(",") 239 | if self.count_top_level > 1: 240 | return f"Fragment[{code}]" 241 | return code 242 | 243 | 244 | def _template_process(html: str) -> str: 245 | # Replace opening `block` 246 | html = re_sub( 247 | r"{%[+-]?\s+block\s+([a-zA-Z_]+)\s+[+-]?%}", 248 | lambda match: f"", 249 | html, 250 | ) 251 | # Replace closing `block` 252 | html = re_sub( 253 | r"{%[+-]?\s+endblock(?:\s+[a-zA-Z_]+)?\s+[+-]?%}", 254 | # we can use whatever closing tag name we want here as it'll end up being replace with a closing bracket 255 | "", 256 | html, 257 | ) 258 | # Escape template contents 259 | html = re_sub( 260 | r"{{.*?}}|{%.*?%}|{#.*?#}", 261 | lambda match: escape(match.group(0)), 262 | html, 263 | ) 264 | return html 265 | 266 | 267 | def to_markupy( 268 | html: str, 269 | *, 270 | use_selector: bool = True, 271 | use_dict: bool = False, 272 | use_import_el: bool = False, 273 | ) -> str: 274 | parser = MarkupyParser( 275 | use_selector=use_selector, use_dict=use_dict, use_import_el=use_import_el 276 | ) 277 | parser.feed(_template_process(html)) 278 | parser.close() 279 | if tag := parser.unclosed_stack.pop(): 280 | raise MarkupyError(f"Opening tag `<{tag}>` was not closed") 281 | if code := parser.output_code(): 282 | return f"{parser.output_imports()}{code}" 283 | return "" 284 | -------------------------------------------------------------------------------- /src/markupy/_private/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .component import Component 2 | from .element import Element, get_element 3 | from .fragment import Fragment 4 | from .view import View 5 | 6 | __all__ = [ 7 | "Component", 8 | "Element", 9 | "Fragment", 10 | "View", 11 | "get_element", 12 | ] 13 | -------------------------------------------------------------------------------- /src/markupy/_private/views/component.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from collections.abc import Iterator 3 | from typing import Any, final 4 | 5 | from typing_extensions import Self 6 | 7 | from markupy.exceptions import MarkupyError 8 | 9 | from .view import View 10 | 11 | 12 | class Component(View): 13 | __slots__ = () 14 | 15 | def __init__(self) -> None: 16 | # Implementation here is useless but allows for a nice 17 | # argument-less super().__init__() autocomplete in user's IDE 18 | super().__init__() 19 | 20 | def __post_init__(self) -> None: 21 | # Allows for simple dataclass Component definition 22 | super().__init__() 23 | 24 | @final 25 | def __getitem__(self, content: Any) -> Self: 26 | if not hasattr(self, "_children"): 27 | raise MarkupyError( 28 | "Subclasses of must call `super().__init__()` if they override the default initializer." 29 | ) 30 | 31 | return super().__getitem__(content) 32 | 33 | @abstractmethod 34 | def render(self) -> View: ... 35 | 36 | @final 37 | def render_content(self) -> View: 38 | # Use getattr here to avoid error in case super()__init__ hasn't been called 39 | if children := getattr(self, "_children", None): 40 | if len(children) == 1 and isinstance(children[0], View): 41 | # Only 1 view child: return it 42 | return children[0] 43 | else: 44 | # One non-view child or multiple children: wrap in a fragment 45 | # (do not use the [] syntax to avoid re-processing/re-escaping) 46 | view = View() 47 | view._children = children 48 | return view 49 | 50 | else: 51 | # No children or super().__init__() hasn't been called and we are 52 | # missing the _children attribute: return empty view 53 | return View() 54 | 55 | @final 56 | def __iter__(self) -> Iterator[str]: 57 | node = self.render() 58 | if isinstance(node, View): # type: ignore[unused-ignore] 59 | yield from node 60 | else: 61 | raise MarkupyError( 62 | f"`{type(self).__name__}.render()` must return an instance of or one of its subclasses (Element, Fragment, Component)" 63 | ) 64 | 65 | @final 66 | def __repr__(self) -> str: 67 | return f"" 68 | -------------------------------------------------------------------------------- /src/markupy/_private/views/element.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator, Mapping 2 | from functools import lru_cache 3 | from re import match as re_fullmatch 4 | from re import sub as re_sub 5 | from typing import Any, TypeAlias, overload 6 | 7 | from typing_extensions import Self, override 8 | 9 | from markupy.exceptions import MarkupyError 10 | 11 | from ..attributes import Attribute, AttributeStore 12 | from .fragment import Fragment 13 | 14 | AttributeArgs: TypeAlias = ( 15 | Mapping[Attribute.Name, Attribute.Value] 16 | | tuple[Attribute.Name, Attribute.Value] 17 | | Attribute 18 | | None 19 | ) 20 | 21 | 22 | class Element(Fragment): 23 | __slots__ = ("_attributes", "_name") 24 | 25 | def __init__(self, name: str, *, safe: bool = False, shared: bool = True) -> None: 26 | super().__init__(safe=safe, shared=shared) 27 | self._name = name 28 | self._attributes: str | None = None 29 | 30 | def __copy__(self) -> Self: 31 | return type(self)(self.name, shared=False) 32 | 33 | @property 34 | def name(self) -> str: 35 | return self._name 36 | 37 | def _tag_opening(self) -> str: 38 | if attributes := self._attributes: 39 | return f"<{self._name} {attributes}>" 40 | return f"<{self._name}>" 41 | 42 | def _tag_closing(self) -> str: 43 | return f"" 44 | 45 | def __iter__(self) -> Iterator[str]: 46 | yield self._tag_opening() 47 | yield from super().__iter__() 48 | yield self._tag_closing() 49 | 50 | def __repr__(self) -> str: 51 | return f"" 52 | 53 | # Use call syntax () to define attributes 54 | @overload 55 | def __call__(self, *args: AttributeArgs, **kwargs: Attribute.Value) -> Self: ... 56 | @overload 57 | def __call__( 58 | self, 59 | selector: str, 60 | *args: AttributeArgs, 61 | **kwargs: Attribute.Value, 62 | ) -> Self: ... 63 | def __call__(self, *args: Any, **kwargs: Any) -> Self: 64 | if self._attributes is not None: 65 | raise MarkupyError( 66 | f"Illegal attempt to redefine attributes for element {self!r}" 67 | ) 68 | 69 | if self._children: 70 | raise MarkupyError( 71 | f"Illegal attempt to define attributes after children for element {self!r}" 72 | ) 73 | 74 | attrs = AttributeStore() 75 | for arg in args: 76 | if len(attrs) == 0 and isinstance(arg, str): 77 | attrs.add_selector(arg) 78 | elif arg is None: 79 | pass 80 | elif isinstance(arg, Mapping): 81 | attrs.add_dict(arg) # type:ignore[unused-ignore] 82 | elif isinstance(arg, tuple): 83 | attrs.add_tuple(arg) # type:ignore[unused-ignore] 84 | elif isinstance(arg, Attribute): 85 | attrs.add(arg) 86 | else: 87 | raise MarkupyError(f"Invalid argument {arg!r} for element {self!r}") 88 | if kwargs: 89 | attrs.add_dict(kwargs, rewrite_keys=True) 90 | 91 | if attributes := str(attrs): 92 | el = self._get_instance() 93 | el._attributes = attributes 94 | return el 95 | 96 | return self 97 | 98 | 99 | class HtmlElement(Element): 100 | __slots__ = () 101 | 102 | @override 103 | def __iter__(self) -> Iterator[str]: 104 | yield "" 105 | yield from super().__iter__() 106 | 107 | 108 | class VoidElement(Element): 109 | __slots__ = () 110 | 111 | @override 112 | def __iter__(self) -> Iterator[str]: 113 | yield self._tag_opening() 114 | 115 | @override 116 | def __getitem__(self, children: Any) -> Self: 117 | raise MarkupyError(f"Void element {self!r} cannot contain children") 118 | 119 | 120 | class CommentElement(Element): 121 | __slots__ = () 122 | 123 | @override 124 | def _tag_opening(self) -> str: 125 | return "" 130 | 131 | @override 132 | def __call__(self, *args: Any, **kwargs: Any) -> Self: 133 | raise MarkupyError(f"Comment element {self!r} cannot have attributes") 134 | 135 | 136 | class SafeElement(Element): 137 | __slots__ = () 138 | 139 | def __init__(self, name: str, *, shared: bool = True) -> None: 140 | super().__init__(name, safe=True, shared=shared) 141 | 142 | 143 | SPECIAL_ELEMENTS: dict[str, type[Element]] = { 144 | "area": VoidElement, 145 | "base": VoidElement, 146 | "br": VoidElement, 147 | "col": VoidElement, 148 | "embed": VoidElement, 149 | "hr": VoidElement, 150 | "img": VoidElement, 151 | "input": VoidElement, 152 | "link": VoidElement, 153 | "meta": VoidElement, 154 | "param": VoidElement, 155 | "source": VoidElement, 156 | "track": VoidElement, 157 | "wbr": VoidElement, 158 | "_": CommentElement, 159 | "script": SafeElement, 160 | "style": SafeElement, 161 | "html": HtmlElement, 162 | } 163 | 164 | 165 | @lru_cache(maxsize=300) 166 | def get_element(name: str) -> Element: 167 | if name == "_": 168 | # Special exception for CommentElement 169 | html_name = "_" 170 | elif name.startswith("_"): 171 | # Needed when called from __getattr__ 172 | raise AttributeError 173 | elif not re_fullmatch(r"^(?:[A-Z][a-z0-9]*)+$", name): 174 | raise MarkupyError( 175 | f"`{name}` is not a valid element name (must use CapitalizedCase)" 176 | ) 177 | else: 178 | # Uppercase chars are word boundaries for tag names 179 | words = filter(None, re_sub(r"([A-Z])", r" \1", name).split()) 180 | html_name = "-".join(words).lower() 181 | 182 | cls = SPECIAL_ELEMENTS.get(html_name, Element) 183 | return cls(html_name) 184 | -------------------------------------------------------------------------------- /src/markupy/_private/views/fragment.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from typing import final 3 | 4 | from typing_extensions import Self 5 | 6 | from .view import View 7 | 8 | 9 | class Fragment(View): 10 | __slots__ = ("_shared",) 11 | 12 | def __init__(self, *, safe: bool = False, shared: bool = True) -> None: 13 | super().__init__(safe=safe) 14 | self._shared: bool = shared 15 | 16 | def __copy__(self) -> Self: 17 | return type(self)(shared=False) 18 | 19 | def __call__(self) -> Self: 20 | return self 21 | 22 | @final 23 | def _get_instance(self: Self) -> Self: 24 | # When imported, elements are loaded from a shared instance 25 | # Make sure we re-instantiate them on setting attributes/children 26 | # to avoid sharing attributes/children between multiple instances 27 | if self._shared: 28 | return copy(self) 29 | return self 30 | 31 | # Avoid having Django "call" a markupy fragment (or element) that is injected into a template. 32 | # Setting do_not_call_in_templates will prevent Django from doing an extra call: 33 | # https://docs.djangoproject.com/en/5.0/ref/templates/api/#variables-and-lookups 34 | do_not_call_in_templates = True 35 | -------------------------------------------------------------------------------- /src/markupy/_private/views/view.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Iterator 2 | from inspect import isclass, isfunction, ismethod 3 | from typing import Any, TypeAlias, final 4 | 5 | from markupsafe import Markup, escape 6 | from typing_extensions import Self 7 | 8 | from ...exceptions import MarkupyError 9 | 10 | ChildType: TypeAlias = "str | View" 11 | ChildrenType: TypeAlias = tuple[ChildType, ...] 12 | 13 | 14 | class View: 15 | __slots__ = ("_children", "_safe") 16 | 17 | def __init__(self, *, safe: bool = False) -> None: 18 | super().__init__() 19 | self._children: ChildrenType = tuple() 20 | self._safe: bool = safe 21 | 22 | @final 23 | def __str__(self) -> str: 24 | # Return needs to be Markup and not plain str 25 | # to be properly injected in template engines 26 | return Markup("".join(self)) 27 | 28 | @final 29 | def __html__(self) -> str: 30 | return str(self) 31 | 32 | @final 33 | def __eq__(self, other: object) -> bool: 34 | return str(other) == str(self) 35 | 36 | @final 37 | def __ne__(self, other: object) -> bool: 38 | return not self.__eq__(other) 39 | 40 | def __repr__(self) -> str: 41 | return "" 42 | 43 | def __iter__(self) -> Iterator[str]: 44 | for node in self._children: 45 | if isinstance(node, View): 46 | yield from node 47 | else: 48 | yield node 49 | 50 | def _iter_node(self, node: Any) -> Iterator[ChildType]: 51 | if node is None or isinstance(node, bool): 52 | return 53 | elif isinstance(node, View): 54 | # View is Iterable, must check in priority 55 | yield node 56 | elif isinstance(node, Iterable) and not isinstance(node, str): 57 | for child in node: # type: ignore[unused-ignore] 58 | yield from self._iter_node(child) 59 | elif isfunction(node) or ismethod(node) or isclass(node): 60 | # Allows to catch uncalled functions/methods or uninstanciated classes 61 | raise MarkupyError( 62 | f"Invalid child node {node!r} provided for {self!r}; Did you mean `{node.__name__}()` ?" 63 | ) 64 | else: 65 | try: 66 | if s := str(node if self._safe else escape(node)): 67 | yield s 68 | except Exception as e: 69 | raise MarkupyError( 70 | f"Invalid child node {node!r} provided for {self!r}" 71 | ) from e 72 | 73 | # Use subscriptable [] syntax to assign children 74 | def __getitem__(self, content: Any) -> Self: 75 | if self._children: 76 | raise MarkupyError(f"Illegal attempt to redefine children of {self!r}") 77 | 78 | if children := tuple(self._iter_node(content)): 79 | instance = self._get_instance() 80 | instance._children = children 81 | return instance 82 | 83 | return self 84 | 85 | def _get_instance(self: Self) -> Self: 86 | return self 87 | 88 | # Allow starlette Response.render to directly render this element without 89 | # explicitly casting to str: 90 | # https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49 91 | @final 92 | def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: 93 | return str(self).encode(encoding, errors) 94 | -------------------------------------------------------------------------------- /src/markupy/attributes.py: -------------------------------------------------------------------------------- 1 | from ._private.attributes import html as _html 2 | 3 | __getattr__ = _html.getattr 4 | 5 | accept = _html.accept 6 | accept_charset = _html.accept_charset 7 | accesskey = _html.accesskey 8 | action = _html.action 9 | align = _html.align 10 | allow = _html.allow 11 | alt = _html.alt 12 | as_ = _html.as_ 13 | async_ = _html.async_ 14 | autocapitalize = _html.autocapitalize 15 | autocomplete = _html.autocomplete 16 | autofocus = _html.autofocus 17 | autoplay = _html.autoplay 18 | background = _html.background 19 | bgcolor = _html.bgcolor 20 | border = _html.border 21 | capture = _html.capture 22 | charset = _html.charset 23 | checked = _html.checked 24 | cite = _html.cite 25 | class_ = _html.class_ 26 | color = _html.color 27 | cols = _html.cols 28 | colspan = _html.colspan 29 | content = _html.content 30 | contenteditable = _html.contenteditable 31 | controls = _html.controls 32 | coords = _html.coords 33 | crossorigin = _html.crossorigin 34 | csp = _html.csp 35 | data = _html.data 36 | datetime = _html.datetime 37 | decoding = _html.decoding 38 | default = _html.default 39 | defer = _html.defer 40 | dir = _html.dir 41 | dirname = _html.dirname 42 | disabled = _html.disabled 43 | download = _html.download 44 | draggable = _html.draggable 45 | enctype = _html.enctype 46 | enterkeyhint = _html.enterkeyhint 47 | elementtiming = _html.elementtiming 48 | for_ = _html.for_ 49 | form = _html.form 50 | formaction = _html.formaction 51 | formenctype = _html.formenctype 52 | formmethod = _html.formmethod 53 | formnovalidate = _html.formnovalidate 54 | formtarget = _html.formtarget 55 | headers = _html.headers 56 | height = _html.height 57 | hidden = _html.hidden 58 | high = _html.high 59 | href = _html.href 60 | hreflang = _html.hreflang 61 | http_equiv = _html.http_equiv 62 | id = _html.id 63 | inputmode = _html.inputmode 64 | integrity = _html.integrity 65 | ismap = _html.ismap 66 | itemprop = _html.itemprop 67 | kind = _html.kind 68 | label = _html.label 69 | lang = _html.lang 70 | loading = _html.loading 71 | list_ = _html.list_ 72 | loop = _html.loop 73 | low = _html.low 74 | max = _html.max 75 | maxlength = _html.maxlength 76 | media = _html.media 77 | method = _html.method 78 | min = _html.min 79 | minlength = _html.minlength 80 | multiple = _html.multiple 81 | muted = _html.muted 82 | name = _html.name 83 | novalidate = _html.novalidate 84 | onabort = _html.onabort 85 | onautocomplete = _html.onautocomplete 86 | onautocompleteerror = _html.onautocompleteerror 87 | onblur = _html.onblur 88 | oncancel = _html.oncancel 89 | oncanplay = _html.oncanplay 90 | oncanplaythrough = _html.oncanplaythrough 91 | onchange = _html.onchange 92 | onclick = _html.onclick 93 | onclose = _html.onclose 94 | oncontextmenu = _html.oncontextmenu 95 | oncuechange = _html.oncuechange 96 | ondblclick = _html.ondblclick 97 | ondrag = _html.ondrag 98 | ondragend = _html.ondragend 99 | ondragenter = _html.ondragenter 100 | ondragexit = _html.ondragexit 101 | ondragleave = _html.ondragleave 102 | ondragover = _html.ondragover 103 | ondragstart = _html.ondragstart 104 | ondrop = _html.ondrop 105 | ondurationchange = _html.ondurationchange 106 | onemptied = _html.onemptied 107 | onended = _html.onended 108 | onerror = _html.onerror 109 | onfocus = _html.onfocus 110 | onformdata = _html.onformdata 111 | oninput = _html.oninput 112 | oninvalid = _html.oninvalid 113 | onkeydown = _html.onkeydown 114 | onkeypress = _html.onkeypress 115 | onkeyup = _html.onkeyup 116 | onload = _html.onload 117 | onloadeddata = _html.onloadeddata 118 | onloadedmetadata = _html.onloadedmetadata 119 | onloadstart = _html.onloadstart 120 | onmousedown = _html.onmousedown 121 | onmouseenter = _html.onmouseenter 122 | onmouseleave = _html.onmouseleave 123 | onmousemove = _html.onmousemove 124 | onmouseout = _html.onmouseout 125 | onmouseover = _html.onmouseover 126 | onmouseup = _html.onmouseup 127 | onpause = _html.onpause 128 | onplay = _html.onplay 129 | onplaying = _html.onplaying 130 | onprogress = _html.onprogress 131 | onratechange = _html.onratechange 132 | onreset = _html.onreset 133 | onresize = _html.onresize 134 | onscroll = _html.onscroll 135 | onsecuritypolicyviolation = _html.onsecuritypolicyviolation 136 | onseeked = _html.onseeked 137 | onseeking = _html.onseeking 138 | onselect = _html.onselect 139 | onslotchange = _html.onslotchange 140 | onstalled = _html.onstalled 141 | onsubmit = _html.onsubmit 142 | onsuspend = _html.onsuspend 143 | ontimeupdate = _html.ontimeupdate 144 | ontoggle = _html.ontoggle 145 | onvolumechange = _html.onvolumechange 146 | onwaiting = _html.onwaiting 147 | onwheel = _html.onwheel 148 | open = _html.open 149 | optimum = _html.optimum 150 | pattern = _html.pattern 151 | ping = _html.ping 152 | placeholder = _html.placeholder 153 | playsinline = _html.playsinline 154 | popover = _html.popover 155 | poster = _html.poster 156 | preload = _html.preload 157 | readonly = _html.readonly 158 | referrerpolicy = _html.referrerpolicy 159 | rel = _html.rel 160 | required = _html.required 161 | reversed = _html.reversed 162 | role = _html.role 163 | rows = _html.rows 164 | rowspan = _html.rowspan 165 | sandbox = _html.sandbox 166 | scope = _html.scope 167 | selected = _html.selected 168 | shape = _html.shape 169 | size = _html.size 170 | sizes = _html.sizes 171 | slot = _html.slot 172 | span = _html.span 173 | spellcheck = _html.spellcheck 174 | src = _html.src 175 | srcdoc = _html.srcdoc 176 | srclang = _html.srclang 177 | srcset = _html.srcset 178 | start = _html.start 179 | step = _html.step 180 | style = _html.style 181 | tabindex = _html.tabindex 182 | target = _html.target 183 | title = _html.title 184 | translate = _html.translate 185 | type_ = _html.type_ 186 | usemap = _html.usemap 187 | value = _html.value 188 | virtualkeyboardpolicy = _html.virtualkeyboardpolicy 189 | writingsuggestions = _html.writingsuggestions 190 | width = _html.width 191 | wrap = _html.wrap 192 | 193 | __all__ = [ 194 | "accept", 195 | "accept_charset", 196 | "accesskey", 197 | "action", 198 | "align", 199 | "allow", 200 | "alt", 201 | "as_", 202 | "async_", 203 | "autocapitalize", 204 | "autocomplete", 205 | "autofocus", 206 | "autoplay", 207 | "background", 208 | "bgcolor", 209 | "border", 210 | "capture", 211 | "charset", 212 | "checked", 213 | "cite", 214 | "class_", 215 | "color", 216 | "cols", 217 | "colspan", 218 | "content", 219 | "contenteditable", 220 | "controls", 221 | "coords", 222 | "crossorigin", 223 | "csp", 224 | "data", 225 | "datetime", 226 | "decoding", 227 | "default", 228 | "defer", 229 | "dir", 230 | "dirname", 231 | "disabled", 232 | "download", 233 | "draggable", 234 | "enctype", 235 | "enterkeyhint", 236 | "elementtiming", 237 | "for_", 238 | "form", 239 | "formaction", 240 | "formenctype", 241 | "formmethod", 242 | "formnovalidate", 243 | "formtarget", 244 | "headers", 245 | "height", 246 | "hidden", 247 | "high", 248 | "href", 249 | "hreflang", 250 | "http_equiv", 251 | "id", 252 | "inputmode", 253 | "integrity", 254 | "ismap", 255 | "itemprop", 256 | "kind", 257 | "label", 258 | "lang", 259 | "loading", 260 | "list_", 261 | "loop", 262 | "low", 263 | "max", 264 | "maxlength", 265 | "media", 266 | "method", 267 | "min", 268 | "minlength", 269 | "multiple", 270 | "muted", 271 | "name", 272 | "novalidate", 273 | "onabort", 274 | "onautocomplete", 275 | "onautocompleteerror", 276 | "onblur", 277 | "oncancel", 278 | "oncanplay", 279 | "oncanplaythrough", 280 | "onchange", 281 | "onclick", 282 | "onclose", 283 | "oncontextmenu", 284 | "oncuechange", 285 | "ondblclick", 286 | "ondrag", 287 | "ondragend", 288 | "ondragenter", 289 | "ondragexit", 290 | "ondragleave", 291 | "ondragover", 292 | "ondragstart", 293 | "ondrop", 294 | "ondurationchange", 295 | "onemptied", 296 | "onended", 297 | "onerror", 298 | "onfocus", 299 | "onformdata", 300 | "oninput", 301 | "oninvalid", 302 | "onkeydown", 303 | "onkeypress", 304 | "onkeyup", 305 | "onload", 306 | "onloadeddata", 307 | "onloadedmetadata", 308 | "onloadstart", 309 | "onmousedown", 310 | "onmouseenter", 311 | "onmouseleave", 312 | "onmousemove", 313 | "onmouseout", 314 | "onmouseover", 315 | "onmouseup", 316 | "onpause", 317 | "onplay", 318 | "onplaying", 319 | "onprogress", 320 | "onratechange", 321 | "onreset", 322 | "onresize", 323 | "onscroll", 324 | "onsecuritypolicyviolation", 325 | "onseeked", 326 | "onseeking", 327 | "onselect", 328 | "onslotchange", 329 | "onstalled", 330 | "onsubmit", 331 | "onsuspend", 332 | "ontimeupdate", 333 | "ontoggle", 334 | "onvolumechange", 335 | "onwaiting", 336 | "onwheel", 337 | "open", 338 | "optimum", 339 | "pattern", 340 | "ping", 341 | "placeholder", 342 | "playsinline", 343 | "popover", 344 | "poster", 345 | "preload", 346 | "readonly", 347 | "referrerpolicy", 348 | "rel", 349 | "required", 350 | "reversed", 351 | "role", 352 | "rows", 353 | "rowspan", 354 | "sandbox", 355 | "scope", 356 | "selected", 357 | "shape", 358 | "size", 359 | "sizes", 360 | "slot", 361 | "span", 362 | "spellcheck", 363 | "src", 364 | "srcdoc", 365 | "srclang", 366 | "srcset", 367 | "start", 368 | "step", 369 | "style", 370 | "tabindex", 371 | "target", 372 | "title", 373 | "translate", 374 | "type_", 375 | "usemap", 376 | "value", 377 | "virtualkeyboardpolicy", 378 | "writingsuggestions", 379 | "width", 380 | "wrap", 381 | ] 382 | -------------------------------------------------------------------------------- /src/markupy/elements.py: -------------------------------------------------------------------------------- 1 | from ._private import views as _views 2 | 3 | __all__ = [ 4 | "_", 5 | "Html", 6 | "Area", 7 | "Base", 8 | "Br", 9 | "Col", 10 | "Embed", 11 | "Hr", 12 | "Img", 13 | "Input", 14 | "Link", 15 | "Meta", 16 | "Param", 17 | "Source", 18 | "Track", 19 | "Wbr", 20 | "A", 21 | "Abbr", 22 | "Abc", 23 | "Address", 24 | "Article", 25 | "Aside", 26 | "Audio", 27 | "B", 28 | "Bdi", 29 | "Bdo", 30 | "Blockquote", 31 | "Body", 32 | "Button", 33 | "Canvas", 34 | "Caption", 35 | "Cite", 36 | "Code", 37 | "Colgroup", 38 | "Data", 39 | "Datalist", 40 | "Dd", 41 | "Del", 42 | "Details", 43 | "Dfn", 44 | "Dialog", 45 | "Div", 46 | "Dl", 47 | "Dt", 48 | "Em", 49 | "Fieldset", 50 | "Figcaption", 51 | "Figure", 52 | "Footer", 53 | "Form", 54 | "H1", 55 | "H2", 56 | "H3", 57 | "H4", 58 | "H5", 59 | "H6", 60 | "Head", 61 | "Header", 62 | "Hgroup", 63 | "I", 64 | "Iframe", 65 | "Ins", 66 | "Kbd", 67 | "Label", 68 | "Legend", 69 | "Li", 70 | "Main", 71 | "Map", 72 | "Mark", 73 | "Menu", 74 | "Meter", 75 | "Nav", 76 | "Noscript", 77 | "Object", 78 | "Ol", 79 | "Optgroup", 80 | "Option", 81 | "Output", 82 | "P", 83 | "Picture", 84 | "Portal", 85 | "Pre", 86 | "Progress", 87 | "Q", 88 | "Rp", 89 | "Rt", 90 | "Ruby", 91 | "S", 92 | "Samp", 93 | "Script", 94 | "Search", 95 | "Section", 96 | "Select", 97 | "Slot", 98 | "Small", 99 | "Span", 100 | "Strong", 101 | "Style", 102 | "Sub", 103 | "Summary", 104 | "Sup", 105 | "Table", 106 | "Tbody", 107 | "Td", 108 | "Template", 109 | "Textarea", 110 | "Tfoot", 111 | "Th", 112 | "Thead", 113 | "Time", 114 | "Title", 115 | "Tr", 116 | "U", 117 | "Ul", 118 | "Var", 119 | ] 120 | 121 | 122 | def __getattr__(name: str) -> _views.Element: 123 | return _views.get_element(name) 124 | 125 | 126 | _: _views.Element 127 | A: _views.Element 128 | Abbr: _views.Element 129 | Abc: _views.Element 130 | Address: _views.Element 131 | Area: _views.Element 132 | Article: _views.Element 133 | Aside: _views.Element 134 | Audio: _views.Element 135 | B: _views.Element 136 | Base: _views.Element 137 | Bdi: _views.Element 138 | Bdo: _views.Element 139 | Blockquote: _views.Element 140 | Body: _views.Element 141 | Br: _views.Element 142 | Button: _views.Element 143 | Canvas: _views.Element 144 | Caption: _views.Element 145 | Cite: _views.Element 146 | Code: _views.Element 147 | Col: _views.Element 148 | Colgroup: _views.Element 149 | Data: _views.Element 150 | Datalist: _views.Element 151 | Dd: _views.Element 152 | Del: _views.Element 153 | Details: _views.Element 154 | Dfn: _views.Element 155 | Dialog: _views.Element 156 | Div: _views.Element 157 | Dl: _views.Element 158 | Dt: _views.Element 159 | Em: _views.Element 160 | Embed: _views.Element 161 | Fieldset: _views.Element 162 | Figcaption: _views.Element 163 | Figure: _views.Element 164 | Footer: _views.Element 165 | Form: _views.Element 166 | H1: _views.Element 167 | H2: _views.Element 168 | H3: _views.Element 169 | H4: _views.Element 170 | H5: _views.Element 171 | H6: _views.Element 172 | Head: _views.Element 173 | Header: _views.Element 174 | Hgroup: _views.Element 175 | Hr: _views.Element 176 | Html: _views.Element 177 | I: _views.Element # noqa: E741 178 | Iframe: _views.Element 179 | Img: _views.Element 180 | Input: _views.Element 181 | Ins: _views.Element 182 | Kbd: _views.Element 183 | Label: _views.Element 184 | Legend: _views.Element 185 | Li: _views.Element 186 | Link: _views.Element 187 | Main: _views.Element 188 | Map: _views.Element 189 | Mark: _views.Element 190 | Menu: _views.Element 191 | Meta: _views.Element 192 | Meter: _views.Element 193 | Nav: _views.Element 194 | Noscript: _views.Element 195 | Object: _views.Element 196 | Ol: _views.Element 197 | Optgroup: _views.Element 198 | Option: _views.Element 199 | Output: _views.Element 200 | P: _views.Element 201 | Param: _views.Element 202 | Picture: _views.Element 203 | Portal: _views.Element 204 | Pre: _views.Element 205 | Progress: _views.Element 206 | Q: _views.Element 207 | Rp: _views.Element 208 | Rt: _views.Element 209 | Ruby: _views.Element 210 | S: _views.Element 211 | Samp: _views.Element 212 | Script: _views.Element 213 | Search: _views.Element 214 | Section: _views.Element 215 | Select: _views.Element 216 | Slot: _views.Element 217 | Small: _views.Element 218 | Source: _views.Element 219 | Span: _views.Element 220 | Strong: _views.Element 221 | Style: _views.Element 222 | Sub: _views.Element 223 | Summary: _views.Element 224 | Sup: _views.Element 225 | Table: _views.Element 226 | Tbody: _views.Element 227 | Td: _views.Element 228 | Template: _views.Element 229 | Textarea: _views.Element 230 | Tfoot: _views.Element 231 | Th: _views.Element 232 | Thead: _views.Element 233 | Time: _views.Element 234 | Title: _views.Element 235 | Tr: _views.Element 236 | Track: _views.Element 237 | U: _views.Element 238 | Ul: _views.Element 239 | Var: _views.Element 240 | Wbr: _views.Element 241 | -------------------------------------------------------------------------------- /src/markupy/exceptions.py: -------------------------------------------------------------------------------- 1 | class MarkupyError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/markupy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/src/markupy/py.typed -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/witiz/markupy/f0a5bb37078b71eda3df8dd81fe33c02fb8236e3/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | 3 | from markupy import View 4 | 5 | 6 | def pytest_assertrepr_compare(op: str, left: Any, right: Any) -> Sequence[str] | None: 7 | l_repr = f"`{left}`" if isinstance(left, View) else repr(left) 8 | r_repr = f"`{right}`" if isinstance(right, View) else repr(right) 9 | return [op, l_repr, r_repr] 10 | -------------------------------------------------------------------------------- /src/tests/test_attribute_handler.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Generator 3 | 4 | import pytest 5 | 6 | from markupy import Attribute, attribute_handlers 7 | from markupy import elements as el 8 | from markupy._private.attributes.handlers import AttributeHandler 9 | from markupy.exceptions import MarkupyError 10 | 11 | 12 | @contextmanager 13 | def tmp_handler(handler: AttributeHandler) -> Generator[None, None, None]: 14 | """Temporarily register a handler within a context.""" 15 | attribute_handlers.register(handler) 16 | try: 17 | yield 18 | finally: 19 | attribute_handlers.unregister(handler) 20 | return None 21 | 22 | 23 | def test_multi_register() -> None: 24 | def handler(old: Attribute | None, new: Attribute) -> Attribute | None: 25 | return None 26 | 27 | with tmp_handler(handler): 28 | with pytest.raises(MarkupyError): 29 | attribute_handlers.register(handler) 30 | 31 | 32 | def test_handler_order() -> None: 33 | def handler1(old: Attribute | None, new: Attribute) -> Attribute | None: 34 | new.value = f"{new.value}1" 35 | return None 36 | 37 | def handler2(old: Attribute | None, new: Attribute) -> Attribute | None: 38 | new.value = f"{new.value}2" 39 | return None 40 | 41 | with tmp_handler(handler1): 42 | with tmp_handler(handler2): 43 | assert el.Input(foo="bar") == """""" 44 | 45 | 46 | def test_handler() -> None: 47 | def handler(old: Attribute | None, new: Attribute) -> Attribute | None: 48 | if new.name == "id": 49 | assert old is None and new.value == "foo" 50 | elif new.name == "class": 51 | assert (old is None and new.value == "bar") or ( 52 | old is not None and old.value == "bar" and new.value == "baz" 53 | ) 54 | elif new.name == "hello": 55 | assert old is None and new.value == "world" 56 | else: 57 | raise Exception("Not supposed to happen") 58 | return None 59 | 60 | with tmp_handler(handler): 61 | el.Input("#foo.bar", class_="baz", hello="world") 62 | 63 | 64 | def test_class_replace() -> None: 65 | def handler(old: Attribute | None, new: Attribute) -> Attribute | None: 66 | if new.name == "class": 67 | return new 68 | return None 69 | 70 | with tmp_handler(handler): 71 | assert el.Input(".foo", class_="bar") == """""" 72 | 73 | 74 | def test_prefix_attribute() -> None: 75 | def handler(old: Attribute | None, new: Attribute) -> Attribute | None: 76 | prefix = "foo-" 77 | if not new.name.startswith(prefix): 78 | return Attribute(f"{prefix}{new.name}", new.value) 79 | return None 80 | 81 | with tmp_handler(handler): 82 | assert ( 83 | el.Input("#bar.baz", hello="world") 84 | == """""" 85 | ) 86 | -------------------------------------------------------------------------------- /src/tests/test_attributes.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from datetime import date 3 | 4 | import pytest 5 | from markupsafe import Markup 6 | 7 | from markupy import Attribute 8 | from markupy import attributes as at 9 | from markupy import elements as el 10 | from markupy.exceptions import MarkupyError 11 | 12 | 13 | def test_order() -> None: 14 | with pytest.raises(MarkupyError): 15 | el.Input({"foo": "bar"}, "selector") # type: ignore 16 | with pytest.raises(MarkupyError): 17 | el.Input(at.disabled(), "#foo.bar") # type: ignore 18 | 19 | 20 | def test_attribute_equivalence() -> None: 21 | obj = el.Input(at.onclick("console.log('yo')"), at.disabled()) 22 | dct = el.Input({"onclick": "console.log('yo')", "disabled": True}) 23 | kwd = el.Input(onclick="console.log('yo')", disabled=True) 24 | assert obj == dct == kwd 25 | 26 | 27 | def test_comment_attributes() -> None: 28 | with pytest.raises(MarkupyError): 29 | el._(attr="foo") 30 | 31 | 32 | def test_underscore_replacement() -> None: 33 | result = """""" 34 | assert el.Button(hx_post="/foo", _="bar", whatever_="ok")["click me!"] == result 35 | 36 | 37 | class Test_value_escape: 38 | pytestmark = pytest.mark.parametrize( 39 | "value", 40 | [ 41 | '.<"foo', 42 | Markup('.<"foo'), 43 | ], 44 | ) 45 | 46 | def test_selector(self, value: str) -> None: 47 | assert el.Div(value) == """
    """ 48 | 49 | def test_dict(self, value: str) -> None: 50 | assert el.Div({"bar": value}) == """
    """ 51 | 52 | def test_kwarg(self, value: str) -> None: 53 | assert el.Div(**{"bar": value}) == """
    """ 54 | 55 | 56 | def test_boolean_attribute() -> None: 57 | assert el.Input(disabled="whatever") == """""" 58 | assert el.Input(disabled=0) == """""" 59 | 60 | 61 | def test_boolean_attribute_true() -> None: 62 | assert el.Button(disabled=True) == "" 63 | 64 | 65 | def test_boolean_attribute_false() -> None: 66 | assert el.Button(disabled=False) == "" 67 | 68 | 69 | def test_selector_and_kwargs() -> None: 70 | result = """
    """ 71 | assert el.Div("#theid", for_="hello", data_foo=" None: 75 | result = """
    """ 76 | assert ( 77 | el.Div(".selector", {"class": "dict"}, at.class_("obj"), class_="kwarg") 78 | == result 79 | ) 80 | 81 | 82 | def test_class_merge() -> None: 83 | result = """
    """ 84 | assert ( 85 | el.Div(".foo.bar", {"class": "bar baz"}, at.class_("foo"), class_="foo baz") 86 | == result 87 | ) 88 | 89 | 90 | @pytest.mark.parametrize("not_an_attr", [1234, b"foo", object(), object, 1, 0, None]) 91 | def test_invalid_attribute_key(not_an_attr: t.Any) -> None: 92 | with pytest.raises(MarkupyError): 93 | el.Div({not_an_attr: "foo"}) 94 | with pytest.raises(MarkupyError): 95 | el.Div(Attribute(not_an_attr, "foo")) 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "not_an_attr", 100 | [b"foo", object(), object], 101 | ) 102 | def test_invalid_attribute_value(not_an_attr: t.Any) -> None: 103 | with pytest.raises(MarkupyError): 104 | el.Div(foo=not_an_attr) 105 | with pytest.raises(MarkupyError): 106 | el.Div(at.foo(not_an_attr)) 107 | with pytest.raises(MarkupyError): 108 | el.Div({"foo": not_an_attr}) 109 | 110 | 111 | def test_attribute_redefinition() -> None: 112 | with pytest.raises(MarkupyError): 113 | el.Div(id="hello")(class_="world") 114 | 115 | 116 | @pytest.mark.parametrize( 117 | "key", 118 | ["", " ", " foo ", "bAr", "foo bar", '<"foo', Markup('<"foo'), date.today()], 119 | ) 120 | def test_invalid_key(key: str) -> None: 121 | with pytest.raises(MarkupyError): 122 | el.Div(Attribute(key, "bar")) 123 | with pytest.raises(MarkupyError): 124 | el.Div({key: "bar"}) 125 | with pytest.raises((MarkupyError, TypeError)): 126 | el.Div(**{key: "bar"}) 127 | 128 | 129 | def test_duplicate() -> None: 130 | with pytest.raises(MarkupyError): 131 | el.Div("#foo", {"id": "bar"}) 132 | with pytest.raises(MarkupyError): 133 | el.Div("#foo", at.id("bar")) 134 | with pytest.raises(MarkupyError): 135 | el.Div("#foo", id="bar") 136 | with pytest.raises(MarkupyError): 137 | el.Div({"disabled": False}, at.disabled(True)) 138 | with pytest.raises(MarkupyError): 139 | el.Div({"disabled": False}, disabled=True) 140 | with pytest.raises(MarkupyError): 141 | el.Div(at.disabled(False), disabled=True) 142 | with pytest.raises(MarkupyError): 143 | el.Div(at.disabled(False), at.disabled(True)) 144 | assert ( 145 | el.A(at.href("/"), href="/") 146 | == el.A(at.href("/"), at.href("/")) 147 | == """""" 148 | ) 149 | 150 | 151 | def test_none_override() -> None: 152 | assert ( 153 | el.Input({"class": None, "foo": None}, foo="bar", class_="baz") 154 | == """""" 155 | ) 156 | assert ( 157 | el.Input({"class": "baz", "foo": "bar"}, foo=None, class_=None) 158 | == """""" 159 | ) 160 | -------------------------------------------------------------------------------- /src/tests/test_attributes_dict.py: -------------------------------------------------------------------------------- 1 | from markupy.elements import Div 2 | 3 | 4 | def test_redefinition() -> None: 5 | result = """
    """ 6 | assert result == Div({"attr": "val"}, {"attr2": "val2"}) 7 | 8 | 9 | def test_dict_attributes_escape() -> None: 10 | result = Div({"@click": 'hi = "hello"'}) 11 | assert result == """
    """ 12 | 13 | 14 | def test_dict_attributes_avoid_replace() -> None: 15 | result = Div({"class_": "foo", "hello_hi": "abc"}) 16 | assert result == """
    """ 17 | 18 | 19 | def test_dict_attribute_false() -> None: 20 | result = Div({"bool-false": False}) 21 | assert result == "
    " 22 | 23 | 24 | def test_dict_attribute_true() -> None: 25 | result = Div({"bool-true": True}) 26 | assert result == "
    " 27 | 28 | 29 | def test_dict_attribute_none() -> None: 30 | result = Div({"foo": None}) 31 | assert result == "
    " 32 | -------------------------------------------------------------------------------- /src/tests/test_attributes_kwargs.py: -------------------------------------------------------------------------------- 1 | from markupsafe import Markup 2 | 3 | from markupy.elements import Div, Input, Th 4 | 5 | # from markupy.exceptions import MarkupyError 6 | 7 | 8 | def test_kwarg_attribute_none() -> None: 9 | result = Div(foo=None) 10 | assert str(result) == "
    " 11 | 12 | 13 | def test_underscore() -> None: 14 | # Hyperscript (https://hyperscript.org/) uses _, make sure it works good. 15 | result = Div(_="foo") 16 | assert str(result) == """
    """ 17 | 18 | 19 | def test_true_value() -> None: 20 | assert str(Input(disabled=True)) == "" 21 | assert str(Input(attr=True)) == "" 22 | 23 | 24 | def test_false_value() -> None: 25 | assert str(Input(disabled=False)) == "" 26 | assert str(Input(attr=False)) == "" 27 | 28 | 29 | def test_none_value() -> None: 30 | assert str(Input(disabled=None)) == "" 31 | assert str(Input(attr=None)) == "" 32 | 33 | 34 | def test_empty_value() -> None: 35 | # Different behaviour for boolean attributes vs regular 36 | assert str(Input(disabled="")) == """""" 37 | assert str(Input(attr="")) == """""" 38 | assert str(Input(id="", class_="", name="")) == """""" 39 | 40 | 41 | def test_integer_attribute() -> None: 42 | result = Th(colspan=123, tabindex=0) 43 | assert str(result) == '' 44 | 45 | 46 | def test_float_attribute() -> None: 47 | result = Input(value=37.2) 48 | assert str(result) == '' 49 | 50 | 51 | class Test_class_names: 52 | def test_str(self) -> None: 53 | result = Div(class_='">foo bar') 54 | assert str(result) == '
    ' 55 | 56 | def test_safestring(self) -> None: 57 | result = Div(class_=Markup('">foo bar')) 58 | assert str(result) == '
    ' 59 | 60 | def test_false(self) -> None: 61 | result = str(Div(class_=False)) 62 | assert result == "
    " 63 | 64 | def test_none(self) -> None: 65 | result = str(Div(class_=None)) 66 | assert result == "
    " 67 | -------------------------------------------------------------------------------- /src/tests/test_attributes_obj.py: -------------------------------------------------------------------------------- 1 | import markupy.attributes as at 2 | import markupy.elements as el 3 | from markupy import Attribute 4 | 5 | 6 | def test_int() -> None: 7 | result = """""" 8 | assert el.Input(at.minlength(0), at.maxlength(5)) == result 9 | 10 | 11 | def test_boolean() -> None: 12 | result = """""" 13 | assert el.Input(at.disabled()) == result 14 | assert el.Input(at.disabled(True)) == result 15 | 16 | 17 | def test_missing_attribute() -> None: 18 | result = """

    """ 19 | assert el.H1(at.foo_bar("baz")) == result 20 | 21 | 22 | def test_classes() -> None: 23 | result = """

    """ 24 | assert el.H1(at.class_("foo bar")) == result 25 | 26 | 27 | def test_class_list() -> None: 28 | result = """

    """ 29 | assert el.H1(at.class_(["foo", "bar"])) == result 30 | 31 | 32 | def test_class_dict() -> None: 33 | result = """

    """ 34 | assert el.H1(at.class_({"foo": True, "baz": False, "bar": True})) == result 35 | 36 | 37 | def test_non_identifier() -> None: 38 | result = """""" 39 | assert el.Input(Attribute("@click", "hello")) == result 40 | 41 | 42 | def test_tuple() -> None: 43 | result = """""" 44 | assert el.Input(("@click", "hello")) == result 45 | 46 | 47 | def test_none() -> None: 48 | assert el.Input(None) == """""" 49 | assert el.Input("#foo", None) == """""" 50 | assert el.Input(at.foo("bar"), None) == """""" 51 | assert el.Input(None, at.foo("bar")) == """""" 52 | assert el.Input(None, foo="bar") == """""" 53 | -------------------------------------------------------------------------------- /src/tests/test_attributes_selector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from markupy.elements import Div 4 | from markupy.exceptions import MarkupyError 5 | 6 | 7 | def test_redefinition() -> None: 8 | with pytest.raises(MarkupyError): 9 | Div("#id.cls", "#id2.cls2") # type: ignore 10 | 11 | 12 | def test_selector() -> None: 13 | result = Div("#myid.cls1.cls2") 14 | 15 | assert result == """
    """ 16 | 17 | 18 | def test_selector_only_id() -> None: 19 | result = Div("#myid") 20 | assert result == """
    """ 21 | 22 | 23 | def test_selector_only_classes() -> None: 24 | result = Div(".foo.bar") 25 | assert result == """
    """ 26 | 27 | 28 | def test_selector_empty_classes() -> None: 29 | result = Div(".foo..bar.") 30 | assert result == """
    """ 31 | 32 | 33 | def test_selector_classes_space_separator() -> None: 34 | result = Div("foo bar") 35 | assert result == """
    """ 36 | 37 | 38 | def test_selector_bad_type() -> None: 39 | with pytest.raises(MarkupyError): 40 | Div(object(), {"oops": "yes"}) # type: ignore 41 | 42 | 43 | @pytest.mark.parametrize("selector", ["", " ", " # ", " . ", " # . "]) 44 | def test_empty_selector(selector: str) -> None: 45 | result = Div(selector) 46 | assert result == """
    """ 47 | 48 | 49 | def test_selector_strip() -> None: 50 | result = Div(" #myid .myclass .other ") 51 | assert result == """
    """ 52 | 53 | 54 | def test_selector_invalid_id_position() -> None: 55 | with pytest.raises(MarkupyError): 56 | Div(".bar#foo") 57 | 58 | 59 | def test_selector_multiple_id() -> None: 60 | with pytest.raises(MarkupyError): 61 | Div("#foo#bar") 62 | 63 | 64 | def test_selector_empty_id() -> None: 65 | assert Div("# foo bar") == """
    """ 66 | assert Div("#.foo.bar") == """
    """ 67 | -------------------------------------------------------------------------------- /src/tests/test_children.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | from markupsafe import Markup 7 | 8 | from markupy._private.views.element import Element, VoidElement 9 | from markupy.elements import ( 10 | Dd, 11 | Div, 12 | Dl, 13 | Dt, 14 | Html, 15 | Img, 16 | Input, 17 | Li, 18 | MyCustomElement, 19 | Script, 20 | Style, 21 | Ul, 22 | ) 23 | from markupy.exceptions import MarkupyError 24 | 25 | if t.TYPE_CHECKING: 26 | from collections.abc import Generator 27 | 28 | 29 | def test_void_element() -> None: 30 | element = Input(name="foo") 31 | assert isinstance(element, VoidElement) 32 | 33 | result = element 34 | assert result == '' 35 | 36 | with pytest.raises(MarkupyError): 37 | element["child"] 38 | 39 | 40 | def test_safe_script_element() -> None: 41 | # The ' quotes don't get escaped 42 | result = """""" 43 | assert Script(type="text/javascript")["alert('test');"] == result 44 | 45 | 46 | def test_safe_style_element() -> None: 47 | # The > symbol doesn't get escaped 48 | result = """""" 49 | assert Style(type="text/css")["body>.test {color:red}"] == result 50 | 51 | 52 | def test_children() -> None: 53 | assert Div[Img] == "
    " 54 | 55 | 56 | def test_integer_child() -> None: 57 | assert Div[123] == "
    123
    " 58 | 59 | 60 | def test_multiple_children() -> None: 61 | result = Ul[Li, Li] 62 | 63 | assert result == "
    " 64 | 65 | 66 | def test_list_children() -> None: 67 | children: list[Element] = [Li["a"], Li["b"]] 68 | result = Ul[children] 69 | assert result == "
    • a
    • b
    " 70 | 71 | 72 | def test_list_children_with_element_and_none() -> None: 73 | children: list[t.Any] = [None, Li["b"]] 74 | result = Ul[children] 75 | assert result == "
    • b
    " 76 | 77 | 78 | def test_list_children_with_none() -> None: 79 | children: list[t.Any] = [None] 80 | result = Ul[children] 81 | assert result == "
      " 82 | 83 | 84 | def test_tuple_children() -> None: 85 | result = Ul[(Li["a"], Li["b"])] 86 | assert result == "
      • a
      • b
      " 87 | 88 | 89 | def test_flatten_nested_children() -> None: 90 | result = Dl[ 91 | [ 92 | (Dt["a"], Dd["b"]), 93 | (Dt["c"], Dd["d"]), 94 | ] 95 | ] 96 | assert result == """
      a
      b
      c
      d
      """ 97 | 98 | 99 | def test_flatten_very_nested_children() -> None: 100 | # maybe not super useful but the nesting may be arbitrarily deep 101 | result = Div[[([["a"]],)], [([["b"]],)]] 102 | assert result == """
      ab
      """ 103 | 104 | 105 | def test_flatten_nested_generators() -> None: 106 | def cols() -> Generator[str, None, None]: 107 | yield "a" 108 | yield "b" 109 | yield "c" 110 | 111 | def rows() -> Generator[Generator[str, None, None], None, None]: 112 | yield cols() 113 | yield cols() 114 | yield cols() 115 | 116 | result = Div[rows()] 117 | 118 | assert result == """
      abcabcabc
      """ 119 | 120 | 121 | def test_generator_children() -> None: 122 | gen: Generator[Element, None, None] = (Li[x] for x in ["a", "b"]) 123 | result = Ul[gen] 124 | assert result == "
      • a
      • b
      " 125 | 126 | 127 | def test_generator_exhaustion() -> None: 128 | top_three = Ul[(Li[i] for i in range(1, 4))] 129 | result = "
      • 1
      • 2
      • 3
      " 130 | assert top_three == result 131 | # we make sure rendering is not impacted by generator exhaustion after initial run 132 | assert top_three == result 133 | 134 | 135 | def test_html_tag_with_doctype() -> None: 136 | result = Html(foo="bar")["hello"] 137 | assert result == 'hello' 138 | 139 | 140 | def test_void_element_children() -> None: 141 | with pytest.raises(MarkupyError): 142 | Img["hey"] 143 | 144 | 145 | def test_call_without_args() -> None: 146 | result = Img() 147 | assert result == "" 148 | 149 | 150 | def test_custom_element() -> None: 151 | el = MyCustomElement() 152 | assert isinstance(el, Element) 153 | assert el == "" 154 | 155 | 156 | @pytest.mark.parametrize("ignored_value", [None, True, False]) 157 | def test_ignored(ignored_value: t.Any) -> None: 158 | assert Div[ignored_value] == "
      " 159 | 160 | 161 | def test_iter_str() -> None: 162 | _, child, _ = Div["a"] 163 | 164 | assert child == "a" 165 | # Make sure we dont get Markup (subclass of str) 166 | assert type(child) is str 167 | 168 | 169 | def test_iter_markup() -> None: 170 | _, child, _ = Div["a"] 171 | 172 | assert child == "a" 173 | # Make sure we dont get Markup (subclass of str) 174 | assert type(child) is str 175 | 176 | 177 | def test_escape_children() -> None: 178 | result = Div['>"'] 179 | assert result == "
      >"
      " 180 | 181 | 182 | def test_safe_children() -> None: 183 | result = Div[Markup("")] 184 | assert result == "
      " 185 | 186 | 187 | def test_children_redefinition() -> None: 188 | with pytest.raises(MarkupyError): 189 | Div["Hello"]["World"] 190 | 191 | 192 | def test_callable() -> None: 193 | def hello() -> str: 194 | return "world" 195 | 196 | with pytest.raises(MarkupyError): 197 | Div[hello] 198 | -------------------------------------------------------------------------------- /src/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from markupy._private.html_to_markupy.parser import to_markupy 4 | from markupy.exceptions import MarkupyError 5 | 6 | 7 | def test_nested_void() -> None: 8 | html = """

      """ 9 | py = """from markupy.elements import Div,Hr\nDiv[Hr]""" 10 | assert to_markupy(html) == py 11 | 12 | 13 | def test_empty_element() -> None: 14 | html = """
      """ 15 | py = """from markupy.elements import Div\nDiv""" 16 | assert to_markupy(html) == py 17 | 18 | 19 | def test_strip() -> None: 20 | html = """
      \n
      \n
      """ 21 | py = """from markupy import Fragment\nfrom markupy.elements import Div\nFragment[Div,Div]""" 22 | assert to_markupy(html) == py 23 | 24 | 25 | def test_doctype() -> None: 26 | html = """""" 27 | py = "" 28 | assert to_markupy(html) == py 29 | 30 | 31 | def test_selector() -> None: 32 | html = """""" 33 | py = """from markupy.elements import Img\nImg("#test.portait.image")""" 34 | assert to_markupy(html) == py 35 | 36 | 37 | def test_kwargs() -> None: 38 | html = """""" 39 | py = """from markupy.elements import Label\nLabel(for_="myfield",style="display:none",http_equiv="x")["Hello"]""" 40 | assert to_markupy(html) == py 41 | 42 | 43 | def test_empty_kwargs() -> None: 44 | html = """""" 45 | py = """from markupy.elements import Input\nInput(disabled=True)""" 46 | assert to_markupy(html) == py 47 | 48 | 49 | def test_invalid_html_unclosed() -> None: 50 | html = """
      """ 51 | with pytest.raises(MarkupyError): 52 | to_markupy(html) 53 | 54 | 55 | def test_invalid_html_toomany_closed() -> None: 56 | html = """
      """ 57 | with pytest.raises(MarkupyError): 58 | to_markupy(html) 59 | 60 | 61 | def test_invalid_html_not_matching() -> None: 62 | html = """
      """ 63 | with pytest.raises(MarkupyError): 64 | to_markupy(html) 65 | 66 | 67 | def test_to_markupy() -> None: 68 | html = """Test

      Parse me!


      Click!""" 69 | py = """from markupy.elements import Body,H1,Head,Hr,Html,Input,SlButton,Title\nHtml[Head[Title["Test"]],Body[H1("#myid.title.header",{"burger&fries":"good"})["Parse me!"],Hr,Input(".my-input",{"@click.outside.500ms":"test"},disabled=True,value="0",data_test="other",data_url_valid="hop"),SlButton({"hx-on:htmx:config-request":"attr"})["Click!"]]]""" 70 | assert to_markupy(html) == py 71 | 72 | 73 | def test_escape() -> None: 74 | html = """Hello""" 75 | py = """from markupy.elements import A\nA(href="{{ url_for(\\".index\\") }}")["Hello"]""" 76 | assert to_markupy(html) == py 77 | 78 | 79 | def test_jinja() -> None: 80 | html = """ 81 | 82 |

      {{ heading }}

      83 |

      Welcome to our cooking site, {{ user.name }}!

      84 | 85 |

      Recipe of the Day: {{ recipe.name }}

      86 |

      {{ recipe.description }}

      87 | 88 |

      Instructions:

      89 |
        90 | {% for step in recipe.steps %} 91 |
      1. {{ step }}
      2. 92 | {% endfor %} 93 |
      94 | 95 | """ 96 | py = """from markupy.elements import Body,H1,H2,H3,Li,Ol,P\nBody[H1["{{ heading }}"],P["Welcome to our cooking site, {{ user.name }}!"],H2["Recipe of the Day: {{ recipe.name }}"],P["{{ recipe.description }}"],H3["Instructions:"],Ol["{% for step in recipe.steps %}",Li["{{ step }}"],"{% endfor %}"]]""" 97 | assert to_markupy(html) == py 98 | 99 | 100 | def test_self_closing() -> None: 101 | html = """""" 102 | py = """from markupy.elements import Input\nInput(type="checkbox")""" 103 | assert to_markupy(html) == py 104 | 105 | 106 | def test_use_import_tag() -> None: 107 | html = """
      hello
      """ 108 | py = """from markupy import elements as el\nel.Div["hello"]""" 109 | assert to_markupy(html, use_import_el=True) == py 110 | 111 | 112 | def test_use_selector() -> None: 113 | html = """
      hello
      """ 114 | py = """from markupy.elements import Div\nDiv(id="myid",class_="cls1 cls2",del_="ok")["hello"]""" 115 | assert to_markupy(html, use_selector=False) == py 116 | 117 | 118 | def test_use_dict_noselector() -> None: 119 | html = """
      hello
      """ 120 | py = """from markupy.elements import Div\nDiv({"id":"myid","class":"cls1 cls2","del":"ok"})["hello"]""" 121 | assert to_markupy(html, use_dict=True, use_selector=False) == py 122 | 123 | 124 | def test_use_dict_selector() -> None: 125 | html = """
      hello
      """ 126 | py = """from markupy.elements import Div\nDiv("#myid.cls1.cls2",{"del":"ok"})["hello"]""" 127 | assert to_markupy(html, use_dict=True, use_selector=True) == py 128 | 129 | 130 | def test_no_dict_invalid_identifier() -> None: 131 | html = """""" 132 | py = """from markupy.elements import Input\nInput({"@foo":"bar"},hello="world")""" 133 | assert to_markupy(html, use_dict=False) == py 134 | 135 | 136 | def test_jinja_block() -> None: 137 | html = """ 138 | {% block my_name %} 139 |
      140 | {% endblock %} 141 | """ 142 | py = """from markupy.elements import BlockMyName,Div\nBlockMyName[Div]""" 143 | assert to_markupy(html) == py 144 | -------------------------------------------------------------------------------- /src/tests/test_component.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | import pytest 4 | 5 | from markupy import Component, Fragment, View, elements 6 | from markupy.exceptions import MarkupyError 7 | 8 | 9 | class ComponentElement(Component): 10 | def __init__(self, id: str): 11 | self.id = id 12 | 13 | def render(self) -> View: 14 | return elements.Div(id=self.id) 15 | 16 | 17 | def test_component_element() -> None: 18 | assert ComponentElement("component") == """
      """ 19 | 20 | 21 | class ComponentFragment(Component): 22 | def render(self) -> View: 23 | return Fragment[elements.Div, elements.Img] 24 | 25 | 26 | def test_uninitialized_component() -> None: 27 | with pytest.raises(MarkupyError): 28 | elements.P[ComponentFragment] 29 | 30 | 31 | def test_component_fragment() -> None: 32 | assert ComponentFragment() == """
      """ 33 | 34 | 35 | class ComponentInComponent(Component): 36 | def render(self) -> View: 37 | return Fragment[elements.Input, ComponentElement("inside")] 38 | 39 | 40 | def test_component_in_component() -> None: 41 | assert ComponentInComponent() == """
      """ 42 | 43 | 44 | class ComponentAsComponent(Component): 45 | def render(self) -> View: 46 | return ComponentElement("other") 47 | 48 | 49 | def test_component_as_component() -> None: 50 | assert ComponentAsComponent() == """
      """ 51 | 52 | 53 | class ContentComponent(Component): 54 | def __init__(self, id: str) -> None: 55 | super().__init__() 56 | self.id = id 57 | 58 | def render(self) -> View: 59 | return elements.H1(".title.header", id=self.id)[self.render_content()] 60 | 61 | 62 | def test_component_content() -> None: 63 | assert ( 64 | ContentComponent(id="test")["Hello", elements.Div[elements.Input]] 65 | == """

      Hello

      """ 66 | ) 67 | 68 | 69 | def test_component_content_escape() -> None: 70 | # Make sure component contents are not re-escaped when assigned to element children 71 | assert ( 72 | ContentComponent(id="test")['He>"llo'] 73 | == """

      He>"llo

      """ 74 | ) 75 | 76 | 77 | class TypeErrorComponent(Component): 78 | def render(self) -> str: # type:ignore 79 | return "Hello" 80 | 81 | 82 | def test_type_error_component() -> None: 83 | with pytest.raises(MarkupyError): 84 | str(TypeErrorComponent()) 85 | 86 | 87 | class SuperErrorComponent(Component): 88 | def __init__(self, *, id: str) -> None: 89 | # Missing the call to super().__init() 90 | # calls to __getitem__() will fail 91 | self.id = id 92 | 93 | def render(self) -> View: 94 | return elements.H1(id=self.id) 95 | 96 | 97 | def test_super_error_component() -> None: 98 | assert SuperErrorComponent(id="foo") == """

      """ 99 | with pytest.raises(MarkupyError): 100 | SuperErrorComponent(id="foo")["bar"] 101 | 102 | 103 | @dataclass(eq=False) 104 | class DataComponent(Component): 105 | href: str = field(default="https://google.com") 106 | 107 | def render(self) -> View: 108 | return elements.A(href=self.href)[self.render_content()] 109 | 110 | 111 | def test_dataclass_component() -> None: 112 | result = """Google""" 113 | assert DataComponent()["Google"] == result 114 | -------------------------------------------------------------------------------- /src/tests/test_django.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from django.conf import settings 3 | from django.http import HttpResponse, StreamingHttpResponse 4 | from django.urls import path 5 | 6 | from markupy import elements 7 | 8 | # --- Django minimal config --- 9 | settings.configure( 10 | ROOT_URLCONF=__name__, 11 | ) 12 | 13 | 14 | # --- The view --- 15 | def render(request): 16 | return HttpResponse(elements.H1(".title")["render"]) 17 | 18 | 19 | def stream(request): 20 | return StreamingHttpResponse(iter(elements.H1(".title")["stream"])) 21 | 22 | 23 | # --- URL config --- 24 | urlpatterns = [ 25 | path("render/", render), 26 | path("stream/", stream), 27 | ] 28 | 29 | 30 | def test_render(client): 31 | response = client.get("/render/") 32 | assert response.status_code == 200 33 | assert response.content.decode() == """

      render

      """ 34 | 35 | 36 | def test_stream(client): 37 | response = client.get("/stream/") 38 | assert response.status_code == 200 39 | assert ( 40 | b"".join(response.streaming_content).decode() 41 | == """

      stream

      """ 42 | ) 43 | -------------------------------------------------------------------------------- /src/tests/test_element.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from markupsafe import Markup 3 | 4 | from markupy import elements as el 5 | from markupy._private.views.element import ( 6 | CommentElement, 7 | Element, 8 | HtmlElement, 9 | VoidElement, 10 | ) 11 | from markupy.exceptions import MarkupyError 12 | 13 | 14 | def test_instance_cache() -> None: 15 | """ 16 | markupy creates element object dynamically. make sure they are reused. 17 | """ 18 | assert el.Div is el.Div 19 | assert el.Div is el.Div() 20 | assert el.Div is el.Div("", {}, None, attr1=None, attr2=False) 21 | assert el.Div is el.Div[None] 22 | assert el.Div is el.Div[True] 23 | assert el.Div is el.Div[False] 24 | assert el.Div is el.Div[[]] 25 | assert el.Div is el.Div[[None]] 26 | assert el.Div is el.Div[""] 27 | assert el.Div is not el.Div[0] 28 | 29 | 30 | def test_invalid_case() -> None: 31 | with pytest.raises(MarkupyError): 32 | el.div 33 | with pytest.raises(MarkupyError): 34 | el.My_Div 35 | 36 | 37 | def test_name() -> None: 38 | assert el.SlInput == "" 39 | assert el.XInput == "" 40 | assert el.X1Input == "" 41 | 42 | 43 | def test_element_repr() -> None: 44 | assert repr(el.Div("#a")) == """""" 45 | 46 | 47 | def test_void_element_repr() -> None: 48 | assert repr(el.Hr("#a")) == """""" 49 | 50 | 51 | def test_markup_str() -> None: 52 | result = str(el.Div(id="a")) 53 | assert isinstance(result, str) 54 | assert isinstance(result, Markup) 55 | assert result == '
      ' 56 | 57 | 58 | def test_element_type() -> None: 59 | assert type(el.Unknown) is Element 60 | assert type(el.MyElement) is Element 61 | assert type(el.Div) is Element 62 | assert type(el.Input) is VoidElement 63 | assert type(el.Html) is HtmlElement 64 | assert type(el._) is CommentElement 65 | 66 | 67 | def test_comment() -> None: 68 | assert el._["Hello"] == "" 69 | assert el._[el.Div["Hello"]] == "" 70 | 71 | 72 | def test_attributes_after_children() -> None: 73 | with pytest.raises(MarkupyError): 74 | el.Div["hello"](id="world") 75 | -------------------------------------------------------------------------------- /src/tests/test_flask.py: -------------------------------------------------------------------------------- 1 | # type:ignore 2 | import pytest 3 | from flask import Flask, request, stream_with_context 4 | 5 | from markupy import elements 6 | 7 | app = Flask(__name__) 8 | 9 | 10 | @app.route("/render") 11 | def render(): 12 | return str(elements.H1(".title")["render"]) 13 | 14 | 15 | @app.route("/stream") 16 | def stream(): 17 | return iter(elements.H1(".title")[request.args["name"]]) 18 | 19 | 20 | @app.route("/stream_context") 21 | def stream_context(): 22 | # Here stream_with_context is useless since the View is build before 23 | # the streaming starts and context is no longer needed 24 | return stream_with_context(elements.H1(".title")[request.args["name"]]) 25 | 26 | 27 | @pytest.fixture 28 | def client(): 29 | with app.test_client() as client: 30 | yield client 31 | 32 | 33 | def test_render(client): 34 | response = client.get("/render") 35 | assert response.status_code == 200 36 | assert response.data.decode() == """

      render

      """ 37 | 38 | 39 | def test_stream(client): 40 | response = client.get("/stream?name=stream") 41 | assert response.status_code == 200 42 | assert response.data.decode() == """

      stream

      """ 43 | 44 | 45 | def test_stream_context(client): 46 | response = client.get("/stream_context?name=context") 47 | assert response.status_code == 200 48 | assert response.data.decode() == """

      context

      """ 49 | -------------------------------------------------------------------------------- /src/tests/test_fragment.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import pytest 4 | 5 | from markupy import Fragment 6 | from markupy.elements import Div, I, P, Tr 7 | from markupy.exceptions import MarkupyError 8 | 9 | 10 | def test_render_direct() -> None: 11 | assert Fragment["Hello ", None, I["World"]] == """Hello World""" 12 | 13 | 14 | def test_render_as_child() -> None: 15 | assert ( 16 | P["Say: ", Fragment["Hello ", None, I["World"]], "!"] 17 | == """

      Say: Hello World!

      """ 18 | ) 19 | 20 | 21 | def test_render_nested() -> None: 22 | assert Fragment[Fragment["Hel", "lo "], "World"] == """Hello World""" 23 | 24 | 25 | def test_render_embedded() -> None: 26 | assert ( 27 | P[Fragment["Good ", I["morning"]], " ", I["World"]] 28 | == """

      Good morning World

      """ 29 | ) 30 | 31 | 32 | def test_safe() -> None: 33 | assert Fragment['>"'] == """>"""" 34 | 35 | 36 | def test_iter() -> None: 37 | assert list(Fragment["Hello ", None, I["World"]]) == [ 38 | "Hello ", 39 | "", 40 | "World", 41 | "", 42 | ] 43 | 44 | 45 | def test_element() -> None: 46 | result = Fragment[Div["a"]] 47 | assert result == "
      a
      " 48 | 49 | 50 | def test_multiple_element() -> None: 51 | result = Fragment[Tr["a"], Tr["b"]] 52 | assert result == "ab" 53 | 54 | 55 | def test_list() -> None: 56 | result = Fragment[[Tr["a"], Tr["b"]]] 57 | assert result == "ab" 58 | 59 | 60 | def test_none() -> None: 61 | result = Fragment[None] 62 | assert result == "" 63 | 64 | 65 | def test_string() -> None: 66 | result = Fragment["hello!"] 67 | assert result == "hello!" 68 | 69 | 70 | def test_class() -> None: 71 | class Test: 72 | def __call__(self) -> str: 73 | return self.message() 74 | 75 | def __str__(self) -> str: 76 | return self() 77 | 78 | def message(self) -> str: 79 | return "hello" 80 | 81 | assert Fragment[Test()] == "hello" 82 | assert Fragment[Test().message()] == "hello" 83 | with pytest.raises(MarkupyError): 84 | Fragment[Test] 85 | with pytest.raises(MarkupyError): 86 | Fragment[Test().message] 87 | 88 | 89 | def test_lambda() -> None: 90 | with pytest.raises(MarkupyError): 91 | Fragment[lambda: "hello"] 92 | 93 | 94 | def test_function() -> None: 95 | def test() -> str: 96 | return "hello" 97 | 98 | assert Fragment[test()] == "hello" 99 | with pytest.raises(MarkupyError): 100 | Fragment[test] 101 | 102 | 103 | def test_generator() -> None: 104 | def generator() -> Iterator[str]: 105 | yield "hello" 106 | yield "world" 107 | 108 | assert Fragment[generator()] == "helloworld" 109 | with pytest.raises(MarkupyError): 110 | Fragment[generator] 111 | -------------------------------------------------------------------------------- /src/tests/test_starlette.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from starlette.responses import HTMLResponse, StreamingResponse 3 | from starlette.testclient import TestClient 4 | 5 | from markupy import elements 6 | 7 | 8 | async def render(scope, receive, send): 9 | assert scope["type"] == "http" 10 | response = HTMLResponse(elements.H1(".title")["render"]) 11 | await response(scope, receive, send) 12 | 13 | 14 | async def stream(scope, receive, send): 15 | assert scope["type"] == "http" 16 | response = StreamingResponse(iter(elements.H1(".title")["stream"])) 17 | await response(scope, receive, send) 18 | 19 | 20 | def test_render() -> None: 21 | client = TestClient(render) 22 | response = client.get("/") 23 | assert response.text == """

      render

      """ 24 | 25 | 26 | def test_stream() -> None: 27 | client = TestClient(stream) 28 | response = client.get("/") 29 | assert response.text == """

      stream

      """ 30 | -------------------------------------------------------------------------------- /src/tests/test_template.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from django.template import Context, Engine # type:ignore 4 | from jinja2 import Environment 5 | 6 | from markupy import elements 7 | 8 | 9 | @typing.no_type_check 10 | def test_django() -> None: 11 | template = Engine().from_string("
      {{ content }}
      ") 12 | context = Context({"content": elements.H1(".title")["django"]}) 13 | rendered = template.render(context) 14 | assert rendered == """

      django

      """ 15 | 16 | 17 | def test_jinja2() -> None: 18 | env = Environment(autoescape=True) 19 | template = env.from_string("
      {{ content }}
      ") 20 | rendered = template.render(content=elements.H1(".title")["jinja"]) 21 | assert rendered == """

      jinja

      """ 22 | -------------------------------------------------------------------------------- /src/tests/test_view.py: -------------------------------------------------------------------------------- 1 | from markupy import Fragment, View 2 | from markupy import elements as el 3 | 4 | 5 | def test_empty_view() -> None: 6 | assert View() == "" 7 | 8 | 9 | def test_equalty() -> None: 10 | assert View() == View() 11 | assert View() == "" 12 | assert "" == View() 13 | assert "" != el.Input 14 | assert View() == Fragment[""] 15 | assert View() != el.Input 16 | assert View() != "hello!" 17 | assert el.P("#foo.bar", hello="world") == el.P("#foo.bar", hello="world") 18 | --------------------------------------------------------------------------------