diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-08-21 07:51:15 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-08-21 07:51:15 +0000 |
commit | d8409535af8f9e9944c63c73f33bc7cc9577ccfb (patch) | |
tree | 5d3adfdc0309ee50bd74d974d3488ea6ba5b8229 /src | |
parent | Initial commit. (diff) | |
download | jinjax-d8409535af8f9e9944c63c73f33bc7cc9577ccfb.tar.xz jinjax-d8409535af8f9e9944c63c73f33bc7cc9577ccfb.zip |
Adding upstream version 0.45+dfsg.upstream/0.45+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src')
-rw-r--r-- | src/jinjax/__init__.py | 5 | ||||
-rw-r--r-- | src/jinjax/catalog.py | 530 | ||||
-rw-r--r-- | src/jinjax/component.py | 258 | ||||
-rw-r--r-- | src/jinjax/exceptions.py | 37 | ||||
-rw-r--r-- | src/jinjax/html_attrs.py | 348 | ||||
-rw-r--r-- | src/jinjax/jinjax.py | 161 | ||||
-rw-r--r-- | src/jinjax/middleware.py | 39 | ||||
-rw-r--r-- | src/jinjax/py.typed | 0 | ||||
-rw-r--r-- | src/jinjax/utils.py | 16 |
9 files changed, 1394 insertions, 0 deletions
diff --git a/src/jinjax/__init__.py b/src/jinjax/__init__.py new file mode 100644 index 0000000..e30f39b --- /dev/null +++ b/src/jinjax/__init__.py @@ -0,0 +1,5 @@ +from .catalog import Catalog # noqa +from .component import Component # noqa +from .exceptions import * # noqa +from .jinjax import JinjaX # noqa +from .html_attrs import HTMLAttrs, LazyString # noqa diff --git a/src/jinjax/catalog.py b/src/jinjax/catalog.py new file mode 100644 index 0000000..c70f81d --- /dev/null +++ b/src/jinjax/catalog.py @@ -0,0 +1,530 @@ +import os +import typing as t +from collections import UserString +from hashlib import sha256 +from pathlib import Path + +import jinja2 +from markupsafe import Markup + +from .component import Component +from .exceptions import ComponentNotFound, InvalidArgument +from .html_attrs import HTMLAttrs +from .jinjax import JinjaX +from .middleware import ComponentsMiddleware +from .utils import DELIMITER, SLASH, get_url_prefix, logger + + +DEFAULT_URL_ROOT = "/static/components/" +ALLOWED_EXTENSIONS = (".css", ".js", ".mjs") +DEFAULT_PREFIX = "" +DEFAULT_EXTENSION = ".jinja" +ARGS_ATTRS = "attrs" +ARGS_CONTENT = "content" + + +class CallerWrapper(UserString): + def __init__(self, caller: t.Callable | None, content: str = "") -> None: + self._caller = caller + # Pre-calculate the defaut content so the assets are loaded + self._content = caller("") if caller else Markup(content) + + def __call__(self, slot: str = "") -> str: + if slot and self._caller: + return self._caller(slot) + return self._content + + def __html__(self) -> str: + return self() + + @property + def data(self) -> str: # type: ignore + return self() + + + +class Catalog: + """ + The object that manages the components and their global settings. + + Arguments: + + globals: + Dictionary of Jinja globals to add to the Catalog's Jinja environment + (or the one passed in `jinja_env`). + + filters: + Dictionary of Jinja filters to add to the Catalog's Jinja environment + (or the one passed in `jinja_env`). + + tests: + Dictionary of Jinja tests to add to the Catalog's Jinja environment + (or the one passed in `jinja_env`). + + extensions: + List of Jinja extensions to add to the Catalog's Jinja environment + (or the one passed in `jinja_env`). The `jinja2.ext.do` extension is + always added at the end of these. + + jinja_env: + Custom Jinja environment to use. This argument is useful to reuse an + existing Jinja Environment from your web framework. + + root_url: + Add this prefix to every asset URL of the static middleware. By default, + it is `/static/components/`, so, for example, the URL of the CSS file of + a `Card` component is `/static/components/Card.css`. + + You can also change this argument so the assets are requested from a + Content Delivery Network (CDN) in production, for example, + `root_url="https://example.my-cdn.com/"`. + + file_ext: + The extensions the components files have. By default, ".jinja". + + This argument can also be a list to allow more than one type of file to + be a component. + + use_cache: + Cache the metadata of the component in memory. + + auto_reload: + Used with `use_cache`. If `True`, the last-modified date of the component + file is checked every time to see if the cache is up-to-date. + + Set to `False` in production. + + fingerprint: + If `True`, inserts a hash of the updated time into the URL of the + asset files (after the name but before the extension). + + This strategy encourages long-term caching while ensuring that new copies + are only requested when the content changes, as any modification alters the + fingerprint and thus the filename. + + **WARNING**: Only works if the server knows how to filter the fingerprint + to get the real name of the file. + + Attributes: + + collected_css: + List of CSS paths collected during a render. + + collected_js: + List of JS paths collected during a render. + + prefixes: + Mapping between folder prefixes and the Jinja loader that uses. + + """ + + __slots__ = ( + "prefixes", + "root_url", + "file_ext", + "jinja_env", + "fingerprint", + "collected_css", + "collected_js", + "auto_reload", + "use_cache", + "_tmpl_globals", + "_cache", + ) + + def __init__( + self, + *, + globals: "dict[str, t.Any] | None" = None, + filters: "dict[str, t.Any] | None" = None, + tests: "dict[str, t.Any] | None" = None, + extensions: "list | None" = None, + jinja_env: "jinja2.Environment | None" = None, + root_url: str = DEFAULT_URL_ROOT, + file_ext: "str | tuple[str, ...]" = DEFAULT_EXTENSION, + use_cache: bool = True, + auto_reload: bool = True, + fingerprint: bool = False, + ) -> None: + self.prefixes: dict[str, jinja2.FileSystemLoader] = {} + self.collected_css: list[str] = [] + self.collected_js: list[str] = [] + self.file_ext = file_ext + self.use_cache = use_cache + self.auto_reload = auto_reload + self.fingerprint = fingerprint + + root_url = root_url.strip().rstrip(SLASH) + self.root_url = f"{root_url}{SLASH}" + + env = jinja2.Environment(undefined=jinja2.StrictUndefined) + extensions = [*(extensions or []), "jinja2.ext.do", JinjaX] + globals = globals or {} + filters = filters or {} + tests = tests or {} + + if jinja_env: + env.extensions.update(jinja_env.extensions) + env.autoescape = jinja_env.autoescape + globals.update(jinja_env.globals) + filters.update(jinja_env.filters) + tests.update(jinja_env.tests) + jinja_env.globals["catalog"] = self + jinja_env.filters["catalog"] = self + + globals["catalog"] = self + filters["catalog"] = self + + for ext in extensions: + env.add_extension(ext) + env.globals.update(globals) + env.filters.update(filters) + env.tests.update(tests) + env.extend(catalog=self) + + self.jinja_env = env + + self._tmpl_globals: "t.MutableMapping[str, t.Any] | None" = None + self._cache: dict[str, dict] = {} + + @property + def paths(self) -> list[Path]: + """ + A helper property that returns a list of all the components folder paths. + """ + _paths = [] + for loader in self.prefixes.values(): + _paths.extend(loader.searchpath) + return _paths + + def add_folder( + self, + root_path: "str | Path", + *, + prefix: str = DEFAULT_PREFIX, + ) -> None: + """ + Add a folder path from where to search for components, optionally under a prefix. + + The prefix acts like a namespace. For example, the name of a + `components/Card.jinja` component is, by default, "Card", + but under the prefix "common", it becomes "common.Card". + + The rule for subfolders remains the same: a `components/wrappers/Card.jinja` + name is, by default, "wrappers.Card", but under the prefix "common", + it becomes "common.wrappers.Card". + + If there is more than one component with the same name in multiple + added folders under the same prefix, the one in the folder added + first takes precedence. + + Arguments: + + root_path: + Absolute path of the folder with component files. + + prefix: + Optional prefix that all the components in the folder will + have. The default is empty. + + """ + prefix = prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(SLASH, DELIMITER) + + root_path = str(root_path) + if prefix in self.prefixes: + loader = self.prefixes[prefix] + if root_path in loader.searchpath: + return + logger.debug(f"Adding folder `{root_path}` with the prefix `{prefix}`") + loader.searchpath.append(root_path) + else: + logger.debug(f"Adding folder `{root_path}` with the prefix `{prefix}`") + self.prefixes[prefix] = jinja2.FileSystemLoader(root_path) + + def add_module(self, module: t.Any, *, prefix: str | None = None) -> None: + """ + Reads an absolute path from `module.components_path` and an optional prefix + from `module.prefix`, then calls `Catalog.add_folder(path, prefix)`. + + The prefix can also be passed as an argument instead of being read from + the module. + + This method exists to make it easy and consistent to have + components installable as Python libraries. + + Arguments: + + module: + A Python module. + + prefix: + An optional prefix that replaces the one the module + might include. + + """ + mprefix = ( + prefix if prefix is not None else getattr(module, "prefix", DEFAULT_PREFIX) + ) + self.add_folder(module.components_path, prefix=mprefix) + + def render( + self, + /, + __name: str, + *, + caller: "t.Callable | None" = None, + **kw, + ) -> str: + """ + Resets the `collected_css` and `collected_js` lists and renders the + component and subcomponents inside of it. + + This is the method you should call to render a parent component from a + view/controller in your app. + + """ + self.collected_css = [] + self.collected_js = [] + self._tmpl_globals = kw.pop("__globals", None) + return self.irender(__name, caller=caller, **kw) + + def irender( + self, + /, + __name: str, + *, + caller: "t.Callable | None" = None, + **kw, + ) -> str: + """ + Renders the component and subcomponents inside of it **without** + resetting the `collected_css` and `collected_js` lists. + + This is the method you should call to render individual components that + are later inserted into a parent template. + + """ + content = (kw.pop("_content", kw.pop("__content", "")) or "").strip() + attrs = kw.pop("_attrs", kw.pop("__attrs", None)) or {} + file_ext = kw.pop("_file_ext", kw.pop("__file_ext", "")) + source = kw.pop("_source", kw.pop("__source", "")) + + prefix, name = self._split_name(__name) + self.jinja_env.loader = self.prefixes[prefix] + + if source: + logger.debug("Rendering from source %s", __name) + component = self._get_from_source(name=name, prefix=prefix, source=source) + elif self.use_cache: + logger.debug("Rendering from cache or file %s", __name) + component = self._get_from_cache(prefix=prefix, name=name, file_ext=file_ext) + else: + logger.debug("Rendering from file %s", __name) + component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext) + + root_path = component.path.parent if component.path else None + + for url in component.css: + if ( + root_path + and self.fingerprint + and not url.startswith(("http://", "https://")) + ): + url = self._fingerprint(root_path, url) + + if url not in self.collected_css: + self.collected_css.append(url) + + for url in component.js: + if ( + root_path + and self.fingerprint + and not url.startswith(("http://", "https://")) + ): + url = self._fingerprint(root_path, url) + + if url not in self.collected_js: + self.collected_js.append(url) + + attrs = attrs.as_dict if isinstance(attrs, HTMLAttrs) else attrs + attrs.update(kw) + kw = attrs + args, extra = component.filter_args(kw) + try: + args[ARGS_ATTRS] = HTMLAttrs(extra) + except Exception as exc: + raise InvalidArgument( + f"The arguments of the component <{component.name}>" + f"were parsed incorrectly as:\n {str(kw)}" + ) from exc + + args[ARGS_CONTENT] = CallerWrapper(caller=caller, content=content) + return component.render(**args) + + def get_middleware( + self, + application: t.Callable, + allowed_ext: "t.Iterable[str] | None" = ALLOWED_EXTENSIONS, + **kwargs, + ) -> ComponentsMiddleware: + """ + Wraps you application with [Withenoise](https://whitenoise.readthedocs.io/), + a static file serving middleware. + + Tecnically not neccesary if your components doesn't use static assets + or if you serve them by other means. + + Arguments: + + application: + A WSGI application + + allowed_ext: + A list of file extensions the static middleware is allowed to read + and return. By default, is just ".css", ".js", and ".mjs". + + """ + logger.debug("Creating middleware") + middleware = ComponentsMiddleware( + application=application, allowed_ext=tuple(allowed_ext or []), **kwargs + ) + for prefix, loader in self.prefixes.items(): + url_prefix = get_url_prefix(prefix) + url = f"{self.root_url}{url_prefix}" + for root in loader.searchpath[::-1]: + middleware.add_files(root, url) + + return middleware + + def get_source(self, cname: str, file_ext: "tuple[str, ...] | str" = "") -> str: + """ + A helper method that returns the source file of a component. + """ + prefix, name = self._split_name(cname) + path, _ = self._get_component_path(prefix, name, file_ext=file_ext) + return path.read_text() + + def render_assets(self) -> str: + """ + Uses the `collected_css` and `collected_js` lists to generate + an HTML fragment with `<link rel="stylesheet" href="{url}">` + and `<script type="module" src="{url}"></script>` tags. + + The URLs are prepended by `root_url` unless they begin with + "http://" or "https://". + """ + html_css = [] + for url in self.collected_css: + if not url.startswith(("http://", "https://")): + url = f"{self.root_url}{url}" + html_css.append(f'<link rel="stylesheet" href="{url}">') + + html_js = [] + for url in self.collected_js: + if not url.startswith(("http://", "https://")): + url = f"{self.root_url}{url}" + html_js.append(f'<script type="module" src="{url}"></script>') + + return Markup("\n".join(html_css + html_js)) + + # Private + + def _fingerprint(self, root: Path, filename: str) -> str: + relpath = Path(filename.lstrip(os.path.sep)) + filepath = root / relpath + if not filepath.is_file(): + return filename + + stat = filepath.stat() + fingerprint = sha256(str(stat.st_mtime).encode()).hexdigest() + + ext = "".join(relpath.suffixes) + stem = relpath.name.removesuffix(ext) + parent = str(relpath.parent) + parent = "" if parent == "." else f"{parent}/" + + return f"{parent}{stem}-{fingerprint}{ext}" + + def _get_from_source(self, *, name: str, prefix: str, source: str) -> Component: + tmpl = self.jinja_env.from_string(source, globals=self._tmpl_globals) + component = Component(name=name, prefix=prefix, source=source, tmpl=tmpl) + return component + + def _get_from_cache(self, *, prefix: str, name: str, file_ext: str) -> Component: + key = f"{prefix}.{name}.{file_ext}" + cache = self._from_cache(key) + if cache: + component = Component.from_cache( + cache, auto_reload=self.auto_reload, globals=self._tmpl_globals + ) + if component: + return component + + logger.debug("Loading %s", key) + component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext) + self._to_cache(key, component) + return component + + def _from_cache(self, key: str) -> dict[str, t.Any]: + if key not in self._cache: + return {} + cache = self._cache[key] + logger.debug("Loading from cache %s", key) + return cache + + def _to_cache(self, key: str, component: Component) -> None: + self._cache[key] = component.serialize() + + def _get_from_file(self, *, prefix: str, name: str, file_ext: str) -> Component: + path, tmpl_name = self._get_component_path(prefix, name, file_ext=file_ext) + component = Component(name=name, prefix=prefix, path=path) + component.tmpl = self.jinja_env.get_template(tmpl_name, globals=self._tmpl_globals) + return component + + def _split_name(self, cname: str) -> tuple[str, str]: + cname = cname.strip().strip(DELIMITER) + if DELIMITER not in cname: + return DEFAULT_PREFIX, cname + for prefix in self.prefixes.keys(): + _prefix = f"{prefix}{DELIMITER}" + if cname.startswith(_prefix): + return prefix, cname.removeprefix(_prefix) + return DEFAULT_PREFIX, cname + + def _get_component_path( + self, prefix: str, name: str, file_ext: "tuple[str, ...] | str" = "" + ) -> tuple[Path, str]: + name = name.replace(DELIMITER, SLASH) + root_paths = self.prefixes[prefix].searchpath + name_dot = f"{name}." + file_ext = file_ext or self.file_ext + + for root_path in root_paths: + for curr_folder, _, files in os.walk( + root_path, topdown=False, followlinks=True + ): + relfolder = os.path.relpath(curr_folder, root_path).strip(".") + if relfolder and not name_dot.startswith(relfolder): + continue + + for filename in files: + if relfolder: + filepath = f"{relfolder}/{filename}" + else: + filepath = filename + if filepath.startswith(name_dot) and filepath.endswith(file_ext): + return Path(curr_folder) / filename, filepath + + raise ComponentNotFound( + f"Unable to find a file named {name}{file_ext} " + f"or one following the pattern {name_dot}*{file_ext}" + ) + + def _render_attrs(self, attrs: dict[str, t.Any]) -> Markup: + html_attrs = [] + for name, value in attrs.items(): + if value != "": + html_attrs.append(f"{name}={value}") + else: + html_attrs.append(name) + return Markup(" ".join(html_attrs)) diff --git a/src/jinjax/component.py b/src/jinjax/component.py new file mode 100644 index 0000000..948d170 --- /dev/null +++ b/src/jinjax/component.py @@ -0,0 +1,258 @@ +import ast +import re +import typing as t +from keyword import iskeyword +from pathlib import Path + +from jinja2 import Template +from markupsafe import Markup + +from .exceptions import ( + DuplicateDefDeclaration, + InvalidArgument, + MissingRequiredArgument, +) +from .utils import DELIMITER, get_url_prefix + + +if t.TYPE_CHECKING: + from typing_extensions import Self + +RX_COMMA = re.compile(r"\s*,\s*") + +RX_ARGS_START = re.compile(r"{#-?\s*def\s+") +RX_CSS_START = re.compile(r"{#-?\s*css\s+") +RX_JS_START = re.compile(r"{#-?\s*js\s+") + +# This regexp matches the meta declarations (`{#def .. #}``, `{#css .. #}``, +# and `{#js .. #}`) and regular Jinja comments AT THE BEGINNING of the components source. +# You can also have comments inside the declarations. +RX_META_HEADER = re.compile(r"^(\s*{#.*?#})+", re.DOTALL) + +# This regexep matches comments (everything after a `#`) +# Used to remove them from inside meta declarations +RX_INTER_COMMENTS = re.compile(r"\s*#[^\n]*") + + +ALLOWED_NAMES_IN_EXPRESSION_VALUES = { + "len": len, + "max": max, + "min": min, + "pow": pow, + "sum": sum, + # Jinja allows using lowercase booleans, so we do it too for consistency + "false": False, + "true": True, +} + + +def eval_expression(input_string): + code = compile(input_string, "<string>", "eval") + for name in code.co_names: + if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES: + raise InvalidArgument(f"Use of {name} not allowed") + try: + return eval(code, {"__builtins__": {}}, ALLOWED_NAMES_IN_EXPRESSION_VALUES) + except NameError as err: + raise InvalidArgument(err) from err + + +def is_valid_variable_name(name): + return name.isidentifier() and not iskeyword(name) + + +class Component: + """Internal class + """ + __slots__ = ( + "name", + "prefix", + "url_prefix", + "required", + "optional", + "css", + "js", + "path", + "mtime", + "tmpl", + ) + + def __init__( + self, + *, + name: str, + prefix: str = "", + url_prefix: str = "", + source: str = "", + mtime: float = 0, + tmpl: "Template | None" = None, + path: "Path | None" = None, + ) -> None: + self.name = name + self.prefix = prefix + self.url_prefix = url_prefix or get_url_prefix(prefix) + self.required: list[str] = [] + self.optional: dict[str, t.Any] = {} + self.css: list[str] = [] + self.js: list[str] = [] + + if path is not None: + source = source or path.read_text() + mtime = mtime or path.stat().st_mtime + if source: + self.load_metadata(source) + + if path is not None: + default_name = self.name.replace(DELIMITER, "/") + + default_css = f"{default_name}.css" + if (path.with_suffix(".css")).is_file(): + self.css.extend(self.parse_files_expr(default_css)) + + default_js = f"{default_name}.js" + if (path.with_suffix(".js")).is_file(): + self.js.extend(self.parse_files_expr(default_js)) + + self.path = path + self.mtime = mtime + self.tmpl = tmpl + + @classmethod + def from_cache( + cls, + cache: dict[str, t.Any], + auto_reload: bool = True, + globals: "t.MutableMapping[str, t.Any] | None" = None, + ) -> "Self | None": + path = cache["path"] + mtime = cache["mtime"] + + if auto_reload: + if not path.is_file() or path.stat().st_mtime != mtime: + return None + + self = cls(name=cache["name"]) + self.prefix = cache["prefix"] + self.url_prefix = cache["url_prefix"] + self.required = cache["required"] + self.optional = cache["optional"] + self.css = cache["css"] + self.js = cache["js"] + self.path = path + self.mtime = cache["mtime"] + self.tmpl = cache["tmpl"] + + if globals: + # updating the template globals, does not affect the environment globals + self.tmpl.globals.update(globals) + + return self + + def serialize(self) -> dict[str, t.Any]: + return { + "name": self.name, + "prefix": self.prefix, + "url_prefix": self.url_prefix, + "required": self.required, + "optional": self.optional, + "css": self.css, + "js": self.js, + "path": self.path, + "mtime": self.mtime, + "tmpl": self.tmpl, + } + + def load_metadata(self, source: str) -> None: + match = RX_META_HEADER.match(source) + if not match: + return + + header = match.group(0) + # Reversed because I will use `header.pop()` + header = header.split("#}")[:-1][::-1] + def_found = False + + while header: + item = header.pop().strip(" -\n") + + expr = self.read_metadata_item(item, RX_ARGS_START) + if expr: + if def_found: + raise DuplicateDefDeclaration(self.name) + self.required, self.optional = self.parse_args_expr(expr) + def_found = True + continue + + expr = self.read_metadata_item(item, RX_CSS_START) + if expr: + expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ") + self.css = [*self.css, *self.parse_files_expr(expr)] + continue + + expr = self.read_metadata_item(item, RX_JS_START) + if expr: + expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ") + self.js = [*self.js, *self.parse_files_expr(expr)] + continue + + def read_metadata_item(self, source: str, rx_start: re.Pattern) -> str: + start = rx_start.match(source) + if not start: + return "" + return source[start.end():].strip() + + def parse_args_expr(self, expr: str) -> tuple[list[str], dict[str, t.Any]]: + expr = expr.strip(" *,/") + required = [] + optional = {} + + try: + p = ast.parse(f"def component(*,\n{expr}\n): pass") + except SyntaxError as err: + raise InvalidArgument(err) from err + + args = p.body[0].args # type: ignore + arg_names = [arg.arg for arg in args.kwonlyargs] + for name, value in zip(arg_names, args.kw_defaults): # noqa: B905 + if value is None: + required.append(name) + continue + expr = ast.unparse(value) + optional[name] = eval_expression(expr) + + return required, optional + + def parse_files_expr(self, expr: str) -> list[str]: + files = [] + for url in RX_COMMA.split(expr): + url = url.strip("\"'").rstrip("/") + if not url: + continue + if url.startswith(("/", "http://", "https://")): + files.append(url) + else: + files.append(f"{self.url_prefix}{url}") + return files + + def filter_args( + self, kw: dict[str, t.Any] + ) -> tuple[dict[str, t.Any], dict[str, t.Any]]: + args = {} + + for key in self.required: + if key not in kw: + raise MissingRequiredArgument(self.name, key) + args[key] = kw.pop(key) + + for key in self.optional: + args[key] = kw.pop(key, self.optional[key]) + extra = kw.copy() + return args, extra + + def render(self, **kwargs): + assert self.tmpl, f"Component {self.name} has no template" + html = self.tmpl.render(**kwargs).strip() + return Markup(html) + + def __repr__(self) -> str: + return f'<Component "{self.name}">' diff --git a/src/jinjax/exceptions.py b/src/jinjax/exceptions.py new file mode 100644 index 0000000..b1fd97f --- /dev/null +++ b/src/jinjax/exceptions.py @@ -0,0 +1,37 @@ +class ComponentNotFound(Exception): + """ + Raised when JinjaX can't find a component by name in none of the + added folders, probably because of a typo. + """ + + def __init__(self, name: str) -> None: + msg = f"File with pattern `{name}` not found" + super().__init__(msg) + + +class MissingRequiredArgument(Exception): + """ + Raised when a component is used/invoked without passing one or more + of its required arguments (those without a default value). + """ + + def __init__(self, component: str, arg: str) -> None: + msg = f"`{component}` component requires a `{arg}` argument" + super().__init__(msg) + + +class DuplicateDefDeclaration(Exception): + """ + Raised when a component has more then one `{#def ... #}` declarations. + """ + + def __init__(self, component: str) -> None: + msg = "`" + str(component) + "` has two `{#def ... #}` declarations" + super().__init__(msg) + + +class InvalidArgument(Exception): + """ + Raised when the arguments passed to the component cannot be parsed + by JinjaX because of an invalid syntax. + """ diff --git a/src/jinjax/html_attrs.py b/src/jinjax/html_attrs.py new file mode 100644 index 0000000..8b4bd35 --- /dev/null +++ b/src/jinjax/html_attrs.py @@ -0,0 +1,348 @@ +import re +import typing as t +from collections import UserString +from functools import cached_property + +from markupsafe import Markup + + +CLASS_KEY = "class" +CLASS_ALT_KEY = "classes" +CLASS_KEYS = (CLASS_KEY, CLASS_ALT_KEY) + + +def split(ssl: str) -> list[str]: + return re.split(r"\s+", ssl.strip()) + + +def quote(text: str) -> str: + if '"' in text: + if "'" in text: + text = text.replace('"', """) + return f'"{text}"' + else: + return f"'{text}'" + + return f'"{text}"' + + +class LazyString(UserString): + """ + Behave like regular strings, but the actual casting of the initial value + is deferred until the value is actually required. + """ + + __slots__ = ("_seq",) + + def __init__(self, seq): + self._seq = seq + + @cached_property + def data(self): # type: ignore + return str(self._seq) + + +class HTMLAttrs: + """ + Contains all the HTML attributes/properties (a property is an + attribute without a value) passed to a component but that weren't + in the declared attributes list. + + For HTML classes you can use the name "classes" (instead of "class") + if you need to. + + **NOTE**: The string values passed to this class, are not cast to `str` until + the string representation is actually needed, for example when + `attrs.render()` is invoked. + + """ + + def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None: + attributes: "dict[str, str | LazyString]" = {} + properties: set[str] = set() + + class_names = split(" ".join([ + str(attrs.pop(CLASS_KEY, "")), + str(attrs.get(CLASS_ALT_KEY, "")), + ])) + self.__classes = {name for name in class_names if name} + + for name, value in attrs.items(): + name = name.replace("_", "-") + if value is True: + properties.add(name) + elif value is not False and value is not None: + attributes[name] = LazyString(value) + + self.__attributes = attributes + self.__properties = properties + + @property + def classes(self) -> str: + """ + All the HTML classes alphabetically sorted and separated by a space. + + Example: + + ```python + attrs = HTMLAttrs({"class": "italic bold bg-blue wide abcde"}) + attrs.set(class="bold text-white") + print(attrs.classes) + abcde bg-blue bold italic text-white wide + ``` + + """ + return " ".join(sorted((self.__classes))) + + @property + def as_dict(self) -> dict[str, t.Any]: + """ + An ordered dict of all the attributes and properties, both + sorted by name before join. + + Example: + + ```python + attrs = HTMLAttrs({ + "class": "lorem ipsum", + "data_test": True, + "hidden": True, + "aria_label": "hello", + "id": "world", + }) + attrs.as_dict + { + "aria_label": "hello", + "class": "ipsum lorem", + "id": "world", + "data_test": True, + "hidden": True + } + ``` + + """ + attributes = self.__attributes.copy() + classes = self.classes + if classes: + attributes[CLASS_KEY] = classes + + out: dict[str, t.Any] = dict(sorted(attributes.items())) + for name in sorted((self.__properties)): + out[name] = True + return out + + def __getitem__(self, name: str) -> t.Any: + return self.get(name) + + def __delitem__(self, name: str) -> None: + self._remove(name) + + def __str__(self) -> str: + return str(self.as_dict) + + def set(self, **kw) -> None: + """ + Sets an attribute or property + + - Pass a name and a value to set an attribute (e.g. `type="text"`) + - Use `True` as a value to set a property (e.g. `disabled`) + - Use `False` to remove an attribute or property + - If the attribute is "class", the new classes are appended to + the old ones (if not repeated) instead of replacing them. + - The underscores in the names will be translated automatically to dashes, + so `aria_selected` becomes the attribute `aria-selected`. + + Example: + + ```python + attrs = HTMLAttrs({"secret": "qwertyuiop"}) + attrs.set(secret=False) + attrs.as_dict + {} + + attrs.set(unknown=False, lorem="ipsum", count=42, data_good=True) + attrs.as_dict + {"count":42, "lorem":"ipsum", "data_good": True} + + attrs = HTMLAttrs({"class": "b c a"}) + attrs.set(class="c b f d e") + attrs.as_dict + {"class": "a b c d e f"} + ``` + + """ + for name, value in kw.items(): + name = name.replace("_", "-") + if value is False or value is None: + self._remove(name) + continue + + if name in CLASS_KEYS: + self.add_class(value) + elif value is True: + self.__properties.add(name) + else: + self.__attributes[name] = value + + def setdefault(self, **kw) -> None: + """ + Adds an attribute, but only if it's not already present. + + The underscores in the names will be translated automatically to dashes, + so `aria_selected` becomes the attribute `aria-selected`. + + Example: + + ```python + attrs = HTMLAttrs({"lorem": "ipsum"}) + attrs.setdefault(tabindex=0, lorem="meh") + attrs.as_dict + # "tabindex" changed but "lorem" didn't + {"lorem": "ipsum", tabindex: 0} + ``` + + """ + for name, value in kw.items(): + if value in (True, False, None): + continue + + if name in CLASS_KEYS: + if not self.__classes: + self.add_class(value) + + name = name.replace("_", "-") + if name not in self.__attributes: + self.set(**{name: value}) + + def add_class(self, *values: str) -> None: + """ + Adds one or more classes to the list of classes, if not already present. + + Example: + + ```python + attrs = HTMLAttrs({"class": "a b c"}) + attrs.add_class("c", "d") + attrs.as_dict + {"class": "a b c d"} + ``` + + """ + for names in values: + for name in split(names): + self.__classes.add(name) + + def remove_class(self, *names: str) -> None: + """ + Removes one or more classes from the list of classes. + + Example: + + ```python + attrs = HTMLAttrs({"class": "a b c"}) + attrs.remove_class("c", "d") + attrs.as_dict + {"class": "a b"} + ``` + + """ + for name in names: + self.__classes.remove(name) + + def get(self, name: str, default: t.Any = None) -> t.Any: + """ + Returns the value of the attribute or property, + or the default value if it doesn't exists. + + Example: + + ```python + attrs = HTMLAttrs({"lorem": "ipsum", "hidden": True}) + + attrs.get("lorem", defaut="bar") + 'ipsum' + + attrs.get("foo") + None + + attrs.get("foo", defaut="bar") + 'bar' + + attrs.get("hidden") + True + ``` + + """ + name = name.replace("_", "-") + if name in CLASS_KEYS: + return self.classes + if name in self.__attributes: + return self.__attributes[name] + if name in self.__properties: + return True + return default + + def render(self, **kw) -> str: + """ + Renders the attributes and properties as a string. + + Any arguments you use with this function are merged with the existing + attibutes/properties by the same rules as the `HTMLAttrs.set()` function: + + - Pass a name and a value to set an attribute (e.g. `type="text"`) + - Use `True` as a value to set a property (e.g. `disabled`) + - Use `False` to remove an attribute or property + - If the attribute is "class", the new classes are appended to + the old ones (if not repeated) instead of replacing them. + - The underscores in the names will be translated automatically to dashes, + so `aria_selected` becomes the attribute `aria-selected`. + + To provide consistent output, the attributes and properties + are sorted by name and rendered like this: + `<sorted attributes> + <sorted properties>`. + + Example: + + ```python + attrs = HTMLAttrs({"class": "ipsum", "data_good": True, "width": 42}) + + attrs.render() + 'class="ipsum" width="42" data-good' + + attrs.render(class="abc", data_good=False, tabindex=0) + 'class="abc ipsum" width="42" tabindex="0"' + ``` + + """ + if kw: + self.set(**kw) + + attributes = self.__attributes.copy() + + classes = self.classes + if classes: + attributes[CLASS_KEY] = classes + + attributes = dict(sorted(attributes.items())) + properties = sorted((self.__properties)) + + html_attrs = [ + f"{name}={quote(str(value))}" + for name, value in attributes.items() + ] + html_attrs.extend(properties) + + return Markup(" ".join(html_attrs)) + + # Private + + def _remove(self, name: str) -> None: + """ + Removes an attribute or property. + """ + if name in CLASS_KEYS: + self.__classes = set() + if name in self.__attributes: + del self.__attributes[name] + if name in self.__properties: + self.__properties.remove(name) diff --git a/src/jinjax/jinjax.py b/src/jinjax/jinjax.py new file mode 100644 index 0000000..b7a9783 --- /dev/null +++ b/src/jinjax/jinjax.py @@ -0,0 +1,161 @@ +import re +import typing as t +from uuid import uuid4 + +from jinja2.exceptions import TemplateSyntaxError +from jinja2.ext import Extension +from jinja2.filters import do_forceescape + +from .utils import logger + + +RENDER_CMD = "catalog.irender" +BLOCK_CALL = '{% call(_slot) [CMD]("[TAG]"[ATTRS]) -%}[CONTENT]{%- endcall %}' +BLOCK_CALL = BLOCK_CALL.replace("[CMD]", RENDER_CMD) +INLINE_CALL = '{{ [CMD]("[TAG]"[ATTRS]) }}' +INLINE_CALL = INLINE_CALL.replace("[CMD]", RENDER_CMD) + +re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}" +RX_RAW = re.compile(re_raw, re.DOTALL) + +re_tag_name = r"([0-9A-Za-z_-]+\.)*[A-Z][0-9A-Za-z_-]*" +re_raw_attrs = r"(?P<attrs>[^\>]*)" +re_tag = rf"<(?P<tag>{re_tag_name}){re_raw_attrs}\s*/?>" +RX_TAG = re.compile(re_tag) + +re_attr_name = r"" +re_equal = r"" +re_attr = r""" +(?P<name>[a-zA-Z@:$_][a-zA-Z@:$_0-9-]*) +(?: + \s*=\s* + (?P<value>".*?"|'.*?'|\{\{.*?\}\}) +)? +(?:\s+|/|"|$) +""" +RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL) + + +class JinjaX(Extension): + def preprocess( + self, + source: str, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + ) -> str: + self.__raw_blocks = {} + self._name = name + self._filename = filename + source = self._replace_raw_blocks(source) + source = self._process_tags(source) + source = self._restore_raw_blocks(source) + self.__raw_blocks = {} + return source + + def _replace_raw_blocks(self, source: str) -> str: + while True: + match = RX_RAW.search(source) + if not match: + break + start, end = match.span(0) + repl = self._replace_raw_block(match) + source = f"{source[:start]}{repl}{source[end:]}" + + return source + + def _replace_raw_block(self, match: re.Match) -> str: + uid = f"--RAW-{uuid4().hex}--" + self.__raw_blocks[uid] = do_forceescape(match.group(0)) + return uid + + def _restore_raw_blocks(self, source: str) -> str: + for uid, code in self.__raw_blocks.items(): + source = source.replace(uid, code) + return source + + def _process_tags(self, source: str) -> str: + while True: + match = RX_TAG.search(source) + if not match: + break + source = self._process_tag(source, match) + return source + + def _process_tag(self, source: str, match: re.Match) -> str: + start, end = match.span(0) + tag = match.group("tag") + attrs = (match.group("attrs") or "").strip() + inline = match.group(0).endswith("/>") + lineno = source[:start].count("\n") + 1 + + logger.debug(f"{tag} {attrs} {'inline' if not inline else ''}") + if inline: + content = "" + else: + end_tag = f"</{tag}>" + index = source.find(end_tag, end, None) + if index == -1: + raise TemplateSyntaxError( + message=f"Unclosed component {match.group(0)}", + lineno=lineno, + name=self._name, + filename=self._filename + ) + content = source[end:index] + end = index + len(end_tag) + + attrs_list = self._parse_attrs(attrs) + repl = self._build_call(tag, attrs_list, content) + + return f"{source[:start]}{repl}{source[end:]}" + + def _parse_attrs(self, attrs: str) -> list[tuple[str, str]]: + attrs = attrs.replace("\n", " ").strip() + if not attrs: + return [] + return RX_ATTR.findall(attrs) + + def _build_call( + self, + tag: str, + attrs_list: list[tuple[str, str]], + content: str = "", + ) -> str: + logger.debug(f"{tag} {attrs_list} {'inline' if not content else ''}") + attrs = [] + for name, value in attrs_list: + name = name.strip().replace("-", "_") + value = value.strip() + + if not value: + name = name.lstrip(":") + attrs.append(f'"{name}"=True') + else: + # vue-like syntax + if ( + name[0] == ":" + and value[0] in ("\"'") + and value[-1] in ("\"'") + ): + value = value[1:-1].strip() + + # double curly braces syntax + if value[:2] == "{{" and value[-2:] == "}}": + value = value[2:-2].strip() + + name = name.lstrip(":") + attrs.append(f'"{name}"={value}') + + str_attrs = "**{" + ", ".join([a.replace("=", ":", 1) for a in attrs]) + "}" + if str_attrs: + str_attrs = f", {str_attrs}" + + if not content: + call = INLINE_CALL.replace("[TAG]", tag).replace("[ATTRS]", str_attrs) + else: + call = ( + BLOCK_CALL.replace("[TAG]", tag) + .replace("[ATTRS]", str_attrs) + .replace("[CONTENT]", content) + ) + return call diff --git a/src/jinjax/middleware.py b/src/jinjax/middleware.py new file mode 100644 index 0000000..7c8acd8 --- /dev/null +++ b/src/jinjax/middleware.py @@ -0,0 +1,39 @@ +import re +import typing as t +from pathlib import Path + +from whitenoise import WhiteNoise +from whitenoise.responders import Redirect, StaticFile + + +RX_FINGERPRINT = re.compile("(.*)-([abcdef0-9]{64})") + + +class ComponentsMiddleware(WhiteNoise): + """WSGI middleware for serving components assets""" + allowed_ext: tuple[str, ...] + + def __init__(self, **kwargs) -> None: + self.allowed_ext = kwargs.pop("allowed_ext", ()) + super().__init__(**kwargs) + + def find_file(self, url: str) -> "StaticFile | Redirect | None": + + if self.allowed_ext and not url.endswith(self.allowed_ext): + return None + + # Ignore the fingerprint in the filename + # since is only for managing the cache in the client + relpath = Path(url) + ext = "".join(relpath.suffixes) + stem = relpath.name.removesuffix(ext) + fingerprinted = RX_FINGERPRINT.match(stem) + if fingerprinted: + stem = fingerprinted.group(1) + relpath = relpath.with_name(f"{stem}{ext}") + + return super().find_file(str(relpath)) + + def add_file_to_dictionary(self, url: str, path: str, stat_cache: t.Any = None) -> None: + if not self.allowed_ext or url.endswith(self.allowed_ext): + super().add_file_to_dictionary(url, path, stat_cache) diff --git a/src/jinjax/py.typed b/src/jinjax/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/jinjax/py.typed diff --git a/src/jinjax/utils.py b/src/jinjax/utils.py new file mode 100644 index 0000000..ff1ca4f --- /dev/null +++ b/src/jinjax/utils.py @@ -0,0 +1,16 @@ +import logging + + +logger = logging.getLogger("jinjax") + +DELIMITER = "." +SLASH = "/" + + +def get_url_prefix(prefix: str) -> str: + url_prefix = ( + prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(DELIMITER, SLASH) + ) + if url_prefix: + url_prefix = f"{url_prefix}{SLASH}" + return url_prefix |