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("
Lima, Peru
New York, USA
") -@pytest.mark.parametrize("autoescape", [True, False]) -def test_cleanup_assets(catalog, folder, autoescape): - catalog.jinja_env.autoescape = autoescape - - (folder / "Layout.jinja").write_text(""" - -{{ catalog.render_assets() }} -{{ content }} - -""") - - (folder / "Foo.jinja").write_text(""" -{#js foo.js #} -Foo
-Bar
-Foo
- -""".strip() - in html - ) - - html = catalog.render("Bar") - print(html) - assert ( - """ - - -Bar
- -""".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(""" - -{{ catalog.render_assets() }} -{{ content }} - -""") - - (folder / "Page.jinja").write_text(""" -{#css app.css, http://example.com/super.css #} -{#js app.js #} -Child {i}
""".strip() - - parent_tmpl = """ -{{ catalog.render_assets() }} -{{ content }}""".strip() - - comp_tmpl = """ -{#css "a{i}.css", "b{i}.css" #} -{#js "a{i}.js", "b{i}.js" #} -Child {i}
""".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" #} -Foo
+Bar
+Foo
+ +""".strip() + in html + ) + + html = catalog.render("Bar") + print(html) + assert ( + """ + + +Bar
+ +""".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(""" + +{{ catalog.render_assets() }} +{{ content }} + +""") + + (folder / "Page.jinja").write_text(""" +{#css app.css, http://example.com/super.css #} +{#js app.js #} +Child {i}
""".strip() + + parent_tmpl = """ +{{ catalog.render_assets() }} +{{ content }}""".strip() + + comp_tmpl = """ +{#css "a{i}.css", "b{i}.css" #} +{#js "a{i}.js", "b{i}.js" #} +Child {i}
""".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" #} +