1
0
Fork 0

Merging upstream version 0.53+dfsg.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-03-25 04:36:42 +01:00
parent b13d435d9c
commit be4bb35aeb
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
14 changed files with 764 additions and 526 deletions

4
.gitignore vendored
View file

@ -120,3 +120,7 @@ venv.bak/
# Pyre type checker
.pyre/
# AI
CLAUDE.md

View file

@ -4,7 +4,7 @@ requires = ["setuptools"]
[project]
name = "jinjax"
version = "0.52"
version = "0.53"
description = "Replace your HTML templates with Python server-Side components"
authors = [
{name = "Juan Pablo Scaletti", email = "juanpablo@jpscaletti.com"},

View file

@ -12,7 +12,7 @@ from .component import Component
from .exceptions import ComponentNotFound, InvalidArgument
from .html_attrs import HTMLAttrs
from .jinjax import JinjaX
from .utils import DELIMITER, SLASH, get_random_id, get_url_prefix, logger
from .utils import DELIMITER, SLASH, get_random_id, get_url_prefix, kebab_case, logger
if t.TYPE_CHECKING:
@ -96,9 +96,6 @@ class Catalog:
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.
@ -148,21 +145,19 @@ class Catalog:
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,
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 | list[str] | tuple[str, ...]" = DEFAULT_EXTENSION,
file_ext: str = DEFAULT_EXTENSION,
use_cache: bool = True,
auto_reload: bool = True,
fingerprint: bool = False,
) -> None:
self.prefixes: dict[str, jinja2.FileSystemLoader] = {}
if isinstance(file_ext, list):
file_ext = tuple(file_ext)
self.file_ext = file_ext
self.file_ext = file_ext or DEFAULT_EXTENSION
self.use_cache = use_cache
self.auto_reload = auto_reload
self.fingerprint = fingerprint
@ -203,63 +198,110 @@ class Catalog:
self._key = id(self)
def __del__(self) -> None:
name = f"collected_css_{self._key}"
if name in collected_css:
del collected_css[name]
name = f"collected_js_{self._key}"
if name in collected_js:
del collected_js[name]
# Safely clean up context variables associated with this catalog
try:
key = self._key
# Clean up the collected_css
if key in collected_css:
del collected_css[key]
# Clean up the collected_js
if key in collected_js:
del collected_js[key]
# Clean up the tmpl_globals
if key in tmpl_globals:
del tmpl_globals[key]
except Exception:
# Ignore exceptions during cleanup
pass
@property
def collected_css(self) -> list[str]:
if self._key not in collected_css:
name = f"collected_css_{self._key}"
collected_css[self._key] = ContextVar(name)
collected_css[self._key].set([])
key = self._key
if key not in collected_css:
name = f"collected_css_{key}"
collected_css[key] = ContextVar(name)
value = []
collected_css[key].set(value)
return value
return collected_css[self._key].get([])
try:
# Make a defensive copy to avoid shared references
return list(collected_css[key].get())
except (KeyError, LookupError):
# Handle case where the ContextVar exists but no value was set
value = []
collected_css[key].set(value)
return value
@collected_css.setter
def collected_css(self, value: list[str]) -> None:
if self._key not in collected_css:
name = f"collected_css_{self._key}"
collected_css[self._key] = ContextVar(name)
key = self._key
if key not in collected_css:
name = f"collected_css_{key}"
collected_css[key] = ContextVar(name)
collected_css[self._key].set(list(value))
# Make a defensive copy to avoid shared references
collected_css[key].set(list(value))
@property
def collected_js(self) -> list[str]:
if self._key not in collected_js:
name = f"collected_js_{self._key}"
collected_js[self._key] = ContextVar(name)
collected_js[self._key].set([])
key = self._key
if key not in collected_js:
name = f"collected_js_{key}"
collected_js[key] = ContextVar(name)
value = []
collected_js[key].set(value)
return value
return collected_js[self._key].get([])
try:
# Make a defensive copy to avoid shared references
return list(collected_js[key].get())
except (KeyError, LookupError):
# Handle case where the ContextVar exists but no value was set
value = []
collected_js[key].set(value)
return value
@collected_js.setter
def collected_js(self, value: list[str]) -> None:
if self._key not in collected_js:
name = f"collected_js_{self._key}"
collected_js[self._key] = ContextVar(name)
key = self._key
if key not in collected_js:
name = f"collected_js_{key}"
collected_js[key] = ContextVar(name)
collected_js[self._key].set(list(value))
# Make a defensive copy to avoid shared references
collected_js[key].set(list(value))
@property
def tmpl_globals(self) -> dict[str, t.Any]:
if self._key not in tmpl_globals:
name = f"tmpl_globals_{self._key}"
tmpl_globals[self._key] = ContextVar(name)
tmpl_globals[self._key].set({})
key = self._key
if key not in tmpl_globals:
name = f"tmpl_globals_{key}"
tmpl_globals[key] = ContextVar(name)
value = {}
tmpl_globals[key].set(value)
return value
return tmpl_globals[self._key].get({})
try:
# Make a defensive copy to avoid shared references
return dict(tmpl_globals[key].get())
except (KeyError, LookupError):
# Handle case where the ContextVar exists but no value was set
value = {}
tmpl_globals[key].set(value)
return value
@tmpl_globals.setter
def tmpl_globals(self, value: dict[str, t.Any]) -> None:
if self._key not in tmpl_globals:
name = f"tmpl_globals_{self._key}"
tmpl_globals[self._key] = ContextVar(name)
key = self._key
if key not in tmpl_globals:
name = f"tmpl_globals_{key}"
tmpl_globals[key] = ContextVar(name)
tmpl_globals[self._key].set(dict(value))
# Make a defensive copy to avoid shared references
tmpl_globals[key].set(dict(value))
@property
def paths(self) -> list[Path]:
@ -274,26 +316,12 @@ class Catalog:
def add_folder(
self,
root_path: "str | Path",
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.
Add a folder path from which to search for components, optionally under a prefix.
Arguments:
@ -301,35 +329,47 @@ class Catalog:
Absolute path of the folder with component files.
prefix:
Optional prefix that all the components in the folder will
have. The default is empty.
Optional prefix that all the components in the folder will have.
The default is empty.
The prefix acts like a namespace. For example, the name of a
`Card.jinja` component is, by default, "Card", but under
the prefix "common", it becomes "common.Card".
An important caveat is that when a component under a prefix calls another
component without a prefix, the called component is searched **first**
under the caller's prefix and then under the empty prefix.
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".
The prefixes take precedence over subfolders, so don't create a subfolder with
the same name as a prefix because it will be ignored.
If **under the same prefix** (including the empty one), there are more than one
component with the same name in multiple added folders, the one in the folder
added **first** takes precedence. You can use this to override components loaded
from a library: just add your folder first.
"""
prefix = (
prefix.strip()
.strip(f"{DELIMITER}{SLASH}")
.replace(SLASH, DELIMITER)
)
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}`"
)
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}`"
)
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:
def add_module(self, module: t.Any, *, prefix: str = DEFAULT_PREFIX) -> None:
"""
Reads an absolute path from `module.components_path` and an optional
prefix from `module.prefix`, then calls
DEPRECATED
Reads an absolute path from `module.components_path` and then calls
`Catalog.add_folder(path, prefix)`.
The prefix can also be passed as an argument instead of being read
@ -348,19 +388,14 @@ class Catalog:
might include.
"""
mprefix = (
prefix
if prefix is not None
else getattr(module, "prefix", DEFAULT_PREFIX)
)
self.add_folder(module.components_path, prefix=mprefix)
self.add_folder(module.components_path, prefix=prefix)
def render(
self,
/,
__name: str,
*,
caller: "t.Callable | None" = None,
caller: t.Callable | None = None,
**kw,
) -> str:
"""
@ -371,9 +406,10 @@ class Catalog:
view/controller in your app.
"""
# Clear any existing assets
self.collected_css = []
self.collected_js = []
self.tmpl_globals = kw.pop("__globals", {})
self.tmpl_globals = kw.pop("_globals", kw.pop("__globals", None)) or {}
return self.irender(__name, caller=caller, **kw)
def irender(
@ -381,7 +417,7 @@ class Catalog:
/,
__name: str,
*,
caller: "t.Callable | None" = None,
caller: t.Callable | None = None,
**kw,
) -> str:
"""
@ -394,33 +430,47 @@ class Catalog:
"""
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", ""))
file_ext = kw.pop("_file_ext", kw.pop("__file_ext", "")) or self.file_ext
caller_prefix = kw.pop("__prefix", "")
prefix, name = self._split_name(__name)
self.jinja_env.loader = self.prefixes[prefix]
cname = __name
prefix, name = self._split_name(cname)
component = None
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
)
logger.debug("Rendering from source %s", cname)
self.jinja_env.loader = self.prefixes[prefix]
component = self._get_from_source(prefix=prefix, name=name, source=source)
else:
logger.debug("Rendering from file %s", __name)
component = self._get_from_file(
prefix=prefix, name=name, file_ext=file_ext
logger.debug("Rendering from cache or file %s", cname)
get_from = self._get_from_cache if self.use_cache else self._get_from_file
if caller_prefix:
self.jinja_env.loader = self.prefixes[caller_prefix]
component = get_from(
prefix=caller_prefix,
name=cname,
file_ext=file_ext
)
if not component:
self.jinja_env.loader = self.prefixes[prefix]
component = get_from(
prefix=prefix,
name=name,
file_ext=file_ext
)
if not component:
raise ComponentNotFound(cname, file_ext)
root_path = component.path.parent if component.path else None
# Get current assets lists
css_list = self.collected_css
js_list = self.collected_js
css = self.collected_css
js = self.collected_js
# Process CSS assets
css_to_add = []
for url in component.css:
if (
root_path
@ -429,9 +479,17 @@ class Catalog:
):
url = self._fingerprint(root_path, url)
if url not in css:
css.append(url)
if url not in css_list:
css_to_add.append(url)
# Update CSS assets in one operation if needed
if css_to_add:
new_css = list(css_list)
new_css.extend(css_to_add)
self.collected_css = new_css
# Process JS assets
js_to_add = []
for url in component.js:
if (
root_path
@ -440,8 +498,14 @@ class Catalog:
):
url = self._fingerprint(root_path, url)
if url not in js:
js.append(url)
if url not in js_list:
js_to_add.append(url)
# Update JS assets in one operation if needed
if js_to_add:
new_js = list(js_list)
new_js.extend(js_to_add)
self.collected_js = new_js
attrs = attrs.as_dict if isinstance(attrs, HTMLAttrs) else attrs
attrs.update(kw)
@ -455,13 +519,14 @@ class Catalog:
f"were parsed incorrectly as:\n {str(kw)}"
) from exc
args["__prefix"] = component.prefix
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,
allowed_ext: t.Iterable[str] | None = ALLOWED_EXTENSIONS,
**kwargs,
) -> "ComponentsMiddleware":
"""
@ -502,14 +567,17 @@ class Catalog:
def get_source(
self,
cname: str,
file_ext: "tuple[str, ...] | str" = "",
file_ext: str = "",
) -> str:
"""
A helper method that returns the source file of a component.
"""
file_ext = file_ext or self.file_ext
prefix, name = self._split_name(cname)
path, _ = self._get_component_path(prefix, name, file_ext=file_ext)
return path.read_text()
paths = self._get_component_path(prefix, name, file_ext=file_ext)
if not paths:
raise ComponentNotFound(cname, file_ext)
return paths[0].read_text()
def render_assets(self) -> str:
"""
@ -521,16 +589,29 @@ class Catalog:
"http://" or "https://".
"""
html_css = []
# Use a set to track rendered URLs to avoid duplicates
rendered_urls = set()
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}">')
full_url = f"{self.root_url}{url}"
else:
full_url = url
if full_url not in rendered_urls:
html_css.append(f'<link rel="stylesheet" href="{full_url}">')
rendered_urls.add(full_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>')
full_url = f"{self.root_url}{url}"
else:
full_url = url
if full_url not in rendered_urls:
html_js.append(f'<script type="module" src="{full_url}"></script>')
rendered_urls.add(full_url)
return Markup("\n".join(html_css + html_js))
@ -555,14 +636,12 @@ class Catalog:
def _get_from_source(
self,
*,
name: str,
prefix: str,
name: 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
)
component = Component(prefix=prefix, name=name, source=source, tmpl=tmpl)
return component
def _get_from_cache(
@ -571,9 +650,10 @@ class Catalog:
prefix: str,
name: str,
file_ext: str,
) -> Component:
key = f"{prefix}.{name}.{file_ext}"
) -> Component | None:
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
@ -582,9 +662,9 @@ class Catalog:
return component
logger.debug("Loading %s", key)
component = self._get_from_file(
prefix=prefix, name=name, file_ext=file_ext
)
component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext)
if not component:
return
self._to_cache(key, component)
return component
@ -598,16 +678,13 @@ class Catalog:
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
)
def _get_from_file(self, *, prefix: str, name: str, file_ext: str) -> Component | None:
paths = self._get_component_path(prefix, name, file_ext=file_ext)
if not paths:
return
path, relpath = paths
component = Component(name=name, prefix=prefix, path=path, relpath=relpath)
component.tmpl = self.jinja_env.get_template(str(relpath), globals=self.tmpl_globals)
return component
def _split_name(self, cname: str) -> tuple[str, str]:
@ -624,19 +701,22 @@ class Catalog:
self,
prefix: str,
name: str,
file_ext: "tuple[str, ...] | str" = "",
) -> tuple[Path, str]:
name = name.replace(DELIMITER, SLASH)
file_ext: str,
) -> tuple[Path, Path] | None:
root_paths = self.prefixes[prefix].searchpath
name_dot = f"{name}."
file_ext = file_ext or self.file_ext
name = name.replace(DELIMITER, SLASH)
name_path = Path(name)
kebab_stem = kebab_case(name_path.stem)
kebab_name = str(name_path.with_name(kebab_stem))
dot_names = (f"{name}.", f"{kebab_name}.")
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):
if relfolder and not name.startswith(relfolder):
continue
for filename in files:
@ -644,16 +724,8 @@ class Catalog:
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}"
)
if filepath.startswith(dot_names) and filepath.endswith(file_ext):
return Path(curr_folder) / filename, Path(filepath)
def _render_attrs(self, attrs: dict[str, t.Any]) -> Markup:
html_attrs = []

View file

@ -12,7 +12,7 @@ from .exceptions import (
InvalidArgument,
MissingRequiredArgument,
)
from .utils import DELIMITER, get_url_prefix
from .utils import get_url_prefix
if t.TYPE_CHECKING:
@ -73,6 +73,7 @@ class Component:
"css",
"js",
"path",
"relpath",
"mtime",
"tmpl",
)
@ -87,6 +88,7 @@ class Component:
mtime: float = 0,
tmpl: "Template | None" = None,
path: "Path | None" = None,
relpath: "Path | None" = None,
) -> None:
self.name = name
self.prefix = prefix
@ -102,18 +104,17 @@ class Component:
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 is not None and relpath is not None:
default_css = str(relpath.with_suffix(".css"))
if (path.with_suffix(".css")).is_file():
self.css.extend(self.parse_files_expr(default_css))
default_js = f"{default_name}.js"
default_js = str(relpath.with_suffix(".js"))
if (path.with_suffix(".js")).is_file():
self.js.extend(self.parse_files_expr(default_js))
self.path = path
self.relpath = relpath
self.mtime = mtime
self.tmpl = tmpl

View file

@ -4,8 +4,8 @@ class ComponentNotFound(Exception):
added folders, probably because of a typo.
"""
def __init__(self, name: str) -> None:
msg = f"File with pattern `{name}` not found"
def __init__(self, name: str, file_ext: str) -> None:
msg = f"Unable to find component `{name}` with file extension `*{file_ext}`"
super().__init__(msg)

View file

@ -68,6 +68,8 @@ class HTMLAttrs:
self.__classes = {name for name in class_names if name}
for name, value in attrs.items():
if name.startswith("__"):
continue
name = name.replace("_", "-")
if value is True:
properties.add(name)

View file

@ -10,9 +10,9 @@ from .utils import logger
RENDER_CMD = "catalog.irender"
BLOCK_CALL = '{% call(_slot="") [CMD]("[TAG]"[ATTRS]) -%}[CONTENT]{%- endcall %}'
BLOCK_CALL = '{% call(_slot="") [CMD]("[TAG]", __prefix=__prefix[ATTRS]) -%}[CONTENT]{%- endcall %}'
BLOCK_CALL = BLOCK_CALL.replace("[CMD]", RENDER_CMD)
INLINE_CALL = '{{ [CMD]("[TAG]"[ATTRS]) }}'
INLINE_CALL = '{{ [CMD]("[TAG]", __prefix=__prefix[ATTRS]) }}'
INLINE_CALL = INLINE_CALL.replace("[CMD]", RENDER_CMD)
re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"

View file

@ -1,4 +1,5 @@
import logging
import re
import uuid
@ -9,9 +10,7 @@ SLASH = "/"
def get_url_prefix(prefix: str) -> str:
url_prefix = (
prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(DELIMITER, SLASH)
)
url_prefix = prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(DELIMITER, SLASH)
if url_prefix:
url_prefix = f"{url_prefix}{SLASH}"
return url_prefix
@ -19,3 +18,23 @@ def get_url_prefix(prefix: str) -> str:
def get_random_id(prefix="id") -> str:
return f"{prefix}-{str(uuid.uuid4().hex)}"
def kebab_case(word: str) -> str:
"""Returns the lowercased kebab-cases form of `word`.
Returns the right result even whith acronyms::
>>> kebab_case("DeviceType")
'device-type'
>>> kebab_case("IOError")
'io-error'
>>> kebab_case("HTML")
'html'
>>> kebab_case("ui.AwesomeDialog")
'ui.awesome-dialog'
"""
word = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", word)
word = re.sub(r"([a-z\d])([A-Z])", r"\1-\2", word)
word = word.replace("_", "-")
return word.lower()

View file

@ -45,11 +45,10 @@ def test_add_same_folder_in_same_prefix_does_nothing():
def test_add_module_legacy():
class Module:
components_path = "legacy_path"
prefix = "legacy"
catalog = jinjax.Catalog()
module = Module()
catalog.add_module(module)
catalog.add_module(module, prefix="legacy")
assert "legacy_path" in catalog.prefixes["legacy"].searchpath
@ -65,19 +64,6 @@ def test_add_module_legacy_with_default_prefix():
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

80
tests/test_prefix.py Normal file
View file

@ -0,0 +1,80 @@
import pytest
from markupsafe import Markup
@pytest.mark.parametrize("autoescape", [True, False])
def test_prefix_namespace(catalog, folder, folder_t, autoescape):
"""Components mounted with a prefix should be able to import other components
from the same folder without specifying the prefix.
"""
catalog.jinja_env.autoescape = autoescape
catalog.add_folder(folder_t, prefix="ui")
(folder / "Title.jinja").write_text("parent")
(folder_t / "Title.jinja").write_text("prefix")
(folder_t / "Alert.jinja").write_text("<Title />")
html = catalog.render("ui.Alert")
assert html == Markup("prefix")
@pytest.mark.parametrize("autoescape", [True, False])
def test_prefix_namespace_sub(catalog, folder, folder_t, autoescape):
"""Components mounted with a prefix should be able to import other components
from the same folder without specifying the prefix, even if those components
are in a subfolder.
"""
catalog.jinja_env.autoescape = autoescape
catalog.add_folder(folder_t, prefix="ui")
(folder / "sub").mkdir()
(folder_t / "sub").mkdir()
(folder / "Title.jinja").write_text("parent")
(folder / "sub" / "Title.jinja").write_text("sub/parent")
(folder_t / "Title.jinja").write_text("sub")
(folder_t / "sub" / "Title.jinja").write_text("sub/prefix")
(folder_t / "Alert.jinja").write_text("<sub.Title />")
html = catalog.render("ui.Alert")
assert html == Markup("sub/prefix")
@pytest.mark.parametrize("autoescape", [True, False])
def test_prefix_fallback(catalog, folder, folder_t, autoescape):
"""If a component is not found in the folder with the prefix, it should
fallback to the no-prefix folders.
"""
catalog.jinja_env.autoescape = autoescape
catalog.add_folder(folder_t, prefix="ui")
(folder / "Title.jinja").write_text("parent")
(folder_t / "Alert.jinja").write_text("<Title />")
html = catalog.render("ui.Alert")
assert html == Markup("parent")
@pytest.mark.parametrize("autoescape", [True, False])
def test_prefix_namespace_assets(catalog, folder, folder_t, autoescape):
"""Components import without specifying the prefix should also be
able to auto-import their assets.
"""
catalog.jinja_env.autoescape = autoescape
catalog.add_folder(folder_t, prefix="ui")
(folder_t / "Title.jinja").write_text("prefix")
(folder_t / "Title.css").touch()
(folder_t / "Layout.jinja").write_text("""
{{ catalog.render_assets() }}
{{ content }}
""")
(folder_t / "Alert.jinja").write_text("<Layout><Title /></Layout>")
html = catalog.render("ui.Alert")
assert html == Markup("""
<link rel="stylesheet" href="/static/components/ui/Title.css">
prefix
""".strip())

View file

@ -1,7 +1,5 @@
import time
from pathlib import Path
from textwrap import dedent
from threading import Thread
import jinja2
import pytest
@ -183,74 +181,6 @@ def test_just_properties(catalog, folder, autoescape):
)
@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
@ -280,11 +210,14 @@ def test_required_attr_are_required(catalog, folder, autoescape):
@pytest.mark.parametrize("autoescape", [True, False])
def test_subfolder(catalog, folder, autoescape):
"""Components can be organized in subfolders and called
using the dot notation.
"""
catalog.jinja_env.autoescape = autoescape
sub = folder / "UI"
sub = folder / "ui"
sub.mkdir()
(folder / "Meh.jinja").write_text("<UI.Tab>Meh</UI.Tab>")
(folder / "Meh.jinja").write_text("<ui.Tab>Meh</ui.Tab>")
(sub / "Tab.jinja").write_text('<div class="tab">{{ content }}</div>')
html = catalog.render("Meh")
@ -437,56 +370,6 @@ def test_dict_as_attr(catalog, folder, autoescape):
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"""
@ -637,34 +520,6 @@ def test_subcomponents(catalog, folder, autoescape):
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
@ -703,7 +558,7 @@ def test_template_globals(catalog, folder, autoescape):
<Form><Input name="foo" :value="value"/></Form>
""")
html = catalog.render("Page", value="bar", __globals={"csrf_token": "abc"})
html = catalog.render("Page", value="bar", _globals={"csrf_token": "abc"})
print(html)
assert """<input type="hidden" name="csrft" value="abc">""" in html
@ -717,11 +572,11 @@ def test_template_globals_update_cache(catalog, folder, autoescape):
)
(folder / "Page.jinja").write_text("""<CsrfToken/>""")
html = catalog.render("Page", __globals={"csrf_token": "abc"})
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"})
html = catalog.render("Page", _globals={"csrf_token": "xyz"})
print(html)
assert """<input type="hidden" name="csrft" value="xyz">""" in html
@ -870,50 +725,6 @@ 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 #}
@ -993,149 +804,13 @@ def test_slots(catalog, folder, autoescape):
assert html == Markup(expected)
class ThreadWithReturnValue(Thread):
def __init__(self, group=None, target=None, name=None, args=None, kwargs=None):
args = args or ()
kwargs = kwargs or {}
Thread.__init__(
self,
group=group,
target=target,
name=name,
args=args,
kwargs=kwargs,
)
self._target = target
self._args = args
self._kwargs = kwargs
self._return = None
@pytest.mark.parametrize("autoescape", [True, False])
def test_alt_cased_component_names(catalog, folder, autoescape):
(folder / "a_tricky-FOLDER").mkdir()
catalog.jinja_env.autoescape = autoescape
def run(self):
if self._target is not None:
self._return = self._target(*self._args, **self._kwargs)
def join(self, *args):
Thread.join(self, *args)
return self._return
def test_thread_safety_of_render_assets(catalog, folder):
NUM_THREADS = 5
child_tmpl = """
{#css "c{i}.css" #}
{#js "c{i}.js" #}
<p>Child {i}</p>""".strip()
parent_tmpl = """
{{ catalog.render_assets() }}
{{ content }}""".strip()
comp_tmpl = """
{#css "a{i}.css", "b{i}.css" #}
{#js "a{i}.js", "b{i}.js" #}
<Parent{i}><Child{i} /></Parent{i}>""".strip()
expected_tmpl = """
<link rel="stylesheet" href="/static/components/a{i}.css">
<link rel="stylesheet" href="/static/components/b{i}.css">
<link rel="stylesheet" href="/static/components/c{i}.css">
<script type="module" src="/static/components/a{i}.js"></script>
<script type="module" src="/static/components/b{i}.js"></script>
<script type="module" src="/static/components/c{i}.js"></script>
<p>Child {i}</p>""".strip()
def render(i):
return catalog.render(f"Page{i}")
for i in range(NUM_THREADS):
si = str(i)
child_name = f"Child{i}.jinja"
child_src = child_tmpl.replace("{i}", si)
parent_name = f"Parent{i}.jinja"
parent_src = parent_tmpl.replace("{i}", si)
comp_name = f"Page{i}.jinja"
comp_src = comp_tmpl.replace("{i}", si)
(folder / child_name).write_text(child_src)
(folder / comp_name).write_text(comp_src)
(folder / parent_name).write_text(parent_src)
threads = []
for i in range(NUM_THREADS):
thread = ThreadWithReturnValue(target=render, args=(i,))
threads.append(thread)
thread.start()
results = [thread.join() for thread in threads]
for i, result in enumerate(results):
expected = expected_tmpl.replace("{i}", str(i))
print(f"---- EXPECTED {i}----")
print(expected)
print(f"---- RESULT {i}----")
print(result)
assert result == Markup(expected)
def test_same_thread_assets_independence(catalog, folder):
catalog2 = jinjax.Catalog()
catalog2.add_folder(folder)
print(catalog._key)
print(catalog2._key)
(folder / "Parent.jinja").write_text("""
{{ catalog.render_assets() }}
{{ content }}""".strip())
(folder / "Comp1.jinja").write_text("""
{#css "a.css" #}
{#js "a.js" #}
<Parent />""".strip())
(folder / "Comp2.jinja").write_text("""
{#css "b.css" #}
{#js "b.js" #}
<Parent />""".strip())
expected_1 = """
<link rel="stylesheet" href="/static/components/a.css">
<script type="module" src="/static/components/a.js"></script>""".strip()
expected_2 = """
<link rel="stylesheet" href="/static/components/b.css">
<script type="module" src="/static/components/b.js"></script>""".strip()
html1 = catalog.render("Comp1")
# `irender` instead of `render` so the assets are not cleared
html2 = catalog2.irender("Comp2")
print(html1)
print(html2)
assert html1 == Markup(expected_1)
assert html2 == Markup(expected_2)
def test_thread_safety_of_template_globals(catalog, folder):
NUM_THREADS = 5
(folder / "Page.jinja").write_text("{{ globalvar if globalvar is defined else 'not set' }}")
def render(i):
return catalog.render("Page", __globals={"globalvar": i})
threads = []
for i in range(NUM_THREADS):
thread = ThreadWithReturnValue(target=render, args=(i,))
threads.append(thread)
thread.start()
results = [thread.join() for thread in threads]
for i, result in enumerate(results):
assert result == Markup(str(i))
(folder / "kebab-cased.jinja").write_text("kebab")
(folder / "a_tricky-FOLDER" / "Greeting.jinja").write_text("pascal")
assert catalog.render("KebabCased") == Markup("kebab")
assert catalog.render("a_tricky-FOLDER.Greeting") == Markup("pascal")

214
tests/test_render_assets.py Normal file
View file

@ -0,0 +1,214 @@
from pathlib import Path
import pytest
from markupsafe import Markup
@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_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_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_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)
@pytest.mark.parametrize("autoescape", [True, False])
def test_auto_load_assets_for_kebab_cased_names(catalog, folder, autoescape):
catalog.jinja_env.autoescape = autoescape
(folder / "Layout.jinja").write_text(
"""{{ catalog.render_assets() }}\n{{ content }}"""
)
(folder / "my-component.jinja").write_text("")
(folder / "my-component.css").touch()
(folder / "my-component.js").touch()
(folder / "page.jinja").write_text("<Layout><MyComponent /></Layout>")
html = catalog.render("Page")
print(html)
assert "/static/components/my-component.css" in html
assert "/static/components/my-component.js" in html

187
tests/test_thread_safety.py Normal file
View file

@ -0,0 +1,187 @@
from threading import Thread
from markupsafe import Markup
import jinjax
class ThreadWithReturnValue(Thread):
def __init__(self, group=None, target=None, name=None, args=None, kwargs=None):
args = args or ()
kwargs = kwargs or {}
Thread.__init__(
self,
group=group,
target=target,
name=name,
args=args,
kwargs=kwargs,
)
self._target = target
self._args = args
self._kwargs = kwargs
self._return = None
def run(self):
if self._target is not None:
self._return = self._target(*self._args, **self._kwargs)
def join(self, *args):
Thread.join(self, *args)
return self._return
def test_thread_safety_of_render_assets(catalog, folder):
NUM_THREADS = 5
child_tmpl = """
{#css "c{i}.css" #}
{#js "c{i}.js" #}
<p>Child {i}</p>""".strip()
parent_tmpl = """
{{ catalog.render_assets() }}
{{ content }}""".strip()
comp_tmpl = """
{#css "a{i}.css", "b{i}.css" #}
{#js "a{i}.js", "b{i}.js" #}
<Parent{i}><Child{i} /></Parent{i}>""".strip()
expected_tmpl = """
<link rel="stylesheet" href="/static/components/a{i}.css">
<link rel="stylesheet" href="/static/components/b{i}.css">
<link rel="stylesheet" href="/static/components/c{i}.css">
<script type="module" src="/static/components/a{i}.js"></script>
<script type="module" src="/static/components/b{i}.js"></script>
<script type="module" src="/static/components/c{i}.js"></script>
<p>Child {i}</p>""".strip()
def render(i):
return catalog.render(f"Page{i}")
for i in range(NUM_THREADS):
si = str(i)
child_name = f"Child{i}.jinja"
child_src = child_tmpl.replace("{i}", si)
parent_name = f"Parent{i}.jinja"
parent_src = parent_tmpl.replace("{i}", si)
comp_name = f"Page{i}.jinja"
comp_src = comp_tmpl.replace("{i}", si)
(folder / child_name).write_text(child_src)
(folder / comp_name).write_text(comp_src)
(folder / parent_name).write_text(parent_src)
threads = []
for i in range(NUM_THREADS):
thread = ThreadWithReturnValue(target=render, args=(i,))
threads.append(thread)
thread.start()
results = [thread.join() for thread in threads]
for i, result in enumerate(results):
expected = expected_tmpl.replace("{i}", str(i))
print(f"---- EXPECTED {i}----")
print(expected)
print(f"---- RESULT {i}----")
print(result)
assert result == Markup(expected)
def test_same_thread_assets_independence(catalog, folder):
catalog2 = jinjax.Catalog()
catalog2.add_folder(folder)
print("Catalog1 key:", catalog._key)
print("Catalog2 key:", catalog2._key)
# Check if the context variables exist before the test
print("Before any rendering:")
print("Catalog1 in collected_css:", catalog._key in jinjax.catalog.collected_css)
print("Catalog2 in collected_css:", catalog2._key in jinjax.catalog.collected_css)
print("collected_css keys:", list(jinjax.catalog.collected_css.keys()))
print("collected_js keys:", list(jinjax.catalog.collected_js.keys()))
(folder / "Parent.jinja").write_text(
"""
{{ catalog.render_assets() }}
{{ content }}""".strip()
)
(folder / "Comp1.jinja").write_text(
"""
{#css "a.css" #}
{#js "a.js" #}
<Parent />""".strip()
)
(folder / "Comp2.jinja").write_text(
"""
{#css "b.css" #}
{#js "b.js" #}
<Parent />""".strip()
)
expected_1 = """
<link rel="stylesheet" href="/static/components/a.css">
<script type="module" src="/static/components/a.js"></script>""".strip()
expected_2 = """
<link rel="stylesheet" href="/static/components/b.css">
<script type="module" src="/static/components/b.js"></script>""".strip()
# Render first component with first catalog
html1 = catalog.render("Comp1")
# Check context variables after first render
print("\nAfter first render:")
print("Catalog1 collected_css:", catalog.collected_css)
print("Catalog2 collected_css:", catalog2.collected_css)
print("Catalog1 in collected_css:", catalog._key in jinjax.catalog.collected_css)
print("Catalog2 in collected_css:", catalog2._key in jinjax.catalog.collected_css)
print("collected_css keys:", list(jinjax.catalog.collected_css.keys()))
# `irender` instead of `render` so the assets are not cleared
html2 = catalog2.irender("Comp2")
# Check context variables after second render
print("\nAfter second render:")
print("Catalog1 collected_css:", catalog.collected_css)
print("Catalog2 collected_css:", catalog2.collected_css)
print("Catalog1 in collected_css:", catalog._key in jinjax.catalog.collected_css)
print("Catalog2 in collected_css:", catalog2._key in jinjax.catalog.collected_css)
print("collected_css keys:", list(jinjax.catalog.collected_css.keys()))
print("\nHTML outputs:")
print("HTML1:", html1)
print("HTML2:", html2)
assert html1 == Markup(expected_1)
assert html2 == Markup(expected_2)
def test_thread_safety_of_template_globals(catalog, folder):
NUM_THREADS = 5
(folder / "Page.jinja").write_text(
"{{ globalvar if globalvar is defined else 'not set' }}"
)
def render(i):
return catalog.render("Page", _globals={"globalvar": i})
threads = []
for i in range(NUM_THREADS):
thread = ThreadWithReturnValue(target=render, args=(i,))
threads.append(thread)
thread.start()
results = [thread.join() for thread in threads]
for i, result in enumerate(results):
assert result == Markup(str(i))

4
uv.lock generated
View file

@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.11, <4"
[[package]]
@ -215,7 +214,7 @@ wheels = [
[[package]]
name = "jinjax"
version = "0.52"
version = "0.53"
source = { editable = "." }
dependencies = [
{ name = "jinja2" },
@ -248,7 +247,6 @@ requires-dist = [
{ name = "markupsafe", specifier = ">=2.0" },
{ name = "whitenoise", marker = "extra == 'whitenoise'" },
]
provides-extras = ["whitenoise"]
[package.metadata.requires-dev]
dev = [