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