Merging upstream version 0.53+dfsg.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
b13d435d9c
commit
be4bb35aeb
14 changed files with 764 additions and 526 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -120,3 +120,7 @@ venv.bak/
|
|||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# AI
|
||||
CLAUDE.md
|
||||
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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*-?%\}"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
80
tests/test_prefix.py
Normal 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())
|
|
@ -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
214
tests/test_render_assets.py
Normal 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
187
tests/test_thread_safety.py
Normal 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
4
uv.lock
generated
|
@ -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 = [
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue