diff --git a/copier/jinja_ext.py b/copier/jinja_ext.py new file mode 100644 index 000000000..b67234a4f --- /dev/null +++ b/copier/jinja_ext.py @@ -0,0 +1,351 @@ +"""Jinja2 extensions.""" + +from __future__ import annotations + +import re +import uuid +from base64 import b64decode, b64encode +from collections.abc import Iterator +from datetime import datetime +from functools import reduce +from hashlib import new as new_hash +from json import dumps as to_json, loads as from_json +from ntpath import ( + basename as win_basename, + dirname as win_dirname, + splitdrive as win_splitdrive, +) +from os.path import expanduser, expandvars, realpath, relpath, splitext +from pathlib import Path +from posixpath import basename, dirname +from random import Random +from shlex import quote +from time import gmtime, localtime, strftime +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Final, + Literal, + Sequence, + TypeVar, + overload, +) +from warnings import warn + +import yaml +from jinja2 import Environment, Undefined, UndefinedError, pass_environment +from jinja2.ext import Extension +from jinja2.filters import do_groupby + +from .tools import cast_to_bool + +if TYPE_CHECKING: + from typing_extensions import TypeGuard + +_T = TypeVar("_T") + +_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_DNS, "https://github.com/copier-org/copier") + + +def _is_sequence(obj: object) -> TypeGuard[Sequence[Any]]: + return hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)) + + +def _do_b64decode(value: str) -> str: + return b64decode(value).decode() + + +def _do_b64encode(value: str) -> str: + return b64encode(value.encode()).decode() + + +def _do_bool(value: Any) -> bool | None: + return None if value is None else cast_to_bool(value) + + +def _do_hash(value: str, algorithm: str) -> str: + hasher = new_hash(algorithm) + hasher.update(value.encode()) + return hasher.hexdigest() + + +def _do_sha1(value: str) -> str: + return _do_hash(value, "sha1") + + +def _do_md5(value: str) -> str: + return _do_hash(value, "md5") + + +def _do_mandatory(value: _T, msg: str | None = None) -> _T: + if isinstance(value, Undefined): + # See https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Undefined._undefined_name + raise UndefinedError( + msg + or f'Mandatory variable `{value._undefined_name or ""}` is undefined' + ) + return value + + +def _do_to_uuid(name: str | bytes, namespace: str | uuid.UUID = _UUID_NAMESPACE) -> str: + if not isinstance(namespace, uuid.UUID): + namespace = uuid.UUID(namespace) + return str(uuid.uuid5(namespace, name)) + + +def _do_to_yaml(value: Any, *args: Any, **kwargs: Any) -> str: + kwargs.setdefault("allow_unicode", True) + return yaml.dump(value, *args, **kwargs) # type: ignore[no-any-return] + + +def _do_from_yaml(value: str) -> Any: + return yaml.load(value, Loader=yaml.SafeLoader) + + +def _do_from_yaml_all(value: str) -> Iterator[Any]: + return yaml.load_all(value, Loader=yaml.SafeLoader) + + +def _do_strftime(format: str, seconds: float | None = None, utc: bool = False) -> str: + return strftime(format, gmtime(seconds) if utc else localtime(seconds)) + + +def _do_to_datetime(date_string: str, format: str = "%Y-%m-%d %H:%M:%S") -> datetime: + return datetime.strptime(date_string, format) + + +def _do_ternary(condition: bool | None, true: Any, false: Any, none: Any = None) -> Any: + if condition is None: + return none + return true if condition else false + + +def _do_to_nice_json(value: Any, /, **kwargs: Any) -> str: + kwargs.setdefault("skipkeys", False) + kwargs.setdefault("ensure_ascii", True) + kwargs.setdefault("check_circular", True) + kwargs.setdefault("allow_nan", True) + kwargs.setdefault("indent", 4) + kwargs.setdefault("sort_keys", True) + return to_json(value, **kwargs) + + +def _do_to_nice_yaml(value: Any, *args: Any, **kwargs: Any) -> str: + kwargs.setdefault("allow_unicode", True) + kwargs.setdefault("indent", 4) + return yaml.dump(value, *args, **kwargs) # type: ignore[no-any-return] + + +def _do_shuffle(seq: Sequence[_T], seed: str | None = None) -> list[_T]: + seq = list(seq) + Random(seed).shuffle(seq) + return seq + + +@overload +def _do_random(stop: int, start: int, step: int, seed: str | None) -> int: ... + + +@overload +def _do_random(stop: Sequence[_T], start: None, step: None, seed: str | None) -> _T: ... + + +def _do_random( + stop: int | Sequence[_T], + start: int | None = None, + step: int | None = None, + seed: str | None = None, +) -> int | _T: + rng = Random(seed) + + if isinstance(stop, int): + if start is None: + start = 0 + if step is None: + step = 1 + return rng.randrange(start, stop, step) + + for arg_name, arg_value in [("start", start), ("stop", stop)]: + if arg_value is None: + raise TypeError(f'"{arg_name}" can only be used when "stop" is an integer') + return rng.choice(stop) + + +def _do_flatten( + seq: Sequence[Any], levels: int | None = None, skip_nulls: bool = True +) -> Sequence[Any]: + if levels is not None: + if levels < 1: + return seq + levels -= 1 + result: list[Any] = [] + for item in seq: + if _is_sequence(item): + result.extend(_do_flatten(item, levels, skip_nulls)) + elif not skip_nulls or item is not None: + result.append(item) + return result + + +def _do_fileglob(pattern: str) -> Sequence[str]: + return [str(path) for path in Path(".").glob(pattern) if path.is_file()] + + +def _do_random_mac(prefix: str, seed: str | None = None) -> str: + parts = prefix.lower().strip(":").split(":") + if len(parts) > 5: + raise ValueError(f"Invalid MAC address prefix {prefix}: too many parts") + for part in parts: + if not re.match(r"[a-f0-9]{2}", part): + raise ValueError( + f"Invalid MAC address prefix {prefix}: {part} is not a hexadecimal byte" + ) + rng = Random(seed) + return ":".join( + parts + [f"{rng.randint(0, 255):02x}" for _ in range(6 - len(parts))] + ) + + +def _do_regex_escape( + pattern: str, re_type: Literal["python", "posix_basic"] = "python" +) -> str: + if re_type == "python": + return re.escape(pattern) + raise NotImplementedError(f"Regex type {re_type} not implemented") + + +def _do_regex_search( + value: str, + pattern: str, + *args: str, + ignorecase: bool = False, + multiline: bool = False, +) -> str | list[str] | None: + groups: list[str | int] = [] + for arg in args: + if match := re.match(r"^\\g<(\S+)>$", arg): + groups.append(match.group(1)) + elif match := re.match(r"^\\(\d+)$", arg): + groups.append(int(match.group(1))) + else: + raise ValueError("Invalid backref format") + + flags = 0 + if ignorecase: + flags |= re.IGNORECASE + if multiline: + flags |= re.MULTILINE + + return (match := re.search(pattern, value, flags)) and ( + list(result) if isinstance((result := match.group(*groups)), tuple) else result + ) + + +def _do_regex_replace( + value: str, + pattern: str, + replacement: str, + *, + ignorecase: bool = False, +) -> str: + return re.sub(pattern, replacement, value, flags=re.I if ignorecase else 0) + + +def _do_regex_findall( + value: str, + pattern: str, + *, + ignorecase: bool = False, + multiline: bool = False, +) -> list[str]: + flags = 0 + if ignorecase: + flags |= re.IGNORECASE + if multiline: + flags |= re.MULTILINE + return re.findall(pattern, value, flags) + + +def _do_type_debug(value: object) -> str: + return value.__class__.__name__ + + +@pass_environment +def _do_extract( + environment: Environment, + key: Any, + container: Any, + *, + morekeys: Any | Sequence[Any] | None = None, +) -> Any | Undefined: + keys: list[Any] + if morekeys is None: + keys = [key] + elif _is_sequence(morekeys): + keys = [key, *morekeys] + else: + keys = [key, morekeys] + return reduce(environment.getitem, keys, container) + + +class CopierExtension(Extension): + """Jinja2 extension for Copier.""" + + # NOTE: mypy disallows `Callable[[Any, ...], Any]` + _filters: Final[dict[str, Callable[..., Any]]] = { + "ans_groupby": do_groupby, + "ans_random": _do_random, + "b64decode": _do_b64decode, + "b64encode": _do_b64encode, + "basename": basename, + "bool": _do_bool, + "checksum": _do_sha1, + "dirname": dirname, + "expanduser": expanduser, + "expandvars": expandvars, + "extract": _do_extract, + "fileglob": _do_fileglob, + "flatten": _do_flatten, + "from_json": from_json, + "from_yaml": _do_from_yaml, + "from_yaml_all": _do_from_yaml_all, + "hash": _do_hash, + "mandatory": _do_mandatory, + "md5": _do_md5, + "quote": quote, + "random_mac": _do_random_mac, + "realpath": realpath, + "regex_escape": _do_regex_escape, + "regex_findall": _do_regex_findall, + "regex_replace": _do_regex_replace, + "regex_search": _do_regex_search, + "relpath": relpath, + "sha1": _do_sha1, + "shuffle": _do_shuffle, + "splitext": splitext, + "strftime": _do_strftime, + "ternary": _do_ternary, + "to_datetime": _do_to_datetime, + "to_json": to_json, + "to_nice_json": _do_to_nice_json, + "to_nice_yaml": _do_to_nice_yaml, + "to_uuid": _do_to_uuid, + "to_yaml": _do_to_yaml, + "type_debug": _do_type_debug, + "win_basename": win_basename, + "win_dirname": win_dirname, + "win_splitdrive": win_splitdrive, + } + + def __init__(self, environment: Environment) -> None: + super().__init__(environment) + for k, v in self._filters.items(): + if k in environment.filters: + warn( + f'A filter named "{k}" already exists in the Jinja2 environment', + category=RuntimeWarning, + stacklevel=2, + ) + else: + environment.filters[k] = v diff --git a/copier/main.py b/copier/main.py index dc0c9c058..fc2df8a82 100644 --- a/copier/main.py +++ b/copier/main.py @@ -44,6 +44,7 @@ UnsafeTemplateError, UserMessageError, ) +from .jinja_ext import CopierExtension from .subproject import Subproject from .template import Task, Template from .tools import ( @@ -547,9 +548,7 @@ def jinja_env(self) -> SandboxedEnvironment: """ paths = [str(self.template.local_abspath)] loader = FileSystemLoader(paths) - default_extensions = [ - "jinja2_ansible_filters.AnsibleCoreFiltersExtension", - ] + default_extensions = [CopierExtension] extensions = default_extensions + list(self.template.jinja_extensions) # We want to minimize the risk of hidden malware in the templates # so we use the SandboxedEnvironment instead of the regular one. diff --git a/docs/configuring.md b/docs/configuring.md index 13c2d297b..7aac95583 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -950,15 +950,52 @@ Overwrite files that already exist, without asking. Additional Jinja2 extensions to load in the Jinja2 environment. Extensions can add filters, global variables and functions, or tags to the environment. -The following extensions are _always_ loaded: - -- [`jinja2_ansible_filters.AnsibleCoreFiltersExtension`](https://gitlab.com/dreamer-labs/libraries/jinja2-ansible-filters/): - this extension adds most of the - [Ansible filters](https://docs.ansible.com/ansible/2.3/playbooks_filters.html) to - the environment. - -You don't need to tell your template users to install these extensions: Copier depends -on them, so they are always installed when Copier is installed. +By default, most of the +[Ansible filters](https://docs.ansible.com/ansible/2.3/playbooks_filters.html) are +_always_ loaded: + +- `ans_groupby` +- `ans_random` +- `b64decode` +- `b64encode` +- `basename` +- `bool` +- `checksum` +- `dirname` +- `expanduser` +- `expandvars` +- `extract` +- `fileglob` +- `flatten` +- `from_json` +- `from_yaml` +- `from_yaml_all` +- `hash` +- `mandatory` +- `md5` +- `quote` +- `random_mac` +- `realpath` +- `regex_escape` +- `regex_findall` +- `regex_replace` +- `regex_search` +- `relpath` +- `sha1` +- `shuffle` +- `splitext` +- `strftime` +- `ternary` +- `to_datetime` +- `to_json` +- `to_nice_json` +- `to_nice_yaml` +- `to_uuid` +- `to_yaml` +- `type_debug` +- `win_basename` +- `win_dirname` +- `win_splitdrive` !!! warning diff --git a/docs/creating.md b/docs/creating.md index 01131f9fd..0b38c7a56 100644 --- a/docs/creating.md +++ b/docs/creating.md @@ -77,12 +77,10 @@ In addition to [all the features Jinja supports](https://jinja.palletsprojects.com/en/3.1.x/templates/), Copier includes: -- All functions and filters from - [jinja2-ansible-filters](https://gitlab.com/dreamer-labs/libraries/jinja2-ansible-filters/). - - - This includes the `to_nice_yaml` filter, which is used extensively in our - context. - +- Most of the + [Ansible filters](https://docs.ansible.com/ansible/2.3/playbooks_filters.html) + including the `to_nice_yaml` filter, which is used extensively in our context. See + the [`jinja_extensions`][] setting for more details. - `_copier_answers` includes the current answers dict, but slightly modified to make it suitable to [autoupdate your project safely][the-copier-answersyml-file]: - It doesn't contain secret answers. diff --git a/poetry.lock b/poetry.lock index 7cb1561d6..e3b98d215 100644 --- a/poetry.lock +++ b/poetry.lock @@ -28,6 +28,34 @@ files = [ [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} +[[package]] +name = "backports-zoneinfo" +version = "0.2.1" +description = "Backport of the standard library zoneinfo module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, + {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, +] + +[package.extras] +tzdata = ["tzdata"] + [[package]] name = "certifi" version = "2024.7.4" @@ -477,24 +505,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jinja2-ansible-filters" -version = "1.3.2" -description = "A port of Ansible's jinja2 filters without requiring ansible core." -optional = false -python-versions = "*" -files = [ - {file = "jinja2-ansible-filters-1.3.2.tar.gz", hash = "sha256:07c10cf44d7073f4f01102ca12d9a2dc31b41d47e4c61ed92ef6a6d2669b356b"}, - {file = "jinja2_ansible_filters-1.3.2-py3-none-any.whl", hash = "sha256:e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34"}, -] - -[package.dependencies] -Jinja2 = "*" -PyYAML = "*" - -[package.extras] -test = ["pytest", "pytest-cov"] - [[package]] name = "markdown" version = "3.7" @@ -1504,6 +1514,74 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "time-machine" +version = "2.14.2" +description = "Travel through time in your tests." +optional = false +python-versions = ">=3.8" +files = [ + {file = "time_machine-2.14.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8293386d8ac68ecf6a432f8c2ca7251e108e160093954b14225dbed856c0d55"}, + {file = "time_machine-2.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4c5ff83704abbc48083e899df712861d0acd31abe6b0f1f0795e1b15f521c90"}, + {file = "time_machine-2.14.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32b5b44372d1f025b4fcc4209cbdc5d3e10a3e07a8334b297bb0ba4a827906e4"}, + {file = "time_machine-2.14.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03dcbda69bdc1186fe93e5fc095493e577ecf82390bb6b86d2a445727c3e722d"}, + {file = "time_machine-2.14.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6327866c00c64ce1c18b1c0444e61bd65c267d4929d2be787fa11da0455823c3"}, + {file = "time_machine-2.14.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1c6e9b6df0e6ab34776e04ce936f1f6099e8d3983ce0cc60aca2d3cf2d5ef27b"}, + {file = "time_machine-2.14.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2f2eb7ccf5f1c706f335a998ce8b009b3f968d625a4ffcf1b16ddef38fa283bc"}, + {file = "time_machine-2.14.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fa488e27fb6f7efbfbb41586533963cebff3ce396b3e8cd7b013ed30e4f830df"}, + {file = "time_machine-2.14.2-cp310-cp310-win32.whl", hash = "sha256:4386f303a4b4bc12d3b0266e88deb64c11109474ad32ba71c18bc4812cbb3e1f"}, + {file = "time_machine-2.14.2-cp310-cp310-win_amd64.whl", hash = "sha256:826a3608420e08f0c4bc404dce6141d8ec80d3729e0278a6e0d5ae4532f76247"}, + {file = "time_machine-2.14.2-cp310-cp310-win_arm64.whl", hash = "sha256:c80664830c774d60e26a267bc25c59151f281b2befc1b40a7526fc7633286401"}, + {file = "time_machine-2.14.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c2e8a877c1c2a39011979680bbd44b05e2d7fef45000cdcef3f1b7c1c56d53de"}, + {file = "time_machine-2.14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a665fa8f4484850c8df0d33edaa781b37a7cd2d615479f0e5467599a49e5f6c0"}, + {file = "time_machine-2.14.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e24f8b526c1f1c17b478fe68360afba8a609c3547b7a51e0ca350ac8a2959961"}, + {file = "time_machine-2.14.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27d12a3eaca2f7b10da33774a8edd3a6b97358a3bed9ffecefc88d7e3d7b5f5f"}, + {file = "time_machine-2.14.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f373873583c93e2107e4e9e4db4cb4d637df75d82c57aaa6349c4993305b77"}, + {file = "time_machine-2.14.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9219e488ab0637120ebbfb2183e1c676f3de79ce6b11666ec0383d71e82803be"}, + {file = "time_machine-2.14.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:22db0f8af1686b5d96be39dd21ddb7de13caf5a45f3fca6c41d61007e08c0eb0"}, + {file = "time_machine-2.14.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:caaf7700e6b47799c94bf4b4fb9b5cc067f463ec29f5fdc38a66628e3b062a4c"}, + {file = "time_machine-2.14.2-cp311-cp311-win32.whl", hash = "sha256:134ec3c5050ddbc6926da11a17c2d632cef8bb3f164098084f6f267f913c9304"}, + {file = "time_machine-2.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:fda6fc706a2d78cc8688018d17fb52ea80169fb9fd0f70642d218bd676049f9d"}, + {file = "time_machine-2.14.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2f05834faf501fa14d5a0318f736965b7ea58dd3a11c22bf8e9eca4889d5955"}, + {file = "time_machine-2.14.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:576179845483203182e4d423db1c6c27b3a8b569a3e3df9980a785adefc3ef6f"}, + {file = "time_machine-2.14.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:146aee86d237aa3a0ad1287718f1228107d21f3cd775c40f121a4670b3dee02c"}, + {file = "time_machine-2.14.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:603fb67082f1795f1bd352dccad5c6884e56cfb7a115ac6edb03bb9434ec5698"}, + {file = "time_machine-2.14.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3b76ef7c02bbf3dce58a7c4a5c73ed919483a946150e7dda89ea1be0314811c"}, + {file = "time_machine-2.14.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:faa7c67a1dafa29d17ca098b61a717419dd5c7ebb21f4f644f4a859983013273"}, + {file = "time_machine-2.14.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:24034c253b37c125842cf9bbd112786c4381a067b1c1cb224615688101066f5f"}, + {file = "time_machine-2.14.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1ea4319010914c8d69bd16d9839a5c2f1df104b5a4704882bc44599d81611582"}, + {file = "time_machine-2.14.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:10c7cf6134e32e1074d37319f8b7662cc200ee9dd813a48b7520dd4aa49131a9"}, + {file = "time_machine-2.14.2-cp312-cp312-win32.whl", hash = "sha256:3f985a98704e81e0183043db5889f17fa68daea1ad230e9c8feb3bb303a518c1"}, + {file = "time_machine-2.14.2-cp312-cp312-win_amd64.whl", hash = "sha256:25edfd2d8c62cbe25ea2c80463c4ab7e3386792a7fe0d70909d52dbfc9aa4c6d"}, + {file = "time_machine-2.14.2-cp312-cp312-win_arm64.whl", hash = "sha256:71f42b2257ce71ce9b90320072e327edeeb6368ccd0602acd979033e172df656"}, + {file = "time_machine-2.14.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1a6627ce920f1b4b73b2a4957e53f2740d684535af6924f62085005e6e3181cb"}, + {file = "time_machine-2.14.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1bbbb04a8e5f0381b75847c96356c7b55348bfac54bee024bd61dfbf33176c11"}, + {file = "time_machine-2.14.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f739a7660a97869333ff960e7e03c6047910e19bccc3adc86954050ec9c8e074"}, + {file = "time_machine-2.14.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0573432aadc97b07e2be6756476e9ba3f5864aa4453c473a03da72ae8b6c5145"}, + {file = "time_machine-2.14.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1076e8435f27f25e55c659cf0de9a20ffc12265a1f8e00641512fb023c60fab"}, + {file = "time_machine-2.14.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6f03ae4ee4c854d1534768fb579d4ca6b680373ad8ab35cc9008289c9efec9"}, + {file = "time_machine-2.14.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:720071c6fd7edae7149dc3b336de0bfb03d4fb66b13abd96e6145c4bef7c1b40"}, + {file = "time_machine-2.14.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f1bc051f7a3204fb8aceac0f4aa01bdc3a5c936dd0d7334ae1b791862ced89b3"}, + {file = "time_machine-2.14.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:69428e17e2b9ab04ccbd178f18aedbb4fa4e7f53807ee067fe3c55fca286a6df"}, + {file = "time_machine-2.14.2-cp38-cp38-win32.whl", hash = "sha256:7726801fa7d744fb0faab7131bf2a6bd2c56e2cf01c7215cfef6987968652392"}, + {file = "time_machine-2.14.2-cp38-cp38-win_amd64.whl", hash = "sha256:93ad7844a67ae29043b78ab3148d0fa59f00e68f762eb8982110ac27f684dd62"}, + {file = "time_machine-2.14.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8cca04142f39564722648b03ad061c411b6a83f01549c59248d604f2ac76789b"}, + {file = "time_machine-2.14.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34c35287b6667a6c233ed0658649d52854858bb6a8ee30d2aa680bf2288a166d"}, + {file = "time_machine-2.14.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca63bd68fe1b31a1135c535bb579dd96ddaa1f802d9cbf638cc344f18701575f"}, + {file = "time_machine-2.14.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30d1e3c18e7dcf5981e7e0fa3ed8b4bfbe6b1dc430442838283455049996f9e0"}, + {file = "time_machine-2.14.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76004bd92f23e3863ace7fd4ac0751134ea13953ec11bd8f47a8fec1f8dc89ff"}, + {file = "time_machine-2.14.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:037ff158179517fa9ae045c5ac8e995a4d465660f4d4b53510630e2ab2aa4eab"}, + {file = "time_machine-2.14.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:098b709455bc9f95e5cc42a2cf42373a4f2aa3f6d5e79e4fe9a7c3f44834cdb7"}, + {file = "time_machine-2.14.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:51a0b17ddd29e7106f84db7539f6a92153c3617754f691c851af6b1cf524f60c"}, + {file = "time_machine-2.14.2-cp39-cp39-win32.whl", hash = "sha256:875456bb4389112e1e827492cb47965910fa2dfe00c4d521670baf0125d7a454"}, + {file = "time_machine-2.14.2-cp39-cp39-win_amd64.whl", hash = "sha256:cc19096db9465905662d680b1667cbe37c4ca9cdfbeb30680d45687fdc449c14"}, + {file = "time_machine-2.14.2-cp39-cp39-win_arm64.whl", hash = "sha256:f9c5d5b8a8667d85a37f07c0b6f85fa551fb65e8b6e647b2dee29c517a249f0c"}, + {file = "time_machine-2.14.2.tar.gz", hash = "sha256:6e5150cdf1e128c4b3bea214204b4d7747456d9c7ce8e3d83c204e59f9640b72"}, +] + +[package.dependencies] +python-dateutil = "*" + [[package]] name = "tomli" version = "2.0.1" @@ -1723,4 +1801,4 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-it [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "e9f5b300ab32886155c864b53e3d0e1dcf7cfdd7ed12567cf781cdf5e2edaaa8" +content-hash = "3cb26d7fee6779aa8e9696d2d9845b5f686969e2cdcb2eec00184af497d4b907" diff --git a/pyproject.toml b/pyproject.toml index 87fcb98cd..b7bf47cfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ colorama = ">=0.4.6" dunamai = ">=1.7.0" funcy = ">=1.17" jinja2 = ">=3.1.4" -jinja2-ansible-filters = ">=1.3.1" packaging = ">=23.0" pathspec = ">=0.9.0" plumbum = ">=1.6.9" @@ -41,13 +40,14 @@ pydantic = ">=2.4.2" pygments = ">=2.7.1" pyyaml = ">=5.3.1" questionary = ">=1.8.1" -typing-extensions = { version = ">=3.7.4,<5.0.0", python = "<3.9" } +typing-extensions = { version = ">=3.10.0.0,<5.0.0", python = "<3.9" } eval-type-backport = { version = ">=0.1.3,<0.3.0", python = "<3.10" } [tool.poetry.group.dev] optional = true [tool.poetry.group.dev.dependencies] +backports-zoneinfo = { version = ">=0.2.1", python = "<3.9" } mypy = ">=0.931" pexpect = ">=4.8.0" poethepoet = ">=0.12.3" @@ -56,6 +56,7 @@ pytest = ">=7.2.0" pytest-cov = ">=3.0.0" pytest-gitconfig = ">=0.6.0" pytest-xdist = ">=2.5.0" +time-machine = ">=2.14.2" types-backports = ">=0.1.3" types-colorama = ">=0.4" types-psutil = "*" diff --git a/tests/test_jinja2_extensions.py b/tests/test_jinja2_extensions.py index 3e1bc7a93..7bc1ea189 100644 --- a/tests/test_jinja2_extensions.py +++ b/tests/test_jinja2_extensions.py @@ -1,15 +1,28 @@ +from __future__ import annotations + import json +import re +import sys +from contextlib import AbstractContextManager, nullcontext as does_not_raise +from datetime import datetime +from os.path import expanduser, expandvars from pathlib import Path from typing import Any import pytest -from jinja2 import Environment +from jinja2 import Environment, UndefinedError from jinja2.ext import Extension +from time_machine import travel import copier from .helpers import PROJECT_TEMPLATE, build_file_tree +if sys.version_info >= (3, 9): + from zoneinfo import ZoneInfo +else: + from backports.zoneinfo import ZoneInfo + class FilterExtension(Extension): """Jinja2 extension to add a filter to the Jinja2 environment.""" @@ -64,3 +77,610 @@ def test_to_json_filter_with_conf(tmp_path_factory: pytest.TempPathFactory) -> N assert conf_file.exists() # must not raise an error assert json.loads(conf_file.read_text()) + + +@pytest.mark.parametrize( + ("template", "value", "expected"), + [ + # b64decode + pytest.param( + "{{ value | b64decode }}", + "MTIz", + "123", + id="b64decode", + ), + # b64encode + pytest.param( + "{{ value | b64encode }}", + "123", + "MTIz", + id="b64encode", + ), + # basename + pytest.param( + "{{ value | basename }}", + "/etc/asdf/foo.txt", + "foo.txt", + id="basename", + ), + # win_basename + pytest.param( + "{{ value | win_basename }}", + "etc\\asdf\\foo.txt", + "foo.txt", + id="win_basename", + ), + # win_splitdrive + pytest.param( + "{{ value | win_splitdrive | join(' & ') }}", + "C:\\etc\\asdf\\foo.txt", + "C: & \\etc\\asdf\\foo.txt", + id="win_splitdrive", + ), + pytest.param( + "{{ value | win_splitdrive | join(' & ') }}", + "C:/etc/asdf/foo.txt", + "C: & /etc/asdf/foo.txt", + id="win_splitdrive with forward slash", + ), + # dirname + pytest.param( + "{{ value | dirname }}", + "/etc/asdf/foo.txt", + "/etc/asdf", + id="dirname", + ), + # win_dirname + pytest.param( + "{{ value | win_dirname }}", + "etc\\asdf\\foo.txt", + "etc\\asdf", + id="win_dirname", + ), + pytest.param( + "{{ value | win_dirname }}", + "etc/asdf/foo.txt", + "etc/asdf", + id="win_dirname with forward slash", + ), + pytest.param( + "{{ value | win_dirname }}", + "C:\\etc\\asdf\\foo.txt", + "C:\\etc\\asdf", + id="win_dirname with drive", + ), + pytest.param( + "{{ value | win_dirname }}", + "C:/etc/asdf/foo.txt", + "C:/etc/asdf", + id="win_dirname with drive and forward slash", + ), + # expanduser + pytest.param( + "{{ value | expanduser }}", + "~/etc/asdf/foo.txt", + f'{expanduser("~")}/etc/asdf/foo.txt', + id="expanduser", + ), + # expandvars + pytest.param( + "{{ value | expandvars }}", + "$HOME/etc/asdf/foo.txt", + f'{expandvars("$HOME")}/etc/asdf/foo.txt', + id="expandvars", + ), + # realpath + pytest.param( + "{{ value | realpath }}", + "/etc/../asdf/foo.txt", + "/asdf/foo.txt", + id="realpath", + ), + # relpath + pytest.param( + "{{ value | relpath('/etc') }}", + "/etc/asdf/foo.txt", + "asdf/foo.txt", + id="relpath", + ), + # splitext + pytest.param( + "{{ value | splitext | join(' + ') }}", + "foo.txt", + "foo + .txt", + id="splitext", + ), + # bool + pytest.param( + "{{ value | bool is true }}", + "1", + "True", + id='bool: "1" -> True', + ), + pytest.param( + "{{ value | bool is false }}", + "0", + "True", + id='bool: "0" -> False', + ), + pytest.param( + "{{ value | bool is true }}", + "True", + "True", + id='bool: "True" -> True', + ), + pytest.param( + "{{ value | bool is false }}", + "false", + "True", + id='bool: "false" -> False', + ), + pytest.param( + "{{ value | bool is true }}", + "yes", + "True", + id='bool: "yes" -> True', + ), + pytest.param( + "{{ value | bool is false }}", + "no", + "True", + id='bool: "no" -> False', + ), + pytest.param( + "{{ value | bool is true }}", + "on", + "True", + id='bool: "on" -> True', + ), + pytest.param( + "{{ value | bool is false }}", + "off", + "True", + id='bool: "off" -> False', + ), + pytest.param( + "{{ value | bool is true }}", + True, + "True", + id="bool: True -> True", + ), + pytest.param( + "{{ value | bool is false }}", + False, + "True", + id="bool: False -> False", + ), + pytest.param( + "{{ value | bool is none }}", + None, + "True", + id="bool: None -> None", + ), + # checksum + pytest.param( + "{{ value | checksum }}", + "test2", + "109f4b3c50d7b0df729d299bc6f8e9ef9066971f", + id="checksum", + ), + # sha1 + pytest.param( + "{{ value | sha1 }}", + "test2", + "109f4b3c50d7b0df729d299bc6f8e9ef9066971f", + id="sha1", + ), + # hash('sha1') + pytest.param( + "{{ value | hash('sha1') }}", + "test2", + "109f4b3c50d7b0df729d299bc6f8e9ef9066971f", + id="hash('sha1')", + ), + # md5 + pytest.param( + "{{ value | md5 }}", + "test2", + "ad0234829205b9033196ba818f7a872b", + id="md5", + ), + # hash('md5') + pytest.param( + "{{ value | hash('md5') }}", + "test2", + "ad0234829205b9033196ba818f7a872b", + id="hash('md5')", + ), + # to_json + pytest.param( + "{{ value | to_json }}", + "München", + r'"M\u00fcnchen"', + id="to_json", + ), + pytest.param( + "{{ value | to_json(ensure_ascii=False) }}", + "München", + '"München"', + id="to_json(ensure_ascii=False)", + ), + # from_json + pytest.param( + "{{ value | from_json }}", + '"München"', + "München", + id="from_json", + ), + # to_yaml + pytest.param( + "{{ value | to_yaml }}", + {"k": True}, + "k: true\n", + id="to_yaml", + ), + # from_yaml + pytest.param( + "{{ value | from_yaml == {'k': true} }}", + "k: true", + "True", + id="from_yaml", + ), + # from_yaml_all + pytest.param( + """\ + {%- set result = value | from_yaml_all -%} + {{- result is iterable -}}| + {{- result is not sequence -}}| + {{- result | list -}} + """, + "k1: v1\n---\nk2: v2", + "True|True|[{'k1': 'v1'}, {'k2': 'v2'}]", + id="from_yaml_all", + ), + # mandatory + pytest.param( + "{{ value | mandatory }}", + "test2", + "test2", + id="mandatory: passthrough", + ), + pytest.param( + "{{ undef | mandatory }}", + "", + pytest.raises( + UndefinedError, + match=re.escape("Mandatory variable `undef` is undefined"), + ), + id="mandatory: undefined variable", + ), + # to_uuid + pytest.param( + "{{ value | to_uuid }}", + "test2", + "daf9c796-57b5-57c0-aa86-6637fc9c3c88", + id="to_uuid", + ), + pytest.param( + "{{ value | to_uuid('11111111-2222-3333-4444-555555555555') }}", + "test2", + "eb47636c-32aa-5e19-aba9-e19ffafacbc2", + id="to_uuid: custom namespace", + ), + # quote + pytest.param( + "echo {{ value | quote }}", + "hello world", + "echo 'hello world'", + id="quote", + ), + pytest.param( + "echo {{ value | quote }}", + "hello world", + "echo 'hello world'", + id="quote", + ), + # strftime + pytest.param( + "{{ value | strftime }}", + "%H:%M:%S", + "02:03:04", + id="strftime", + ), + pytest.param( + "{{ value | strftime(seconds=12345) }}", + "%H:%M:%S", + "19:25:45", + id="strftime: custom seconds", + ), + pytest.param( + "{{ value | strftime(utc=True) }}", + "%H:%M:%S", + "10:03:04", + id="strftime: utc", + ), + # ternary + pytest.param( + "{{ value | ternary('yes', 'no') }}", + True, + "yes", + id="ternary: true", + ), + pytest.param( + "{{ value | ternary('yes', 'no') }}", + False, + "no", + id="ternary: false", + ), + pytest.param( + "{{ value | ternary('yes', 'no') is none }}", + None, + "True", + id="ternary: none (default)", + ), + pytest.param( + "{{ value | ternary('yes', 'no', 'null') }}", + None, + "null", + id="ternary: none (custom)", + ), + # to_nice_json + pytest.param( + "{{ value | to_nice_json }}", + {"x": [1, 2], "k": "v"}, + '{\n "k": "v",\n "x": [\n 1,\n 2\n ]\n}', + id="to_nice_json", + ), + pytest.param( + "{{ value | to_nice_json(indent=2) }}", + {"x": [1, 2], "k": "v"}, + '{\n "k": "v",\n "x": [\n 1,\n 2\n ]\n}', + id="to_nice_json: custom indent", + ), + # to_nice_yaml + pytest.param( + "{{ value | to_nice_yaml }}", + {"x": {"y": [1, 2]}, "k": "v"}, + "k: v\nx:\n y:\n - 1\n - 2\n", + id="to_nice_yaml", + ), + pytest.param( + "{{ value | to_nice_yaml(indent=2) }}", + {"x": {"y": [1, 2]}, "k": "v"}, + "k: v\nx:\n y:\n - 1\n - 2\n", + id="to_nice_yaml: custom indent", + ), + # to_datetime + pytest.param( + "{{ (('2016-08-14 20:00:12' | to_datetime) - ('2016-08-12' | to_datetime('%Y-%m-%d'))).days }}", + None, + "2", + id="to_datetime", + ), + # shuffle + pytest.param( + "{{ value | shuffle(seed='123') | join(', ') }}", + [1, 2, 3], + "2, 1, 3", + id="shuffle", + ), + # ans_random + pytest.param( + "{{ value | ans_random(seed='123') }}", + 100, + "93", + id="ans_random: stop", + ), + pytest.param( + "{{ value | ans_random(start=94, step=2, seed='123') }}", + 100, + "98", + id="ans_random: start/stop/step", + ), + # flatten + pytest.param( + "{{ value | flatten }}", + ["a", [1, [2, [None, 3]]]], + "['a', 1, 2, 3]", + id="flatten", + ), + pytest.param( + "{{ value | flatten(levels=0) }}", + ["a", [1, [2, [None, 3]]]], + "['a', [1, [2, [None, 3]]]]", + id="flatten: levels=0", + ), + pytest.param( + "{{ value | flatten(levels=1) }}", + ["a", [1, [2, [None, 3]]]], + "['a', 1, [2, [None, 3]]]", + id="flatten: levels=1", + ), + pytest.param( + "{{ value | flatten(skip_nulls=False) }}", + ["a", [1, [2, [None, 3]]]], + "['a', 1, 2, None, 3]", + id="flatten: skip_nulls=False", + ), + # random_mac + pytest.param( + "{{ value | random_mac(seed='123') }}", + "52:54:00", + "52:54:00:25:a4:fc", + id="random_mac", + ), + pytest.param( + "{{ value | random_mac(seed='123') }}", + "52:54:00:25:a4:fc", + pytest.raises( + ValueError, + match=re.escape( + "Invalid MAC address prefix 52:54:00:25:a4:fc: too many parts" + ), + ), + id="random_mac: too many parts", + ), + pytest.param( + "{{ value | random_mac(seed='123') }}", + "52:54:gg", + pytest.raises( + ValueError, + match=re.escape( + "Invalid MAC address prefix 52:54:gg: gg is not a hexadecimal byte" + ), + ), + id="random_mac: invalid hexadecimal byte", + ), + # regex_escape + pytest.param( + "{{ value | regex_escape }}", + "^f.*o(.*)$", + "\\^f\\.\\*o\\(\\.\\*\\)\\$", + id="regex_escape", + ), + # regex_search + pytest.param( + "{{ value | regex_search('database[0-9]+') }}", + "server1/database42", + "database42", + id="regex_search", + ), + pytest.param( + "{{ value | regex_search('(?i)server([0-9]+)') }}", + "sErver1/database42", + "sErver1", + id="regex_search: inline flags", + ), + pytest.param( + "{{ value | regex_search('^bar', multiline=True, ignorecase=True) }}", + "foo\nBAR", + "BAR", + id="regex_search: keyword argument flags", + ), + pytest.param( + "{{ value | regex_search('server([0-9]+)/database([0-9]+)', '\\\\1', '\\\\2') }}", + "server1/database42", + "['1', '42']", + id="regex_search: backrefs (index)", + ), + pytest.param( + "{{ value | regex_search('(?P[0-9]+)/(?P[0-9]+)', '\\\\g', '\\\\g') }}", + "21/42", + "['21', '42']", + id="regex_search: backrefs (name)", + ), + pytest.param( + "{{ value | regex_search('(?P[0-9]+)/(?P[0-9]+)', 'INVALID') }}", + "21/42", + pytest.raises( + ValueError, + match=re.escape("Invalid backref format"), + ), + id="regex_search: invalid backref format", + ), + # regex_replace + pytest.param( + "{{ value | regex_replace('^a.*i(.*)$', 'a\\\\1') }}", + "ansible", + "able", + id="regex_replace", + ), + pytest.param( + "{{ value | regex_replace('(?i)^a.*i(.*)$', 'a\\\\1') }}", + "AnsIbLe", + "abLe", + id="regex_replace: inline flags", + ), + pytest.param( + "{{ value | regex_replace('^a.*i(.*)$', 'a\\\\1', ignorecase=True) }}", + "AnsIbLe", + "abLe", + id="regex_replace: keyword argument flags", + ), + # regex_findall + pytest.param( + "{{ value | regex_findall('\\\\b(?:[0-9]{1,3}\\\\.){3}[0-9]{1,3}\\\\b') }}", + "Some DNS servers are 8.8.8.8 and 8.8.4.4", + "['8.8.8.8', '8.8.4.4']", + id="regex_findall", + ), + pytest.param( + "{{ value | regex_findall('(?im)^.ar$') }}", + "CAR\ntar\nfoo\nbar\n", + "['CAR', 'tar', 'bar']", + id="regex_findall: inline flags", + ), + pytest.param( + "{{ value | regex_findall('^.ar$', multiline=True, ignorecase=True) }}", + "CAR\ntar\nfoo\nbar\n", + "['CAR', 'tar', 'bar']", + id="regex_findall: keyword argument flags", + ), + # type_debug + pytest.param( + "{{ value | type_debug }}", + "foo", + "str", + id="type_debug: str", + ), + pytest.param( + "{{ value | type_debug }}", + 123, + "int", + id="type_debug: int", + ), + # extract + pytest.param( + "{{ value | extract(['a', 'b', 'c']) }}", + 1, + "b", + id="extract", + ), + pytest.param( + "{{ value | extract(['a', 'b', 'c']) is undefined }}", + 3, + "True", + id="extract: undefined", + ), + pytest.param( + "{{ value | extract([{'a': 1, 'b': 2, 'c': 3}, {'x': 9, 'y': 10}], morekeys='b') }}", + 0, + "2", + id="extract: nested", + ), + pytest.param( + "{{ value | extract([{'a': 1, 'b': 2, 'c': 3}, {'x': 9, 'y': 10}], morekeys='z') is undefined }}", + 0, + "True", + id="extract: nested undefined", + ), + ], +) +@travel(datetime(1970, 1, 1, 2, 3, 4, tzinfo=ZoneInfo("America/Los_Angeles"))) +def test_filters( + tmp_path_factory: pytest.TempPathFactory, + template: str, + value: Any, + expected: str | Exception, +) -> None: + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree({src / "result.txt.jinja": template}) + with expected if isinstance(expected, AbstractContextManager) else does_not_raise(): + copier.run_copy(str(src), dst, data={"value": value}) + assert (dst / "result.txt").exists() + assert (dst / "result.txt").read_text() == expected + + +@pytest.mark.xfail(reason="cwd while rendering isn't destination root") +def test_filter_fileglob(tmp_path_factory: pytest.TempPathFactory) -> None: + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "result.txt.jinja": "{{ '**/*.txt' | fileglob | sort | join('|') }}", + dst / "a.txt": "", + dst / "b" / "c.txt": "", + } + ) + copier.run_copy(str(src), dst) + assert (dst / "result.txt").exists() + assert (dst / "result.txt").read_text() == f'a.txt|{Path("b", "c.txt")}'