diff --git a/.gitignore b/.gitignore index 3f7fb77..6a33b30 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,7 @@ venv.bak/ # Pyre type checker .pyre/ + +# AI +CLAUDE.md + diff --git a/pyproject.toml b/pyproject.toml index 083f72f..6fe7e7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}, diff --git a/src/jinjax/catalog.py b/src/jinjax/catalog.py index 050e73c..367ef11 100644 --- a/src/jinjax/catalog.py +++ b/src/jinjax/catalog.py @@ -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'') + full_url = f"{self.root_url}{url}" + else: + full_url = url + + if full_url not in rendered_urls: + html_css.append(f'') + 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'') + full_url = f"{self.root_url}{url}" + else: + full_url = url + + if full_url not in rendered_urls: + html_js.append(f'') + 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 = [] diff --git a/src/jinjax/component.py b/src/jinjax/component.py index e4443be..78795bd 100644 --- a/src/jinjax/component.py +++ b/src/jinjax/component.py @@ -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 diff --git a/src/jinjax/exceptions.py b/src/jinjax/exceptions.py index b1fd97f..9624076 100644 --- a/src/jinjax/exceptions.py +++ b/src/jinjax/exceptions.py @@ -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) diff --git a/src/jinjax/html_attrs.py b/src/jinjax/html_attrs.py index 8b4bd35..7f0f47a 100644 --- a/src/jinjax/html_attrs.py +++ b/src/jinjax/html_attrs.py @@ -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) diff --git a/src/jinjax/jinjax.py b/src/jinjax/jinjax.py index 27d5feb..c4966ae 100644 --- a/src/jinjax/jinjax.py +++ b/src/jinjax/jinjax.py @@ -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*-?%\}" diff --git a/src/jinjax/utils.py b/src/jinjax/utils.py index 6f4d1cc..26270eb 100644 --- a/src/jinjax/utils.py +++ b/src/jinjax/utils.py @@ -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() diff --git a/tests/test_catalog.py b/tests/test_catalog.py index d3c4bc0..d74e0dc 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -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 diff --git a/tests/test_prefix.py b/tests/test_prefix.py new file mode 100644 index 0000000..379174f --- /dev/null +++ b/tests/test_prefix.py @@ -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("") + + 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()) diff --git a/tests/test_render.py b/tests/test_render.py index 8338a55..b8b92d9 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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") diff --git a/tests/test_render_assets.py b/tests/test_render_assets.py new file mode 100644 index 0000000..e4730d1 --- /dev/null +++ b/tests/test_render_assets.py @@ -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 diff --git a/tests/test_thread_safety.py b/tests/test_thread_safety.py new file mode 100644 index 0000000..5443e8c --- /dev/null +++ b/tests/test_thread_safety.py @@ -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)) diff --git a/uv.lock b/uv.lock index 4e90638..1aa19f4 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [