summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.github/workflows/run_tests.yml37
-rw-r--r--.github/workflows/upload-to-pypi.yml29
-rw-r--r--.gitignore122
-rw-r--r--.pre-commit-config.yaml12
-rw-r--r--MIT-LICENSE21
-rw-r--r--Makefile37
-rw-r--r--README.md21
-rw-r--r--benchmark/Card.jinja4
-rw-r--r--benchmark/CloseBtn.jinja2
-rw-r--r--benchmark/Greeting.jinja2
-rw-r--r--benchmark/Layout.jinja13
-rw-r--r--benchmark/Real.jinja8
-rw-r--r--benchmark/Simple.jinja16
-rw-r--r--benchmark/benchmark.py93
-rw-r--r--benchmark/hello.html15
-rw-r--r--benchmark/profile.py29
-rw-r--r--package-lock.json6
-rw-r--r--poetry.lock587
-rw-r--r--pyproject.toml189
-rw-r--r--src/jinjax/__init__.py5
-rw-r--r--src/jinjax/catalog.py530
-rw-r--r--src/jinjax/component.py258
-rw-r--r--src/jinjax/exceptions.py37
-rw-r--r--src/jinjax/html_attrs.py348
-rw-r--r--src/jinjax/jinjax.py161
-rw-r--r--src/jinjax/middleware.py39
-rw-r--r--src/jinjax/py.typed0
-rw-r--r--src/jinjax/utils.py16
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/conftest.py24
-rw-r--r--tests/test_catalog.py88
-rw-r--r--tests/test_component.py315
-rw-r--r--tests/test_html_attrs.py281
-rw-r--r--tests/test_middleware.py152
-rw-r--r--tests/test_render.py992
35 files changed, 4489 insertions, 0 deletions
diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
new file mode 100644
index 0000000..27b9040
--- /dev/null
+++ b/.github/workflows/run_tests.yml
@@ -0,0 +1,37 @@
+name: run_tests
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+jobs:
+ lint:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ - name: "Installs dependencies"
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+ - run: ~/.local/share/pypoetry/venv/bin/poetry install --with test
+ - run: make lint
+ tests:
+ name: tests
+ strategy:
+ matrix:
+ python: ["3.10", "3.11", "3.12"]
+ fail-fast: false
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python }}
+ - name: "Installs dependencies"
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+ - run: ~/.local/share/pypoetry/venv/bin/poetry install --with test
+ - run: make test
diff --git a/.github/workflows/upload-to-pypi.yml b/.github/workflows/upload-to-pypi.yml
new file mode 100644
index 0000000..2503665
--- /dev/null
+++ b/.github/workflows/upload-to-pypi.yml
@@ -0,0 +1,29 @@
+name: Upload to PyPI
+
+on:
+ # Triggers the workflow when a release is created
+ release:
+ types: [released]
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+jobs:
+ upload:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: 3.9
+
+ - name: "Installs dependencies"
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+
+ - name: "Builds and uploads to PyPI"
+ run: |
+ ~/.local/share/pypoetry/venv/bin/poetry build
+ ~/.local/share/pypoetry/venv/bin/poetry publish
+
+ env:
+ POETRY_PYPI_TOKEN_PYPI: ${{ secrets.TOKEN_PYPI }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f7fb77
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,122 @@
+docs/build
+.DS_Store
+.vscode
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+covreport/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# Pyre type checker
+.pyre/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..d523706
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,12 @@
+exclude: ^(docs)/
+fail_fast: true
+repos:
+ - repo: local
+ hooks:
+ - id: lint
+ name: lint
+ entry: make lint
+ language: system
+ types: [python]
+ pass_filenames: false
+ always_run: true
diff --git a/MIT-LICENSE b/MIT-LICENSE
new file mode 100644
index 0000000..ae168dc
--- /dev/null
+++ b/MIT-LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021-2022 Juan-Pablo Scaletti
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..139a760
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,37 @@
+.PHONY: test
+test:
+ poetry run pytest -x src/jinjax tests
+
+.PHONY: lint
+lint:
+ poetry run ruff check src/jinjax tests
+
+.PHONY: coverage
+coverage:
+ poetry run pytest --cov-config=pyproject.toml --cov-report html --cov jinjax src/jinjax tests
+
+.PHONY: types
+types:
+ poetry run pyright src/jinjax
+
+.PHONY: install
+install:
+ poetry install --with dev,test
+ poetry run pre-commit install
+
+.PHONY: install.docs
+install.docs:
+ pip install -e ../jinjax-ui/
+ pip install -e ../claydocs/
+
+.PHONY: docs
+docs:
+ cd docs && python docs.py
+
+.PHONY: docs.build
+docs.build:
+ cd docs && python docs.py build
+
+.PHONY: docs.deploy
+docs.deploy:
+ cd docs && ./deploy.sh
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9943219
--- /dev/null
+++ b/README.md
@@ -0,0 +1,21 @@
+<h1>
+ <img src="https://github.com/jpsca/jinjax/raw/main/jinjax-logo.png" height="50" align="top">
+</h1>
+
+From chaos to clarity: The power of components in your server-side-rendered Python web app.
+
+**Documentation:** https://jinjax.scaletti.dev/
+
+Write server-side components as single Jinja template files.
+Use them as HTML tags without doing any importing.
+
+
+## Roadmap
+
+#### Planned
+- [ ] Type checking at runtime
+- [ ] ...
+
+#### Done
+- [x] Slots
+- [x] Autoloading assets (optional?) (`Card.jinja` autoloads `Card.css` and/or `Card.js` if exists)
diff --git a/benchmark/Card.jinja b/benchmark/Card.jinja
new file mode 100644
index 0000000..3a0e2e8
--- /dev/null
+++ b/benchmark/Card.jinja
@@ -0,0 +1,4 @@
+<section class="card">
+{{ content }}
+<CloseBtn disabled />
+</section>
diff --git a/benchmark/CloseBtn.jinja b/benchmark/CloseBtn.jinja
new file mode 100644
index 0000000..c4fd809
--- /dev/null
+++ b/benchmark/CloseBtn.jinja
@@ -0,0 +1,2 @@
+{#def disabled=False -#}
+<button type="button"{{ " disabled" if disabled else "" }}>&times;</button>
diff --git a/benchmark/Greeting.jinja b/benchmark/Greeting.jinja
new file mode 100644
index 0000000..1cd2abe
--- /dev/null
+++ b/benchmark/Greeting.jinja
@@ -0,0 +1,2 @@
+{#def message #}
+<div class="greeting [&_a]:flex">{{ message }}</div>
diff --git a/benchmark/Layout.jinja b/benchmark/Layout.jinja
new file mode 100644
index 0000000..2437b70
--- /dev/null
+++ b/benchmark/Layout.jinja
@@ -0,0 +1,13 @@
+{#def title #}
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport" />
+<title>{{ title }}</title>
+</head>
+<body>
+{{ content }}
+</body>
+</html>
diff --git a/benchmark/Real.jinja b/benchmark/Real.jinja
new file mode 100644
index 0000000..163b3af
--- /dev/null
+++ b/benchmark/Real.jinja
@@ -0,0 +1,8 @@
+{#def message #}
+
+<Layout title="Hello">
+<Card>
+<Greeting message={{ message }} />
+<button type="button">Close</button>
+</Card>
+</Layout>
diff --git a/benchmark/Simple.jinja b/benchmark/Simple.jinja
new file mode 100644
index 0000000..7b3c9d1
--- /dev/null
+++ b/benchmark/Simple.jinja
@@ -0,0 +1,16 @@
+{#def message #}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport" />
+<title>{{ message }}</title>
+</head>
+<body>
+<section class="card">
+<div class="greeting [&_a]:flex">{{ message }}</div>
+<button type="button">Close</button>
+<button type="button" disabled>&times;</button>
+</section>
+</body>
+</html>
diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py
new file mode 100644
index 0000000..09a7447
--- /dev/null
+++ b/benchmark/benchmark.py
@@ -0,0 +1,93 @@
+import timeit
+from pathlib import Path
+
+from fastapi.templating import Jinja2Templates
+from jinja2 import Environment, FileSystemLoader
+from jinjax import Catalog
+
+
+here = Path(__file__).parent
+number = 10_000
+
+catalog = Catalog()
+catalog.add_folder(here)
+
+env = Environment(loader=FileSystemLoader(here))
+
+templates = Jinja2Templates(directory=here)
+
+
+def render_jinjax_simple():
+ """simple case"""
+ catalog.render("Simple", message="Hey there")
+
+
+def render_jinjax_real():
+ """realistic case"""
+ catalog.render("Real", message="Hey there")
+
+
+def render_jinja():
+ env.get_template("hello.html").render(message="Hey there")
+
+
+def render_fastapi():
+ templates.TemplateResponse("hello.html", {"request": None, "message": "Hey there"})
+
+
+def benchmark_no_cache(func):
+ print(f"NO CACHE: {number:_} renders of {func.__doc__}...\n")
+ catalog.use_cache = False
+ benchmark(func)
+
+
+def benchmark_auto_reload(func):
+ print(f"CACHE, AUTO-RELOAD: {number:_} renders of {func.__doc__}...\n")
+ catalog.use_cache = True
+ catalog.auto_reload = True
+ benchmark(func)
+
+
+def benchmark_no_auto_reload(func):
+ print(f"CACHE, NO AUTO-RELOAD: {number:_} renders of {func.__doc__}...\n")
+ catalog.use_cache = True
+ catalog.auto_reload = False
+ benchmark(func)
+
+
+def benchmark(func):
+ time_jinjax = timeit.timeit(func, number=number)
+ print_line("JinjaX", time_jinjax)
+ print(f"{time_jinjax / time_jinja:.1f} times Jinja")
+ print(f"{time_jinjax / time_fastapi:.1f} times FastApi")
+
+
+def print_line(name, time):
+ print(f"{name}: {(time / number):.12f}s per render ({(1_000_000 * time / number):.0f}µs), {time:.1f}s total")
+
+
+def print_separator():
+ print()
+ print("-" * 60)
+
+
+if __name__ == "__main__":
+ print(f"Benchmarking...\n")
+ time_jinja = timeit.timeit(render_jinja, number=number)
+ time_fastapi = timeit.timeit(render_fastapi, number=number)
+
+ print_line("Jinja", time_jinja)
+ print_line("FastApi", time_fastapi)
+ print_separator()
+ benchmark_no_cache(render_jinjax_simple)
+ print_separator()
+ benchmark_auto_reload(render_jinjax_simple)
+ print_separator()
+ benchmark_no_auto_reload(render_jinjax_simple)
+ print_separator()
+ benchmark_no_cache(render_jinjax_real)
+ print_separator()
+ benchmark_auto_reload(render_jinjax_real)
+ print_separator()
+ benchmark_no_auto_reload(render_jinjax_real)
+ print()
diff --git a/benchmark/hello.html b/benchmark/hello.html
new file mode 100644
index 0000000..762b921
--- /dev/null
+++ b/benchmark/hello.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport" />
+<title>{{ message }}</title>
+</head>
+<body>
+<section class="card">
+<div class="greeting [&_a]:flex">{{ message }}</div>
+<button type="button">Close</button>
+<button type="button" disabled>&times;</button>
+</section>
+</body>
+</html>
diff --git a/benchmark/profile.py b/benchmark/profile.py
new file mode 100644
index 0000000..8c88ad0
--- /dev/null
+++ b/benchmark/profile.py
@@ -0,0 +1,29 @@
+from pathlib import Path
+
+from jinjax import Catalog, Component
+from line_profiler import LineProfiler
+
+
+HERE = Path(__file__).parent
+
+catalog = Catalog()
+catalog.add_folder(HERE)
+
+profile = LineProfiler(
+ Catalog.irender,
+ Catalog._get_from_file,
+ Component.__init__,
+ Component.from_cache,
+ Component.filter_args,
+ Component.render,
+)
+
+def render_jinjax():
+ for _ in range(1000):
+ catalog.render("Hello", message="Hey there")
+
+
+if __name__ == "__main__":
+ print("Profiling...")
+ profile.runcall(render_jinjax)
+ profile.print_stats()
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..d6bd744
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "jinjax",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..6bd91d8
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,587 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
+[[package]]
+name = "cachetools"
+version = "5.3.3"
+description = "Extensible memoizing collections and decorators"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"},
+ {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"},
+]
+
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+description = "Validate configuration and produce human readable error messages."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
+ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
+]
+
+[[package]]
+name = "chardet"
+version = "5.2.0"
+description = "Universal encoding detector for Python 3"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
+ {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.5.4"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"},
+ {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"},
+ {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"},
+ {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"},
+ {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"},
+ {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"},
+ {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"},
+ {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"},
+ {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"},
+ {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"},
+ {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"},
+ {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"},
+ {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"},
+ {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"},
+ {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"},
+ {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"},
+ {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"},
+ {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"},
+ {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"},
+ {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"},
+ {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"},
+ {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"},
+ {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"},
+ {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"},
+ {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"},
+ {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"},
+ {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"},
+ {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"},
+ {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"},
+ {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"},
+ {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"},
+ {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"},
+ {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"},
+ {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"},
+ {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"},
+ {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"},
+ {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"},
+ {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"},
+ {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"},
+ {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"},
+ {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"},
+ {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"},
+ {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"},
+ {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"},
+ {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"},
+ {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"},
+ {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"},
+ {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"},
+ {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"},
+ {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"},
+ {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"},
+ {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"},
+]
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "distlib"
+version = "0.3.8"
+description = "Distribution utilities"
+optional = false
+python-versions = "*"
+files = [
+ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
+ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.1"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
+ {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "filelock"
+version = "3.15.4"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
+ {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
+typing = ["typing-extensions (>=4.8)"]
+
+[[package]]
+name = "identify"
+version = "2.5.36"
+description = "File identification library for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"},
+ {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"},
+]
+
+[package.extras]
+license = ["ukkonen"]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.4"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
+ {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.5"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+description = "Node.js virtual environment builder"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
+ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.2.2"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
+ {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
+type = ["mypy (>=1.8)"]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pre-commit"
+version = "3.7.1"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"},
+ {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"},
+]
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+virtualenv = ">=20.10.0"
+
+[[package]]
+name = "pyproject-api"
+version = "1.7.1"
+description = "API to interact with the python pyproject.toml based projects"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pyproject_api-1.7.1-py3-none-any.whl", hash = "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb"},
+ {file = "pyproject_api-1.7.1.tar.gz", hash = "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827"},
+]
+
+[package.dependencies]
+packaging = ">=24.1"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+docs = ["furo (>=2024.5.6)", "sphinx-autodoc-typehints (>=2.2.1)"]
+testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=70.1)"]
+
+[[package]]
+name = "pyright"
+version = "1.1.369"
+description = "Command line wrapper for pyright"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyright-1.1.369-py3-none-any.whl", hash = "sha256:06d5167a8d7be62523ced0265c5d2f1e022e110caf57a25d92f50fb2d07bcda0"},
+ {file = "pyright-1.1.369.tar.gz", hash = "sha256:ad290710072d021e213b98cc7a2f90ae3a48609ef5b978f749346d1a47eb9af8"},
+]
+
+[package.dependencies]
+nodeenv = ">=1.6.0"
+
+[package.extras]
+all = ["twine (>=3.4.1)"]
+dev = ["twine (>=3.4.1)"]
+
+[[package]]
+name = "pytest"
+version = "8.2.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
+ {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2.0"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "5.0.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
+ {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
+]
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
+[[package]]
+name = "ruff"
+version = "0.5.0"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "ruff-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:ee770ea8ab38918f34e7560a597cc0a8c9a193aaa01bfbd879ef43cb06bd9c4c"},
+ {file = "ruff-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38f3b8327b3cb43474559d435f5fa65dacf723351c159ed0dc567f7ab735d1b6"},
+ {file = "ruff-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7594f8df5404a5c5c8f64b8311169879f6cf42142da644c7e0ba3c3f14130370"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adc7012d6ec85032bc4e9065110df205752d64010bed5f958d25dbee9ce35de3"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d505fb93b0fabef974b168d9b27c3960714d2ecda24b6ffa6a87ac432905ea38"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dc5cfd3558f14513ed0d5b70ce531e28ea81a8a3b1b07f0f48421a3d9e7d80a"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:db3ca35265de239a1176d56a464b51557fce41095c37d6c406e658cf80bbb362"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1a321c4f68809fddd9b282fab6a8d8db796b270fff44722589a8b946925a2a8"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c4dfcd8d34b143916994b3876b63d53f56724c03f8c1a33a253b7b1e6bf2a7d"},
+ {file = "ruff-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81e5facfc9f4a674c6a78c64d38becfbd5e4f739c31fcd9ce44c849f1fad9e4c"},
+ {file = "ruff-0.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e589e27971c2a3efff3fadafb16e5aef7ff93250f0134ec4b52052b673cf988d"},
+ {file = "ruff-0.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2ffbc3715a52b037bcb0f6ff524a9367f642cdc5817944f6af5479bbb2eb50e"},
+ {file = "ruff-0.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd096e23c6a4f9c819525a437fa0a99d1c67a1b6bb30948d46f33afbc53596cf"},
+ {file = "ruff-0.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:46e193b36f2255729ad34a49c9a997d506e58f08555366b2108783b3064a0e1e"},
+ {file = "ruff-0.5.0-py3-none-win32.whl", hash = "sha256:49141d267100f5ceff541b4e06552e98527870eafa1acc9dec9139c9ec5af64c"},
+ {file = "ruff-0.5.0-py3-none-win_amd64.whl", hash = "sha256:e9118f60091047444c1b90952736ee7b1792910cab56e9b9a9ac20af94cd0440"},
+ {file = "ruff-0.5.0-py3-none-win_arm64.whl", hash = "sha256:ed5c4df5c1fb4518abcb57725b576659542bdbe93366f4f329e8f398c4b71178"},
+ {file = "ruff-0.5.0.tar.gz", hash = "sha256:eb641b5873492cf9bd45bc9c5ae5320648218e04386a5f0c264ad6ccce8226a1"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "tox"
+version = "4.15.1"
+description = "tox is a generic virtualenv management and test command line tool"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "tox-4.15.1-py3-none-any.whl", hash = "sha256:f00a5dc4222b358e69694e47e3da0227ac41253509bca9f45aa8f012053e8d9d"},
+ {file = "tox-4.15.1.tar.gz", hash = "sha256:53a092527d65e873e39213ebd4bd027a64623320b6b0326136384213f95b7076"},
+]
+
+[package.dependencies]
+cachetools = ">=5.3.2"
+chardet = ">=5.2"
+colorama = ">=0.4.6"
+filelock = ">=3.13.1"
+packaging = ">=23.2"
+platformdirs = ">=4.1"
+pluggy = ">=1.3"
+pyproject-api = ">=1.6.1"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+virtualenv = ">=20.25"
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"]
+testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[[package]]
+name = "virtualenv"
+version = "20.26.3"
+description = "Virtual Python Environment builder"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"},
+ {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"},
+]
+
+[package.dependencies]
+distlib = ">=0.3.7,<1"
+filelock = ">=3.12.2,<4"
+platformdirs = ">=3.9.1,<5"
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+
+[[package]]
+name = "whitenoise"
+version = "6.7.0"
+description = "Radically simplified static file serving for WSGI applications"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "whitenoise-6.7.0-py3-none-any.whl", hash = "sha256:a1ae85e01fdc9815d12fa33f17765bc132ed2c54fa76daf9e39e879dd93566f6"},
+ {file = "whitenoise-6.7.0.tar.gz", hash = "sha256:58c7a6cd811e275a6c91af22e96e87da0b1109e9a53bb7464116ef4c963bf636"},
+]
+
+[package.extras]
+brotli = ["brotli"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.10"
+content-hash = "36b1c02ae6cf0c193066f5d7371995106331bd05502b88f82da7b0d5a76318b6"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..693d296
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,189 @@
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+
+[tool.poetry]
+name = "jinjax"
+version = "0.45"
+description = "Replace your HTML templates with Python server-Side components"
+authors = ["Juan-Pablo Scaletti <juanpablo@jpscaletti.com>"]
+license = "MIT"
+readme = "README.md"
+homepage = "https://jinjax.scaletti.dev/"
+repository = "https://github.com/jpsca/jinjax"
+documentation = "https://jinjax.scaletti.dev/guides/"
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Environment :: Web Environment",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: Software Development :: User Interfaces",
+ "Topic :: Text Processing :: Markup :: HTML",
+ "Typing :: Typed",
+]
+
+[tool.poetry.dependencies]
+python = "^3.10"
+jinja2 = ">=3.0"
+markupsafe = ">=2.0"
+whitenoise = ">=5.3"
+
+[tool.poetry.group.dev]
+optional = true
+
+[tool.poetry.group.dev.dependencies]
+pyright = ">=1.1.282"
+pre-commit = "*"
+tox = "*"
+typing-extensions = "^4.11.0"
+
+[tool.poetry.group.test]
+optional = true
+
+[tool.poetry.group.test.dependencies]
+pytest = "^8.1.1"
+pytest-cov = "*"
+ruff = ">0.3"
+
+
+[tool.coverage.run]
+branch = true
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "TYPE_CHECKING",
+ "def __repr__",
+ "def __str__",
+ "raise AssertionError",
+ "raise NotImplementedError",
+ "if __name__ == .__main__.:"
+]
+
+[tool.coverage.html]
+directory = "covreport"
+
+
+[tool.pyright]
+include = ["src"]
+exclude = [
+ "**/node_modules",
+ "**/__pycache__",
+ "**/tests",
+]
+ignore = []
+reportPrivateImportUsage = false
+reportWildcardImportFromLibrary = false
+
+
+[tool.pytest.ini_options]
+addopts = "--doctest-modules"
+
+
+[tool.tox]
+legacy_tox_ini = """
+[tox]
+skipsdist = True
+envlist = py310,py311,py312,pypy3.10
+
+[testenv]
+skip_install = true
+allowlist_externals = poetry
+commands =
+ pip install -U pip wheel
+ poetry install --with test
+ pytest -x src/jinjax tests
+"""
+
+
+[tool.ruff]
+line-length = 90
+indent-width = 4
+target-version = "py311"
+
+exclude = [
+ ".*",
+ "_build",
+ "build",
+ "covreport",
+ "dist",
+]
+include = ["*.py"]
+
+
+[tool.ruff.format]
+# Like Black, use double quotes for strings.
+quote-style = "double"
+
+# Like Black, indent with spaces, rather than tabs.
+indent-style = "space"
+
+# Like Black, respect magic trailing commas.
+skip-magic-trailing-comma = false
+
+# Like Black, automatically detect the appropriate line ending.
+line-ending = "auto"
+
+# Enable auto-formatting of code examples in docstrings. Markdown,
+# reStructuredText code/literal blocks and doctests are all supported.
+#
+# This is currently disabled by default, but it is planned for this
+# to be opt-out in the future.
+docstring-code-format = false
+
+# Set the line length limit used when formatting code snippets in
+# docstrings.
+#
+# This only has an effect when the `docstring-code-format` setting is
+# enabled.
+docstring-code-line-length = "dynamic"
+
+
+[tool.ruff.lint]
+fixable = ["ALL"]
+
+ignore = [
+ # x is too complex
+ "C901",
+ # whitespace before ':'
+ "E203",
+ "E501",
+ # x defined from star imports
+ "F405",
+ # line break before binary operator
+ "W505",
+ "W605",
+]
+select = [
+ # bugbear
+ "B",
+ # mccabe"", comprehensions, commas
+ "C",
+ # pycodestyle errors
+ "E",
+ # pyflakes
+ "F",
+ # logging format
+ "G",
+ # imports
+ "I",
+ # quotes
+ "Q",
+ # pycodestyle warnings
+ "W",
+]
+
+
+[tool.ruff.lint.isort]
+known-first-party = ["jinjax"]
+
+# Use two line after imports.
+lines-after-imports = 2
diff --git a/src/jinjax/__init__.py b/src/jinjax/__init__.py
new file mode 100644
index 0000000..e30f39b
--- /dev/null
+++ b/src/jinjax/__init__.py
@@ -0,0 +1,5 @@
+from .catalog import Catalog # noqa
+from .component import Component # noqa
+from .exceptions import * # noqa
+from .jinjax import JinjaX # noqa
+from .html_attrs import HTMLAttrs, LazyString # noqa
diff --git a/src/jinjax/catalog.py b/src/jinjax/catalog.py
new file mode 100644
index 0000000..c70f81d
--- /dev/null
+++ b/src/jinjax/catalog.py
@@ -0,0 +1,530 @@
+import os
+import typing as t
+from collections import UserString
+from hashlib import sha256
+from pathlib import Path
+
+import jinja2
+from markupsafe import Markup
+
+from .component import Component
+from .exceptions import ComponentNotFound, InvalidArgument
+from .html_attrs import HTMLAttrs
+from .jinjax import JinjaX
+from .middleware import ComponentsMiddleware
+from .utils import DELIMITER, SLASH, get_url_prefix, logger
+
+
+DEFAULT_URL_ROOT = "/static/components/"
+ALLOWED_EXTENSIONS = (".css", ".js", ".mjs")
+DEFAULT_PREFIX = ""
+DEFAULT_EXTENSION = ".jinja"
+ARGS_ATTRS = "attrs"
+ARGS_CONTENT = "content"
+
+
+class CallerWrapper(UserString):
+ def __init__(self, caller: t.Callable | None, content: str = "") -> None:
+ self._caller = caller
+ # Pre-calculate the defaut content so the assets are loaded
+ self._content = caller("") if caller else Markup(content)
+
+ def __call__(self, slot: str = "") -> str:
+ if slot and self._caller:
+ return self._caller(slot)
+ return self._content
+
+ def __html__(self) -> str:
+ return self()
+
+ @property
+ def data(self) -> str: # type: ignore
+ return self()
+
+
+
+class Catalog:
+ """
+ The object that manages the components and their global settings.
+
+ Arguments:
+
+ globals:
+ Dictionary of Jinja globals to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`).
+
+ filters:
+ Dictionary of Jinja filters to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`).
+
+ tests:
+ Dictionary of Jinja tests to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`).
+
+ extensions:
+ List of Jinja extensions to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`). The `jinja2.ext.do` extension is
+ always added at the end of these.
+
+ jinja_env:
+ Custom Jinja environment to use. This argument is useful to reuse an
+ existing Jinja Environment from your web framework.
+
+ root_url:
+ Add this prefix to every asset URL of the static middleware. By default,
+ it is `/static/components/`, so, for example, the URL of the CSS file of
+ a `Card` component is `/static/components/Card.css`.
+
+ You can also change this argument so the assets are requested from a
+ Content Delivery Network (CDN) in production, for example,
+ `root_url="https://example.my-cdn.com/"`.
+
+ file_ext:
+ The extensions the components files have. By default, ".jinja".
+
+ This argument can also be a list to allow more than one type of file to
+ be a component.
+
+ use_cache:
+ Cache the metadata of the component in memory.
+
+ auto_reload:
+ Used with `use_cache`. If `True`, the last-modified date of the component
+ file is checked every time to see if the cache is up-to-date.
+
+ Set to `False` in production.
+
+ fingerprint:
+ If `True`, inserts a hash of the updated time into the URL of the
+ asset files (after the name but before the extension).
+
+ This strategy encourages long-term caching while ensuring that new copies
+ are only requested when the content changes, as any modification alters the
+ fingerprint and thus the filename.
+
+ **WARNING**: Only works if the server knows how to filter the fingerprint
+ to get the real name of the file.
+
+ Attributes:
+
+ collected_css:
+ List of CSS paths collected during a render.
+
+ collected_js:
+ List of JS paths collected during a render.
+
+ prefixes:
+ Mapping between folder prefixes and the Jinja loader that uses.
+
+ """
+
+ __slots__ = (
+ "prefixes",
+ "root_url",
+ "file_ext",
+ "jinja_env",
+ "fingerprint",
+ "collected_css",
+ "collected_js",
+ "auto_reload",
+ "use_cache",
+ "_tmpl_globals",
+ "_cache",
+ )
+
+ def __init__(
+ self,
+ *,
+ globals: "dict[str, t.Any] | None" = None,
+ filters: "dict[str, t.Any] | None" = None,
+ tests: "dict[str, t.Any] | None" = None,
+ extensions: "list | None" = None,
+ jinja_env: "jinja2.Environment | None" = None,
+ root_url: str = DEFAULT_URL_ROOT,
+ file_ext: "str | tuple[str, ...]" = DEFAULT_EXTENSION,
+ use_cache: bool = True,
+ auto_reload: bool = True,
+ fingerprint: bool = False,
+ ) -> None:
+ self.prefixes: dict[str, jinja2.FileSystemLoader] = {}
+ self.collected_css: list[str] = []
+ self.collected_js: list[str] = []
+ self.file_ext = file_ext
+ self.use_cache = use_cache
+ self.auto_reload = auto_reload
+ self.fingerprint = fingerprint
+
+ root_url = root_url.strip().rstrip(SLASH)
+ self.root_url = f"{root_url}{SLASH}"
+
+ env = jinja2.Environment(undefined=jinja2.StrictUndefined)
+ extensions = [*(extensions or []), "jinja2.ext.do", JinjaX]
+ globals = globals or {}
+ filters = filters or {}
+ tests = tests or {}
+
+ if jinja_env:
+ env.extensions.update(jinja_env.extensions)
+ env.autoescape = jinja_env.autoescape
+ globals.update(jinja_env.globals)
+ filters.update(jinja_env.filters)
+ tests.update(jinja_env.tests)
+ jinja_env.globals["catalog"] = self
+ jinja_env.filters["catalog"] = self
+
+ globals["catalog"] = self
+ filters["catalog"] = self
+
+ for ext in extensions:
+ env.add_extension(ext)
+ env.globals.update(globals)
+ env.filters.update(filters)
+ env.tests.update(tests)
+ env.extend(catalog=self)
+
+ self.jinja_env = env
+
+ self._tmpl_globals: "t.MutableMapping[str, t.Any] | None" = None
+ self._cache: dict[str, dict] = {}
+
+ @property
+ def paths(self) -> list[Path]:
+ """
+ A helper property that returns a list of all the components folder paths.
+ """
+ _paths = []
+ for loader in self.prefixes.values():
+ _paths.extend(loader.searchpath)
+ return _paths
+
+ def add_folder(
+ self,
+ root_path: "str | Path",
+ *,
+ prefix: str = DEFAULT_PREFIX,
+ ) -> None:
+ """
+ Add a folder path from where to search for components, optionally under a prefix.
+
+ The prefix acts like a namespace. For example, the name of a
+ `components/Card.jinja` component is, by default, "Card",
+ but under the prefix "common", it becomes "common.Card".
+
+ The rule for subfolders remains the same: a `components/wrappers/Card.jinja`
+ name is, by default, "wrappers.Card", but under the prefix "common",
+ it becomes "common.wrappers.Card".
+
+ If there is more than one component with the same name in multiple
+ added folders under the same prefix, the one in the folder added
+ first takes precedence.
+
+ Arguments:
+
+ root_path:
+ Absolute path of the folder with component files.
+
+ prefix:
+ Optional prefix that all the components in the folder will
+ have. The default is empty.
+
+ """
+ prefix = prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(SLASH, DELIMITER)
+
+ root_path = str(root_path)
+ if prefix in self.prefixes:
+ loader = self.prefixes[prefix]
+ if root_path in loader.searchpath:
+ return
+ logger.debug(f"Adding folder `{root_path}` with the prefix `{prefix}`")
+ loader.searchpath.append(root_path)
+ else:
+ logger.debug(f"Adding folder `{root_path}` with the prefix `{prefix}`")
+ self.prefixes[prefix] = jinja2.FileSystemLoader(root_path)
+
+ def add_module(self, module: t.Any, *, prefix: str | None = None) -> None:
+ """
+ Reads an absolute path from `module.components_path` and an optional prefix
+ from `module.prefix`, then calls `Catalog.add_folder(path, prefix)`.
+
+ The prefix can also be passed as an argument instead of being read from
+ the module.
+
+ This method exists to make it easy and consistent to have
+ components installable as Python libraries.
+
+ Arguments:
+
+ module:
+ A Python module.
+
+ prefix:
+ An optional prefix that replaces the one the module
+ might include.
+
+ """
+ mprefix = (
+ prefix if prefix is not None else getattr(module, "prefix", DEFAULT_PREFIX)
+ )
+ self.add_folder(module.components_path, prefix=mprefix)
+
+ def render(
+ self,
+ /,
+ __name: str,
+ *,
+ caller: "t.Callable | None" = None,
+ **kw,
+ ) -> str:
+ """
+ Resets the `collected_css` and `collected_js` lists and renders the
+ component and subcomponents inside of it.
+
+ This is the method you should call to render a parent component from a
+ view/controller in your app.
+
+ """
+ self.collected_css = []
+ self.collected_js = []
+ self._tmpl_globals = kw.pop("__globals", None)
+ return self.irender(__name, caller=caller, **kw)
+
+ def irender(
+ self,
+ /,
+ __name: str,
+ *,
+ caller: "t.Callable | None" = None,
+ **kw,
+ ) -> str:
+ """
+ Renders the component and subcomponents inside of it **without**
+ resetting the `collected_css` and `collected_js` lists.
+
+ This is the method you should call to render individual components that
+ are later inserted into a parent template.
+
+ """
+ content = (kw.pop("_content", kw.pop("__content", "")) or "").strip()
+ attrs = kw.pop("_attrs", kw.pop("__attrs", None)) or {}
+ file_ext = kw.pop("_file_ext", kw.pop("__file_ext", ""))
+ source = kw.pop("_source", kw.pop("__source", ""))
+
+ prefix, name = self._split_name(__name)
+ self.jinja_env.loader = self.prefixes[prefix]
+
+ if source:
+ logger.debug("Rendering from source %s", __name)
+ component = self._get_from_source(name=name, prefix=prefix, source=source)
+ elif self.use_cache:
+ logger.debug("Rendering from cache or file %s", __name)
+ component = self._get_from_cache(prefix=prefix, name=name, file_ext=file_ext)
+ else:
+ logger.debug("Rendering from file %s", __name)
+ component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext)
+
+ root_path = component.path.parent if component.path else None
+
+ for url in component.css:
+ if (
+ root_path
+ and self.fingerprint
+ and not url.startswith(("http://", "https://"))
+ ):
+ url = self._fingerprint(root_path, url)
+
+ if url not in self.collected_css:
+ self.collected_css.append(url)
+
+ for url in component.js:
+ if (
+ root_path
+ and self.fingerprint
+ and not url.startswith(("http://", "https://"))
+ ):
+ url = self._fingerprint(root_path, url)
+
+ if url not in self.collected_js:
+ self.collected_js.append(url)
+
+ attrs = attrs.as_dict if isinstance(attrs, HTMLAttrs) else attrs
+ attrs.update(kw)
+ kw = attrs
+ args, extra = component.filter_args(kw)
+ try:
+ args[ARGS_ATTRS] = HTMLAttrs(extra)
+ except Exception as exc:
+ raise InvalidArgument(
+ f"The arguments of the component <{component.name}>"
+ f"were parsed incorrectly as:\n {str(kw)}"
+ ) from exc
+
+ args[ARGS_CONTENT] = CallerWrapper(caller=caller, content=content)
+ return component.render(**args)
+
+ def get_middleware(
+ self,
+ application: t.Callable,
+ allowed_ext: "t.Iterable[str] | None" = ALLOWED_EXTENSIONS,
+ **kwargs,
+ ) -> ComponentsMiddleware:
+ """
+ Wraps you application with [Withenoise](https://whitenoise.readthedocs.io/),
+ a static file serving middleware.
+
+ Tecnically not neccesary if your components doesn't use static assets
+ or if you serve them by other means.
+
+ Arguments:
+
+ application:
+ A WSGI application
+
+ allowed_ext:
+ A list of file extensions the static middleware is allowed to read
+ and return. By default, is just ".css", ".js", and ".mjs".
+
+ """
+ logger.debug("Creating middleware")
+ middleware = ComponentsMiddleware(
+ application=application, allowed_ext=tuple(allowed_ext or []), **kwargs
+ )
+ for prefix, loader in self.prefixes.items():
+ url_prefix = get_url_prefix(prefix)
+ url = f"{self.root_url}{url_prefix}"
+ for root in loader.searchpath[::-1]:
+ middleware.add_files(root, url)
+
+ return middleware
+
+ def get_source(self, cname: str, file_ext: "tuple[str, ...] | str" = "") -> str:
+ """
+ A helper method that returns the source file of a component.
+ """
+ prefix, name = self._split_name(cname)
+ path, _ = self._get_component_path(prefix, name, file_ext=file_ext)
+ return path.read_text()
+
+ def render_assets(self) -> str:
+ """
+ Uses the `collected_css` and `collected_js` lists to generate
+ an HTML fragment with `<link rel="stylesheet" href="{url}">`
+ and `<script type="module" src="{url}"></script>` tags.
+
+ The URLs are prepended by `root_url` unless they begin with
+ "http://" or "https://".
+ """
+ html_css = []
+ for url in self.collected_css:
+ if not url.startswith(("http://", "https://")):
+ url = f"{self.root_url}{url}"
+ html_css.append(f'<link rel="stylesheet" href="{url}">')
+
+ html_js = []
+ for url in self.collected_js:
+ if not url.startswith(("http://", "https://")):
+ url = f"{self.root_url}{url}"
+ html_js.append(f'<script type="module" src="{url}"></script>')
+
+ return Markup("\n".join(html_css + html_js))
+
+ # Private
+
+ def _fingerprint(self, root: Path, filename: str) -> str:
+ relpath = Path(filename.lstrip(os.path.sep))
+ filepath = root / relpath
+ if not filepath.is_file():
+ return filename
+
+ stat = filepath.stat()
+ fingerprint = sha256(str(stat.st_mtime).encode()).hexdigest()
+
+ ext = "".join(relpath.suffixes)
+ stem = relpath.name.removesuffix(ext)
+ parent = str(relpath.parent)
+ parent = "" if parent == "." else f"{parent}/"
+
+ return f"{parent}{stem}-{fingerprint}{ext}"
+
+ def _get_from_source(self, *, name: str, prefix: str, source: str) -> Component:
+ tmpl = self.jinja_env.from_string(source, globals=self._tmpl_globals)
+ component = Component(name=name, prefix=prefix, source=source, tmpl=tmpl)
+ return component
+
+ def _get_from_cache(self, *, prefix: str, name: str, file_ext: str) -> Component:
+ key = f"{prefix}.{name}.{file_ext}"
+ cache = self._from_cache(key)
+ if cache:
+ component = Component.from_cache(
+ cache, auto_reload=self.auto_reload, globals=self._tmpl_globals
+ )
+ if component:
+ return component
+
+ logger.debug("Loading %s", key)
+ component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext)
+ self._to_cache(key, component)
+ return component
+
+ def _from_cache(self, key: str) -> dict[str, t.Any]:
+ if key not in self._cache:
+ return {}
+ cache = self._cache[key]
+ logger.debug("Loading from cache %s", key)
+ return cache
+
+ def _to_cache(self, key: str, component: Component) -> None:
+ self._cache[key] = component.serialize()
+
+ def _get_from_file(self, *, prefix: str, name: str, file_ext: str) -> Component:
+ path, tmpl_name = self._get_component_path(prefix, name, file_ext=file_ext)
+ component = Component(name=name, prefix=prefix, path=path)
+ component.tmpl = self.jinja_env.get_template(tmpl_name, globals=self._tmpl_globals)
+ return component
+
+ def _split_name(self, cname: str) -> tuple[str, str]:
+ cname = cname.strip().strip(DELIMITER)
+ if DELIMITER not in cname:
+ return DEFAULT_PREFIX, cname
+ for prefix in self.prefixes.keys():
+ _prefix = f"{prefix}{DELIMITER}"
+ if cname.startswith(_prefix):
+ return prefix, cname.removeprefix(_prefix)
+ return DEFAULT_PREFIX, cname
+
+ def _get_component_path(
+ self, prefix: str, name: str, file_ext: "tuple[str, ...] | str" = ""
+ ) -> tuple[Path, str]:
+ name = name.replace(DELIMITER, SLASH)
+ root_paths = self.prefixes[prefix].searchpath
+ name_dot = f"{name}."
+ file_ext = file_ext or self.file_ext
+
+ for root_path in root_paths:
+ for curr_folder, _, files in os.walk(
+ root_path, topdown=False, followlinks=True
+ ):
+ relfolder = os.path.relpath(curr_folder, root_path).strip(".")
+ if relfolder and not name_dot.startswith(relfolder):
+ continue
+
+ for filename in files:
+ if relfolder:
+ filepath = f"{relfolder}/{filename}"
+ else:
+ filepath = filename
+ if filepath.startswith(name_dot) and filepath.endswith(file_ext):
+ return Path(curr_folder) / filename, filepath
+
+ raise ComponentNotFound(
+ f"Unable to find a file named {name}{file_ext} "
+ f"or one following the pattern {name_dot}*{file_ext}"
+ )
+
+ def _render_attrs(self, attrs: dict[str, t.Any]) -> Markup:
+ html_attrs = []
+ for name, value in attrs.items():
+ if value != "":
+ html_attrs.append(f"{name}={value}")
+ else:
+ html_attrs.append(name)
+ return Markup(" ".join(html_attrs))
diff --git a/src/jinjax/component.py b/src/jinjax/component.py
new file mode 100644
index 0000000..948d170
--- /dev/null
+++ b/src/jinjax/component.py
@@ -0,0 +1,258 @@
+import ast
+import re
+import typing as t
+from keyword import iskeyword
+from pathlib import Path
+
+from jinja2 import Template
+from markupsafe import Markup
+
+from .exceptions import (
+ DuplicateDefDeclaration,
+ InvalidArgument,
+ MissingRequiredArgument,
+)
+from .utils import DELIMITER, get_url_prefix
+
+
+if t.TYPE_CHECKING:
+ from typing_extensions import Self
+
+RX_COMMA = re.compile(r"\s*,\s*")
+
+RX_ARGS_START = re.compile(r"{#-?\s*def\s+")
+RX_CSS_START = re.compile(r"{#-?\s*css\s+")
+RX_JS_START = re.compile(r"{#-?\s*js\s+")
+
+# This regexp matches the meta declarations (`{#def .. #}``, `{#css .. #}``,
+# and `{#js .. #}`) and regular Jinja comments AT THE BEGINNING of the components source.
+# You can also have comments inside the declarations.
+RX_META_HEADER = re.compile(r"^(\s*{#.*?#})+", re.DOTALL)
+
+# This regexep matches comments (everything after a `#`)
+# Used to remove them from inside meta declarations
+RX_INTER_COMMENTS = re.compile(r"\s*#[^\n]*")
+
+
+ALLOWED_NAMES_IN_EXPRESSION_VALUES = {
+ "len": len,
+ "max": max,
+ "min": min,
+ "pow": pow,
+ "sum": sum,
+ # Jinja allows using lowercase booleans, so we do it too for consistency
+ "false": False,
+ "true": True,
+}
+
+
+def eval_expression(input_string):
+ code = compile(input_string, "<string>", "eval")
+ for name in code.co_names:
+ if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES:
+ raise InvalidArgument(f"Use of {name} not allowed")
+ try:
+ return eval(code, {"__builtins__": {}}, ALLOWED_NAMES_IN_EXPRESSION_VALUES)
+ except NameError as err:
+ raise InvalidArgument(err) from err
+
+
+def is_valid_variable_name(name):
+ return name.isidentifier() and not iskeyword(name)
+
+
+class Component:
+ """Internal class
+ """
+ __slots__ = (
+ "name",
+ "prefix",
+ "url_prefix",
+ "required",
+ "optional",
+ "css",
+ "js",
+ "path",
+ "mtime",
+ "tmpl",
+ )
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ prefix: str = "",
+ url_prefix: str = "",
+ source: str = "",
+ mtime: float = 0,
+ tmpl: "Template | None" = None,
+ path: "Path | None" = None,
+ ) -> None:
+ self.name = name
+ self.prefix = prefix
+ self.url_prefix = url_prefix or get_url_prefix(prefix)
+ self.required: list[str] = []
+ self.optional: dict[str, t.Any] = {}
+ self.css: list[str] = []
+ self.js: list[str] = []
+
+ if path is not None:
+ source = source or path.read_text()
+ mtime = mtime or path.stat().st_mtime
+ if source:
+ self.load_metadata(source)
+
+ if path is not None:
+ default_name = self.name.replace(DELIMITER, "/")
+
+ default_css = f"{default_name}.css"
+ if (path.with_suffix(".css")).is_file():
+ self.css.extend(self.parse_files_expr(default_css))
+
+ default_js = f"{default_name}.js"
+ if (path.with_suffix(".js")).is_file():
+ self.js.extend(self.parse_files_expr(default_js))
+
+ self.path = path
+ self.mtime = mtime
+ self.tmpl = tmpl
+
+ @classmethod
+ def from_cache(
+ cls,
+ cache: dict[str, t.Any],
+ auto_reload: bool = True,
+ globals: "t.MutableMapping[str, t.Any] | None" = None,
+ ) -> "Self | None":
+ path = cache["path"]
+ mtime = cache["mtime"]
+
+ if auto_reload:
+ if not path.is_file() or path.stat().st_mtime != mtime:
+ return None
+
+ self = cls(name=cache["name"])
+ self.prefix = cache["prefix"]
+ self.url_prefix = cache["url_prefix"]
+ self.required = cache["required"]
+ self.optional = cache["optional"]
+ self.css = cache["css"]
+ self.js = cache["js"]
+ self.path = path
+ self.mtime = cache["mtime"]
+ self.tmpl = cache["tmpl"]
+
+ if globals:
+ # updating the template globals, does not affect the environment globals
+ self.tmpl.globals.update(globals)
+
+ return self
+
+ def serialize(self) -> dict[str, t.Any]:
+ return {
+ "name": self.name,
+ "prefix": self.prefix,
+ "url_prefix": self.url_prefix,
+ "required": self.required,
+ "optional": self.optional,
+ "css": self.css,
+ "js": self.js,
+ "path": self.path,
+ "mtime": self.mtime,
+ "tmpl": self.tmpl,
+ }
+
+ def load_metadata(self, source: str) -> None:
+ match = RX_META_HEADER.match(source)
+ if not match:
+ return
+
+ header = match.group(0)
+ # Reversed because I will use `header.pop()`
+ header = header.split("#}")[:-1][::-1]
+ def_found = False
+
+ while header:
+ item = header.pop().strip(" -\n")
+
+ expr = self.read_metadata_item(item, RX_ARGS_START)
+ if expr:
+ if def_found:
+ raise DuplicateDefDeclaration(self.name)
+ self.required, self.optional = self.parse_args_expr(expr)
+ def_found = True
+ continue
+
+ expr = self.read_metadata_item(item, RX_CSS_START)
+ if expr:
+ expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
+ self.css = [*self.css, *self.parse_files_expr(expr)]
+ continue
+
+ expr = self.read_metadata_item(item, RX_JS_START)
+ if expr:
+ expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
+ self.js = [*self.js, *self.parse_files_expr(expr)]
+ continue
+
+ def read_metadata_item(self, source: str, rx_start: re.Pattern) -> str:
+ start = rx_start.match(source)
+ if not start:
+ return ""
+ return source[start.end():].strip()
+
+ def parse_args_expr(self, expr: str) -> tuple[list[str], dict[str, t.Any]]:
+ expr = expr.strip(" *,/")
+ required = []
+ optional = {}
+
+ try:
+ p = ast.parse(f"def component(*,\n{expr}\n): pass")
+ except SyntaxError as err:
+ raise InvalidArgument(err) from err
+
+ args = p.body[0].args # type: ignore
+ arg_names = [arg.arg for arg in args.kwonlyargs]
+ for name, value in zip(arg_names, args.kw_defaults): # noqa: B905
+ if value is None:
+ required.append(name)
+ continue
+ expr = ast.unparse(value)
+ optional[name] = eval_expression(expr)
+
+ return required, optional
+
+ def parse_files_expr(self, expr: str) -> list[str]:
+ files = []
+ for url in RX_COMMA.split(expr):
+ url = url.strip("\"'").rstrip("/")
+ if not url:
+ continue
+ if url.startswith(("/", "http://", "https://")):
+ files.append(url)
+ else:
+ files.append(f"{self.url_prefix}{url}")
+ return files
+
+ def filter_args(
+ self, kw: dict[str, t.Any]
+ ) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
+ args = {}
+
+ for key in self.required:
+ if key not in kw:
+ raise MissingRequiredArgument(self.name, key)
+ args[key] = kw.pop(key)
+
+ for key in self.optional:
+ args[key] = kw.pop(key, self.optional[key])
+ extra = kw.copy()
+ return args, extra
+
+ def render(self, **kwargs):
+ assert self.tmpl, f"Component {self.name} has no template"
+ html = self.tmpl.render(**kwargs).strip()
+ return Markup(html)
+
+ def __repr__(self) -> str:
+ return f'<Component "{self.name}">'
diff --git a/src/jinjax/exceptions.py b/src/jinjax/exceptions.py
new file mode 100644
index 0000000..b1fd97f
--- /dev/null
+++ b/src/jinjax/exceptions.py
@@ -0,0 +1,37 @@
+class ComponentNotFound(Exception):
+ """
+ Raised when JinjaX can't find a component by name in none of the
+ added folders, probably because of a typo.
+ """
+
+ def __init__(self, name: str) -> None:
+ msg = f"File with pattern `{name}` not found"
+ super().__init__(msg)
+
+
+class MissingRequiredArgument(Exception):
+ """
+ Raised when a component is used/invoked without passing one or more
+ of its required arguments (those without a default value).
+ """
+
+ def __init__(self, component: str, arg: str) -> None:
+ msg = f"`{component}` component requires a `{arg}` argument"
+ super().__init__(msg)
+
+
+class DuplicateDefDeclaration(Exception):
+ """
+ Raised when a component has more then one `{#def ... #}` declarations.
+ """
+
+ def __init__(self, component: str) -> None:
+ msg = "`" + str(component) + "` has two `{#def ... #}` declarations"
+ super().__init__(msg)
+
+
+class InvalidArgument(Exception):
+ """
+ Raised when the arguments passed to the component cannot be parsed
+ by JinjaX because of an invalid syntax.
+ """
diff --git a/src/jinjax/html_attrs.py b/src/jinjax/html_attrs.py
new file mode 100644
index 0000000..8b4bd35
--- /dev/null
+++ b/src/jinjax/html_attrs.py
@@ -0,0 +1,348 @@
+import re
+import typing as t
+from collections import UserString
+from functools import cached_property
+
+from markupsafe import Markup
+
+
+CLASS_KEY = "class"
+CLASS_ALT_KEY = "classes"
+CLASS_KEYS = (CLASS_KEY, CLASS_ALT_KEY)
+
+
+def split(ssl: str) -> list[str]:
+ return re.split(r"\s+", ssl.strip())
+
+
+def quote(text: str) -> str:
+ if '"' in text:
+ if "'" in text:
+ text = text.replace('"', "&quot;")
+ return f'"{text}"'
+ else:
+ return f"'{text}'"
+
+ return f'"{text}"'
+
+
+class LazyString(UserString):
+ """
+ Behave like regular strings, but the actual casting of the initial value
+ is deferred until the value is actually required.
+ """
+
+ __slots__ = ("_seq",)
+
+ def __init__(self, seq):
+ self._seq = seq
+
+ @cached_property
+ def data(self): # type: ignore
+ return str(self._seq)
+
+
+class HTMLAttrs:
+ """
+ Contains all the HTML attributes/properties (a property is an
+ attribute without a value) passed to a component but that weren't
+ in the declared attributes list.
+
+ For HTML classes you can use the name "classes" (instead of "class")
+ if you need to.
+
+ **NOTE**: The string values passed to this class, are not cast to `str` until
+ the string representation is actually needed, for example when
+ `attrs.render()` is invoked.
+
+ """
+
+ def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
+ attributes: "dict[str, str | LazyString]" = {}
+ properties: set[str] = set()
+
+ class_names = split(" ".join([
+ str(attrs.pop(CLASS_KEY, "")),
+ str(attrs.get(CLASS_ALT_KEY, "")),
+ ]))
+ self.__classes = {name for name in class_names if name}
+
+ for name, value in attrs.items():
+ name = name.replace("_", "-")
+ if value is True:
+ properties.add(name)
+ elif value is not False and value is not None:
+ attributes[name] = LazyString(value)
+
+ self.__attributes = attributes
+ self.__properties = properties
+
+ @property
+ def classes(self) -> str:
+ """
+ All the HTML classes alphabetically sorted and separated by a space.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "italic bold bg-blue wide abcde"})
+ attrs.set(class="bold text-white")
+ print(attrs.classes)
+ abcde bg-blue bold italic text-white wide
+ ```
+
+ """
+ return " ".join(sorted((self.__classes)))
+
+ @property
+ def as_dict(self) -> dict[str, t.Any]:
+ """
+ An ordered dict of all the attributes and properties, both
+ sorted by name before join.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({
+ "class": "lorem ipsum",
+ "data_test": True,
+ "hidden": True,
+ "aria_label": "hello",
+ "id": "world",
+ })
+ attrs.as_dict
+ {
+ "aria_label": "hello",
+ "class": "ipsum lorem",
+ "id": "world",
+ "data_test": True,
+ "hidden": True
+ }
+ ```
+
+ """
+ attributes = self.__attributes.copy()
+ classes = self.classes
+ if classes:
+ attributes[CLASS_KEY] = classes
+
+ out: dict[str, t.Any] = dict(sorted(attributes.items()))
+ for name in sorted((self.__properties)):
+ out[name] = True
+ return out
+
+ def __getitem__(self, name: str) -> t.Any:
+ return self.get(name)
+
+ def __delitem__(self, name: str) -> None:
+ self._remove(name)
+
+ def __str__(self) -> str:
+ return str(self.as_dict)
+
+ def set(self, **kw) -> None:
+ """
+ Sets an attribute or property
+
+ - Pass a name and a value to set an attribute (e.g. `type="text"`)
+ - Use `True` as a value to set a property (e.g. `disabled`)
+ - Use `False` to remove an attribute or property
+ - If the attribute is "class", the new classes are appended to
+ the old ones (if not repeated) instead of replacing them.
+ - The underscores in the names will be translated automatically to dashes,
+ so `aria_selected` becomes the attribute `aria-selected`.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"secret": "qwertyuiop"})
+ attrs.set(secret=False)
+ attrs.as_dict
+ {}
+
+ attrs.set(unknown=False, lorem="ipsum", count=42, data_good=True)
+ attrs.as_dict
+ {"count":42, "lorem":"ipsum", "data_good": True}
+
+ attrs = HTMLAttrs({"class": "b c a"})
+ attrs.set(class="c b f d e")
+ attrs.as_dict
+ {"class": "a b c d e f"}
+ ```
+
+ """
+ for name, value in kw.items():
+ name = name.replace("_", "-")
+ if value is False or value is None:
+ self._remove(name)
+ continue
+
+ if name in CLASS_KEYS:
+ self.add_class(value)
+ elif value is True:
+ self.__properties.add(name)
+ else:
+ self.__attributes[name] = value
+
+ def setdefault(self, **kw) -> None:
+ """
+ Adds an attribute, but only if it's not already present.
+
+ The underscores in the names will be translated automatically to dashes,
+ so `aria_selected` becomes the attribute `aria-selected`.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"lorem": "ipsum"})
+ attrs.setdefault(tabindex=0, lorem="meh")
+ attrs.as_dict
+ # "tabindex" changed but "lorem" didn't
+ {"lorem": "ipsum", tabindex: 0}
+ ```
+
+ """
+ for name, value in kw.items():
+ if value in (True, False, None):
+ continue
+
+ if name in CLASS_KEYS:
+ if not self.__classes:
+ self.add_class(value)
+
+ name = name.replace("_", "-")
+ if name not in self.__attributes:
+ self.set(**{name: value})
+
+ def add_class(self, *values: str) -> None:
+ """
+ Adds one or more classes to the list of classes, if not already present.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "a b c"})
+ attrs.add_class("c", "d")
+ attrs.as_dict
+ {"class": "a b c d"}
+ ```
+
+ """
+ for names in values:
+ for name in split(names):
+ self.__classes.add(name)
+
+ def remove_class(self, *names: str) -> None:
+ """
+ Removes one or more classes from the list of classes.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "a b c"})
+ attrs.remove_class("c", "d")
+ attrs.as_dict
+ {"class": "a b"}
+ ```
+
+ """
+ for name in names:
+ self.__classes.remove(name)
+
+ def get(self, name: str, default: t.Any = None) -> t.Any:
+ """
+ Returns the value of the attribute or property,
+ or the default value if it doesn't exists.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"lorem": "ipsum", "hidden": True})
+
+ attrs.get("lorem", defaut="bar")
+ 'ipsum'
+
+ attrs.get("foo")
+ None
+
+ attrs.get("foo", defaut="bar")
+ 'bar'
+
+ attrs.get("hidden")
+ True
+ ```
+
+ """
+ name = name.replace("_", "-")
+ if name in CLASS_KEYS:
+ return self.classes
+ if name in self.__attributes:
+ return self.__attributes[name]
+ if name in self.__properties:
+ return True
+ return default
+
+ def render(self, **kw) -> str:
+ """
+ Renders the attributes and properties as a string.
+
+ Any arguments you use with this function are merged with the existing
+ attibutes/properties by the same rules as the `HTMLAttrs.set()` function:
+
+ - Pass a name and a value to set an attribute (e.g. `type="text"`)
+ - Use `True` as a value to set a property (e.g. `disabled`)
+ - Use `False` to remove an attribute or property
+ - If the attribute is "class", the new classes are appended to
+ the old ones (if not repeated) instead of replacing them.
+ - The underscores in the names will be translated automatically to dashes,
+ so `aria_selected` becomes the attribute `aria-selected`.
+
+ To provide consistent output, the attributes and properties
+ are sorted by name and rendered like this:
+ `<sorted attributes> + <sorted properties>`.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "ipsum", "data_good": True, "width": 42})
+
+ attrs.render()
+ 'class="ipsum" width="42" data-good'
+
+ attrs.render(class="abc", data_good=False, tabindex=0)
+ 'class="abc ipsum" width="42" tabindex="0"'
+ ```
+
+ """
+ if kw:
+ self.set(**kw)
+
+ attributes = self.__attributes.copy()
+
+ classes = self.classes
+ if classes:
+ attributes[CLASS_KEY] = classes
+
+ attributes = dict(sorted(attributes.items()))
+ properties = sorted((self.__properties))
+
+ html_attrs = [
+ f"{name}={quote(str(value))}"
+ for name, value in attributes.items()
+ ]
+ html_attrs.extend(properties)
+
+ return Markup(" ".join(html_attrs))
+
+ # Private
+
+ def _remove(self, name: str) -> None:
+ """
+ Removes an attribute or property.
+ """
+ if name in CLASS_KEYS:
+ self.__classes = set()
+ if name in self.__attributes:
+ del self.__attributes[name]
+ if name in self.__properties:
+ self.__properties.remove(name)
diff --git a/src/jinjax/jinjax.py b/src/jinjax/jinjax.py
new file mode 100644
index 0000000..b7a9783
--- /dev/null
+++ b/src/jinjax/jinjax.py
@@ -0,0 +1,161 @@
+import re
+import typing as t
+from uuid import uuid4
+
+from jinja2.exceptions import TemplateSyntaxError
+from jinja2.ext import Extension
+from jinja2.filters import do_forceescape
+
+from .utils import logger
+
+
+RENDER_CMD = "catalog.irender"
+BLOCK_CALL = '{% call(_slot) [CMD]("[TAG]"[ATTRS]) -%}[CONTENT]{%- endcall %}'
+BLOCK_CALL = BLOCK_CALL.replace("[CMD]", RENDER_CMD)
+INLINE_CALL = '{{ [CMD]("[TAG]"[ATTRS]) }}'
+INLINE_CALL = INLINE_CALL.replace("[CMD]", RENDER_CMD)
+
+re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"
+RX_RAW = re.compile(re_raw, re.DOTALL)
+
+re_tag_name = r"([0-9A-Za-z_-]+\.)*[A-Z][0-9A-Za-z_-]*"
+re_raw_attrs = r"(?P<attrs>[^\>]*)"
+re_tag = rf"<(?P<tag>{re_tag_name}){re_raw_attrs}\s*/?>"
+RX_TAG = re.compile(re_tag)
+
+re_attr_name = r""
+re_equal = r""
+re_attr = r"""
+(?P<name>[a-zA-Z@:$_][a-zA-Z@:$_0-9-]*)
+(?:
+ \s*=\s*
+ (?P<value>".*?"|'.*?'|\{\{.*?\}\})
+)?
+(?:\s+|/|"|$)
+"""
+RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL)
+
+
+class JinjaX(Extension):
+ def preprocess(
+ self,
+ source: str,
+ name: t.Optional[str] = None,
+ filename: t.Optional[str] = None,
+ ) -> str:
+ self.__raw_blocks = {}
+ self._name = name
+ self._filename = filename
+ source = self._replace_raw_blocks(source)
+ source = self._process_tags(source)
+ source = self._restore_raw_blocks(source)
+ self.__raw_blocks = {}
+ return source
+
+ def _replace_raw_blocks(self, source: str) -> str:
+ while True:
+ match = RX_RAW.search(source)
+ if not match:
+ break
+ start, end = match.span(0)
+ repl = self._replace_raw_block(match)
+ source = f"{source[:start]}{repl}{source[end:]}"
+
+ return source
+
+ def _replace_raw_block(self, match: re.Match) -> str:
+ uid = f"--RAW-{uuid4().hex}--"
+ self.__raw_blocks[uid] = do_forceescape(match.group(0))
+ return uid
+
+ def _restore_raw_blocks(self, source: str) -> str:
+ for uid, code in self.__raw_blocks.items():
+ source = source.replace(uid, code)
+ return source
+
+ def _process_tags(self, source: str) -> str:
+ while True:
+ match = RX_TAG.search(source)
+ if not match:
+ break
+ source = self._process_tag(source, match)
+ return source
+
+ def _process_tag(self, source: str, match: re.Match) -> str:
+ start, end = match.span(0)
+ tag = match.group("tag")
+ attrs = (match.group("attrs") or "").strip()
+ inline = match.group(0).endswith("/>")
+ lineno = source[:start].count("\n") + 1
+
+ logger.debug(f"{tag} {attrs} {'inline' if not inline else ''}")
+ if inline:
+ content = ""
+ else:
+ end_tag = f"</{tag}>"
+ index = source.find(end_tag, end, None)
+ if index == -1:
+ raise TemplateSyntaxError(
+ message=f"Unclosed component {match.group(0)}",
+ lineno=lineno,
+ name=self._name,
+ filename=self._filename
+ )
+ content = source[end:index]
+ end = index + len(end_tag)
+
+ attrs_list = self._parse_attrs(attrs)
+ repl = self._build_call(tag, attrs_list, content)
+
+ return f"{source[:start]}{repl}{source[end:]}"
+
+ def _parse_attrs(self, attrs: str) -> list[tuple[str, str]]:
+ attrs = attrs.replace("\n", " ").strip()
+ if not attrs:
+ return []
+ return RX_ATTR.findall(attrs)
+
+ def _build_call(
+ self,
+ tag: str,
+ attrs_list: list[tuple[str, str]],
+ content: str = "",
+ ) -> str:
+ logger.debug(f"{tag} {attrs_list} {'inline' if not content else ''}")
+ attrs = []
+ for name, value in attrs_list:
+ name = name.strip().replace("-", "_")
+ value = value.strip()
+
+ if not value:
+ name = name.lstrip(":")
+ attrs.append(f'"{name}"=True')
+ else:
+ # vue-like syntax
+ if (
+ name[0] == ":"
+ and value[0] in ("\"'")
+ and value[-1] in ("\"'")
+ ):
+ value = value[1:-1].strip()
+
+ # double curly braces syntax
+ if value[:2] == "{{" and value[-2:] == "}}":
+ value = value[2:-2].strip()
+
+ name = name.lstrip(":")
+ attrs.append(f'"{name}"={value}')
+
+ str_attrs = "**{" + ", ".join([a.replace("=", ":", 1) for a in attrs]) + "}"
+ if str_attrs:
+ str_attrs = f", {str_attrs}"
+
+ if not content:
+ call = INLINE_CALL.replace("[TAG]", tag).replace("[ATTRS]", str_attrs)
+ else:
+ call = (
+ BLOCK_CALL.replace("[TAG]", tag)
+ .replace("[ATTRS]", str_attrs)
+ .replace("[CONTENT]", content)
+ )
+ return call
diff --git a/src/jinjax/middleware.py b/src/jinjax/middleware.py
new file mode 100644
index 0000000..7c8acd8
--- /dev/null
+++ b/src/jinjax/middleware.py
@@ -0,0 +1,39 @@
+import re
+import typing as t
+from pathlib import Path
+
+from whitenoise import WhiteNoise
+from whitenoise.responders import Redirect, StaticFile
+
+
+RX_FINGERPRINT = re.compile("(.*)-([abcdef0-9]{64})")
+
+
+class ComponentsMiddleware(WhiteNoise):
+ """WSGI middleware for serving components assets"""
+ allowed_ext: tuple[str, ...]
+
+ def __init__(self, **kwargs) -> None:
+ self.allowed_ext = kwargs.pop("allowed_ext", ())
+ super().__init__(**kwargs)
+
+ def find_file(self, url: str) -> "StaticFile | Redirect | None":
+
+ if self.allowed_ext and not url.endswith(self.allowed_ext):
+ return None
+
+ # Ignore the fingerprint in the filename
+ # since is only for managing the cache in the client
+ relpath = Path(url)
+ ext = "".join(relpath.suffixes)
+ stem = relpath.name.removesuffix(ext)
+ fingerprinted = RX_FINGERPRINT.match(stem)
+ if fingerprinted:
+ stem = fingerprinted.group(1)
+ relpath = relpath.with_name(f"{stem}{ext}")
+
+ return super().find_file(str(relpath))
+
+ def add_file_to_dictionary(self, url: str, path: str, stat_cache: t.Any = None) -> None:
+ if not self.allowed_ext or url.endswith(self.allowed_ext):
+ super().add_file_to_dictionary(url, path, stat_cache)
diff --git a/src/jinjax/py.typed b/src/jinjax/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/jinjax/py.typed
diff --git a/src/jinjax/utils.py b/src/jinjax/utils.py
new file mode 100644
index 0000000..ff1ca4f
--- /dev/null
+++ b/src/jinjax/utils.py
@@ -0,0 +1,16 @@
+import logging
+
+
+logger = logging.getLogger("jinjax")
+
+DELIMITER = "."
+SLASH = "/"
+
+
+def get_url_prefix(prefix: str) -> str:
+ url_prefix = (
+ prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(DELIMITER, SLASH)
+ )
+ if url_prefix:
+ url_prefix = f"{url_prefix}{SLASH}"
+ return url_prefix
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..6256373
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,24 @@
+import pytest
+
+import jinjax
+
+
+@pytest.fixture()
+def folder(tmp_path):
+ d = tmp_path / "components"
+ d.mkdir()
+ return d
+
+
+@pytest.fixture()
+def folder_t(tmp_path):
+ d = tmp_path / "templates"
+ d.mkdir()
+ return d
+
+
+@pytest.fixture()
+def catalog(folder):
+ catalog = jinjax.Catalog(auto_reload=False)
+ catalog.add_folder(folder)
+ return catalog
diff --git a/tests/test_catalog.py b/tests/test_catalog.py
new file mode 100644
index 0000000..d3c4bc0
--- /dev/null
+++ b/tests/test_catalog.py
@@ -0,0 +1,88 @@
+import pytest
+
+import jinjax
+
+
+def test_add_folder_with_default_prefix():
+ catalog = jinjax.Catalog()
+ catalog.add_folder("file_path")
+
+ assert "file_path" in catalog.prefixes[""].searchpath
+
+
+def test_add_folder_with_custom_prefix():
+ catalog = jinjax.Catalog()
+ catalog.add_folder("file_path", prefix="custom")
+
+ assert "file_path" in catalog.prefixes["custom"].searchpath
+
+
+def test_add_folder_with_dirty_prefix():
+ catalog = jinjax.Catalog()
+ catalog.add_folder("file_path", prefix="/custom.")
+
+ assert "/custom." not in catalog.prefixes
+ assert "file_path" in catalog.prefixes["custom"].searchpath
+
+
+def test_add_folders_with_same_prefix():
+ catalog = jinjax.Catalog()
+ catalog.add_folder("file_path1", prefix="custom")
+ catalog.add_folder("file_path2", prefix="custom")
+
+ assert "file_path1" in catalog.prefixes["custom"].searchpath
+ assert "file_path2" in catalog.prefixes["custom"].searchpath
+
+
+def test_add_same_folder_in_same_prefix_does_nothing():
+ catalog = jinjax.Catalog()
+ catalog.add_folder("file_path", prefix="custom")
+ catalog.add_folder("file_path", prefix="custom")
+
+ assert catalog.prefixes["custom"].searchpath.count("file_path") == 1
+
+
+def test_add_module_legacy():
+ class Module:
+ components_path = "legacy_path"
+ prefix = "legacy"
+
+ catalog = jinjax.Catalog()
+ module = Module()
+ catalog.add_module(module)
+
+ assert "legacy_path" in catalog.prefixes["legacy"].searchpath
+
+
+def test_add_module_legacy_with_default_prefix():
+ class Module:
+ components_path = "legacy_path"
+
+ catalog = jinjax.Catalog()
+ module = Module()
+ catalog.add_module(module)
+
+ assert "legacy_path" in catalog.prefixes[""].searchpath
+
+
+def test_add_module_legacy_with_custom_prefix():
+ class Module:
+ components_path = "legacy_path"
+ prefix = "legacy"
+
+ catalog = jinjax.Catalog()
+ module = Module()
+ catalog.add_module(module, prefix="custom")
+
+ assert "legacy" not in catalog.prefixes
+ assert "legacy_path" in catalog.prefixes["custom"].searchpath
+
+
+def test_add_module_fails_with_other_modules():
+ class Module:
+ pass
+
+ catalog = jinjax.Catalog()
+ module = Module()
+ with pytest.raises(AttributeError):
+ catalog.add_module(module)
diff --git a/tests/test_component.py b/tests/test_component.py
new file mode 100644
index 0000000..9961b21
--- /dev/null
+++ b/tests/test_component.py
@@ -0,0 +1,315 @@
+import pytest
+
+from jinjax import Component, DuplicateDefDeclaration, InvalidArgument
+
+
+def test_load_args():
+ com = Component(
+ name="Test.jinja",
+ source='{#def message, lorem=4, ipsum="bar" -#}\n',
+ )
+ assert com.required == ["message"]
+ assert com.optional == {
+ "lorem": 4,
+ "ipsum": "bar",
+ }
+
+
+def test_expression_args():
+ com = Component(
+ name="Test.jinja",
+ source="{#def expr=1 + 2 + 3, a=1 -#}\n",
+ )
+ assert com.required == []
+ assert com.optional == {
+ "expr": 6,
+ "a": 1,
+ }
+
+
+def test_dict_args():
+ com = Component(
+ name="Test.jinja",
+ source="{#def expr={'a': 'b', 'c': 'd'} -#}\n",
+ )
+ assert com.optional == {
+ "expr": {"a": "b", "c": "d"},
+ }
+
+ com = Component(
+ name="Test.jinja",
+ source='{#def a=1, expr={"a": "b", "c": "d"} -#}\n',
+ )
+ assert com.optional == {
+ "a": 1,
+ "expr": {"a": "b", "c": "d"},
+ }
+
+
+def test_lowercase_booleans():
+ com = Component(
+ name="Test.jinja",
+ source="{#def a=false, b=true -#}\n",
+ )
+ assert com.optional == {
+ "a": False,
+ "b": True,
+ }
+
+
+def test_no_args():
+ com = Component(
+ name="Test.jinja",
+ source="\n",
+ )
+ assert com.required == []
+ assert com.optional == {}
+
+
+def test_fails_when_invalid_name():
+ with pytest.raises(InvalidArgument):
+ source = "{#def 000abc -#}\n"
+ co = Component(name="", source=source)
+ print(co.required, co.optional)
+
+
+def test_fails_when_missing_comma_between_args():
+ with pytest.raises(InvalidArgument):
+ source = "{#def lorem ipsum -#}\n"
+ co = Component(name="", source=source)
+ print(co.required, co.optional)
+
+
+def test_fails_when_missing_quotes_arround_default_value():
+ with pytest.raises(InvalidArgument):
+ source = "{#def lorem=ipsum -#}\n"
+ co = Component(name="", source=source)
+ print(co.required, co.optional)
+
+
+def test_fails_when_prop_is_expression():
+ with pytest.raises(InvalidArgument):
+ source = "{#def a-b -#}\n"
+ co = Component(name="", source=source)
+ print(co.required, co.optional)
+
+
+def test_fails_when_extra_comma_between_args():
+ with pytest.raises(InvalidArgument):
+ source = "{#def a, , b -#}\n"
+ co = Component(name="", source=source)
+ print(co.required, co.optional)
+
+
+def test_comma_in_default_value():
+ com = Component(
+ name="Test.jinja",
+ source="{#def a='lorem, ipsum' -#}\n",
+ )
+ assert com.optional == {"a": "lorem, ipsum"}
+
+
+def test_load_assets():
+ com = Component(
+ name="Test.jinja",
+ url_prefix="/static/",
+ source="""
+ {#css a.css, "b.css", c.css -#}
+ {#js a.js, b.js, c.js -#}
+ """,
+ )
+ assert com.css == ["/static/a.css", "/static/b.css", "/static/c.css"]
+ assert com.js == ["/static/a.js", "/static/b.js", "/static/c.js"]
+
+
+def test_no_comma_in_assets_list_is_your_problem():
+ com = Component(
+ name="Test.jinja",
+ source="{#js a.js b.js c.js -#}\n",
+ url_prefix="/static/"
+ )
+ assert com.js == ["/static/a.js b.js c.js"]
+
+
+def test_load_metadata_in_any_order():
+ com = Component(
+ name="Test.jinja",
+ source="""
+ {#css a.css #}
+ {#def lorem, ipsum=4 #}
+ {#js a.js #}
+ """,
+ )
+ assert com.required == ["lorem"]
+ assert com.optional == {"ipsum": 4}
+ assert com.css == ["a.css"]
+ assert com.js == ["a.js"]
+
+
+def test_ignore_metadata_if_not_first():
+ com = Component(
+ name="Test.jinja",
+ source="""
+ I am content
+ {#css a.css #}
+ {#def lorem, ipsum=4 #}
+ {#js a.js #}
+ """,
+ )
+ assert com.required == []
+ assert com.optional == {}
+ assert com.css == []
+ assert com.js == []
+
+
+def test_fail_with_more_than_one_args_declaration():
+ with pytest.raises(DuplicateDefDeclaration):
+ Component(
+ name="Test.jinja",
+ source="""
+ {#def lorem, ipsum=4 #}
+ {#def a, b, c, ipsum="nope" #}
+ """,
+ )
+
+
+def test_merge_repeated_css_or_js_declarations():
+ com = Component(
+ name="Test.jinja",
+ source="""
+ {#css a.css #}
+ {#def lorem, ipsum=4 #}
+ {#css b.css #}
+ {#js a.js #}
+ {#js b.js #}
+ """,
+ )
+ assert com.required == ["lorem"]
+ assert com.optional == {"ipsum": 4}
+ assert com.css == ["a.css", "b.css"]
+ assert com.js == ["a.js", "b.js"]
+
+
+def test_linejump_in_args_decl():
+ com = Component(
+ name="Test.jinja",
+ source='{#def\n message,\n lorem=4,\n ipsum="bar"\n#}\n',
+ )
+ assert com.required == ["message"]
+ assert com.optional == {
+ "lorem": 4,
+ "ipsum": "bar",
+ }
+
+
+def test_global_assets():
+ com = Component(
+ name="Test.jinja",
+ source="""
+ {#css a.css, /static/shared/b.css, http://example.com/cdn.css #}
+ {#js "http://example.com/cdn.js", a.js, /static/shared/b.js #}
+ """,
+ )
+ assert com.css == ["a.css", "/static/shared/b.css", "http://example.com/cdn.css"]
+ assert com.js == ["http://example.com/cdn.js", "a.js", "/static/shared/b.js"]
+
+
+def test_types_in_args_decl():
+ com = Component(
+ name="Test.jinja",
+ source="""{# def
+ ring_class: str = "ring-1 ring-black",
+ rounded_class: str = "rounded-2xl md:rounded-3xl",
+
+ image: str | None = None,
+
+ title: str = "",
+ p_class: str = "px-5 md:px-6 py-5 md:py-6",
+ gap_class: str = "gap-4",
+ content_class: str = "",
+
+ layer_class: str | None = None,
+ layer_height: int = 4,
+#}"""
+ )
+ assert com.required == []
+ print(com.optional)
+ assert com.optional == {
+ "ring_class": "ring-1 ring-black",
+ "rounded_class": "rounded-2xl md:rounded-3xl",
+ "image": None,
+ "title": "",
+ "p_class": "px-5 md:px-6 py-5 md:py-6",
+ "gap_class": "gap-4",
+ "content_class": "",
+ "layer_class": None,
+ "layer_height": 4,
+ }
+
+
+def test_comments_in_args_decl():
+ com = Component(
+ name="Test.jinja",
+ source="""{# def
+ #
+ # Card style
+ ring_class: str = "ring-1 ring-black",
+ rounded_class: str = "rounded-2xl md:rounded-3xl",
+ #
+ # Image
+ image: str | None = None,
+ #
+ # Content
+ title: str = "",
+ p_class: str = "px-5 md:px-6 py-5 md:py-6",
+ gap_class: str = "gap-4",
+ content_class: str = "",
+ #
+ # Decorative layer
+ layer_class: str | None = None,
+ layer_height: int = 4,
+#}"""
+ )
+ assert com.required == []
+ print(com.optional)
+ assert com.optional == {
+ "ring_class": "ring-1 ring-black",
+ "rounded_class": "rounded-2xl md:rounded-3xl",
+ "image": None,
+ "title": "",
+ "p_class": "px-5 md:px-6 py-5 md:py-6",
+ "gap_class": "gap-4",
+ "content_class": "",
+ "layer_class": None,
+ "layer_height": 4,
+ }
+
+
+def test_comment_after_args_decl():
+ com = Component(
+ name="Test.jinja",
+ source="""
+{# def
+ arg,
+#}
+
+{#
+ Some comment.
+#}
+Hi
+""".strip())
+ assert com.required == ["arg"]
+ assert com.optional == {}
+
+
+def test_fake_decl():
+ com = Component(
+ name="Test.jinja",
+ source="""
+{# definitely not an args decl! #}
+{# def arg #}
+{# jsadfghkl are letters #}
+{# csssssss #}
+""".strip())
+ assert com.required == ["arg"]
+ assert com.optional == {}
diff --git a/tests/test_html_attrs.py b/tests/test_html_attrs.py
new file mode 100644
index 0000000..bc1a68f
--- /dev/null
+++ b/tests/test_html_attrs.py
@@ -0,0 +1,281 @@
+import pytest
+
+from jinjax.html_attrs import HTMLAttrs
+
+
+def test_parse_initial_attrs():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ "data-position": "top",
+ "class": "z4 c3 a1 z4 b2",
+ "open": True,
+ "disabled": False,
+ "value": 0,
+ "foobar": None,
+ }
+ )
+ assert attrs.classes == "a1 b2 c3 z4"
+ assert attrs.get("class") == "a1 b2 c3 z4"
+ assert attrs.get("data-position") == "top"
+ assert attrs.get("data_position") == "top"
+ assert attrs.get("title") == "hi"
+ assert attrs.get("open") is True
+ assert attrs.get("disabled", "meh") == "meh"
+ assert attrs.get("value") == "0"
+
+ assert attrs.get("disabled") is None
+ assert attrs.get("foobar") is None
+
+ attrs.set(data_value=0)
+ attrs.set(data_position=False)
+ assert attrs.get("data-value") == 0
+ assert attrs.get("data-position") is None
+ assert attrs.get("data_position") is None
+
+def test_getattr():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ "class": "z4 c3 a1 z4 b2",
+ "open": True,
+ }
+ )
+ assert attrs["class"] == "a1 b2 c3 z4"
+ assert attrs["title"] == "hi"
+ assert attrs["open"] is True
+ assert attrs["lorem"] is None
+
+
+def test_deltattr():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ "class": "z4 c3 a1 z4 b2",
+ "open": True,
+ }
+ )
+ assert attrs["class"] == "a1 b2 c3 z4"
+ del attrs["title"]
+ assert attrs["title"] is None
+
+
+def test_render():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ "data-position": "top",
+ "class": "z4 c3 a1 z4 b2",
+ "open": True,
+ "disabled": False,
+ }
+ )
+ assert 'class="a1 b2 c3 z4" data-position="top" title="hi" open' == attrs.render()
+
+
+def test_set():
+ attrs = HTMLAttrs({})
+ attrs.set(title="hi", data_position="top")
+ attrs.set(open=True)
+ assert 'data-position="top" title="hi" open' == attrs.render()
+
+ attrs.set(title=False, open=False)
+ assert 'data-position="top"' == attrs.render()
+
+
+def test_class_management():
+ attrs = HTMLAttrs(
+ {
+ "class": "z4 c3 a1 z4 b2",
+ }
+ )
+ attrs.set(classes="lorem bipsum lorem a1")
+
+ assert attrs.classes == "a1 b2 bipsum c3 lorem z4"
+
+ attrs.remove_class("bipsum")
+ assert attrs.classes == "a1 b2 c3 lorem z4"
+
+ attrs.set(classes=None)
+ attrs.set(classes="meh")
+ assert attrs.classes == "meh"
+
+
+def test_setdefault():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ }
+ )
+ attrs.setdefault(
+ title="default title",
+ data_lorem="ipsum",
+ open=True,
+ disabled=False,
+ )
+ assert 'data-lorem="ipsum" title="hi"' == attrs.render()
+
+
+def test_as_dict():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ "data-position": "top",
+ "class": "z4 c3 a1 z4 b2",
+ "open": True,
+ "disabled": False,
+ }
+ )
+ assert attrs.as_dict == {
+ "class": "a1 b2 c3 z4",
+ "data-position": "top",
+ "title": "hi",
+ "open": True,
+ }
+
+
+def test_as_dict_no_classes():
+ attrs = HTMLAttrs(
+ {
+ "title": "hi",
+ "data-position": "top",
+ "open": True,
+ }
+ )
+ assert attrs.as_dict == {
+ "data-position": "top",
+ "title": "hi",
+ "open": True,
+ }
+
+
+def test_render_attrs_lik_set():
+ attrs = HTMLAttrs({"class": "lorem"})
+ expected = 'class="ipsum lorem" data-position="top" title="hi" open'
+ result = attrs.render(
+ title="hi",
+ data_position="top",
+ classes="ipsum",
+ open=True,
+ )
+ print(result)
+ assert expected == result
+
+
+def test_do_not_escape_tailwind_syntax():
+ attrs = HTMLAttrs({"class": "lorem [&_a]:flex"})
+ expected = 'class="[&_a]:flex ipsum lorem" title="Hi&Stuff"'
+ result = attrs.render(
+ **{
+ "title": "Hi&Stuff",
+ "class": "ipsum",
+ }
+ )
+ print(result)
+ assert expected == result
+
+
+def test_do_escape_quotes_inside_attrs():
+ attrs = HTMLAttrs(
+ {
+ "class": "lorem text-['red']",
+ "title": 'I say "hey"',
+ "open": True,
+ }
+ )
+ expected = """class="lorem text-['red']" title='I say "hey"' open"""
+ result = attrs.render()
+ print(result)
+ assert expected == result
+
+
+def test_additional_attributes_are_lazily_evaluated_to_strings():
+ class TestObject:
+ def __str__(self):
+ raise RuntimeError("Should not be called unless rendered.")
+
+ attrs = HTMLAttrs(
+ {
+ "some_object": TestObject(),
+ }
+ )
+
+ with pytest.raises(RuntimeError):
+ attrs.render()
+
+
+def test_additional_attributes_lazily_evaluated_has_string_methods():
+ class TestObject:
+ def __str__(self):
+ return "test"
+
+ attrs = HTMLAttrs({"some_object": TestObject()})
+
+ assert attrs["some_object"].__str__
+ assert attrs["some_object"].__repr__
+ assert attrs["some_object"].__int__
+ assert attrs["some_object"].__float__
+ assert attrs["some_object"].__complex__
+ assert attrs["some_object"].__hash__
+ assert attrs["some_object"].__eq__
+ assert attrs["some_object"].__lt__
+ assert attrs["some_object"].__le__
+ assert attrs["some_object"].__gt__
+ assert attrs["some_object"].__ge__
+ assert attrs["some_object"].__contains__
+ assert attrs["some_object"].__len__
+ assert attrs["some_object"].__getitem__
+ assert attrs["some_object"].__add__
+ assert attrs["some_object"].__radd__
+ assert attrs["some_object"].__mul__
+ assert attrs["some_object"].__mod__
+ assert attrs["some_object"].__rmod__
+ assert attrs["some_object"].capitalize
+ assert attrs["some_object"].casefold
+ assert attrs["some_object"].center
+ assert attrs["some_object"].count
+ assert attrs["some_object"].removeprefix
+ assert attrs["some_object"].removesuffix
+ assert attrs["some_object"].encode
+ assert attrs["some_object"].endswith
+ assert attrs["some_object"].expandtabs
+ assert attrs["some_object"].find
+ assert attrs["some_object"].format
+ assert attrs["some_object"].format_map
+ assert attrs["some_object"].index
+ assert attrs["some_object"].isalpha
+ assert attrs["some_object"].isalnum
+ assert attrs["some_object"].isascii
+ assert attrs["some_object"].isdecimal
+ assert attrs["some_object"].isdigit
+ assert attrs["some_object"].isidentifier
+ assert attrs["some_object"].islower
+ assert attrs["some_object"].isnumeric
+ assert attrs["some_object"].isprintable
+ assert attrs["some_object"].isspace
+ assert attrs["some_object"].istitle
+ assert attrs["some_object"].isupper
+ assert attrs["some_object"].join
+ assert attrs["some_object"].ljust
+ assert attrs["some_object"].lower
+ assert attrs["some_object"].lstrip
+ assert attrs["some_object"].partition
+ assert attrs["some_object"].replace
+ assert attrs["some_object"].rfind
+ assert attrs["some_object"].rindex
+ assert attrs["some_object"].rjust
+ assert attrs["some_object"].rpartition
+ assert attrs["some_object"].rstrip
+ assert attrs["some_object"].split
+ assert attrs["some_object"].rsplit
+ assert attrs["some_object"].splitlines
+ assert attrs["some_object"].startswith
+ assert attrs["some_object"].strip
+ assert attrs["some_object"].swapcase
+ assert attrs["some_object"].title
+ assert attrs["some_object"].translate
+ assert attrs["some_object"].upper
+ assert attrs["some_object"].zfill
+
+ assert attrs["some_object"].upper() == "TEST"
+ assert attrs["some_object"].title() == "Test"
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
new file mode 100644
index 0000000..b1fa9a9
--- /dev/null
+++ b/tests/test_middleware.py
@@ -0,0 +1,152 @@
+import typing as t
+from pathlib import Path
+
+import jinjax
+
+
+def application(environ, start_response) -> list[bytes]:
+ status = "200 OK"
+ headers = [("Content-type", "text/plain")]
+ start_response(status, headers)
+ return [b"NOPE"]
+
+
+def make_environ(**kw) -> dict[str, t.Any]:
+ kw.setdefault("PATH_INFO", "/")
+ kw.setdefault("REQUEST_METHOD", "GET")
+ return kw
+
+
+def mock_start_response(status: str, headers: dict[str, t.Any]):
+ pass
+
+
+def get_catalog(folder: "str | Path", **kw) -> jinjax.Catalog:
+ catalog = jinjax.Catalog(**kw)
+ catalog.add_folder(folder)
+ return catalog
+
+
+TMiddleware = t.Callable[
+ [
+ dict[str, t.Any],
+ t.Callable[[str, dict[str, t.Any]], None],
+ ],
+ t.Any
+]
+
+def run_middleware(middleware: TMiddleware, url: str):
+ return middleware(make_environ(PATH_INFO=url), mock_start_response)
+
+
+# Tests
+
+
+def test_css_is_returned(folder):
+ (folder / "page.css").write_text("/* Page.css */")
+ catalog = get_catalog(folder)
+ middleware = catalog.get_middleware(application)
+
+ resp = run_middleware(middleware, "/static/components/page.css")
+ assert resp and not isinstance(resp, list)
+ text = resp.filelike.read().strip()
+ assert text == b"/* Page.css */"
+
+
+def test_js_is_returned(folder):
+ (folder / "page.js").write_text("/* Page.js */")
+ catalog = get_catalog(folder)
+ middleware = catalog.get_middleware(application)
+
+ resp = run_middleware(middleware, "/static/components/page.js")
+ assert resp and not isinstance(resp, list)
+ text = resp.filelike.read().strip()
+ assert text == b"/* Page.js */"
+
+
+def test_other_file_extensions_ignored(folder):
+ (folder / "Page.jinja").write_text("???")
+ catalog = get_catalog(folder)
+ middleware = catalog.get_middleware(application)
+ resp = run_middleware(middleware, "/static/components/Page.jinja")
+ assert resp == [b"NOPE"]
+
+
+def test_add_custom_extensions(folder):
+ (folder / "Page.jinja").write_text("???")
+ catalog = get_catalog(folder)
+ middleware = catalog.get_middleware(application, allowed_ext=[".jinja"])
+
+ resp = run_middleware(middleware, "/static/components/Page.jinja")
+ assert resp and not isinstance(resp, list)
+ text = resp.filelike.read().strip()
+ assert text == b"???"
+
+
+def test_custom_root_url(folder):
+ (folder / "page.css").write_text("/* Page.css */")
+ catalog = get_catalog(folder, root_url="/static/co/")
+ middleware = catalog.get_middleware(application)
+
+ resp = run_middleware(middleware, "/static/co/page.css")
+ assert resp and not isinstance(resp, list)
+ text = resp.filelike.read().strip()
+ assert text == b"/* Page.css */"
+
+
+def test_autorefresh_load(folder):
+ (folder / "page.css").write_text("/* Page.css */")
+ catalog = get_catalog(folder)
+ middleware = catalog.get_middleware(application, autorefresh=True)
+
+ resp = run_middleware(middleware, "/static/components/page.css")
+ assert resp and not isinstance(resp, list)
+ text = resp.filelike.read().strip()
+ assert text == b"/* Page.css */"
+
+
+def test_autorefresh_block(folder):
+ (folder / "Page.jinja").write_text("???")
+ catalog = get_catalog(folder)
+ middleware = catalog.get_middleware(application, autorefresh=True)
+
+ resp = run_middleware(middleware, "/static/components/Page.jinja")
+ assert resp == [b"NOPE"]
+
+
+def test_multiple_folders(tmp_path):
+ folder1 = tmp_path / "folder1"
+ folder1.mkdir()
+ (folder1 / "folder1.css").write_text("folder1")
+
+ folder2 = tmp_path / "folder2"
+ folder2.mkdir()
+ (folder2 / "folder2.css").write_text("folder2")
+
+ catalog = jinjax.Catalog()
+ catalog.add_folder(folder1)
+ catalog.add_folder(folder2)
+ middleware = catalog.get_middleware(application)
+
+ resp = run_middleware(middleware, "/static/components/folder1.css")
+ assert resp.filelike.read() == b"folder1"
+ resp = run_middleware(middleware, "/static/components/folder2.css")
+ assert resp.filelike.read() == b"folder2"
+
+
+def test_multiple_folders_precedence(tmp_path):
+ folder1 = tmp_path / "folder1"
+ folder1.mkdir()
+ (folder1 / "name.css").write_text("folder1")
+
+ folder2 = tmp_path / "folder2"
+ folder2.mkdir()
+ (folder2 / "name.css").write_text("folder2")
+
+ catalog = jinjax.Catalog()
+ catalog.add_folder(folder1)
+ catalog.add_folder(folder2)
+ middleware = catalog.get_middleware(application)
+
+ resp = run_middleware(middleware, "/static/components/name.css")
+ assert resp.filelike.read() == b"folder1"
diff --git a/tests/test_render.py b/tests/test_render.py
new file mode 100644
index 0000000..1e52cda
--- /dev/null
+++ b/tests/test_render.py
@@ -0,0 +1,992 @@
+import time
+from pathlib import Path
+from textwrap import dedent
+
+import jinja2
+import pytest
+from jinja2.exceptions import TemplateSyntaxError
+from markupsafe import Markup
+
+import jinjax
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_render_simple(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Greeting.jinja").write_text(
+ """
+{#def message #}
+<div class="greeting [&_a]:flex">{{ message }}</div>
+ """
+ )
+ html = catalog.render("Greeting", message="Hello world!")
+ assert html == Markup('<div class="greeting [&_a]:flex">Hello world!</div>')
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_render_source(catalog, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ source = '{#def message #}\n<div class="greeting [&_a]:flex">{{ message }}</div>'
+ expected = Markup('<div class="greeting [&_a]:flex">Hello world!</div>')
+
+ html = catalog.render("Greeting", message="Hello world!", _source=source)
+ assert expected == html
+
+ # Legacy
+ html = catalog.render("Greeting", message="Hello world!", __source=source)
+ assert expected == html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_render_content(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Card.jinja").write_text("""
+<section class="card">
+{{ content }}
+</section>
+ """)
+
+ content = '<button type="button">Close</button>'
+ expected = Markup(f'<section class="card">\n{content}\n</section>')
+
+ html = catalog.render("Card", _content=content)
+ print(html)
+ assert expected == html
+
+ # Legacy
+ html = catalog.render("Card", __content=content)
+ assert expected == html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+@pytest.mark.parametrize(
+ "source, expected",
+ [
+ ("<Title>Hi</Title><Title>Hi</Title>", "<h1>Hi</h1><h1>Hi</h1>"),
+ ("<Icon /><Icon />", '<i class="icon"></i><i class="icon"></i>'),
+ ("<Title>Hi</Title><Icon />", '<h1>Hi</h1><i class="icon"></i>'),
+ ("<Icon /><Title>Hi</Title>", '<i class="icon"></i><h1>Hi</h1>'),
+ ],
+)
+def test_render_mix_of_contentful_and_contentless_components(
+ catalog,
+ folder,
+ source,
+ expected,
+ autoescape,
+):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Icon.jinja").write_text('<i class="icon"></i>')
+ (folder / "Title.jinja").write_text("<h1>{{ content }}</h1>")
+ (folder / "Page.jinja").write_text(source)
+
+ html = catalog.render("Page")
+ assert html == Markup(expected)
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_composition(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Greeting.jinja").write_text(
+ """
+{#def message #}
+<div class="greeting [&_a]:flex">{{ message }}</div>
+"""
+ )
+
+ (folder / "CloseBtn.jinja").write_text(
+ """
+{#def disabled=False -#}
+<button type="button"{{ " disabled" if disabled else "" }}>&times;</button>
+"""
+ )
+
+ (folder / "Card.jinja").write_text(
+ """
+<section class="card">
+{{ content }}
+<CloseBtn disabled />
+</section>
+"""
+ )
+
+ (folder / "Page.jinja").write_text(
+ """
+{#def message #}
+<Card>
+<Greeting :message="message" />
+<button type="button">Close</button>
+</Card>
+"""
+ )
+
+ html = catalog.render("Page", message="Hello")
+ print(html)
+ assert (
+ """
+<section class="card">
+<div class="greeting [&_a]:flex">Hello</div>
+<button type="button">Close</button>
+<button type="button" disabled>&times;</button>
+</section>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_just_properties(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Lorem.jinja").write_text(
+ """
+{#def ipsum=False #}
+<p>lorem {{ "ipsum" if ipsum else "lorem" }}</p>
+"""
+ )
+
+ (folder / "Layout.jinja").write_text(
+ """
+<main>
+{{ content }}
+</main>
+"""
+ )
+
+ (folder / "Page.jinja").write_text(
+ """
+<Layout>
+<Lorem ipsum />
+<p>meh</p>
+<Lorem />
+</Layout>
+"""
+ )
+
+ html = catalog.render("Page")
+ print(html)
+ assert (
+ """
+<main>
+<p>lorem ipsum</p>
+<p>meh</p>
+<p>lorem lorem</p>
+</main>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_render_assets(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Greeting.jinja").write_text(
+ """
+{#def message #}
+{#css greeting.css, http://example.com/super.css #}
+{#js greeting.js #}
+<div class="greeting [&_a]:flex">{{ message }}</div>
+"""
+ )
+
+ (folder / "Card.jinja").write_text(
+ """
+{#css https://somewhere.com/style.css, card.css #}
+{#js card.js, shared.js #}
+<section class="card">
+{{ content }}
+</section>
+"""
+ )
+
+ (folder / "Layout.jinja").write_text(
+ """
+<html>
+{{ catalog.render_assets() }}
+{{ content }}
+</html>
+"""
+ )
+
+ (folder / "Page.jinja").write_text(
+ """
+{#def message #}
+{#js https://somewhere.com/blabla.js, shared.js #}
+<Layout>
+<Card>
+<Greeting :message="message" />
+<button type="button">Close</button>
+</Card>
+</Layout>
+"""
+ )
+
+ html = catalog.render("Page", message="Hello")
+ print(html)
+ assert (
+ """
+<html>
+<link rel="stylesheet" href="https://somewhere.com/style.css">
+<link rel="stylesheet" href="/static/components/card.css">
+<link rel="stylesheet" href="/static/components/greeting.css">
+<link rel="stylesheet" href="http://example.com/super.css">
+<script type="module" src="https://somewhere.com/blabla.js"></script>
+<script type="module" src="/static/components/shared.js"></script>
+<script type="module" src="/static/components/card.js"></script>
+<script type="module" src="/static/components/greeting.js"></script>
+<section class="card">
+<div class="greeting [&_a]:flex">Hello</div>
+<button type="button">Close</button>
+</section>
+</html>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_global_values(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Global.jinja").write_text("""{{ globalvar }}""")
+ message = "Hello world!"
+ catalog.jinja_env.globals["globalvar"] = message
+ html = catalog.render("Global")
+ print(html)
+ assert message in html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_required_attr_are_required(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Greeting.jinja").write_text(
+ """
+{#def message #}
+<div class="greeting">{{ message }}</div>
+"""
+ )
+
+ with pytest.raises(jinjax.MissingRequiredArgument):
+ catalog.render("Greeting")
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_subfolder(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ sub = folder / "UI"
+ sub.mkdir()
+ (folder / "Meh.jinja").write_text("<UI.Tab>Meh</UI.Tab>")
+ (sub / "Tab.jinja").write_text('<div class="tab">{{ content }}</div>')
+
+ html = catalog.render("Meh")
+ assert html == Markup('<div class="tab">Meh</div>')
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_default_attr(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Greeting.jinja").write_text(
+ """
+{#def message="Hello", world=False #}
+<div>{{ message }}{% if world %} World{% endif %}</div>
+"""
+ )
+
+ (folder / "Page.jinja").write_text(
+ """
+<Greeting />
+<Greeting message="Hi" />
+<Greeting :world="False" />
+<Greeting :world="True" />
+<Greeting world />
+"""
+ )
+
+ html = catalog.render("Page", message="Hello")
+ print(html)
+ assert (
+ """
+<div>Hello</div>
+<div>Hi</div>
+<div>Hello</div>
+<div>Hello World</div>
+<div>Hello World</div>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_raw_content(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Code.jinja").write_text("""
+<pre class="code">
+{{ content|e }}
+</pre>
+""")
+
+ (folder / "Page.jinja").write_text("""
+<Code>
+{% raw -%}
+{#def message="Hello", world=False #}
+<Header />
+<div>{{ message }}{% if world %} World{% endif %}</div>
+{%- endraw %}
+</Code>
+""")
+
+ html = catalog.render("Page")
+ print(html)
+ assert (
+ """
+<pre class="code">
+{#def message=&#34;Hello&#34;, world=False #}
+&lt;Header /&gt;
+&lt;div&gt;{{ message }}{% if world %} World{% endif %}&lt;/div&gt;
+</pre>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_multiple_raw(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "C.jinja").write_text("""
+<div {{ attrs.render() }}></div>
+""")
+
+ (folder / "Page.jinja").write_text("""
+<C id="1" />
+{% raw -%}
+<C id="2" />
+{%- endraw %}
+<C id="3" />
+{% raw %}<C id="4" />{% endraw %}
+<C id="5" />
+""")
+
+ html = catalog.render("Page", message="Hello")
+ print(html)
+ assert (
+ """
+<div id="1"></div>
+&lt;C id=&#34;2&#34; /&gt;
+<div id="3"></div>
+&lt;C id=&#34;4&#34; /&gt;
+<div id="5"></div>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_check_for_unclosed(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Lorem.jinja").write_text("""
+{#def ipsum=False #}
+<p>lorem {{ "ipsum" if ipsum else "lorem" }}</p>
+""")
+
+ (folder / "Page.jinja").write_text("""
+<main>
+<Lorem ipsum>
+</main>
+""")
+
+ with pytest.raises(TemplateSyntaxError):
+ try:
+ catalog.render("Page")
+ except TemplateSyntaxError as err:
+ print(err)
+ raise
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_dict_as_attr(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "CitiesList.jinja").write_text("""
+{#def cities #}
+{% for city, country in cities.items() -%}
+<p>{{ city }}, {{ country }}</p>
+{%- endfor %}
+""")
+
+ (folder / "Page.jinja").write_text("""
+<CitiesList :cities="{
+ 'Lima': 'Peru',
+ 'New York': 'USA',
+}" />
+""")
+
+ html = catalog.render("Page")
+ assert html == Markup("<p>Lima, Peru</p><p>New York, USA</p>")
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_cleanup_assets(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Layout.jinja").write_text("""
+<html>
+{{ catalog.render_assets() }}
+{{ content }}
+</html>
+""")
+
+ (folder / "Foo.jinja").write_text("""
+{#js foo.js #}
+<Layout>
+<p>Foo</p>
+</Layout>
+""")
+
+ (folder / "Bar.jinja").write_text("""
+{#js bar.js #}
+<Layout>
+<p>Bar</p>
+</Layout>
+""")
+
+ html = catalog.render("Foo")
+ print(html, "\n")
+ assert (
+ """
+<html>
+<script type="module" src="/static/components/foo.js"></script>
+<p>Foo</p>
+</html>
+""".strip()
+ in html
+ )
+
+ html = catalog.render("Bar")
+ print(html)
+ assert (
+ """
+<html>
+<script type="module" src="/static/components/bar.js"></script>
+<p>Bar</p>
+</html>
+""".strip()
+ in html
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_do_not_mess_with_external_jinja_env(folder_t, folder, autoescape):
+ """https://github.com/jpsca/jinjax/issues/19"""
+ (folder_t / "greeting.html").write_text("Jinja still works")
+ (folder / "Greeting.jinja").write_text("JinjaX works")
+
+ jinja_env = jinja2.Environment(
+ loader=jinja2.FileSystemLoader(folder_t),
+ extensions=["jinja2.ext.i18n"],
+ )
+ jinja_env.globals = {"glo": "bar"}
+ jinja_env.filters = {"fil": lambda x: x}
+ jinja_env.tests = {"tes": lambda x: x}
+ jinja_env.autoescape = autoescape
+
+ catalog = jinjax.Catalog(
+ jinja_env=jinja_env,
+ extensions=["jinja2.ext.debug"],
+ globals={"xglo": "foo"},
+ filters={"xfil": lambda x: x},
+ tests={"xtes": lambda x: x},
+ )
+ catalog.add_folder(folder)
+
+ html = catalog.render("Greeting")
+ assert html == Markup("JinjaX works")
+
+ assert catalog.jinja_env.globals["catalog"] == catalog
+ assert catalog.jinja_env.globals["glo"] == "bar"
+ assert catalog.jinja_env.globals["xglo"] == "foo"
+ assert catalog.jinja_env.filters["fil"]
+ assert catalog.jinja_env.filters["xfil"]
+ assert catalog.jinja_env.tests["tes"]
+ assert catalog.jinja_env.tests["xtes"]
+ assert "jinja2.ext.InternationalizationExtension" in catalog.jinja_env.extensions
+ assert "jinja2.ext.DebugExtension" in catalog.jinja_env.extensions
+ assert "jinja2.ext.ExprStmtExtension" in catalog.jinja_env.extensions
+
+ tmpl = jinja_env.get_template("greeting.html")
+ assert tmpl.render() == "Jinja still works"
+
+ assert jinja_env.globals["catalog"] == catalog
+ assert jinja_env.globals["glo"] == "bar"
+ assert "xglo" not in jinja_env.globals
+ assert jinja_env.filters["fil"]
+ assert "xfil" not in jinja_env.filters
+ assert jinja_env.tests["tes"]
+ assert "xtes" not in jinja_env.tests
+ assert "jinja2.ext.InternationalizationExtension" in jinja_env.extensions
+ assert "jinja2.ext.DebugExtension" not in jinja_env.extensions
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_auto_reload(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Layout.jinja").write_text("""
+<html>
+{{ content }}
+</html>
+""")
+
+ (folder / "Foo.jinja").write_text("""
+<Layout>
+<p>Foo</p>
+<Bar></Bar>
+</Layout>
+""")
+
+ bar_file = folder / "Bar.jinja"
+ bar_file.write_text("<p>Bar</p>")
+
+ html1 = catalog.render("Foo")
+ print(bar_file.stat().st_mtime)
+ print(html1, "\n")
+ assert (
+ """
+<html>
+<p>Foo</p>
+<p>Bar</p>
+</html>
+""".strip()
+ in html1
+ )
+
+ # Give it some time so the st_mtime are different
+ time.sleep(0.1)
+
+ catalog.auto_reload = False
+ bar_file.write_text("<p>Ignored</p>")
+ print(bar_file.stat().st_mtime)
+ html2 = catalog.render("Foo")
+ print(html2, "\n")
+
+ catalog.auto_reload = True
+ bar_file.write_text("<p>Updated</p>")
+ print(bar_file.stat().st_mtime)
+ html3 = catalog.render("Foo")
+ print(html3, "\n")
+
+ assert html1 == html2
+ assert (
+ """
+<html>
+<p>Foo</p>
+<p>Updated</p>
+</html>
+""".strip()
+ in html3
+ )
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_subcomponents(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ """Issue https://github.com/jpsca/jinjax/issues/32"""
+ (folder / "Page.jinja").write_text("""
+{#def message #}
+<html>
+<p>lorem ipsum</p>
+<Subcomponent />
+{{ message }}
+</html>
+""")
+
+ (folder / "Subcomponent.jinja").write_text("""
+<p>foo bar</p>
+""")
+
+ html = catalog.render("Page", message="<3")
+
+ if autoescape:
+ expected = """
+<html>
+<p>lorem ipsum</p>
+<p>foo bar</p>
+&lt;3
+</html>"""
+ else:
+ expected = """
+<html>
+<p>lorem ipsum</p>
+<p>foo bar</p>
+<3
+</html>"""
+
+ assert html == Markup(expected.strip())
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_fingerprint_assets(catalog, folder: Path, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Layout.jinja").write_text("""
+<html>
+{{ catalog.render_assets() }}
+{{ content }}
+</html>
+""")
+
+ (folder / "Page.jinja").write_text("""
+{#css app.css, http://example.com/super.css #}
+{#js app.js #}
+<Layout>Hi</Layout>
+""")
+
+ (folder / "app.css").write_text("...")
+
+ catalog.fingerprint = True
+ html = catalog.render("Page", message="Hello")
+ print(html)
+
+ assert 'src="/static/components/app.js"' in html
+ assert 'href="/static/components/app-' in html
+ assert 'href="http://example.com/super.css' in html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_colon_in_attrs(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "C.jinja").write_text("""
+<div {{ attrs.render() }}></div>
+""")
+
+ (folder / "Page.jinja").write_text("""
+<C hx-on:click="show = !show" />
+""")
+
+ html = catalog.render("Page", message="Hello")
+ print(html)
+ assert """<div hx-on:click="show = !show"></div>""" in html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_template_globals(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Input.jinja").write_text("""
+{# def name, value #}<input type="text" name="{{name}}" value="{{value}}">
+""")
+
+ (folder / "CsrfToken.jinja").write_text("""
+<input type="hidden" name="csrft" value="{{csrf_token}}">
+""")
+
+ (folder / "Form.jinja").write_text("""
+<form><CsrfToken/>{{content}}</form>
+""")
+
+ (folder / "Page.jinja").write_text("""
+{# def value #}
+<Form><Input name="foo" :value="value"/></Form>
+""")
+
+ html = catalog.render("Page", value="bar", __globals={"csrf_token": "abc"})
+ print(html)
+ assert """<input type="hidden" name="csrft" value="abc">""" in html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_template_globals_update_cache(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "CsrfToken.jinja").write_text(
+ """<input type="hidden" name="csrft" value="{{csrf_token}}">"""
+ )
+ (folder / "Page.jinja").write_text("""<CsrfToken/>""")
+
+ html = catalog.render("Page", __globals={"csrf_token": "abc"})
+ print(html)
+ assert """<input type="hidden" name="csrft" value="abc">""" in html
+
+ html = catalog.render("Page", __globals={"csrf_token": "xyz"})
+ print(html)
+ assert """<input type="hidden" name="csrft" value="xyz">""" in html
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_alpine_sintax(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Greeting.jinja").write_text("""
+{#def message #}
+<button @click="alert('{{ message }}')">Say Hi</button>""")
+
+ html = catalog.render("Greeting", message="Hello world!")
+ print(html)
+ expected = """<button @click="alert('Hello world!')">Say Hi</button>"""
+ assert html == Markup(expected)
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_alpine_sintax_in_component(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Button.jinja").write_text(
+ """<button {{ attrs.render() }}>{{ content }}</button>"""
+ )
+
+ (folder / "Greeting.jinja").write_text(
+ """<Button @click="alert('Hello world!')">Say Hi</Button>"""
+ )
+
+ html = catalog.render("Greeting")
+ print(html)
+ expected = """<button @click="alert('Hello world!')">Say Hi</button>"""
+ assert html == Markup(expected)
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_autoescaped_attrs(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "CheckboxItem.jinja").write_text(
+ """<div {{ attrs.render(class="relative") }}></div>"""
+ )
+
+ (folder / "Page.jinja").write_text(
+ """<CheckboxItem class="border border-red-500" />"""
+ )
+
+ html = catalog.render("Page")
+ print(html)
+ expected = """<div class="border border-red-500 relative"></div>"""
+ assert html == Markup(expected)
+
+
+@pytest.mark.parametrize(
+ "template",
+ [
+ pytest.param(
+ dedent(
+ """
+ {# def
+ href,
+ hx_target="#maincontent",
+ hx_swap="innerHTML show:body:top",
+ hx_push_url=true,
+ #}
+ <a href="{{href}}" hx-get="{{href}}" hx-target="{{hx_target}}"
+ hx-swap="{{hx_swap}}"
+ {% if hx_push_url %}hx-push-url="true"{% endif %}>
+ {{- content -}}
+ </a>
+ """
+ ),
+ id="no comment",
+ ),
+ pytest.param(
+ dedent(
+ """
+ {# def
+ href,
+ hx_target="#maincontent", # css selector
+ hx_swap="innerHTML show:body:top",
+ hx_push_url=true,
+ #}
+ <a href="{{href}}" hx-get="{{href}}" hx-target="{{hx_target}}"
+ hx-swap="{{hx_swap}}"
+ {% if hx_push_url %}hx-push-url="true"{% endif %}>
+ {{- content -}}
+ </a>
+ """
+ ),
+ id="comment with # on line",
+ ),
+ pytest.param(
+ dedent(
+ """
+ {# def
+ href, # url of the target page
+ hx_target="#maincontent", # css selector
+ hx_swap="innerHTML show:body:top", # browse on top of the page
+ hx_push_url=true, # replace the url of the browser
+ #}
+ <a href="{{href}}" hx-get="{{href}}" hx-target="{{hx_target}}"
+ hx-swap="{{hx_swap}}"
+ {% if hx_push_url %}hx-push-url="true"{% endif %}>
+ {{- content -}}
+ </a>
+ """
+ ),
+ id="many comments",
+ ),
+ pytest.param(
+ dedent(
+ """
+ {# def
+ href: str, # url of the target page
+ hx_target: str = "#maincontent", # css selector
+ hx_swap: str = "innerHTML show:body:top", # browse on top of the page
+ hx_push_url: bool = true, # replace the url
+ #}
+ <a href="{{href}}" hx-get="{{href}}" hx-target="{{hx_target}}"
+ hx-swap="{{hx_swap}}"
+ {% if hx_push_url %}hx-push-url="true"{% endif %}>
+ {{- content -}}
+ </a>
+ """
+ ),
+ id="many comments and typing",
+ ),
+ ],
+)
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_strip_comment(catalog, folder, autoescape, template):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "A.jinja").write_text(template)
+
+ (folder / "Page.jinja").write_text("""<A href="/yolo">Yolo</A>""")
+
+ html = catalog.render("Page")
+ print(html)
+ expected = """
+<a href="/yolo" hx-get="/yolo" hx-target="#maincontent"
+hx-swap="innerHTML show:body:top"
+hx-push-url="true">Yolo</a>""".strip()
+ assert html == Markup(expected)
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_auto_load_assets_with_same_name(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Layout.jinja").write_text(
+ """{{ catalog.render_assets() }}\n{{ content }}"""
+ )
+
+ (folder / "FooBar.css").touch()
+
+ (folder / "common").mkdir()
+ (folder / "common" / "Form.jinja").write_text(
+ """
+{#js "shared.js" #}
+<form></form>"""
+ )
+
+ (folder / "common" / "Form.css").touch()
+ (folder / "common" / "Form.js").touch()
+
+ (folder / "Page.jinja").write_text(
+ """
+{#css "Page.css" #}
+<Layout><common.Form></common.Form></Layout>"""
+ )
+
+ (folder / "Page.css").touch()
+ (folder / "Page.js").touch()
+
+ html = catalog.render("Page")
+ print(html)
+
+ expected = """
+<link rel="stylesheet" href="/static/components/Page.css">
+<link rel="stylesheet" href="/static/components/common/Form.css">
+<script type="module" src="/static/components/Page.js"></script>
+<script type="module" src="/static/components/shared.js"></script>
+<script type="module" src="/static/components/common/Form.js"></script>
+<form></form>
+""".strip()
+
+ assert html == Markup(expected)
+
+
+def test_vue_like_syntax(catalog, folder):
+ (folder / "Test.jinja").write_text("""
+ {#def a, b, c, d #}
+ {{ a }} {{ b }} {{ c }} {{ d }}
+ """)
+ (folder / "Caller.jinja").write_text(
+ """<Test :a="2+2" b="2+2" :c="{'lorem': 'ipsum'}" :d="false" />"""
+ )
+ html = catalog.render("Caller")
+ print(html)
+ expected = """4 2+2 {'lorem': 'ipsum'} False""".strip()
+ assert html == Markup(expected)
+
+
+def test_jinja_like_syntax(catalog, folder):
+ (folder / "Test.jinja").write_text("""
+ {#def a, b, c, d #}
+ {{ a }} {{ b }} {{ c }} {{ d }}
+ """)
+ (folder / "Caller.jinja").write_text(
+ """<Test a={{ 2+2 }} b="2+2" c={{ {'lorem': 'ipsum'} }} d={{ false }} />"""
+ )
+ html = catalog.render("Caller")
+ print(html)
+ expected = """4 2+2 {'lorem': 'ipsum'} False""".strip()
+ assert html == Markup(expected)
+
+
+def test_mixed_syntax(catalog, folder):
+ (folder / "Test.jinja").write_text("""
+ {#def a, b, c, d #}
+ {{ a }} {{ b }} {{ c }} {{ d }}
+ """)
+ (folder / "Caller.jinja").write_text(
+ """<Test :a={{ 2+2 }} b="{{2+2}}" :c={{ {'lorem': 'ipsum'} }} :d={{ false }} />"""
+ )
+ html = catalog.render("Caller")
+ print(html)
+ expected = """4 {{2+2}} {'lorem': 'ipsum'} False""".strip()
+ assert html == Markup(expected)
+
+
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_slots(catalog, folder, autoescape):
+ catalog.jinja_env.autoescape = autoescape
+
+ (folder / "Component.jinja").write_text(
+ """
+<p>{{ content }}</p>
+<p>{{ content("first") }}</p>
+<p>{{ content("second") }}</p>
+<p>{{ content("antoher") }}</p>
+<p>{{ content() }}</p>
+""".strip()
+ )
+
+ (folder / "Messages.jinja").write_text(
+ """
+<Component>
+{% if _slot == "first" %}Hello World
+{%- elif _slot == "second" %}Lorem Ipsum
+{%- elif _slot == "meh" %}QWERTYUIOP
+{%- else %}Default{% endif %}
+</Component>
+""".strip()
+ )
+
+ html = catalog.render("Messages")
+ print(html)
+ expected = """
+<p>Default</p>
+<p>Hello World</p>
+<p>Lorem Ipsum</p>
+<p>Default</p>
+<p>Default</p>
+""".strip()
+ assert html == Markup(expected)