diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0e7439b..a54c43f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -24,22 +24,9 @@ jobs: - name: Install project deps (including test extras) run: | - poetry install --with dev + poetry install --with test - name: Run test script run: | ./tests.sh - # 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" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml index a8ba06d..60768d8 100644 --- a/.forgejo/workflows/lint.yml +++ b/.forgejo/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - black pyflakes3 python3-bandit vulture + black pyflakes3 python3-bandit - name: Run linters run: | @@ -24,18 +24,3 @@ jobs: pyflakes3 src/* pyflakes3 tests/* bandit -s B110 -r src/ - vulture . - - # 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" diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml index fad2f6f..18ced32 100644 --- a/.forgejo/workflows/trivy.yml +++ b/.forgejo/workflows/trivy.yml @@ -24,17 +24,3 @@ jobs: - name: Run trivy run: | trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . - - # 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" diff --git a/.gitignore b/.gitignore index dedc5da..2352872 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,3 @@ __pycache__ .pytest_cache dist .coverage -*.yml -*.j2 -*.toml -regenerated_* diff --git a/Dockerfile.debbuild b/Dockerfile.debbuild deleted file mode 100644 index 8d185b1..0000000 --- a/Dockerfile.debbuild +++ /dev/null @@ -1,85 +0,0 @@ -# syntax=docker/dockerfile:1 -ARG BASE_IMAGE=debian:bookworm -FROM ${BASE_IMAGE} - -ENV DEBIAN_FRONTEND=noninteractive - -# If Ubuntu, ensure Universe is enabled. -RUN set -eux; \ - . /etc/os-release; \ - if [ "${ID:-}" = "ubuntu" ]; then \ - apt-get update; \ - apt-get install -y --no-install-recommends software-properties-common ca-certificates; \ - add-apt-repository -y universe; \ - fi; \ - if [ "${VERSION_CODENAME:-}" = "jammy" ]; then \ - apt-get update; \ - apt-get install -y --no-install-recommends python3-tomli; \ - fi - -# Build deps -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - build-essential \ - devscripts \ - debhelper \ - dh-python \ - pybuild-plugin-pyproject \ - python3-all \ - python3-poetry-core \ - python3-yaml \ - python3-defusedxml \ - python3-jinja2 \ - python3-toml \ - rsync \ - ca-certificates \ - ; \ - rm -rf /var/lib/apt/lists/* - -# Build runner script -RUN set -eux; \ - cat > /usr/local/bin/build-deb <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -SRC="${SRC:-/src}" -WORKROOT="${WORKROOT:-/work}" -WORK="${WORKROOT}/src" -OUT="${OUT:-/out}" - -mkdir -p "$WORK" "$OUT" - -rsync -a --delete \ - --exclude '.git' \ - --exclude '.venv' \ - --exclude 'dist' \ - --exclude 'build' \ - --exclude '__pycache__' \ - --exclude '.pytest_cache' \ - --exclude '.mypy_cache' \ - "${SRC}/" "${WORK}/" - -cd "${WORK}" -if [ -n "${SUITE:-}" ]; then - export DEBEMAIL="mig@mig5.net" - export DEBFULLNAME="Miguel Jacq" - - dch --distribution "$SUITE" --local "~${SUITE}" "CI build for $SUITE" -fi -dpkg-buildpackage -us -uc -b - -shopt -s nullglob -cp -v "${WORKROOT}"/*.deb \ - "${WORKROOT}"/*.changes \ - "${WORKROOT}"/*.buildinfo \ - "${WORKROOT}"/*.dsc \ - "${WORKROOT}"/*.tar.* \ - "${OUT}/" || true - -echo "Artifacts copied to ${OUT}" -EOF -RUN chmod +x /usr/local/bin/build-deb - -WORKDIR /work -ENTRYPOINT ["/usr/local/bin/build-deb"] diff --git a/README.md b/README.md index 80763f3..e7bf5ea 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ # JinjaTurtle -
- JinjaTurtle logo -
- JinjaTurtle is a command-line tool to help you generate Jinja2 templates and -Ansible inventory from a native configuration file of a piece of software. +Ansible `defaults/main.yml` files from a native configuration file of a piece +of software. ## How it works @@ -16,40 +13,14 @@ Ansible inventory from a native configuration file of a piece of software. role. * A Jinja2 file is generated from the file with those parameter key names injected as the `{{ variable }}` names. - * An Ansible inventory YAML file is generated with those key names and the - *values* taken from the original config file as the defaults. + * A `defaults/main.yml` is generated with those key names and the *values* + taken from the original config file as the defaults. -By default, the Jinja2 template and the Ansible inventory are printed to +By default, the Jinja2 template and the `defaults/main.yml` are printed to stdout. However, it is possible to output the results to new files. -## What sort of config files can it handle? - -TOML, YAML, INI, JSON and XML-style config files should be okay. There are always -going to be some edge cases in very complex files that are difficult to work -with, though, so you may still find that you need to tweak the results. - -For XML and YAML files, JinjaTurtle will attempt to generate 'for' loops -and lists in the Ansible yaml if the config file looks homogenous enough to -support it. However, if it lacks the confidence in this, it will fall back to -using scalar-style flattened attributes. - -You may need or wish to tidy up the config to suit your needs. - -The goal here is really to *speed up* converting files into Ansible/Jinja2, -but not necessarily to make it perfect. - ## How to install it -### Ubuntu/Debian apt repository - -```bash -sudo mkdir -p /usr/share/keyrings -curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg -echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mig5.list -sudo apt update -sudo apt install jinjaturtle -``` - ### From PyPi ``` @@ -84,9 +55,9 @@ 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 {ini,toml}] [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] config -Convert a config file into Ansible inventory and a Jinja2 template. +Convert a config file into an Ansible defaults file and Jinja2 template. positional arguments: config Path to the source configuration file (TOML or INI-style). @@ -95,7 +66,7 @@ options: -h, --help show this help message and exit -r, --role-name ROLE_NAME Ansible role name, used as variable prefix (e.g. cometbft). - -f, --format {ini,json,toml,xml} + -f, --format {ini,toml} Force config format instead of auto-detecting from filename. -d, --defaults-output DEFAULTS_OUTPUT Path to write defaults/main.yml. If omitted, defaults YAML is printed to stdout. @@ -103,9 +74,3 @@ options: Path to write the Jinja2 config template. If omitted, template is printed to stdout. ``` - -## Found a bug, have a suggestion? - -You can e-mail me (see the pyproject.toml for details) or contact me on the Fediverse: - -https://goto.mig5.net/@mig5 diff --git a/debian/changelog b/debian/changelog deleted file mode 100644 index 9db1779..0000000 --- a/debian/changelog +++ /dev/null @@ -1,11 +0,0 @@ -jinjaturtle (0.3.3) unstable; urgency=medium - - * Fixes for tomli on Ubuntu 22 - - -- Miguel Jacq Mon, 15 Dec 2025 14:00:00 +0000 - -jinjaturtle (0.3.2) unstable; urgency=medium - - * Initial package - - -- Miguel Jacq Mon, 15 Dec 2025 12:00:00 +0000 diff --git a/debian/control b/debian/control deleted file mode 100644 index 72a7e21..0000000 --- a/debian/control +++ /dev/null @@ -1,28 +0,0 @@ -Source: jinjaturtle -Section: admin -Priority: optional -Maintainer: Miguel Jacq -Rules-Requires-Root: no -Build-Depends: - debhelper-compat (= 13), - dh-python, - pybuild-plugin-pyproject, - python3-all, - python3-poetry-core, - python3-yaml, - python3-toml, - python3-defusedxml, - python3-jinja2 -Standards-Version: 4.6.2 -Homepage: https://git.mig5.net/mig5/jinjaturtle - -Package: jinjaturtle -Architecture: all -Depends: - ${misc:Depends}, - ${python3:Depends}, - python3-yaml, - python3-toml, - python3-defusedxml, - python3-jinja2 -Description: Convert config files into Ansible defaults and Jinja2 templates. diff --git a/debian/rules b/debian/rules deleted file mode 100755 index 4c26136..0000000 --- a/debian/rules +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/make -f -export PYBUILD_NAME=jinjaturtle -export PYBUILD_SYSTEM=pyproject - -%: - dh $@ --with python3 --buildsystem=pybuild diff --git a/debian/source/format b/debian/source/format deleted file mode 100644 index 163aaf8..0000000 --- a/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (quilt) diff --git a/debian/source/options b/debian/source/options deleted file mode 100644 index c32a8c1..0000000 --- a/debian/source/options +++ /dev/null @@ -1,6 +0,0 @@ -tar-ignore = ".git" -tar-ignore = ".venv" -tar-ignore = "__pycache__" -tar-ignore = ".pytest_cache" -tar-ignore = "dist" -tar-ignore = "build" diff --git a/jinjaturtle.svg b/jinjaturtle.svg deleted file mode 100644 index 2e6fcf2..0000000 --- a/jinjaturtle.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - {{ }} - - - - - - - - - - - - - - - - - - - - diff --git a/poetry.lock b/poetry.lock index 0d40c6c..8891448 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,42 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] + +[[package]] +name = "build" +version = "1.3.0" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.9" +files = [ + {file = "build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4"}, + {file = "build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +importlib-metadata = {version = ">=4.6", markers = "python_full_version < \"3.10.2\""} +packaging = ">=19.1" +pyproject_hooks = "*" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.11)", "virtualenv (>=20.17)", "virtualenv (>=20.31)"] + [[package]] name = "certifi" version = "2025.11.12" @@ -11,6 +48,102 @@ files = [ {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -146,103 +279,103 @@ files = [ [[package]] name = "coverage" -version = "7.13.0" +version = "7.12.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.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, + {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, + {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, + {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, + {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, + {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, + {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, + {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, + {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, + {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, + {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, + {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, + {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, + {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, + {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, + {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, + {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, + {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, + {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, + {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, + {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, + {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, + {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, + {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, + {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, + {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, + {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, + {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, + {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, + {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, + {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, + {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, + {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, + {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, + {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, + {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, ] [package.dependencies] @@ -252,16 +385,82 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 toml = ["tomli"] [[package]] -name = "defusedxml" -version = "0.7.1" -description = "XML bomb protection for Python stdlib modules" +name = "cryptography" +version = "46.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.9.0,!=3.9.1,>=3.8" files = [ - {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, - {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, ] +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "desktop-entry-lib" version = "5.0" @@ -276,6 +475,17 @@ files = [ [package.extras] xdg-desktop-portal = ["jeepney"] +[[package]] +name = "docutils" +version = "0.22.3" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.9" +files = [ + {file = "docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb"}, + {file = "docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd"}, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -307,6 +517,29 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + [[package]] name = "iniconfig" version = "2.3.0" @@ -319,118 +552,185 @@ files = [ ] [[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, ] [package.dependencies] -MarkupSafe = ">=2.0" +more-itertools = "*" [package.extras] -i18n = ["Babel (>=2.7)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." +name = "jaraco-context" +version = "6.0.1" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"}, + {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-functools" +version = "4.3.0" +description = "Functools like those found in stdlib" optional = false python-versions = ">=3.9" 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"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, + {file = "jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8"}, + {file = "jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy"] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + +[[package]] +name = "nh3" +version = "0.3.2" +description = "Python binding to Ammonia HTML sanitizer Rust crate" +optional = false +python-versions = ">=3.8" +files = [ + {file = "nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d"}, + {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130"}, + {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868"}, + {file = "nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93"}, + {file = "nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13"}, + {file = "nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80"}, + {file = "nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6"}, + {file = "nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b"}, + {file = "nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe"}, + {file = "nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104"}, + {file = "nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376"}, ] [[package]] @@ -444,6 +744,20 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "pkginfo" +version = "1.10.0" +description = "Query metadata from sdists / bdists / installed packages." +optional = false +python-versions = ">=3.6" +files = [ + {file = "pkginfo-1.10.0-py3-none-any.whl", hash = "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097"}, + {file = "pkginfo-1.10.0.tar.gz", hash = "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297"}, +] + +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + [[package]] name = "pluggy" version = "1.6.0" @@ -459,6 +773,17 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + [[package]] name = "pygments" version = "2.19.2" @@ -489,38 +814,48 @@ desktop-entry-lib = "*" requests = "*" tomli = {version = "*", markers = "python_version < \"3.11\""} +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + [[package]] name = "pytest" -version = "8.4.2" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.7" files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1" -packaging = ">=20" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" -version = "5.0.0" +version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" 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"}, + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] @@ -528,7 +863,18 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] [[package]] name = "pyyaml" @@ -612,6 +958,25 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "readme-renderer" +version = "44.0" +description = "readme_renderer is a library for rendering readme descriptions for Warehouse" +optional = false +python-versions = ">=3.9" +files = [ + {file = "readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151"}, + {file = "readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1"}, +] + +[package.dependencies] +docutils = ">=0.21.2" +nh3 = ">=0.2.14" +Pygments = ">=2.5.1" + +[package.extras] +md = ["cmarkgfm (>=0.8.0)"] + [[package]] name = "requests" version = "2.32.5" @@ -633,6 +998,67 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rfc3986" +version = "2.0.0" +description = "Validating URI References per RFC 3986" +optional = false +python-versions = ">=3.7" +files = [ + {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, + {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, +] + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.10" +files = [ + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + [[package]] name = "tomli" version = "2.3.0" @@ -684,6 +1110,28 @@ files = [ {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] +[[package]] +name = "twine" +version = "5.1.1" +description = "Collection of utilities for publishing packages on PyPI" +optional = false +python-versions = ">=3.8" +files = [ + {file = "twine-5.1.1-py3-none-any.whl", hash = "sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997"}, + {file = "twine-5.1.1.tar.gz", hash = "sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db"}, +] + +[package.dependencies] +importlib-metadata = ">=3.6" +keyring = ">=15.1" +pkginfo = ">=1.8.1,<1.11" +readme-renderer = ">=35.0" +requests = ">=2.20" +requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" +rfc3986 = ">=1.4.0" +rich = ">=12.0.0" +urllib3 = ">=1.26.0" + [[package]] name = "typing-extensions" version = "4.15.0" @@ -697,22 +1145,41 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" 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.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "026c4acd254e889b70bb8c25ffb5e6323eee86380f54f2d8ef02f59ae9307529" +content-hash = "17e97a5516576384aafd227385b42be9178527537a52ab44e8797816534b5193" diff --git a/pyproject.toml b/pyproject.toml index 2d29795..e8609af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [tool.poetry] name = "jinjaturtle" -version = "0.3.3" +version = "0.1.0" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" readme = "README.md" packages = [{ include = "jinjaturtle", from = "src" }] -keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"] +keywords = ["ansible", "jinja2", "config", "toml", "ini", "devops"] homepage = "https://git.mig5.net/mig5/jinjaturtle" repository = "https://git.mig5.net/mig5/jinjaturtle" @@ -16,8 +16,13 @@ repository = "https://git.mig5.net/mig5/jinjaturtle" python = "^3.10" PyYAML = "^6.0" tomli = { version = "^2.0.0", python = "<3.11" } -defusedxml = "^0.7.1" -jinja2 = "^3.1.6" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.0" +pytest-cov = "^4.0" +build = "^1.0" +twine = "^5.0" +pyproject-appimage = "^4.2" [tool.poetry.scripts] jinjaturtle = "jinjaturtle.cli:main" @@ -29,8 +34,3 @@ 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" diff --git a/release.sh b/release.sh index 8133992..7e8521c 100755 --- a/release.sh +++ b/release.sh @@ -2,8 +2,7 @@ set -eo pipefail -# Clean caches etc -filedust -y . +rm -rf dist # Publish to Pypi poetry build @@ -15,30 +14,3 @@ 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 - -# Deb stuff -DISTS=( - debian:bookworm - debian:trixie - ubuntu:jammy - ubuntu:noble -) - -for dist in ${DISTS[@]}; do - release=$(echo ${dist} | cut -d: -f2) - mkdir -p dist/${release} - - docker build -f Dockerfile.debbuild -t jinjaturtle-deb:${release} \ - --no-cache \ - --progress=plain \ - --build-arg BASE_IMAGE=${dist} . - - docker run --rm \ - -e SUITE="${release}" \ - -v "$PWD":/src \ - -v "$PWD/dist/${release}":/out \ - jinjaturtle-deb:${release} - - debfile=$(ls -1 dist/${release}/*.deb) - reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}" -done diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index c222e86..83a4d67 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -2,15 +2,13 @@ from __future__ import annotations import argparse import sys -from defusedxml import defuse_stdlib from pathlib import Path from .core import ( parse_config, - analyze_loops, flatten_config, - generate_ansible_yaml, - generate_jinja2_template, + generate_defaults_yaml, + generate_template, ) @@ -21,7 +19,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: ) ap.add_argument( "config", - help="Path to the source configuration file (TOML, YAML, JSON or INI-style).", + help="Path to the source configuration file (TOML or INI-style).", ) ap.add_argument( "-r", @@ -32,7 +30,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: ap.add_argument( "-f", "--format", - choices=["ini", "json", "toml", "yaml", "xml"], + choices=["ini", "toml"], help="Force config format instead of auto-detecting from filename.", ) ap.add_argument( @@ -49,39 +47,20 @@ def _build_arg_parser() -> argparse.ArgumentParser: def _main(argv: list[str] | None = None) -> int: - defuse_stdlib() parser = _build_arg_parser() args = parser.parse_args(argv) config_path = Path(args.config) - config_text = config_path.read_text(encoding="utf-8") - - # Parse the config fmt, parsed = parse_config(config_path, args.format) - - # Analyze for loops - loop_candidates = analyze_loops(fmt, parsed) - - # 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) - - # Generate template (with loops if detected) - template_str = generate_jinja2_template( - fmt, - parsed, - args.role_name, - original_text=config_text, - loop_candidates=loop_candidates, - ) + flat_items = flatten_config(fmt, parsed) + defaults_yaml = generate_defaults_yaml(args.role_name, flat_items) + template_str = generate_template(fmt, parsed, args.role_name) if args.defaults_output: - Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") + Path(args.defaults_output).write_text(defaults_yaml, encoding="utf-8") else: print("# defaults/main.yml") - print(ansible_yaml, end="") + print(defaults_yaml, end="") if args.template_output: Path(args.template_output).write_text(template_str, encoding="utf-8") @@ -89,11 +68,14 @@ def _main(argv: list[str] | None = None) -> int: print("# config.j2") print(template_str, end="") - return True + return 0 def main() -> None: """ Console-script entry point. + + Defined in pyproject.toml as: + jinjaturtle = jinjaturtle.cli:main """ - _main(sys.argv[1:]) + raise SystemExit(_main(sys.argv[1:])) diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index e4f3d13..8e27bc1 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -1,78 +1,23 @@ from __future__ import annotations +import configparser from pathlib import Path from typing import Any, Iterable -import datetime import yaml -from .loop_analyzer import LoopAnalyzer, LoopCandidate -from .handlers import ( - BaseHandler, - IniHandler, - JsonHandler, - TomlHandler, - YamlHandler, - XmlHandler, -) - - -class QuotedString(str): - """ - Marker type for strings that must be double-quoted in YAML output. - """ - - pass - - -def _fallback_str_representer(dumper: yaml.SafeDumper, data: Any): - """ - Fallback for objects the dumper doesn't know about. - """ - return dumper.represent_scalar("tag:yaml.org,2002:str", str(data)) - - -class _TurtleDumper(yaml.SafeDumper): - """ - Custom YAML dumper that always double-quotes QuotedString values. - """ - - pass - - -def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString): - return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"') - - -_TurtleDumper.add_representer(QuotedString, _quoted_str_representer) -# Use our fallback for any unknown object types -_TurtleDumper.add_representer(None, _fallback_str_representer) - -_HANDLERS: dict[str, BaseHandler] = {} - -_INI_HANDLER = IniHandler() -_JSON_HANDLER = JsonHandler() -_TOML_HANDLER = TomlHandler() -_YAML_HANDLER = YamlHandler() -_XML_HANDLER = XmlHandler() - -_HANDLERS["ini"] = _INI_HANDLER -_HANDLERS["json"] = _JSON_HANDLER -_HANDLERS["toml"] = _TOML_HANDLER -_HANDLERS["yaml"] = _YAML_HANDLER -_HANDLERS["xml"] = _XML_HANDLER - - -def make_var_name(role_prefix: str, path: Iterable[str]) -> str: - """ - Wrapper for :meth:`BaseHandler.make_var_name`. - """ - return BaseHandler.make_var_name(role_prefix, path) +try: + import tomllib # Python 3.11+ +except ModuleNotFoundError: # pragma: no cover + try: + import tomli as tomllib # type: ignore + except ModuleNotFoundError: # pragma: no cover + tomllib = None # type: ignore def detect_format(path: Path, explicit: str | None = None) -> str: """ - Determine config format from argument or filename. + Determine config format (toml vs ini-ish) from argument or filename. """ if explicit: return explicit @@ -80,168 +25,214 @@ def detect_format(path: Path, explicit: str | None = None) -> str: name = path.name.lower() 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" # Fallback: treat as INI-ish return "ini" def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: """ - Parse config file into a Python object. + Parse config file into a Python object: + + TOML -> nested dict + INI -> configparser.ConfigParser """ fmt = detect_format(path, fmt) - handler = _HANDLERS.get(fmt) - if handler is None: - raise ValueError(f"Unsupported config format: {fmt}") - parsed = handler.parse(path) - # Make sure datetime objects are treated as strings (TOML, YAML) - parsed = _stringify_timestamps(parsed) - return fmt, parsed + if fmt == "toml": + if tomllib is None: + raise RuntimeError( + "tomllib/tomli is required to parse TOML files but is not installed" + ) + with path.open("rb") as f: + data = tomllib.load(f) + return fmt, data + + if fmt == "ini": + parser = configparser.ConfigParser() + parser.optionxform = str # preserve key case + with path.open("r", encoding="utf-8") as f: + parser.read_file(f) + return fmt, parser + + raise ValueError(f"Unsupported config format: {fmt}") -def analyze_loops(fmt: str, parsed: Any) -> list[LoopCandidate]: +def flatten_config(fmt: str, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: """ - Analyze parsed config to find loop opportunities. + Flatten parsed config into a list of (path_tuple, value). + + Examples: + TOML: [server.tls] enabled = true + -> (("server", "tls", "enabled"), True) + + INI: [somesection] foo = "bar" + -> (("somesection", "foo"), "bar") + + For INI, values are processed as strings (quotes stripped when obvious). """ - analyzer = LoopAnalyzer() - candidates = analyzer.analyze(parsed, fmt) + items: list[tuple[tuple[str, ...], Any]] = [] - # Filter by confidence threshold - return [c for c in candidates if c.confidence >= LoopAnalyzer.MIN_CONFIDENCE] + if fmt == "toml": + def _walk(obj: Any, path: tuple[str, ...] = ()) -> None: + if isinstance(obj, dict): + for k, v in obj.items(): + _walk(v, path + (str(k),)) + else: + items.append((path, obj)) -def flatten_config( - fmt: str, parsed: Any, loop_candidates: list[LoopCandidate] | None = None -) -> list[tuple[tuple[str, ...], Any]]: - """ - Flatten parsed config into (path, value) pairs. + _walk(parsed) - If loop_candidates is provided, paths within those loops are excluded - from flattening (they'll be handled via loops in the template). - """ - handler = _HANDLERS.get(fmt) - if handler is None: + elif fmt == "ini": + parser: configparser.ConfigParser = parsed + for section in parser.sections(): + for key, value in parser.items(section, raw=True): + raw = value.strip() + # Strip surrounding quotes from INI values for defaults + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}: + processed: Any = raw[1:-1] + else: + processed = raw + items.append(((section, key), processed)) + else: # pragma: no cover raise ValueError(f"Unsupported format: {fmt}") - all_items = handler.flatten(parsed) - - if not loop_candidates: - return all_items - - # Build set of paths to exclude (anything under a loop path) - excluded_prefixes = {candidate.path for candidate in loop_candidates} - - # Filter out items that fall under loop paths - filtered_items = [] - for item_path, value in all_items: - # Check if this path starts with any loop path - is_excluded = False - for loop_path in excluded_prefixes: - if _path_starts_with(item_path, loop_path): - is_excluded = True - break - - if not is_excluded: - filtered_items.append((item_path, value)) - - return filtered_items + return items -def _path_starts_with(path: tuple[str, ...], prefix: tuple[str, ...]) -> bool: - """Check if path starts with prefix.""" - if len(path) < len(prefix): - return False - return path[: len(prefix)] == prefix +def make_var_name(role_prefix: str, path: Iterable[str]) -> str: + """ + Build an Ansible var name like: + role_prefix_section_subsection_key + + Sanitises parts to lowercase [a-z0-9_] and strips extras. + """ + role_prefix = role_prefix.strip().lower() + clean_parts: list[str] = [] + + for part in path: + part = str(part).strip() + part = part.replace(" ", "_") + cleaned_chars: list[str] = [] + for c in part: + if c.isalnum() or c == "_": + cleaned_chars.append(c.lower()) + else: + cleaned_chars.append("_") + cleaned_part = "".join(cleaned_chars).strip("_") + if cleaned_part: + clean_parts.append(cleaned_part) + + if clean_parts: + return role_prefix + "_" + "_".join(clean_parts) + return role_prefix -def generate_ansible_yaml( - role_prefix: str, - flat_items: list[tuple[tuple[str, ...], Any]], - loop_candidates: list[LoopCandidate] | None = None, +def generate_defaults_yaml( + role_prefix: str, flat_items: list[tuple[tuple[str, ...], Any]] ) -> str: """ - Create Ansible YAML for defaults/main.yml. + Create YAML for defaults/main.yml from flattened items. """ defaults: dict[str, Any] = {} - - # Add scalar variables for path, value in flat_items: var_name = make_var_name(role_prefix, path) - defaults[var_name] = value # No normalization - keep original types + defaults[var_name] = value - # Add loop collections - if loop_candidates: - for candidate in loop_candidates: - var_name = make_var_name(role_prefix, candidate.path) - defaults[var_name] = candidate.items - - return yaml.dump( + return yaml.safe_dump( defaults, - Dumper=_TurtleDumper, sort_keys=True, default_flow_style=False, allow_unicode=True, - explicit_start=True, - indent=2, ) -def generate_jinja2_template( - fmt: str, - parsed: Any, - role_prefix: str, - original_text: str | None = None, - loop_candidates: list[LoopCandidate] | None = None, -) -> str: +def _generate_toml_template(role_prefix: str, data: dict[str, Any]) -> str: """ - Generate a Jinja2 template for the config. + Generate a TOML Jinja2 template from parsed TOML dict. + + Values become Jinja placeholders, with quoting preserved for strings: + foo = "bar" -> foo = "{{ prefix_foo }}" + port = 8080 -> port = {{ prefix_port }} """ - handler = _HANDLERS.get(fmt) + lines: list[str] = [] - if handler is None: - raise ValueError(f"Unsupported format: {fmt}") + def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: + var_name = make_var_name(role_prefix, path + (key,)) + if isinstance(value, str): + lines.append(f'{key} = "{{{{ {var_name} }}}}"') + else: + lines.append(f"{key} = {{{{ {var_name} }}}}") - # 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( - parsed, role_prefix, original_text, loop_candidates - ) + 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)} + nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} - # Fallback to original scalar-only generation - return handler.generate_jinja2_template( - parsed, role_prefix, original_text=original_text - ) + if path: + header = ".".join(path) + lines.append(f"[{header}]") + + for key, val in scalar_items.items(): + emit_kv(path, str(key), val) + + if scalar_items: + lines.append("") + + for key, val in nested_items.items(): + walk(val, path + (str(key),)) + + # Root scalars (no table header) + root_scalars = {k: v for k, v in data.items() if not isinstance(v, dict)} + for key, val in root_scalars.items(): + emit_kv((), str(key), val) + if root_scalars: + lines.append("") + + # Tables + for key, val in data.items(): + if isinstance(val, dict): + walk(val, (str(key),)) + + return "\n".join(lines).rstrip() + "\n" -def _stringify_timestamps(obj: Any) -> Any: +def _generate_ini_template(role_prefix: str, parser: configparser.ConfigParser) -> str: """ - Recursively walk a parsed config and turn any datetime/date/time objects - into plain strings in ISO-8601 form. + Generate an INI-style Jinja2 template from a ConfigParser. - This prevents Python datetime objects from leaking into YAML/Jinja, which - would otherwise reformat the value (e.g. replacing 'T' with a space). - - This commonly occurs otherwise with TOML and YAML files, which sees - Python automatically convert those sorts of strings into datetime objects. + Quoting heuristic: + foo = "bar" -> foo = "{{ prefix_section_foo }}" + num = 42 -> num = {{ prefix_section_num }} """ - if isinstance(obj, dict): - return {k: _stringify_timestamps(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_stringify_timestamps(v) for v in obj] + lines: list[str] = [] - # TOML & YAML both use the standard datetime types - if isinstance(obj, datetime.datetime): - # Use default ISO-8601: 'YYYY-MM-DDTHH:MM:SS±HH:MM' (with 'T') - return obj.isoformat() - if isinstance(obj, (datetime.date, datetime.time)): - return obj.isoformat() + for section in parser.sections(): + lines.append(f"[{section}]") + for key, value in parser.items(section, raw=True): + path = (section, key) + var_name = 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} }}}}"') + else: + lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append("") - return obj + return "\n".join(lines).rstrip() + "\n" + + +def generate_template(fmt: str, parsed: Any, role_prefix: str) -> str: + """ + Dispatch to the appropriate template generator. + """ + if fmt == "toml": + if not isinstance(parsed, dict): + raise TypeError("TOML parser result must be a dict") + return _generate_toml_template(role_prefix, parsed) + if fmt == "ini": + if not isinstance(parsed, configparser.ConfigParser): + raise TypeError("INI parser result must be a ConfigParser") + return _generate_ini_template(role_prefix, parsed) + raise ValueError(f"Unsupported format: {fmt}") diff --git a/src/jinjaturtle/handlers/__init__.py b/src/jinjaturtle/handlers/__init__.py deleted file mode 100644 index 6bbcba1..0000000 --- a/src/jinjaturtle/handlers/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from .base import BaseHandler -from .dict import DictLikeHandler -from .ini import IniHandler -from .json import JsonHandler -from .toml import TomlHandler -from .yaml import YamlHandler -from .xml import XmlHandler - -__all__ = [ - "BaseHandler", - "DictLikeHandler", - "IniHandler", - "JsonHandler", - "TomlHandler", - "YamlHandler", - "XmlHandler", -] diff --git a/src/jinjaturtle/handlers/base.py b/src/jinjaturtle/handlers/base.py deleted file mode 100644 index 14aaec7..0000000 --- a/src/jinjaturtle/handlers/base.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any, Iterable - - -class BaseHandler: - """ - Base class for a config format handler. - - Each handler is responsible for: - - parse(path) -> parsed object - - flatten(parsed) -> list[(path_tuple, value)] - - generate_jinja2_template(parsed, role_prefix, original_text=None) -> str - """ - - fmt: str # e.g. "ini", "yaml", ... - - def parse(self, path: Path) -> Any: - raise NotImplementedError - - def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: - raise NotImplementedError - - def generate_jinja2_template( - self, - parsed: Any, - role_prefix: str, - original_text: str | None = None, - ) -> str: - raise NotImplementedError - - def _split_inline_comment( - self, text: str, comment_chars: set[str] - ) -> tuple[str, str]: - """ - Split 'value # comment' into (value_part, comment_part), where - comment_part starts at the first unquoted comment character. - - comment_chars is e.g. {'#'} for TOML/YAML, {'#', ';'} for INI. - """ - in_single = False - in_double = False - for i, ch in enumerate(text): - if ch == "'" and not in_double: - in_single = not in_single - elif ch == '"' and not in_single: - in_double = not in_double - elif ch in comment_chars and not in_single and not in_double: - return text[:i], text[i:] - return text, "" - - @staticmethod - def make_var_name(role_prefix: str, path: Iterable[str]) -> str: - """ - Build an Ansible var name like: - role_prefix_section_subsection_key - - Sanitises parts to lowercase [a-z0-9_] and strips extras. - """ - role_prefix = role_prefix.strip().lower() - clean_parts: list[str] = [] - - for part in path: - part = str(part).strip() - part = part.replace(" ", "_") - cleaned_chars: list[str] = [] - for c in part: - if c.isalnum() or c == "_": - cleaned_chars.append(c.lower()) - else: - cleaned_chars.append("_") - cleaned_part = "".join(cleaned_chars).strip("_") - if cleaned_part: - clean_parts.append(cleaned_part) - - if clean_parts: - return role_prefix + "_" + "_".join(clean_parts) - return role_prefix diff --git a/src/jinjaturtle/handlers/dict.py b/src/jinjaturtle/handlers/dict.py deleted file mode 100644 index eb8d926..0000000 --- a/src/jinjaturtle/handlers/dict.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from . import BaseHandler - - -class DictLikeHandler(BaseHandler): - """ - Base for TOML/YAML/JSON: nested dict/list structures. - - Subclasses control whether lists are flattened. - """ - - flatten_lists: bool = False # override in subclasses - - def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: - items: list[tuple[tuple[str, ...], Any]] = [] - - def _walk(obj: Any, path: tuple[str, ...] = ()) -> None: - if isinstance(obj, dict): - for k, v in obj.items(): - _walk(v, path + (str(k),)) - elif isinstance(obj, list) and self.flatten_lists: - for i, v in enumerate(obj): - _walk(v, path + (str(i),)) - else: - items.append((path, obj)) - - _walk(parsed) - return items diff --git a/src/jinjaturtle/handlers/ini.py b/src/jinjaturtle/handlers/ini.py deleted file mode 100644 index ad92b72..0000000 --- a/src/jinjaturtle/handlers/ini.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -import configparser -from pathlib import Path -from typing import Any - -from . import BaseHandler - - -class IniHandler(BaseHandler): - fmt = "ini" - - def parse(self, path: Path) -> configparser.ConfigParser: - parser = configparser.ConfigParser() - parser.optionxform = str # noqa - with path.open("r", encoding="utf-8") as f: - parser.read_file(f) - return parser - - def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: - if not isinstance(parsed, configparser.ConfigParser): - raise TypeError("INI parser result must be a ConfigParser") - parser: configparser.ConfigParser = parsed - items: list[tuple[tuple[str, ...], Any]] = [] - for section in parser.sections(): - for key, value in parser.items(section, raw=True): - raw = value.strip() - if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}: - processed: Any = raw[1:-1] - else: - processed = raw - items.append(((section, key), processed)) - return items - - def generate_jinja2_template( - self, - parsed: Any, - role_prefix: str, - original_text: str | None = None, - ) -> str: - if original_text is not None: - return self._generate_ini_template_from_text(role_prefix, original_text) - if not isinstance(parsed, configparser.ConfigParser): - raise TypeError("INI parser result must be a ConfigParser") - return self._generate_ini_template(role_prefix, parsed) - - def _generate_ini_template( - self, role_prefix: str, parser: configparser.ConfigParser - ) -> str: - """ - Generate an INI-style Jinja2 template from a ConfigParser. - - Quoting heuristic: - foo = "bar" -> foo = "{{ prefix_section_foo }}" - num = 42 -> num = {{ prefix_section_num }} - """ - lines: list[str] = [] - - for section in parser.sections(): - lines.append(f"[{section}]") - for key, value in parser.items(section, raw=True): - path = (section, key) - 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} }}}}"') - else: - lines.append(f"{key} = {{{{ {var_name} }}}}") - lines.append("") - - return "\n".join(lines).rstrip() + "\n" - - def _generate_ini_template_from_text(self, role_prefix: str, text: str) -> str: - """ - Generate a Jinja2 template for an INI/php.ini-style file, preserving - comments, blank lines, and section headers by patching values in-place. - """ - lines = text.splitlines(keepends=True) - current_section: str | None = None - out_lines: list[str] = [] - - for raw_line in lines: - line = raw_line - stripped = line.lstrip() - - # Blank or pure comment: keep as-is - if not stripped or stripped[0] in {"#", ";"}: - out_lines.append(raw_line) - continue - - # Section header - if stripped.startswith("[") and "]" in stripped: - header_inner = stripped[1 : stripped.index("]")] - current_section = header_inner.strip() - out_lines.append(raw_line) - continue - - # Work without newline so we can re-attach it exactly - newline = "" - content = raw_line - if content.endswith("\r\n"): - newline = "\r\n" - content = content[:-2] - elif content.endswith("\n"): - newline = content[-1] - content = content[:-1] - - eq_index = content.find("=") - if eq_index == -1: - # Not a simple key=value line: leave untouched - out_lines.append(raw_line) - continue - - before_eq = content[:eq_index] - after_eq = content[eq_index + 1 :] - - key = before_eq.strip() - if not key: - out_lines.append(raw_line) - 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, {"#", ";"} - ) - raw_value = value_part.strip() - - path = (key,) if current_section is None else (current_section, key) - var_name = self.make_var_name(role_prefix, path) - - # Was the original value quoted? - use_quotes = ( - len(raw_value) >= 2 - and raw_value[0] == raw_value[-1] - and raw_value[0] in {'"', "'"} - ) - - if use_quotes: - quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" - else: - replacement_value = f"{{{{ {var_name} }}}}" - - new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part - ) - out_lines.append(new_content + newline) - - return "".join(out_lines) diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py deleted file mode 100644 index 035efdc..0000000 --- a/src/jinjaturtle/handlers/json.py +++ /dev/null @@ -1,191 +0,0 @@ -from __future__ import annotations - -import json -import re -from pathlib import Path -from typing import Any - -from . import DictLikeHandler -from ..loop_analyzer import LoopCandidate - - -class JsonHandler(DictLikeHandler): - fmt = "json" - flatten_lists = True - - def parse(self, path: Path) -> Any: - with path.open("r", encoding="utf-8") as f: - return json.load(f) - - def generate_jinja2_template( - self, - parsed: Any, - role_prefix: str, - original_text: str | None = None, - ) -> str: - """Original scalar-only template generation.""" - 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 - return self._generate_json_template(role_prefix, parsed) - - def generate_jinja2_template_with_loops( - self, - parsed: Any, - role_prefix: str, - original_text: str | None, - loop_candidates: list[LoopCandidate], - ) -> str: - """Generate template with Jinja2 for loops where appropriate.""" - if not isinstance(parsed, (dict, list)): - raise TypeError("JSON parser result must be a dict or list") - - # Build loop path set for quick lookup - loop_paths = {candidate.path for candidate in loop_candidates} - - return self._generate_json_template_with_loops( - role_prefix, parsed, loop_paths, loop_candidates - ) - - def _generate_json_template(self, role_prefix: str, data: Any) -> str: - """ - Generate a JSON Jinja2 template from parsed JSON data. - - 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). - """ - - 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()} - 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 - 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 - # 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 - ) - - return json_str + "\n" - - def _generate_json_template_with_loops( - self, - role_prefix: str, - data: Any, - loop_paths: set[tuple[str, ...]], - loop_candidates: list[LoopCandidate], - path: tuple[str, ...] = (), - ) -> str: - """ - Generate a JSON Jinja2 template with for loops where appropriate. - """ - - def _walk(obj: Any, current_path: tuple[str, ...] = ()) -> Any: - # Check if this path is a loop candidate - if current_path in loop_paths: - # Find the matching candidate - candidate = next(c for c in loop_candidates if c.path == current_path) - collection_var = self.make_var_name(role_prefix, candidate.path) - item_var = candidate.loop_var - - if candidate.item_schema == "scalar": - # Simple list of scalars - use special marker that we'll replace - return f"__LOOP_SCALAR__{collection_var}__{item_var}__" - elif candidate.item_schema in ("simple_dict", "nested"): - # List of dicts - use special marker - 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()} - if isinstance(obj, list): - # Check if this list is a loop candidate - if current_path in loop_paths: - # Already handled above - return _walk(obj, current_path) - return [_walk(v, current_path + (str(i),)) for i, v in enumerate(obj)] - - # scalar - use marker to preserve type - var_name = self.make_var_name(role_prefix, current_path) - return f"__SCALAR__{var_name}__" - - templated = _walk(data, path) - - # Convert to JSON string - json_str = json.dumps(templated, indent=2, ensure_ascii=False) - - # Replace scalar markers with Jinja expressions using tojson filter - json_str = re.sub( - r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str - ) - - # Post-process to replace loop markers with actual Jinja loops - for candidate in loop_candidates: - collection_var = self.make_var_name(role_prefix, candidate.path) - item_var = candidate.loop_var - - if candidate.item_schema == "scalar": - # Replace scalar loop marker with Jinja for loop - marker = f'"__LOOP_SCALAR__{collection_var}__{item_var}__"' - replacement = self._generate_json_scalar_loop( - collection_var, item_var, candidate - ) - json_str = json_str.replace(marker, replacement) - - elif candidate.item_schema in ("simple_dict", "nested"): - # Replace dict loop marker with Jinja for loop - marker = f'"__LOOP_DICT__{collection_var}__{item_var}__"' - replacement = self._generate_json_dict_loop( - collection_var, item_var, candidate - ) - json_str = json_str.replace(marker, replacement) - - return json_str + "\n" - - def _generate_json_scalar_loop( - self, collection_var: str, item_var: str, candidate: LoopCandidate - ) -> str: - """Generate a Jinja for loop for a scalar list in JSON.""" - # Use tojson filter to properly handle strings (quotes them) and other types - # Include array brackets around the loop - return ( - f"[{{% for {item_var} in {collection_var} %}}" - f"{{{{ {item_var} | tojson }}}}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}]" - ) - - def _generate_json_dict_loop( - self, collection_var: str, item_var: str, candidate: LoopCandidate - ) -> str: - """Generate a Jinja for loop for a dict list in JSON.""" - if not candidate.items: - return "[]" - - # Get first item as template - sample_item = candidate.items[0] - - # Build the dict template - use tojson for all values to handle types correctly - fields = [] - for key, value in sample_item.items(): - if key == "_key": - continue - # Use tojson filter to properly serialize all types (strings, numbers, booleans) - fields.append(f'"{key}": {{{{ {item_var}.{key} | tojson }}}}') - - dict_template = "{" + ", ".join(fields) + "}" - - return ( - f"{{% for {item_var} in {collection_var} %}}" - f"{dict_template}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}" - ) diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py deleted file mode 100644 index fe071bd..0000000 --- a/src/jinjaturtle/handlers/toml.py +++ /dev/null @@ -1,547 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from . import DictLikeHandler -from ..loop_analyzer import LoopCandidate - -try: - import tomllib -except Exception: - import tomli as tomllib - - -class TomlHandler(DictLikeHandler): - fmt = "toml" - flatten_lists = False # keep lists as scalars - - def parse(self, path: Path) -> Any: - if tomllib is None: - raise RuntimeError( - "tomllib/tomli is required to parse TOML files but is not installed" - ) - with path.open("rb") as f: - return tomllib.load(f) - - def generate_jinja2_template( - self, - parsed: Any, - role_prefix: str, - original_text: str | None = None, - ) -> str: - """Original scalar-only template generation.""" - if original_text is not None: - return self._generate_toml_template_from_text(role_prefix, original_text) - if not isinstance(parsed, dict): - raise TypeError("TOML parser result must be a dict") - return self._generate_toml_template(role_prefix, parsed) - - def generate_jinja2_template_with_loops( - self, - parsed: Any, - role_prefix: str, - original_text: str | None, - loop_candidates: list[LoopCandidate], - ) -> str: - """Generate template with Jinja2 for loops where appropriate.""" - if original_text is not None: - return self._generate_toml_template_with_loops_from_text( - role_prefix, original_text, loop_candidates - ) - if not isinstance(parsed, dict): - raise TypeError("TOML parser result must be a dict") - return self._generate_toml_template_with_loops( - role_prefix, parsed, loop_candidates - ) - - def _generate_toml_template(self, role_prefix: str, data: dict[str, Any]) -> str: - """ - Generate a TOML Jinja2 template from parsed TOML dict. - - Values become Jinja placeholders, with quoting preserved for strings: - foo = "bar" -> foo = "{{ prefix_foo }}" - port = 8080 -> port = {{ prefix_port }} - """ - lines: list[str] = [] - - 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} }}}}"') - elif isinstance(value, bool): - # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append(f"{key} = {{{{ {var_name} | lower }}}}") - else: - lines.append(f"{key} = {{{{ {var_name} }}}}") - - 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)} - nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} - - if path: - header = ".".join(path) - lines.append(f"[{header}]") - - for key, val in scalar_items.items(): - emit_kv(path, str(key), val) - - if scalar_items: - lines.append("") - - for key, val in nested_items.items(): - walk(val, path + (str(key),)) - - # Root scalars (no table header) - root_scalars = {k: v for k, v in data.items() if not isinstance(v, dict)} - for key, val in root_scalars.items(): - emit_kv((), str(key), val) - if root_scalars: - lines.append("") - - # Tables - for key, val in data.items(): - if isinstance(val, dict): - walk(val, (str(key),)) - - return "\n".join(lines).rstrip() + "\n" - - def _generate_toml_template_with_loops( - self, - role_prefix: str, - data: dict[str, Any], - loop_candidates: list[LoopCandidate], - ) -> str: - """ - Generate a TOML Jinja2 template with for loops where appropriate. - """ - lines: list[str] = [] - loop_paths = {candidate.path for candidate in loop_candidates} - - 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} }}}}"') - elif isinstance(value, bool): - # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append(f"{key} = {{{{ {var_name} | lower }}}}") - elif isinstance(value, list): - # Check if this list is a loop candidate - if path + (key,) in loop_paths: - # Find the matching candidate - candidate = next( - c for c in loop_candidates if c.path == path + (key,) - ) - collection_var = self.make_var_name(role_prefix, candidate.path) - item_var = candidate.loop_var - - if candidate.item_schema == "scalar": - # 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"]" - ) - 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 }}}}") - else: - # Not a loop, treat as regular variable - lines.append(f"{key} = {{{{ {var_name} }}}}") - else: - lines.append(f"{key} = {{{{ {var_name} }}}}") - - 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)} - nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} - - if path: - header = ".".join(path) - lines.append(f"[{header}]") - - for key, val in scalar_items.items(): - emit_kv(path, str(key), val) - - if scalar_items: - lines.append("") - - for key, val in nested_items.items(): - walk(val, path + (str(key),)) - - # Root scalars (no table header) - root_scalars = {k: v for k, v in data.items() if not isinstance(v, dict)} - for key, val in root_scalars.items(): - emit_kv((), str(key), val) - if root_scalars: - lines.append("") - - # Tables - for key, val in data.items(): - if isinstance(val, dict): - walk(val, (str(key),)) - - return "\n".join(lines).rstrip() + "\n" - - def _generate_toml_template_from_text(self, role_prefix: str, text: str) -> str: - """ - Generate a Jinja2 template for a TOML file, preserving comments, - blank lines, and table headers by patching values in-place. - - Handles inline tables like: - temp_targets = { cpu = 79.5, case = 72.0 } - - by mapping them to: - temp_targets = { cpu = {{ prefix_database_temp_targets_cpu }}, - case = {{ prefix_database_temp_targets_case }} } - """ - lines = text.splitlines(keepends=True) - current_table: tuple[str, ...] = () - out_lines: list[str] = [] - - for raw_line in lines: - line = raw_line - stripped = line.lstrip() - - # Blank or pure comment - if not stripped or stripped.startswith("#"): - out_lines.append(raw_line) - continue - - # Table header: [server] or [server.tls] or [[array.of.tables]] - if stripped.startswith("[") and "]" in stripped: - header = stripped - first_bracket = header.find("[") - closing_bracket = header.find("]", first_bracket + 1) - if first_bracket != -1 and closing_bracket != -1: - inner = header[first_bracket + 1 : closing_bracket].strip() - inner = inner.strip("[]") # handle [[table]] as well - parts = [p.strip() for p in inner.split(".") if p.strip()] - current_table = tuple(parts) - out_lines.append(raw_line) - continue - - # Try key = value - newline = "" - content = raw_line - if content.endswith("\r\n"): - newline = "\r\n" - content = content[:-2] - elif content.endswith("\n"): - newline = content[-1] - content = content[:-1] - - eq_index = content.find("=") - if eq_index == -1: - out_lines.append(raw_line) - continue - - before_eq = content[:eq_index] - after_eq = content[eq_index + 1 :] - - key = before_eq.strip() - if not key: - out_lines.append(raw_line) - 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, {"#"} - ) - raw_value = value_part.strip() - - # Path for this key (table + key) - path = current_table + (key,) - - # Special case: inline table - if ( - raw_value.startswith("{") - and raw_value.endswith("}") - and tomllib is not None - ): - try: - # Parse the inline table as a tiny TOML document - mini_source = "table = " + raw_value + "\n" - mini_data = tomllib.loads(mini_source)["table"] - except Exception: - mini_data = None - - if isinstance(mini_data, dict): - inner_bits: list[str] = [] - for sub_key, sub_val in mini_data.items(): - 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} }}}}"') - elif isinstance(sub_val, bool): - inner_bits.append( - f"{sub_key} = {{{{ {nested_var} | lower }}}}" - ) - else: - inner_bits.append(f"{sub_key} = {{{ {nested_var} }}}") - replacement_value = "{ " + ", ".join(inner_bits) + " }" - new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part - ) - out_lines.append(new_content + newline) - continue - # If parsing fails, fall through to normal handling - - # Normal scalar value handling (including bools, numbers, strings) - var_name = self.make_var_name(role_prefix, path) - use_quotes = ( - len(raw_value) >= 2 - and raw_value[0] == raw_value[-1] - and raw_value[0] in {'"', "'"} - ) - - # Check if value is a boolean in the text - is_bool = raw_value.strip().lower() in ("true", "false") - - if use_quotes: - quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" - elif is_bool: - replacement_value = f"{{{{ {var_name} | lower }}}}" - else: - replacement_value = f"{{{{ {var_name} }}}}" - - new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part - ) - out_lines.append(new_content + newline) - - return "".join(out_lines) - - def _generate_toml_template_with_loops_from_text( - self, role_prefix: str, text: str, loop_candidates: list[LoopCandidate] - ) -> str: - """ - Generate a Jinja2 template for a TOML file with loop support. - """ - loop_paths = {candidate.path for candidate in loop_candidates} - lines = text.splitlines(keepends=True) - current_table: tuple[str, ...] = () - out_lines: list[str] = [] - skip_until_next_table = ( - False # Track when we're inside a looped array-of-tables - ) - - for raw_line in lines: - line = raw_line - stripped = line.lstrip() - - # Blank or pure comment - if not stripped or stripped.startswith("#"): - # Only output if we're not skipping - if not skip_until_next_table: - out_lines.append(raw_line) - continue - - # Table header: [server] or [server.tls] or [[array.of.tables]] - if stripped.startswith("[") and "]" in stripped: - header = stripped - # Check if it's array-of-tables ([[name]]) or regular table ([name]) - is_array_table = header.startswith("[[") and "]]" in header - - if is_array_table: - # Extract content between [[ and ]] - start = header.find("[[") + 2 - end = header.find("]]", start) - inner = header[start:end].strip() if end != -1 else "" - else: - # Extract content between [ and ] - start = header.find("[") + 1 - end = header.find("]", start) - inner = header[start:end].strip() if end != -1 else "" - - if inner: - parts = [p.strip() for p in inner.split(".") if p.strip()] - table_path = tuple(parts) - - # Check if this is an array-of-tables that's a loop candidate - if is_array_table and table_path in loop_paths: - # If we're already skipping this table, this is a subsequent occurrence - if skip_until_next_table and current_table == table_path: - # This is a duplicate [[table]] - skip it - continue - - # This is the first occurrence - generate the loop - current_table = table_path - candidate = next( - c for c in loop_candidates if c.path == table_path - ) - - # Generate the loop header - collection_var = self.make_var_name(role_prefix, candidate.path) - item_var = candidate.loop_var - - # Get sample item to build template - if candidate.items: - sample_item = candidate.items[0] - - # Build loop - out_lines.append( - f"{{% for {item_var} in {collection_var} %}}\n" - ) - out_lines.append(f"[[{'.'.join(table_path)}]]\n") - - # Add fields from sample item - for key, value in sample_item.items(): - if key == "_key": - continue - if isinstance(value, str): - out_lines.append( - f'{key} = "{{{{ {item_var}.{key} }}}}"\n' - ) - else: - out_lines.append( - f"{key} = {{{{ {item_var}.{key} }}}}\n" - ) - - out_lines.append("{% endfor %}\n") - - # Skip all content until the next different table - skip_until_next_table = True - continue - else: - # Regular table or non-loop array - reset skip flag if it's a different table - if current_table != table_path: - skip_until_next_table = False - current_table = table_path - - out_lines.append(raw_line) - continue - - # If we're inside a skipped array-of-tables section, skip this line - if skip_until_next_table: - continue - - # Try key = value - newline = "" - content = raw_line - if content.endswith("\r\n"): - newline = "\r\n" - content = content[:-2] - elif content.endswith("\n"): - newline = content[-1] - content = content[:-1] - - eq_index = content.find("=") - if eq_index == -1: - out_lines.append(raw_line) - continue - - before_eq = content[:eq_index] - after_eq = content[eq_index + 1 :] - - key = before_eq.strip() - if not key: - out_lines.append(raw_line) - 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, {"#"} - ) - raw_value = value_part.strip() - - # Path for this key (table + key) - path = current_table + (key,) - - # Check if this path is a loop candidate - if path in loop_paths: - candidate = next(c for c in loop_candidates if c.path == path) - collection_var = self.make_var_name(role_prefix, candidate.path) - item_var = candidate.loop_var - - if candidate.item_schema == "scalar": - # 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"]" - ) - else: - # Dict/nested loop - use tojson filter for complex arrays - replacement_value = f"{{{{ {collection_var} | tojson }}}}" - - new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part - ) - out_lines.append(new_content + newline) - continue - - # Special case: inline table - if ( - raw_value.startswith("{") - and raw_value.endswith("}") - and tomllib is not None - ): - try: - # Parse the inline table as a tiny TOML document - mini_source = "table = " + raw_value + "\n" - mini_data = tomllib.loads(mini_source)["table"] - except Exception: - mini_data = None - - if isinstance(mini_data, dict): - inner_bits: list[str] = [] - for sub_key, sub_val in mini_data.items(): - 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} }}}}"') - elif isinstance(sub_val, bool): - inner_bits.append( - f"{sub_key} = {{{{ {nested_var} | lower }}}}" - ) - else: - inner_bits.append(f"{sub_key} = {{{{ {nested_var} }}}}") - replacement_value = "{ " + ", ".join(inner_bits) + " }" - new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part - ) - out_lines.append(new_content + newline) - continue - # If parsing fails, fall through to normal handling - - # Normal scalar value handling (including bools, numbers, strings) - var_name = self.make_var_name(role_prefix, path) - use_quotes = ( - len(raw_value) >= 2 - and raw_value[0] == raw_value[-1] - and raw_value[0] in {'"', "'"} - ) - - # Check if value is a boolean in the text - is_bool = raw_value.strip().lower() in ("true", "false") - - if use_quotes: - quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" - elif is_bool: - replacement_value = f"{{{{ {var_name} | lower }}}}" - else: - replacement_value = f"{{{{ {var_name} }}}}" - - new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part - ) - out_lines.append(new_content + newline) - - return "".join(out_lines) diff --git a/src/jinjaturtle/handlers/xml.py b/src/jinjaturtle/handlers/xml.py deleted file mode 100644 index fed6aba..0000000 --- a/src/jinjaturtle/handlers/xml.py +++ /dev/null @@ -1,459 +0,0 @@ -from __future__ import annotations - -from collections import Counter, defaultdict -from pathlib import Path -from typing import Any -import xml.etree.ElementTree as ET # nosec - -from .base import BaseHandler -from ..loop_analyzer import LoopCandidate - - -class XmlHandler(BaseHandler): - """ - XML handler that can generate both scalar templates and loop-based templates. - """ - - fmt = "xml" - - 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() - return root - - def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: - if not isinstance(parsed, ET.Element): - raise TypeError("XML parser result must be an Element") - return self._flatten_xml(parsed) - - def generate_jinja2_template( - self, - parsed: Any, - role_prefix: str, - original_text: str | None = None, - ) -> str: - """Original scalar-only template generation.""" - if original_text is not None: - return self._generate_xml_template_from_text(role_prefix, original_text) - if not isinstance(parsed, ET.Element): - raise TypeError("XML parser result must be an Element") - xml_str = ET.tostring(parsed, encoding="unicode") - return self._generate_xml_template_from_text(role_prefix, xml_str) - - def generate_jinja2_template_with_loops( - self, - parsed: Any, - role_prefix: str, - original_text: str | None, - loop_candidates: list[LoopCandidate], - ) -> str: - """Generate template with Jinja2 for loops where appropriate.""" - - if original_text is not None: - return self._generate_xml_template_with_loops_from_text( - role_prefix, original_text, loop_candidates - ) - - if not isinstance(parsed, ET.Element): - raise TypeError("XML parser result must be an Element") - - xml_str = ET.tostring(parsed, encoding="unicode") - return self._generate_xml_template_with_loops_from_text( - role_prefix, xml_str, loop_candidates - ) - - def _flatten_xml(self, root: ET.Element) -> list[tuple[tuple[str, ...], Any]]: - """Flatten an XML tree into (path, value) pairs.""" - items: list[tuple[tuple[str, ...], Any]] = [] - - def walk(elem: ET.Element, path: tuple[str, ...]) -> None: - # Attributes - for attr_name, attr_val in elem.attrib.items(): - attr_path = path + (f"@{attr_name}",) - items.append((attr_path, attr_val)) - - # Children - children = [c for c in list(elem) if isinstance(c.tag, str)] - - # Text content - text = (elem.text or "").strip() - if text: - if not elem.attrib and not children: - items.append((path, text)) - else: - items.append((path + ("value",), text)) - - # Repeated siblings get an index; singletons just use the tag - counts = Counter(child.tag for child in children) - index_counters: dict[str, int] = defaultdict(int) - - for child in children: - tag = child.tag - if counts[tag] > 1: - idx = index_counters[tag] - index_counters[tag] += 1 - child_path = path + (tag, str(idx)) - else: - child_path = path + (tag,) - walk(child, child_path) - - walk(root, ()) - return items - - def _split_xml_prolog(self, text: str) -> tuple[str, str]: - """Split XML into (prolog, body).""" - i = 0 - n = len(text) - prolog_parts: list[str] = [] - - while i < n: - while i < n and text[i].isspace(): - prolog_parts.append(text[i]) - i += 1 - if i >= n: - break - - if text.startswith("", i + 2) - if end == -1: - break - prolog_parts.append(text[i : end + 2]) - i = end + 2 - continue - - if text.startswith("", i + 4) - if end == -1: - break - prolog_parts.append(text[i : end + 3]) - i = end + 3 - continue - - if text.startswith("", i + 9) - if end == -1: - break - prolog_parts.append(text[i : end + 1]) - i = end + 1 - continue - - if text[i] == "<": - break - - break - - return "".join(prolog_parts), text[i:] - - def _apply_jinja_to_xml_tree( - self, - role_prefix: str, - root: ET.Element, - loop_candidates: list[LoopCandidate] | None = None, - ) -> None: - """ - Mutate XML tree in-place, replacing values with Jinja expressions. - - If loop_candidates is provided, repeated elements matching a candidate - will be replaced with a {% for %} loop. - """ - - # Build a map of loop paths for quick lookup - loop_paths = {} - if loop_candidates: - for candidate in loop_candidates: - loop_paths[candidate.path] = candidate - - def walk(elem: ET.Element, path: tuple[str, ...]) -> None: - # Attributes (unless this element is in a loop) - 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} }}}}") - - # Children - children = [c for c in list(elem) if isinstance(c.tag, str)] - - # Text content - text = (elem.text or "").strip() - if text: - if not elem.attrib and not children: - text_path = path - else: - text_path = path + ("value",) - var_name = self.make_var_name(role_prefix, text_path) - elem.text = f"{{{{ {var_name} }}}}" - - # Handle children - check for loops first - counts = Counter(child.tag for child in children) - index_counters: dict[str, int] = defaultdict(int) - - # Check each tag to see if it's a loop candidate - processed_tags = set() - - for child in children: - tag = child.tag - - # Skip if we've already processed this tag as a loop - if tag in processed_tags: - continue - - child_path = path + (tag,) - - # Check if this is a loop candidate - if child_path in loop_paths: - # Mark this tag as processed - processed_tags.add(tag) - - # Remove all children with this tag - for child_to_remove in [c for c in children if c.tag == tag]: - elem.remove(child_to_remove) - - # Create a loop comment/marker - # We'll handle the actual loop generation in text processing - loop_marker = ET.Comment(f"LOOP:{tag}") - elem.append(loop_marker) - - elif counts[tag] > 1: - # Multiple children but not a loop candidate - use indexed paths - idx = index_counters[tag] - index_counters[tag] += 1 - indexed_path = path + (tag, str(idx)) - walk(child, indexed_path) - else: - # Single child - walk(child, child_path) - - walk(root, ()) - - 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) - - parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) # nosec B314 - parser.feed(body) - root = parser.close() - - self._apply_jinja_to_xml_tree(role_prefix, 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 - - def _generate_xml_template_with_loops_from_text( - self, - role_prefix: str, - text: str, - loop_candidates: list[LoopCandidate], - ) -> str: - """Generate Jinja2 template with for loops.""" - - prolog, body = self._split_xml_prolog(text) - - # Parse with comments preserved - parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) # nosec B314 - parser.feed(body) - root = parser.close() - - # Apply Jinja transformations (including loop markers) - self._apply_jinja_to_xml_tree(role_prefix, root, loop_candidates) - - # Convert to string - indent = getattr(ET, "indent", None) - if indent is not None: - indent(root, space=" ") # type: ignore[arg-type] - - xml_body = ET.tostring(root, encoding="unicode") - - # 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 - - def _insert_xml_loops( - self, - xml_str: str, - role_prefix: str, - loop_candidates: list[LoopCandidate], - root: ET.Element, - ) -> str: - """ - Post-process XML string to insert Jinja2 for loops. - - This replaces markers with actual loop constructs. - """ - - # Build a sample element for each loop to use as template - lines = xml_str.split("\n") - result_lines = [] - - for line in lines: - # Check if this line contains a loop marker - if "", start) - tag_name = line[start:end].strip() - - # Find matching loop candidate - candidate = None - for cand in loop_candidates: - if cand.path and cand.path[-1] == tag_name: - candidate = cand - break - - if candidate: - # Get indentation from current line - indent_level = len(line) - len(line.lstrip()) - indent_str = " " * indent_level - - # Generate loop variable name - collection_var = self.make_var_name(role_prefix, candidate.path) - item_var = candidate.loop_var - - # Create sample element with ALL possible fields from ALL items - if candidate.items: - # Merge all items to get the union of all fields - merged_dict = self._merge_dicts_for_template(candidate.items) - - sample_elem = self._dict_to_xml_element( - tag_name, merged_dict, item_var - ) - - # Apply indentation to the sample element - ET.indent(sample_elem, space=" ") - - # Convert sample to string - sample_str = ET.tostring( - sample_elem, encoding="unicode" - ).strip() - - # Add proper indentation to each line of the sample - sample_lines = sample_str.split("\n") - - # Build loop - result_lines.append( - f"{indent_str}{{% for {item_var} in {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 %}}") - else: - # Keep the marker if we can't find the candidate - result_lines.append(line) - else: - result_lines.append(line) - - # Post-process to replace and with Jinja2 conditionals - final_lines = [] - for line in result_lines: - # Replace with {% if var.field is defined %} - if "", start) - condition = line[start:end] - indent = len(line) - len(line.lstrip()) - final_lines.append(f"{' ' * indent}{{% if {condition} is defined %}}") - # Replace with {% endif %} - elif " - - - - - web-log - Access log messages grouped. - - - - 31100 - ^2|^3 - is_simple_http_request - Ignored URLs (simple queries). - - - - 31100 - ^4 - Web server 400 error code. - - - - 31101 - \.jpg$|\.gif$|favicon\.ico$|\.png$|robots\.txt$|\.css$|\.js$|\.jpeg$ - is_simple_http_request - Ignored extensions on 400 error codes. - - - - 31100,31108 - =select%20|select\+|insert%20|%20from%20|%20where%20|union%20| - union\+|where\+|null,null|xp_cmdshell - SQL injection attempt. - attack,sql_injection, - - - - 31100 - - - %027|%00|%01|%7f|%2E%2E|%0A|%0D|\.\./\.\.|\.\.\\\.\.|echo;| - cmd\.exe|root\.exe|_mem_bin|msadc|/winnt/|/boot\.ini| - /x90/|default\.ida|/sumthin|nsiislog\.dll|chmod%|wget%|cd%20| - exec%20|\.\./\.\.//|%5C\.\./%5C|\./\./\./\./|2e%2e%5c%2e|\\x5C\\x5C - Common web attack. - attack, - - - - 31100 - %3Cscript|%3C%2Fscript|script>|script%3E|SRC=javascript|IMG%20| - %20ONLOAD=|INPUT%20|iframe%20 - XSS (Cross Site Scripting) attempt. - attack, - - - - 31103, 31104, 31105 - ^200 - A web attack returned code 200 (success). - attack, - - - - 31100 - \?-d|\?-s|\?-a|\?-b|\?-w - PHP CGI-bin vulnerability attempt. - attack, - - - - 31100 - \+as\+varchar - %2Bchar\(\d+\)%2Bchar\(\d+\)%2Bchar\(\d+\)%2Bchar\(\d+\)%2Bchar\(\d+\)%2Bchar\(\d+\) - MSSQL Injection attempt (/ur.php, urchin.js) - attack, - - - - - - 31103, 31104, 31105 - ^/search\.php\?search=|^/index\.php\?searchword= - Ignored URLs for the web attacks - - - - 31100 - URL too long. Higher than allowed on most - browsers. Possible attack. - invalid_access, - - - - - - 31100 - ^50 - Web server 500 error code (server error). - - - - 31120 - ^501 - Web server 501 error code (Not Implemented). - - - - 31120 - ^500 - alert_by_email - Web server 500 error code (Internal Error). - system_error, - - - - 31120 - ^503 - alert_by_email - Web server 503 error code (Service unavailable). - - - - - - 31101 - is_valid_crawler - Ignoring google/msn/yahoo bots. - - - - - 31101 - ^499 - Ignored 499's on nginx. - - - - - 31101 - - Multiple web server 400 error codes - from same source ip. - web_scan,recon, - - - - 31103 - - Multiple SQL injection attempts from same - source ip. - attack,sql_injection, - - - - 31104 - - Multiple common web attacks from same source ip. - attack, - - - - 31105 - - Multiple XSS (Cross Site Scripting) attempts - from same source ip. - attack, - - - - 31121 - - Multiple web server 501 error code (Not Implemented). - web_scan,recon, - - - - 31122 - - Multiple web server 500 error code (Internal Error). - system_error, - - - - 31123 - - Multiple web server 503 error code (Service unavailable). - web_scan,recon, - - - - 31100 - =%27|select%2B|insert%2B|%2Bfrom%2B|%2Bwhere%2B|%2Bunion%2B - SQL injection attempt. - attack,sqlinjection, - - - - 31100 - %EF%BC%87|%EF%BC%87|%EF%BC%87|%2531|%u0053%u0045 - SQL injection attempt. - attack,sqlinjection, - - - diff --git a/tests/test_base_handler.py b/tests/test_base_handler.py deleted file mode 100644 index 5ee761f..0000000 --- a/tests/test_base_handler.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from jinjaturtle.handlers.base import BaseHandler - - -def test_split_inline_comment_handles_quoted_hash(): - # The '#' inside quotes should not start a comment; the one outside should. - text = " 'foo # not comment' # real" - handler = BaseHandler() - value, comment = handler._split_inline_comment(text, {"#"}) - assert "not comment" in value - assert comment.strip() == "# real" - - -def test_base_handler_abstract_methods_raise_not_implemented(tmp_path: Path): - """ - Ensure the abstract methods on BaseHandler all raise NotImplementedError. - This covers the stub implementations. - """ - handler = BaseHandler() - dummy_path = tmp_path / "dummy.cfg" - - with pytest.raises(NotImplementedError): - handler.parse(dummy_path) - - with pytest.raises(NotImplementedError): - handler.flatten(object()) - - with pytest.raises(NotImplementedError): - handler.generate_jinja2_template(parsed=object(), role_prefix="role") diff --git a/tests/test_cli.py b/tests/test_cli.py index a880135..4df5bf0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,10 @@ from __future__ import annotations +import sys from pathlib import Path + +import pytest + from jinjaturtle import cli SAMPLES_DIR = Path(__file__).parent / "samples" @@ -14,7 +18,7 @@ def test_cli_stdout_toml(capsys): cfg_path = SAMPLES_DIR / "tom.toml" exit_code = cli._main([str(cfg_path), "-r", "jinjaturtle"]) - assert exit_code + assert exit_code == 0 captured = capsys.readouterr() out = captured.out @@ -48,7 +52,7 @@ def test_cli_writes_output_files(tmp_path, capsys): ] ) - assert exit_code + assert exit_code == 0 assert defaults_path.is_file() assert template_path.is_file() @@ -62,3 +66,20 @@ 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_main_wrapper_exits_with_zero(monkeypatch): + """ + Cover the main() wrapper that raises SystemExit. + """ + cfg_path = SAMPLES_DIR / "tom.toml" + monkeypatch.setattr( + sys, + "argv", + ["jinjaturtle", str(cfg_path), "-r", "jinjaturtle"], + ) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 0 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..bcdd2f7 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +import jinjaturtle.core as core +from jinjaturtle.core import ( + detect_format, + parse_config, + flatten_config, + generate_defaults_yaml, + generate_template, + make_var_name, +) + +SAMPLES_DIR = Path(__file__).parent / "samples" + + +def test_make_var_name_basic(): + # simple sanity checks on the naming rules + assert ( + make_var_name("jinjaturtle", ("somesection", "foo")) + == "jinjaturtle_somesection_foo" + ) + assert ( + make_var_name("JinjaTurtle", ("Other-Section", "some value")) + == "jinjaturtle_other_section_some_value" + ) + # no trailing underscores, all lowercase, no spaces + name = make_var_name("MyRole", (" Section Name ", "Key-Name ")) + assert name == name.lower() + assert " " not in name + assert not name.endswith("_") + + +def test_make_var_name_empty_path_returns_prefix(): + # Cover the branch where there are no path components. + assert make_var_name("MyRole", ()) == "myrole" + + +def test_detect_format_explicit_overrides_suffix(tmp_path: Path): + # Explicit format should win over file suffix. + cfg_path = tmp_path / "config.ini" + cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8") + + fmt = detect_format(cfg_path, explicit="toml") + assert fmt == "toml" + + +def test_detect_format_fallback_ini(tmp_path: Path): + # Unknown suffix should fall back to "ini". + cfg_path = tmp_path / "weird.cnf" + cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8") + + fmt, parsed = parse_config(cfg_path) # no explicit fmt + assert fmt == "ini" + # parsed should be an INI ConfigParser with our section/key + flat = flatten_config(fmt, parsed) + assert any(path == ("section", "key") for path, _ in flat) + + +def test_toml_sample_roundtrip(): + toml_path = SAMPLES_DIR / "tom.toml" + assert toml_path.is_file(), f"Missing sample TOML file: {toml_path}" + + fmt, parsed = parse_config(toml_path) + assert fmt == "toml" + + flat_items = flatten_config(fmt, parsed) + assert flat_items, "Expected at least one flattened item from TOML sample" + + defaults_yaml = generate_defaults_yaml("jinjaturtle", flat_items) + defaults = yaml.safe_load(defaults_yaml) + + # defaults should be a non-empty dict + assert isinstance(defaults, dict) + assert defaults, "Expected non-empty defaults for TOML sample" + + # all keys should be lowercase, start with prefix, and have no spaces + for key in defaults: + assert key.startswith("jinjaturtle_") + assert key == key.lower() + assert " " not in key + + # template generation + template = generate_template(fmt, parsed, "jinjaturtle") + assert isinstance(template, str) + assert template.strip(), "Template for TOML sample should not be empty" + + # each default variable name should appear in the template as a Jinja placeholder + for var_name in defaults: + assert ( + var_name in template + ), f"Variable {var_name} not referenced in TOML template" + + +def test_ini_php_sample_roundtrip(): + ini_path = SAMPLES_DIR / "php.ini" + assert ini_path.is_file(), f"Missing sample INI file: {ini_path}" + + fmt, parsed = parse_config(ini_path) + assert fmt == "ini" + + flat_items = flatten_config(fmt, parsed) + assert flat_items, "Expected at least one flattened item from php.ini sample" + + defaults_yaml = generate_defaults_yaml("php", flat_items) + defaults = yaml.safe_load(defaults_yaml) + + # defaults should be a non-empty dict + assert isinstance(defaults, dict) + assert defaults, "Expected non-empty defaults for php.ini sample" + + # all keys should be lowercase, start with prefix, and have no spaces + for key in defaults: + assert key.startswith("php_") + assert key == key.lower() + assert " " not in key + + # template generation + template = generate_template(fmt, parsed, "php") + assert isinstance(template, str) + assert template.strip(), "Template for php.ini sample should not be empty" + + # each default variable name should appear in the template as a Jinja placeholder + for var_name in defaults: + assert ( + var_name in template + ), f"Variable {var_name} not referenced in INI template" + + +def test_formats_match_expected_extensions(): + """ + Sanity check that format detection lines up with the filenames + we’re using for the samples. + """ + toml_path = SAMPLES_DIR / "tom.toml" + ini_path = SAMPLES_DIR / "php.ini" + + fmt_toml, _ = parse_config(toml_path) + fmt_ini, _ = parse_config(ini_path) + + assert fmt_toml == "toml" + assert fmt_ini == "ini" + + +def test_parse_config_toml_missing_tomllib(monkeypatch): + """ + Force tomllib to None to hit the RuntimeError branch when parsing TOML. + """ + toml_path = SAMPLES_DIR / "tom.toml" + + # Simulate an environment without tomllib/tomli + monkeypatch.setattr(core, "tomllib", None) + + with pytest.raises(RuntimeError) as exc: + core.parse_config(toml_path, fmt="toml") + assert "tomllib/tomli is required" in str(exc.value) + + +def test_parse_config_unsupported_format(tmp_path: Path): + """ + Hit the ValueError in parse_config when fmt is neither 'toml' nor 'ini'. + """ + cfg_path = tmp_path / "config.whatever" + cfg_path.write_text("", encoding="utf-8") + + with pytest.raises(ValueError): + parse_config(cfg_path, fmt="yaml") + + +def test_generate_template_type_and_format_errors(): + """ + Exercise the error branches in generate_template: + - toml with non-dict parsed + - ini with non-ConfigParser parsed + - completely unsupported fmt + """ + # wrong type for TOML + with pytest.raises(TypeError): + generate_template("toml", parsed="not a dict", role_prefix="role") + + # wrong type for INI + with pytest.raises(TypeError): + generate_template("ini", parsed={"not": "a configparser"}, role_prefix="role") + + # unsupported format + with pytest.raises(ValueError): + generate_template("yaml", parsed=None, role_prefix="role") diff --git a/tests/test_core_utils.py b/tests/test_core_utils.py deleted file mode 100644 index c8e41e1..0000000 --- a/tests/test_core_utils.py +++ /dev/null @@ -1,193 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml - -import jinjaturtle.core as core -from jinjaturtle.core import ( - detect_format, - parse_config, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, - make_var_name, -) - -SAMPLES_DIR = Path(__file__).parent / "samples" - - -def test_make_var_name_basic(): - # simple sanity checks on the naming rules - assert ( - make_var_name("jinjaturtle", ("somesection", "foo")) - == "jinjaturtle_somesection_foo" - ) - assert ( - make_var_name("JinjaTurtle", ("Other-Section", "some value")) - == "jinjaturtle_other_section_some_value" - ) - # no trailing underscores, all lowercase, no spaces - name = make_var_name("MyRole", (" Section Name ", "Key-Name ")) - assert name == name.lower() - assert " " not in name - assert not name.endswith("_") - - -def test_make_var_name_empty_path_returns_prefix(): - # Cover the branch where there are no path components. - assert make_var_name("MyRole", ()) == "myrole" - - -def test_detect_format_explicit_overrides_suffix(tmp_path: Path): - # Explicit format should win over file suffix. - cfg_path = tmp_path / "config.ini" - cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8") - - fmt = detect_format(cfg_path, explicit="toml") - assert fmt == "toml" - - -def test_detect_format_fallback_ini(tmp_path: Path): - # Unknown suffix should fall back to "ini". - cfg_path = tmp_path / "weird.cnf" - cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8") - - fmt, parsed = parse_config(cfg_path) # no explicit fmt - assert fmt == "ini" - # parsed should be an INI ConfigParser with our section/key - flat = flatten_config(fmt, parsed) - assert any(path == ("section", "key") for path, _ in flat) - - -def test_formats_match_expected_extensions(): - """ - Sanity check that format detection lines up with the filenames - we’re using for the samples. - """ - toml_path = SAMPLES_DIR / "tom.toml" - ini_path = SAMPLES_DIR / "php.ini" - xml_path = SAMPLES_DIR / "ossec.xml" - - fmt_toml, _ = parse_config(toml_path) - fmt_ini, _ = parse_config(ini_path) - fmt_xml, _ = parse_config(xml_path) - - assert fmt_toml == "toml" - assert fmt_ini == "ini" - assert fmt_xml == "xml" - - -def test_parse_config_unsupported_format(tmp_path: Path): - """ - Hit the ValueError in parse_config when fmt is not a supported format. - """ - cfg_path = tmp_path / "config.whatever" - cfg_path.write_text("", encoding="utf-8") - - with pytest.raises(ValueError): - parse_config(cfg_path, fmt="bogus") - - -def test_generate_jinja2_template_type_and_format_errors(): - """ - Exercise the error branches in generate_jinja2_template: - - toml with non-dict parsed - - ini with non-ConfigParser parsed - - yaml with wrong parsed type - - json with wrong parsed type - - completely unsupported fmt (with and without original_text) - """ - # wrong type for TOML - with pytest.raises(TypeError): - generate_jinja2_template("toml", parsed="not a dict", role_prefix="role") - - # wrong type for INI - with pytest.raises(TypeError): - generate_jinja2_template( - "ini", parsed={"not": "a configparser"}, role_prefix="role" - ) - - # wrong type for YAML - with pytest.raises(TypeError): - generate_jinja2_template("yaml", parsed=None, role_prefix="role") - - # wrong type for JSON - with pytest.raises(TypeError): - generate_jinja2_template("json", parsed=None, role_prefix="role") - - # unsupported format, no original_text - with pytest.raises(ValueError): - generate_jinja2_template("bogusfmt", parsed=None, role_prefix="role") - - # unsupported format, with original_text - with pytest.raises(ValueError): - generate_jinja2_template( - "bogusfmt", - parsed=None, - role_prefix="role", - original_text="foo=bar", - ) - - -def test_normalize_default_value_true_false_strings(): - # 'true'/'false' strings should be preserved as strings and double-quoted in YAML. - flat_items = [ - (("section", "foo"), "true"), - (("section", "bar"), "FALSE"), - ] - ansible_yaml = generate_ansible_yaml("role", flat_items) - data = yaml.safe_load(ansible_yaml) - assert data["role_section_foo"] == "true" - assert data["role_section_bar"] == "FALSE" - - -def test_fallback_str_representer_for_unknown_type(): - """ - Ensure that the _fallback_str_representer is used for objects that - PyYAML doesn't know how to represent. - """ - - class Weird: - def __str__(self) -> str: - return "weird-value" - - data = {"foo": Weird()} - - dumped = yaml.dump( - data, - Dumper=core._TurtleDumper, - sort_keys=False, - default_flow_style=False, - ) - - # It should serialize without error, and the string form should appear. - assert "weird-value" in dumped - - -def test_normalize_default_value_bool_inputs_are_stringified(): - """ - Boolean values are now preserved as booleans in YAML (not stringified). - This supports proper type preservation for JSON and other formats. - """ - flat_items = [ - (("section", "flag_true"), True), - (("section", "flag_false"), False), - ] - ansible_yaml = generate_ansible_yaml("role", flat_items) - data = yaml.safe_load(ansible_yaml) - - # Booleans are now preserved as booleans - assert data["role_section_flag_true"] is True - assert data["role_section_flag_false"] is False - - -def test_flatten_config_unsupported_format(): - """ - Calling flatten_config with an unknown fmt should raise ValueError. - """ - with pytest.raises(ValueError) as exc: - flatten_config("bogusfmt", parsed=None) - - assert "Unsupported format" in str(exc.value) diff --git a/tests/test_ini_handler.py b/tests/test_ini_handler.py deleted file mode 100644 index 3bf1252..0000000 --- a/tests/test_ini_handler.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import configparser -import pytest -import yaml - -from jinjaturtle.core import ( - parse_config, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) -from jinjaturtle.handlers.ini import IniHandler - -SAMPLES_DIR = Path(__file__).parent / "samples" - - -def test_ini_php_sample_roundtrip(): - ini_path = SAMPLES_DIR / "php.ini" - assert ini_path.is_file(), f"Missing sample INI file: {ini_path}" - - fmt, parsed = parse_config(ini_path) - assert fmt == "ini" - - flat_items = flatten_config(fmt, parsed) - assert flat_items, "Expected at least one flattened item from php.ini sample" - - ansible_yaml = generate_ansible_yaml("php", flat_items) - defaults = yaml.safe_load(ansible_yaml) - - # defaults should be a non-empty dict - assert isinstance(defaults, dict) - assert defaults, "Expected non-empty defaults for php.ini sample" - - # all keys should be lowercase, start with prefix, and have no spaces - for key in defaults: - assert key.startswith("php_") - assert key == key.lower() - assert " " not in key - - # template generation - original_text = ini_path.read_text(encoding="utf-8") - template = generate_jinja2_template(fmt, parsed, "php", original_text=original_text) - assert "; About this file" in template - assert isinstance(template, str) - assert template.strip(), "Template for php.ini sample should not be empty" - - # each default variable name should appear in the template as a Jinja placeholder - for var_name in defaults: - assert ( - var_name in template - ), f"Variable {var_name} not referenced in INI template" - - -def test_generate_jinja2_template_fallback_ini(): - """ - When original_text is not provided, generate_jinja2_template should use the - structural fallback path for INI configs. - """ - parser = configparser.ConfigParser() - # foo is quoted in the INI text to hit the "preserve quotes" branch - parser["section"] = {"foo": '"bar"', "num": "42"} - - tmpl_ini = generate_jinja2_template("ini", parsed=parser, role_prefix="role") - assert "[section]" in tmpl_ini - assert "role_section_foo" in tmpl_ini - assert '"{{ role_section_foo }}"' in tmpl_ini # came from quoted INI value - - -def test_generate_ini_template_from_text_edge_cases(): - # Cover CRLF newlines, lines without '=', and lines with no key before '='. - text = "[section]\r\nkey=value\r\nnoequals\r\n = bare\r\n" - handler = IniHandler() - tmpl = handler._generate_ini_template_from_text("role", text) - - # We don't care about exact formatting here, just that it runs and - # produces some reasonable output. - assert "[section]" in tmpl - assert "role_section_key" in tmpl - # The "noequals" line should be preserved as-is. - assert "noequals" in tmpl - # The " = bare" line has no key and should be left untouched. - assert " = bare" in tmpl - - -def test_ini_handler_flatten_type_error(): - """ - Passing a non-ConfigParser into IniHandler.flatten should raise TypeError. - """ - handler = IniHandler() - with pytest.raises(TypeError): - handler.flatten(parsed={"not": "a configparser"}) diff --git a/tests/test_json_handler.py b/tests/test_json_handler.py deleted file mode 100644 index dd502b1..0000000 --- a/tests/test_json_handler.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml - -from jinjaturtle.core import ( - parse_config, - flatten_config, - generate_ansible_yaml, - analyze_loops, - generate_jinja2_template, -) -from jinjaturtle.handlers.json import JsonHandler - -SAMPLES_DIR = Path(__file__).parent / "samples" - - -def test_json_roundtrip(): - json_path = SAMPLES_DIR / "foo.json" - assert json_path.is_file(), f"Missing sample JSON file: {json_path}" - - fmt, parsed = parse_config(json_path) - assert fmt == "json" - - # With loop detection - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - ansible_yaml = generate_ansible_yaml("foobar", flat_items, loop_candidates) - defaults = yaml.safe_load(ansible_yaml) - - # Defaults: nested keys - assert defaults["foobar_foo"] == "bar" - 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] - - # 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 - - # 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_generate_jinja2_template_json_type_error(): - """ - Wrong type for JSON in JsonHandler.generate_jinja2_template should raise TypeError. - """ - handler = JsonHandler() - with pytest.raises(TypeError): - handler.generate_jinja2_template(parsed="not a dict", role_prefix="role") diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py deleted file mode 100644 index 5182e8c..0000000 --- a/tests/test_roundtrip.py +++ /dev/null @@ -1,566 +0,0 @@ -""" -Roundtrip tests: Generate config → template/YAML → regenerate config → compare. - -These tests verify that: -1. Generated Jinja2 template + Ansible YAML can reproduce the original config -2. The regenerated config is semantically equivalent (allowing whitespace differences) -3. No data loss occurs during the template generation process - -This is the ultimate validation - if the roundtrip works, the templates are correct. -""" - -from __future__ import annotations - -import json -import yaml -from pathlib import Path -from typing import Any -from jinja2 import Environment, StrictUndefined - -import pytest - -from jinjaturtle.core import ( - parse_config, - analyze_loops, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) - - -def render_template(template: str, variables: dict[str, Any]) -> str: - """Render a Jinja2 template with variables.""" - env = Environment(undefined=StrictUndefined) - jinja_template = env.from_string(template) - return jinja_template.render(variables) - - -class TestRoundtripJSON: - """Roundtrip tests for JSON files.""" - - def test_foo_json_roundtrip(self): - """Test foo.json can be perfectly regenerated from template.""" - samples_dir = Path(__file__).parent / "samples" - json_file = samples_dir / "foo.json" - - if not json_file.exists(): - pytest.skip("foo.json not found") - - # Read original - original_text = json_file.read_text() - original_data = json.loads(original_text) - - # Generate template and YAML - fmt, parsed = parse_config(json_file) - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template(fmt, parsed, "test", None, loop_candidates) - - # Load variables from YAML - variables = yaml.safe_load(ansible_yaml) - - # Render template - regenerated_text = render_template(template, variables) - regenerated_data = json.loads(regenerated_text) - - # Compare data structures (should match exactly) - assert regenerated_data == original_data, ( - f"Regenerated JSON differs from original\n" - f"Original: {json.dumps(original_data, indent=2, sort_keys=True)}\n" - f"Regenerated: {json.dumps(regenerated_data, indent=2, sort_keys=True)}" - ) - - def test_json_all_types_roundtrip(self): - """Test JSON with all data types roundtrips perfectly.""" - json_text = """ - { - "string": "value", - "number": 42, - "float": 3.14, - "boolean": true, - "false_val": false, - "null_value": null, - "array": [1, 2, 3], - "object": { - "nested": "data" - } - } - """ - - original_data = json.loads(json_text) - - # Generate template and YAML - loop_candidates = analyze_loops("json", original_data) - flat_items = flatten_config("json", original_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", original_data, "test", None, loop_candidates - ) - - # Render template - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = json.loads(regenerated_text) - - # Should match exactly - assert regenerated_data == original_data - - -class TestRoundtripYAML: - """Roundtrip tests for YAML files.""" - - def test_bar_yaml_roundtrip(self): - """Test bar.yaml can be regenerated from template.""" - samples_dir = Path(__file__).parent / "samples" - yaml_file = samples_dir / "bar.yaml" - - if not yaml_file.exists(): - pytest.skip("bar.yaml not found") - - # Read original - original_text = yaml_file.read_text() - original_data = yaml.safe_load(original_text) - - # Generate template and YAML - fmt, parsed = parse_config(yaml_file) - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, parsed, "test", original_text, loop_candidates - ) - - # Load variables from YAML - variables = yaml.safe_load(ansible_yaml) - - # Render template - regenerated_text = render_template(template, variables) - regenerated_data = yaml.safe_load(regenerated_text) - - # Compare data structures - assert regenerated_data == original_data, ( - f"Regenerated YAML differs from original\n" - f"Original: {original_data}\n" - f"Regenerated: {regenerated_data}" - ) - - def test_yaml_with_lists_roundtrip(self): - """Test YAML with various list structures.""" - yaml_text = """ - name: myapp - simple_list: - - item1 - - item2 - - item3 - list_of_dicts: - - name: first - value: 1 - - name: second - value: 2 - nested: - inner_list: - - a - - b - """ - - original_data = yaml.safe_load(yaml_text) - - # Generate template and YAML - loop_candidates = analyze_loops("yaml", original_data) - flat_items = flatten_config("yaml", original_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "yaml", original_data, "test", yaml_text, loop_candidates - ) - - # Render template - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = yaml.safe_load(regenerated_text) - - # Compare - assert regenerated_data == original_data - - -class TestRoundtripTOML: - """Roundtrip tests for TOML files.""" - - def test_tom_toml_roundtrip(self): - """Test tom.toml can be regenerated from template.""" - samples_dir = Path(__file__).parent / "samples" - toml_file = samples_dir / "tom.toml" - - if not toml_file.exists(): - pytest.skip("tom.toml not found") - - # Read original - original_text = toml_file.read_text() - import tomllib - - original_data = tomllib.loads(original_text) - - # Generate template and YAML - fmt, parsed = parse_config(toml_file) - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, parsed, "test", original_text, loop_candidates - ) - - # Load variables from YAML - variables = yaml.safe_load(ansible_yaml) - - # Render template - regenerated_text = render_template(template, variables) - regenerated_data = tomllib.loads(regenerated_text) - - # Compare data structures - # Note: TOML datetime objects need special handling - assert _compare_toml_data(regenerated_data, original_data), ( - f"Regenerated TOML differs from original\n" - f"Original: {original_data}\n" - f"Regenerated: {regenerated_data}" - ) - - def test_toml_with_arrays_roundtrip(self): - """Test TOML with inline arrays and array-of-tables.""" - toml_text = """ - name = "test" - ports = [8080, 8081, 8082] - - [[database]] - host = "db1.example.com" - port = 5432 - - [[database]] - host = "db2.example.com" - port = 5433 - """ - - import tomllib - - original_data = tomllib.loads(toml_text) - - # Generate template and YAML - loop_candidates = analyze_loops("toml", original_data) - flat_items = flatten_config("toml", original_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "toml", original_data, "test", toml_text, loop_candidates - ) - - # Render template - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = tomllib.loads(regenerated_text) - - # Compare - assert regenerated_data == original_data - - -class TestRoundtripXML: - """Roundtrip tests for XML files.""" - - def test_xml_simple_roundtrip(self): - """Test simple XML can be regenerated.""" - xml_text = """ - - test - 8080 - server1 - server2 - server3 - -""" - - import xml.etree.ElementTree as ET - - original_root = ET.fromstring(xml_text) - - # Generate template and YAML - fmt = "xml" - loop_candidates = analyze_loops(fmt, original_root) - flat_items = flatten_config(fmt, original_root, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, original_root, "test", xml_text, loop_candidates - ) - - # Render template - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - - # Parse regenerated XML - regenerated_root = ET.fromstring(regenerated_text) - - # Compare XML structures (ignore insignificant whitespace) - assert _xml_elements_equal( - original_root, regenerated_root, ignore_whitespace=True - ), ( - f"Regenerated XML differs from original\n" - f"Original: {ET.tostring(original_root, encoding='unicode')}\n" - f"Regenerated: {ET.tostring(regenerated_root, encoding='unicode')}" - ) - - def test_ossec_xml_roundtrip(self): - """Test ossec.xml (complex real-world XML) roundtrip.""" - samples_dir = Path(__file__).parent / "samples" - xml_file = samples_dir / "ossec.xml" - - if not xml_file.exists(): - pytest.skip("ossec.xml not found") - - # Read original - original_text = xml_file.read_text() - import xml.etree.ElementTree as ET - - original_root = ET.fromstring(original_text) - - # Generate template and YAML - fmt, parsed = parse_config(xml_file) - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, parsed, "test", original_text, loop_candidates - ) - - # Load variables and render - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - - # Parse regenerated - regenerated_root = ET.fromstring(regenerated_text) - - # Compare - for complex XML, we compare structure not exact text - assert _xml_elements_equal( - original_root, regenerated_root, ignore_whitespace=True - ) - - -class TestRoundtripINI: - """Roundtrip tests for INI files.""" - - def test_ini_simple_roundtrip(self): - """Test simple INI can be regenerated.""" - ini_text = """[section1] -key1 = value1 -key2 = value2 - -[section2] -key3 = value3 -""" - - from configparser import ConfigParser - - original_config = ConfigParser() - original_config.read_string(ini_text) - - # Generate template and YAML - fmt = "ini" - loop_candidates = analyze_loops(fmt, original_config) - flat_items = flatten_config(fmt, original_config, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, original_config, "test", ini_text, loop_candidates - ) - - # Render template - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - - # Parse regenerated - regenerated_config = ConfigParser() - regenerated_config.read_string(regenerated_text) - - # Compare - assert _ini_configs_equal(original_config, regenerated_config) - - -class TestRoundtripEdgeCases: - """Roundtrip tests for edge cases and special scenarios.""" - - def test_empty_lists_roundtrip(self): - """Test handling of empty lists.""" - json_text = '{"items": []}' - original_data = json.loads(json_text) - - loop_candidates = analyze_loops("json", original_data) - flat_items = flatten_config("json", original_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", original_data, "test", None, loop_candidates - ) - - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = json.loads(regenerated_text) - - assert regenerated_data == original_data - - def test_special_characters_roundtrip(self): - """Test handling of special characters.""" - json_data = { - "quote": 'He said "hello"', - "backslash": "path\\to\\file", - "newline": "line1\nline2", - "unicode": "emoji: šŸš€", - } - - loop_candidates = analyze_loops("json", json_data) - flat_items = flatten_config("json", json_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", json_data, "test", None, loop_candidates - ) - - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = json.loads(regenerated_text) - - assert regenerated_data == json_data - - def test_numeric_types_roundtrip(self): - """Test preservation of numeric types.""" - json_data = { - "int": 42, - "float": 3.14159, - "negative": -100, - "zero": 0, - "large": 9999999999, - } - - loop_candidates = analyze_loops("json", json_data) - flat_items = flatten_config("json", json_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", json_data, "test", None, loop_candidates - ) - - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = json.loads(regenerated_text) - - assert regenerated_data == json_data - - def test_boolean_preservation_roundtrip(self): - """Test that booleans are preserved correctly.""" - yaml_text = """ - enabled: true - disabled: false - """ - - original_data = yaml.safe_load(yaml_text) - - loop_candidates = analyze_loops("yaml", original_data) - flat_items = flatten_config("yaml", original_data, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - "yaml", original_data, "test", yaml_text, loop_candidates - ) - - variables = yaml.safe_load(ansible_yaml) - regenerated_text = render_template(template, variables) - regenerated_data = yaml.safe_load(regenerated_text) - - # Both should be actual booleans - assert regenerated_data["enabled"] is True - assert regenerated_data["disabled"] is False - - -# Helper functions - - -def _compare_toml_data(data1: Any, data2: Any) -> bool: - """Compare TOML data, handling datetime objects.""" - import datetime - - if type(data1) != type(data2): - return False - - if isinstance(data1, dict): - if set(data1.keys()) != set(data2.keys()): - return False - return all(_compare_toml_data(data1[k], data2[k]) for k in data1.keys()) - - elif isinstance(data1, list): - if len(data1) != len(data2): - return False - return all(_compare_toml_data(v1, v2) for v1, v2 in zip(data1, data2)) - - elif isinstance(data1, datetime.datetime): - # Compare datetime objects - return data1 == data2 - - else: - return data1 == data2 - - -def _xml_elements_equal(elem1, elem2, ignore_whitespace: bool = False) -> bool: - """Compare two XML elements for equality.""" - # Compare tags - if elem1.tag != elem2.tag: - return False - - # Compare attributes - if elem1.attrib != elem2.attrib: - return False - - # Compare text - text1 = (elem1.text or "").strip() if ignore_whitespace else (elem1.text or "") - text2 = (elem2.text or "").strip() if ignore_whitespace else (elem2.text or "") - if text1 != text2: - return False - - # Compare tail - tail1 = (elem1.tail or "").strip() if ignore_whitespace else (elem1.tail or "") - tail2 = (elem2.tail or "").strip() if ignore_whitespace else (elem2.tail or "") - if tail1 != tail2: - return False - - # Compare children - children1 = list(elem1) - children2 = list(elem2) - - if len(children1) != len(children2): - return False - - return all( - _xml_elements_equal(c1, c2, ignore_whitespace) - for c1, c2 in zip(children1, children2) - ) - - -def _ini_configs_equal(config1, config2) -> bool: - """Compare two ConfigParser objects for equality.""" - if set(config1.sections()) != set(config2.sections()): - return False - - for section in config1.sections(): - if set(config1.options(section)) != set(config2.options(section)): - return False - - for option in config1.options(section): - if config1.get(section, option) != config2.get(section, option): - return False - - return True - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_toml_handler.py b/tests/test_toml_handler.py deleted file mode 100644 index a446536..0000000 --- a/tests/test_toml_handler.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml - -from jinjaturtle.core import ( - parse_config, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) -from jinjaturtle.handlers.toml import TomlHandler -import jinjaturtle.handlers.toml as toml_module - -SAMPLES_DIR = Path(__file__).parent / "samples" - - -def test_toml_sample_roundtrip(): - toml_path = SAMPLES_DIR / "tom.toml" - assert toml_path.is_file(), f"Missing sample TOML file: {toml_path}" - - fmt, parsed = parse_config(toml_path) - assert fmt == "toml" - - flat_items = flatten_config(fmt, parsed) - assert flat_items - - ansible_yaml = generate_ansible_yaml("jinjaturtle", flat_items) - defaults = yaml.safe_load(ansible_yaml) - - # defaults should be a non-empty dict - assert isinstance(defaults, dict) - assert defaults, "Expected non-empty defaults for TOML sample" - - # all keys should be lowercase, start with prefix, and have no spaces - for key in defaults: - assert key.startswith("jinjaturtle_") - assert key == key.lower() - assert " " not in key - - # template generation – **now with original_text** - original_text = toml_path.read_text(encoding="utf-8") - template = generate_jinja2_template( - fmt, parsed, "jinjaturtle", original_text=original_text - ) - assert isinstance(template, str) - assert template.strip() - - # comments from the original file should now be preserved - assert "# This is a TOML document" in template - - # each default variable name should appear in the template as a Jinja placeholder - for var_name in defaults: - assert ( - var_name in template - ), f"Variable {var_name} not referenced in TOML template" - - -def test_parse_config_toml_missing_tomllib(monkeypatch): - """ - Force tomllib to None to hit the RuntimeError branch when parsing TOML. - """ - toml_path = SAMPLES_DIR / "tom.toml" - - # Simulate an environment without tomllib/tomli - monkeypatch.setattr(toml_module, "tomllib", None) - - with pytest.raises(RuntimeError) as exc: - parse_config(toml_path, fmt="toml") - assert "tomllib/tomli is required" in str(exc.value) - - -def test_generate_jinja2_template_fallback_toml(): - """ - When original_text is not provided, generate_jinja2_template should use the - structural fallback path for TOML configs. - """ - parsed_toml = { - "title": "Example", - "server": {"port": 8080, "host": "127.0.0.1"}, - "logging": { - "file": {"path": "/tmp/app.log"} - }, # nested table to hit recursive walk - } - tmpl_toml = generate_jinja2_template("toml", parsed=parsed_toml, role_prefix="role") - assert "[server]" in tmpl_toml - assert "role_server_port" in tmpl_toml - assert "[logging]" in tmpl_toml or "[logging.file]" in tmpl_toml - - -def test_generate_toml_template_from_text_edge_cases(): - # Cover CRLF newlines, lines without '=', empty keys, and inline tables - # that both parse successfully and fail parsing. - text = ( - "# comment\r\n" - "[table]\r\n" - "noequals\r\n" - " = 42\r\n" - 'inline_good = { name = "abc", value = 1 }\r\n' - "inline_bad = { invalid = }\r\n" - ) - handler = TomlHandler() - tmpl = handler._generate_toml_template_from_text("role", text) - - # The good inline table should expand into two separate variables. - assert "role_table_inline_good_name" in tmpl - assert "role_table_inline_good_value" in tmpl - # The bad inline table should fall back to scalar handling. - assert "role_table_inline_bad" in tmpl - # Ensure the lines without '=' / empty key were handled without exploding. - assert "[table]" in tmpl - assert "noequals" in tmpl diff --git a/tests/test_xml_handler.py b/tests/test_xml_handler.py deleted file mode 100644 index 6b124c4..0000000 --- a/tests/test_xml_handler.py +++ /dev/null @@ -1,232 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import textwrap -import xml.etree.ElementTree as ET - -import pytest -import yaml - -from jinjaturtle.core import ( - parse_config, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) -from jinjaturtle.handlers.xml import XmlHandler - -SAMPLES_DIR = Path(__file__).parent / "samples" - - -def test_xml_roundtrip_ossec_web_rules(): - xml_path = SAMPLES_DIR / "ossec.xml" - assert xml_path.is_file(), f"Missing sample XML file: {xml_path}" - - fmt, parsed = parse_config(xml_path) - assert fmt == "xml" - - flat_items = flatten_config(fmt, parsed) - assert flat_items, "Expected at least one flattened item from XML sample" - - ansible_yaml = generate_ansible_yaml("ossec", flat_items) - defaults = yaml.safe_load(ansible_yaml) - - # defaults should be a non-empty dict - assert isinstance(defaults, dict) - assert defaults, "Expected non-empty defaults for XML sample" - - # all keys should be lowercase, start with prefix, and have no spaces - for key in defaults: - assert key.startswith("ossec_") - assert key == key.lower() - assert " " not in key - - # Root attribute should flatten to ossec_name - assert defaults["ossec_name"] == "web,accesslog," - - # There should be at least one default for rule id="31100" - id_keys = [k for k, v in defaults.items() if v == "31100"] - assert id_keys, "Expected to find a default for rule id 31100" - - # At least one of them should be the rule *id* attribute - assert any( - key.startswith("ossec_rule_") and key.endswith("_id") for key in id_keys - ), f"Expected at least one *_id var for value 31100, got: {id_keys}" - - # Template generation (preserving comments) - original_text = xml_path.read_text(encoding="utf-8") - template = generate_jinja2_template( - fmt, parsed, "ossec", original_text=original_text - ) - assert isinstance(template, str) - assert template.strip(), "Template for XML sample should not be empty" - - # Top-of-file and mid-file comments should be preserved - assert "Official Web access rules for OSSEC." in template - assert "Rules to ignore crawlers" in template - - # Each default variable name should appear in the template as a Jinja placeholder - for var_name in defaults: - assert ( - var_name in template - ), f"Variable {var_name} not referenced in XML template" - - -def test_generate_xml_template_from_text_edge_cases(): - """ - Exercise XML text edge cases: - - XML declaration and DOCTYPE in prolog - - top-level and inner comments - - repeated child elements (indexing) - - attributes and text content - """ - text = textwrap.dedent( - """\ - - - - - - text - other - - """ - ) - - handler = XmlHandler() - tmpl = handler._generate_xml_template_from_text("role", text) - - # Prolog and comments preserved - assert " role_attr) - assert "role_attr" in tmpl - - # Repeated elements should be indexed in both attr and text - assert "role_child_0_attr" in tmpl - assert "role_child_0" in tmpl - assert "role_child_1" in tmpl - - -def test_generate_jinja2_template_xml_type_error(): - """ - Wrong type for XML in XmlHandler.generate_jinja2_template should raise TypeError. - """ - handler = XmlHandler() - with pytest.raises(TypeError): - handler.generate_jinja2_template(parsed="not an element", role_prefix="role") - - -def test_flatten_config_xml_type_error(): - """ - Wrong type for XML in flatten_config should raise TypeError. - """ - with pytest.raises(TypeError): - flatten_config("xml", parsed="not-an-element") - - -def test_generate_jinja2_template_xml_structural_fallback(): - """ - When original_text is not provided for XML, generate_jinja2_template should use - the structural fallback path (ET.tostring + handler processing). - """ - xml_text = textwrap.dedent( - """\ - - 2 - text - - """ - ) - root = ET.fromstring(xml_text) - - tmpl = generate_jinja2_template("xml", parsed=root, role_prefix="role") - - # Root attribute path ("@attr",) -> role_attr - assert "role_attr" in tmpl - - # Simple child element text ("child",) -> role_child - assert "role_child" in tmpl - - # Element with both attr and text: - # - attr -> ("node", "@attr") -> role_node_attr - # - text -> ("node", "value") -> role_node_value - assert "role_node_attr" in tmpl - assert "role_node_value" in tmpl - - -def test_split_xml_prolog_only_whitespace(): - """ - Whitespace-only input: prolog is the whitespace, body is empty. - Exercises the 'if i >= n: break' path. - """ - text = " \n\t" - handler = XmlHandler() - prolog, body = handler._split_xml_prolog(text) - assert prolog == text - assert body == "" - - -def test_split_xml_prolog_unterminated_declaration(): - """ - Unterminated XML declaration should hit the 'end == -1' branch and - treat the whole string as body. - """ - text = "" - handler = XmlHandler() - prolog, body = handler._split_xml_prolog(text) - assert prolog == "" - assert body == text - - -def test_flatten_xml_text_with_attributes_uses_value_suffix(): - """ - When an element has both attributes and text, _flatten_xml should store - the text at path + ('value',), not just path. - """ - xml_text = "text" - root = ET.fromstring(xml_text) - - items = flatten_config("xml", root) - - # Attribute path: ("node", "@attr") -> "x" - assert (("node", "@attr"), "x") in items - - # Text-with-attrs path: ("node", "value") -> "text" - assert (("node", "value"), "text") in items diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py deleted file mode 100644 index c7bacb7..0000000 --- a/tests/test_yaml_handler.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import textwrap - -import yaml - -from jinjaturtle.core import ( - parse_config, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) -from jinjaturtle.handlers.yaml import YamlHandler - -SAMPLES_DIR = Path(__file__).parent / "samples" - - -def test_yaml_roundtrip_with_list_and_comment(): - yaml_path = SAMPLES_DIR / "bar.yaml" - assert yaml_path.is_file(), f"Missing sample YAML file: {yaml_path}" - - fmt, parsed = parse_config(yaml_path) - assert fmt == "yaml" - - flat_items = flatten_config(fmt, parsed) - ansible_yaml = generate_ansible_yaml("foobar", flat_items) - defaults = yaml.safe_load(ansible_yaml) - - # Defaults: keys are flattened with indices - assert defaults["foobar_foo"] == "bar" - assert defaults["foobar_blah_0"] == "something" - assert defaults["foobar_blah_1"] == "else" - - # Template generation (preserving comments) - original_text = yaml_path.read_text(encoding="utf-8") - template = generate_jinja2_template( - fmt, parsed, "foobar", original_text=original_text - ) - - # Comment preserved - assert "# Top comment" in template - - # Scalar replacement - assert "foo:" in template - assert "foobar_foo" in template - - # List items use indexed vars, not "item" - assert "foobar_blah_0" in template - assert "foobar_blah_1" in template - assert "{{ foobar_blah }}" not in template - assert "foobar_blah_item" not in template - - -def test_generate_yaml_template_from_text_edge_cases(): - """ - Exercise YAML text edge cases: - - indentation dedent (stack pop) - - empty key before ':' - - quoted and unquoted list items - """ - text = textwrap.dedent( - """ - root: - child: 1 - other: 2 - : 3 - list: - - "quoted" - - unquoted - """ - ) - - handler = YamlHandler() - tmpl = handler._generate_yaml_template_from_text("role", text) - - # Dedent from "root -> child" back to "other" exercises the stack-pop path. - # Just check the expected variable names appear. - assert "role_root_child" in tmpl - assert "role_other" in tmpl - - # The weird " : 3" line has no key and should be left untouched. - assert " : 3" in tmpl - - # The list should generate indexed variables for each item. - # First item is quoted (use_quotes=True), second is unquoted. - assert "role_list_0" in tmpl - assert "role_list_1" in tmpl - - -def test_generate_jinja2_template_yaml_structural_fallback(): - """ - When original_text is not provided for YAML, generate_jinja2_template should use - the structural fallback path (yaml.safe_dump + handler processing). - """ - parsed = {"outer": {"inner": "val"}} - - tmpl = generate_jinja2_template("yaml", parsed=parsed, role_prefix="role") - - # 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 diff --git a/tests/test_yaml_template_consistency.py b/tests/test_yaml_template_consistency.py deleted file mode 100644 index 69184dd..0000000 --- a/tests/test_yaml_template_consistency.py +++ /dev/null @@ -1,558 +0,0 @@ -""" -Tests to ensure all Jinja2 template variables exist in the Ansible YAML. - -These tests catch the bug where templates reference variables that don't exist -because the YAML has a list but the template uses scalar references (or vice versa). -""" - -from __future__ import annotations - -import re -from pathlib import Path -from typing import Set -import yaml -import pytest - -from jinjaturtle.core import ( - parse_config, - analyze_loops, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) - - -def extract_jinja_variables(template: str) -> Set[str]: - """ - Extract all Jinja2 variable names from a template that must exist in YAML. - - Extracts variables from: - - {{ variable_name }} - - {{ variable.field }} - - {% for item in collection %} - - Returns only the base variable names that must be defined in YAML. - Filters out loop variables (the 'item' part of 'for item in collection'). - """ - variables = set() - - # First, find all loop variables (these are defined by the template, not YAML) - loop_vars = set() - for_pattern = r"\{%\s*for\s+(\w+)\s+in\s+([a-zA-Z_][a-zA-Z0-9_]*)" - for match in re.finditer(for_pattern, template): - loop_var = match.group(1) # The item - collection = match.group(2) # The collection - loop_vars.add(loop_var) - variables.add(collection) # Collection must exist in YAML - - # Pattern 1: {{ variable_name }} or {{ variable.field }} - # Captures the first part before any dots or filters - var_pattern = r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)" - for match in re.finditer(var_pattern, template): - var_name = match.group(1) - # Only add if it's not a loop variable - if var_name not in loop_vars: - variables.add(var_name) - - return variables - - -def extract_yaml_variables(ansible_yaml: str) -> Set[str]: - """ - Extract all variable names from Ansible YAML. - - Returns the top-level keys from the YAML document. - """ - data = yaml.safe_load(ansible_yaml) - if not isinstance(data, dict): - return set() - return set(data.keys()) - - -class TestTemplateYamlConsistency: - """Tests that verify template variables exist in YAML.""" - - def test_simple_json_consistency(self): - """Simple JSON with scalars and lists.""" - json_text = """ - { - "name": "test", - "values": [1, 2, 3] - } - """ - - fmt = "json" - import json - - parsed = json.loads(json_text) - - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template(fmt, parsed, "app", None, loop_candidates) - - yaml_vars = extract_yaml_variables(ansible_yaml) - template_vars = extract_jinja_variables(template) - - # Every variable in template must exist in YAML - missing_vars = template_vars - yaml_vars - assert not missing_vars, ( - f"Template references variables not in YAML: {missing_vars}\n" - f"YAML vars: {yaml_vars}\n" - f"Template vars: {template_vars}\n" - f"Template:\n{template}\n" - f"YAML:\n{ansible_yaml}" - ) - - def test_toml_inline_array_consistency(self): - """TOML with inline array should use loops consistently.""" - import tomllib - - toml_text = """ - name = "myapp" - servers = ["server1", "server2", "server3"] - """ - - parsed = tomllib.loads(toml_text) - loop_candidates = analyze_loops("toml", parsed) - flat_items = flatten_config("toml", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "toml", parsed, "app", toml_text, loop_candidates - ) - - yaml_vars = extract_yaml_variables(ansible_yaml) - template_vars = extract_jinja_variables(template) - - missing_vars = template_vars - yaml_vars - assert not missing_vars, ( - f"Template references variables not in YAML: {missing_vars}\n" - f"Template:\n{template}\n" - f"YAML:\n{ansible_yaml}" - ) - - def test_toml_array_of_tables_consistency(self): - """TOML with [[array.of.tables]] should use loops consistently.""" - import tomllib - - toml_text = """ - [[database]] - host = "db1.example.com" - port = 5432 - - [[database]] - host = "db2.example.com" - port = 5433 - """ - - parsed = tomllib.loads(toml_text) - loop_candidates = analyze_loops("toml", parsed) - flat_items = flatten_config("toml", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "toml", parsed, "app", toml_text, loop_candidates - ) - - yaml_vars = extract_yaml_variables(ansible_yaml) - template_vars = extract_jinja_variables(template) - - missing_vars = template_vars - yaml_vars - assert not missing_vars, ( - f"Template references variables not in YAML: {missing_vars}\n" - f"Template:\n{template}\n" - f"YAML:\n{ansible_yaml}" - ) - - # Additionally verify that if YAML has a list, template uses a loop - defaults = yaml.safe_load(ansible_yaml) - for var_name, value in defaults.items(): - if isinstance(value, list) and len(value) > 1: - # YAML has a list - template should use {% for %} - assert "{% for" in template, ( - f"YAML has list variable '{var_name}' but template doesn't use loops\n" - f"Template:\n{template}" - ) - - def test_yaml_list_consistency(self): - """YAML with lists should use loops consistently.""" - yaml_text = """ - name: myapp - servers: - - server1 - - server2 - - server3 - databases: - - host: db1 - port: 5432 - - host: db2 - port: 5433 - """ - - parsed = yaml.safe_load(yaml_text) - loop_candidates = analyze_loops("yaml", parsed) - flat_items = flatten_config("yaml", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "yaml", parsed, "app", yaml_text, loop_candidates - ) - - yaml_vars = extract_yaml_variables(ansible_yaml) - template_vars = extract_jinja_variables(template) - - missing_vars = template_vars - yaml_vars - assert not missing_vars, ( - f"Template references variables not in YAML: {missing_vars}\n" - f"Template:\n{template}\n" - f"YAML:\n{ansible_yaml}" - ) - - def test_mixed_scalars_and_loops_consistency(self): - """Config with both scalars and loops should be consistent.""" - import tomllib - - toml_text = """ - name = "myapp" - version = "1.0" - ports = [8080, 8081, 8082] - - [database] - host = "localhost" - port = 5432 - - [[servers]] - name = "web1" - ip = "10.0.0.1" - - [[servers]] - name = "web2" - ip = "10.0.0.2" - """ - - parsed = tomllib.loads(toml_text) - loop_candidates = analyze_loops("toml", parsed) - flat_items = flatten_config("toml", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "toml", parsed, "app", toml_text, loop_candidates - ) - - yaml_vars = extract_yaml_variables(ansible_yaml) - template_vars = extract_jinja_variables(template) - - missing_vars = template_vars - yaml_vars - assert not missing_vars, ( - f"Template references variables not in YAML: {missing_vars}\n" - f"Template:\n{template}\n" - f"YAML:\n{ansible_yaml}" - ) - - def test_no_orphaned_scalar_references(self): - """ - When YAML has a list variable, template must NOT reference scalar indices. - - This catches the bug where: - - YAML has: app_list: [1, 2, 3] - - Template incorrectly uses: {{ app_list_0 }}, {{ app_list_1 }} - """ - import json - - json_text = '{"items": [1, 2, 3, 4, 5]}' - parsed = json.loads(json_text) - - loop_candidates = analyze_loops("json", parsed) - flat_items = flatten_config("json", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", parsed, "app", None, loop_candidates - ) - - defaults = yaml.safe_load(ansible_yaml) - - # Check each list variable in YAML - for var_name, value in defaults.items(): - if isinstance(value, list): - # Template should NOT reference app_items_0, app_items_1, etc. - for i in range(len(value)): - scalar_ref = f"{var_name}_{i}" - assert scalar_ref not in template, ( - f"Template incorrectly uses scalar reference '{scalar_ref}' " - f"when YAML has '{var_name}' as a list\n" - f"Template should use loops, not scalar indices\n" - f"Template:\n{template}" - ) - - def test_all_sample_files_consistency(self): - """Test all sample files for consistency.""" - samples_dir = Path(__file__).parent / "samples" - - sample_files = [ - ("foo.json", "json"), - ("bar.yaml", "yaml"), - ("tom.toml", "toml"), - ] - - for filename, fmt in sample_files: - file_path = samples_dir / filename - if not file_path.exists(): - pytest.skip(f"Sample file {filename} not found") - - original_text = file_path.read_text() - fmt_detected, parsed = parse_config(file_path) - - loop_candidates = analyze_loops(fmt_detected, parsed) - flat_items = flatten_config(fmt_detected, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("test", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt_detected, parsed, "test", original_text, loop_candidates - ) - - yaml_vars = extract_yaml_variables(ansible_yaml) - template_vars = extract_jinja_variables(template) - - missing_vars = template_vars - yaml_vars - assert not missing_vars, ( - f"File: {filename}\n" - f"Template references variables not in YAML: {missing_vars}\n" - f"YAML vars: {yaml_vars}\n" - f"Template vars: {template_vars}\n" - f"Template:\n{template}\n" - f"YAML:\n{ansible_yaml}" - ) - - -class TestStructuralConsistency: - """Tests that verify structural consistency between YAML and templates.""" - - def test_list_in_yaml_means_loop_in_template(self): - """When YAML has a list (len > 1), template should use {% for %}.""" - import json - - json_text = """ - { - "scalar": "value", - "list": [1, 2, 3] - } - """ - - parsed = json.loads(json_text) - loop_candidates = analyze_loops("json", parsed) - flat_items = flatten_config("json", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", parsed, "app", None, loop_candidates - ) - - defaults = yaml.safe_load(ansible_yaml) - - # Find list variables in YAML - list_vars = [ - k for k, v in defaults.items() if isinstance(v, list) and len(v) > 1 - ] - - if list_vars: - # Template must contain for loops - assert "{% for" in template, ( - f"YAML has list variables {list_vars} but template has no loops\n" - f"Template:\n{template}" - ) - - # Each list variable should be used in a for loop - for var_name in list_vars: - # Look for "{% for ... in var_name %}" - for_pattern = ( - r"\{%\s*for\s+\w+\s+in\s+" + re.escape(var_name) + r"\s*%\}" - ) - assert re.search(for_pattern, template), ( - f"List variable '{var_name}' not used in a for loop\n" - f"Template:\n{template}" - ) - - def test_scalar_in_yaml_means_no_loop_in_template(self): - """When YAML has scalars, template should use {{ var }}, not loops.""" - import json - - json_text = """ - { - "name": "test", - "port": 8080, - "enabled": true - } - """ - - parsed = json.loads(json_text) - loop_candidates = analyze_loops("json", parsed) - flat_items = flatten_config("json", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", parsed, "app", None, loop_candidates - ) - - defaults = yaml.safe_load(ansible_yaml) - - # All variables are scalars - template should NOT have loops - scalar_vars = [ - k for k, v in defaults.items() if not isinstance(v, (list, dict)) - ] - - # Check that scalar vars are used directly, not in loops - for var_name in scalar_vars: - # Should appear in {{ var_name }}, not {% for ... in var_name %} - direct_ref = f"{{{{ {var_name}" - loop_ref = f"for .* in {var_name}" - - assert direct_ref in template, ( - f"Scalar variable '{var_name}' should be directly referenced\n" - f"Template:\n{template}" - ) - - assert not re.search(loop_ref, template), ( - f"Scalar variable '{var_name}' incorrectly used in a loop\n" - f"Template:\n{template}" - ) - - def test_no_undefined_variable_errors(self): - """ - Simulate Ansible template rendering to catch undefined variables. - - This is the ultimate test - actually render the template with the YAML - and verify no undefined variable errors occur. - """ - from jinja2 import Environment, StrictUndefined - import json - - json_text = """ - { - "name": "myapp", - "servers": ["web1", "web2"], - "database": { - "host": "localhost", - "port": 5432 - } - } - """ - - parsed = json.loads(json_text) - loop_candidates = analyze_loops("json", parsed) - flat_items = flatten_config("json", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", parsed, "app", None, loop_candidates - ) - - # Load variables from YAML - variables = yaml.safe_load(ansible_yaml) - - # Try to render the template - env = Environment(undefined=StrictUndefined) - try: - jinja_template = env.from_string(template) - rendered = jinja_template.render(variables) - - # Successfully rendered - this is what we want! - assert rendered, "Template rendered successfully" - - except Exception as e: - pytest.fail( - f"Template rendering failed with variables from YAML\n" - f"Error: {e}\n" - f"Template:\n{template}\n" - f"Variables:\n{ansible_yaml}" - ) - - -class TestRegressionBugs: - """Tests for specific bugs that were found and fixed.""" - - def test_toml_array_of_tables_no_scalar_refs(self): - """ - Regression test: TOML [[array]] should not generate scalar references. - - Bug: Template had {{ app_database_host }} when YAML had app_database as list. - """ - import tomllib - - toml_text = """ - [[database]] - host = "db1" - port = 5432 - - [[database]] - host = "db2" - port = 5433 - """ - - parsed = tomllib.loads(toml_text) - loop_candidates = analyze_loops("toml", parsed) - flat_items = flatten_config("toml", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "toml", parsed, "app", toml_text, loop_candidates - ) - - # YAML should have app_database as a list - defaults = yaml.safe_load(ansible_yaml) - assert isinstance( - defaults.get("app_database"), list - ), f"Expected app_database to be a list in YAML\n{ansible_yaml}" - - # Template should NOT have app_database_host or app_database_port - assert ( - "app_database_host" not in template - ), f"Template incorrectly uses scalar 'app_database_host'\n{template}" - assert ( - "app_database_port" not in template - ), f"Template incorrectly uses scalar 'app_database_port'\n{template}" - - # Template SHOULD use a loop - assert "{% for" in template, f"Template should use a loop\n{template}" - assert ( - "app_database" in template - ), f"Template should reference app_database\n{template}" - - def test_json_array_no_index_refs(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. - """ - import json - - json_text = '{"items": [1, 2, 3]}' - parsed = json.loads(json_text) - - loop_candidates = analyze_loops("json", parsed) - flat_items = flatten_config("json", parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - "json", parsed, "app", None, 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 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/utils/diff_configs.py b/utils/diff_configs.py deleted file mode 100644 index b35d6aa..0000000 --- a/utils/diff_configs.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -""" -Side-by-side comparison of original vs regenerated config. - -Usage: - ./diff_configs.py tests/samples/foo.json - ./diff_configs.py tests/samples/tom.toml --context 5 -""" - -import argparse -import sys -from pathlib import Path -import difflib -import yaml -from jinja2 import Environment, StrictUndefined - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from jinjaturtle.core import ( - parse_config, - analyze_loops, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) - - -def colorize(text: str, color: str) -> str: - """Add ANSI color codes.""" - colors = { - "red": "\033[91m", - "green": "\033[92m", - "yellow": "\033[93m", - "blue": "\033[94m", - "reset": "\033[0m", - } - return f"{colors.get(color, '')}{text}{colors['reset']}" - - -def side_by_side_diff(original: str, regenerated: str, width: int = 80): - """Print side-by-side diff.""" - orig_lines = original.splitlines() - regen_lines = regenerated.splitlines() - - # Calculate column width - col_width = width // 2 - 3 - - print( - colorize("ORIGINAL".center(col_width), "blue") - + " | " - + colorize("REGENERATED".center(col_width), "green") - ) - print("-" * col_width + "-+-" + "-" * col_width) - - max_lines = max(len(orig_lines), len(regen_lines)) - - for i in range(max_lines): - orig_line = orig_lines[i] if i < len(orig_lines) else "" - regen_line = regen_lines[i] if i < len(regen_lines) else "" - - # Truncate if too long - if len(orig_line) > col_width - 2: - orig_line = orig_line[: col_width - 5] + "..." - if len(regen_line) > col_width - 2: - regen_line = regen_line[: col_width - 5] + "..." - - # Color lines if different - if orig_line != regen_line: - orig_display = colorize(orig_line.ljust(col_width), "red") - regen_display = colorize(regen_line.ljust(col_width), "green") - else: - orig_display = orig_line.ljust(col_width) - regen_display = regen_line.ljust(col_width) - - print(f"{orig_display} | {regen_display}") - - -def unified_diff(original: str, regenerated: str, filename: str, context: int = 3): - """Print unified diff.""" - orig_lines = original.splitlines(keepends=True) - regen_lines = regenerated.splitlines(keepends=True) - - diff = difflib.unified_diff( - orig_lines, - regen_lines, - fromfile=f"{filename} (original)", - tofile=f"{filename} (regenerated)", - n=context, - ) - - for line in diff: - if line.startswith("+++") or line.startswith("---"): - print(colorize(line.rstrip(), "blue")) - elif line.startswith("@@"): - print(colorize(line.rstrip(), "cyan")) - elif line.startswith("+"): - print(colorize(line.rstrip(), "green")) - elif line.startswith("-"): - print(colorize(line.rstrip(), "red")) - else: - print(line.rstrip()) - - -def main(): - parser = argparse.ArgumentParser( - description="Compare original config with regenerated version", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - parser.add_argument("file", type=Path, help="Config file to check") - - parser.add_argument( - "--mode", - choices=["side-by-side", "unified", "both"], - default="both", - help="Comparison mode (default: both)", - ) - - parser.add_argument( - "--context", - type=int, - default=3, - help="Number of context lines for unified diff (default: 3)", - ) - - parser.add_argument( - "--width", - type=int, - default=160, - help="Terminal width for side-by-side (default: 160)", - ) - - args = parser.parse_args() - - if not args.file.exists(): - print(colorize(f"āŒ File not found: {args.file}", "red")) - return 1 - - print(colorize(f"\n{'=' * 80}", "blue")) - print(colorize(f" Comparing: {args.file}", "blue")) - print(colorize(f"{'=' * 80}\n", "blue")) - - # Read and regenerate - try: - original_text = args.file.read_text() - - fmt, parsed = parse_config(args.file) - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, parsed, "app", original_text, loop_candidates - ) - - variables = yaml.safe_load(ansible_yaml) - env = Environment(undefined=StrictUndefined) - jinja_template = env.from_string(template) - regenerated_text = jinja_template.render(variables) - - # Check if identical - if original_text.strip() == regenerated_text.strip(): - print(colorize("āœ… Files are IDENTICAL (text comparison)\n", "green")) - else: - # Show diff - if args.mode in ("unified", "both"): - print(colorize("\n--- UNIFIED DIFF ---\n", "yellow")) - unified_diff( - original_text, regenerated_text, args.file.name, args.context - ) - - if args.mode in ("side-by-side", "both"): - print(colorize("\n--- SIDE-BY-SIDE COMPARISON ---\n", "yellow")) - side_by_side_diff(original_text, regenerated_text, args.width) - - # Try semantic comparison - print(colorize(f"\n{'=' * 80}", "cyan")) - print(colorize(" Semantic Comparison", "cyan")) - print(colorize(f"{'=' * 80}", "cyan")) - - try: - if fmt == "json": - import json - - if json.loads(original_text) == json.loads(regenerated_text): - print(colorize("āœ… JSON data structures are IDENTICAL", "green")) - else: - print(colorize("āš ļø JSON data structures DIFFER", "yellow")) - elif fmt == "yaml": - if yaml.safe_load(original_text) == yaml.safe_load(regenerated_text): - print(colorize("āœ… YAML data structures are IDENTICAL", "green")) - else: - print(colorize("āš ļø YAML data structures DIFFER", "yellow")) - elif fmt == "toml": - try: - import tomllib - except Exception: - import tomli as tomllib - if tomllib.loads(original_text) == tomllib.loads(regenerated_text): - print(colorize("āœ… TOML data structures are IDENTICAL", "green")) - else: - print(colorize("āš ļø TOML data structures DIFFER", "yellow")) - except Exception as e: - print(colorize(f"ā„¹ļø Could not compare semantically: {e}", "yellow")) - - except Exception as e: - print(colorize(f"āŒ ERROR: {e}", "red")) - import traceback - - traceback.print_exc() - return 1 - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/utils/regenerate.py b/utils/regenerate.py deleted file mode 100644 index f26bb32..0000000 --- a/utils/regenerate.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -""" -Regenerate config files and save all intermediate files. - -Creates: - - original.{ext} - - defaults/main.yml - - templates/config.j2 - - regenerated.{ext} - -Usage: - ./regenerate.py tests/samples/foo.json - ./regenerate.py tests/samples/tom.toml --output-dir tmp/toml_test -""" - -import argparse -import sys -from pathlib import Path -import yaml -from jinja2 import Environment, StrictUndefined - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from jinjaturtle.core import ( - parse_config, - analyze_loops, - flatten_config, - generate_ansible_yaml, - generate_jinja2_template, -) - - -def regenerate_and_save(config_file: Path, output_dir: Path, role_prefix: str = "app"): - """ - Regenerate config and save all intermediate files. - """ - output_dir.mkdir(parents=True, exist_ok=True) - - # Read original - original_text = config_file.read_text() - fmt, parsed = parse_config(config_file) - - # Determine extension - ext = config_file.suffix - - # Save original - original_out = output_dir / f"original{ext}" - original_out.write_text(original_text) - print(f"šŸ“„ Saved: {original_out}") - - # Generate Ansible files - loop_candidates = analyze_loops(fmt, parsed) - flat_items = flatten_config(fmt, parsed, loop_candidates) - - ansible_yaml = generate_ansible_yaml(role_prefix, flat_items, loop_candidates) - template = generate_jinja2_template( - fmt, parsed, role_prefix, original_text, loop_candidates - ) - - # Save Ansible YAML - defaults_dir = output_dir / "defaults" - defaults_dir.mkdir(exist_ok=True) - defaults_file = defaults_dir / "main.yml" - defaults_file.write_text(ansible_yaml) - print(f"šŸ“„ Saved: {defaults_file}") - - # Save template - templates_dir = output_dir / "templates" - templates_dir.mkdir(exist_ok=True) - template_file = templates_dir / "config.j2" - template_file.write_text(template) - print(f"šŸ“„ Saved: {template_file}") - - # Render template - variables = yaml.safe_load(ansible_yaml) - env = Environment(undefined=StrictUndefined) - jinja_template = env.from_string(template) - regenerated_text = jinja_template.render(variables) - - # Save regenerated - regenerated_out = output_dir / f"regenerated{ext}" - regenerated_out.write_text(regenerated_text) - print(f"šŸ“„ Saved: {regenerated_out}") - - # Summary - print(f"\nāœ… All files saved to: {output_dir}") - print("\nšŸ“Š Statistics:") - print(f" Format: {fmt}") - print(f" Loop candidates: {len(loop_candidates)}") - if loop_candidates: - print(" Loops detected:") - for c in loop_candidates: - print(f" - {'.'.join(c.path)}: {len(c.items)} items") - - # Check if identical - if original_text.strip() == regenerated_text.strip(): - print("\nāœ… Original and regenerated are IDENTICAL (text comparison)") - else: - print("\nāš ļø Original and regenerated differ in whitespace/formatting") - print(f" Run: diff {original_out} {regenerated_out}") - - return output_dir - - -def main(): - parser = argparse.ArgumentParser( - description="Regenerate config and save all intermediate files", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s tests/samples/foo.json - %(prog)s tests/samples/tom.toml -o tmp/toml_output - %(prog)s tests/samples/bar.yaml --role-prefix myapp - """, - ) - - parser.add_argument("file", type=Path, help="Config file to process") - - parser.add_argument( - "-o", - "--output-dir", - type=Path, - help="Output directory (default: regenerated_)", - ) - - parser.add_argument( - "-r", - "--role-prefix", - default="app", - help="Ansible role prefix for variables (default: app)", - ) - - args = parser.parse_args() - - if not args.file.exists(): - print(f"āŒ File not found: {args.file}") - return 1 - - # Determine output directory - if args.output_dir: - output_dir = args.output_dir - else: - output_dir = Path(f"regenerated_{args.file.stem}") - - print(f"šŸ”„ Regenerating: {args.file}") - print(f"šŸ“ Output directory: {output_dir}") - print(f"šŸ·ļø Role prefix: {args.role_prefix}\n") - - try: - regenerate_and_save(args.file, output_dir, args.role_prefix) - return 0 - except Exception as e: - print(f"\nāŒ ERROR: {e}") - import traceback - - traceback.print_exc() - return 1 - - -if __name__ == "__main__": - sys.exit(main())