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