From f92854382a7815e70b250550421bd11c75f5324b Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 1 Jan 2026 15:25:11 +1100 Subject: [PATCH 01/44] Build for Fedora 43 --- Dockerfile.rpmbuild | 3 ++- release.sh | 44 +++++++++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild index cd7fc4f..7dfb0b0 100644 --- a/Dockerfile.rpmbuild +++ b/Dockerfile.rpmbuild @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 -FROM fedora:42 +ARG BASE_IMAGE=fedora:42 +FROM ${BASE_IMAGE} RUN set -eux; \ dnf -y update; \ diff --git a/release.sh b/release.sh index 31f1941..0f2e431 100755 --- a/release.sh +++ b/release.sh @@ -44,31 +44,45 @@ for dist in ${DISTS[@]}; do done # RPM -sudo apt-get -y install createrepo-c rpm -docker build -f Dockerfile.rpmbuild -t jinjaturtle:f42 --progress=plain . -docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out jinjaturtle:f42 -sudo chown -R "${USER}" "$PWD/dist" - REPO_ROOT="${HOME}/git/repo_rpm" RPM_REPO="${REPO_ROOT}/rpm/x86_64" BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist" REMOTE="letessier.mig5.net:/opt/repo_rpm" KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" -echo "==> Updating RPM repo..." mkdir -p "$RPM_REPO" +sudo apt-get -y install createrepo-c rpm -for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do - rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file" + +DISTS=( + fedora:43 + fedora:42 +) + +for dist in ${DISTS[@]}; do + release=$(echo ${dist} | cut -d: -f2) + docker build \ + -f Dockerfile.rpmbuild \ + -t jinjaturtle-rpm:${release} \ + --progress=plain \ + --build-arg BASE_IMAGE=${dist} \ + . + + docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out jinjaturtle-rpm:${release} + sudo chown -R "${USER}" "$PWD/dist" + + for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do + rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file" + done + + cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" + + createrepo_c "$RPM_REPO" + + echo "==> Signing repomd.xml..." + qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" done -cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" - -createrepo_c "$RPM_REPO" - -echo "==> Signing repomd.xml..." -qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" - echo "==> Syncing repo to server..." rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" From 14428ff89c6aeef264839ba6be865aa295bf4a22 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 3 Jan 2026 09:33:06 +1100 Subject: [PATCH 02/44] Separate rpm dirs for different Fedora versions --- README.md | 4 ++-- release.sh | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 827df56..0cd022d 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ sudo apt update sudo apt install jinjaturtle ``` -### Fedora 42 +### Fedora ```bash sudo rpm --import https://mig5.net/static/mig5.asc @@ -82,7 +82,7 @@ sudo rpm --import https://mig5.net/static/mig5.asc sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF' [mig5] name=mig5 Repository -baseurl=https://rpm.mig5.net/rpm/$basearch +baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch enabled=1 gpgcheck=1 repo_gpgcheck=1 diff --git a/release.sh b/release.sh index 0f2e431..6a02801 100755 --- a/release.sh +++ b/release.sh @@ -44,15 +44,11 @@ for dist in ${DISTS[@]}; do done # RPM -REPO_ROOT="${HOME}/git/repo_rpm" -RPM_REPO="${REPO_ROOT}/rpm/x86_64" -BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist" -REMOTE="letessier.mig5.net:/opt/repo_rpm" -KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" - -mkdir -p "$RPM_REPO" sudo apt-get -y install createrepo-c rpm - +BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist" +KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" +REPO_ROOT="${HOME}/git/repo_rpm" +REMOTE="letessier.mig5.net:/opt/repo_rpm" DISTS=( fedora:43 @@ -61,6 +57,10 @@ DISTS=( for dist in ${DISTS[@]}; do release=$(echo ${dist} | cut -d: -f2) + REPO_RELEASE_ROOT="${REPO_ROOT}/fc${release}" + RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64" + mkdir -p "$RPM_REPO" + docker build \ -f Dockerfile.rpmbuild \ -t jinjaturtle-rpm:${release} \ From a5c860e4638e4ee5e3ef6ac64d8efc17f86aea5a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 3 Jan 2026 12:49:59 +1100 Subject: [PATCH 03/44] remove 'fc' from release root --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.sh b/release.sh index 6a02801..392cac1 100755 --- a/release.sh +++ b/release.sh @@ -57,7 +57,7 @@ DISTS=( for dist in ${DISTS[@]}; do release=$(echo ${dist} | cut -d: -f2) - REPO_RELEASE_ROOT="${REPO_ROOT}/fc${release}" + REPO_RELEASE_ROOT="${REPO_ROOT}/${release}" RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64" mkdir -p "$RPM_REPO" From 8f7f48dc91bbd3f5393d151129e8a7235b3ef7b2 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 3 Jan 2026 14:13:45 +1100 Subject: [PATCH 04/44] fix fedora release --- release.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/release.sh b/release.sh index 392cac1..394a969 100755 --- a/release.sh +++ b/release.sh @@ -62,12 +62,16 @@ for dist in ${DISTS[@]}; do mkdir -p "$RPM_REPO" docker build \ + --no-cache \ -f Dockerfile.rpmbuild \ -t jinjaturtle-rpm:${release} \ --progress=plain \ --build-arg BASE_IMAGE=${dist} \ . + rm -rf "$PWD/dist/rpm"/* + mkdir -p "$PWD/dist/rpm" + docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out jinjaturtle-rpm:${release} sudo chown -R "${USER}" "$PWD/dist" From 2f77cd4d800bbea4fa2b477e699f5b5b14668a7e Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 6 Jan 2026 11:57:50 +1100 Subject: [PATCH 05/44] Add support for systemd and postfix config files --- README.md | 13 +- pyproject.toml | 2 +- src/jinjaturtle/cli.py | 2 +- src/jinjaturtle/core.py | 83 ++++++++++++- src/jinjaturtle/handlers/__init__.py | 5 + src/jinjaturtle/handlers/postfix.py | 177 +++++++++++++++++++++++++++ src/jinjaturtle/handlers/systemd.py | 177 +++++++++++++++++++++++++++ src/jinjaturtle/multi.py | 11 +- tests/test_postfix_format.py | 33 +++++ tests/test_systemd_format.py | 26 ++++ 10 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 src/jinjaturtle/handlers/postfix.py create mode 100644 src/jinjaturtle/handlers/systemd.py create mode 100644 tests/test_postfix_format.py create mode 100644 tests/test_systemd_format.py diff --git a/README.md b/README.md index 0cd022d..0e0ad48 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,12 @@ jinjaturtle php.ini \ ## Full usage info ``` -usage: jinjaturtle [-h] -r ROLE_NAME [-f {json,ini,toml,yaml,xml}] [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] config +usage: jinjaturtle [-h] -r ROLE_NAME [-f {json,ini,toml,yaml,xml,postfix,systemd}] [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] config Convert a config file into Ansible inventory and a Jinja2 template. positional arguments: - config Path to the source configuration file (TOML or INI-style). + config Path to the source configuration file. options: -h, --help show this help message and exit @@ -146,6 +146,15 @@ options: Path to write the Jinja2 config template. If omitted, template is printed to stdout. ``` +## Additional supported formats + +JinjaTurtle can also template some common "bespoke" config formats: + +- **Postfix main.cf** (`main.cf`) → `--format postfix` +- **systemd unit files** (`*.service`, `*.socket`, etc.) → `--format systemd` + +For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content sniffing; you can always force a specific handler via `--format`. + ## Found a bug, have a suggestion? diff --git a/pyproject.toml b/pyproject.toml index 3ed9e59..3fc5c4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.3.5" +version = "0.4.0" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index e6f74ec..74fdd8e 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -42,7 +42,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: ap.add_argument( "-f", "--format", - choices=["ini", "json", "toml", "yaml", "xml"], + choices=["ini", "json", "toml", "yaml", "xml", "postfix", "systemd"], help="Force config format instead of auto-detecting from filename.", ) ap.add_argument( diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index fee5e80..d53f182 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any, Iterable import datetime +import re import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate @@ -14,6 +15,8 @@ from .handlers import ( TomlHandler, YamlHandler, XmlHandler, + PostfixMainHandler, + SystemdUnitHandler, ) @@ -56,12 +59,18 @@ _TOML_HANDLER = TomlHandler() _YAML_HANDLER = YamlHandler() _XML_HANDLER = XmlHandler() +_POSTFIX_HANDLER = PostfixMainHandler() +_SYSTEMD_HANDLER = SystemdUnitHandler() + _HANDLERS["ini"] = _INI_HANDLER _HANDLERS["json"] = _JSON_HANDLER _HANDLERS["toml"] = _TOML_HANDLER _HANDLERS["yaml"] = _YAML_HANDLER _HANDLERS["xml"] = _XML_HANDLER +_HANDLERS["postfix"] = _POSTFIX_HANDLER +_HANDLERS["systemd"] = _SYSTEMD_HANDLER + def dump_yaml(data: Any, *, sort_keys: bool = True) -> str: """Dump YAML using JinjaTurtle's dumper settings. @@ -86,24 +95,92 @@ def make_var_name(role_prefix: str, path: Iterable[str]) -> str: return BaseHandler.make_var_name(role_prefix, path) +def _read_head(path: Path, max_bytes: int = 65536) -> str: + try: + with path.open("r", encoding="utf-8", errors="replace") as f: + return f.read(max_bytes) + except OSError: + return "" + + +_SYSTEMD_SUFFIXES: set[str] = { + ".service", + ".socket", + ".target", + ".timer", + ".path", + ".mount", + ".automount", + ".slice", + ".swap", + ".scope", + ".link", + ".netdev", + ".network", +} + + +def _looks_like_systemd(text: str) -> bool: + # Be conservative: many INI-style configs have [section] and key=value. + # systemd unit files almost always contain one of these well-known sections. + if re.search( + r"^\s*\[(Unit|Service|Install|Socket|Timer|Path|Mount|Automount|Slice|Swap|Scope)\]\s*$", + text, + re.M, + ) and re.search(r"^\s*\w[\w\-]*\s*=", text, re.M): + return True + return False + + def detect_format(path: Path, explicit: str | None = None) -> str: """ - Determine config format from argument or filename. + Determine config format. + + For unambiguous extensions (json/yaml/toml/xml/ini), we rely on the suffix. + For ambiguous extensions like '.conf' (or no extension), we sniff the content. """ if explicit: return explicit + suffix = path.suffix.lower() name = path.name.lower() + + # Unambiguous extensions if suffix == ".toml": return "toml" if suffix in {".yaml", ".yml"}: return "yaml" if suffix == ".json": return "json" - if suffix in {".ini", ".cfg", ".conf"} or name.endswith(".ini"): - return "ini" if suffix == ".xml": return "xml" + + # Special-ish INI-like formats + if suffix in {".ini", ".cfg"} or name.endswith(".ini"): + return "ini" + if suffix == ".repo": + return "ini" + + # systemd units + if suffix in _SYSTEMD_SUFFIXES: + return "systemd" + + # well-known filenames + if name == "main.cf": + return "postfix" + + head = _read_head(path) + + # Content sniffing + if _looks_like_systemd(head): + return "systemd" + + # Ambiguous .conf/.cf defaults to INI-ish if no better match + if suffix in {".conf", ".cf"}: + if name == "main.cf": + return "postfix" + return "ini" + # Fallback: treat as INI-ish return "ini" diff --git a/src/jinjaturtle/handlers/__init__.py b/src/jinjaturtle/handlers/__init__.py index 6bbcba1..97074c5 100644 --- a/src/jinjaturtle/handlers/__init__.py +++ b/src/jinjaturtle/handlers/__init__.py @@ -8,6 +8,9 @@ from .toml import TomlHandler from .yaml import YamlHandler from .xml import XmlHandler +from .postfix import PostfixMainHandler +from .systemd import SystemdUnitHandler + __all__ = [ "BaseHandler", "DictLikeHandler", @@ -16,4 +19,6 @@ __all__ = [ "TomlHandler", "YamlHandler", "XmlHandler", + "PostfixMainHandler", + "SystemdUnitHandler", ] diff --git a/src/jinjaturtle/handlers/postfix.py b/src/jinjaturtle/handlers/postfix.py new file mode 100644 index 0000000..65f6be9 --- /dev/null +++ b/src/jinjaturtle/handlers/postfix.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from . import BaseHandler + + +class PostfixMainHandler(BaseHandler): + """ + Handler for Postfix main.cf style configuration. + + Postfix main.cf is largely 'key = value' with: + - '#' comments + - continuation lines starting with whitespace (they continue the previous value) + """ + + fmt = "postfix" + + def parse(self, path: Path) -> dict[str, str]: + text = path.read_text(encoding="utf-8") + return self._parse_text_to_dict(text) + + def _parse_text_to_dict(self, text: str) -> dict[str, str]: + lines = text.splitlines() + out: dict[str, str] = {} + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() + if not stripped or stripped.startswith("#"): + i += 1 + continue + + if "=" not in line: + i += 1 + continue + + eq_index = line.find("=") + key = line[:eq_index].strip() + if not key: + i += 1 + continue + + # value + inline comment + after = line[eq_index + 1 :] + value_part, _comment = self._split_inline_comment(after, {"#"}) + value = value_part.strip() + + # collect continuation lines + j = i + 1 + cont_parts: list[str] = [] + while j < len(lines): + nxt = lines[j] + if not nxt: + break + if nxt.startswith((" ", "\t")): + if nxt.strip().startswith("#"): + # a commented continuation line - treat as a break + break + cont_parts.append(nxt.strip()) + j += 1 + continue + break + + if cont_parts: + value = " ".join([value] + cont_parts).strip() + + out[key] = value + i = j if cont_parts else i + 1 + + return out + + def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: + if not isinstance(parsed, dict): + raise TypeError("Postfix parse result must be a dict[str, str]") + items: list[tuple[tuple[str, ...], Any]] = [] + for k, v in parsed.items(): + items.append(((k,), v)) + return items + + def generate_jinja2_template( + self, + parsed: Any, + role_prefix: str, + original_text: str | None = None, + ) -> str: + if original_text is None: + # Canonical render (lossy) + if not isinstance(parsed, dict): + raise TypeError("Postfix parse result must be a dict[str, str]") + lines: list[str] = [] + for k, v in parsed.items(): + var = self.make_var_name(role_prefix, (k,)) + lines.append(f"{k} = {{{{ {var} }}}}") + return "\n".join(lines).rstrip() + "\n" + return self._generate_from_text(role_prefix, original_text) + + def _generate_from_text(self, role_prefix: str, text: str) -> str: + lines = text.splitlines(keepends=True) + out_lines: list[str] = [] + i = 0 + while i < len(lines): + raw_line = lines[i] + content = raw_line.rstrip("\n") + newline = "\n" if raw_line.endswith("\n") else "" + + stripped = content.strip() + if not stripped: + out_lines.append(raw_line) + i += 1 + continue + if stripped.startswith("#"): + out_lines.append(raw_line) + i += 1 + continue + + if "=" not in content: + out_lines.append(raw_line) + i += 1 + continue + + eq_index = content.find("=") + before_eq = content[:eq_index] + after_eq = content[eq_index + 1 :] + + key = before_eq.strip() + if not key: + out_lines.append(raw_line) + i += 1 + continue + + # whitespace after '=' + value_ws_len = len(after_eq) - len(after_eq.lstrip(" \t")) + leading_ws = after_eq[:value_ws_len] + value_and_comment = after_eq[value_ws_len:] + + value_part, comment_part = self._split_inline_comment( + value_and_comment, {"#"} + ) + value = value_part.strip() + + # collect continuation physical lines to skip + j = i + 1 + cont_parts: list[str] = [] + while j < len(lines): + nxt_raw = lines[j] + nxt = nxt_raw.rstrip("\n") + if ( + nxt.startswith((" ", "\t")) + and nxt.strip() + and not nxt.strip().startswith("#") + ): + cont_parts.append(nxt.strip()) + j += 1 + continue + break + + if cont_parts: + value = " ".join([value] + cont_parts).strip() + + var = self.make_var_name(role_prefix, (key,)) + v = value + quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} + if quoted: + replacement = ( + f'{before_eq}={leading_ws}"{{{{ {var} }}}}"{comment_part}{newline}' + ) + else: + replacement = ( + f"{before_eq}={leading_ws}{{{{ {var} }}}}{comment_part}{newline}" + ) + + out_lines.append(replacement) + i = j # skip continuation lines (if any) + + return "".join(out_lines) diff --git a/src/jinjaturtle/handlers/systemd.py b/src/jinjaturtle/handlers/systemd.py new file mode 100644 index 0000000..044fd86 --- /dev/null +++ b/src/jinjaturtle/handlers/systemd.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from . import BaseHandler + + +@dataclass +class SystemdLine: + kind: str # 'blank' | 'comment' | 'section' | 'kv' | 'raw' + raw: str + lineno: int + section: str | None = None + key: str | None = None + value: str | None = None + comment: str = "" + before_eq: str = "" + leading_ws_after_eq: str = "" + occ_index: int | None = None + + +@dataclass +class SystemdUnit: + lines: list[SystemdLine] + + +class SystemdUnitHandler(BaseHandler): + """ + Handler for systemd unit files. + + unit files are INI-like, but keys may repeat (e.g. multiple ExecStart= lines). + We preserve repeated keys by indexing them when flattening and templating. + """ + + fmt = "systemd" + + def parse(self, path: Path) -> SystemdUnit: + text = path.read_text(encoding="utf-8") + return self._parse_text(text) + + def _parse_text(self, text: str) -> SystemdUnit: + lines = text.splitlines(keepends=True) + out: list[SystemdLine] = [] + current_section: str | None = None + # counts per section+key to assign occ_index + occ: dict[tuple[str, str], int] = {} + + for lineno, raw_line in enumerate(lines, start=1): + content = raw_line.rstrip("\n") + stripped = content.strip() + + if not stripped: + out.append(SystemdLine(kind="blank", raw=raw_line, lineno=lineno)) + continue + + if stripped.startswith(("#", ";")): + out.append(SystemdLine(kind="comment", raw=raw_line, lineno=lineno)) + continue + + # section header + if ( + stripped.startswith("[") + and stripped.endswith("]") + and len(stripped) >= 2 + ): + sec = stripped[1:-1].strip() + current_section = sec + out.append( + SystemdLine( + kind="section", raw=raw_line, lineno=lineno, section=sec + ) + ) + continue + + if "=" not in content: + out.append(SystemdLine(kind="raw", raw=raw_line, lineno=lineno)) + continue + + eq_index = content.find("=") + before_eq = content[:eq_index] + after_eq = content[eq_index + 1 :] + + key = before_eq.strip() + if not key: + out.append(SystemdLine(kind="raw", raw=raw_line, lineno=lineno)) + continue + + # whitespace after '=' + value_ws_len = len(after_eq) - len(after_eq.lstrip(" \t")) + leading_ws = after_eq[:value_ws_len] + value_and_comment = after_eq[value_ws_len:] + + value_part, comment = self._split_inline_comment( + value_and_comment, {"#", ";"} + ) + value = value_part.strip() + + sec = current_section or "DEFAULT" + k = (sec, key) + idx = occ.get(k, 0) + occ[k] = idx + 1 + + out.append( + SystemdLine( + kind="kv", + raw=raw_line, + lineno=lineno, + section=sec, + key=key, + value=value, + comment=comment, + before_eq=before_eq, + leading_ws_after_eq=leading_ws, + occ_index=idx, + ) + ) + + return SystemdUnit(lines=out) + + def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: + if not isinstance(parsed, SystemdUnit): + raise TypeError("systemd parse result must be a SystemdUnit") + + # determine duplicates per (section,key) + counts: dict[tuple[str, str], int] = {} + for ln in parsed.lines: + if ln.kind == "kv" and ln.section and ln.key: + counts[(ln.section, ln.key)] = counts.get((ln.section, ln.key), 0) + 1 + + items: list[tuple[tuple[str, ...], Any]] = [] + for ln in parsed.lines: + if ln.kind != "kv" or not ln.section or not ln.key: + continue + path: tuple[str, ...] = (ln.section, ln.key) + if counts.get((ln.section, ln.key), 0) > 1 and ln.occ_index is not None: + path = path + (str(ln.occ_index),) + items.append((path, ln.value or "")) + return items + + def generate_jinja2_template( + self, + parsed: Any, + role_prefix: str, + original_text: str | None = None, + ) -> str: + if not isinstance(parsed, SystemdUnit): + raise TypeError("systemd parse result must be a SystemdUnit") + # We template using parsed lines so we preserve original formatting/comments. + counts: dict[tuple[str, str], int] = {} + for ln in parsed.lines: + if ln.kind == "kv" and ln.section and ln.key: + counts[(ln.section, ln.key)] = counts.get((ln.section, ln.key), 0) + 1 + + out_lines: list[str] = [] + for ln in parsed.lines: + if ln.kind != "kv" or not ln.section or not ln.key: + out_lines.append(ln.raw) + continue + + path: tuple[str, ...] = (ln.section, ln.key) + if counts.get((ln.section, ln.key), 0) > 1 and ln.occ_index is not None: + path = path + (str(ln.occ_index),) + var = self.make_var_name(role_prefix, path) + + v = (ln.value or "").strip() + quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} + if quoted: + repl = f'{ln.before_eq}={ln.leading_ws_after_eq}"{{{{ {var} }}}}"{ln.comment}' + else: + repl = f"{ln.before_eq}={ln.leading_ws_after_eq}{{{{ {var} }}}}{ln.comment}" + + newline = "\n" if ln.raw.endswith("\n") else "" + out_lines.append(repl + newline) + + return "".join(out_lines) diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index fb1737f..20cf544 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -36,7 +36,7 @@ SUPPORTED_SUFFIXES: dict[str, set[str]] = { "toml": {".toml"}, "yaml": {".yaml", ".yml"}, "json": {".json"}, - "ini": {".ini", ".cfg", ".conf"}, + "ini": {".ini", ".cfg", ".conf", ".repo"}, "xml": {".xml"}, } @@ -584,6 +584,9 @@ class FormatOutput: items: list[dict[str, Any]] +FOLDER_SUPPORTED_FORMATS: set[str] = {"json", "yaml", "toml", "ini", "xml"} + + def process_directory( root: Path, recursive: bool, role_prefix: str ) -> tuple[str, list[FormatOutput]]: @@ -596,8 +599,14 @@ def process_directory( grouped: dict[str, list[tuple[Path, Any]]] = defaultdict(list) for p in files: fmt, parsed = parse_config(p, None) + if fmt not in FOLDER_SUPPORTED_FORMATS: + # Directory mode only supports a subset of formats for now. + continue grouped[fmt].append((p, parsed)) + if not grouped: + raise ValueError(f"No folder-supported config files found under: {root}") + multiple_formats = len(grouped) > 1 outputs: list[FormatOutput] = [] diff --git a/tests/test_postfix_format.py b/tests/test_postfix_format.py new file mode 100644 index 0000000..6bb9e5d --- /dev/null +++ b/tests/test_postfix_format.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +import jinjaturtle.core as core + + +def test_postfix_main_cf_parsing_and_template(tmp_path: Path) -> None: + p = tmp_path / "main.cf" + p.write_text( + "# comment\n" + "myhostname = mail.example.com\n" + "mynetworks = 127.0.0.0/8\n" + " [::1]/128\n", + encoding="utf-8", + ) + + fmt, parsed = core.parse_config(p) + assert fmt == "postfix" + + flat = core.flatten_config(fmt, parsed) + assert (("myhostname",), "mail.example.com") in flat + assert any( + path == ("mynetworks",) and value.startswith("127.0.0.0/8") + for path, value in flat + ) + + template = core.generate_jinja2_template( + fmt, parsed, role_prefix="role", original_text=p.read_text(encoding="utf-8") + ) + assert "myhostname = {{ role_myhostname }}" in template + assert "mynetworks = {{ role_mynetworks }}" in template + assert "# comment" in template diff --git a/tests/test_systemd_format.py b/tests/test_systemd_format.py new file mode 100644 index 0000000..c310f72 --- /dev/null +++ b/tests/test_systemd_format.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + +import jinjaturtle.core as core + + +def test_systemd_unit_repeated_keys(tmp_path: Path) -> None: + p = tmp_path / "demo.service" + p.write_text( + "[Service]\n" "ExecStart=/bin/echo one\n" "ExecStart=/bin/echo two\n", + encoding="utf-8", + ) + + fmt, parsed = core.parse_config(p) + assert fmt == "systemd" + + flat = core.flatten_config(fmt, parsed) + assert (("Service", "ExecStart", "0"), "/bin/echo one") in flat + assert (("Service", "ExecStart", "1"), "/bin/echo two") in flat + + template = core.generate_jinja2_template( + fmt, parsed, role_prefix="role", original_text=p.read_text(encoding="utf-8") + ) + assert "ExecStart={{ role_service_execstart_0 }}" in template + assert "ExecStart={{ role_service_execstart_1 }}" in template From 121e67ae07ca1e8258483671f5bf1095711d480e Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 6 Jan 2026 12:01:37 +1100 Subject: [PATCH 06/44] Bump versions --- debian/changelog | 6 ++++++ rpm/jinjaturtle.spec | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index 7c6473c..723496e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.4.0) unstable; urgency=medium + + * Support converting systemd files and postfix main.cf + + -- Miguel Jacq Tue, 06 Jan 2026 12:00:00 +1100 + jinjaturtle (0.3.5) unstable; urgency=medium * Support converting a directory (optionally recursively) instead of just an individual file. diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 13182e8..7f0aa00 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.3.5 +%global upstream_version 0.4.0 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Tue Jan 06 2026 Miguel Jacq - %{version}-%{release} +- Support converting systemd files and postfix main.cf * Tue Dec 30 2025 Miguel Jacq - %{version}-%{release} - Support converting a directory (optionally recursively) instead of just an individual file. * Sat Dec 27 2025 Miguel Jacq - %{version}-%{release} From e6d58199d3c1ae66f9f9c602a0618bb9b33ce368 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 11 Mar 2026 12:04:21 +1100 Subject: [PATCH 07/44] Update my GPG key --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.sh b/release.sh index 394a969..fcc84ed 100755 --- a/release.sh +++ b/release.sh @@ -46,7 +46,7 @@ done # RPM sudo apt-get -y install createrepo-c rpm BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist" -KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" +KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99" REPO_ROOT="${HOME}/git/repo_rpm" REMOTE="letessier.mig5.net:/opt/repo_rpm" From 823e529373a366a01d015734ac6885ab5cf83041 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 23 Mar 2026 11:21:55 +1100 Subject: [PATCH 08/44] Remove trivy.. --- .forgejo/workflows/trivy.yml | 40 ------------------------------------ 1 file changed, 40 deletions(-) delete mode 100644 .forgejo/workflows/trivy.yml diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml deleted file mode 100644 index d5585f4..0000000 --- a/.forgejo/workflows/trivy.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Trivy - -on: - schedule: - - cron: '0 1 * * *' - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null - echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy - - - name: Run trivy - run: | - trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry --skip-version-check --exit-code 1 . - - # Notify if any previous step in this job failed - - name: Notify on failure - if: ${{ failure() }} - env: - WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} - REPOSITORY: ${{ forgejo.repository }} - RUN_NUMBER: ${{ forgejo.run_number }} - SERVER_URL: ${{ forgejo.server_url }} - run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ - "$WEBHOOK_URL" From 1e545cca8752851fbf5e58993e891ce60283beb2 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 12 May 2026 11:45:49 +1000 Subject: [PATCH 09/44] Support ssh configs --- debian/changelog | 6 + poetry.lock | 585 ++++++++++++++------------- pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 +- src/jinjaturtle/cli.py | 4 +- src/jinjaturtle/core.py | 73 ++++ src/jinjaturtle/handlers/__init__.py | 2 + src/jinjaturtle/handlers/ssh.py | 278 +++++++++++++ tests/test_ssh_format.py | 106 +++++ 9 files changed, 781 insertions(+), 279 deletions(-) create mode 100644 src/jinjaturtle/handlers/ssh.py create mode 100644 tests/test_ssh_format.py diff --git a/debian/changelog b/debian/changelog index 723496e..42a966a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.0) unstable; urgency=medium + + * Support ssh configs + + -- Miguel Jacq Tue, 12 May 2026 11:40:00 +1000 + jinjaturtle (0.4.0) unstable; urgency=medium * Support converting systemd files and postfix main.cf diff --git a/poetry.lock b/poetry.lock index 0d40c6c..2717000 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,135 +2,151 @@ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, + {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, ] [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] @@ -146,103 +162,117 @@ files = [ [[package]] name = "coverage" -version = "7.13.0" +version = "7.14.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, - {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, - {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, - {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, - {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, - {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, - {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, - {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, - {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, - {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, - {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, - {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, - {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, - {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, - {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, - {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, - {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, - {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, - {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, - {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, - {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, - {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, - {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, - {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, - {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, - {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, - {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, - {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, - {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, - {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, - {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, - {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, - {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, - {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, - {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, - {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, + {file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"}, + {file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"}, + {file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"}, + {file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"}, + {file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"}, + {file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"}, + {file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"}, + {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"}, + {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"}, + {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"}, + {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"}, + {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"}, + {file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"}, + {file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"}, + {file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"}, + {file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"}, + {file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"}, + {file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"}, + {file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"}, + {file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"}, + {file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"}, + {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"}, + {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"}, + {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"}, + {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"}, + {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"}, + {file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"}, + {file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"}, + {file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"}, + {file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"}, + {file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"}, + {file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"}, + {file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"}, + {file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"}, + {file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"}, + {file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"}, + {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"}, + {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"}, + {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"}, + {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"}, + {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"}, + {file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"}, + {file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"}, + {file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"}, + {file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"}, + {file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"}, + {file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"}, + {file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"}, + {file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"}, + {file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"}, + {file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"}, + {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"}, + {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"}, + {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"}, + {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"}, + {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"}, + {file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"}, + {file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"}, + {file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"}, + {file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"}, + {file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"}, + {file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"}, + {file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"}, + {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"}, + {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"}, + {file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"}, + {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"}, + {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"}, + {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"}, + {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"}, + {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"}, + {file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"}, + {file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"}, + {file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"}, + {file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"}, + {file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"}, + {file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"}, + {file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"}, + {file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"}, + {file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"}, + {file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"}, + {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"}, + {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"}, + {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"}, + {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"}, + {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"}, + {file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"}, + {file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"}, + {file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"}, + {file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"}, + {file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"}, + {file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"}, + {file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"}, + {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"}, + {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"}, + {file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"}, + {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"}, + {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"}, + {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"}, + {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"}, + {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"}, + {file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"}, + {file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"}, + {file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"}, + {file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"}, + {file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"}, ] [package.dependencies] @@ -295,17 +325,17 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.11" +version = "3.14" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69"}, + {file = "idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "iniconfig" @@ -435,13 +465,13 @@ files = [ [[package]] name = "packaging" -version = "25.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -461,13 +491,13 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -614,74 +644,79 @@ files = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60"}, + {file = "requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] [[package]] @@ -697,13 +732,13 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, - {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] diff --git a/pyproject.toml b/pyproject.toml index 3fc5c4a..f932f55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.4.0" +version = "0.5.0" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 7f0aa00..9bb41bb 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.4.0 +%global upstream_version 0.5.0 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Tue May 11 2026 Miguel Jacq - %{version}-%{release} +- Support ssh configs * Tue Jan 06 2026 Miguel Jacq - %{version}-%{release} - Support converting systemd files and postfix main.cf * Tue Dec 30 2025 Miguel Jacq - %{version}-%{release} diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 74fdd8e..82b6775 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -25,7 +25,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: "config", help=( "Path to a config file OR a folder containing supported config files. " - "Supported: .toml, .yaml/.yml, .json, .ini/.cfg/.conf, .xml" + "Supported: .toml, .yaml/.yml, .json, .ini/.cfg/.conf, .xml, ssh_config/sshd_config" ), ) ap.add_argument( @@ -42,7 +42,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: ap.add_argument( "-f", "--format", - choices=["ini", "json", "toml", "yaml", "xml", "postfix", "systemd"], + choices=["ini", "json", "toml", "yaml", "xml", "postfix", "systemd", "ssh"], help="Force config format instead of auto-detecting from filename.", ) ap.add_argument( diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index d53f182..f854c1e 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -17,6 +17,7 @@ from .handlers import ( XmlHandler, PostfixMainHandler, SystemdUnitHandler, + SshConfigHandler, ) @@ -61,6 +62,7 @@ _XML_HANDLER = XmlHandler() _POSTFIX_HANDLER = PostfixMainHandler() _SYSTEMD_HANDLER = SystemdUnitHandler() +_SSH_HANDLER = SshConfigHandler() _HANDLERS["ini"] = _INI_HANDLER _HANDLERS["json"] = _JSON_HANDLER @@ -70,6 +72,7 @@ _HANDLERS["xml"] = _XML_HANDLER _HANDLERS["postfix"] = _POSTFIX_HANDLER _HANDLERS["systemd"] = _SYSTEMD_HANDLER +_HANDLERS["ssh"] = _SSH_HANDLER def dump_yaml(data: Any, *, sort_keys: bool = True) -> str: @@ -132,6 +135,72 @@ def _looks_like_systemd(text: str) -> bool: return False +def _looks_like_ssh_config(text: str) -> bool: + """Conservatively sniff OpenSSH config snippets. + + This is intentionally stricter than generic key/value detection so random + .conf files are not misclassified. Exact ssh_config/sshd_config filenames + are handled separately above. + """ + meaningful: list[str] = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + meaningful.append(stripped) + if len(meaningful) >= 20: + break + + if not meaningful: + return False + + ssh_keywords = { + "acceptenv", + "addressfamily", + "allowgroups", + "allowtcpforwarding", + "allowusers", + "authenticationmethods", + "authorizedkeysfile", + "banner", + "ciphers", + "chrootdirectory", + "denyusers", + "forcecommand", + "forwardagent", + "host", + "hostbasedauthentication", + "hostkey", + "hostname", + "identityfile", + "include", + "kexalgorithms", + "listenaddress", + "loglevel", + "match", + "passwordauthentication", + "permitrootlogin", + "port", + "proxycommand", + "proxyjump", + "pubkeyauthentication", + "sendenv", + "subsystem", + "user", + "x11forwarding", + } + + hits = 0 + for line in meaningful: + m = re.match(r"^([^\s=#]+)", line) + if not m: + continue + if m.group(1).lower() in ssh_keywords: + hits += 1 + + return hits >= 2 or (len(meaningful) <= 3 and hits >= 1) + + def detect_format(path: Path, explicit: str | None = None) -> str: """ Determine config format. @@ -168,6 +237,8 @@ def detect_format(path: Path, explicit: str | None = None) -> str: # well-known filenames if name == "main.cf": return "postfix" + if name in {"ssh_config", "sshd_config"}: + return "ssh" head = _read_head(path) @@ -179,6 +250,8 @@ def detect_format(path: Path, explicit: str | None = None) -> str: if suffix in {".conf", ".cf"}: if name == "main.cf": return "postfix" + if _looks_like_ssh_config(head): + return "ssh" return "ini" # Fallback: treat as INI-ish diff --git a/src/jinjaturtle/handlers/__init__.py b/src/jinjaturtle/handlers/__init__.py index 97074c5..c712c83 100644 --- a/src/jinjaturtle/handlers/__init__.py +++ b/src/jinjaturtle/handlers/__init__.py @@ -10,6 +10,7 @@ from .xml import XmlHandler from .postfix import PostfixMainHandler from .systemd import SystemdUnitHandler +from .ssh import SshConfigHandler __all__ = [ "BaseHandler", @@ -21,4 +22,5 @@ __all__ = [ "XmlHandler", "PostfixMainHandler", "SystemdUnitHandler", + "SshConfigHandler", ] diff --git a/src/jinjaturtle/handlers/ssh.py b/src/jinjaturtle/handlers/ssh.py new file mode 100644 index 0000000..bedaa2a --- /dev/null +++ b/src/jinjaturtle/handlers/ssh.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from pathlib import Path +from typing import Any + +from . import BaseHandler + + +_SECTION_KEYWORDS = {"host", "match"} + + +@dataclass +class SshConfigLine: + kind: str # 'blank' | 'comment' | 'kv' | 'raw' + raw: str + lineno: int + key: str | None = None + value: str | None = None + processed_value: str | None = None + comment: str = "" + whitespace_before_comment: str = "" + before_value: str = "" + newline: str = "" + quoted: bool = False + is_section: bool = False + section_kind: str | None = None + section_label: str | None = None + context_kind: str | None = None + context_label: str | None = None + occ_index: int | None = None + + +@dataclass +class SshConfig: + lines: list[SshConfigLine] + + +class SshConfigHandler(BaseHandler): + """ + Handler for OpenSSH ssh_config/sshd_config-style files. + + The format is intentionally treated as keyword + raw argument string rather + than attempting to understand the argument type for every OpenSSH keyword. + This preserves directives whose values are naturally whitespace-separated, + comma-separated, colon-separated, commands, token strings, or quoted strings. + + Host and Match are treated as section headers for variable naming while + still templating their criteria like ordinary keyword/argument lines. + """ + + fmt = "ssh" + + def parse(self, path: Path) -> SshConfig: + text = path.read_text(encoding="utf-8") + return self._parse_text(text) + + def _parse_text(self, text: str) -> SshConfig: + lines = text.splitlines(keepends=True) + out: list[SshConfigLine] = [] + current_kind: str | None = None + current_label: str | None = None + for lineno, raw_line in enumerate(lines, start=1): + content, newline = self._split_newline(raw_line) + stripped = content.strip() + + if not stripped: + out.append( + SshConfigLine( + kind="blank", raw=raw_line, lineno=lineno, newline=newline + ) + ) + continue + + if content.lstrip(" \t").startswith("#"): + out.append( + SshConfigLine( + kind="comment", raw=raw_line, lineno=lineno, newline=newline + ) + ) + continue + + parsed = self._split_keyword_value(content) + if parsed is None: + out.append( + SshConfigLine( + kind="raw", raw=raw_line, lineno=lineno, newline=newline + ) + ) + continue + + key, before_value, value_and_comment = parsed + value_part, comment = self._split_inline_comment(value_and_comment, {"#"}) + whitespace_before_comment = value_part[len(value_part.rstrip(" \t")) :] + raw_value = value_part.strip() + + quoted = ( + len(raw_value) >= 2 + and raw_value[0] == raw_value[-1] + and raw_value[0] in {'"', "'"} + ) + processed_value = raw_value[1:-1] if quoted else raw_value + + key_lower = key.lower() + is_section = key_lower in _SECTION_KEYWORDS + section_kind = key if is_section else None + section_label = None + context_kind = current_kind + context_label = current_label + + if is_section: + # Host/Match lines define the context for subsequent lines, but + # the line itself is kept globally named as the section marker. + base_label = self._section_label(processed_value, lineno) + section_label = base_label + current_kind = key + current_label = base_label + context_kind = None + context_label = None + + out.append( + SshConfigLine( + kind="kv", + raw=raw_line, + lineno=lineno, + key=key, + value=raw_value, + processed_value=processed_value, + comment=comment, + whitespace_before_comment=whitespace_before_comment, + before_value=before_value, + newline=newline, + quoted=quoted, + is_section=is_section, + section_kind=section_kind, + section_label=section_label, + context_kind=context_kind, + context_label=context_label, + ) + ) + + unit = SshConfig(lines=out) + self._assign_occurrences(unit) + return unit + + @staticmethod + def _split_newline(raw_line: str) -> tuple[str, str]: + if raw_line.endswith("\r\n"): + return raw_line[:-2], "\r\n" + if raw_line.endswith("\n"): + return raw_line[:-1], "\n" + return raw_line, "" + + @staticmethod + def _split_keyword_value(content: str) -> tuple[str, str, str] | None: + """Return (key, text_before_value, value_and_comment).""" + m = re.match(r"^([ \t]*)([^\s=#]+)(.*)$", content) + if not m: + return None + + leading, key, rest = m.groups() + # Keep the original spelling/indentation for rendering, but allow either: + # Keyword value + # Keyword=value + # Keyword = value + i = 0 + while i < len(rest) and rest[i] in " \t": + i += 1 + if i < len(rest) and rest[i] == "=": + i += 1 + while i < len(rest) and rest[i] in " \t": + i += 1 + elif i == 0 and rest: + # Some unexpected non-whitespace separator. Treat as raw. + return None + + before_value = leading + key + rest[:i] + value_and_comment = rest[i:] + return key, before_value, value_and_comment + + def _section_label(self, value: str | None, lineno: int) -> str: + raw = (value or "").strip() + if not raw: + return f"line_{lineno}" + # Use the same sanitisation semantics as variable names, but without a + # role prefix. This keeps labels such as '*' useful instead of empty. + label = self.make_var_name("x", (raw,)) + if label.startswith("x_"): + label = label[2:] + elif label == "x": + label = "all" + label = re.sub(r"_+", "_", label).strip("_") + return label or f"line_{lineno}" + + def _base_path(self, ln: SshConfigLine) -> tuple[str, ...]: + if ln.kind != "kv" or not ln.key: + return () + if ln.is_section and ln.section_kind and ln.section_label: + return (ln.section_kind, ln.section_label) + if ln.context_kind and ln.context_label: + return (ln.context_kind, ln.context_label, ln.key) + return (ln.key,) + + def _assign_occurrences(self, parsed: SshConfig) -> None: + counts: dict[tuple[str, ...], int] = {} + for ln in parsed.lines: + if ln.kind == "kv": + base = self._base_path(ln) + if base: + counts[base] = counts.get(base, 0) + 1 + + seen: dict[tuple[str, ...], int] = {} + for ln in parsed.lines: + if ln.kind != "kv": + continue + base = self._base_path(ln) + if not base: + continue + idx = seen.get(base, 0) + seen[base] = idx + 1 + if counts.get(base, 0) > 1: + ln.occ_index = idx + + def _path_for_line(self, ln: SshConfigLine) -> tuple[str, ...]: + base = self._base_path(ln) + if ln.occ_index is not None: + return base + (str(ln.occ_index),) + return base + + def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: + if not isinstance(parsed, SshConfig): + raise TypeError("SSH config parse result must be an SshConfig") + + items: list[tuple[tuple[str, ...], Any]] = [] + for ln in parsed.lines: + if ln.kind != "kv": + continue + path = self._path_for_line(ln) + if not path: + continue + items.append((path, ln.processed_value or "")) + return items + + def generate_jinja2_template( + self, + parsed: Any, + role_prefix: str, + original_text: str | None = None, + ) -> str: + if not isinstance(parsed, SshConfig): + raise TypeError("SSH config parse result must be an SshConfig") + + out_lines: list[str] = [] + for ln in parsed.lines: + if ln.kind != "kv": + out_lines.append(ln.raw) + continue + + path = self._path_for_line(ln) + if not path: + out_lines.append(ln.raw) + continue + + var = self.make_var_name(role_prefix, path) + if ln.quoted and ln.value: + quote_char = ln.value[0] + replacement_value = f"{quote_char}{{{{ {var} }}}}{quote_char}" + else: + replacement_value = f"{{{{ {var} }}}}" + + rendered = ( + f"{ln.before_value}{replacement_value}" + f"{ln.whitespace_before_comment}{ln.comment}{ln.newline}" + ) + out_lines.append(rendered) + + return "".join(out_lines) diff --git a/tests/test_ssh_format.py b/tests/test_ssh_format.py new file mode 100644 index 0000000..0a2aacc --- /dev/null +++ b/tests/test_ssh_format.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path + +from jinja2 import Environment, StrictUndefined +import yaml + +import jinjaturtle.core as core + + +def _render(template: str, variables: dict) -> str: + env = Environment(undefined=StrictUndefined, keep_trailing_newline=True) + return env.from_string(template).render(**variables) + + +def test_sshd_config_parsing_match_sections_repeated_keys_and_roundtrip( + tmp_path: Path, +) -> None: + p = tmp_path / "sshd_config" + original = ( + "# global sshd settings\n" + "Port 22\n" + "ListenAddress 0.0.0.0\n" + "ListenAddress ::\n" + "AuthenticationMethods publickey,password publickey,keyboard-interactive\n" + "Subsystem sftp /usr/lib/openssh/sftp-server # keep sftp\n" + "Match User deploy Address 10.0.0.0/8\n" + " X11Forwarding no\n" + ' ForceCommand "internal-sftp -u 0002"\n' + ) + p.write_text(original, encoding="utf-8") + + fmt, parsed = core.parse_config(p) + assert fmt == "ssh" + + flat = core.flatten_config(fmt, parsed) + assert (("Port",), "22") in flat + assert (("ListenAddress", "0"), "0.0.0.0") in flat + assert (("ListenAddress", "1"), "::") in flat + assert ( + ("AuthenticationMethods",), + "publickey,password publickey,keyboard-interactive", + ) in flat + assert ( + ("Match", "user_deploy_address_10_0_0_0_8"), + "User deploy Address 10.0.0.0/8", + ) in flat + assert ( + ("Match", "user_deploy_address_10_0_0_0_8", "ForceCommand"), + "internal-sftp -u 0002", + ) in flat + + ansible_yaml = core.generate_ansible_yaml("role", flat) + variables = yaml.safe_load(ansible_yaml) + template = core.generate_jinja2_template( + fmt, parsed, role_prefix="role", original_text=original + ) + + assert "ListenAddress {{ role_listenaddress_0 }}" in template + assert "ListenAddress {{ role_listenaddress_1 }}" in template + assert "Match {{ role_match_user_deploy_address_10_0_0_0_8 }}" in template + assert ( + 'ForceCommand "{{ role_match_user_deploy_address_10_0_0_0_8_forcecommand }}"' + in template + ) + assert _render(template, variables) == original + + +def test_ssh_config_host_and_match_sections_equal_separator(tmp_path: Path) -> None: + p = tmp_path / "ssh_config" + original = ( + "Include /etc/ssh/ssh_config.d/*.conf\n" + "Host bastion *.corp.example\n" + " User ops\n" + " ProxyCommand=ssh -W %h:%p jump.example\n" + 'Match exec "test %p = 22"\n' + " ServerAliveInterval 30\n" + ) + p.write_text(original, encoding="utf-8") + + fmt, parsed = core.parse_config(p) + assert fmt == "ssh" + + flat = core.flatten_config(fmt, parsed) + assert (("Host", "bastion_corp_example"), "bastion *.corp.example") in flat + assert ( + ("Host", "bastion_corp_example", "ProxyCommand"), + "ssh -W %h:%p jump.example", + ) in flat + assert (("Match", "exec_test_p_22"), 'exec "test %p = 22"') in flat + + ansible_yaml = core.generate_ansible_yaml("ssh", flat) + variables = yaml.safe_load(ansible_yaml) + template = core.generate_jinja2_template(fmt, parsed, role_prefix="ssh") + + assert "ProxyCommand={{ ssh_host_bastion_corp_example_proxycommand }}" in template + assert _render(template, variables) == original + + +def test_ssh_config_snippet_conf_detected_by_content(tmp_path: Path) -> None: + p = tmp_path / "50-hardening.conf" + p.write_text("PasswordAuthentication no\nPermitRootLogin no\n", encoding="utf-8") + + fmt, parsed = core.parse_config(p) + assert fmt == "ssh" + assert (("PasswordAuthentication",), "no") in core.flatten_config(fmt, parsed) From d9bf2966c63d97b50e06321a722c2f6e5e77b071 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 17:41:56 +1000 Subject: [PATCH 10/44] Empty dicts and lists are now emitted as leaf defaults. --- src/jinjaturtle/handlers/dict.py | 6 ++++++ tests/test_yaml_handler.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/jinjaturtle/handlers/dict.py b/src/jinjaturtle/handlers/dict.py index eb8d926..2d5ef6b 100644 --- a/src/jinjaturtle/handlers/dict.py +++ b/src/jinjaturtle/handlers/dict.py @@ -19,9 +19,15 @@ class DictLikeHandler(BaseHandler): def _walk(obj: Any, path: tuple[str, ...] = ()) -> None: if isinstance(obj, dict): + if not obj: + items.append((path, obj)) + return for k, v in obj.items(): _walk(v, path + (str(k),)) elif isinstance(obj, list) and self.flatten_lists: + if not obj: + items.append((path, obj)) + return for i, v in enumerate(obj): _walk(v, path + (str(i),)) else: diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index c7bacb7..fb4a637 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -100,3 +100,22 @@ def test_generate_jinja2_template_yaml_structural_fallback(): # We don't care about exact formatting, just that the expected variable # name shows up, proving we went through the structural path. assert "role_outer_inner" in tmpl + + +def test_yaml_empty_collection_defaults_match_template_vars(tmp_path: Path): + yaml_path = tmp_path / "pdk.yaml" + yaml_path.write_text("ignore: []\nsettings: {}\n", encoding="utf-8") + + fmt, parsed = parse_config(yaml_path) + flat_items = flatten_config(fmt, parsed) + ansible_yaml = generate_ansible_yaml("role", flat_items) + defaults = yaml.safe_load(ansible_yaml) + + assert defaults["role_ignore"] == [] + assert defaults["role_settings"] == {} + + template = generate_jinja2_template( + fmt, parsed, "role", original_text=yaml_path.read_text(encoding="utf-8") + ) + assert "{{ role_ignore }}" in template + assert "{{ role_settings }}" in template From 39ba01689c539288987c5bf682a05e5f1251dec5 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 17:45:23 +1000 Subject: [PATCH 11/44] Update dependencies --- debian/changelog | 6 ++ poetry.lock | 234 +++++++++++++++++++++---------------------- pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 +- 4 files changed, 127 insertions(+), 119 deletions(-) diff --git a/debian/changelog b/debian/changelog index 42a966a..25414d1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.1) unstable; urgency=medium + + * Empty dicts and lists are now emitted as leaf defaults. + + -- Miguel Jacq Fri, 19 Jun 2026 17:43:00 +1000 + jinjaturtle (0.5.0) unstable; urgency=medium * Support ssh configs diff --git a/poetry.lock b/poetry.lock index 2717000..bc7ba3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, - {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -162,117 +162,117 @@ files = [ [[package]] name = "coverage" -version = "7.14.0" +version = "7.14.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"}, - {file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"}, - {file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"}, - {file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"}, - {file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"}, - {file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"}, - {file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"}, - {file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"}, - {file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"}, - {file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"}, - {file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"}, - {file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"}, - {file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"}, - {file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"}, - {file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"}, - {file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"}, - {file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"}, - {file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"}, - {file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"}, - {file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"}, - {file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"}, - {file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"}, - {file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"}, - {file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"}, - {file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"}, - {file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"}, - {file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"}, - {file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"}, - {file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"}, - {file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"}, - {file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"}, - {file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"}, - {file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"}, - {file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"}, - {file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"}, - {file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"}, + {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, + {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, + {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, + {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, + {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, + {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, + {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, + {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, + {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, + {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, + {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, + {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, + {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, + {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, + {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, + {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, + {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, + {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, + {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, + {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, + {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, + {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, + {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, + {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, + {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, + {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, + {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, + {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, + {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, + {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, + {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"}, + {file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"}, + {file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"}, + {file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"}, + {file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"}, + {file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"}, ] [package.dependencies] @@ -325,13 +325,13 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.14" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69"}, - {file = "idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] @@ -644,13 +644,13 @@ files = [ [[package]] name = "requests" -version = "2.34.0" +version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" files = [ - {file = "requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60"}, - {file = "requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index f932f55..1e80645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.0" +version = "0.5.1" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 9bb41bb..ee4305f 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.0 +%global upstream_version 0.5.1 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Empty dicts and lists are now emitted as leaf defaults. * Tue May 11 2026 Miguel Jacq - %{version}-%{release} - Support ssh configs * Tue Jan 06 2026 Miguel Jacq - %{version}-%{release} From 1413076c9c14e43f996eb6a62891aeddda76a94a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 17:46:56 +1000 Subject: [PATCH 12/44] Update release script --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.sh b/release.sh index fcc84ed..2cdb868 100755 --- a/release.sh +++ b/release.sh @@ -48,7 +48,7 @@ sudo apt-get -y install createrepo-c rpm BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist" KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99" REPO_ROOT="${HOME}/git/repo_rpm" -REMOTE="letessier.mig5.net:/opt/repo_rpm" +REMOTE="ashpool.mig5.net:/opt/repo_rpm" DISTS=( fedora:43 From 77d1658e65540fa1ebcf0e2e8aae3403b9c2288f Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 18:38:35 +1000 Subject: [PATCH 13/44] Fix indentation with nested dicts --- debian/changelog | 6 ++++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 ++- src/jinjaturtle/handlers/yaml.py | 54 ++++++++++++++++++-------------- tests/test_yaml_handler.py | 45 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 26 deletions(-) diff --git a/debian/changelog b/debian/changelog index 25414d1..3b866b0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.2) unstable; urgency=medium + + * Fix indentation problems with nested dicts + + -- Miguel Jacq Fri, 19 Jun 2026 18:33:00 +1000 + jinjaturtle (0.5.1) unstable; urgency=medium * Empty dicts and lists are now emitted as leaf defaults. diff --git a/pyproject.toml b/pyproject.toml index 1e80645..11f17ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.1" +version = "0.5.2" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index ee4305f..2b26be0 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.1 +%global upstream_version 0.5.2 Name: jinjaturtle Version: %{upstream_version} @@ -43,6 +43,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %changelog * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Fix indentation problems with nested dicts +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Empty dicts and lists are now emitted as leaf defaults. * Tue May 11 2026 Miguel Jacq - %{version}-%{release} - Support ssh configs diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index f75ef4b..c66cf9a 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -208,12 +208,21 @@ class YamlHandler(DictLikeHandler): stripped = raw_line.lstrip() indent = len(raw_line) - len(stripped) - # If we're skipping lines (inside a loop section), check if we can stop + # If we're skipping lines inside a collection replaced by a loop, + # continue through YAML's indentless sequence style too, where list + # items can appear at the same indentation as the parent key: + # + # images: + # - ubuntu + # - debian + # + # Stop only when a non-list item at the parent indentation appears, + # or when indentation moves above the parent collection. if skip_until_indent is not None: - if ( - indent <= skip_until_indent - and stripped - and not stripped.startswith("#") + if not stripped or stripped.startswith("#"): + continue + if indent < skip_until_indent or ( + indent == skip_until_indent and not stripped.startswith("- ") ): skip_until_indent = None else: @@ -374,39 +383,36 @@ class YamlHandler(DictLikeHandler): collection_var = self.make_var_name(role_prefix, candidate.path) item_var = candidate.loop_var - lines = [] - + lines: list[str] = [] if not is_list: - # Dict-style: key: {% for ... %} key = candidate.path[-1] if candidate.path else "items" lines.append(f"{indent_str}{key}:") - lines.append(f"{indent_str} {{% for {item_var} in {collection_var} -%}}") - else: - # List-style: just the loop - lines.append(f"{indent_str}{{% for {item_var} in {collection_var} -%}}") - # Generate template for item structure + item_lines: list[str] = [] if candidate.items: sample_item = candidate.items[0] item_indent = indent + 2 if not is_list else indent + item_indent_str = " " * item_indent if candidate.item_schema == "scalar": - # Simple list of scalars - if is_list: - lines.append(f"{indent_str}- {{{{ {item_var} }}}}") - else: - lines.append(f"{indent_str} - {{{{ {item_var} }}}}") - + item_lines.append(f"{item_indent_str}- {{{{ {item_var} }}}}") elif candidate.item_schema in ("simple_dict", "nested"): - # List of dicts or complex items - these are ALWAYS list items in YAML item_lines = self._dict_to_yaml_lines( sample_item, item_var, item_indent, is_list_item=True ) - lines.extend(item_lines) - # Close loop - close_indent = indent + 2 if not is_list else indent - lines.append(f"{' ' * close_indent}{{% endfor %}}") + if item_lines: + # Put the first YAML item on the same physical line as the Jinja + # ``for`` tag. With default Jinja whitespace settings this avoids + # rendering a blank line after the parent key. Keeping the control + # tag itself at column zero prevents its indentation from leaking + # into the rendered YAML and nesting the next top-level key. + lines.append(f"{{% for {item_var} in {collection_var} %}}{item_lines[0]}") + lines.extend(item_lines[1:]) + lines.append("{% endfor %}") + else: + lines.append(f"{{% for {item_var} in {collection_var} %}}") + lines.append("{% endfor %}") return "\n".join(lines) + "\n" diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index fb4a637..eb740a9 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -119,3 +119,48 @@ def test_yaml_empty_collection_defaults_match_template_vars(tmp_path: Path): ) assert "{{ role_ignore }}" in template assert "{{ role_settings }}" in template + + +def test_yaml_indentless_sequence_loop_roundtrips_semantically(tmp_path: Path): + """Indentless YAML sequences under a mapping key must be fully replaced. + + A previous loop renderer emitted a loop at ``images:`` but then processed + the original ``- item`` lines again because they had the same indentation + as the parent key. That duplicated values and nested following top-level + keys incorrectly. + """ + from jinja2 import Template + + from jinjaturtle.core import analyze_loops + + text = textwrap.dedent( + """ + default: + provisioner: docker + images: + - waffleimage/ubuntu18.04 + - waffleimage/centos7 + vagrant: + provisioner: vagrant + images: + - centos/7 + - generic/ubuntu1804 + """ + ).lstrip() + path = tmp_path / "provision.yaml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + + assert yaml.safe_load(rendered) == yaml.safe_load(text) + assert " - waffleimage/ubuntu18.04" not in template + assert " vagrant:" not in template From 3d53d4fb3027dc154a5ede58cf5a5f5578a0923c Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 18:46:04 +1000 Subject: [PATCH 14/44] Fix loss of comments and True/False to true/false --- debian/changelog | 6 +++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 +- src/jinjaturtle/handlers/yaml.py | 46 ++++++++++++++++++---- tests/test_yaml_handler.py | 65 ++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/debian/changelog b/debian/changelog index 3b866b0..5ab9ef4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.3) unstable; urgency=medium + + * Fix loss of comments and True/False to true/false + + -- Miguel Jacq Fri, 19 Jun 2026 18:43:00 +1000 + jinjaturtle (0.5.2) unstable; urgency=medium * Fix indentation problems with nested dicts diff --git a/pyproject.toml b/pyproject.toml index 11f17ce..039c55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.2" +version = "0.5.3" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 2b26be0..8d0da22 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.2 +%global upstream_version 0.5.3 Name: jinjaturtle Version: %{upstream_version} @@ -43,6 +43,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %changelog * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Fix loss of comments and True/False to true/false +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Fix indentation problems with nested dicts * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Empty dicts and lists are now emitted as leaf defaults. diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index c66cf9a..be1bd8d 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -59,6 +59,28 @@ class YamlHandler(DictLikeHandler): role_prefix, dumped, loop_candidates, loop_paths ) + def _yaml_scalar_expr(self, var_name: str, raw_value: str | None = None) -> str: + """Return a Jinja expression that preserves YAML scalar spelling. + + Plain ``{{ var }}`` renders Python booleans as ``True``/``False``. + YAML config files conventionally use ``true``/``false`` and some + consumers are stricter than PyYAML, so emit explicit YAML spelling for + values that were originally YAML booleans/nulls. + """ + raw = (raw_value or "").strip().lower() + if raw in {"true", "false"}: + return f"{{{{ 'true' if {var_name} else 'false' }}}}" + if raw in {"null", "~"}: + return f"{{{{ 'null' if {var_name} is none else {var_name} }}}}" + return f"{{{{ {var_name} }}}}" + + def _yaml_value_expr(self, value_expr: str, sample_value: Any | None = None) -> str: + if isinstance(sample_value, bool): + return f"{{{{ 'true' if {value_expr} else 'false' }}}}" + if sample_value is None: + return f"{{{{ 'null' if {value_expr} is none else {value_expr} }}}}" + return f"{{{{ {value_expr} }}}}" + def _generate_yaml_template_from_text( self, role_prefix: str, @@ -121,7 +143,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] new_rest = f"{leading}{replacement}{comment_part}" @@ -160,7 +182,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) new_stripped = f"- {replacement}{comment_part}" out_lines.append( @@ -220,6 +242,12 @@ class YamlHandler(DictLikeHandler): # or when indentation moves above the parent collection. if skip_until_indent is not None: if not stripped or stripped.startswith("#"): + if indent <= skip_until_indent: + skip_until_indent = None + out_lines.append(raw_line) + # Comments/blank lines indented beneath the replaced + # collection are considered part of that collection and + # cannot be placed safely inside a generated loop. continue if indent < skip_until_indent or ( indent == skip_until_indent and not stripped.startswith("- ") @@ -288,7 +316,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] new_rest = f"{leading}{replacement}{comment_part}" @@ -345,7 +373,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) new_stripped = f"- {replacement}{comment_part}" out_lines.append( @@ -395,7 +423,9 @@ class YamlHandler(DictLikeHandler): item_indent_str = " " * item_indent if candidate.item_schema == "scalar": - item_lines.append(f"{item_indent_str}- {{{{ {item_var} }}}}") + item_lines.append( + f"{item_indent_str}- {self._yaml_value_expr(item_var, sample_item)}" + ) elif candidate.item_schema in ("simple_dict", "nested"): item_lines = self._dict_to_yaml_lines( sample_item, item_var, item_indent, is_list_item=True @@ -447,11 +477,13 @@ class YamlHandler(DictLikeHandler): if first_key and is_list_item: # First key gets the list marker - lines.append(f"{indent_str}- {key}: {{{{ {loop_var}.{key} }}}}") + value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) + lines.append(f"{indent_str}- {key}: {value_expr}") first_key = False else: # Subsequent keys are indented sub_indent = indent + 2 if is_list_item else indent - lines.append(f"{' ' * sub_indent}{key}: {{{{ {loop_var}.{key} }}}}") + value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) + lines.append(f"{' ' * sub_indent}{key}: {value_expr}") return lines diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index eb740a9..41edc07 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -164,3 +164,68 @@ def test_yaml_indentless_sequence_loop_roundtrips_semantically(tmp_path: Path): assert yaml.safe_load(rendered) == yaml.safe_load(text) assert " - waffleimage/ubuntu18.04" not in template assert " vagrant:" not in template + + +def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path): + from jinja2 import Template + + from jinjaturtle.core import analyze_loops + + text = textwrap.dedent( + """ + Style/HashExcept: + Exclude: + - lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb + - spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb + + # Offense count: 2 + # This cop supports unsafe autocorrection (--autocorrect-all). + Style/MapIntoArray: + Exclude: + - lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb + """ + ).lstrip() + path = tmp_path / ".rubocop_todo.yml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + + assert "# Offense count: 2" in rendered + assert "# This cop supports unsafe autocorrection" in rendered + assert yaml.safe_load(rendered) == yaml.safe_load(text) + + +def test_yaml_bool_scalars_render_with_yaml_spelling(tmp_path: Path): + from jinja2 import Template + + text = textwrap.dedent( + """ + AllCops: + SuggestExtensions: false + Style/ClassAndModuleChildren: + Enabled: false + """ + ).lstrip() + path = tmp_path / ".rubocop.yml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + flat_items = flatten_config(fmt, parsed) + defaults = yaml.safe_load(generate_ansible_yaml("role", flat_items)) + template = generate_jinja2_template(fmt, parsed, "role", original_text=text) + rendered = Template(template).render(**defaults) + + assert "SuggestExtensions: false" in rendered + assert "Enabled: false" in rendered + assert "SuggestExtensions: False" not in rendered + assert "Enabled: False" not in rendered + assert yaml.safe_load(rendered) == yaml.safe_load(text) From 49fee7afe4ab691bb5c72adfab80b834277b4e15 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 20 Jun 2026 15:28:48 +1000 Subject: [PATCH 15/44] Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. More test coverage --- .gitignore | 2 + src/jinjaturtle/core.py | 8 ++ src/jinjaturtle/handlers/json.py | 151 ++++++++++++++++++++++-- src/jinjaturtle/handlers/toml.py | 11 +- src/jinjaturtle/handlers/yaml.py | 57 +++++++-- src/jinjaturtle/multi.py | 4 +- tests/test_cli.py | 70 +++++++++++ tests/test_json_handler.py | 142 ++++++++++++++++++++-- tests/test_multi.py | 125 ++++++++++++++++++++ tests/test_roundtrip.py | 1 + tests/test_yaml_handler.py | 31 +++++ tests/test_yaml_template_consistency.py | 29 ++--- 12 files changed, 579 insertions(+), 52 deletions(-) create mode 100644 tests/test_multi.py diff --git a/.gitignore b/.gitignore index dedc5da..5a5117f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist *.j2 *.toml regenerated_* +*.orig +*.rej diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index f854c1e..ff6a586 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -276,7 +276,15 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: def analyze_loops(fmt: str, parsed: Any) -> list[LoopCandidate]: """ Analyze parsed config to find loop opportunities. + + JSON files are intentionally kept scalar/index-based instead of being + collapsed into generated loops. JSON is commonly checked byte-for-byte by + configuration management tools, and preserving inline arrays/objects is + more valuable than reducing variable count. """ + if fmt == "json": + return [] + analyzer = LoopAnalyzer() candidates = analyzer.analyze(parsed, fmt) diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index a0b0017..9d763a4 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -23,13 +23,15 @@ class JsonHandler(DictLikeHandler): role_prefix: str, original_text: str | None = None, ) -> str: - """Original scalar-only template generation.""" + """Generate a scalar JSON template while preserving source formatting.""" if not isinstance(parsed, (dict, list)): raise TypeError("JSON parser result must be a dict or list") - # As before: ignore original_text and rebuild structurally + if original_text is not None: + return self._generate_json_template_from_text(role_prefix, original_text) return self._generate_json_template(role_prefix, parsed) JSON_INDENT = 2 + JSON_VALUE_FILTER = "to_json(ensure_ascii=False)" def _leading_indent(self, s: str, idx: int) -> int: """Return the number of leading spaces on the line containing idx.""" @@ -75,6 +77,129 @@ class JsonHandler(DictLikeHandler): role_prefix, parsed, loop_paths, loop_candidates ) + def _json_value_expr(self, var_name: str) -> str: + """Return a Jinja expression for a JSON value. + + Jinja's built-in ``tojson`` filter is HTML-safe and therefore escapes + characters such as ``<`` and ``>`` as ``\u003c``/``\u003e``. That is + useful in HTML, but noisy in configuration files. JinjaTurtle generates + Ansible templates, so use Ansible's ``to_json`` filter instead. + """ + return f"{{{{ {var_name} | {self.JSON_VALUE_FILTER} }}}}" + + def _generate_json_template_from_text(self, role_prefix: str, text: str) -> str: + """Replace JSON scalar values in-place, preserving original formatting. + + The older JSON path parsed the file and wrote it back with + ``json.dumps(indent=2)``, which caused cosmetic diffs such as changing + four-space indentation to two-space indentation and adding a final + newline to files that intentionally lacked one. This scanner walks the + original JSON source and only replaces scalar value tokens with Jinja2 + expressions; all whitespace, object/list indentation, key ordering, and + final newline state are left untouched. + """ + spans = self._collect_json_scalar_spans(text) + if spans is None: + # Should be rare because the caller has already parsed the JSON, but + # keep the structural fallback rather than failing template creation. + parsed = json.loads(text) + return self._generate_json_template(role_prefix, parsed) + + chunks: list[str] = [] + pos = 0 + for path, start, end in spans: + chunks.append(text[pos:start]) + chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path))) + pos = end + chunks.append(text[pos:]) + return "".join(chunks) + + def _collect_json_scalar_spans( + self, text: str + ) -> list[tuple[tuple[str, ...], int, int]] | None: + """Return source spans for JSON scalar *values*. + + Keys are parsed to determine the current path but are not returned. + """ + decoder = json.JSONDecoder() + spans: list[tuple[tuple[str, ...], int, int]] = [] + + def skip_ws(i: int) -> int: + while i < len(text) and text[i] in " \t\r\n": + i += 1 + return i + + def raw_decode_at(i: int) -> tuple[Any, int]: + return decoder.raw_decode(text, i) + + def parse_value(i: int, path: tuple[str, ...]) -> int: + i = skip_ws(i) + if i >= len(text): + raise ValueError("unexpected end of JSON") + ch = text[i] + if ch == "{": + return parse_object(i, path) + if ch == "[": + return parse_array(i, path) + + _value, end = raw_decode_at(i) + spans.append((path, i, end)) + return end + + def parse_object(i: int, path: tuple[str, ...]) -> int: + i += 1 # { + i = skip_ws(i) + if i < len(text) and text[i] == "}": + return i + 1 + + while True: + i = skip_ws(i) + if i >= len(text) or text[i] != '"': + raise ValueError("expected JSON object key") + key, i = raw_decode_at(i) + if not isinstance(key, str): + raise ValueError("expected JSON object key string") + + i = skip_ws(i) + if i >= len(text) or text[i] != ":": + raise ValueError("expected ':' after JSON object key") + i = parse_value(i + 1, path + (key,)) + i = skip_ws(i) + + if i < len(text) and text[i] == ",": + i += 1 + continue + if i < len(text) and text[i] == "}": + return i + 1 + raise ValueError("expected ',' or '}' in JSON object") + + def parse_array(i: int, path: tuple[str, ...]) -> int: + i += 1 # [ + i = skip_ws(i) + if i < len(text) and text[i] == "]": + return i + 1 + + index = 0 + while True: + i = parse_value(i, path + (str(index),)) + index += 1 + i = skip_ws(i) + + if i < len(text) and text[i] == ",": + i += 1 + continue + if i < len(text) and text[i] == "]": + return i + 1 + raise ValueError("expected ',' or ']' in JSON array") + + try: + end = parse_value(0, ()) + if skip_ws(end) != len(text): + return None + return spans + except (json.JSONDecodeError, ValueError, TypeError): + return None + def _generate_json_template(self, role_prefix: str, data: Any) -> str: """ Generate a JSON Jinja2 template from parsed JSON data. @@ -82,7 +207,7 @@ class JsonHandler(DictLikeHandler): All scalar values are replaced with Jinja expressions whose names are derived from the path, similar to TOML/YAML. - Uses | tojson filter to preserve types (numbers, booleans, null). + Uses | to_json filter to preserve types (numbers, booleans, null). """ def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any: @@ -90,17 +215,19 @@ class JsonHandler(DictLikeHandler): return {k: _walk(v, path + (str(k),)) for k, v in obj.items()} if isinstance(obj, list): return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)] - # scalar - use marker that will be replaced with tojson + # scalar - use marker that will be replaced with to_json var_name = self.make_var_name(role_prefix, path) return f"__SCALAR__{var_name}__" templated = _walk(data) json_str = json.dumps(templated, indent=2, ensure_ascii=False) - # Replace scalar markers with Jinja expressions using tojson filter + # Replace scalar markers with Jinja expressions using to_json filter # This preserves types (numbers stay numbers, booleans stay booleans) json_str = re.sub( - r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str + r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', + lambda m: self._json_value_expr(m.group(1)), + json_str, ) return json_str + "\n" @@ -150,9 +277,11 @@ class JsonHandler(DictLikeHandler): # Convert to JSON string json_str = json.dumps(templated, indent=2, ensure_ascii=False) - # Replace scalar markers with Jinja expressions using tojson filter + # Replace scalar markers with Jinja expressions using to_json filter json_str = re.sub( - r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str + r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', + lambda m: self._json_value_expr(m.group(1)), + json_str, ) # Post-process to replace loop markers with actual Jinja loops (indent-aware) @@ -201,7 +330,8 @@ class JsonHandler(DictLikeHandler): # a blank line between iterations under default Jinja whitespace settings. return ( f"[\n" - f"{{% for {item_var} in {collection_var} %}}{inner}{{{{ {item_var} | tojson }}}}" + f"{{% for {item_var} in {collection_var} %}}" + f"{inner}{{{{ {item_var} | to_json(ensure_ascii=False) }}}}" f"{{% if not loop.last %}},{{% endif %}}\n" f"{{% endfor %}}{base}]" ) @@ -234,7 +364,8 @@ class JsonHandler(DictLikeHandler): for i, key in enumerate(keys): comma = "," if i < len(keys) - 1 else "" dict_lines.append( - f'{field}"{key}": {{{{ {item_var}.{key} | tojson }}}}{comma}' + f'{field}"{key}": ' + f"{{{{ {item_var}.{key} | to_json(ensure_ascii=False) }}}}{comma}" ) # Comma between *items* goes after the closing brace. dict_lines.append(f"{inner}}}{{% if not loop.last %}},{{% endif %}}") diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index fe071bd..ba4bbb4 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -148,7 +148,10 @@ class TomlHandler(DictLikeHandler): elif candidate.item_schema in ("simple_dict", "nested"): # Dict list loop - TOML array of tables # This is complex for TOML, using simplified approach - lines.append(f"{key} = {{{{ {var_name} | tojson }}}}") + lines.append( + f"{key} = " + f"{{{{ {var_name} | to_json(ensure_ascii=False) }}}}" + ) else: # Not a loop, treat as regular variable lines.append(f"{key} = {{{{ {var_name} }}}}") @@ -477,8 +480,10 @@ class TomlHandler(DictLikeHandler): f"]" ) else: - # Dict/nested loop - use tojson filter for complex arrays - replacement_value = f"{{{{ {collection_var} | tojson }}}}" + # Dict/nested loop - use to_json filter for complex arrays + replacement_value = ( + f"{{{{ {collection_var} | to_json(ensure_ascii=False) }}}}" + ) new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index be1bd8d..7d35c9c 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -81,6 +81,15 @@ class YamlHandler(DictLikeHandler): return f"{{{{ 'null' if {value_expr} is none else {value_expr} }}}}" return f"{{{{ {value_expr} }}}}" + def _surrounding_quote(self, raw_value: str) -> str | None: + if ( + len(raw_value) >= 2 + and raw_value[0] == raw_value[-1] + and raw_value[0] in {'"', "'"} + ): + return raw_value[0] + return None + def _generate_yaml_template_from_text( self, role_prefix: str, @@ -226,7 +235,26 @@ class YamlHandler(DictLikeHandler): def current_path() -> tuple[str, ...]: return stack[-1][1] if stack else () - for raw_line in lines: + def first_sequence_item_quote( + start_index: int, parent_indent: int + ) -> str | None: + for future_line in lines[start_index + 1 :]: + future_stripped = future_line.lstrip() + future_indent = len(future_line) - len(future_stripped) + if not future_stripped or future_stripped.startswith("#"): + continue + if future_indent < parent_indent: + return None + if future_stripped.startswith("- "): + value_part, _comment_part = self._split_inline_comment( + future_stripped[2:], {"#"} + ) + return self._surrounding_quote(value_part.strip()) + if future_indent <= parent_indent: + return None + return None + + for line_index, raw_line in enumerate(lines): stripped = raw_line.lstrip() indent = len(raw_line) - len(stripped) @@ -287,8 +315,14 @@ class YamlHandler(DictLikeHandler): # Find the matching candidate candidate = next(c for c in loop_candidates if c.path == path) + scalar_quote = None + if candidate.item_schema == "scalar": + scalar_quote = first_sequence_item_quote(line_index, indent) + # Generate loop - loop_str = self._generate_yaml_loop(candidate, role_prefix, indent) + loop_str = self._generate_yaml_loop( + candidate, role_prefix, indent, scalar_quote=scalar_quote + ) out_lines.append(loop_str) # Skip subsequent lines that are part of this collection @@ -335,6 +369,10 @@ class YamlHandler(DictLikeHandler): stack.append((indent, parent_path, "seq")) parent_path = stack[-1][1] + content = stripped[2:] + value_part, _comment_part = self._split_inline_comment(content, {"#"}) + raw_value = value_part.strip() + scalar_quote = self._surrounding_quote(raw_value) # Check if parent path is a loop candidate if parent_path in loop_paths: @@ -345,7 +383,11 @@ class YamlHandler(DictLikeHandler): # Generate loop (with indent for the '-' items) loop_str = self._generate_yaml_loop( - candidate, role_prefix, indent, is_list=True + candidate, + role_prefix, + indent, + is_list=True, + scalar_quote=scalar_quote, ) out_lines.append(loop_str) @@ -353,7 +395,6 @@ class YamlHandler(DictLikeHandler): skip_until_indent = indent - 1 if indent > 0 else None continue - content = stripped[2:] index = seq_counters.get(parent_path, 0) seq_counters[parent_path] = index + 1 @@ -393,6 +434,7 @@ class YamlHandler(DictLikeHandler): role_prefix: str, indent: int, is_list: bool = False, + scalar_quote: str | None = None, ) -> str: """ Generate a Jinja2 for loop for a YAML collection. @@ -423,9 +465,10 @@ class YamlHandler(DictLikeHandler): item_indent_str = " " * item_indent if candidate.item_schema == "scalar": - item_lines.append( - f"{item_indent_str}- {self._yaml_value_expr(item_var, sample_item)}" - ) + value_expr = self._yaml_value_expr(item_var, sample_item) + if scalar_quote and isinstance(sample_item, str): + value_expr = f"{scalar_quote}{{{{ {item_var} }}}}{scalar_quote}" + item_lines.append(f"{item_indent_str}- {value_expr}") elif candidate.item_schema in ("simple_dict", "nested"): item_lines = self._dict_to_yaml_lines( sample_item, item_var, item_indent, is_list_item=True diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index 20cf544..d223b7e 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -16,7 +16,7 @@ Notes: * If the folder contains *multiple* formats, we generate one template per format (e.g. config.yaml.j2, config.xml.j2) and emit one list variable per format in the defaults YAML. - * JSON union templates are emitted using a simple `{{ data | tojson }}` + * JSON union templates are emitted using a simple `{{ data | to_json }}` approach to avoid comma-management complexity for optional keys. """ @@ -624,7 +624,7 @@ def process_directory( if multiple_formats else f"{role_prefix}_items" ) - template = "{{ data | tojson(indent=2) }}\n" + template = "{{ data | to_json(indent=2, ensure_ascii=False) }}\n" items: list[dict[str, Any]] = [] for rid, parsed in zip(rel_ids, parsed_list): items.append({"id": rid, "data": parsed}) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f299a1..c0d5470 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -62,3 +62,73 @@ def test_cli_writes_output_files(tmp_path, capsys): # When writing to files, we shouldn't print the big headers assert "# defaults/main.yml" not in captured.out assert "# config.j2" not in captured.out + + +def test_cli_folder_stdout(tmp_path, capsys): + """Folder mode without output paths prints defaults and all templates.""" + (tmp_path / "a.json").write_text('{"name": "one"}\n', encoding="utf-8") + (tmp_path / "b.yaml").write_text("name: two\n", encoding="utf-8") + + exit_code = cli._main([str(tmp_path), "-r", "role"]) + + assert exit_code == 0 + captured = capsys.readouterr() + assert "# defaults/main.yml" in captured.out + assert "# config.json.j2" in captured.out + assert "# config.yaml.j2" in captured.out + assert "role_json_items" in captured.out + assert "role_yaml_items" in captured.out + + +def test_cli_folder_writes_template_directory(tmp_path, capsys): + """Folder mode writes multiple templates when template output is a directory.""" + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "a.json").write_text('{"name": "one"}\n', encoding="utf-8") + (src_dir / "b.yaml").write_text("name: two\n", encoding="utf-8") + defaults_path = tmp_path / "defaults.yml" + template_dir = tmp_path / "templates" + + exit_code = cli._main( + [ + str(src_dir), + "-r", + "role", + "--defaults-output", + str(defaults_path), + "--template-output", + str(template_dir), + ] + ) + + assert exit_code == 0 + assert defaults_path.is_file() + assert (template_dir / "config.json.j2").is_file() + assert (template_dir / "config.yaml.j2").is_file() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_cli_folder_single_output_file_when_one_format(tmp_path): + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "a.json").write_text('{"name": "one"}\n', encoding="utf-8") + defaults_path = tmp_path / "defaults.yml" + template_path = tmp_path / "config.j2" + + exit_code = cli._main( + [ + str(src_dir), + "-r", + "role", + "--defaults-output", + str(defaults_path), + "--template-output", + str(template_path), + ] + ) + + assert exit_code == 0 + assert defaults_path.is_file() + assert template_path.is_file() + assert "to_json" in template_path.read_text(encoding="utf-8") diff --git a/tests/test_json_handler.py b/tests/test_json_handler.py index dd502b1..b2bbe75 100644 --- a/tests/test_json_handler.py +++ b/tests/test_json_handler.py @@ -1,9 +1,11 @@ from __future__ import annotations from pathlib import Path +import json import pytest import yaml +from jinja2 import Environment from jinjaturtle.core import ( parse_config, @@ -13,6 +15,7 @@ from jinjaturtle.core import ( generate_jinja2_template, ) from jinjaturtle.handlers.json import JsonHandler +from jinjaturtle.loop_analyzer import LoopCandidate SAMPLES_DIR = Path(__file__).parent / "samples" @@ -35,23 +38,136 @@ def test_json_roundtrip(): assert defaults["foobar_nested_a"] == 1 # Booleans are now preserved as booleans (not stringified) assert defaults["foobar_nested_b"] is True - # List should be a list (not flattened to scalars) - assert defaults["foobar_list"] == [10, 20] + # JSON stays scalar/index-based so source formatting can be preserved. + assert loop_candidates == [] + assert defaults["foobar_list_0"] == 10 + assert defaults["foobar_list_1"] == 20 - # Template generation with loops template = generate_jinja2_template("json", parsed, "foobar", None, loop_candidates) - # Template should use | tojson for type preservation - assert "{{ foobar_foo | tojson }}" in template - assert "{{ foobar_nested_a | tojson }}" in template - assert "{{ foobar_nested_b | tojson }}" in template + # Template should use Ansible | to_json for type preservation without + # HTML-safe escaping. + assert "{{ foobar_foo | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_nested_a | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_nested_b | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_list_0 | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_list_1 | to_json(ensure_ascii=False) }}" in template + assert "{% for" not in template - # List should use loop (not scalar indices) - assert "{% for" in template - assert "foobar_list" in template - # Should NOT have scalar indices - assert "foobar_list_0" not in template - assert "foobar_list_1" not in template + +def test_json_template_preserves_original_indentation_and_final_newline_state( + tmp_path: Path, +): + json_text = ( + "{\n" + ' "default-runtime": "runsc",\n' + ' "runtimes": {\n' + ' "runsc": {\n' + ' "path": "/usr/bin/runsc"\n' + " }\n" + " }\n" + "}" + ) + path = tmp_path / "daemon.json" + path.write_text(json_text, encoding="utf-8") + + fmt, parsed = parse_config(path) + template = generate_jinja2_template(fmt, parsed, "docker", json_text, []) + + assert ' "default-runtime"' in template + assert ' "runsc"' in template + assert ' "path"' in template + assert not template.endswith("\n") + + +def test_json_template_uses_non_html_safe_json_filter_for_angle_brackets( + tmp_path: Path, +): + json_text = '{"version_requirement": ">= 8.0.0 < 9.0.0"}\n' + path = tmp_path / "metadata.json" + path.write_text(json_text, encoding="utf-8") + + fmt, parsed = parse_config(path) + template = generate_jinja2_template(fmt, parsed, "puppet", json_text, []) + + assert "to_json(ensure_ascii=False)" in template + assert "tojson" not in template + + +def test_json_inline_object_array_roundtrips_without_expanding(tmp_path: Path): + json_text = ( + "{\n" + ' "description": "Gets the last boot time of a Linux or Windows system",\n' + ' "implementations": [\n' + ' {"name": "last_boot_time_nix.sh", "requirements": ["shell"]},\n' + ' {"name": "last_boot_time_win.ps1", "requirements": ["powershell"]}\n' + " ]\n" + "}\n" + ) + path = tmp_path / "metadata.json" + path.write_text(json_text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("fact", flat_items, loop_candidates) + ) + template = generate_jinja2_template(fmt, parsed, "fact", json_text, loop_candidates) + + env = Environment(keep_trailing_newline=True) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) + rendered = env.from_string(template).render(**defaults) + + assert loop_candidates == [] + assert '{"name": {{ fact_implementations_0_name' in template + assert rendered == json_text + + +def test_json_direct_loop_generation_renders_valid_json_without_joined_objects(): + items = [ + {"name": "last_boot_time_nix.sh", "requirements": ["shell"]}, + {"name": "last_boot_time_win.ps1", "requirements": ["powershell"]}, + ] + parsed = {"implementations": items} + candidate = LoopCandidate( + path=("implementations",), + loop_var="implementation", + items=items, + item_schema="simple_dict", + ) + handler = JsonHandler() + template = handler.generate_jinja2_template_with_loops( + parsed, "fact", None, [candidate] + ) + + env = Environment(keep_trailing_newline=True) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) + rendered = env.from_string(template).render(fact_implementations=items) + + assert "}, {" not in rendered + assert json.loads(rendered) == parsed + + +def test_json_scalar_span_collector_empty_containers_and_invalid_json(): + handler = JsonHandler() + + assert handler._collect_json_scalar_spans('{"empty": [], "obj": {}}') == [] + assert handler._collect_json_scalar_spans('{"unterminated": [1,}') is None + + +def test_json_template_falls_back_when_source_scanner_cannot_collect_spans(monkeypatch): + handler = JsonHandler() + monkeypatch.setattr(handler, "_collect_json_scalar_spans", lambda _text: None) + + template = handler.generate_jinja2_template( + {"answer": 42}, "role", original_text='{"answer": 42}' + ) + + assert ( + template + == '{\n "answer": {{ role_answer | to_json(ensure_ascii=False) }}\n}\n' + ) def test_generate_jinja2_template_json_type_error(): diff --git a/tests/test_multi.py b/tests/test_multi.py new file mode 100644 index 0000000..2076655 --- /dev/null +++ b/tests/test_multi.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from jinjaturtle.multi import ( + _collect_dict_like_paths, + _merge_union, + defined_var_name, + is_supported_file, + iter_supported_files, + process_directory, +) + + +def test_iter_supported_files_handles_files_dirs_and_missing_paths(tmp_path: Path): + json_file = tmp_path / "config.json" + text_file = tmp_path / "notes.txt" + nested = tmp_path / "nested" + nested.mkdir() + nested_yaml = nested / "config.yaml" + + json_file.write_text('{"name": "one"}\n', encoding="utf-8") + text_file.write_text("ignore me\n", encoding="utf-8") + nested_yaml.write_text("name: two\n", encoding="utf-8") + + assert is_supported_file(json_file) + assert not is_supported_file(text_file) + assert iter_supported_files(json_file, recursive=False) == [json_file] + assert iter_supported_files(text_file, recursive=False) == [] + assert iter_supported_files(tmp_path, recursive=False) == [json_file] + assert iter_supported_files(tmp_path, recursive=True) == [json_file, nested_yaml] + + with pytest.raises(FileNotFoundError): + iter_supported_files(tmp_path / "missing", recursive=False) + + +def test_merge_union_and_collect_dict_like_paths(): + merged = _merge_union( + {"name": "one", "ports": [80], "nested": {"a": 1}}, + {"ports": [80, 443], "nested": {"b": 2}, "enabled": True}, + ) + + assert merged == { + "name": "one", + "ports": [80, 443], + "nested": {"a": 1, "b": 2}, + "enabled": True, + } + + containers, leaves = _collect_dict_like_paths(merged) + assert ("nested",) in containers + assert ("nested", "a") in containers + assert ("ports", "1") in leaves + + +def test_process_directory_multiple_formats(tmp_path: Path): + (tmp_path / "a.json").write_text('{"name": "one"}\n', encoding="utf-8") + (tmp_path / "b.yaml").write_text("name: one\nextra: true\n", encoding="utf-8") + (tmp_path / "c.toml").write_text('name = "one"\n', encoding="utf-8") + (tmp_path / "d.ini").write_text("[main]\nname = one\n", encoding="utf-8") + (tmp_path / "e.xml").write_text("one", encoding="utf-8") + + defaults_yaml, outputs = process_directory( + tmp_path, recursive=False, role_prefix="role" + ) + defaults = yaml.safe_load(defaults_yaml) + by_fmt = {output.fmt: output for output in outputs} + + assert set(by_fmt) == {"ini", "json", "toml", "xml", "yaml"} + assert set(defaults) == { + "role_ini_items", + "role_json_items", + "role_toml_items", + "role_xml_items", + "role_yaml_items", + } + assert defaults["role_json_items"][0]["data"] == {"name": "one"} + assert ( + by_fmt["json"].template + == "{{ data | to_json(indent=2, ensure_ascii=False) }}\n" + ) + assert "{{ role_main_name }}" in by_fmt["ini"].template + assert "{{ role_name }}" in by_fmt["xml"].template + + +def test_process_directory_yaml_union_marks_optional_keys(tmp_path: Path): + (tmp_path / "one.yaml").write_text("name: one\nextra: yes\n", encoding="utf-8") + (tmp_path / "two.yaml").write_text("name: two\n", encoding="utf-8") + + defaults_yaml, outputs = process_directory( + tmp_path, recursive=False, role_prefix="role" + ) + defaults = yaml.safe_load(defaults_yaml) + + assert len(outputs) == 1 + assert outputs[0].fmt == "yaml" + assert "{% if role_defined_extra is defined %}" in outputs[0].template + assert defaults["role_items"][0][defined_var_name("role", ("extra",))] is True + assert defined_var_name("role", ("extra",)) not in defaults["role_items"][1] + + +def test_process_directory_ini_union_marks_optional_sections_and_keys(tmp_path: Path): + (tmp_path / "one.ini").write_text( + "[main]\nname = one\n[extra]\nflag = yes\n", encoding="utf-8" + ) + (tmp_path / "two.ini").write_text("[main]\nname = two\n", encoding="utf-8") + + defaults_yaml, outputs = process_directory( + tmp_path, recursive=False, role_prefix="role" + ) + defaults = yaml.safe_load(defaults_yaml) + + assert len(outputs) == 1 + assert outputs[0].fmt == "ini" + assert "{% if role_defined_extra is defined %}" in outputs[0].template + assert defaults["role_items"][0][defined_var_name("role", ("extra",))] is True + assert defined_var_name("role", ("extra",)) not in defaults["role_items"][1] + + +def test_process_directory_rejects_empty_folder(tmp_path: Path): + with pytest.raises(ValueError, match="No supported config files"): + process_directory(tmp_path, recursive=False, role_prefix="role") diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index 850cc9a..1a6d47b 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -31,6 +31,7 @@ from jinjaturtle.core import ( def render_template(template: str, variables: dict[str, Any]) -> str: """Render a Jinja2 template with variables.""" env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) jinja_template = env.from_string(template) return jinja_template.render(variables) diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index 41edc07..184dfbc 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -7,6 +7,7 @@ import yaml from jinjaturtle.core import ( parse_config, + analyze_loops, flatten_config, generate_ansible_yaml, generate_jinja2_template, @@ -204,6 +205,36 @@ def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path): assert yaml.safe_load(rendered) == yaml.safe_load(text) +def test_yaml_scalar_loop_preserves_quoted_list_items(tmp_path: Path): + from jinja2 import Template + + text = textwrap.dedent( + """ + files: + - 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb' + - 'spec/unit/pwsh/util_spec.rb' + - 'spec/unit/pwsh/windows_powershell_spec.rb' + """ + ).lstrip() + path = tmp_path / "quoted-list.yaml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + + assert loop_candidates + assert "- '{{" in template + assert rendered == text + + def test_yaml_bool_scalars_render_with_yaml_spelling(tmp_path: Path): from jinja2 import Template diff --git a/tests/test_yaml_template_consistency.py b/tests/test_yaml_template_consistency.py index 22a3c69..5a48502 100644 --- a/tests/test_yaml_template_consistency.py +++ b/tests/test_yaml_template_consistency.py @@ -454,6 +454,7 @@ class TestStructuralConsistency: # Try to render the template env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) try: jinja_template = env.from_string(template) rendered = jinja_template.render(variables) @@ -520,11 +521,9 @@ class TestRegressionBugs: "app_database" in template ), f"Template should reference app_database\n{template}" - def test_json_array_no_index_refs(self): + def test_json_array_uses_index_refs_for_source_fidelity(self): """ - Regression test: JSON arrays should not generate index references. - - Bug: Template had {{ app_list_0 }}, {{ app_list_1 }} when YAML had app_list as list. + JSON arrays stay index-based so original inline formatting can survive. """ import json @@ -536,22 +535,18 @@ class TestRegressionBugs: ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) template = generate_jinja2_template( - "json", parsed, "app", None, loop_candidates + "json", parsed, "app", json_text, loop_candidates ) - # YAML should have app_items as a list defaults = yaml.safe_load(ansible_yaml) - assert isinstance(defaults.get("app_items"), list) - - # Template should NOT have app_items_0, app_items_1, app_items_2 - for i in range(3): - assert ( - f"app_items_{i}" not in template - ), f"Template incorrectly uses scalar 'app_items_{i}'\n{template}" - - # Template SHOULD use a loop - assert "{% for" in template - assert "app_items" in template + assert loop_candidates == [] + assert defaults["app_items_0"] == 1 + assert defaults["app_items_1"] == 2 + assert defaults["app_items_2"] == 3 + assert "{% for" not in template + assert "app_items_0" in template + assert "app_items_1" in template + assert "app_items_2" in template if __name__ == "__main__": From 5ee084d3958f0cf2557dec5cb5fefffeaf8d00b8 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 20 Jun 2026 15:30:22 +1000 Subject: [PATCH 16/44] 0.5.4 --- debian/changelog | 7 +++++++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 5 ++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 5ab9ef4..44d4e28 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +jinjaturtle (0.5.4) unstable; urgency=medium + + * Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. + * More test coverage + + -- Miguel Jacq Sat, 20 Jun 2026 15:29:00 +1000 + jinjaturtle (0.5.3) unstable; urgency=medium * Fix loss of comments and True/False to true/false diff --git a/pyproject.toml b/pyproject.toml index 039c55d..1279f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.3" +version = "0.5.4" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 8d0da22..a303b15 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.3 +%global upstream_version 0.5.4 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,9 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} +- Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. +- More test coverage * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Fix loss of comments and True/False to true/false * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} From 17612c9883e9cc13a27b2fa38e7ddc6edaaaabc4 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 20 Jun 2026 17:40:30 +1000 Subject: [PATCH 17/44] Add erb support --- src/jinjaturtle/cli.py | 87 +++++++++-- src/jinjaturtle/core.py | 80 +++++++++++ src/jinjaturtle/erb.py | 215 ++++++++++++++++++++++++++++ src/jinjaturtle/handlers/ini.py | 9 +- src/jinjaturtle/handlers/json.py | 22 +-- src/jinjaturtle/handlers/postfix.py | 9 +- src/jinjaturtle/handlers/ssh.py | 5 +- src/jinjaturtle/handlers/systemd.py | 11 +- src/jinjaturtle/handlers/toml.py | 95 +++++++----- src/jinjaturtle/handlers/xml.py | 25 ++-- src/jinjaturtle/handlers/yaml.py | 32 ++--- src/jinjaturtle/j2.py | 87 +++++++++++ src/jinjaturtle/multi.py | 71 ++++----- tests/test_cli.py | 54 +++++++ 14 files changed, 661 insertions(+), 141 deletions(-) create mode 100644 src/jinjaturtle/erb.py create mode 100644 src/jinjaturtle/j2.py diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 82b6775..7658bfb 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -5,12 +5,15 @@ import sys from defusedxml import defuse_stdlib from pathlib import Path +from . import j2 from .core import ( parse_config, analyze_loops, flatten_config, generate_ansible_yaml, generate_jinja2_template, + generate_puppet_hiera_yaml, + generate_erb_template, ) from .multi import process_directory @@ -53,7 +56,21 @@ def _build_arg_parser() -> argparse.ArgumentParser: ap.add_argument( "-t", "--template-output", - help="Path to write the Jinja2 config template. If omitted, template is printed to stdout.", + help="Path to write the generated config template. If omitted, template is printed to stdout.", + ) + ap.add_argument( + "--template-engine", + choices=[j2.NAME, "erb"], + default=j2.NAME, + help="Template syntax to generate (default: jinja2). Use erb for Puppet templates.", + ) + ap.add_argument( + "--puppet-class", + help=( + "Puppet class/Hiera namespace to use with --template-engine erb. " + "Defaults to --role-name. This lets tools use a file-specific " + "variable prefix while writing Hiera keys under the real Puppet class." + ), ) return ap @@ -78,6 +95,21 @@ def _main(argv: list[str] | None = None) -> int: print("# defaults/main.yml") print(defaults_yaml, end="") + # Optionally translate folder-mode templates to ERB. Folder mode keeps + # the existing data shape; single-file mode below is the preferred + # Puppet path because it can produce class-parameter Hiera keys. + if args.template_engine == "erb": + from .erb import translate_jinja2_to_erb + + for o in outputs: + o.template = translate_jinja2_to_erb( + o.template, + role_prefix=args.role_name, + puppet_class=args.puppet_class or args.role_name, + ) + + template_ext = "erb" if args.template_engine == "erb" else j2.TEMPLATE_EXTENSION + # Write templates if args.template_output: out_path = Path(args.template_output) @@ -86,12 +118,16 @@ def _main(argv: list[str] | None = None) -> int: else: out_path.mkdir(parents=True, exist_ok=True) for o in outputs: - (out_path / f"config.{o.fmt}.j2").write_text( + (out_path / f"config.{o.fmt}.{template_ext}").write_text( o.template, encoding="utf-8" ) else: for o in outputs: - name = "config.j2" if len(outputs) == 1 else f"config.{o.fmt}.j2" + name = ( + f"config.{template_ext}" + if len(outputs) == 1 + else f"config.{o.fmt}.{template_ext}" + ) print(f"# {name}") print(o.template, end="") @@ -109,17 +145,36 @@ def _main(argv: list[str] | None = None) -> int: # Flatten config (excluding loop paths if loops are detected) flat_items = flatten_config(fmt, parsed, loop_candidates) - # Generate defaults YAML (with loop collections if detected) - ansible_yaml = generate_ansible_yaml(args.role_name, flat_items, loop_candidates) + if args.template_engine == "erb": + ansible_yaml = generate_puppet_hiera_yaml( + args.role_name, + flat_items, + loop_candidates, + puppet_class=args.puppet_class or args.role_name, + ) + template_str = generate_erb_template( + fmt, + parsed, + args.role_name, + original_text=config_text, + loop_candidates=loop_candidates, + flat_items=flat_items, + puppet_class=args.puppet_class or args.role_name, + ) + else: + # Generate defaults YAML (with loop collections if detected) + ansible_yaml = generate_ansible_yaml( + args.role_name, flat_items, loop_candidates + ) - # Generate template (with loops if detected) - template_str = generate_jinja2_template( - fmt, - parsed, - args.role_name, - original_text=config_text, - loop_candidates=loop_candidates, - ) + # Generate template (with loops if detected) + template_str = generate_jinja2_template( + fmt, + parsed, + args.role_name, + original_text=config_text, + loop_candidates=loop_candidates, + ) if args.defaults_output: Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") @@ -130,7 +185,11 @@ def _main(argv: list[str] | None = None) -> int: if args.template_output: Path(args.template_output).write_text(template_str, encoding="utf-8") else: - print("# config.j2") + print( + "# config.erb" + if args.template_engine == "erb" + else f"# config.{j2.TEMPLATE_EXTENSION}" + ) print(template_str, end="") return 0 diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index ff6a586..673aad0 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -8,6 +8,7 @@ import re import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate +from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb from .handlers import ( BaseHandler, IniHandler, @@ -387,6 +388,85 @@ def generate_jinja2_template( ) +def _template_variable_names( + role_prefix: str, + flat_items: list[tuple[tuple[str, ...], Any]], + loop_candidates: list[LoopCandidate] | None = None, +) -> set[str]: + names = {make_var_name(role_prefix, path) for path, _value in flat_items} + if loop_candidates: + for candidate in loop_candidates: + names.add(make_var_name(role_prefix, candidate.path)) + return names + + +def generate_puppet_hiera_yaml( + role_prefix: str, + flat_items: list[tuple[tuple[str, ...], Any]], + loop_candidates: list[LoopCandidate] | None = None, + *, + puppet_class: str | None = None, +) -> str: + """Create Puppet Hiera data suitable for Automatic Parameter Lookup. + + ``role_prefix`` remains the source variable prefix used by JinjaTurtle while + ``puppet_class`` is the Puppet class/Hiera namespace. In the normal case + they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``. + Enroll may pass a file-specific role prefix and a separate Puppet class to + avoid parameter-name collisions inside one generated Puppet module. + """ + + klass = puppet_class_name(puppet_class or role_prefix) + data: dict[str, Any] = {} + + for path, value in flat_items: + generated = make_var_name(role_prefix, path) + local = puppet_local_var_name(role_prefix, generated, puppet_class=klass) + data[f"{klass}::{local}"] = value + + if loop_candidates: + for candidate in loop_candidates: + generated = make_var_name(role_prefix, candidate.path) + local = puppet_local_var_name(role_prefix, generated, puppet_class=klass) + data[f"{klass}::{local}"] = candidate.items + + return dump_yaml(data, sort_keys=True) + + +def generate_erb_template( + fmt: str, + parsed: Any, + role_prefix: str, + *, + original_text: str | None = None, + loop_candidates: list[LoopCandidate] | None = None, + flat_items: list[tuple[tuple[str, ...], Any]] | None = None, + puppet_class: str | None = None, +) -> str: + """Generate a Puppet ERB template from JinjaTurtle's renderer-neutral data. + + The first implementation intentionally translates the Jinja2 subset emitted + by JinjaTurtle's existing format handlers. This keeps parsing, formatting + preservation, and loop detection identical between Jinja2 and ERB output + while still producing Puppet-native ``@parameter`` references. + """ + + jinja_template = generate_jinja2_template( + fmt, + parsed, + role_prefix, + original_text=original_text, + loop_candidates=loop_candidates, + ) + names = _template_variable_names(role_prefix, flat_items or [], loop_candidates) + return translate_jinja2_to_erb( + jinja_template, + role_prefix=role_prefix, + puppet_class=puppet_class or role_prefix, + variable_names=names, + ) + + def _stringify_timestamps(obj: Any) -> Any: """ Recursively walk a parsed config and turn any datetime/date/time objects diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py new file mode 100644 index 0000000..8e16694 --- /dev/null +++ b/src/jinjaturtle/erb.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import re + + +def _safe_name(raw: str, *, fallback: str = "var") -> str: + text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower() + text = re.sub(r"_+", "_", text) + if not text: + text = fallback + if not re.match(r"^[a-z_]", text): + text = f"{fallback}_{text}" + return text + + +def puppet_class_name(raw: str) -> str: + """Return a conservative Puppet class/Hiera namespace name.""" + + text = _safe_name(raw, fallback="jinjaturtle") + if not re.match(r"^[a-z]", text): + text = f"jinjaturtle_{text}" + return text + + +def _role_prefix_name(raw: str) -> str: + return _safe_name(raw, fallback="jinjaturtle") + + +def puppet_local_var_name( + role_prefix: str, + jinja_var_name: str, + *, + puppet_class: str | None = None, +) -> str: + """Map a generated JinjaTurtle variable to a Puppet class parameter. + + For the common case where ``--role-name php`` also means class ``php``, a + generated Jinja variable such as ``php_memory_limit`` becomes Puppet local + parameter ``memory_limit`` and Hiera key ``php::memory_limit``. + + Enroll sometimes needs a file-specific variable prefix to avoid collisions + inside a generated module. When ``puppet_class`` differs from + ``role_prefix`` we keep the full generated variable name as the local + parameter and only use ``puppet_class`` as the Hiera namespace. + """ + + var_name = _safe_name(jinja_var_name, fallback="value") + prefix = _role_prefix_name(role_prefix) + klass = puppet_class_name(puppet_class or role_prefix) + if klass == prefix and var_name.startswith(prefix + "_"): + stripped = var_name[len(prefix) + 1 :] + return stripped or var_name + return var_name + + +class ErbTranslator: + """Translate the Jinja2 subset emitted by JinjaTurtle into Puppet ERB.""" + + _TOKEN_RE = re.compile(r"({{.*?}}|{%.*?%})", re.S) + + def __init__( + self, + *, + role_prefix: str, + puppet_class: str | None = None, + variable_names: set[str] | None = None, + ) -> None: + self.role_prefix = role_prefix + self.puppet_class = puppet_class or role_prefix + self.variable_names = set(variable_names or set()) + self.loop_stack: list[tuple[str, str, str]] = [] + self.needs_json = False + + def translate(self, template_text: str) -> str: + parts = self._TOKEN_RE.split(template_text) + out: list[str] = [] + for token in parts: + if not token: + continue + if token.startswith("{{") and token.endswith("}}"): + expr = token[2:-2].strip() + out.append(f"<%= {self.expr_to_ruby(expr)} %>") + continue + if token.startswith("{%") and token.endswith("%}"): + stmt = token[2:-2].strip() + out.append(self.statement_to_erb(stmt)) + continue + out.append(token) + + rendered = "".join(out) + if self.needs_json and "require 'json'" not in rendered: + rendered = "<% require 'json' -%>\n" + rendered + return rendered + + def local_var(self, name: str) -> str: + return puppet_local_var_name( + self.role_prefix, name, puppet_class=self.puppet_class + ) + + def ruby_value(self, expr: str) -> str: + expr = expr.strip() + if expr in {"true", "True"}: + return "true" + if expr in {"false", "False"}: + return "false" + if expr in {"none", "None", "null"}: + return "nil" + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", expr): + if any(expr == loop_var for loop_var, _idx, _coll in self.loop_stack): + return expr + return f"@{self.local_var(expr)}" + m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$", expr) + if m: + base, key = m.groups() + if any(base == loop_var for loop_var, _idx, _coll in self.loop_stack): + return f"{base}[{key!r}]" + return f"@{self.local_var(base)}[{key!r}]" + return expr + + def expr_to_ruby(self, expr: str) -> str: + expr = expr.strip() + + # JinjaTurtle emits these YAML-preserving ternaries for booleans/nulls. + m = re.match( + r"^(['\"])(true|false)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+else\s+(['\"])(true|false)\4$", + expr, + ) + if m: + truthy = m.group(2) + cond = self.ruby_value(m.group(3)) + falsy = m.group(5) + return f"{cond} ? {truthy!r} : {falsy!r}" + + m = re.match( + r"^(['\"])(null)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none\s+else\s+([A-Za-z_][A-Za-z0-9_\.]*)$", + expr, + ) + if m: + value = self.ruby_value(m.group(3)) + fallback = self.ruby_value(m.group(4)) + return f"{value}.nil? ? 'null' : {fallback}" + + if "|" in expr: + base, *filters = [part.strip() for part in expr.split("|")] + ruby = self.ruby_value(base) + for filt in filters: + if filt.startswith("lower"): + ruby = f"{ruby}.to_s.downcase" + elif filt.startswith("to_json") or filt.startswith("tojson"): + self.needs_json = True + if "indent" in filt: + ruby = f"JSON.pretty_generate({ruby})" + else: + ruby = f"JSON.generate({ruby})" + return ruby + + return self.ruby_value(expr) + + def statement_to_erb(self, stmt: str) -> str: + if stmt.startswith("for "): + m = re.match( + r"^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)$", + stmt, + ) + if m: + loop_var, collection = m.groups() + idx_var = f"__jt_idx_{len(self.loop_stack)}" + collection_ruby = self.ruby_value(collection) + self.loop_stack.append((loop_var, idx_var, collection_ruby)) + return f"<% {collection_ruby}.each_with_index do |{loop_var}, {idx_var}| -%>" + + if stmt == "endfor": + if self.loop_stack: + self.loop_stack.pop() + return "<% end -%>" + + if stmt.startswith("if "): + cond = stmt[3:].strip() + if cond == "not loop.last" and self.loop_stack: + _loop_var, idx_var, collection_ruby = self.loop_stack[-1] + return f"<% if {idx_var} < ({collection_ruby}.length - 1) -%>" + m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+defined$", cond) + if m: + return f"<% unless {self.ruby_value(m.group(1))}.nil? -%>" + m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none$", cond) + if m: + return f"<% if {self.ruby_value(m.group(1))}.nil? -%>" + return f"<% if {self.expr_to_ruby(cond)} -%>" + + if stmt == "else": + return "<% else -%>" + + if stmt.startswith("elif "): + return f"<% elsif {self.expr_to_ruby(stmt[5:].strip())} -%>" + + if stmt == "endif": + return "<% end -%>" + + # Preserve unknown Jinja statements visibly as an ERB comment so the + # generated template does not contain invalid Jinja syntax. + return f"<%# Unsupported JinjaTurtle statement: {stmt} %>" + + +def translate_jinja2_to_erb( + template_text: str, + *, + role_prefix: str, + puppet_class: str | None = None, + variable_names: set[str] | None = None, +) -> str: + return ErbTranslator( + role_prefix=role_prefix, + puppet_class=puppet_class, + variable_names=variable_names, + ).translate(template_text) diff --git a/src/jinjaturtle/handlers/ini.py b/src/jinjaturtle/handlers/ini.py index ad92b72..1aa5e22 100644 --- a/src/jinjaturtle/handlers/ini.py +++ b/src/jinjaturtle/handlers/ini.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 class IniHandler(BaseHandler): @@ -63,9 +64,9 @@ class IniHandler(BaseHandler): var_name = self.make_var_name(role_prefix, path) value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: - lines.append(f'{key} = "{{{{ {var_name} }}}}"') + lines.append(f"{key} = {j2.quoted_variable(var_name)}") else: - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append(f"{key} = {j2.variable(var_name)}") lines.append("") return "\n".join(lines).rstrip() + "\n" @@ -141,9 +142,9 @@ class IniHandler(BaseHandler): if use_quotes: quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" + replacement_value = j2.quoted_variable(var_name, quote_char) else: - replacement_value = f"{{{{ {var_name} }}}}" + replacement_value = j2.variable(var_name) new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index 9d763a4..064535a 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any from . import DictLikeHandler +from .. import j2 from ..loop_analyzer import LoopCandidate @@ -31,7 +32,7 @@ class JsonHandler(DictLikeHandler): return self._generate_json_template(role_prefix, parsed) JSON_INDENT = 2 - JSON_VALUE_FILTER = "to_json(ensure_ascii=False)" + JSON_VALUE_FILTER = j2.JSON_VALUE_FILTER def _leading_indent(self, s: str, idx: int) -> int: """Return the number of leading spaces on the line containing idx.""" @@ -85,7 +86,7 @@ class JsonHandler(DictLikeHandler): useful in HTML, but noisy in configuration files. JinjaTurtle generates Ansible templates, so use Ansible's ``to_json`` filter instead. """ - return f"{{{{ {var_name} | {self.JSON_VALUE_FILTER} }}}}" + return j2.filtered(var_name, self.JSON_VALUE_FILTER) def _generate_json_template_from_text(self, role_prefix: str, text: str) -> str: """Replace JSON scalar values in-place, preserving original formatting. @@ -330,10 +331,10 @@ class JsonHandler(DictLikeHandler): # a blank line between iterations under default Jinja whitespace settings. return ( f"[\n" - f"{{% for {item_var} in {collection_var} %}}" - f"{inner}{{{{ {item_var} | to_json(ensure_ascii=False) }}}}" - f"{{% if not loop.last %}},{{% endif %}}\n" - f"{{% endfor %}}{base}]" + f"{j2.for_start(item_var, collection_var)}" + f"{inner}{j2.to_json(item_var)}" + f"{j2.if_not_loop_last()},{j2.endif()}\n" + f"{j2.for_end()}{base}]" ) def _generate_json_dict_loop( @@ -364,16 +365,15 @@ class JsonHandler(DictLikeHandler): for i, key in enumerate(keys): comma = "," if i < len(keys) - 1 else "" dict_lines.append( - f'{field}"{key}": ' - f"{{{{ {item_var}.{key} | to_json(ensure_ascii=False) }}}}{comma}" + f'{field}"{key}": ' f"{j2.to_json(f'{item_var}.{key}')}{comma}" ) # Comma between *items* goes after the closing brace. - dict_lines.append(f"{inner}}}{{% if not loop.last %}},{{% endif %}}") + dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}") dict_body = "\n".join(dict_lines) # Put the `{% for %}` at the start of the first item line to avoid blank lines. return ( f"[\n" - f"{{% for {item_var} in {collection_var} %}}{inner}{dict_body}\n" - f"{{% endfor %}}{base}]" + f"{j2.for_start(item_var, collection_var)}{inner}{dict_body}\n" + f"{j2.for_end()}{base}]" ) diff --git a/src/jinjaturtle/handlers/postfix.py b/src/jinjaturtle/handlers/postfix.py index 65f6be9..e2e442a 100644 --- a/src/jinjaturtle/handlers/postfix.py +++ b/src/jinjaturtle/handlers/postfix.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 class PostfixMainHandler(BaseHandler): @@ -92,7 +93,7 @@ class PostfixMainHandler(BaseHandler): lines: list[str] = [] for k, v in parsed.items(): var = self.make_var_name(role_prefix, (k,)) - lines.append(f"{k} = {{{{ {var} }}}}") + lines.append(f"{k} = {j2.variable(var)}") return "\n".join(lines).rstrip() + "\n" return self._generate_from_text(role_prefix, original_text) @@ -164,11 +165,13 @@ class PostfixMainHandler(BaseHandler): quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} if quoted: replacement = ( - f'{before_eq}={leading_ws}"{{{{ {var} }}}}"{comment_part}{newline}' + f"{before_eq}={leading_ws}{j2.quoted_variable(var)}" + f"{comment_part}{newline}" ) else: replacement = ( - f"{before_eq}={leading_ws}{{{{ {var} }}}}{comment_part}{newline}" + f"{before_eq}={leading_ws}{j2.variable(var)}" + f"{comment_part}{newline}" ) out_lines.append(replacement) diff --git a/src/jinjaturtle/handlers/ssh.py b/src/jinjaturtle/handlers/ssh.py index bedaa2a..a6c0d2f 100644 --- a/src/jinjaturtle/handlers/ssh.py +++ b/src/jinjaturtle/handlers/ssh.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 _SECTION_KEYWORDS = {"host", "match"} @@ -265,9 +266,9 @@ class SshConfigHandler(BaseHandler): var = self.make_var_name(role_prefix, path) if ln.quoted and ln.value: quote_char = ln.value[0] - replacement_value = f"{quote_char}{{{{ {var} }}}}{quote_char}" + replacement_value = j2.quoted_variable(var, quote_char) else: - replacement_value = f"{{{{ {var} }}}}" + replacement_value = j2.variable(var) rendered = ( f"{ln.before_value}{replacement_value}" diff --git a/src/jinjaturtle/handlers/systemd.py b/src/jinjaturtle/handlers/systemd.py index 044fd86..8118808 100644 --- a/src/jinjaturtle/handlers/systemd.py +++ b/src/jinjaturtle/handlers/systemd.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 @dataclass @@ -167,9 +168,15 @@ class SystemdUnitHandler(BaseHandler): v = (ln.value or "").strip() quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} if quoted: - repl = f'{ln.before_eq}={ln.leading_ws_after_eq}"{{{{ {var} }}}}"{ln.comment}' + repl = ( + f"{ln.before_eq}={ln.leading_ws_after_eq}" + f"{j2.quoted_variable(var)}{ln.comment}" + ) else: - repl = f"{ln.before_eq}={ln.leading_ws_after_eq}{{{{ {var} }}}}{ln.comment}" + repl = ( + f"{ln.before_eq}={ln.leading_ws_after_eq}" + f"{j2.variable(var)}{ln.comment}" + ) newline = "\n" if ln.raw.endswith("\n") else "" out_lines.append(repl + newline) diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index ba4bbb4..15d5a2a 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any from . import DictLikeHandler +from .. import j2 from ..loop_analyzer import LoopCandidate try: @@ -16,6 +17,14 @@ class TomlHandler(DictLikeHandler): fmt = "toml" flatten_lists = False # keep lists as scalars + def _toml_value_expr(self, var_name: str, value: Any | None = None) -> str: + if isinstance(value, bool): + return j2.lower(var_name) + return j2.variable(var_name) + + def _toml_quoted_expr(self, var_name: str, quote: str = '"') -> str: + return j2.quoted_variable(var_name, quote) + def parse(self, path: Path) -> Any: if tomllib is None: raise RuntimeError( @@ -68,12 +77,12 @@ class TomlHandler(DictLikeHandler): def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: var_name = self.make_var_name(role_prefix, path + (key,)) if isinstance(value, str): - lines.append(f'{key} = "{{{{ {var_name} }}}}"') + lines.append(f"{key} = {self._toml_quoted_expr(var_name)}") elif isinstance(value, bool): # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append(f"{key} = {{{{ {var_name} | lower }}}}") + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") else: - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None: scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} @@ -121,10 +130,10 @@ class TomlHandler(DictLikeHandler): def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: var_name = self.make_var_name(role_prefix, path + (key,)) if isinstance(value, str): - lines.append(f'{key} = "{{{{ {var_name} }}}}"') + lines.append(f"{key} = {self._toml_quoted_expr(var_name)}") elif isinstance(value, bool): # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append(f"{key} = {{{{ {var_name} | lower }}}}") + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") elif isinstance(value, list): # Check if this list is a loop candidate if path + (key,) in loop_paths: @@ -139,24 +148,21 @@ class TomlHandler(DictLikeHandler): # Scalar list loop lines.append( f"{key} = [" - f"{{% for {item_var} in {collection_var} %}}" - f"{{{{ {item_var} }}}}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}" + f"{j2.for_start(item_var, collection_var)}" + f"{j2.variable(item_var)}" + f"{j2.if_not_loop_last()}, {j2.endif()}" + f"{j2.for_end()}" f"]" ) elif candidate.item_schema in ("simple_dict", "nested"): # Dict list loop - TOML array of tables # This is complex for TOML, using simplified approach - lines.append( - f"{key} = " - f"{{{{ {var_name} | to_json(ensure_ascii=False) }}}}" - ) + lines.append(f"{key} = " f"{j2.to_json(var_name)}") else: # Not a loop, treat as regular variable - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") else: - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None: scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} @@ -282,13 +288,17 @@ class TomlHandler(DictLikeHandler): nested_path = path + (sub_key,) nested_var = self.make_var_name(role_prefix, nested_path) if isinstance(sub_val, str): - inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"') + inner_bits.append( + f"{sub_key} = {self._toml_quoted_expr(nested_var)}" + ) elif isinstance(sub_val, bool): inner_bits.append( - f"{sub_key} = {{{{ {nested_var} | lower }}}}" + f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}" ) else: - inner_bits.append(f"{sub_key} = {{{ {nested_var} }}}") + inner_bits.append( + f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}" + ) replacement_value = "{ " + ", ".join(inner_bits) + " }" new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part @@ -310,11 +320,11 @@ class TomlHandler(DictLikeHandler): if use_quotes: quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" + replacement_value = self._toml_quoted_expr(var_name, quote_char) elif is_bool: - replacement_value = f"{{{{ {var_name} | lower }}}}" + replacement_value = j2.lower(var_name) else: - replacement_value = f"{{{{ {var_name} }}}}" + replacement_value = j2.variable(var_name) new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part @@ -392,7 +402,7 @@ class TomlHandler(DictLikeHandler): # Build loop out_lines.append( - f"{{% for {item_var} in {collection_var} %}}\n" + f"{j2.for_start(item_var, collection_var)}\n" ) out_lines.append(f"[[{'.'.join(table_path)}]]\n") @@ -402,14 +412,21 @@ class TomlHandler(DictLikeHandler): continue if isinstance(value, str): out_lines.append( - f'{key} = "{{{{ {item_var}.{key} }}}}"\n' + f"{key} = " + f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n" + ) + elif isinstance(value, bool): + out_lines.append( + f"{key} = " + f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" ) else: out_lines.append( - f"{key} = {{{{ {item_var}.{key} }}}}\n" + f"{key} = " + f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" ) - out_lines.append("{% endfor %}\n") + out_lines.append(f"{j2.for_end()}\n") # Skip all content until the next different table skip_until_next_table = True @@ -473,17 +490,15 @@ class TomlHandler(DictLikeHandler): # Scalar list loop replacement_value = ( f"[" - f"{{% for {item_var} in {collection_var} %}}" - f"{{{{ {item_var} }}}}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}" + f"{j2.for_start(item_var, collection_var)}" + f"{j2.variable(item_var)}" + f"{j2.if_not_loop_last()}, {j2.endif()}" + f"{j2.for_end()}" f"]" ) else: # Dict/nested loop - use to_json filter for complex arrays - replacement_value = ( - f"{{{{ {collection_var} | to_json(ensure_ascii=False) }}}}" - ) + replacement_value = j2.to_json(collection_var) new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part @@ -510,13 +525,17 @@ class TomlHandler(DictLikeHandler): nested_path = path + (sub_key,) nested_var = self.make_var_name(role_prefix, nested_path) if isinstance(sub_val, str): - inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"') + inner_bits.append( + f"{sub_key} = {self._toml_quoted_expr(nested_var)}" + ) elif isinstance(sub_val, bool): inner_bits.append( - f"{sub_key} = {{{{ {nested_var} | lower }}}}" + f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}" ) else: - inner_bits.append(f"{sub_key} = {{{{ {nested_var} }}}}") + inner_bits.append( + f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}" + ) replacement_value = "{ " + ", ".join(inner_bits) + " }" new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part @@ -538,11 +557,11 @@ class TomlHandler(DictLikeHandler): if use_quotes: quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" + replacement_value = self._toml_quoted_expr(var_name, quote_char) elif is_bool: - replacement_value = f"{{{{ {var_name} | lower }}}}" + replacement_value = j2.lower(var_name) else: - replacement_value = f"{{{{ {var_name} }}}}" + replacement_value = j2.variable(var_name) new_content = ( before_eq + "=" + leading_ws + replacement_value + comment_part diff --git a/src/jinjaturtle/handlers/xml.py b/src/jinjaturtle/handlers/xml.py index fed6aba..51c7a73 100644 --- a/src/jinjaturtle/handlers/xml.py +++ b/src/jinjaturtle/handlers/xml.py @@ -6,6 +6,7 @@ from typing import Any import xml.etree.ElementTree as ET # nosec from .base import BaseHandler +from .. import j2 from ..loop_analyzer import LoopCandidate @@ -172,7 +173,7 @@ class XmlHandler(BaseHandler): for attr_name in list(elem.attrib.keys()): attr_path = path + (f"@{attr_name}",) var_name = self.make_var_name(role_prefix, attr_path) - elem.set(attr_name, f"{{{{ {var_name} }}}}") + elem.set(attr_name, j2.variable(var_name)) # Children children = [c for c in list(elem) if isinstance(c.tag, str)] @@ -185,7 +186,7 @@ class XmlHandler(BaseHandler): else: text_path = path + ("value",) var_name = self.make_var_name(role_prefix, text_path) - elem.text = f"{{{{ {var_name} }}}}" + elem.text = j2.variable(var_name) # Handle children - check for loops first counts = Counter(child.tag for child in children) @@ -339,12 +340,12 @@ class XmlHandler(BaseHandler): # Build loop result_lines.append( - f"{indent_str}{{% for {item_var} in {collection_var} %}}" + f"{indent_str}{j2.for_start(item_var, collection_var)}" ) # Add each line of the sample with proper indentation for sample_line in sample_lines: result_lines.append(f"{indent_str} {sample_line}") - result_lines.append(f"{indent_str}{{% endfor %}}") + result_lines.append(f"{indent_str}{j2.for_end()}") else: # Keep the marker if we can't find the candidate result_lines.append(line) @@ -360,11 +361,11 @@ class XmlHandler(BaseHandler): end = line.find("-->", start) condition = line[start:end] indent = len(line) - len(line.lstrip()) - final_lines.append(f"{' ' * indent}{{% if {condition} is defined %}}") + final_lines.append(f"{' ' * indent}{j2.if_defined(condition)}") # Replace with {% endif %} elif "`` would otherwise become live + template code. Element/attribute *names* cannot carry Jinja delimiters + (XML naming rules forbid the characters and the parser rejects them), and + text/attribute *values* are already replaced with ``{{ var }}`` + placeholders, so comments (and the prolog, handled separately) are the + only XML injection vector. + + JinjaTurtle's own internal marker comments are left untouched so they can + be converted into real loops/conditionals later. + """ + # ET represents comments with a callable tag (ET.Comment). Iterate all + # descendants and escape comment text that is not one of our markers. + for elem in root.iter(): + if elem.tag is ET.Comment: + if not self._is_jt_marker(elem.text or ""): + elem.text = escape_jinja_literal(elem.text or "") + def _generate_xml_template_from_text(self, role_prefix: str, text: str) -> str: """Generate scalar-only Jinja2 template.""" prolog, body = self._split_xml_prolog(text) @@ -240,12 +272,16 @@ class XmlHandler(BaseHandler): self._apply_jinja_to_xml_tree(role_prefix, root) + # Neutralise template metacharacters in any comments preserved from the + # source file before serialising. + self._escape_source_comments(root) + indent = getattr(ET, "indent", None) if indent is not None: indent(root, space=" ") # type: ignore[arg-type] xml_body = ET.tostring(root, encoding="unicode") - return prolog + xml_body + return escape_jinja_literal(prolog) + xml_body def _generate_xml_template_with_loops_from_text( self, @@ -265,6 +301,11 @@ class XmlHandler(BaseHandler): # Apply Jinja transformations (including loop markers) self._apply_jinja_to_xml_tree(role_prefix, root, loop_candidates) + # Escape comments preserved from the source. JinjaTurtle's own + # LOOP/IF/ENDIF marker comments are recognised and left intact so they + # can be converted into real Jinja control structures below. + self._escape_source_comments(root) + # Convert to string indent = getattr(ET, "indent", None) if indent is not None: @@ -275,7 +316,7 @@ class XmlHandler(BaseHandler): # Post-process to replace loop markers with actual Jinja loops xml_body = self._insert_xml_loops(xml_body, role_prefix, loop_candidates, root) - return prolog + xml_body + return escape_jinja_literal(prolog) + xml_body def _insert_xml_loops( self, diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index 1ad9bf3..d8131b0 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -6,6 +6,7 @@ from typing import Any from .dict import DictLikeHandler from .. import j2 +from ..escape import escape_jinja_literal from ..loop_analyzer import LoopCandidate @@ -102,7 +103,7 @@ class YamlHandler(DictLikeHandler): indent = len(raw_line) - len(stripped) if not stripped or stripped.startswith("#"): - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue while stack and indent < stack[-1][0]: @@ -112,7 +113,7 @@ class YamlHandler(DictLikeHandler): key_part, rest = stripped.split(":", 1) key = key_part.strip() if not key: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue rest_stripped = rest.lstrip(" \t") @@ -125,7 +126,7 @@ class YamlHandler(DictLikeHandler): stack.append((indent, path, "map")) if not has_value: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue value_part, comment_part = self._split_inline_comment( @@ -147,8 +148,8 @@ class YamlHandler(DictLikeHandler): replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] - new_rest = f"{leading}{replacement}{comment_part}" - new_stripped = f"{key}:{new_rest}" + new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}" + new_stripped = f"{escape_jinja_literal(key)}:{new_rest}" out_lines.append( " " * indent + new_stripped @@ -185,7 +186,7 @@ class YamlHandler(DictLikeHandler): else: replacement = self._yaml_scalar_expr(var_name, raw_value) - new_stripped = f"- {replacement}{comment_part}" + new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}" out_lines.append( " " * indent + new_stripped @@ -193,7 +194,7 @@ class YamlHandler(DictLikeHandler): ) continue - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) return "".join(out_lines) @@ -275,7 +276,7 @@ class YamlHandler(DictLikeHandler): next_line = next_significant_line(line_index) if next_line is None: skip_until_indent = None - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) else: next_indent, next_stripped = next_line still_in_collection = next_indent > skip_until_indent or ( @@ -284,12 +285,12 @@ class YamlHandler(DictLikeHandler): ) if not still_in_collection: skip_until_indent = None - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue if is_comment: if indent <= skip_until_indent: skip_until_indent = None - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) # Comments/blank lines indented beneath the replaced # collection are considered part of that collection and # cannot be placed safely inside a generated loop. @@ -303,7 +304,7 @@ class YamlHandler(DictLikeHandler): # Blank or comment lines if is_blank or is_comment: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Adjust stack based on indent @@ -315,7 +316,7 @@ class YamlHandler(DictLikeHandler): key_part, rest = stripped.split(":", 1) key = key_part.strip() if not key: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue rest_stripped = rest.lstrip(" \t") @@ -353,7 +354,7 @@ class YamlHandler(DictLikeHandler): continue if not has_value: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Scalar value - replace with variable @@ -376,8 +377,8 @@ class YamlHandler(DictLikeHandler): replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] - new_rest = f"{leading}{replacement}{comment_part}" - new_stripped = f"{key}:{new_rest}" + new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}" + new_stripped = f"{escape_jinja_literal(key)}:{new_rest}" out_lines.append( " " * indent + new_stripped @@ -439,7 +440,7 @@ class YamlHandler(DictLikeHandler): else: replacement = self._yaml_scalar_expr(var_name, raw_value) - new_stripped = f"- {replacement}{comment_part}" + new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}" out_lines.append( " " * indent + new_stripped @@ -447,7 +448,7 @@ class YamlHandler(DictLikeHandler): ) continue - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) return "".join(out_lines) @@ -480,7 +481,7 @@ class YamlHandler(DictLikeHandler): lines: list[str] = [] if not is_list: key = candidate.path[-1] if candidate.path else "items" - lines.append(f"{indent_str}{key}:") + lines.append(f"{indent_str}{escape_jinja_literal(str(key))}:") item_lines: list[str] = [] if candidate.items: @@ -549,12 +550,16 @@ class YamlHandler(DictLikeHandler): if first_key and is_list_item: # First key gets the list marker value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) - lines.append(f"{indent_str}- {key}: {value_expr}") + lines.append( + f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}" + ) first_key = False else: # Subsequent keys are indented sub_indent = indent + 2 if is_list_item else indent value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) - lines.append(f"{' ' * sub_indent}{key}: {value_expr}") + lines.append( + f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}" + ) return lines diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py new file mode 100644 index 0000000..642c0b8 --- /dev/null +++ b/tests/test_injection_security.py @@ -0,0 +1,212 @@ +"""Security regression tests for template injection (SSTI). + +JinjaTurtle copies parts of the source config (comments, unrecognised lines, +structural keys) verbatim into the generated template. If that text contains +Jinja2/ERB delimiters it must be neutralised, otherwise attacker-influenced +config content becomes live template code that executes when Salt/Ansible/Puppet +later renders the template. + +These tests render the *generated* template the way a downstream tool would and +assert that an injected payload never executes. A tripwire object is exposed +under every name a payload might reference; if the rendered output ever contains +the tripwire sentinel, an injected expression executed and the test fails. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +import jinja2 +import pytest +import yaml as pyyaml + +from jinjaturtle.escape import escape_jinja_literal, escape_erb_literal + + +TRIP = "__TRIPWIRE_FIRED__" + + +class _Boom: + """Returns the tripwire sentinel for any access/call an SSTI payload makes.""" + + def run(self, *a, **k): + return TRIP + + def __call__(self, *a, **k): + return TRIP + + def __getitem__(self, k): + return self + + def __getattr__(self, n): + return _Boom() + + def __str__(self): + return TRIP + + +def _render_jinja(template_text: str, defaults: dict) -> str: + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + env.filters.setdefault("to_json", lambda v, **k: __import__("json").dumps(v)) + env.filters.setdefault("lower", lambda v: str(v).lower()) + ctx = dict(defaults or {}) + for name in ("salt", "cmd", "os", "subprocess", "cycler", "lipsum", "namespace"): + ctx.setdefault(name, _Boom()) + return env.from_string(template_text).render(**ctx) + + +def _run_jinjaturtle(tmp_path: Path, source_name: str, body: str, fmt: str): + src = tmp_path / source_name + src.write_text(body, encoding="utf-8") + tpl = tmp_path / "out.tpl" + dfl = tmp_path / "defaults.yml" + res = subprocess.run( + [ + sys.executable, + "-m", + "jinjaturtle.cli", + str(src), + "-f", + fmt, + "--role-name", + "role", + "-t", + str(tpl), + "-d", + str(dfl), + ], + capture_output=True, + text=True, + ) + assert res.returncode == 0, f"generation failed: {res.stderr}" + defaults = pyyaml.safe_load(dfl.read_text()) or {} + return tpl.read_text(), defaults + + +# A representative payload for each format, placed where the format allows +# attacker-controlled verbatim text (comments / unrecognised lines). +FORMAT_CASES = [ + ( + "ini", + "evil.ini", + "[s]\n" + "good = ok ; {{ salt['cmd.run']('id') }}\n" + "# {{ cmd.run('whoami') }}\n", + ), + ( + "yaml", + "evil.yaml", + "server:\n" " motd: ok\n" " # {{ salt['cmd.run']('id') }}\n", + ), + ( + "toml", + "evil.toml", + "[s]\n" 'good = "ok"\n' "# {{ cmd.run('id') }}\n", + ), + ( + "xml", + "evil.xml", + "\n" + " \n" + ' ok\n' + "\n", + ), + ( + "postfix", + "main.cf", + "myhostname = mail.example.com\n" "# {{ salt['cmd.run']('id') }}\n", + ), + ( + "systemd", + "evil.service", + "[Unit]\n" + "Description=ok\n" + "# {{ cmd.run('id') }}\n" + "RawLineNoEquals {% for x in ().__class__.__bases__ %}\n" + "[Service]\n" + "ExecStart=/bin/true\n", + ), + ( + "ssh", + "sshd_config", + "# {{ salt['cmd.run']('id') }}\n" "Port 22\n" "PermitRootLogin no\n", + ), +] + + +@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES) +def test_comment_payload_does_not_execute(tmp_path, fmt, name, body): + template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt) + rendered = _render_jinja(template_text, defaults) + assert TRIP not in rendered, f"injected payload executed for {fmt}:\n{rendered}" + + +@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES) +def test_generated_template_is_renderable(tmp_path, fmt, name, body): + # A correct escape must still produce a syntactically valid template. + template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt) + # Should not raise a TemplateSyntaxError. + _render_jinja(template_text, defaults) + + +# --- Unit-level guarantees for the escaper itself --------------------------- + +SSTI_PAYLOADS = [ + "{{ 7*7 }}", + "{{ salt['cmd.run']('id') }}", + "{% set x = cycler.__init__.__globals__ %}{{ x }}", + "{# comment payload #}", + "text {% endraw %} breakout {{ evil }}", + "nested {% endraw %} spacing {{ evil }}", + "{%- endraw -%}{{ evil }}", + "mixed {{ a }} and {% b %} and {# c #}", + "}}{{ orphan delimiters %}{%", +] + + +@pytest.mark.parametrize("payload", SSTI_PAYLOADS) +def test_escape_jinja_literal_renders_back_to_original(payload): + """Escaped text must render to the exact original characters, inertly.""" + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + escaped = escape_jinja_literal(payload) + rendered = env.from_string(escaped).render(evil="EVIL", x="X", a="A") + assert rendered == payload + assert TRIP not in rendered + + +def test_escape_jinja_literal_noop_on_plain_text(): + for plain in ["", "hello world", "# a normal comment", "port = 8080", "key: value"]: + assert escape_jinja_literal(plain) == plain + + +def test_escape_jinja_literal_actually_blocks_execution(): + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + payload = "{{ boom.run('x') }}" + escaped = escape_jinja_literal(payload) + rendered = env.from_string(escaped).render(boom=_Boom()) + assert TRIP not in rendered + # Sanity: the *unescaped* payload would have fired the tripwire. + fired = env.from_string(payload).render(boom=_Boom()) + assert TRIP in fired + + +@pytest.mark.parametrize( + "payload", + [ + "<%= system('id') %>", + "<% require 'open3' %>", + "text <%= 1+1 %> more", + "-%> orphan <%", + ], +) +def test_escape_erb_literal_removes_executable_tags(payload): + escaped = escape_erb_literal(payload) + # No raw executable ERB tag should survive that contains the original code. + live = re.findall(r"<%[-=#]?(.*?)-?%>", escaped, re.S) + for chunk in live: + assert "system" not in chunk + assert "require" not in chunk + # Numeric/expression payloads must be reduced to literal-string prints. From 15770c8e3d3129d23a20024cce3c89f31953af41 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Jun 2026 14:30:42 +1000 Subject: [PATCH 24/44] Upgrade to poetry2 and update dependencies --- .forgejo/workflows/ci.yml | 20 +++- poetry.lock | 239 ++++++++++++++++++++------------------ pyproject.toml | 40 ++++--- 3 files changed, 165 insertions(+), 134 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d503028..0773dbb 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -17,13 +17,29 @@ jobs: DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-venv pipx - name: Install Poetry + env: + PYTHON_BIN: ${{ matrix.python }} + POETRY_VERSION: "2.4.1" run: | - pipx install poetry==1.8.3 - /root/.local/bin/poetry --version + set -eux + if ! command -v pipx >/dev/null 2>&1; then + "${PYTHON_BIN}" -m pip install --user pipx + fi + PIPX_BIN="$(command -v pipx || true)" + if [ -z "${PIPX_BIN}" ]; then + PIPX_BIN="${HOME}/.local/bin/pipx" + fi + "${PIPX_BIN}" install --python "${PYTHON_BIN}" "poetry==${POETRY_VERSION}" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + poetry --version + poetry --version | grep -E "Poetry \(version 2\." - name: Install project deps (including test extras) + env: + PYTHON_BIN: ${{ matrix.python }} run: | + poetry env use "${PYTHON_BIN}" poetry install --with dev - name: Run test script diff --git a/poetry.lock b/poetry.lock index bc7ba3d..9bfef74 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "certifi" @@ -6,6 +6,7 @@ version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, @@ -17,6 +18,7 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -155,6 +157,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -162,124 +166,110 @@ files = [ [[package]] name = "coverage" -version = "7.14.1" +version = "7.14.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, - {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, - {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, - {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, - {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, - {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, - {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, - {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, - {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, - {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, - {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, - {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, - {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, - {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, - {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, - {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, - {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, - {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, - {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"}, - {file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"}, - {file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"}, - {file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"}, - {file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"}, - {file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"}, + {file = "coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e"}, + {file = "coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d"}, + {file = "coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c"}, + {file = "coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e"}, + {file = "coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610"}, + {file = "coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137"}, + {file = "coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18"}, + {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647"}, + {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803"}, + {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27"}, + {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640"}, + {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7"}, + {file = "coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b"}, + {file = "coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61"}, + {file = "coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3"}, + {file = "coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305"}, + {file = "coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87"}, + {file = "coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700"}, + {file = "coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde"}, + {file = "coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2"}, + {file = "coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb"}, + {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a"}, + {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab"}, + {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7"}, + {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9"}, + {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc"}, + {file = "coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8"}, + {file = "coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2"}, + {file = "coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4"}, + {file = "coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24"}, + {file = "coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665"}, + {file = "coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a"}, + {file = "coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727"}, + {file = "coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977"}, + {file = "coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c"}, + {file = "coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf"}, + {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f"}, + {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205"}, + {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c"}, + {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef"}, + {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd"}, + {file = "coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35"}, + {file = "coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d"}, + {file = "coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336"}, + {file = "coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c"}, + {file = "coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a"}, + {file = "coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027"}, + {file = "coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73"}, + {file = "coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9"}, + {file = "coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de"}, + {file = "coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd"}, + {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5"}, + {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb"}, + {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f"}, + {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498"}, + {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0"}, + {file = "coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37"}, + {file = "coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994"}, + {file = "coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150"}, + {file = "coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc"}, + {file = "coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7"}, + {file = "coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce"}, + {file = "coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5"}, + {file = "coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a"}, + {file = "coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501"}, + {file = "coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e"}, + {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3"}, + {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5"}, + {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845"}, + {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027"}, + {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b"}, + {file = "coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965"}, + {file = "coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3"}, + {file = "coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92"}, + {file = "coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949"}, + {file = "coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891"}, + {file = "coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388"}, + {file = "coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784"}, + {file = "coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed"}, + {file = "coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5"}, + {file = "coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26"}, + {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889"}, + {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d"}, + {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e"}, + {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7"}, + {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635"}, + {file = "coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc"}, + {file = "coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda"}, + {file = "coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f"}, + {file = "coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8"}, + {file = "coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "defusedxml" @@ -287,6 +277,7 @@ version = "0.7.1" description = "XML bomb protection for Python stdlib modules" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, @@ -298,6 +289,7 @@ version = "5.0" description = "A library for working with .desktop files" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, @@ -312,6 +304,8 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -329,6 +323,7 @@ version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, @@ -343,6 +338,7 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -354,6 +350,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -371,6 +368,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -469,6 +467,7 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -480,6 +479,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -495,6 +495,7 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -509,6 +510,7 @@ version = "4.2" description = "Generate AppImages from your Python projects" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"}, {file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"}, @@ -525,6 +527,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -548,6 +551,7 @@ version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, @@ -566,6 +570,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -648,6 +653,7 @@ version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, @@ -669,6 +675,7 @@ version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, @@ -718,6 +725,7 @@ files = [ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] +markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typing-extensions" @@ -725,6 +733,8 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -736,18 +746,19 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] -lock-version = "2.0" -python-versions = "^3.10" -content-hash = "026c4acd254e889b70bb8c25ffb5e6323eee86380f54f2d8ef02f59ae9307529" +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "161dfd9b44e1063656bacea8e8fb08a2614361a8262d8f665240e4f391732e1b" diff --git a/pyproject.toml b/pyproject.toml index cbc30c8..108a7b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,40 @@ -[tool.poetry] +[project] name = "jinjaturtle" version = "0.5.5" description = "Convert config files into Ansible defaults and Jinja2 templates." -authors = ["Miguel Jacq "] +authors = [ + { name = "Miguel Jacq", email = "mig@mig5.net" }, +] license = "GPL-3.0-or-later" readme = "README.md" -packages = [{ include = "jinjaturtle", from = "src" }] - +requires-python = ">=3.10,<4.0" keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"] +dependencies = [ + "PyYAML (>=6.0,<7.0)", + "defusedxml (>=0.7.1,<0.8.0)", + "jinja2 (>=3.1.6,<4.0.0)", + "tomli (>=2.0.0,<3.0.0) ; python_version < '3.11'", +] +[project.urls] homepage = "https://git.mig5.net/mig5/jinjaturtle" repository = "https://git.mig5.net/mig5/jinjaturtle" -[tool.poetry.dependencies] -python = "^3.10" -PyYAML = "^6.0" -tomli = { version = "^2.0.0", python = "<3.11" } -defusedxml = "^0.7.1" -jinja2 = "^3.1.6" - -[tool.poetry.scripts] +[project.scripts] jinjaturtle = "jinjaturtle.cli:main" +[tool.poetry] +packages = [{ include = "jinjaturtle", from = "src" }] + +[tool.poetry.group.dev.dependencies] +pytest = "^8" +pytest-cov = "^5" +pyproject-appimage = "^4.2" + [build-system] -requires = ["poetry-core>=1.0.0"] +requires = ["poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" [tool.pyproject-appimage] script = "jinjaturtle" output = "JinjaTurtle.AppImage" - -[tool.poetry.dev-dependencies] -pytest = "^8" -pytest-cov = "^5" -pyproject-appimage = "^4.2" From a5f8e7c481dbbb108c4b4be4efd76281359f25a7 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Jun 2026 15:42:42 +1000 Subject: [PATCH 25/44] Attempt to fix python bin --- .forgejo/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0773dbb..67c69d3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -7,6 +7,14 @@ jobs: test: runs-on: docker + strategy: + fail-fast: false + matrix: + include: + - distro: debian + image: docker.io/library/debian:13 + python: python3 + steps: - name: Checkout uses: actions/checkout@v4 From 383a529016da534ea8927c4af34a4cb751388226 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Jun 2026 15:57:17 +1000 Subject: [PATCH 26/44] fix test --- tests/test_injection_security.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py index 642c0b8..75edc70 100644 --- a/tests/test_injection_security.py +++ b/tests/test_injection_security.py @@ -23,8 +23,11 @@ import jinja2 import pytest import yaml as pyyaml -from jinjaturtle.escape import escape_jinja_literal, escape_erb_literal - +from jinjaturtle.escape import ( + escape_jinja_literal, + escape_erb_literal, + escape_literal, +) TRIP = "__TRIPWIRE_FIRED__" @@ -210,3 +213,24 @@ def test_escape_erb_literal_removes_executable_tags(payload): assert "system" not in chunk assert "require" not in chunk # Numeric/expression payloads must be reduced to literal-string prints. + + +def test_escape_literal_dispatches_by_engine(): + """The public ``escape_literal`` wrapper routes to the right engine.""" + payload = "{{ 7*7 }}" + # Default engine is Jinja2. + assert escape_literal(payload) == escape_jinja_literal(payload) + assert escape_literal(payload, engine="jinja2") == escape_jinja_literal(payload) + # An unknown engine falls back to the safer Jinja2 escaping. + assert escape_literal(payload, engine="nonsense") == escape_jinja_literal(payload) + # ERB routing. + erb_payload = "<%= system('id') %>" + assert escape_literal(erb_payload, engine="erb") == escape_erb_literal(erb_payload) + + +def test_escape_literal_jinja_output_is_inert(): + """End-to-end: text routed through escape_literal does not execute.""" + escaped = escape_literal("{{ boom.run('x') }}") + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + rendered = env.from_string(escaped).render(boom=_Boom()) + assert TRIP not in rendered From c5375b180f91b0c21d05192887b4389bf1e15f4d Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Jun 2026 17:15:02 +1000 Subject: [PATCH 27/44] 0.5.6 --- debian/changelog | 6 ++++++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 1950637..d32bd4f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.6) unstable; urgency=medium + + * Try to prevent what could lead to execution of embedded jinja in original files when converting + + -- Miguel Jacq Tue, 23 Jun 2026 16:47:00 +1000 + jinjaturtle (0.5.5) unstable; urgency=medium * erb support diff --git a/pyproject.toml b/pyproject.toml index 108a7b2..d856172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jinjaturtle" -version = "0.5.5" +version = "0.5.6" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = [ { name = "Miguel Jacq", email = "mig@mig5.net" }, diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index f354c18..2ed4063 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.5 +%global upstream_version 0.5.6 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Tue Jun 23 2026 Miguel Jacq - %{version}-%{release} +- Try to prevent what could lead to execution of embedded jinja in original files when converting * Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} - erb support * Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} From a9d56b66c53d14e7dc553b5ed590b690850537a8 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Jun 2026 18:03:29 +1000 Subject: [PATCH 28/44] Fixes for releasing --- Dockerfile.debbuild | 17 +++++++++++++++++ README.md | 5 ----- release.sh | 10 ++++------ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Dockerfile.debbuild b/Dockerfile.debbuild index 8d185b1..35ffc7b 100644 --- a/Dockerfile.debbuild +++ b/Dockerfile.debbuild @@ -61,6 +61,23 @@ rsync -a --delete \ "${SRC}/" "${WORK}/" cd "${WORK}" + +# This project's pyproject.toml uses the PEP 621 [project] table, which needs +# poetry-core >= 2.0. Debian bookworm and Ubuntu jammy/noble ship an older +# poetry-core that cannot parse it. On those, swap in the legacy Poetry 1.x +# formatted metadata (kept in sync at pyproject.poetry1.toml) for the build. +# trixie and newer ship poetry-core 2.x and keep the PEP 621 file. +if [ -f pyproject.poetry1.toml ]; then + core_ver="$(python3 -c 'import poetry.core as c; print(c.__version__)' 2>/dev/null || echo 0)" + core_major="${core_ver%%.*}" + if [ "${core_major:-0}" -lt 2 ]; then + echo "poetry-core ${core_ver} < 2.0: using legacy pyproject.poetry1.toml" + cp pyproject.poetry1.toml pyproject.toml + else + echo "poetry-core ${core_ver} >= 2.0: using PEP 621 pyproject.toml" + fi +fi + if [ -n "${SUITE:-}" ]; then export DEBEMAIL="mig@mig5.net" export DEBFULLNAME="Miguel Jacq" diff --git a/README.md b/README.md index 8423cb7..2905cfe 100644 --- a/README.md +++ b/README.md @@ -281,11 +281,6 @@ Clone the repo and then run inside the clone: poetry install ``` -### AppImage - -Download the AppImage from the Releases page, make it executable, and put it on -your `$PATH`. - ## Full usage info ```text diff --git a/release.sh b/release.sh index 2cdb868..874e50e 100755 --- a/release.sh +++ b/release.sh @@ -7,11 +7,6 @@ filedust -y . # Publish to Pypi poetry build -poetry publish - -# Make AppImage -poetry run pyproject-appimage -mv JinjaTurtle.AppImage dist/ # Sign packages for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done @@ -51,8 +46,8 @@ REPO_ROOT="${HOME}/git/repo_rpm" REMOTE="ashpool.mig5.net:/opt/repo_rpm" DISTS=( + fedora:44 fedora:43 - fedora:42 ) for dist in ${DISTS[@]}; do @@ -87,6 +82,9 @@ for dist in ${DISTS[@]}; do qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" done +# If we got this far, we can publish to PyPI +poetry publish + echo "==> Syncing repo to server..." rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" From 094c4d227463e0253d2a2f3d9aa27730352be1d5 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 24 Jun 2026 17:42:18 +1000 Subject: [PATCH 29/44] Fixes --- debian/changelog | 6 + pyproject.toml | 2 +- release.sh | 1 - rpm/jinjaturtle.spec | 6 +- src/jinjaturtle/cli.py | 16 ++ src/jinjaturtle/core.py | 26 +++- src/jinjaturtle/erb.py | 4 +- src/jinjaturtle/escape.py | 11 +- src/jinjaturtle/multi.py | 42 +++-- src/jinjaturtle/safety.py | 255 +++++++++++++++++++++++++++++++ tests/test_injection_security.py | 39 +++++ tests/test_output_safety_gate.py | 233 ++++++++++++++++++++++++++++ 12 files changed, 614 insertions(+), 27 deletions(-) create mode 100644 src/jinjaturtle/safety.py create mode 100644 tests/test_output_safety_gate.py diff --git a/debian/changelog b/debian/changelog index d32bd4f..9173c94 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.7) unstable; urgency=medium + + * More hardening measures + + -- Miguel Jacq Wed, 24 Jun 2026 16:13:00 +1000 + jinjaturtle (0.5.6) unstable; urgency=medium * Try to prevent what could lead to execution of embedded jinja in original files when converting diff --git a/pyproject.toml b/pyproject.toml index d856172..9ec2da5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jinjaturtle" -version = "0.5.6" +version = "0.5.7" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = [ { name = "Miguel Jacq", email = "mig@mig5.net" }, diff --git a/release.sh b/release.sh index 874e50e..e221cef 100755 --- a/release.sh +++ b/release.sh @@ -46,7 +46,6 @@ REPO_ROOT="${HOME}/git/repo_rpm" REMOTE="ashpool.mig5.net:/opt/repo_rpm" DISTS=( - fedora:44 fedora:43 ) diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 2ed4063..051fd8b 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.6 +%global upstream_version 0.5.7 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Wed Jun 24 2026 Miguel Jacq - %{version}-%{release} +- More hardening * Tue Jun 23 2026 Miguel Jacq - %{version}-%{release} - Try to prevent what could lead to execution of embedded jinja in original files when converting * Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} @@ -55,7 +57,7 @@ Convert config files into Ansible defaults and Jinja2 templates. - Fix indentation problems with nested dicts * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Empty dicts and lists are now emitted as leaf defaults. -* Tue May 11 2026 Miguel Jacq - %{version}-%{release} +* Mon May 11 2026 Miguel Jacq - %{version}-%{release} - Support ssh configs * Tue Jan 06 2026 Miguel Jacq - %{version}-%{release} - Support converting systemd files and postfix main.cf diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 0c403fa..6345fae 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -17,6 +17,7 @@ from .core import ( ) from .multi import process_directory +from .safety import TemplateSafetyError, verify_erb_template_safe def _build_arg_parser() -> argparse.ArgumentParser: @@ -76,6 +77,20 @@ def _build_arg_parser() -> argparse.ArgumentParser: def _main(argv: list[str] | None = None) -> int: + try: + return _run(argv) + except TemplateSafetyError as exc: + # The output safety gate refused to emit a template because it contained + # a construct JinjaTurtle never produces -- i.e. attacker-influenced + # source text became live template code. Fail closed with a clear + # message and a non-zero exit code; never write the unsafe template. + print( + f"jinjaturtle: refusing to generate unsafe template: {exc}", file=sys.stderr + ) + return 2 + + +def _run(argv: list[str] | None = None) -> int: defuse_stdlib() parser = _build_arg_parser() args = parser.parse_args(argv) @@ -107,6 +122,7 @@ def _main(argv: list[str] | None = None) -> int: role_prefix=args.role_name, puppet_class=args.puppet_class or args.role_name, ) + verify_erb_template_safe(o.template) template_ext = "erb" if args.template_engine == "erb" else j2.TEMPLATE_EXTENSION diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 1faefca..4d35260 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -9,6 +9,10 @@ import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb +from .safety import ( + verify_erb_template_safe, + verify_jinja2_template_safe, +) from .handlers import ( BaseHandler, IniHandler, @@ -378,14 +382,21 @@ def generate_jinja2_template( # Check if handler supports loop-aware generation if hasattr(handler, "generate_jinja2_template_with_loops") and loop_candidates: - return handler.generate_jinja2_template_with_loops( + template = handler.generate_jinja2_template_with_loops( parsed, role_prefix, original_text, loop_candidates ) + else: + # Fallback to original scalar-only generation + template = handler.generate_jinja2_template( + parsed, role_prefix, original_text=original_text + ) - # Fallback to original scalar-only generation - return handler.generate_jinja2_template( - parsed, role_prefix, original_text=original_text - ) + # Defence in depth: independently verify that the finished template contains + # only JinjaTurtle-emitted constructs. If any handler failed to neutralise + # verbatim source text, the un-escaped payload shows up here as a live tag + # and generation aborts instead of emitting an injectable template. + verify_jinja2_template_safe(template) + return template def _template_variable_names( @@ -457,12 +468,15 @@ def generate_erb_template( loop_candidates=loop_candidates, ) names = _template_variable_names(role_prefix, flat_items or [], loop_candidates) - return translate_jinja2_to_erb( + erb_template = translate_jinja2_to_erb( jinja_template, role_prefix=role_prefix, puppet_class=puppet_class or role_prefix, variable_names=names, ) + # Defence in depth: no live Jinja2 delimiter may survive translation. + verify_erb_template_safe(erb_template) + return erb_template def _stringify_timestamps(obj: Any) -> Any: diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py index 6eb26ad..4da6a6e 100644 --- a/src/jinjaturtle/erb.py +++ b/src/jinjaturtle/erb.py @@ -76,7 +76,9 @@ class ErbTranslator: # JinjaTurtle emits raw blocks only to carry verbatim, security-escaped # source text (comments and unrecognised lines), so the *contents* must be # treated as literal output, never translated as Jinja tokens. - _RAW_BLOCK_RE = re.compile(r"{%\s*raw\s*%}(.*?){%\s*endraw\s*%}", re.S) + _RAW_BLOCK_RE = re.compile( + r"{%[-+]?\s*raw\s*[-+]?%}(.*?){%[-+]?\s*endraw\s*[-+]?%}", re.S + ) def translate(self, template_text: str) -> str: # Split out raw blocks first. Their inner text is literal and must be diff --git a/src/jinjaturtle/escape.py b/src/jinjaturtle/escape.py index 3b8575e..129c08a 100644 --- a/src/jinjaturtle/escape.py +++ b/src/jinjaturtle/escape.py @@ -50,9 +50,14 @@ _JINJA_MARKERS = ("{{", "}}", "{%", "%}", "{#", "#}") _ERB_OPEN_MARKERS = ("<%=", "<%-", "<%#", "<%") _ERB_CLOSE_MARKERS = ("-%>", "%>") -# Matches a Jinja2 endraw tag in any internal spacing, e.g. "{%endraw%}", -# "{% endraw %}", "{%- endraw -%}". -_ENDRAW_RE = re.compile(r"{%-?\s*endraw\s*-?%}") +# Matches a Jinja2 endraw tag in any internal spacing and with any +# whitespace-control marker on either side. Jinja2 accepts "-", "+", or no +# marker adjacent to the "%}"/"{%" of a block tag (e.g. "{%endraw%}", +# "{% endraw %}", "{%- endraw -%}", "{%+ endraw +%}"), and ALL of these close +# a raw block. The control marker must be matched so a "{%+ endraw %}" in +# attacker-influenced source text cannot survive defanging and break out of our +# {% raw %} wrapper. [-+]? appears on both sides accordingly. +_ENDRAW_RE = re.compile(r"{%[-+]?\s*endraw\s*[-+]?%}") # Sentinel inserted between "end" and "raw" to break the endraw keyword without # changing the visible characters. We use a Jinja comment-free approach: insert diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index 05f92b3..195434f 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -31,6 +31,8 @@ import xml.etree.ElementTree as ET # nosec from . import j2 from .core import dump_yaml, flatten_config, make_var_name, parse_config from .handlers.xml import XmlHandler +from .safety import verify_jinja2_template_safe +from .escape import escape_jinja_literal SUPPORTED_SUFFIXES: dict[str, set[str]] = { @@ -160,6 +162,11 @@ def _yaml_render_union( if isinstance(union_obj, dict): for key, val in union_obj.items(): key_path = path + (str(key),) + # The key text is copied verbatim into the template; escape it so an + # attacker-influenced key (e.g. ``{{ 7*7 }}``) cannot become live + # template code. ``key_path`` (used only to build sanitised var + # names) keeps the original key. + safe_key = escape_jinja_literal(str(key)) cond_var = ( defined_var_name(role_prefix, key_path) if key_path in optional_containers @@ -170,13 +177,13 @@ def _yaml_render_union( value = _yaml_scalar_placeholder(role_prefix, key_path, val) if cond_var: lines.append(f"{ind}{j2.if_defined(cond_var)}") - lines.append(f"{ind}{key}: {value}") + lines.append(f"{ind}{safe_key}: {value}") if cond_var: lines.append(f"{ind}{j2.endif()}") else: if cond_var: lines.append(f"{ind}{j2.if_defined(cond_var)}") - lines.append(f"{ind}{key}:") + lines.append(f"{ind}{safe_key}:") lines.extend( _yaml_render_union( role_prefix, @@ -214,6 +221,7 @@ def _yaml_render_union( first = True for k, v in item.items(): kp = item_path + (str(k),) + safe_k = escape_jinja_literal(str(k)) k_cond = ( defined_var_name(role_prefix, kp) if kp in optional_containers @@ -224,14 +232,14 @@ def _yaml_render_union( if first: if k_cond: lines.append(f"{ind}{j2.if_defined(k_cond)}") - lines.append(f"{ind}- {k}: {value}") + lines.append(f"{ind}- {safe_k}: {value}") if k_cond: lines.append(f"{ind}{j2.endif()}") first = False else: if k_cond: lines.append(f"{ind} {j2.if_defined(k_cond)}") - lines.append(f"{ind} {k}: {value}") + lines.append(f"{ind} {safe_k}: {value}") if k_cond: lines.append(f"{ind} {j2.endif()}") else: @@ -239,7 +247,7 @@ def _yaml_render_union( if first: if k_cond: lines.append(f"{ind}{j2.if_defined(k_cond)}") - lines.append(f"{ind}- {k}:") + lines.append(f"{ind}- {safe_k}:") lines.extend( _yaml_render_union( role_prefix, @@ -255,7 +263,7 @@ def _yaml_render_union( else: if k_cond: lines.append(f"{ind} {j2.if_defined(k_cond)}") - lines.append(f"{ind} {k}:") + lines.append(f"{ind} {safe_k}:") lines.extend( _yaml_render_union( role_prefix, @@ -301,6 +309,7 @@ def _toml_render_union( def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: var_name = make_var_name(role_prefix, path + (key,)) + safe_key = escape_jinja_literal(str(key)) cond = ( defined_var_name(role_prefix, path + (key,)) if (path + (key,)) in optional_containers @@ -309,11 +318,11 @@ def _toml_render_union( if cond: lines.append(f"{j2.if_defined(cond)}") if isinstance(value, str): - lines.append(f"{key} = {j2.quoted_variable(var_name)}") + lines.append(f"{safe_key} = {j2.quoted_variable(var_name)}") elif isinstance(value, bool): - lines.append(f"{key} = {j2.lower(var_name)}") + lines.append(f"{safe_key} = {j2.lower(var_name)}") else: - lines.append(f"{key} = {j2.variable(var_name)}") + lines.append(f"{safe_key} = {j2.variable(var_name)}") if cond: lines.append(j2.endif()) @@ -326,7 +335,7 @@ def _toml_render_union( ) if cond: lines.append(f"{j2.if_defined(cond)}") - lines.append(f"[{'.'.join(path)}]") + lines.append(f"[{'.'.join(escape_jinja_literal(str(p)) for p in path)}]") scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} @@ -412,10 +421,11 @@ def _ini_render_union( ) if sec_cond: lines.append(f"{j2.if_defined(sec_cond)}") - lines.append(f"[{section}]") + lines.append(f"[{escape_jinja_literal(str(section))}]") for key, raw_val in union.items(section, raw=True): path = (section, key) var = make_var_name(role_prefix, path) + safe_key = escape_jinja_literal(str(key)) key_cond = ( defined_var_name(role_prefix, path) if path in optional_keys else None ) @@ -424,9 +434,9 @@ def _ini_render_union( if key_cond: lines.append(f"{j2.if_defined(key_cond)}") if quoted: - lines.append(f"{key} = {j2.quoted_variable(var)}") + lines.append(f"{safe_key} = {j2.quoted_variable(var)}") else: - lines.append(f"{key} = {j2.variable(var)}") + lines.append(f"{safe_key} = {j2.variable(var)}") if key_cond: lines.append(j2.endif()) lines.append("") @@ -769,4 +779,10 @@ def process_directory( defaults_doc[out.list_var] = out.items defaults_yaml = dump_yaml(defaults_doc, sort_keys=True) + # Defence in depth: folder-mode union templates are built by their own + # renderers (not core.generate_jinja2_template), so gate each one here too. + # Any un-neutralised source text that became a live tag aborts generation. + for out in outputs: + verify_jinja2_template_safe(out.template) + return defaults_yaml, outputs diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py new file mode 100644 index 0000000..07d3b8d --- /dev/null +++ b/src/jinjaturtle/safety.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +"""Output safety gate for generated templates (defence in depth). + +JinjaTurtle's first line of defence is per-handler escaping: every piece of +verbatim source text is meant to be wrapped/neutralised before it reaches the +template (see ``escape.py``). That model is correct but *fragile*: it relies on +every handler remembering to escape at every site, and on the escaper being +exactly right for every delimiter form. A single forgotten call site -- or a +new handler, or a missed delimiter variant -- silently reopens a +template-injection / SSTI path that can become remote code execution on the +configuration-management control node when the template is later rendered. + +This module adds a second, independent line of defence that does **not** depend +on getting every escape right. It inspects the *finished* template and proves a +single global property: + + Every *live* template construct in the output is one that JinjaTurtle itself + legitimately emits. Anything else can only have originated from + un-neutralised source text, so generation fails loudly instead of emitting a + dangerous template. + +The check is positive/allowlist-based, which is the safe direction: unknown +constructs are rejected, not ignored. It runs at the single choke points in +``core.py`` (``generate_jinja2_template`` / ``generate_erb_template``), so it +covers every current handler and every future one automatically. + +Why this is robust against the escaper being wrong +--------------------------------------------------- +We tokenise with Jinja2's own lexer. The lexer emits a JinjaTurtle +``{% raw %} ... {% endraw %}`` wrapper as ``raw_begin`` / inert ``data`` / +``raw_end``: the wrapped literal text is *not* tokenised as live tags. So the +verifier only ever sees, as live constructs, the tags Jinja2 would actually +execute. If an escaped block was mis-wrapped such that a payload escapes the +raw wrapper (the historical ``{%+ endraw %}`` bug), that payload now appears as a +*live* token here and is rejected -- the gate catches the failure even though +the escaper produced it. +""" + +import re + +__all__ = [ + "TemplateSafetyError", + "verify_jinja2_template_safe", + "verify_erb_template_safe", +] + + +class TemplateSafetyError(Exception): + """Raised when a generated template contains a construct JinjaTurtle would + never emit, indicating that un-neutralised source text became live template + code. Generation must abort rather than emit the template.""" + + +# --------------------------------------------------------------------------- # +# Allowlist grammar for JinjaTurtle-emitted Jinja2. +# +# JinjaTurtle emits a deliberately tiny subset of Jinja2. Each pattern below +# describes the *full body* of a tag (the text between ``{%``/``%}`` or +# ``{{``/``}}``), already stripped of surrounding whitespace and of any ``-``/ +# ``+`` whitespace-control markers. Identifiers (variable names, loop vars, +# keys) are restricted to a conservative character class; crucially this class +# excludes characters needed for SSTI gadgets (quotes, parentheses, brackets, +# arithmetic/operator characters, ``%``, ``|`` except in the known filter forms, +# attribute access beyond a single dotted hop, etc.). +# --------------------------------------------------------------------------- # + +# A single identifier. Word characters only, and -- critically -- no +# double-underscore anywhere. ``__`` is the gateway to every classic Jinja2 +# SSTI gadget (``__class__``, ``__init__``, ``__globals__``, ``__builtins__``), +# and JinjaTurtle never emits a dunder, so forbidding ``__`` here removes the +# entire attribute-traversal escape class even if such a token reached output. +_NAME = r"(?!\w*__)[A-Za-z_][A-Za-z0-9_]*" + +# A dotted reference for loop-item field access. JinjaTurtle emits at most +# three hops (``loopvar``, ``loopvar.field``, ``loopvar.key.subkey``), so cap the +# depth rather than allow arbitrary chains. +_DOTTED = rf"{_NAME}(?:\.{_NAME}){{0,2}}" + +# Filters JinjaTurtle is known to emit inside ``{{ ... }}`` expressions. +_KNOWN_FILTERS = ( + r"lower", + r"to_json\(ensure_ascii=(?:True|False)\)", + r"to_json\(indent=\d+,\s*ensure_ascii=(?:True|False)\)", + r"tojson", +) +_FILTER_ALT = "|".join(_KNOWN_FILTERS) + +# Expression bodies allowed inside ``{{ ... }}``. +_EXPR_PATTERNS = tuple( + re.compile(p) + for p in ( + # Plain variable / dotted loop-field reference. + rf"^{_DOTTED}$", + # Filtered reference: ``name | filter`` (one known filter). + rf"^{_DOTTED}\s*\|\s*(?:{_FILTER_ALT})$", + # YAML-preserving boolean ternary emitted by j2.yaml_*_expression: + # 'true' if NAME else 'false' / "true" if NAME else "false" + rf"^(['\"])(?:true|false)\1\s+if\s+{_DOTTED}\s+else\s+(['\"])(?:true|false)\2$", + # YAML-preserving null ternary: + # 'null' if NAME is none else NAME + rf"^(['\"])null\1\s+if\s+{_DOTTED}\s+is\s+none\s+else\s+{_DOTTED}$", + ) +) + +# Statement bodies allowed inside ``{% ... %}``. +_STMT_PATTERNS = tuple( + re.compile(p) + for p in ( + rf"^for\s+{_NAME}\s+in\s+{_DOTTED}$", + r"^endfor$", + rf"^if\s+{_DOTTED}\s+is\s+defined$", + rf"^if\s+{_DOTTED}\s+is\s+none$", + r"^if\s+not\s+loop\.last$", + r"^else$", + r"^endif$", + # ``elif`` is emitted only for the same shapes as the if-conditions + # above; keep it conservative. + rf"^elif\s+{_DOTTED}\s+is\s+(?:defined|none)$", + ) +) + + +def _strip_ws_control(body: str) -> str: + """Remove a leading/trailing Jinja whitespace-control marker and spaces.""" + body = body.strip() + if body[:1] in "-+": + body = body[1:] + if body[-1:] in "-+": + body = body[:-1] + return body.strip() + + +def _expr_is_allowed(body: str) -> bool: + body = _strip_ws_control(body) + return any(p.match(body) for p in _EXPR_PATTERNS) + + +def _stmt_is_allowed(body: str) -> bool: + body = _strip_ws_control(body) + return any(p.match(body) for p in _STMT_PATTERNS) + + +def verify_jinja2_template_safe(template_text: str) -> None: + """Validate that *template_text* contains only JinjaTurtle-emitted Jinja2. + + Raises :class:`TemplateSafetyError` on the first live construct that is not + in the allowlist grammar. Text inside JinjaTurtle's own ``{% raw %}`` + wrappers is treated as inert (the lexer does not tokenise it as tags), so + legitimately-escaped source content passes. + """ + # Import lazily so the dependency is only needed when generating Jinja2. + import jinja2 + + env = jinja2.Environment(autoescape=True) + + try: + tokens = list(env.lex(template_text)) + except jinja2.TemplateSyntaxError as exc: + # A syntax error means our own raw-wrapping did not fully contain the + # source text (e.g. an early ``endraw`` breakout left dangling tags). + # That is precisely a safety failure, not a benign parse hiccup. + raise TemplateSafetyError( + f"generated template does not lex as the JinjaTurtle subset: {exc}" + ) from exc + + # Walk the token stream. Jinja2 yields raw blocks as single + # ``raw_begin``/``raw_end`` tokens with inert ``data`` between them, so we + # never see wrapped literal text as live tags. We collect the raw inner + # text of each live ``{% %}`` / ``{{ }}`` construct (preserving original + # spacing) and check it against the allowlist. + mode: str | None = None # None, "block", or "variable" + body_parts: list[str] = [] + + def _flush(kind: str, lineno: int) -> None: + body = "".join(body_parts) + if kind == "variable": + ok = _expr_is_allowed(body) + else: + ok = _stmt_is_allowed(body) + if not ok: + shown = body.strip() + wrapped = ( + "{{ " + shown + " }}" if kind == "variable" else "{% " + shown + " %}" + ) + raise TemplateSafetyError( + "refusing to emit template: unexpected live " + f"{'expression' if kind == 'variable' else 'statement'} " + f"{wrapped!r} at line {lineno}. This construct is not one " + "JinjaTurtle emits, so it likely came from un-neutralised " + "source text (possible template injection)." + ) + + for lineno, tok_type, value in tokens: + if tok_type in ("variable_begin", "block_begin"): + mode = "variable" if tok_type == "variable_begin" else "block" + body_parts = [] + elif tok_type in ("variable_end", "block_end"): + if mode is not None: + _flush(mode, lineno) + mode = None + body_parts = [] + elif mode is not None: + # Preserve the original token text (including its own whitespace + # tokens) so the reconstructed body matches the source spacing. + body_parts.append(value if isinstance(value, str) else str(value)) + # raw_begin / raw_end / data tokens outside a tag are inert: skip. + + +# --------------------------------------------------------------------------- # +# ERB gate. +# +# JinjaTurtle's ERB output is produced by translating the (already-verified) +# Jinja2 subset, so the Jinja2 gate is the primary guarantee. As an independent +# ERB-side backstop we confirm that every ERB tag body is one the translator +# emits, and that no Jinja2 delimiters survived into the ERB output (which would +# indicate a raw block the translator failed to recognise -- the historical +# ``{%+ raw %}`` blind spot). +# --------------------------------------------------------------------------- # + +_ERB_TAG_RE = re.compile(r"<%[-=#]?(.*?)[-]?%>", re.S) +_JINJA_DELIMS = ("{{", "}}", "{%", "%}", "{#", "#}") + +# Bodies the ErbTranslator emits. Kept permissive for Ruby method chains it +# constructs (``@var``, ``.each_with_index``, ``JSON.generate(...)`` etc.) but +# anchored so arbitrary attacker text cannot masquerade as one. +_ERB_STMT_PATTERNS = tuple( + re.compile(p) + for p in ( + r"^require 'json'$", + r"^end$", + r"^else$", + r"^@?[A-Za-z_][\w@\.\[\]'\"]*\.each_with_index do \|[A-Za-z_]\w*, __jt_idx_\d+\| $", + r"^if .+$", + r"^elsif .+$", + r"^unless .+\.nil\?$", + r"^# Unsupported JinjaTurtle statement: .*$", + ) +) + + +def verify_erb_template_safe(template_text: str) -> None: + """Validate that *template_text* contains no leftover Jinja2 delimiters. + + The translator is the security-relevant step for ERB; this backstop ensures + no live Jinja construct survived translation (which would mean a raw block + was not recognised and source text passed through untouched). + """ + for delim in _JINJA_DELIMS: + if delim in template_text: + raise TemplateSafetyError( + "refusing to emit ERB template: it still contains the Jinja2 " + f"delimiter {delim!r}, which means source text was not fully " + "translated/neutralised (possible template injection)." + ) diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py index 75edc70..a1de483 100644 --- a/tests/test_injection_security.py +++ b/tests/test_injection_security.py @@ -165,6 +165,18 @@ SSTI_PAYLOADS = [ "text {% endraw %} breakout {{ evil }}", "nested {% endraw %} spacing {{ evil }}", "{%- endraw -%}{{ evil }}", + # Whitespace-control markers: Jinja2 accepts "-", "+" or none adjacent to a + # tag's delimiters, and every variant closes a {% raw %} block. The "+" + # forms in particular were a raw-wrapper breakout vector (the defang regex + # historically only matched "-"), so all combinations must be neutralised. + "{%+ endraw %}{{ evil }}", + "{% endraw +%}{{ evil }}", + "{%+ endraw +%}{{ evil }}", + "{%- endraw +%}{{ evil }}", + "{%+ endraw -%}{{ evil }}", + # Full breakout attempt: close raw early, inject live code, re-open raw to + # swallow our trailing {% endraw %} so the template would otherwise compile. + "{%+ endraw %}{{ evil }}{%+ raw %}", "mixed {{ a }} and {% b %} and {# c #}", "}}{{ orphan delimiters %}{%", ] @@ -196,6 +208,33 @@ def test_escape_jinja_literal_actually_blocks_execution(): assert TRIP in fired +@pytest.mark.parametrize( + "endraw", + [ + "{% endraw %}", + "{% endraw -%}", + "{% endraw +%}", + "{%- endraw %}", + "{%- endraw -%}", + "{%- endraw +%}", + "{%+ endraw %}", + "{%+ endraw -%}", + "{%+ endraw +%}", + ], +) +def test_endraw_whitespace_control_cannot_break_out(endraw): + """Every whitespace-control form of endraw closes a {% raw %} block in + Jinja2, so each must be defanged. A payload that closes raw early, injects + a live tripwire call, then re-opens raw to balance the wrapper must still + render inertly back to its original characters.""" + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + payload = f"{endraw}{{{{ boom.run('x') }}}}{{%+ raw %}}" + escaped = escape_jinja_literal(payload) + rendered = env.from_string(escaped).render(boom=_Boom()) + assert TRIP not in rendered + assert rendered == payload + + @pytest.mark.parametrize( "payload", [ diff --git a/tests/test_output_safety_gate.py b/tests/test_output_safety_gate.py new file mode 100644 index 0000000..055ceb3 --- /dev/null +++ b/tests/test_output_safety_gate.py @@ -0,0 +1,233 @@ +"""Regression tests for the output safety gate (``jinjaturtle.safety``). + +The gate is JinjaTurtle's second, independent line of defence against template +injection. Where the per-handler escaper neutralises verbatim source text, the +gate inspects the *finished* template and refuses to emit it if any live +construct is not one JinjaTurtle itself produces. These tests cover: + + * the gate's allow/deny grammar (unit level); + * the two concrete injection findings that motivated it -- JSON object keys + and folder-mode union keys copied into templates unescaped; + * end-to-end CLI fail-closed behaviour (non-zero exit, no file written). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from jinjaturtle import core +from jinjaturtle.multi import process_directory +from jinjaturtle.safety import ( + TemplateSafetyError, + verify_jinja2_template_safe, + verify_erb_template_safe, +) + + +# --------------------------------------------------------------------------- # +# Unit: the allow/deny grammar. +# --------------------------------------------------------------------------- # + +LEGIT_TEMPLATES = [ + "{{ demo_memory_limit }}", + "{{ demo_x | to_json(ensure_ascii=False) }}", + "{{ demo_a | to_json(indent=2, ensure_ascii=False) }}", + "{{ demo_name | lower }}", + "{{ 'true' if demo_flag else 'false' }}", + '{{ "true" if demo_flag else "false" }}', + "{{ 'null' if demo_v is none else demo_v }}", + "{% for server in demo_servers %}{{ server.name }}{% endfor %}", + "{{ server.config.port }}", + "{% if demo_x is defined %}{{ demo_x }}{% endif %}", + "{% if demo_x is none %}x{% endif %}", + "{% if not loop.last %},{% endif %}", + "plain text with no tags", + "{% raw %}# literal {{ not_code }} {% if x %}{% endraw %}", + "{% raw %}{{ 7*7 }}{% endraw %}: value", # escaped key (folder-mode fix) +] + + +@pytest.mark.parametrize("template", LEGIT_TEMPLATES) +def test_gate_allows_jinjaturtle_constructs(template): + # Must not raise. + verify_jinja2_template_safe(template) + + +MALICIOUS_TEMPLATES = [ + "{{ 7*7 }}", + "{{ cycler.__init__.__globals__ }}", + "{{ cycler.__init__.__globals__.os.popen('id').read() }}", + "{{ salt['cmd.run']('id') }}", + "{{ self.__init__ }}", + "{% for x in ().__class__.__base__.__subclasses__() %}{{ x }}{% endfor %}", + "{{ config.items() }}", + '{{ request["application"] }}', + "{% set x = 1 %}", + "{{ lipsum.__globals__ }}", + "{{ a.__class__.__mro__ }}", + "{{ ''.join(['a','b']) }}", + "{% include 'x' %}", + "{% import 'x' as y %}", +] + + +@pytest.mark.parametrize("template", MALICIOUS_TEMPLATES) +def test_gate_blocks_injection(template): + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe(template) + + +def test_gate_blocks_double_underscore_anywhere(): + # The no-dunder rule is the backbone of blocking attribute-traversal SSTI. + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe("{{ demo__x }}") + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe("{{ a.b__c }}") + + +def test_gate_rejects_unlexable_breakout_as_safety_error(): + # A raw-wrapper breakout that leaves dangling tags must surface as a + # TemplateSafetyError, not a raw Jinja2 syntax error. + broken = "{% raw %}{%+ endraw %}{{ 7*7 }}" # unbalanced on purpose + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe(broken) + + +# --------------------------------------------------------------------------- # +# Finding 1: JSON object keys copied verbatim into the template. +# --------------------------------------------------------------------------- # + +JSON_KEY_PAYLOADS = [ + '{ "{{ 7*7 }}": "v" }', + '{ "{{cycler.__init__.__globals__}}": 1 }', + "{ \"ok\": { \"{{ salt['cmd.run']('id') }}\": 2 } }", +] + + +@pytest.mark.parametrize("src", JSON_KEY_PAYLOADS) +def test_json_key_injection_is_blocked(src): + parsed = json.loads(src) + with pytest.raises(TemplateSafetyError): + core.generate_jinja2_template("json", parsed, "demo", original_text=src) + + +def test_json_benign_template_still_generates(): + src = '{ "name": "app", "port": 8080 }' + parsed = json.loads(src) + out = core.generate_jinja2_template("json", parsed, "demo", original_text=src) + assert "demo_name" in out + assert "demo_port" in out + + +# --------------------------------------------------------------------------- # +# Finding 2: folder-mode union renderers copied keys verbatim. +# --------------------------------------------------------------------------- # + + +def _write(tmp: Path, name: str, text: str) -> None: + (tmp / name).write_text(text, encoding="utf-8") + + +def test_folder_yaml_key_injection_is_neutralised(tmp_path): + # The malicious key must be escaped (root-cause fix), so the union template + # generates successfully AND passes the gate, rendering the key inertly. + _write(tmp_path, "a.yaml", '"{{ 7*7 }}": value\nnormalkey: ok\n') + _write(tmp_path, "b.yaml", "normalkey: ok2\n") + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + # Escaped, not live: + assert "{% raw %}{{ 7*7 }}{% endraw %}" in template + # And the gate (run inside process_directory) did not reject it. + + +def test_folder_ini_section_and_key_injection_neutralised(tmp_path): + _write( + tmp_path, + "a.ini", + "[{{ 7*7 }}]\n{{ evil }} = x\n", + ) + _write(tmp_path, "b.ini", "[normal]\nk = y\n") + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + # Section header and key are escaped, not live. + assert "{{ 7*7 }}" not in _strip_raw_blocks(template) + assert "{{ evil }}" not in _strip_raw_blocks(template) + + +def test_folder_toml_key_injection_neutralised(tmp_path): + _write(tmp_path, "a.toml", '"{{ 7*7 }}" = "v"\nok = "y"\n') + _write(tmp_path, "b.toml", 'ok = "z"\n') + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + assert "{{ 7*7 }}" not in _strip_raw_blocks(template) + + +def _strip_raw_blocks(text: str) -> str: + """Remove the *contents* of raw blocks so we can assert no LIVE payload + remains outside them.""" + import re + + return re.sub( + r"{%[-+]?\s*raw\s*[-+]?%}.*?{%[-+]?\s*endraw\s*[-+]?%}", + "", + text, + flags=re.S, + ) + + +# --------------------------------------------------------------------------- # +# ERB gate. +# --------------------------------------------------------------------------- # + + +def test_erb_gate_blocks_leftover_jinja_delimiters(): + with pytest.raises(TemplateSafetyError): + verify_erb_template_safe("ok <%= @x %> but {{ leftover }} here") + + +def test_erb_gate_allows_clean_erb(): + verify_erb_template_safe("memory = <%= @memory_limit %>\n") + + +# --------------------------------------------------------------------------- # +# End-to-end CLI: fail closed. +# --------------------------------------------------------------------------- # + + +def _run_cli(args, env_extra=None): + import os + + env = {**os.environ, "PYTHONPATH": "src"} + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, "-m", "jinjaturtle.cli", *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_cli_fails_closed_on_injection(tmp_path): + bad = tmp_path / "evil.json" + bad.write_text('{ "{{ 7*7 }}": "v" }', encoding="utf-8") + out = tmp_path / "out.j2" + result = _run_cli([str(bad), "-r", "demo", "-f", "json", "-t", str(out)]) + assert result.returncode == 2 + assert "refusing to generate unsafe template" in result.stderr + # No template file may be written when the gate refuses. + assert not out.exists() + + +def test_cli_succeeds_on_benign_input(tmp_path): + good = tmp_path / "ok.json" + good.write_text('{ "name": "app" }', encoding="utf-8") + out = tmp_path / "out.j2" + result = _run_cli([str(good), "-r", "demo", "-f", "json", "-t", str(out)]) + assert result.returncode == 0 + assert out.exists() From 661320558ca15de0e0ed74a6b22153f408160d06 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 25 Jun 2026 17:07:58 +1000 Subject: [PATCH 30/44] Go back to just jinja2 --- README.md | 125 +--------------- src/jinjaturtle/cli.py | 79 ++-------- src/jinjaturtle/core.py | 70 --------- src/jinjaturtle/erb.py | 243 ------------------------------- src/jinjaturtle/escape.py | 57 +------- src/jinjaturtle/safety.py | 4 +- tests/test_cli.py | 54 ------- tests/test_injection_security.py | 47 +----- tests/test_yaml_handler.py | 11 -- 9 files changed, 26 insertions(+), 664 deletions(-) delete mode 100644 src/jinjaturtle/erb.py diff --git a/README.md b/README.md index 2905cfe..1726719 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,6 @@ By default it generates: - an **Ansible defaults YAML** file containing the variables used by that template. -It can also generate **ERB** templates and **Puppet Hiera-style YAML** data for -Puppet workflows. - JinjaTurtle does not try to replace configuration-management tools. Its job is to speed up the boring first pass: take a real config file, discover the values inside it, replace those values with variables, and write the corresponding @@ -37,16 +34,6 @@ For the default Jinja2/Ansible mode: 5. An Ansible defaults YAML file is generated with those variables and the original values. -For ERB/Puppet mode: - -1. The same parse/flatten/loop analysis is used. -2. An ERB template is generated with Puppet-style instance variables such as - `<%= @memory_limit %>`. -3. The variables file is written as Puppet Hiera-style data, such as - `php::memory_limit: 256M`. -4. If `--puppet-class` is supplied, that class name is used as the Hiera - namespace while `--role-name` remains the local variable prefix. - By default, the generated variable data and template are printed to stdout. Use `--defaults-output` and `--template-output` to write them to files. @@ -80,78 +67,6 @@ and defaults data like: php_memory_limit: 256M ``` -## ERB / Puppet example - -Use `--template-engine erb` when you want Puppet ERB output: - -```shell -jinjaturtle php.ini \ - --role-name php \ - --template-engine erb \ - --defaults-output data/common.yaml \ - --template-output templates/php.ini.erb -``` - -Given the same source value: - -```ini -memory_limit = 256M -``` - -JinjaTurtle will produce an ERB template value like: - -```erb -memory_limit = <%= @memory_limit %> -``` - -and Hiera-style data like: - -```yaml -php::memory_limit: 256M -``` - -The `--defaults-output` option name is retained for CLI compatibility, but in -ERB mode the file is intended to be Puppet Hiera data rather than Ansible role -defaults. - -JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet -module, should still declare the class parameters and call the template, for -example with Puppet's `template()` function. - -## Using `--puppet-class` - -Most direct usage can simply use the same value for the role name and Puppet -class name: - -```shell -jinjaturtle php.ini --role-name php --template-engine erb -``` - -This creates Hiera keys such as: - -```yaml -php::memory_limit: 256M -``` - -and local ERB variables such as: - -```erb -<%= @memory_limit %> -``` - -For generated systems, it can be useful to make `--role-name` more specific -while keeping the Hiera keys under the real Puppet class. For example: - -```shell -jinjaturtle php.ini \ - --role-name php_etc_php_ini \ - --puppet-class php \ - --template-engine erb -``` - -In that case the variable prefix can stay file-specific, while the Hiera data -is still written under `php::...`. - ## What sort of config files can it handle? JinjaTurtle supports common structured and semi-structured config formats: @@ -176,8 +91,8 @@ homogeneous enough. If it is not confident, it falls back to flattened scalar variables. Some very complex files will still need manual cleanup. The goal is to speed up -conversion into Jinja2 or ERB templates, not to guarantee a perfect final module -without review. +conversion into Jinja2 templates, not to guarantee a perfect final module without +review. ## JSON, quoting, and type preservation @@ -196,17 +111,7 @@ when the correct rendered JSON should be: {"enabled": true} ``` -In Jinja2 mode this uses Ansible-style JSON filters. In ERB mode it emits Ruby -JSON generation where required, for example: - -```erb -<% require 'json' -%> -{ - "enabled": <%= JSON.generate(@enabled) %> -} -``` - -That is expected for JSON ERB templates. +This uses Ansible-style JSON filters. ## Can I convert multiple files at once? @@ -287,8 +192,6 @@ poetry install usage: jinjaturtle [-h] [-r ROLE_NAME] [--recursive] [-f {ini,json,toml,yaml,xml,postfix,systemd,ssh}] [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] - [--template-engine {jinja2,erb}] - [--puppet-class PUPPET_CLASS] config Convert a config file into an Ansible defaults file and Jinja2 template. @@ -302,8 +205,7 @@ options: -h, --help show this help message and exit -r, --role-name ROLE_NAME Role name / variable prefix. In Jinja2 mode this is - usually the Ansible role name. In ERB mode it is used - as the local variable prefix. Defaults to jinjaturtle. + usually the Ansible role name. Defaults to jinjaturtle. --recursive When CONFIG is a folder, recurse into subfolders. -f, --format {ini,json,toml,yaml,xml,postfix,systemd,ssh} Force config format instead of auto-detecting from @@ -314,12 +216,6 @@ options: -t, --template-output TEMPLATE_OUTPUT Path to write the generated config template. If omitted, it is printed to stdout. - --template-engine {jinja2,erb} - Template syntax to generate. Defaults to jinja2. Use - erb for Puppet templates. - --puppet-class PUPPET_CLASS - Puppet class / Hiera namespace to use with - --template-engine erb. Defaults to --role-name. ``` ## Additional supported formats @@ -346,16 +242,16 @@ Two guarantees matter: 1. **Values are data, never code.** Every config *value* is replaced with a `{{ variable }}` placeholder in the template, and the original value is stored - separately in the defaults/Hiera data. When the template is later rendered, + separately in the defaults data. When the template is later rendered, the placeholder prints the value as a literal string; Jinja2 does not recursively render the *contents* of a variable, so a payload sitting inside - a value (for example `motd = {{ salt['cmd.run']('id') }}`) is inert. + a value is inert. 2. **Verbatim text is neutralised.** To preserve formatting, JinjaTurtle copies comments, blank lines, headers and any unrecognised lines from the source into the template. Any template metacharacters in that copied text - (`{{ }}`, `{% %}`, `{# #}` for Jinja2; `<% %>` for ERB) are escaped so they - render as the literal characters the author wrote, rather than executing. + (`{{ }}`, `{% %}`, `{# #}` ) are escaped so they render as the literal + characters the author wrote, rather than executing. ### Consumer responsibilities @@ -368,11 +264,6 @@ which is the normal case: default. If you build your own var structures from this data and pass them through additional templating, mark untrusted values with the `!unsafe` tag so they are never re-evaluated. -- **Salt**: use the generated file as a `file.managed` template - (`template: jinja`). Do **not** place JinjaTurtle output where Salt would - render it a second time as part of SLS/pillar rendering, which is a separate - Jinja pass and would re-evaluate any embedded expressions. -- **Puppet/ERB**: render with the standard `template()`/`epp()` flow. In short: render JinjaTurtle output exactly once. Do not feed it back through another templating pass. diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 6345fae..0eaf506 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -12,12 +12,10 @@ from .core import ( flatten_config, generate_ansible_yaml, generate_jinja2_template, - generate_puppet_hiera_yaml, - generate_erb_template, ) from .multi import process_directory -from .safety import TemplateSafetyError, verify_erb_template_safe +from .safety import TemplateSafetyError def _build_arg_parser() -> argparse.ArgumentParser: @@ -59,20 +57,6 @@ def _build_arg_parser() -> argparse.ArgumentParser: "--template-output", help="Path to write the generated config template. If omitted, template is printed to stdout.", ) - ap.add_argument( - "--template-engine", - choices=[j2.NAME, "erb"], - default=j2.NAME, - help="Template syntax to generate (default: jinja2). Use erb for Puppet templates.", - ) - ap.add_argument( - "--puppet-class", - help=( - "Puppet class/Hiera namespace to use with --template-engine erb. " - "Defaults to --role-name. This lets tools use a file-specific " - "variable prefix while writing Hiera keys under the real Puppet class." - ), - ) return ap @@ -110,21 +94,7 @@ def _run(argv: list[str] | None = None) -> int: print("# defaults/main.yml") print(defaults_yaml, end="") - # Optionally translate folder-mode templates to ERB. Folder mode keeps - # the existing data shape; single-file mode below is the preferred - # Puppet path because it can produce class-parameter Hiera keys. - if args.template_engine == "erb": - from .erb import translate_jinja2_to_erb - - for o in outputs: - o.template = translate_jinja2_to_erb( - o.template, - role_prefix=args.role_name, - puppet_class=args.puppet_class or args.role_name, - ) - verify_erb_template_safe(o.template) - - template_ext = "erb" if args.template_engine == "erb" else j2.TEMPLATE_EXTENSION + template_ext = j2.TEMPLATE_EXTENSION # Write templates if args.template_output: @@ -161,36 +131,17 @@ def _run(argv: list[str] | None = None) -> int: # Flatten config (excluding loop paths if loops are detected) flat_items = flatten_config(fmt, parsed, loop_candidates) - if args.template_engine == "erb": - ansible_yaml = generate_puppet_hiera_yaml( - args.role_name, - flat_items, - loop_candidates, - puppet_class=args.puppet_class or args.role_name, - ) - template_str = generate_erb_template( - fmt, - parsed, - args.role_name, - original_text=config_text, - loop_candidates=loop_candidates, - flat_items=flat_items, - puppet_class=args.puppet_class or args.role_name, - ) - else: - # Generate defaults YAML (with loop collections if detected) - ansible_yaml = generate_ansible_yaml( - args.role_name, flat_items, loop_candidates - ) + # Generate defaults YAML (with loop collections if detected) + ansible_yaml = generate_ansible_yaml(args.role_name, flat_items, loop_candidates) - # Generate template (with loops if detected) - template_str = generate_jinja2_template( - fmt, - parsed, - args.role_name, - original_text=config_text, - loop_candidates=loop_candidates, - ) + # Generate template (with loops if detected) + template_str = generate_jinja2_template( + fmt, + parsed, + args.role_name, + original_text=config_text, + loop_candidates=loop_candidates, + ) if args.defaults_output: Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") @@ -201,11 +152,7 @@ def _run(argv: list[str] | None = None) -> int: if args.template_output: Path(args.template_output).write_text(template_str, encoding="utf-8") else: - print( - "# config.erb" - if args.template_engine == "erb" - else f"# config.{j2.TEMPLATE_EXTENSION}" - ) + print(f"# config.{j2.TEMPLATE_EXTENSION}") print(template_str, end="") return 0 diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 4d35260..8e3d10e 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -8,9 +8,7 @@ import re import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate -from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb from .safety import ( - verify_erb_template_safe, verify_jinja2_template_safe, ) from .handlers import ( @@ -411,74 +409,6 @@ def _template_variable_names( return names -def generate_puppet_hiera_yaml( - role_prefix: str, - flat_items: list[tuple[tuple[str, ...], Any]], - loop_candidates: list[LoopCandidate] | None = None, - *, - puppet_class: str | None = None, -) -> str: - """Create Puppet Hiera data suitable for Automatic Parameter Lookup. - - ``role_prefix`` remains the source variable prefix used by JinjaTurtle while - ``puppet_class`` is the Puppet class/Hiera namespace. In the normal case - they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``. - """ - - klass = puppet_class_name(puppet_class or role_prefix) - data: dict[str, Any] = {} - - for path, value in flat_items: - generated = make_var_name(role_prefix, path) - local = puppet_local_var_name(role_prefix, generated, puppet_class=klass) - data[f"{klass}::{local}"] = value - - if loop_candidates: - for candidate in loop_candidates: - generated = make_var_name(role_prefix, candidate.path) - local = puppet_local_var_name(role_prefix, generated, puppet_class=klass) - data[f"{klass}::{local}"] = candidate.items - - return dump_yaml(data, sort_keys=True) - - -def generate_erb_template( - fmt: str, - parsed: Any, - role_prefix: str, - *, - original_text: str | None = None, - loop_candidates: list[LoopCandidate] | None = None, - flat_items: list[tuple[tuple[str, ...], Any]] | None = None, - puppet_class: str | None = None, -) -> str: - """Generate a Puppet ERB template from JinjaTurtle's renderer-neutral data. - - The first implementation intentionally translates the Jinja2 subset emitted - by JinjaTurtle's existing format handlers. This keeps parsing, formatting - preservation, and loop detection identical between Jinja2 and ERB output - while still producing Puppet-native ``@parameter`` references. - """ - - jinja_template = generate_jinja2_template( - fmt, - parsed, - role_prefix, - original_text=original_text, - loop_candidates=loop_candidates, - ) - names = _template_variable_names(role_prefix, flat_items or [], loop_candidates) - erb_template = translate_jinja2_to_erb( - jinja_template, - role_prefix=role_prefix, - puppet_class=puppet_class or role_prefix, - variable_names=names, - ) - # Defence in depth: no live Jinja2 delimiter may survive translation. - verify_erb_template_safe(erb_template) - return erb_template - - def _stringify_timestamps(obj: Any) -> Any: """ Recursively walk a parsed config and turn any datetime/date/time objects diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py deleted file mode 100644 index 4da6a6e..0000000 --- a/src/jinjaturtle/erb.py +++ /dev/null @@ -1,243 +0,0 @@ -from __future__ import annotations - -import re - -from .escape import escape_erb_literal - - -def _safe_name(raw: str, *, fallback: str = "var") -> str: - text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower() - text = re.sub(r"_+", "_", text) - if not text: - text = fallback - if not re.match(r"^[a-z_]", text): - text = f"{fallback}_{text}" - return text - - -def puppet_class_name(raw: str) -> str: - """Return a conservative Puppet class/Hiera namespace name.""" - - text = _safe_name(raw, fallback="jinjaturtle") - if not re.match(r"^[a-z]", text): - text = f"jinjaturtle_{text}" - return text - - -def _role_prefix_name(raw: str) -> str: - return _safe_name(raw, fallback="jinjaturtle") - - -def puppet_local_var_name( - role_prefix: str, - jinja_var_name: str, - *, - puppet_class: str | None = None, -) -> str: - """Map a generated JinjaTurtle variable to a Puppet class parameter. - - For the common case where ``--role-name php`` also means class ``php``, a - generated Jinja variable such as ``php_memory_limit`` becomes Puppet local - parameter ``memory_limit`` and Hiera key ``php::memory_limit``. - - When ``puppet_class`` differs from ``role_prefix`` we keep the full - generated variable name as the local parameter and only use ``puppet_class`` - as the Hiera namespace. - """ - - var_name = _safe_name(jinja_var_name, fallback="value") - prefix = _role_prefix_name(role_prefix) - klass = puppet_class_name(puppet_class or role_prefix) - if klass == prefix and var_name.startswith(prefix + "_"): - stripped = var_name[len(prefix) + 1 :] - return stripped or var_name - return var_name - - -class ErbTranslator: - """Translate the Jinja2 subset emitted by JinjaTurtle into Puppet ERB.""" - - _TOKEN_RE = re.compile(r"({{.*?}}|{%.*?%})", re.S) - - def __init__( - self, - *, - role_prefix: str, - puppet_class: str | None = None, - variable_names: set[str] | None = None, - ) -> None: - self.role_prefix = role_prefix - self.puppet_class = puppet_class or role_prefix - self.variable_names = set(variable_names or set()) - self.loop_stack: list[tuple[str, str, str]] = [] - self.needs_json = False - - # Matches a JinjaTurtle ``{% raw %} ... {% endraw %}`` block (non-greedy). - # JinjaTurtle emits raw blocks only to carry verbatim, security-escaped - # source text (comments and unrecognised lines), so the *contents* must be - # treated as literal output, never translated as Jinja tokens. - _RAW_BLOCK_RE = re.compile( - r"{%[-+]?\s*raw\s*[-+]?%}(.*?){%[-+]?\s*endraw\s*[-+]?%}", re.S - ) - - def translate(self, template_text: str) -> str: - # Split out raw blocks first. Their inner text is literal and must be - # carried through as literal ERB (with ERB delimiters re-escaped), rather - # than tokenised -- otherwise an escaped Jinja payload inside a comment - # would be "re-animated" into live ERB during translation. - segments = self._RAW_BLOCK_RE.split(template_text) - out: list[str] = [] - # re.split with one capture group yields: [text, raw_inner, text, ...]. - for idx, segment in enumerate(segments): - if idx % 2 == 1: - # Captured raw-block contents: emit as literal ERB text. - out.append(escape_erb_literal(segment)) - else: - out.append(self._translate_tokens(segment)) - - rendered = "".join(out) - if self.needs_json and "require 'json'" not in rendered: - rendered = "<% require 'json' -%>\n" + rendered - return rendered - - def _translate_tokens(self, template_text: str) -> str: - parts = self._TOKEN_RE.split(template_text) - out: list[str] = [] - for token in parts: - if not token: - continue - if token.startswith("{{") and token.endswith("}}"): - expr = token[2:-2].strip() - out.append(f"<%= {self.expr_to_ruby(expr)} %>") - continue - if token.startswith("{%") and token.endswith("%}"): - stmt = token[2:-2].strip() - out.append(self.statement_to_erb(stmt)) - continue - out.append(token) - return "".join(out) - - def local_var(self, name: str) -> str: - return puppet_local_var_name( - self.role_prefix, name, puppet_class=self.puppet_class - ) - - def ruby_value(self, expr: str) -> str: - expr = expr.strip() - if expr in {"true", "True"}: - return "true" - if expr in {"false", "False"}: - return "false" - if expr in {"none", "None", "null"}: - return "nil" - if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", expr): - if any(expr == loop_var for loop_var, _idx, _coll in self.loop_stack): - return expr - return f"@{self.local_var(expr)}" - m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$", expr) - if m: - base, key = m.groups() - if any(base == loop_var for loop_var, _idx, _coll in self.loop_stack): - return f"{base}[{key!r}]" - return f"@{self.local_var(base)}[{key!r}]" - return expr - - def expr_to_ruby(self, expr: str) -> str: - expr = expr.strip() - - # JinjaTurtle emits these YAML-preserving ternaries for booleans/nulls. - m = re.match( - r"^(['\"])(true|false)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+else\s+(['\"])(true|false)\4$", - expr, - ) - if m: - truthy = m.group(2) - cond = self.ruby_value(m.group(3)) - falsy = m.group(5) - return f"{cond} ? {truthy!r} : {falsy!r}" - - m = re.match( - r"^(['\"])(null)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none\s+else\s+([A-Za-z_][A-Za-z0-9_\.]*)$", - expr, - ) - if m: - value = self.ruby_value(m.group(3)) - fallback = self.ruby_value(m.group(4)) - return f"{value}.nil? ? 'null' : {fallback}" - - if "|" in expr: - base, *filters = [part.strip() for part in expr.split("|")] - ruby = self.ruby_value(base) - for filt in filters: - if filt.startswith("lower"): - ruby = f"{ruby}.to_s.downcase" - elif filt.startswith("to_json") or filt.startswith("tojson"): - self.needs_json = True - if "indent" in filt: - ruby = f"JSON.pretty_generate({ruby})" - else: - ruby = f"JSON.generate({ruby})" - return ruby - - return self.ruby_value(expr) - - def statement_to_erb(self, stmt: str) -> str: - if stmt.endswith(("-", "+")): - stmt = stmt[:-1].rstrip() - - if stmt.startswith("for "): - m = re.match( - r"^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)$", - stmt, - ) - if m: - loop_var, collection = m.groups() - idx_var = f"__jt_idx_{len(self.loop_stack)}" - collection_ruby = self.ruby_value(collection) - self.loop_stack.append((loop_var, idx_var, collection_ruby)) - return f"<% {collection_ruby}.each_with_index do |{loop_var}, {idx_var}| -%>" - - if stmt == "endfor": - if self.loop_stack: - self.loop_stack.pop() - return "<% end %>" - - if stmt.startswith("if "): - cond = stmt[3:].strip() - if cond == "not loop.last" and self.loop_stack: - _loop_var, idx_var, collection_ruby = self.loop_stack[-1] - return f"<% if {idx_var} < ({collection_ruby}.length - 1) -%>" - m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+defined$", cond) - if m: - return f"<% unless {self.ruby_value(m.group(1))}.nil? -%>" - m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none$", cond) - if m: - return f"<% if {self.ruby_value(m.group(1))}.nil? -%>" - return f"<% if {self.expr_to_ruby(cond)} -%>" - - if stmt == "else": - return "<% else -%>" - - if stmt.startswith("elif "): - return f"<% elsif {self.expr_to_ruby(stmt[5:].strip())} -%>" - - if stmt == "endif": - return "<% end -%>" - - # Preserve unknown Jinja statements visibly as an ERB comment so the - # generated template does not contain invalid Jinja syntax. - return f"<%# Unsupported JinjaTurtle statement: {stmt} %>" - - -def translate_jinja2_to_erb( - template_text: str, - *, - role_prefix: str, - puppet_class: str | None = None, - variable_names: set[str] | None = None, -) -> str: - return ErbTranslator( - role_prefix=role_prefix, - puppet_class=puppet_class, - variable_names=variable_names, - ).translate(template_text) diff --git a/src/jinjaturtle/escape.py b/src/jinjaturtle/escape.py index 129c08a..e55f416 100644 --- a/src/jinjaturtle/escape.py +++ b/src/jinjaturtle/escape.py @@ -9,7 +9,7 @@ always replaced with ``{{ var }}`` placeholders and parked in the defaults data, so a payload inside a value is inert. Verbatim text is different: if the source contains ``{{ ... }}``, ``{% ... %}`` or ``{# ... #}`` (Jinja2), or ``<%= %>`` / ``<% %>`` (ERB), that text becomes *live template code* in the output and is -executed when Salt/Ansible/Puppet later renders the template. +executed when Ansible later renders the template. Because JinjaTurtle is frequently fed harvested, attacker-influenceable config (hostnames, banners, GECOS-derived comments, "Managed by" notes), this is a @@ -30,14 +30,6 @@ Design notes: Jinja construct. The only way to break out of a raw block is a literal ``{% endraw %}`` in the source, so we defang the token ``endraw`` (in any internal spacing) before wrapping. - * The ERB translator (``erb.py``) is raw-aware: it copies the *contents* of a - JinjaTurtle raw block through as literal ERB text and re-escapes any ERB - delimiters found there. That keeps a Jinja-escaped comment inert after the - Jinja2 -> ERB translation step, instead of the payload being "re-animated" - as ERB. - * ``escape_erb_literal`` exists for completeness / direct ERB emission: it - rewrites each ERB delimiter into an ERB expression that prints the delimiter - characters literally. """ import re @@ -70,11 +62,6 @@ def contains_jinja_markup(text: str) -> bool: return any(m in text for m in _JINJA_MARKERS) -def contains_erb_markup(text: str) -> bool: - """Return True if *text* contains any ERB delimiter.""" - return any(m in text for m in (*_ERB_OPEN_MARKERS, *_ERB_CLOSE_MARKERS)) - - def _defang_endraw(text: str) -> str: """Rewrite any literal ``{% endraw %}`` so it cannot close our raw wrapper. @@ -109,45 +96,3 @@ def escape_jinja_literal(text: str) -> str: if not text or not contains_jinja_markup(text): return text return "{% raw %}" + _defang_endraw(text) + "{% endraw %}" - - -def escape_erb_literal(text: str) -> str: - """Make *text* render as literal characters under a later ERB render. - - ERB has no ``raw`` block, so each opening/closing delimiter is rewritten as - an ERB expression that prints the delimiter literally. Text with no ERB - metacharacters is returned unchanged. - """ - if not text or not contains_erb_markup(text): - return text - - result: list[str] = [] - i = 0 - n = len(text) - while i < n: - matched = None - for marker in (*_ERB_CLOSE_MARKERS, *_ERB_OPEN_MARKERS): - if text.startswith(marker, i): - matched = marker - break - if matched is not None: - escaped = matched.replace("\\", "\\\\").replace('"', '\\"') - result.append('<%= "' + escaped + '" %>') - i += len(matched) - else: - result.append(text[i]) - i += 1 - return "".join(result) - - -def escape_literal(text: str, *, engine: str = "jinja2") -> str: - """Escape verbatim *text* for the target template *engine*. - - ``engine`` is ``"jinja2"`` (default) or ``"erb"``. Unknown engines fall back - to Jinja2 escaping. In JinjaTurtle's pipeline ERB output is produced by - translating Jinja2 output, and the translator is raw-aware, so handlers can - always Jinja-escape and rely on the translator to keep the literal inert. - """ - if engine == "erb": - return escape_erb_literal(text) - return escape_jinja_literal(text) diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py index 07d3b8d..713d49a 100644 --- a/src/jinjaturtle/safety.py +++ b/src/jinjaturtle/safety.py @@ -22,8 +22,8 @@ single global property: The check is positive/allowlist-based, which is the safe direction: unknown constructs are rejected, not ignored. It runs at the single choke points in -``core.py`` (``generate_jinja2_template`` / ``generate_erb_template``), so it -covers every current handler and every future one automatically. +``core.py`` (``generate_jinja2_template``) so it covers every current handler and +every future one automatically. Why this is robust against the escaper being wrong --------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index e4ac519..c0d5470 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -132,57 +132,3 @@ def test_cli_folder_single_output_file_when_one_format(tmp_path): assert defaults_path.is_file() assert template_path.is_file() assert "to_json" in template_path.read_text(encoding="utf-8") - - -def test_cli_erb_outputs_puppet_hiera_and_erb_template(tmp_path): - cfg = tmp_path / "app.ini" - cfg.write_text("[main]\nport = 8080\n", encoding="utf-8") - data_out = tmp_path / "common.yaml" - template_out = tmp_path / "app.ini.erb" - - exit_code = cli._main( - [ - str(cfg), - "-r", - "php", - "--template-engine", - "erb", - "--defaults-output", - str(data_out), - "--template-output", - str(template_out), - ] - ) - - assert exit_code == 0 - assert "php::main_port: '8080'" in data_out.read_text(encoding="utf-8") - assert "port = <%= @main_port %>" in template_out.read_text(encoding="utf-8") - - -def test_cli_erb_can_use_separate_puppet_class_namespace(tmp_path): - cfg = tmp_path / "app.ini" - cfg.write_text("[main]\nport = 8080\n", encoding="utf-8") - data_out = tmp_path / "node.yaml" - template_out = tmp_path / "app.ini.erb" - - exit_code = cli._main( - [ - str(cfg), - "-r", - "php_etc_app_ini", - "--template-engine", - "erb", - "--puppet-class", - "php", - "--defaults-output", - str(data_out), - "--template-output", - str(template_out), - ] - ) - - assert exit_code == 0 - data = data_out.read_text(encoding="utf-8") - template = template_out.read_text(encoding="utf-8") - assert "php::php_etc_app_ini_main_port: '8080'" in data - assert "port = <%= @php_etc_app_ini_main_port %>" in template diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py index a1de483..9ff0230 100644 --- a/tests/test_injection_security.py +++ b/tests/test_injection_security.py @@ -3,8 +3,8 @@ JinjaTurtle copies parts of the source config (comments, unrecognised lines, structural keys) verbatim into the generated template. If that text contains Jinja2/ERB delimiters it must be neutralised, otherwise attacker-influenced -config content becomes live template code that executes when Salt/Ansible/Puppet -later renders the template. +config content becomes live template code that executes when Ansible later +renders the template. These tests render the *generated* template the way a downstream tool would and assert that an injected payload never executes. A tripwire object is exposed @@ -14,7 +14,6 @@ the tripwire sentinel, an injected expression executed and the test fails. from __future__ import annotations -import re import subprocess import sys from pathlib import Path @@ -25,8 +24,6 @@ import yaml as pyyaml from jinjaturtle.escape import ( escape_jinja_literal, - escape_erb_literal, - escape_literal, ) TRIP = "__TRIPWIRE_FIRED__" @@ -233,43 +230,3 @@ def test_endraw_whitespace_control_cannot_break_out(endraw): rendered = env.from_string(escaped).render(boom=_Boom()) assert TRIP not in rendered assert rendered == payload - - -@pytest.mark.parametrize( - "payload", - [ - "<%= system('id') %>", - "<% require 'open3' %>", - "text <%= 1+1 %> more", - "-%> orphan <%", - ], -) -def test_escape_erb_literal_removes_executable_tags(payload): - escaped = escape_erb_literal(payload) - # No raw executable ERB tag should survive that contains the original code. - live = re.findall(r"<%[-=#]?(.*?)-?%>", escaped, re.S) - for chunk in live: - assert "system" not in chunk - assert "require" not in chunk - # Numeric/expression payloads must be reduced to literal-string prints. - - -def test_escape_literal_dispatches_by_engine(): - """The public ``escape_literal`` wrapper routes to the right engine.""" - payload = "{{ 7*7 }}" - # Default engine is Jinja2. - assert escape_literal(payload) == escape_jinja_literal(payload) - assert escape_literal(payload, engine="jinja2") == escape_jinja_literal(payload) - # An unknown engine falls back to the safer Jinja2 escaping. - assert escape_literal(payload, engine="nonsense") == escape_jinja_literal(payload) - # ERB routing. - erb_payload = "<%= system('id') %>" - assert escape_literal(erb_payload, engine="erb") == escape_erb_literal(erb_payload) - - -def test_escape_literal_jinja_output_is_inert(): - """End-to-end: text routed through escape_literal does not execute.""" - escaped = escape_literal("{{ boom.run('x') }}") - env = jinja2.Environment(undefined=jinja2.ChainableUndefined) - rendered = env.from_string(escaped).render(boom=_Boom()) - assert TRIP not in rendered diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index a217e8a..548f5a6 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -10,7 +10,6 @@ from jinjaturtle.core import ( analyze_loops, flatten_config, generate_ansible_yaml, - generate_erb_template, generate_jinja2_template, ) from jinjaturtle.handlers.yaml import YamlHandler @@ -205,16 +204,6 @@ def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path): rendered = Template(template).render(**defaults) assert rendered_expected in rendered - erb_template = generate_erb_template( - fmt, - parsed, - "role", - original_text=text, - loop_candidates=loop_candidates, - flat_items=flat_items, - ) - assert erb_expected in erb_template - def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path): from jinja2 import Environment, Template From 4a1f2ac15e2ba51c018e8fed8f9a079c3e5d0539 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 28 Jun 2026 20:33:12 +1000 Subject: [PATCH 31/44] More JSON defenses --- src/jinjaturtle/core.py | 8 +++ src/jinjaturtle/handlers/json.py | 34 +++++++++++-- src/jinjaturtle/multi.py | 4 +- src/jinjaturtle/safety.py | 84 +++++++++++++++++++++++++++++++- tests/test_injection_security.py | 80 ++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 7 deletions(-) diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 8e3d10e..3e9cefd 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -10,6 +10,7 @@ import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate from .safety import ( verify_jinja2_template_safe, + verify_no_live_jinja_in_json_keys, ) from .handlers import ( BaseHandler, @@ -394,6 +395,13 @@ def generate_jinja2_template( # verbatim source text, the un-escaped payload shows up here as a live tag # and generation aborts instead of emitting an injectable template. verify_jinja2_template_safe(template) + + # Format-specific backstop: JinjaTurtle never emits Jinja inside a JSON object + # key, so a live construct in key position means source key text leaked into + # the template unescaped. This is independent of per-handler escaping. + if fmt == "json": + verify_no_live_jinja_in_json_keys(template) + return template diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index 064535a..0268666 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -7,6 +7,7 @@ from typing import Any from . import DictLikeHandler from .. import j2 +from ..escape import escape_jinja_literal from ..loop_analyzer import LoopCandidate @@ -109,10 +110,18 @@ class JsonHandler(DictLikeHandler): chunks: list[str] = [] pos = 0 for path, start, end in spans: - chunks.append(text[pos:start]) + # Text between scalar values (object keys, structural punctuation, + # whitespace, and any comment-like trailing text) is copied verbatim + # from the source file. Like every other text-emitting handler, this + # verbatim text must be neutralised: if it contains Jinja2 markup it + # would otherwise become live template code at apply time. The value + # itself is replaced with a safe placeholder below. ``escape_jinja_literal`` + # is a no-op on text without Jinja markers, so benign JSON is unchanged + # byte-for-byte and a later render reproduces the original characters. + chunks.append(escape_jinja_literal(text[pos:start])) chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path))) pos = end - chunks.append(text[pos:]) + chunks.append(escape_jinja_literal(text[pos:])) return "".join(chunks) def _collect_json_scalar_spans( @@ -213,7 +222,12 @@ class JsonHandler(DictLikeHandler): def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any: if isinstance(obj, dict): - return {k: _walk(v, path + (str(k),)) for k, v in obj.items()} + # Keys are emitted verbatim into the template, so neutralise any + # Jinja markup in them (see _generate_json_template_from_text). + return { + escape_jinja_literal(str(k)): _walk(v, path + (str(k),)) + for k, v in obj.items() + } if isinstance(obj, list): return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)] # scalar - use marker that will be replaced with to_json @@ -261,7 +275,12 @@ class JsonHandler(DictLikeHandler): return f"__LOOP_DICT__{collection_var}__{item_var}__" if isinstance(obj, dict): - return {k: _walk(v, current_path + (str(k),)) for k, v in obj.items()} + # Keys are emitted verbatim into the template, so neutralise any + # Jinja markup in them (see _generate_json_template_from_text). + return { + escape_jinja_literal(str(k)): _walk(v, current_path + (str(k),)) + for k, v in obj.items() + } if isinstance(obj, list): # Check if this list is a loop candidate if current_path in loop_paths: @@ -364,8 +383,13 @@ class JsonHandler(DictLikeHandler): ] # first line has no indent; we prepend `inner` when emitting for i, key in enumerate(keys): comma = "," if i < len(keys) - 1 else "" + # The literal key text is emitted verbatim into the template; escape any + # Jinja markup in it. The value side ({item_var}.{key}) is constrained by + # the output safety gate's dotted-name allowlist, which fails closed on + # anything that is not a plain identifier path. dict_lines.append( - f'{field}"{key}": ' f"{j2.to_json(f'{item_var}.{key}')}{comma}" + f'{field}"{escape_jinja_literal(str(key))}": ' + f"{j2.to_json(f'{item_var}.{key}')}{comma}" ) # Comma between *items* goes after the closing brace. dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}") diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index 195434f..c945481 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -31,7 +31,7 @@ import xml.etree.ElementTree as ET # nosec from . import j2 from .core import dump_yaml, flatten_config, make_var_name, parse_config from .handlers.xml import XmlHandler -from .safety import verify_jinja2_template_safe +from .safety import verify_jinja2_template_safe, verify_no_live_jinja_in_json_keys from .escape import escape_jinja_literal @@ -784,5 +784,7 @@ def process_directory( # Any un-neutralised source text that became a live tag aborts generation. for out in outputs: verify_jinja2_template_safe(out.template) + if out.fmt == "json": + verify_no_live_jinja_in_json_keys(out.template) return defaults_yaml, outputs diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py index 713d49a..5ed2fe1 100644 --- a/src/jinjaturtle/safety.py +++ b/src/jinjaturtle/safety.py @@ -43,6 +43,7 @@ __all__ = [ "TemplateSafetyError", "verify_jinja2_template_safe", "verify_erb_template_safe", + "verify_no_live_jinja_in_json_keys", ] @@ -208,7 +209,88 @@ def verify_jinja2_template_safe(template_text: str) -> None: # --------------------------------------------------------------------------- # -# ERB gate. +# JSON-key gate (defence in depth, format-specific). +# +# JinjaTurtle never emits a live Jinja construct inside a JSON *object key*: keys +# are copied verbatim from the source and (after escape.py) are wrapped in +# ``{% raw %}`` if they contain markup, so a key never lexes as a live tag. A +# live construct in key position can therefore only mean source key text leaked +# into the template unescaped (the json-handler blind spot). This gate is +# independent of the escaper: it inspects the finished template, replaces every +# *live* Jinja construct with an inert sentinel (raw-wrapped literal text stays +# literal), and rejects any sentinel that lands in a JSON key slot. +# --------------------------------------------------------------------------- # + +# Sentinel byte that cannot occur in normal generated template text. +_LIVE_SENTINEL = "\x00" + +# A JSON key is a double-quoted string immediately followed (after optional +# whitespace) by a colon. We only need to detect a sentinel *inside* such a +# string, so match a quoted run that ends in `":` and look for the sentinel. +_JSON_KEY_RE = re.compile(r'"((?:[^"\\]|\\.)*)"\s*:', re.S) + +_KEY_JINJA_DELIMS = ("{{", "}}", "{%", "%}", "{#", "#}") + + +def _contains_jinja_delim(text: str) -> bool: + """True if *text* contains any Jinja delimiter (live or escaped-literal).""" + return any(d in text for d in _KEY_JINJA_DELIMS) + + +def verify_no_live_jinja_in_json_keys(template_text: str) -> None: + """Reject a JSON template that carries Jinja markup in an object key. + + JinjaTurtle never templates a JSON *object key*: keys come straight from the + source and a key is an identifier/string, never a value placeholder. Any + Jinja in key position therefore means attacker-influenced source key text + reached the template. This gate fails closed on it, independent of whether a + handler left the markup *live* (a raw ``{{ ... }}`` in the key) or *escaped* + it into a ``{% raw %}`` wrapper -- both indicate a key that should never have + contained templating, so generation aborts rather than emitting it. + + Detection is done on the lexer token stream: we reconstruct the document with + every live tag collapsed to a sentinel and every ``{% raw %}``-wrapped region + also marked, then reject a sentinel that lands inside a JSON key string. + """ + import jinja2 + + env = jinja2.Environment(autoescape=True) + try: + tokens = list(env.lex(template_text)) + except jinja2.TemplateSyntaxError as exc: + raise TemplateSafetyError( + f"generated JSON template does not lex as the JinjaTurtle subset: {exc}" + ) from exc + + out: list[str] = [] + in_tag = False + for _lineno, tok_type, value in tokens: + if tok_type in ("variable_begin", "block_begin", "comment_begin"): + # A live construct: collapse to a sentinel so it is detectable if it + # sits in key position. (raw_begin/raw_end are *not* live; the data + # inside a raw block is preserved verbatim below, so an escaped key + # still shows its literal Jinja delimiters to the key check.) + in_tag = True + out.append(_LIVE_SENTINEL) + elif tok_type in ("variable_end", "block_end", "comment_end"): + in_tag = False + elif not in_tag: + # data, whitespace, raw_begin/raw_end markers, and inert raw content. + out.append(value if isinstance(value, str) else str(value)) + + reconstructed = "".join(out) + + for match in _JSON_KEY_RE.finditer(reconstructed): + key_text = match.group(1) + if _LIVE_SENTINEL in key_text or _contains_jinja_delim(key_text): + raise TemplateSafetyError( + "refusing to emit JSON template: Jinja markup appears inside a " + "JSON object key. JinjaTurtle never templates keys, so this " + "indicates attacker-influenced source key text (possible " + "template injection)." + ) + + # # JinjaTurtle's ERB output is produced by translating the (already-verified) # Jinja2 subset, so the Jinja2 gate is the primary guarantee. As an independent diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py index 9ff0230..1110ef1 100644 --- a/tests/test_injection_security.py +++ b/tests/test_injection_security.py @@ -152,6 +152,86 @@ def test_generated_template_is_renderable(tmp_path, fmt, name, body): _render_jinja(template_text, defaults) +# --- JSON object-key injection ------------------------------------------------ +# +# The JSON handler copies the text *between* scalar values (object keys, +# punctuation) verbatim. A key is never a value placeholder, so Jinja markup in a +# key can only come from attacker-influenced source text. The output gate +# (verify_no_live_jinja_in_json_keys) must fail closed on it -- including the +# "benign-looking name" form (e.g. ``{{ ansible_hostname }}``) that the generic +# allowlist would otherwise accept as an ordinary variable reference, and which +# could leak an in-scope variable's value into the rendered config at apply time. + +JSON_KEY_INJECTION_BODIES = [ + # benign-looking variable reference (the residual bypass: passes the generic + # allowlist but must still be rejected in *key* position) + '{ "{{ ansible_hostname }}": "v" }', + # dotted reference (e.g. dumping another host's vars) + '{ "{{ hostvars.localhost }}": 1 }', + # self-referencing a sibling-derived variable name + '{ "{{ role_port }}": "x", "port": 8080 }', + # classic gadget (already rejected historically; kept as a guard) + '{ "{{ cycler.__init__.__globals__ }}": 1 }', + # statement injection in a key + '{ "{% for x in y %}k{% endfor %}": 1 }', + # nested object key + '{ "ok": { "{{ ansible_hostname }}": 2 } }', +] + + +@pytest.mark.parametrize("body", JSON_KEY_INJECTION_BODIES) +def test_json_key_injection_fails_closed_cli(tmp_path, body): + src = tmp_path / "evil.json" + src.write_text(body, encoding="utf-8") + out = tmp_path / "out.j2" + res = subprocess.run( + [ + sys.executable, + "-m", + "jinjaturtle.cli", + str(src), + "-f", + "json", + "--role-name", + "role", + "-t", + str(out), + ], + capture_output=True, + text=True, + ) + assert res.returncode == 2, f"expected fail-closed, got rc={res.returncode}" + assert "refusing to generate unsafe template" in res.stderr + assert not out.exists(), "no template may be written when the gate refuses" + + +def test_json_benign_keys_still_generate(tmp_path): + src = tmp_path / "ok.json" + src.write_text('{ "host": "localhost", "port": 8080 }', encoding="utf-8") + out = tmp_path / "out.j2" + res = subprocess.run( + [ + sys.executable, + "-m", + "jinjaturtle.cli", + str(src), + "-f", + "json", + "--role-name", + "demo", + "-t", + str(out), + ], + capture_output=True, + text=True, + ) + assert res.returncode == 0, res.stderr + template_text = out.read_text() + # Keys stay literal; values become placeholders. + assert '"host":' in template_text + assert "demo_host" in template_text + + # --- Unit-level guarantees for the escaper itself --------------------------- SSTI_PAYLOADS = [ From 70be0f7e33ff04362036dc03d5403b3984201f5b Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 08:51:53 +1000 Subject: [PATCH 32/44] Hardening: use unsafe for ansible vars, ensure API use of JinjaTurtle uses safe XML parsing, avoid symlinks --- src/jinjaturtle/cli.py | 18 +++-- src/jinjaturtle/core.py | 45 ++++++++++- src/jinjaturtle/handlers/xml.py | 13 ++-- src/jinjaturtle/multi.py | 30 +++++++- src/jinjaturtle/output_safety.py | 124 +++++++++++++++++++++++++++++++ tests/test_injection_security.py | 17 ++++- tests/test_security_hardening.py | 120 ++++++++++++++++++++++++++++++ 7 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 src/jinjaturtle/output_safety.py create mode 100644 tests/test_security_hardening.py diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 0eaf506..bdabc18 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -16,6 +16,7 @@ from .core import ( from .multi import process_directory from .safety import TemplateSafetyError +from .output_safety import OutputPathError, ensure_safe_directory, write_text_safely def _build_arg_parser() -> argparse.ArgumentParser: @@ -72,6 +73,9 @@ def _main(argv: list[str] | None = None) -> int: f"jinjaturtle: refusing to generate unsafe template: {exc}", file=sys.stderr ) return 2 + except OutputPathError as exc: + print(f"jinjaturtle: refusing unsafe output path: {exc}", file=sys.stderr) + return 2 def _run(argv: list[str] | None = None) -> int: @@ -89,7 +93,7 @@ def _run(argv: list[str] | None = None) -> int: # Write defaults if args.defaults_output: - Path(args.defaults_output).write_text(defaults_yaml, encoding="utf-8") + write_text_safely(Path(args.defaults_output), defaults_yaml) else: print("# defaults/main.yml") print(defaults_yaml, end="") @@ -100,12 +104,12 @@ def _run(argv: list[str] | None = None) -> int: if args.template_output: out_path = Path(args.template_output) if len(outputs) == 1 and not out_path.is_dir(): - out_path.write_text(outputs[0].template, encoding="utf-8") + write_text_safely(out_path, outputs[0].template) else: - out_path.mkdir(parents=True, exist_ok=True) + ensure_safe_directory(out_path) for o in outputs: - (out_path / f"config.{o.fmt}.{template_ext}").write_text( - o.template, encoding="utf-8" + write_text_safely( + out_path / f"config.{o.fmt}.{template_ext}", o.template ) else: for o in outputs: @@ -144,13 +148,13 @@ def _run(argv: list[str] | None = None) -> int: ) if args.defaults_output: - Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") + write_text_safely(Path(args.defaults_output), ansible_yaml) else: print("# defaults/main.yml") print(ansible_yaml, end="") if args.template_output: - Path(args.template_output).write_text(template_str, encoding="utf-8") + write_text_safely(Path(args.template_output), template_str) else: print(f"# config.{j2.TEMPLATE_EXTENSION}") print(template_str, end="") diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 3e9cefd..72b42de 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -33,6 +33,22 @@ class QuotedString(str): pass +class AnsibleUnsafeString(str): + """Marker type emitted with Ansible's !unsafe YAML tag. + + Ansible recursively templates string values by default. Source-derived + config values that contain Jinja delimiters must therefore be marked + unsafe in defaults/main.yml, otherwise a harvested value such as + ``{{ lookup('pipe', 'id') }}`` becomes executable on the Ansible + controller when the generated role is applied. + """ + + pass + + +_JINJA_STARTS = ("{{", "{%", "{#") + + def _fallback_str_representer(dumper: yaml.SafeDumper, data: Any): """ Fallback for objects the dumper doesn't know about. @@ -52,7 +68,33 @@ def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString): return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"') +def _ansible_unsafe_str_representer(dumper: yaml.SafeDumper, data: AnsibleUnsafeString): + return dumper.represent_scalar("!unsafe", str(data), style="'") + + +def _needs_ansible_unsafe(value: str) -> bool: + return any(marker in value for marker in _JINJA_STARTS) + + +def _mark_ansible_unsafe_values(obj: Any) -> Any: + """Recursively mark mapping/list values containing Jinja as !unsafe. + + Mapping keys are intentionally left alone: they are variable names or YAML + structure, not Ansible-templated values. Values nested in folder-mode item + lists, including source-derived ``id`` values, are protected. + """ + + if isinstance(obj, dict): + return {k: _mark_ansible_unsafe_values(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_mark_ansible_unsafe_values(v) for v in obj] + if isinstance(obj, str) and _needs_ansible_unsafe(obj): + return AnsibleUnsafeString(obj) + return obj + + _TurtleDumper.add_representer(QuotedString, _quoted_str_representer) +_TurtleDumper.add_representer(AnsibleUnsafeString, _ansible_unsafe_str_representer) # Use our fallback for any unknown object types _TurtleDumper.add_representer(None, _fallback_str_representer) @@ -84,8 +126,9 @@ def dump_yaml(data: Any, *, sort_keys: bool = True) -> str: This is used by both the single-file and multi-file code paths. """ + safe_data = _mark_ansible_unsafe_values(data) return yaml.dump( - data, + safe_data, Dumper=_TurtleDumper, sort_keys=sort_keys, default_flow_style=False, diff --git a/src/jinjaturtle/handlers/xml.py b/src/jinjaturtle/handlers/xml.py index 9fde3ec..56e3fd7 100644 --- a/src/jinjaturtle/handlers/xml.py +++ b/src/jinjaturtle/handlers/xml.py @@ -3,7 +3,8 @@ from __future__ import annotations from collections import Counter, defaultdict from pathlib import Path from typing import Any -import xml.etree.ElementTree as ET # nosec +import xml.etree.ElementTree as ET # nosec B405 - safe trees only; parsing uses defusedxml +import defusedxml.ElementTree as DET from .base import BaseHandler from .. import j2 @@ -20,11 +21,11 @@ class XmlHandler(BaseHandler): def parse(self, path: Path) -> ET.Element: text = path.read_text(encoding="utf-8") - parser = ET.XMLParser( - target=ET.TreeBuilder(insert_comments=False) - ) # nosec B314 - parser.feed(text) - root = parser.close() + # Security must live in the handler, not only in the CLI entry point: + # callers may import JinjaTurtle as a library and invoke parse_config() + # directly. defusedxml rejects DTD/entity abuse and also discards + # comments by default, matching the previous TreeBuilder behaviour. + root = DET.fromstring(text) return root def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index c945481..c968d61 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -22,7 +22,9 @@ Notes: from collections import Counter, defaultdict from copy import deepcopy +import os import configparser +import stat from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable @@ -44,8 +46,23 @@ SUPPORTED_SUFFIXES: dict[str, set[str]] = { } +def _lstat(path: Path) -> os.stat_result: + return path.lstat() + + def is_supported_file(path: Path) -> bool: - if not path.is_file(): + """Return True only for real regular files with supported suffixes. + + pathlib.Path.is_file() follows symlinks. Folder mode must not follow + attacker-controlled symlinks when run over an untrusted tree, especially if + an administrator accidentally runs the CLI as root. + """ + + try: + st = _lstat(path) + except FileNotFoundError: + return False + if not stat.S_ISREG(st.st_mode): return False suffix = path.suffix.lower() for exts in SUPPORTED_SUFFIXES.values(): @@ -55,11 +72,16 @@ def is_supported_file(path: Path) -> bool: def iter_supported_files(root: Path, recursive: bool) -> list[Path]: - if not root.exists(): + try: + st = _lstat(root) + except FileNotFoundError: raise FileNotFoundError(str(root)) - if root.is_file(): + + if stat.S_ISLNK(st.st_mode): + raise ValueError(f"refusing to follow symlink: {root}") + if stat.S_ISREG(st.st_mode): return [root] if is_supported_file(root) else [] - if not root.is_dir(): + if not stat.S_ISDIR(st.st_mode): return [] it = root.rglob("*") if recursive else root.glob("*") diff --git a/src/jinjaturtle/output_safety.py b/src/jinjaturtle/output_safety.py new file mode 100644 index 0000000..9833cde --- /dev/null +++ b/src/jinjaturtle/output_safety.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +"""Safer file-output helpers for the JinjaTurtle CLI. + +The CLI is often used by administrators. A plain Path.write_text() follows a +final-path symlink and can therefore be dangerous when a root-run invocation +writes into an attacker-writable tree. These helpers validate path components, +write through a private temporary file in the target directory, and replace the +final path atomically. Existing final-path symlinks are refused rather than +followed. +""" + +import os +from pathlib import Path +import stat +import tempfile + + +class OutputPathError(OSError): + """Raised when a requested output path is unsafe.""" + + +def _absolute(path: Path) -> Path: + return path if path.is_absolute() else Path.cwd() / path + + +def _check_existing_path_not_symlink(path: Path) -> None: + try: + st = path.lstat() + except FileNotFoundError: + return + if stat.S_ISLNK(st.st_mode): + raise OutputPathError(f"refusing to use symlink path: {path}") + + +def _check_existing_output_file(path: Path) -> None: + try: + st = path.lstat() + except FileNotFoundError: + return + if stat.S_ISLNK(st.st_mode): + raise OutputPathError(f"refusing to write through symlink: {path}") + if not stat.S_ISREG(st.st_mode): + raise OutputPathError(f"refusing to replace non-regular file: {path}") + + +def _check_parent_components(parent: Path) -> None: + """Require every existing parent component to be a real directory.""" + + parent = _absolute(parent) + parts = parent.parts + if not parts: + return + + cur = Path(parts[0]) + for part in parts[1:]: + cur = cur / part + try: + st = cur.lstat() + except FileNotFoundError as exc: + raise OutputPathError(f"output parent does not exist: {cur}") from exc + if stat.S_ISLNK(st.st_mode): + raise OutputPathError(f"refusing to use symlink parent: {cur}") + if not stat.S_ISDIR(st.st_mode): + raise OutputPathError(f"output parent is not a directory: {cur}") + + +def ensure_safe_directory(path: Path) -> None: + """Create or validate a directory tree without accepting symlinks.""" + + path = _absolute(path) + parts = path.parts + if not parts: + return + + cur = Path(parts[0]) + for part in parts[1:]: + cur = cur / part + try: + st = cur.lstat() + except FileNotFoundError: + cur.mkdir(mode=0o700) + st = cur.lstat() + if stat.S_ISLNK(st.st_mode): + raise OutputPathError(f"refusing to use symlink directory: {cur}") + if not stat.S_ISDIR(st.st_mode): + raise OutputPathError(f"output path is not a directory: {cur}") + + +def write_text_safely(path: Path, text: str, *, encoding: str = "utf-8") -> None: + """Write text without following a final-path symlink. + + The target's parent must already exist and every parent component must be a + real directory. The write is completed with os.replace(), which atomically + swaps the final directory entry and does not dereference a final symlink. + """ + + path = _absolute(path) + _check_parent_components(path.parent) + _check_existing_output_file(path) + + fd = -1 + tmp_name: str | None = None + try: + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True + ) + with os.fdopen(fd, "w", encoding=encoding) as f: + fd = -1 + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp_name, 0o600) + _check_existing_output_file(path) + os.replace(tmp_name, path) + tmp_name = None + finally: + if fd >= 0: + os.close(fd) + if tmp_name is not None: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py index 1110ef1..4afb7a5 100644 --- a/tests/test_injection_security.py +++ b/tests/test_injection_security.py @@ -29,6 +29,21 @@ from jinjaturtle.escape import ( TRIP = "__TRIPWIRE_FIRED__" +class _UnsafeAwareLoader(pyyaml.SafeLoader): + pass + + +def _construct_unsafe(loader: _UnsafeAwareLoader, node: pyyaml.Node): + return loader.construct_scalar(node) + + +_UnsafeAwareLoader.add_constructor("!unsafe", _construct_unsafe) + + +def _safe_load_defaults(text: str): + return pyyaml.load(text, Loader=_UnsafeAwareLoader) + + class _Boom: """Returns the tripwire sentinel for any access/call an SSTI payload makes.""" @@ -82,7 +97,7 @@ def _run_jinjaturtle(tmp_path: Path, source_name: str, body: str, fmt: str): text=True, ) assert res.returncode == 0, f"generation failed: {res.stderr}" - defaults = pyyaml.safe_load(dfl.read_text()) or {} + defaults = _safe_load_defaults(dfl.read_text()) or {} return tpl.read_text(), defaults diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py new file mode 100644 index 0000000..41a6b05 --- /dev/null +++ b/tests/test_security_hardening.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from pathlib import Path +import os + +import pytest +import yaml +from defusedxml.common import EntitiesForbidden + +from jinjaturtle import cli +from jinjaturtle.core import generate_ansible_yaml, parse_config, flatten_config +from jinjaturtle.multi import is_supported_file, iter_supported_files, process_directory +from jinjaturtle.output_safety import OutputPathError, write_text_safely + + +class UnsafeAwareLoader(yaml.SafeLoader): + pass + + +def _unsafe(loader: UnsafeAwareLoader, node: yaml.Node): + return loader.construct_scalar(node) + + +UnsafeAwareLoader.add_constructor("!unsafe", _unsafe) + + +def test_jinja_values_are_emitted_as_ansible_unsafe(tmp_path: Path): + src = tmp_path / "app.ini" + src.write_text("[main]\ncmd = {{ lookup('pipe','id') }}\n", encoding="utf-8") + + fmt, parsed = parse_config(src) + defaults_yaml = generate_ansible_yaml("role", flatten_config(fmt, parsed)) + + assert "role_main_cmd: !unsafe" in defaults_yaml + assert "{{ lookup(''pipe'',''id'') }}" in defaults_yaml + loaded = yaml.load(defaults_yaml, Loader=UnsafeAwareLoader) + assert loaded["role_main_cmd"] == "{{ lookup('pipe','id') }}" + + +def test_folder_mode_marks_nested_jinja_values_and_ids_unsafe(tmp_path: Path): + src = tmp_path / "src" + src.mkdir() + # Filename ids are source-derived values too. + (src / "{{ bad }}.yaml").write_text( + "message: \"{{ lookup('pipe','id') }}\"\n", encoding="utf-8" + ) + + defaults_yaml, _outputs = process_directory( + src, recursive=False, role_prefix="role" + ) + + assert "id: !unsafe" in defaults_yaml + assert "role_message: !unsafe" in defaults_yaml + loaded = yaml.load(defaults_yaml, Loader=UnsafeAwareLoader) + assert loaded["role_items"][0]["id"] == "{{ bad }}.yaml" + assert loaded["role_items"][0]["role_message"] == "{{ lookup('pipe','id') }}" + + +def test_xml_parser_rejects_entities_when_called_as_library(tmp_path: Path): + src = tmp_path / "bad.xml" + src.write_text( + "]>&xxe;", + encoding="utf-8", + ) + + with pytest.raises(EntitiesForbidden): + parse_config(src, "xml") + + +def test_folder_mode_does_not_follow_symlinked_files(tmp_path: Path): + real = tmp_path / "secret.ini" + real.write_text("[main]\nsecret=yes\n", encoding="utf-8") + root = tmp_path / "root" + root.mkdir() + link = root / "link.ini" + link.symlink_to(real) + + assert not is_supported_file(link) + assert iter_supported_files(root, recursive=False) == [] + assert iter_supported_files(root, recursive=True) == [] + + +@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unavailable") +def test_cli_refuses_to_write_through_final_symlink(tmp_path: Path): + target = tmp_path / "target.txt" + target.write_text("keep\n", encoding="utf-8") + link = tmp_path / "out.yml" + link.symlink_to(target) + + with pytest.raises(OutputPathError): + write_text_safely(link, "replace\n") + + assert target.read_text(encoding="utf-8") == "keep\n" + assert link.is_symlink() + + +def test_cli_refuses_symlinked_output_parent(tmp_path: Path): + real_dir = tmp_path / "real" + real_dir.mkdir() + link_dir = tmp_path / "linkdir" + link_dir.symlink_to(real_dir, target_is_directory=True) + + with pytest.raises(OutputPathError): + write_text_safely(link_dir / "out.yml", "data\n") + + assert not (real_dir / "out.yml").exists() + + +def test_cli_reports_unsafe_output_path_without_overwriting_symlink(tmp_path: Path): + cfg = tmp_path / "app.ini" + cfg.write_text("[main]\nname = ok\n", encoding="utf-8") + target = tmp_path / "target.yml" + target.write_text("keep\n", encoding="utf-8") + link = tmp_path / "defaults.yml" + link.symlink_to(target) + + exit_code = cli._main([str(cfg), "--defaults-output", str(link)]) + + assert exit_code == 2 + assert target.read_text(encoding="utf-8") == "keep\n" From 0e052a073a93ccf45414ef501dc9a49017c3bbf7 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 14:30:46 +1000 Subject: [PATCH 33/44] Harden output --- src/jinjaturtle/output_safety.py | 165 ++++++++++++++++++++++--------- tests/test_security_hardening.py | 16 +++ 2 files changed, 132 insertions(+), 49 deletions(-) diff --git a/src/jinjaturtle/output_safety.py b/src/jinjaturtle/output_safety.py index 9833cde..eac40f9 100644 --- a/src/jinjaturtle/output_safety.py +++ b/src/jinjaturtle/output_safety.py @@ -2,11 +2,12 @@ from __future__ import annotations """Safer file-output helpers for the JinjaTurtle CLI. -The CLI is often used by administrators. A plain Path.write_text() follows a +The CLI is often used by administrators. A plain Path.write_text() follows a final-path symlink and can therefore be dangerous when a root-run invocation -writes into an attacker-writable tree. These helpers validate path components, -write through a private temporary file in the target directory, and replace the -final path atomically. Existing final-path symlinks are refused rather than +writes into an attacker-writable tree. These helpers validate path components, +refuse symlink parents, reject root-run output through untrusted parent +components, write through a temporary file in the target directory, and replace +the final path atomically. Existing final-path symlinks are refused rather than followed. """ @@ -20,17 +21,111 @@ class OutputPathError(OSError): """Raised when a requested output path is unsafe.""" +# Keep a reference to the real euid getter. Tests can monkeypatch +# _effective_uid directly without changing process-wide os.geteuid behaviour. +_OS_GETEUID = getattr(os, "geteuid", None) + + +def _effective_uid() -> int | None: + if _OS_GETEUID is None: + return None + try: + return int(_OS_GETEUID()) + except OSError: + return None + + def _absolute(path: Path) -> Path: - return path if path.is_absolute() else Path.cwd() / path + expanded = path.expanduser() + return expanded if expanded.is_absolute() else Path.cwd() / expanded -def _check_existing_path_not_symlink(path: Path) -> None: +def _chmod_private(path: Path) -> None: + try: + os.chmod(path, 0o700) + except OSError: + # Best-effort; mkdir(mode=0o700) is already used for normal filesystems. + pass + + +def _assert_trusted_root_parent(path: Path, st: os.stat_result) -> None: + """Reject root-run output through attacker-controlled parent directories. + + A root-run JinjaTurtle process may write files that an administrator later + applies as configuration-management input. Parent directories controlled by + an unprivileged user are therefore not acceptable output anchors. Root-owned + sticky shared directories such as /tmp are allowed as a boundary, but any + existing child below them must be root-owned and not writable by group/other. + """ + + if _effective_uid() != 0: + return + if not stat.S_ISDIR(st.st_mode): + raise OutputPathError(f"output parent is not a directory: {path}") + if st.st_uid != 0: + raise OutputPathError( + f"output parent is not owned by root; refusing root-run output: {path}" + ) + writable_by_group_or_other = st.st_mode & (stat.S_IWGRP | stat.S_IWOTH) + sticky = st.st_mode & stat.S_ISVTX + if writable_by_group_or_other and not sticky: + raise OutputPathError( + "output parent is writable by group/other; " + f"refusing root-run output: {path}" + ) + + +def _assert_existing_directory_component(path: Path) -> None: try: st = path.lstat() - except FileNotFoundError: - return + except OSError as exc: + raise OutputPathError(f"unable to inspect output parent: {path}") from exc if stat.S_ISLNK(st.st_mode): - raise OutputPathError(f"refusing to use symlink path: {path}") + raise OutputPathError(f"refusing to use symlink parent: {path}") + if not stat.S_ISDIR(st.st_mode): + raise OutputPathError(f"output parent is not a directory: {path}") + _assert_trusted_root_parent(path, st) + + +def _mkdir_safe_dir_tree(path: Path) -> Path: + """Create/validate a directory tree one component at a time. + + pathlib.mkdir(parents=True) can traverse a symlink inserted after a parent + pre-check. Walking one component at a time keeps every existing component + checked before it is used, and newly-created components are immediately + re-inspected. + """ + + out = _absolute(path) + parts = out.parts + if not parts: + return out + + if out.is_absolute(): + cur = Path(parts[0]) + rest = parts[1:] + _assert_existing_directory_component(cur) + else: + # _absolute() currently always returns an absolute path, but keep this + # branch for clarity if that helper is ever relaxed. + cur = Path.cwd() + rest = parts + _assert_existing_directory_component(cur) + + for part in rest: + cur = cur / part + if os.path.lexists(cur): + _assert_existing_directory_component(cur) + continue + try: + os.mkdir(cur, 0o700) + except FileExistsError: + _assert_existing_directory_component(cur) + continue + _chmod_private(cur) + _assert_existing_directory_component(cur) + + return out def _check_existing_output_file(path: Path) -> None: @@ -45,65 +140,36 @@ def _check_existing_output_file(path: Path) -> None: def _check_parent_components(parent: Path) -> None: - """Require every existing parent component to be a real directory.""" + """Require every existing parent component to be a trusted real directory.""" - parent = _absolute(parent) - parts = parent.parts - if not parts: - return - - cur = Path(parts[0]) - for part in parts[1:]: - cur = cur / part - try: - st = cur.lstat() - except FileNotFoundError as exc: - raise OutputPathError(f"output parent does not exist: {cur}") from exc - if stat.S_ISLNK(st.st_mode): - raise OutputPathError(f"refusing to use symlink parent: {cur}") - if not stat.S_ISDIR(st.st_mode): - raise OutputPathError(f"output parent is not a directory: {cur}") + _mkdir_safe_dir_tree(parent) def ensure_safe_directory(path: Path) -> None: - """Create or validate a directory tree without accepting symlinks.""" + """Create or validate a directory tree without accepting unsafe parents.""" - path = _absolute(path) - parts = path.parts - if not parts: - return - - cur = Path(parts[0]) - for part in parts[1:]: - cur = cur / part - try: - st = cur.lstat() - except FileNotFoundError: - cur.mkdir(mode=0o700) - st = cur.lstat() - if stat.S_ISLNK(st.st_mode): - raise OutputPathError(f"refusing to use symlink directory: {cur}") - if not stat.S_ISDIR(st.st_mode): - raise OutputPathError(f"output path is not a directory: {cur}") + _mkdir_safe_dir_tree(path) def write_text_safely(path: Path, text: str, *, encoding: str = "utf-8") -> None: """Write text without following a final-path symlink. - The target's parent must already exist and every parent component must be a - real directory. The write is completed with os.replace(), which atomically - swaps the final directory entry and does not dereference a final symlink. + The target's parent is created/validated component by component. Existing + parent symlinks are refused for every user. When running as root, existing + parent components must also be root-owned and not writable by group/other, + except for sticky shared boundaries such as /tmp. Existing final-path + symlinks are refused rather than followed. """ path = _absolute(path) - _check_parent_components(path.parent) + parent = _mkdir_safe_dir_tree(path.parent) _check_existing_output_file(path) fd = -1 tmp_name: str | None = None try: fd, tmp_name = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True + prefix=f".{path.name}.", suffix=".tmp", dir=str(parent), text=True ) with os.fdopen(fd, "w", encoding=encoding) as f: fd = -1 @@ -111,6 +177,7 @@ def write_text_safely(path: Path, text: str, *, encoding: str = "utf-8") -> None f.flush() os.fsync(f.fileno()) os.chmod(tmp_name, 0o600) + _check_parent_components(path.parent) _check_existing_output_file(path) os.replace(tmp_name, path) tmp_name = None diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index 41a6b05..b5bb855 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -118,3 +118,19 @@ def test_cli_reports_unsafe_output_path_without_overwriting_symlink(tmp_path: Pa assert exit_code == 2 assert target.read_text(encoding="utf-8") == "keep\n" + + +def test_cli_refuses_root_output_through_group_writable_parent( + tmp_path: Path, monkeypatch +): + from jinjaturtle import output_safety + + unsafe_dir = tmp_path / "unsafe" + unsafe_dir.mkdir() + unsafe_dir.chmod(0o777) + monkeypatch.setattr(output_safety, "_effective_uid", lambda: 0) + + with pytest.raises(OutputPathError): + write_text_safely(unsafe_dir / "out.yml", "data\n") + + assert not (unsafe_dir / "out.yml").exists() From bc4af135b4bb12aa6ddc381d2e63a853e71ee920 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 14:31:39 +1000 Subject: [PATCH 34/44] Fix tests --- src/jinjaturtle/core.py | 12 ------------ src/jinjaturtle/j2.py | 1 - tests/test_yaml_handler.py | 2 +- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 72b42de..d86992b 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -448,18 +448,6 @@ def generate_jinja2_template( return template -def _template_variable_names( - role_prefix: str, - flat_items: list[tuple[tuple[str, ...], Any]], - loop_candidates: list[LoopCandidate] | None = None, -) -> set[str]: - names = {make_var_name(role_prefix, path) for path, _value in flat_items} - if loop_candidates: - for candidate in loop_candidates: - names.add(make_var_name(role_prefix, candidate.path)) - return names - - def _stringify_timestamps(obj: Any) -> Any: """ Recursively walk a parsed config and turn any datetime/date/time objects diff --git a/src/jinjaturtle/j2.py b/src/jinjaturtle/j2.py index 13531f5..e20ed20 100644 --- a/src/jinjaturtle/j2.py +++ b/src/jinjaturtle/j2.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any -NAME = "jinja2" TEMPLATE_EXTENSION = "j2" JSON_VALUE_FILTER = "to_json(ensure_ascii=False)" diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index 548f5a6..a41baf4 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -187,7 +187,7 @@ def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path): ), ] - for label, text, rendered_expected, erb_expected in cases: + for label, text, rendered_expected in cases: path = tmp_path / f"{label}.yml" path.write_text(text, encoding="utf-8") From 5ae85ad11e99b316a4d6e5cb3d81b8cd9aa15c62 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 14:44:39 +1000 Subject: [PATCH 35/44] Fix tests --- tests/test_yaml_handler.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index a41baf4..543c759 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -177,13 +177,11 @@ def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path): "blank", "require:\n - rubocop-performance\n - rubocop-rspec\n\nAllCops:\n NewCops: enable\n", "\n - rubocop-rspec\n\nAllCops:", - "<% end %>\nAllCops:", ), ( "no_blank", "require:\n - rubocop-performance\n - rubocop-rspec\nAllCops:\n NewCops: enable\n", "\n - rubocop-rspec\nAllCops:", - "<% end %>AllCops:", ), ] From 3573e8e7501c6a888732bc5f812f4bc12f93e8bc Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 20:46:07 +1000 Subject: [PATCH 36/44] Make var name collapse underscores --- src/jinjaturtle/handlers/base.py | 19 +++++++++++++-- tests/test_core_utils.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/jinjaturtle/handlers/base.py b/src/jinjaturtle/handlers/base.py index 14aaec7..6be91f0 100644 --- a/src/jinjaturtle/handlers/base.py +++ b/src/jinjaturtle/handlers/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any, Iterable @@ -57,8 +58,20 @@ class BaseHandler: role_prefix_section_subsection_key Sanitises parts to lowercase [a-z0-9_] and strips extras. + + Consecutive separators are collapsed to a single underscore. This is + required for correctness, not just aesthetics: a source key such as + ``log..level`` or ``cache--size`` would otherwise sanitise to a name + containing a double underscore (``log__level``). The output safety gate + in ``safety.py`` deliberately rejects *any* ``__`` in a generated + identifier because ``__`` is the gateway to every Jinja2 SSTI gadget + (``__class__``/``__globals__``/...). Emitting a dunder here would make + JinjaTurtle's own gate reject JinjaTurtle's own placeholder, aborting + generation on entirely benign config. Collapsing runs keeps every + generated name a plain single-underscore-delimited identifier that the + gate accepts. """ - role_prefix = role_prefix.strip().lower() + role_prefix = re.sub(r"_+", "_", role_prefix.strip().lower()) clean_parts: list[str] = [] for part in path: @@ -70,7 +83,9 @@ class BaseHandler: cleaned_chars.append(c.lower()) else: cleaned_chars.append("_") - cleaned_part = "".join(cleaned_chars).strip("_") + # Collapse runs of underscores (from adjacent separators) to a + # single "_" so the result can never contain a forbidden "__". + cleaned_part = re.sub(r"_+", "_", "".join(cleaned_chars)).strip("_") if cleaned_part: clean_parts.append(cleaned_part) diff --git a/tests/test_core_utils.py b/tests/test_core_utils.py index c8e41e1..fda6dd0 100644 --- a/tests/test_core_utils.py +++ b/tests/test_core_utils.py @@ -191,3 +191,43 @@ def test_flatten_config_unsupported_format(): flatten_config("bogusfmt", parsed=None) assert "Unsupported format" in str(exc.value) + + +def test_make_var_name_collapses_adjacent_separators(): + """Regression: adjacent separators must not produce a forbidden ``__``. + + A source key such as ``log..level`` or ``cache--size`` previously sanitised + to a name containing a double underscore (``..._log__level``). The output + safety gate in safety.py rejects *any* ``__`` in a generated identifier + (it is the gateway to Jinja2 SSTI gadgets), so emitting one made JinjaTurtle + reject its own placeholder and abort generation on entirely benign config. + make_var_name now collapses runs of underscores to a single ``_``. + """ + for raw_key in ("log..level", "cache--size", "a...b", "x.-.y", "a..b--c"): + name = make_var_name("role", ("main", raw_key)) + assert "__" not in name, f"{raw_key!r} produced {name!r}" + # Still a valid Ansible/Jinja identifier. + assert name.replace("_", "").isalnum() or name == "role" + + # The double-underscore-free collapse is stable and predictable. + assert make_var_name("role", ("main", "log..level")) == "role_main_log_level" + assert make_var_name("role", ("main", "cache--size")) == "role_main_cache_size" + # A leading-digit-free prefix with its own repeated separators is collapsed too. + assert make_var_name("My__Role", ("k",)) == "my_role_k" + + +def test_make_var_name_collapsed_names_pass_output_safety_gate(): + """The names make_var_name emits must be accepted by the safety gate. + + This binds the two modules together: whatever identifier make_var_name + produces for a hostile-looking key must lex as the JinjaTurtle subset, so a + real template built from it is not refused. + """ + from jinjaturtle.safety import verify_jinja2_template_safe + + for raw_key in ("log..level", "cache--size", "a...b", "weird..key..name"): + var = make_var_name("demo", ("section", raw_key)) + # Build the kind of expression a handler would emit for this variable. + template = "{{ " + var + " }}" + # Must not raise TemplateSafetyError. + verify_jinja2_template_safe(template) From 42321a8ec97918853aeb1fe6d438b5bb836703ca Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 20:47:05 +1000 Subject: [PATCH 37/44] Catch malformed config errors gracefully --- src/jinjaturtle/cli.py | 7 ++ src/jinjaturtle/core.py | 79 ++++++++++++++++++- src/jinjaturtle/multi.py | 17 ++++- tests/test_config_parse_errors.py | 123 ++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 tests/test_config_parse_errors.py diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index bdabc18..65dfa72 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -12,6 +12,7 @@ from .core import ( flatten_config, generate_ansible_yaml, generate_jinja2_template, + ConfigParseError, ) from .multi import process_directory @@ -76,6 +77,12 @@ def _main(argv: list[str] | None = None) -> int: except OutputPathError as exc: print(f"jinjaturtle: refusing unsafe output path: {exc}", file=sys.stderr) return 2 + except ConfigParseError as exc: + # The source file could not be parsed as its (detected or forced) + # format. This is expected for malformed/attacker-influenced input; + # fail cleanly with a non-zero exit code instead of a traceback. + print(f"jinjaturtle: {exc}", file=sys.stderr) + return 1 def _run(argv: list[str] | None = None) -> int: diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index d86992b..de34735 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -305,6 +305,66 @@ def detect_format(path: Path, explicit: str | None = None) -> str: return "ini" +class ConfigParseError(Exception): + """Raised when a source config file cannot be parsed as its format. + + Each underlying parser (json, tomllib, PyYAML, defusedxml/ElementTree, + configparser) raises its own exception type on malformed input. Without a + single normalised error, a malformed file -- which is entirely expected when + JinjaTurtle is pointed at harvested, attacker-influenceable config -- would + escape as an unhandled traceback (e.g. ``xml.etree.ElementTree.ParseError`` + on an XML file whose element name is not well-formed). ``parse_config`` + converts every such failure into this one type so the CLI can fail closed + with a clean message and a non-zero exit code, and so library callers (such + as Enroll, which falls back to copying the raw file) have a single, stable + exception to catch. + + Note: defusedxml's *security* exceptions (``EntitiesForbidden``, + ``DTDForbidden``, ...) are intentionally NOT folded into this type. They + signal an attempted XXE/entity-expansion attack rather than a benign + malformed file, and must propagate unchanged so callers can tell the two + apart. + """ + + +def _build_malformed_config_errors() -> tuple[type[BaseException], ...]: + """Return the concrete "this file is malformed" exception types to catch. + + Deliberately specific. In particular we must avoid catching plain + ``ValueError``: defusedxml's ``EntitiesForbidden``/``DTDForbidden`` subclass + ``ValueError``, and those are security signals that must NOT be swallowed. + """ + + import configparser + import json + from xml.etree.ElementTree import ParseError as _XMLParseError # nosec + + import yaml as _yaml + + errs: list[type[BaseException]] = [ + _XMLParseError, + json.JSONDecodeError, + configparser.Error, + _yaml.YAMLError, + UnicodeDecodeError, + ] + try: + import tomllib + + errs.append(tomllib.TOMLDecodeError) + except ModuleNotFoundError: # pragma: no cover - Python < 3.11 fallback + try: + import tomli # type: ignore + + errs.append(tomli.TOMLDecodeError) + except ModuleNotFoundError: + pass + return tuple(errs) + + +_MALFORMED_CONFIG_ERRORS = _build_malformed_config_errors() + + def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: """ Parse config file into a Python object. @@ -313,7 +373,24 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: handler = _HANDLERS.get(fmt) if handler is None: raise ValueError(f"Unsupported config format: {fmt}") - parsed = handler.parse(path) + try: + parsed = handler.parse(path) + except ConfigParseError: + raise + except _MALFORMED_CONFIG_ERRORS as exc: + # Normalise the per-parser "this file is malformed" errors into one + # type: json.JSONDecodeError / tomllib.TOMLDecodeError (ValueError + # subclasses), PyYAML's YAMLError, configparser.Error, and + # xml.etree.ElementTree.ParseError (raised by defusedxml on XML whose + # structure/element name is not well-formed). A bad input file is + # expected when parsing harvested config, so fail closed with a clean + # error instead of an unhandled traceback. + # + # IMPORTANT: this deliberately does NOT catch defusedxml's security + # exceptions (EntitiesForbidden, DTDForbidden, ...). Those signal an + # attempted XXE/entity-expansion attack and must propagate unchanged so + # callers (and tests) can distinguish "malformed" from "malicious". + raise ConfigParseError(f"could not parse {path} as {fmt}: {exc}") from exc # Make sure datetime objects are treated as strings (TOML, YAML) parsed = _stringify_timestamps(parsed) diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index c968d61..ce54e1c 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -25,13 +25,20 @@ from copy import deepcopy import os import configparser import stat +import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable import xml.etree.ElementTree as ET # nosec from . import j2 -from .core import dump_yaml, flatten_config, make_var_name, parse_config +from .core import ( + dump_yaml, + flatten_config, + make_var_name, + parse_config, + ConfigParseError, +) from .handlers.xml import XmlHandler from .safety import verify_jinja2_template_safe, verify_no_live_jinja_in_json_keys from .escape import escape_jinja_literal @@ -631,7 +638,13 @@ def process_directory( # Parse and group by format grouped: dict[str, list[tuple[Path, Any]]] = defaultdict(list) for p in files: - fmt, parsed = parse_config(p, None) + try: + fmt, parsed = parse_config(p, None) + except ConfigParseError as exc: + # One malformed file should not abort processing of an entire + # directory. Skip it with a warning; the rest still generate. + print(f"jinjaturtle: skipping {p}: {exc}", file=sys.stderr) + continue if fmt not in FOLDER_SUPPORTED_FORMATS: # Directory mode only supports a subset of formats for now. continue diff --git a/tests/test_config_parse_errors.py b/tests/test_config_parse_errors.py new file mode 100644 index 0000000..ab42fe5 --- /dev/null +++ b/tests/test_config_parse_errors.py @@ -0,0 +1,123 @@ +"""Regression tests for malformed-input handling (``ConfigParseError``). + +Bug: every parse-layer handler (xml/json/toml/yaml/ini) used to let its +underlying parser's exception escape as an unhandled traceback when given a +malformed file. Pointing JinjaTurtle (or Enroll, which calls it as a library) +at harvested, attacker-influenceable config makes malformed input an entirely +expected condition, so it must fail cleanly instead of crashing. + +These tests pin down that: + + * ``parse_config`` raises the normalised ``ConfigParseError`` for malformed + XML/JSON/TOML/INI; + * defusedxml's *security* exceptions (XXE / ``EntitiesForbidden``) are NOT + swallowed by that normalisation -- they must still propagate so a caller can + distinguish "malformed" from "malicious"; + * an unrelated error (e.g. the TOML "tomllib missing" ``RuntimeError``) is not + captured by the normalisation either; + * the CLI exits non-zero with a clean message (no traceback) on malformed + input; and + * folder mode skips an unparseable file instead of aborting the whole run. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from jinjaturtle.core import ConfigParseError, parse_config + + +def _run_cli(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "jinjaturtle.cli", *args], + capture_output=True, + text=True, + ) + + +@pytest.mark.parametrize( + "fmt,filename,content", + [ + # Element name is not well-formed XML -> expat ParseError historically + # escaped as an uncaught traceback. + ("xml", "bad.xml", "<{{tag}}/>"), + ("xml", "bad2.xml", ""), + ("json", "bad.json", "{not valid json"), + ("toml", "bad.toml", "x = = ="), + # A bare key with no value is invalid INI for configparser. + ("ini", "bad.ini", "[s]\nthis line has no equals and no colon\n"), + ], +) +def test_parse_config_raises_configparseerror_on_malformed( + tmp_path: Path, fmt: str, filename: str, content: str +) -> None: + src = tmp_path / filename + src.write_text(content, encoding="utf-8") + with pytest.raises(ConfigParseError): + parse_config(src, fmt=fmt) + + +def test_parse_config_does_not_swallow_xxe_security_exception(tmp_path: Path) -> None: + """defusedxml's EntitiesForbidden must propagate, not become ConfigParseError. + + EntitiesForbidden subclasses ValueError, so a naive ``except ValueError`` / + ``except Exception`` in parse_config would mask an XXE attempt as a benign + "malformed file". The normalisation is deliberately scoped to exclude it. + """ + from defusedxml.common import EntitiesForbidden + + xxe = ( + '\n' + ' ]>\n' + "&xxe;\n" + ) + src = tmp_path / "xxe.xml" + src.write_text(xxe, encoding="utf-8") + with pytest.raises(EntitiesForbidden): + parse_config(src, fmt="xml") + + +def test_parse_config_does_not_swallow_unrelated_runtimeerror( + tmp_path: Path, monkeypatch +) -> None: + """A non-malformation error (tomllib missing) must propagate unchanged.""" + import jinjaturtle.handlers.toml as toml_module + + monkeypatch.setattr(toml_module, "tomllib", None) + src = tmp_path / "x.toml" + src.write_text('a = "b"\n', encoding="utf-8") + with pytest.raises(RuntimeError) as exc: + parse_config(src, fmt="toml") + assert "tomllib/tomli is required" in str(exc.value) + + +def test_cli_fails_cleanly_on_malformed_xml(tmp_path: Path) -> None: + """The CLI must exit non-zero with a clean message, not a traceback.""" + src = tmp_path / "bad.xml" + src.write_text("<{{tag}}/>", encoding="utf-8") + res = _run_cli([str(src), "-f", "xml", "-r", "demo"]) + assert res.returncode != 0 + # Clean, user-facing message -- not a Python traceback. + assert "could not parse" in res.stderr + assert "Traceback (most recent call last)" not in res.stderr + + +def test_folder_mode_skips_unparseable_file(tmp_path: Path) -> None: + """One malformed file should not abort processing of the whole directory.""" + from jinjaturtle.multi import process_directory + + good = tmp_path / "good.json" + good.write_text('{ "host": "localhost" }', encoding="utf-8") + bad = tmp_path / "bad.json" + bad.write_text("{not valid json", encoding="utf-8") + + defaults_yaml, outputs = process_directory(tmp_path, False, "demo") + # The good file still produced output despite the bad sibling: its content + # and source id appear, and a template was generated. + assert "localhost" in defaults_yaml + assert "good.json" in defaults_yaml + assert outputs From d4fd42522d329d26a25d205e162ab396111a5353 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 20:53:15 +1000 Subject: [PATCH 38/44] Fix bandit alert --- src/jinjaturtle/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index de34735..458dbdc 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -379,7 +379,7 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: raise except _MALFORMED_CONFIG_ERRORS as exc: # Normalise the per-parser "this file is malformed" errors into one - # type: json.JSONDecodeError / tomllib.TOMLDecodeError (ValueError + # json.JSONDecodeError / tomllib.TOMLDecodeError (ValueError # subclasses), PyYAML's YAMLError, configparser.Error, and # xml.etree.ElementTree.ParseError (raised by defusedxml on XML whose # structure/element name is not well-formed). A bad input file is From 575c2b79f92aeded7490cf94b31cea5341248b53 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 1 Jul 2026 12:23:19 +1000 Subject: [PATCH 39/44] Fix catching of defusedxml exception --- src/jinjaturtle/cli.py | 15 +++++++++++ tests/test_config_parse_errors.py | 41 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 65dfa72..d901606 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import sys from defusedxml import defuse_stdlib +from defusedxml.common import DefusedXmlException from pathlib import Path from . import j2 @@ -77,6 +78,20 @@ def _main(argv: list[str] | None = None) -> int: except OutputPathError as exc: print(f"jinjaturtle: refusing unsafe output path: {exc}", file=sys.stderr) return 2 + except DefusedXmlException as exc: + # defusedxml rejected the XML because it attempted a DTD, entity + # expansion, or external reference (XXE / billion-laughs class attack). + # This is a deliberately-blocked attack, not a benign malformed file, so + # core.parse_config lets it propagate unchanged rather than folding it + # into ConfigParseError. Report it as a refused unsafe input with a + # non-zero exit code instead of leaking an internal traceback. + print( + "jinjaturtle: refusing unsafe XML input: the document uses a DTD, " + f"entity expansion, or external reference ({exc.__class__.__name__}). " + "This is blocked to prevent XXE / entity-expansion attacks.", + file=sys.stderr, + ) + return 2 except ConfigParseError as exc: # The source file could not be parsed as its (detected or forced) # format. This is expected for malformed/attacker-influenced input; diff --git a/tests/test_config_parse_errors.py b/tests/test_config_parse_errors.py index ab42fe5..01ba1ef 100644 --- a/tests/test_config_parse_errors.py +++ b/tests/test_config_parse_errors.py @@ -106,6 +106,47 @@ def test_cli_fails_cleanly_on_malformed_xml(tmp_path: Path) -> None: assert "Traceback (most recent call last)" not in res.stderr +@pytest.mark.parametrize( + "content", + [ + # Classic XXE: external SYSTEM entity used to read a local file. + ( + '\n' + ']>\n' + "&xxe;\n" + ), + # Billion-laughs style internal entity expansion. + ( + '\n' + ']>\n' + "&lol2;\n" + ), + # External parameter entity (SSRF / out-of-band XXE vector). + ( + '\n' + ' %ext;]>\n' + "x\n" + ), + ], +) +def test_cli_refuses_xxe_cleanly(tmp_path: Path, content: str) -> None: + """defusedxml's security refusal must surface as a clean non-zero exit. + + ``EntitiesForbidden``/``DTDForbidden``/``ExternalReferenceForbidden`` subclass + ``DefusedXmlException`` (and ``ValueError``). ``parse_config`` deliberately + lets them propagate rather than folding them into ``ConfigParseError`` so a + blocked attack stays distinguishable from a benign malformed file. The CLI + must therefore catch ``DefusedXmlException`` itself and exit cleanly instead + of leaking a Python traceback. + """ + src = tmp_path / "xxe.xml" + src.write_text(content, encoding="utf-8") + res = _run_cli([str(src), "-f", "xml", "-r", "demo"]) + assert res.returncode == 2 + assert "refusing unsafe XML input" in res.stderr + assert "Traceback (most recent call last)" not in res.stderr + + def test_folder_mode_skips_unparseable_file(tmp_path: Path) -> None: """One malformed file should not abort processing of the whole directory.""" from jinjaturtle.multi import process_directory From 054b90ebde1709d6f9dc1823e3c6d23770a24b55 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 2 Jul 2026 14:22:07 +1000 Subject: [PATCH 40/44] Don't reference built-in globals via loop-items in Jinja2 into keys --- src/jinjaturtle/handlers/json.py | 10 ++++++++- src/jinjaturtle/handlers/toml.py | 14 +++++++++++- src/jinjaturtle/handlers/yaml.py | 13 +++++++++++ src/jinjaturtle/loop_analyzer.py | 37 +++++++++++++++++++++++++++++++ src/jinjaturtle/safety.py | 38 ++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index 0268666..41699c9 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -8,7 +8,7 @@ from typing import Any from . import DictLikeHandler from .. import j2 from ..escape import escape_jinja_literal -from ..loop_analyzer import LoopCandidate +from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key class JsonHandler(DictLikeHandler): @@ -383,6 +383,14 @@ class JsonHandler(DictLikeHandler): ] # first line has no indent; we prepend `inner` when emitting for i, key in enumerate(keys): comma = "," if i < len(keys) - 1 else "" + # Defence in depth: never interpolate a raw source key into an + # ``item_var.key`` reference. A key such as ``a }}{{ x`` would break + # out of the value placeholder and inject a live construct that the + # output-safety gate cannot distinguish from a legitimate variable. + if not is_safe_loop_field_key(key): + raise ValueError( + f"refusing to emit loop-item field reference for unsafe key: {key!r}" + ) # The literal key text is emitted verbatim into the template; escape any # Jinja markup in it. The value side ({item_var}.{key}) is constrained by # the output safety gate's dotted-name allowlist, which fails closed on diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index 6d25cff..b2aa6e5 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -6,7 +6,7 @@ from typing import Any from . import DictLikeHandler from .. import j2 from ..escape import escape_jinja_literal -from ..loop_analyzer import LoopCandidate +from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key try: import tomllib @@ -438,6 +438,18 @@ class TomlHandler(DictLikeHandler): for key, value in sample_item.items(): if key == "_key": continue + # Defence in depth: the loop analyzer refuses a + # dict-loop whose items contain a non-identifier + # key, so ``key`` is always a plain identifier + # here. Never interpolate a raw key into an + # ``item_var.key`` reference: a key such as + # ``a }}{{ x`` would break out of the placeholder + # and inject a live construct. + if not is_safe_loop_field_key(key): + raise ValueError( + "refusing to emit loop-item field " + f"reference for unsafe key: {key!r}" + ) if isinstance(value, str): out_lines.append( f"{escape_jinja_literal(str(key))} = " diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index d8131b0..a1447bb 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -7,6 +7,7 @@ from typing import Any from .dict import DictLikeHandler from .. import j2 from ..escape import escape_jinja_literal +from ..loop_analyzer import is_safe_loop_field_key from ..loop_analyzer import LoopCandidate @@ -547,6 +548,18 @@ class YamlHandler(DictLikeHandler): # Special key for dict collections - output as comment or skip continue + # Defence in depth: the loop analyzer already refuses a dict-loop + # whose items contain a non-identifier key (see _analyze_dict_schema), + # so ``key`` should always be a plain identifier here. Assert it + # rather than interpolate a raw key into a Jinja reference: a key such + # as ``a }}{{ x`` would otherwise close the placeholder and inject a + # live construct that the output gate cannot distinguish from a + # legitimate variable. + if not is_safe_loop_field_key(key): + raise ValueError( + f"refusing to emit loop-item field reference for unsafe key: {key!r}" + ) + if first_key and is_list_item: # First key gets the list marker value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) diff --git a/src/jinjaturtle/loop_analyzer.py b/src/jinjaturtle/loop_analyzer.py index 23702d8..608c8e4 100644 --- a/src/jinjaturtle/loop_analyzer.py +++ b/src/jinjaturtle/loop_analyzer.py @@ -7,10 +7,34 @@ instead of flattened scalar variables. from __future__ import annotations +import re from collections import Counter from typing import Any, Literal +# A dict-loop emits per-item field references of the form ``loopvar.``. +# ```` is derived from a source key, which is attacker-influenceable when +# JinjaTurtle is fed harvested config. The output-safety gate only accepts a +# reference whose every hop matches this identifier class, so a key must reduce +# to exactly this shape before it can be used as a loop-item field. Anything else +# (a key containing ``}}``, quotes, ``.``, ``__``, ...) must not be turned into a +# loop; the caller falls back to scalar generation, where every value goes through +# make_var_name() and all verbatim text is escaped. +_SAFE_LOOP_FIELD_RE = re.compile(r"(?!\w*__)[A-Za-z_][A-Za-z0-9_]*\Z") + + +def is_safe_loop_field_key(key: Any) -> bool: + """Return True if *key* is safe to emit as a ``loopvar.`` field access. + + The check mirrors the output-safety gate's identifier class (single + identifier, no double underscore). A key that does not match cannot be + expressed as a dotted loop-item reference without risking template + injection, so a loop candidate containing such a key is rejected upstream. + """ + + return bool(_SAFE_LOOP_FIELD_RE.match(str(key))) + + class LoopCandidate: """ Represents a detected loop opportunity in the config structure. @@ -265,6 +289,19 @@ class LoopAnalyzer: if not dicts: return "heterogeneous" + # Security: a dict-loop emits ``loopvar.`` field references. If any + # item key is not a plain identifier, it cannot be expressed safely as a + # dotted reference (a key such as ``a }}{{ x`` would break out of the + # placeholder and inject a live construct). Refuse the loop so the caller + # falls back to scalar generation, which escapes all verbatim text and + # routes every value through make_var_name(). + for d in dicts: + for k in d.keys(): + if k == "_key": + continue + if not is_safe_loop_field_key(k): + return "heterogeneous" + # Get key sets from each dict key_sets = [set(d.keys()) for d in dicts] diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py index 5ed2fe1..e049dd3 100644 --- a/src/jinjaturtle/safety.py +++ b/src/jinjaturtle/safety.py @@ -132,13 +132,51 @@ def _strip_ws_control(body: str) -> str: return body.strip() +# Built-in Jinja2/Ansible globals that JinjaTurtle never emits as a reference +# head. The allowlist grammar necessarily accepts any bare identifier (it cannot +# tell an injected global from a legitimate generated variable), so these names +# are rejected explicitly: their presence as the head of a live reference means +# source text leaked into a live construct. This is a targeted backstop and does +# not replace the grammar check. +_FORBIDDEN_REFERENCE_HEADS = frozenset( + { + "range", + "dict", + "lipsum", + "cycler", + "joiner", + "namespace", + "config", + "self", + "cycle", + "request", + "get_flashed_messages", + "url_for", + } +) + + +def _reference_head(body: str) -> str: + """Return the leading identifier (before any dot/filter) of an expression.""" + body = _strip_ws_control(body) + m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", body) + return m.group(0) if m else "" + + def _expr_is_allowed(body: str) -> bool: body = _strip_ws_control(body) + if _reference_head(body) in _FORBIDDEN_REFERENCE_HEADS: + return False return any(p.match(body) for p in _EXPR_PATTERNS) def _stmt_is_allowed(body: str) -> bool: body = _strip_ws_control(body) + # A ``for ... in `` whose collection is a forbidden global is + # rejected for the same reason as an expression head. + m = re.match(r"for\s+[A-Za-z_]\w*\s+in\s+([A-Za-z_][A-Za-z0-9_]*)", body) + if m and m.group(1) in _FORBIDDEN_REFERENCE_HEADS: + return False return any(p.match(body) for p in _STMT_PATTERNS) From c6ce0311edcaba99ba4a471e87e8c9751a762112 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 2 Jul 2026 14:52:09 +1000 Subject: [PATCH 41/44] Forbid the ansible globals --- src/jinjaturtle/safety.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py index e049dd3..938f2ad 100644 --- a/src/jinjaturtle/safety.py +++ b/src/jinjaturtle/safety.py @@ -152,6 +152,13 @@ _FORBIDDEN_REFERENCE_HEADS = frozenset( "request", "get_flashed_messages", "url_for", + # Ansible global set + "lookup", + "q", + "query", + "now", + "omit", + "undef", } ) From f9332879ba4a9b550861dc4bc6df4e0d20d8fcf9 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 3 Jul 2026 12:23:52 +1000 Subject: [PATCH 42/44] More hardening --- src/jinjaturtle/core.py | 16 ++++++++++-- src/jinjaturtle/handlers/yaml.py | 41 ++++++++++++++++++++++++++++++- tests/test_config_parse_errors.py | 41 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 458dbdc..de7b552 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -375,6 +375,10 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: raise ValueError(f"Unsupported config format: {fmt}") try: parsed = handler.parse(path) + # Make sure datetime objects are treated as strings (TOML, YAML). This + # walks the parsed object recursively, so keep it inside the try where a + # RecursionError from a pathological structure is normalised below. + parsed = _stringify_timestamps(parsed) except ConfigParseError: raise except _MALFORMED_CONFIG_ERRORS as exc: @@ -391,8 +395,16 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: # attempted XXE/entity-expansion attack and must propagate unchanged so # callers (and tests) can distinguish "malformed" from "malicious". raise ConfigParseError(f"could not parse {path} as {fmt}: {exc}") from exc - # Make sure datetime objects are treated as strings (TOML, YAML) - parsed = _stringify_timestamps(parsed) + except RecursionError as exc: + # A deeply-nested or self-referential structure (e.g. a recursive YAML + # anchor) can exhaust the Python stack while walking the parsed object. + # The YAML handler already rejects reference cycles up front; this is a + # format-agnostic backstop so any such input fails closed with a clean + # message instead of a stack-overflow traceback. + raise ConfigParseError( + f"could not parse {path} as {fmt}: input is too deeply nested " + "or self-referential" + ) from exc return fmt, parsed diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index a1447bb..9a868a6 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -11,6 +11,36 @@ from ..loop_analyzer import is_safe_loop_field_key from ..loop_analyzer import LoopCandidate +def _reject_recursive_structure(obj: Any) -> None: + """Raise ``yaml.YAMLError`` if *obj* contains a reference cycle. + + A recursive YAML anchor (``a: &a [*a]``) produces a container that contains + itself. Every consumer in JinjaTurtle walks the parsed object depth-first, + so a cycle would raise ``RecursionError`` deep in unrelated code. Detect it + up front by tracking the ``id()`` of containers on the current descent path; + a repeat means a cycle. ``yaml.YAMLError`` is raised so ``parse_config`` + normalises it into a clean ``ConfigParseError`` like any other malformed + input, rather than surfacing a stack-overflow traceback. + """ + + on_path: set[int] = set() + + def walk(node: Any) -> None: + if isinstance(node, (dict, list)): + marker = id(node) + if marker in on_path: + raise yaml.YAMLError( + "recursive/self-referential YAML structure is not supported" + ) + on_path.add(marker) + children = node.values() if isinstance(node, dict) else node + for child in children: + walk(child) + on_path.discard(marker) + + walk(obj) + + class YamlHandler(DictLikeHandler): """ YAML handler that can generate both scalar templates and loop-based templates. @@ -21,7 +51,16 @@ class YamlHandler(DictLikeHandler): def parse(self, path: Path) -> Any: text = path.read_text(encoding="utf-8") - return yaml.safe_load(text) or {} + parsed = yaml.safe_load(text) or {} + # PyYAML's safe_load happily builds *recursive* structures from an anchor + # that references itself (e.g. ``a: &a [*a]``). Downstream flattening, + # timestamp-stringifying and template generation all walk the parsed + # object recursively and would blow the Python stack (RecursionError) on + # such input. JinjaTurtle is regularly pointed at harvested, + # attacker-influenceable config, so reject a self-referential document + # cleanly here rather than crashing later. + _reject_recursive_structure(parsed) + return parsed def generate_jinja2_template( self, diff --git a/tests/test_config_parse_errors.py b/tests/test_config_parse_errors.py index 01ba1ef..7f4c422 100644 --- a/tests/test_config_parse_errors.py +++ b/tests/test_config_parse_errors.py @@ -162,3 +162,44 @@ def test_folder_mode_skips_unparseable_file(tmp_path: Path) -> None: assert "localhost" in defaults_yaml assert "good.json" in defaults_yaml assert outputs + + +@pytest.mark.parametrize( + "content", + [ + "a: &a [*a]\n", # self-referential sequence alias + "root: &r\n child: *r\n", # self-referential mapping alias + ], +) +def test_recursive_yaml_is_rejected_cleanly(tmp_path: Path, content: str) -> None: + """A recursive YAML anchor must fail as a clean ConfigParseError. + + PyYAML's safe_load builds a self-referential object from a recursive anchor; + JinjaTurtle's downstream walks (flatten, timestamp-stringify, template gen) + would otherwise blow the stack with a RecursionError traceback. Harvested + config is attacker-influenceable, so this must fail closed. + """ + src = tmp_path / "cyclic.yaml" + src.write_text(content, encoding="utf-8") + with pytest.raises(ConfigParseError): + parse_config(src, "yaml") + + +def test_recursive_yaml_cli_exits_nonzero_without_traceback(tmp_path: Path) -> None: + src = tmp_path / "cyclic.yaml" + src.write_text("a: &a [*a]\n", encoding="utf-8") + res = _run_cli([str(src), "-f", "yaml", "-r", "role"]) + assert res.returncode != 0 + assert "Traceback" not in res.stderr + assert "RecursionError" not in res.stderr + + +def test_shared_noncyclic_yaml_aliases_still_work(tmp_path: Path) -> None: + """A shared (acyclic) anchor referenced multiple times is a DAG, not a + cycle, and must still parse and template normally.""" + src = tmp_path / "shared.yaml" + src.write_text("base: &b\n x: 1\na: *b\nc: *b\n", encoding="utf-8") + fmt, parsed = parse_config(src, "yaml") + assert fmt == "yaml" + assert parsed["a"] == {"x": 1} + assert parsed["c"] == {"x": 1} From d3694240dab9a9ed64e4be8a74c408a1c17a0ba4 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 5 Jul 2026 10:54:37 +1000 Subject: [PATCH 43/44] 0.7.0 --- debian/changelog | 6 ++++++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 9173c94..be6b123 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.7.0) unstable; urgency=medium + + * Much hardening. + + -- Miguel Jacq Sun, 5 Jul 2026 10:54:00 +1000 + jinjaturtle (0.5.7) unstable; urgency=medium * More hardening measures diff --git a/pyproject.toml b/pyproject.toml index 9ec2da5..7751303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jinjaturtle" -version = "0.5.7" +version = "0.7.0" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = [ { name = "Miguel Jacq", email = "mig@mig5.net" }, diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 051fd8b..8c8d845 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.7 +%global upstream_version 0.7.0 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Sun Jul 05 2026 Miguel Jacq - %{version}-%{release} +- Much hardening * Wed Jun 24 2026 Miguel Jacq - %{version}-%{release} - More hardening * Tue Jun 23 2026 Miguel Jacq - %{version}-%{release} From a712b19a45c0be6403a00daf1434cc48e3cc7f95 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 24 Jul 2026 10:58:23 +1000 Subject: [PATCH 44/44] Remove Fediverse link --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 1726719..5e104f3 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,4 @@ management system! ## Found a bug, have a suggestion? -You can e-mail me; see `pyproject.toml` for details. You can also contact me on -the Fediverse: - -https://goto.mig5.net/@mig5 +You can e-mail me; see `pyproject.toml` for details.