1
0
Fork 0

Adding upstream version 0.45+dfsg.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 18:41:31 +01:00
parent b4efa209be
commit eb42e29864
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
35 changed files with 4489 additions and 0 deletions

37
.github/workflows/run_tests.yml vendored Normal file
View file

@ -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

29
.github/workflows/upload-to-pypi.yml vendored Normal file
View file

@ -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 }}

122
.gitignore vendored Normal file
View file

@ -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/

12
.pre-commit-config.yaml Normal file
View file

@ -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

21
MIT-LICENSE Normal file
View file

@ -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.

37
Makefile Normal file
View file

@ -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

21
README.md Normal file
View file

@ -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)

4
benchmark/Card.jinja Normal file
View file

@ -0,0 +1,4 @@
<section class="card">
{{ content }}
<CloseBtn disabled />
</section>

2
benchmark/CloseBtn.jinja Normal file
View file

@ -0,0 +1,2 @@
{#def disabled=False -#}
<button type="button"{{ " disabled" if disabled else "" }}>&times;</button>

2
benchmark/Greeting.jinja Normal file
View file

@ -0,0 +1,2 @@
{#def message #}
<div class="greeting [&_a]:flex">{{ message }}</div>

13
benchmark/Layout.jinja Normal file
View file

@ -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>

8
benchmark/Real.jinja Normal file
View file

@ -0,0 +1,8 @@
{#def message #}
<Layout title="Hello">
<Card>
<Greeting message={{ message }} />
<button type="button">Close</button>
</Card>
</Layout>

16
benchmark/Simple.jinja Normal file
View file

@ -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>

93
benchmark/benchmark.py Normal file
View file

@ -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()

15
benchmark/hello.html Normal file
View file

@ -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>

29
benchmark/profile.py Normal file
View file

@ -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()

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "jinjax",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

587
poetry.lock generated Normal file
View file

@ -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"

189
pyproject.toml Normal file
View file

@ -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

5
src/jinjax/__init__.py Normal file
View file

@ -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

530
src/jinjax/catalog.py Normal file
View file

@ -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))

258
src/jinjax/component.py Normal file
View file

@ -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}">'

37
src/jinjax/exceptions.py Normal file
View file

@ -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.
"""

348
src/jinjax/html_attrs.py Normal file
View file

@ -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)

161
src/jinjax/jinjax.py Normal file
View file

@ -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

39
src/jinjax/middleware.py Normal file
View file

@ -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)

0
src/jinjax/py.typed Normal file
View file

16
src/jinjax/utils.py Normal file
View file

@ -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

0
tests/__init__.py Normal file
View file

24
tests/conftest.py Normal file
View file

@ -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

88
tests/test_catalog.py Normal file
View file

@ -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)

315
tests/test_component.py Normal file
View file

@ -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 == {}

281
tests/test_html_attrs.py Normal file
View file

@ -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"

152
tests/test_middleware.py Normal file
View file

@ -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"

992
tests/test_render.py Normal file
View file

@ -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)