├── .github ├── CODE_OF_CONDUCT.md ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── delete-pr-image.yml │ ├── lint.yml │ ├── pypi-package.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.apache-2.0 ├── LICENSE.bsd-2-clause ├── README.md ├── changelog.d └── towncrier_template.md.jinja ├── pyproject.toml ├── src └── jinjanator │ ├── __init__.py │ ├── cli.py │ ├── context.py │ ├── customize.py │ ├── filters.py │ ├── formats.py │ └── py.typed ├── tests ├── __init__.py ├── conftest.py ├── test_argparse.py ├── test_cli.py ├── test_customize.py ├── test_env.py ├── test_extension.py ├── test_format_options.py ├── test_invalid_data.py ├── test_json_input.py ├── test_nginx_config.py ├── test_output_file.py ├── test_plugin │ ├── jinjanator_test_plugin.py │ └── pyproject.toml ├── test_template_not_found.py ├── test_undefined.py ├── test_unicode.py └── test_yaml_input.py └── workflow-support ├── ci_paths.yml ├── lint_paths.yml ├── make_ci_image.sh └── versions.json /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | . 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kpfleming 2 | liberapay: kpfleming 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - reopened 11 | - synchronize 12 | branches: 13 | - main 14 | schedule: 15 | - cron: "35 4 * * 6" 16 | 17 | jobs: 18 | preflight: 19 | runs-on: ubuntu-22.04 20 | outputs: 21 | image_base: ${{ steps.details.outputs.image_base }} 22 | image_tag: ${{ steps.preflight.outputs.image_tag }} 23 | versions: ${{ steps.get_versions.outputs.versions }} 24 | need_ci: ${{ (steps.preflight.outputs.need_ci == 'true') || (steps.preflight.outputs.need_image == 'true') }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | show-progress: false 29 | - id: get_versions 30 | run: cat workflow-support/versions.json >> $GITHUB_OUTPUT 31 | - id: details 32 | uses: kpfleming/composite-actions/image-details@v2 33 | with: 34 | base_image: python:v3-bookworm-main 35 | - id: preflight 36 | uses: kpfleming/composite-actions/ci-preflight@v2 37 | with: 38 | ci_paths: workflow-support/ci_paths.yml 39 | files_hash: ${{ hashfiles('pyproject.toml', 'workflow-support/make_ci_image.sh', '.github/workflows/ci.yml') }} 40 | base_image_hash: ${{ steps.details.outputs.base_image_hash }} 41 | - id: make-ci-image 42 | uses: kpfleming/composite-actions/make-ci-image@v2 43 | if: steps.preflight.outputs.need_image == 'true' 44 | with: 45 | build_args: jinjanator 46 | base_image: ${{ steps.details.outputs.base_image }} 47 | image_name: ${{ steps.details.outputs.image_base }}:${{ steps.preflight.outputs.image_tag }} 48 | image_cache_key: ${{ steps.preflight.outputs.image_cache_key }} 49 | image_registry: ${{ steps.details.outputs.image_registry }} 50 | registry_username: ${{ secrets.QUAY_BOT_NAME }} 51 | registry_password: ${{ secrets.QUAY_BOT_PASSWORD }} 52 | ci: 53 | needs: 54 | - preflight 55 | strategy: 56 | matrix: 57 | python: ${{ fromJSON(needs.preflight.outputs.versions).python }} 58 | fail-fast: false 59 | uses: ./.github/workflows/test.yml 60 | with: 61 | if: ${{ needs.preflight.outputs.need_ci == 'true' }} 62 | image: ${{ needs.preflight.outputs.image_base }}:${{ needs.preflight.outputs.image_tag }} 63 | python: ${{ matrix.python }} 64 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL" 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | # The branches below must be a subset of the branches above 9 | branches: [main] 10 | schedule: 11 | - cron: "30 22 * * 2" 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | analyze: 18 | name: Analyze 19 | runs-on: ubuntu-latest 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | 25 | strategy: 26 | fail-fast: false 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | show-progress: false 33 | 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v2 36 | with: 37 | languages: python, javascript 38 | 39 | - name: Perform analysis 40 | uses: github/codeql-action/analyze@v2 41 | -------------------------------------------------------------------------------- /.github/workflows/delete-pr-image.yml: -------------------------------------------------------------------------------- 1 | name: Delete PR Image 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | branches: 8 | - main 9 | 10 | jobs: 11 | clean-image: 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - id: details 15 | uses: kpfleming/composite-actions/image-details@v2 16 | with: 17 | base_image: python:bookworm-main 18 | - uses: kpfleming/composite-actions/delete-pr-image@v2 19 | with: 20 | image_registry: ${{ steps.details.outputs.image_registry }} 21 | registry_account: ${{ steps.details.outputs.registry_account }} 22 | registry_token: ${{ secrets.QUAY_API_TOKEN }} 23 | repo_name: ${{ steps.details.outputs.repo_name }} 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | schedule: 8 | - cron: "35 4 * * 6" 9 | 10 | jobs: 11 | preflight: 12 | runs-on: ubuntu-22.04 13 | outputs: 14 | image_base: ${{ steps.details.outputs.image_base }} 15 | need_lint: ${{ steps.preflight.outputs.need_lint == 'true' }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | show-progress: false 20 | - id: details 21 | uses: kpfleming/composite-actions/image-details@v2 22 | with: 23 | base_image: python:bookworm-main 24 | - id: preflight 25 | uses: kpfleming/composite-actions/lint-preflight@v2 26 | with: 27 | lint_paths: workflow-support/lint_paths.yml 28 | lint: 29 | if: needs.preflight.outputs.need_lint == 'true' 30 | runs-on: ubuntu-22.04 31 | needs: 32 | - preflight 33 | container: 34 | image: ${{ needs.preflight.outputs.image_base }}:main 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | show-progress: false 39 | - uses: kpfleming/composite-actions/lint-hatch@v2 40 | -------------------------------------------------------------------------------- /.github/workflows/pypi-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build and verify package, with optional upload to PyPI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | pull_request: 9 | branches: [main] 10 | release: 11 | types: 12 | - published 13 | 14 | permissions: 15 | contents: read 16 | id-token: write 17 | 18 | jobs: 19 | build-package: 20 | name: Build and verify package 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | show-progress: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@v2 30 | 31 | release-pypi: 32 | name: Publish released package to PyPI 33 | environment: release-pypi 34 | if: github.event.action == 'published' 35 | runs-on: ubuntu-latest 36 | needs: build-package 37 | 38 | steps: 39 | - name: Download previously built packages 40 | uses: actions/download-artifact@v4 41 | with: 42 | name: Packages 43 | path: dist 44 | 45 | - name: Upload packages 46 | uses: pypa/gh-action-pypi-publish@release/v1 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | if: 7 | required: false 8 | default: true 9 | type: boolean 10 | image: 11 | required: true 12 | type: string 13 | python: 14 | required: true 15 | type: string 16 | 17 | jobs: 18 | test: 19 | if: inputs.if 20 | runs-on: ubuntu-22.04 21 | container: 22 | image: ${{ inputs.image }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | show-progress: false 27 | - name: build wheel 28 | run: hatch build -t wheel 29 | shell: bash 30 | - name: install project 31 | run: hatch run ci.py${{ inputs.python }}:uv pip install dist/*.whl 32 | shell: bash 33 | - name: run tests 34 | run: hatch run ci.py${{ inputs.python }}:ci 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.egg-info 3 | dist 4 | build 5 | src/jinjanator/version.py 6 | __pycache__ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/tox-dev/pyproject-fmt 3 | rev: "2.3.0" 4 | hooks: 5 | - id: pyproject-fmt 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | rev: v0.6.9 8 | hooks: 9 | - id: ruff-format 10 | - id: ruff 11 | args: [--fix, --exit-non-zero-on-fix] 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [*Keep a 6 | Changelog*](https://keepachangelog.com/en/1.0.0/) and this project 7 | adheres to [*Calendar Versioning*](https://calver.org/). 8 | 9 | The **first number** of the version is the year. 10 | 11 | The **second number** is incremented with each release, starting at 1 12 | for each year. 13 | 14 | The **third number** is when we need to start branches for older 15 | releases (only for emergencies). 16 | 17 | Committed changes for the next release can be found in the ["changelog.d" 18 | directory](https://github.com/kpfleming/jinjanator/tree/main/changelog.d) 19 | in the project repository. 20 | 21 | 28 | 29 | 30 | 31 | ## [25.2.0](https://github.com/kpfleming/jinjanator/tree/25.2.0) - 2025-05-01 32 | 33 | ### Fixes 34 | 35 | - Corrected behavior of '--customize' argument when the customization file 36 | doesn't contain every possible type of customization function 37 | (contributed by @itamaro). 38 | [#48](https://github.com/kpfleming/jinjanator/issues/48) 39 | 40 | ## [25.1.0](https://github.com/kpfleming/jinjanator/tree/25.1.0) - 2025-02-09 41 | 42 | ### Additions 43 | 44 | - Added support for '--filters', '--tests' and '--customize' from j2cli 45 | (contributed by @mlasevich). 46 | [#46](https://github.com/kpfleming/jinjanator/issues/46) 47 | - Added testing against Python 3.13 (again). 48 | 49 | 50 | ## [24.4.0](https://github.com/kpfleming/jinjanator/tree/24.4.0) - 2024-10-13 51 | 52 | ### Backwards-incompatible Changes 53 | 54 | - Added support for Python 3.13, and removed support for Python 3.8. 55 | 56 | - Upgraded to version 24.2.0 of jinjanator-plugins. 57 | 58 | 59 | ## [24.3.0](https://github.com/kpfleming/jinjanator/tree/24.3.0) - 2024-08-03 60 | 61 | ### Additions 62 | 63 | - Support for full 'interpolation' in env-format input files (contributed by @lgtml) 64 | [#36](https://github.com/kpfleming/jinjanator/issues/36) 65 | 66 | ## [24.2.0](https://github.com/kpfleming/jinjanator/tree/24.2.0) - 2024-06-11 67 | 68 | ### Additions 69 | 70 | - Added 'j2' CLI entrypoint, for users converting from 'j2cli'. 71 | [#25](https://github.com/kpfleming/jinjanator/issues/25) 72 | - Added support for Python 3.13. 73 | [#31](https://github.com/kpfleming/jinjanator/issues/31) 74 | 75 | ## [24.1.0](https://github.com/kpfleming/jinjanator/tree/24.1.0) - 2024-04-27 76 | 77 | ### Additions 78 | 79 | - Support for 'extensions' plugins which enable Jinja2 extensions (contributed by @llange) 80 | [#29](https://github.com/kpfleming/jinjanator/issues/29) 81 | 82 | ## [23.7.0](https://github.com/kpfleming/jinjanator/tree/23.7.0) - 2023-10-07 83 | 84 | ### Additions 85 | 86 | - Added Python 3.12 support. 87 | [#23](https://github.com/kpfleming/jinjanator/issues/23) 88 | 89 | 90 | ## [23.6.0](https://github.com/kpfleming/jinjanator/tree/23.6.0) - 2023-08-01 91 | 92 | ### Backwards-incompatible Changes 93 | 94 | - Upgraded to plugins API 23.4. 95 | 96 | 97 | 98 | ### Additions 99 | 100 | - Added support for 'sequence' data in YAML-format input. 101 | [#14](https://github.com/kpfleming/jinjanator/issues/14) 102 | - Added support for 'array' data in JSON-format input. 103 | [#15](https://github.com/kpfleming/jinjanator/issues/15) 104 | - Added list of discovered plugins to '--version' output. 105 | [#16](https://github.com/kpfleming/jinjanator/issues/16) 106 | - Options-related errors from format parsers are now handled. 107 | [#17](https://github.com/kpfleming/jinjanator/issues/17) 108 | 109 | 110 | ## [23.5.0](https://github.com/kpfleming/jinjanator/tree/23.5.0) - 2023-07-24 111 | 112 | ### Additions 113 | 114 | - Added link to Ansible plugin. 115 | 116 | 117 | 118 | ### Fixes 119 | 120 | - Corrected content of LICENSE file. 121 | 122 | - Corrected formatting of README on PyPI. 123 | 124 | 125 | ## [23.4.0](https://github.com/kpfleming/jinjanator/tree/23.4.0) - 2023-07-24 126 | 127 | ### Backwards-incompatible Changes 128 | 129 | - Moved plugin API to the jinjanator-plugins package. 130 | [#12](https://github.com/kpfleming/jinjanator/issues/12) 131 | 132 | 133 | ## [23.3.0](https://github.com/kpfleming/jinjanator/tree/23.3.0) - 2023-07-22 134 | 135 | ### Fixes 136 | 137 | - Disabled Jinja2 'autoescape' feature since it can produce incorrect output. 138 | [#8](https://github.com/kpfleming/jinjanator/issues/8) 139 | - Add missing 'attrs' package to project dependencies. 140 | [#9](https://github.com/kpfleming/jinjanator/issues/9) 141 | 142 | 143 | ## [23.2.0](https://github.com/kpfleming/jinjanator/tree/23.2.0) - 2023-07-22 144 | 145 | ### Fixes 146 | 147 | - Resolved crash when writing final output. Increased test coverage to avoid regressions. 148 | [#7](https://github.com/kpfleming/jinjanator/issues/7) 149 | 150 | 151 | ## [23.1.0](https://github.com/kpfleming/jinjanator/tree/23.1.0) - 2023-07-22 152 | 153 | ### Added 154 | 155 | - Initial release! 156 | -------------------------------------------------------------------------------- /LICENSE.apache-2.0: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE.bsd-2-clause: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Mark Vartanyan 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 17 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jinjanator 2 | 3 | Open Source Initiative Approved License logo 4 | [![CI](https://github.com/kpfleming/jinjanator/workflows/CI%20checks/badge.svg)](https://github.com/kpfleming/jinjanator/actions?query=workflow%3ACI%20checks) 5 | [![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/release/python-3920/) 6 | [![License - Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-9400d3.svg)](https://spdx.org/licenses/Apache-2.0.html) 7 | [![Types - Mypy](https://img.shields.io/badge/Types-Mypy-blue.svg)](https://github.com/python/mypy) 8 | [![Code Style and Quality - Ruff](https://img.shields.io/badge/Code%20Quality-Ruff-red.svg)](https://github.com/astral-sh/ruff) 9 | [![Project Management - Hatch](https://img.shields.io/badge/Project%20Management-Hatch-purple.svg)](https://github.com/pypa/hatch) 10 | [![Testing - Pytest](https://img.shields.io/badge/Testing-Pytest-orange.svg)](https://github.com/pytest-dev/pytest) 11 | 12 | This repo contains `jinjanator`, a CLI tool to render 13 | [Jinja2](https://github.com/pallets/jinja/) templates. It is a fork of 14 | `j2cli`, which itself was a fork of `jinja2-cli`, both of which are no 15 | longer actively maintained. 16 | 17 | Open Source software: [Apache License 2.0](https://spdx.org/licenses/Apache-2.0.html) 18 | 19 | ##   20 | 21 | 22 | Features: 23 | 24 | * Jinja2 templating 25 | * INI, YAML, JSON data sources supported 26 | * Environment variables can be used with or without data files 27 | * Plugins can provide additional formats, filters, tests, extensions and global 28 | functions (see 29 | [jinjanator-plugins](https://github.com/kpfleming/jinjanator-plugins) 30 | for details) 31 | 32 | ## Installation 33 | 34 | ``` 35 | pip install jinjanator 36 | ``` 37 | 38 | ## Available Plugins 39 | 40 | * [jinjanator-plugin-ansible](https://pypi.org/project/jinjanator-plugin-ansible) - 41 | makes Ansible's 'core' filters and tests available during template 42 | rendering 43 | * [jinjanator-plugin-format-toml](https://pypi.org/project/jinjanator-plugin-format-toml) - 44 | provides a TOML parser for input data files 45 | * [jinjanator-plugin-format-xml](https://pypi.org/project/jinjanator-plugin-format-xml) - 46 | provides an XML parser for input data files 47 | 48 | ## Tutorial 49 | 50 | Suppose you have an NGINX configuration file template, `nginx.j2`: 51 | 52 | ```jinja2 53 | server { 54 | listen 80; 55 | server_name {{ nginx.hostname }}; 56 | 57 | root {{ nginx.webroot }}; 58 | index index.htm; 59 | } 60 | ``` 61 | 62 | And you have a JSON file with the data, `nginx.json`: 63 | 64 | ```json 65 | { 66 | "nginx":{ 67 | "hostname": "localhost", 68 | "webroot": "/var/www/project" 69 | } 70 | } 71 | ``` 72 | 73 | This is how you render it into a working configuration file: 74 | 75 | ```bash 76 | $ jinjanate nginx.j2 nginx.json > nginx.conf 77 | ``` 78 | 79 | The output is saved to `nginx.conf`: 80 | 81 | ``` 82 | server { 83 | listen 80; 84 | server_name localhost; 85 | 86 | root /var/www/project; 87 | index index.htm; 88 | } 89 | ``` 90 | 91 | Alternatively, you can use the `-o nginx.conf` or `--output-file 92 | nginx.conf`options to write directly to the file. 93 | 94 | ## Tutorial with environment variables 95 | 96 | Suppose, you have a very simple template, `person.xml.j2`: 97 | 98 | ```jinja2 99 | {{ name }}{{ age }} 100 | ``` 101 | 102 | What is the easiest way to use jinjanator here? 103 | Use environment variables in your Bash script: 104 | 105 | ```bash 106 | $ export name=Andrew 107 | $ export age=31 108 | $ jinjanate /tmp/person.xml.j2 109 | Andrew31 110 | ``` 111 | 112 | ## Using environment variables 113 | 114 | Even when you use a data file as the data source, you can always 115 | access environment variables using the `env()` function: 116 | 117 | ```jinja2 118 | Username: {{ login }} 119 | Password: {{ env("APP_PASSWORD") }} 120 | ``` 121 | 122 | Or, if you prefer, as a filter: 123 | 124 | ```jinja2 125 | Username: {{ login }} 126 | Password: {{ "APP_PASSWORD" | env }} 127 | ``` 128 | 129 | ## CLI Reference 130 | `jinjanate` accepts the following arguments: 131 | 132 | * `template`: Jinja2 template file to render 133 | * `data`: (optional) path to the data used for rendering. 134 | The default is `-`: use stdin. 135 | 136 | There is some special behavior with environment variables: 137 | 138 | * When `data` is not provided (data is `-`), `--format` defaults to 139 | `env` and thus reads environment variables. 140 | 141 | ### Options: 142 | 143 | * `--format FMT, -f FMT`: format for the data file. The default is 144 | `?`: guess from file extension. Supported formats are YAML (.yaml or 145 | .yml), JSON (.json), INI (.ini), and dotenv (.env), plus any formats 146 | provided by plugins you have installed. 147 | * `--format-option OPT`: option to be passed to the parser for the 148 | data format selected with `--format` (or auto-selected). This can be 149 | specified multiple times. Refer to the documentation for the format 150 | itself to learn whether it supports any options. 151 | * `--help, -h`: generates a help message describing usage of the tool. 152 | * `--import-env VAR, -e VAR`: import all environment variables into 153 | the template as `VAR`. To import environment variables into the 154 | global scope, give it an empty string: `--import-env=`. (This 155 | will overwrite any existing variables with the same names!) 156 | * `--output-file OUTFILE, -o OUTFILE`: Write rendered template to a 157 | file. 158 | * `--quiet`: Avoid generating any output on stderr. 159 | * `--undefined`: Allow undefined variables to be used in templates (no 160 | error will be raised). 161 | * `--version`: prints the version of the tool and the Jinja2 package installed. 162 | 163 | ### Customization Options: 164 | 165 | For details on the behavior of these options, see the 166 | [Customization](#customization) section. 167 | 168 | * `--filters PYTHON_FILE` - specify a file of Python source code, 169 | containing additional Jinja2 filters as simple functions. You can 170 | use this option more than once to include multiple files. 171 | 172 | * NOTE: While this option's behavior matches the `j2cli` 173 | documentation, it does not match the `j2cli` implementation. If 174 | you are migrating from `j2cli` and use more than one filters file, 175 | you will need to specify this option once for each file. 176 | 177 | * `--tests PYTHON_FILE` - specify a file of Python source code, 178 | containing additional Jinja2 tests as simple functions. You can use 179 | this option more than once to include multiple files. 180 | 181 | * NOTE: While this option's behavior matches the `j2cli` 182 | documentation, it does not match the `j2cli` implementation. If 183 | you are migrating from `j2cli` and use more than one tests file, 184 | you will need to specify this option once for each file. 185 | 186 | * `--customize PYTHON_FILE` - specify a file of Python source code 187 | containing customization functions. This file can modify the Jinja2 188 | context, add filters/tests, or change Jinja2's configuration. Unlike 189 | `--filters` and `--tests`, this option can only be specified once. 190 | 191 | ## Usage Examples 192 | 193 | Render a template using INI-file data source: 194 | 195 | $ jinjanate config.j2 data.ini 196 | 197 | Render using JSON data source: 198 | 199 | $ jinjanate config.j2 data.json 200 | 201 | Render using YAML data source: 202 | 203 | $ jinjanate config.j2 data.yaml 204 | 205 | Render using JSON data on stdin: 206 | 207 | $ curl http://example.com/service.json | jinjanate --format=json config.j2 - 208 | 209 | Render using environment variables: 210 | 211 | $ jinjanate config.j2 212 | 213 | Or use environment variables from a file: 214 | 215 | $ jinjanate config.j2 data.env 216 | 217 | Or pipe it: (note that you'll have to use "-" in this particular case): 218 | 219 | $ jinjanate --format=env config.j2 - < data.env 220 | 221 | 222 | ## Data Formats 223 | 224 | ### dotenv 225 | Data input from environment variables. 226 | 227 | #### Options 228 | 229 | This format does not support any options. 230 | 231 | #### Usage 232 | 233 | Render directly from the current environment variable values: 234 | 235 | $ jinjanate config.j2 236 | 237 | Or alternatively, read the values from a dotenv file: 238 | 239 | ``` 240 | NGINX_HOSTNAME=localhost 241 | NGINX_WEBROOT=/var/www/project 242 | NGINX_LOGS=/var/log/nginx/ 243 | ``` 244 | 245 | And render with: 246 | 247 | $ jinjanate config.j2 data.env 248 | 249 | Or: 250 | 251 | $ env | jinjanate --format=env config.j2 252 | 253 | If you're going to pipe a dotenv file into `jinjanate`, you'll need to 254 | use "-" as the second argument: 255 | 256 | $ jinjanate config.j2 - < data.env 257 | 258 | ### INI 259 | INI data input format. 260 | 261 | #### Options 262 | 263 | This format does not support any options. 264 | 265 | #### Usage 266 | 267 | data.ini: 268 | 269 | ``` 270 | [nginx] 271 | hostname=localhost 272 | webroot=/var/www/project 273 | logs=/var/log/nginx 274 | ``` 275 | 276 | Usage: 277 | 278 | $ jinjanate config.j2 data.ini 279 | 280 | Or: 281 | 282 | $ cat data.ini | jinjanate --format=ini config.j2 283 | 284 | ### JSON 285 | JSON data input format. 286 | 287 | #### Options 288 | 289 | * `array-name`: accepts a single string (e.g. `array-name=foo`), which 290 | must be a valid Python identifier and not a Python keyword. If this 291 | option is specified, and the JSON data provided is an `array` 292 | (sequence, list), the specified name will be used to make the data 293 | available to the Jinja2 template. Errors will be generated if 294 | `array` data is provided and this option is not specified, or if 295 | this option is specified and the data provided is an `object`. 296 | 297 | #### Usage 298 | 299 | data.json: 300 | 301 | ``` 302 | { 303 | "nginx":{ 304 | "hostname": "localhost", 305 | "webroot": "/var/www/project", 306 | "logs": "/var/log/nginx" 307 | } 308 | } 309 | ``` 310 | 311 | Usage: 312 | 313 | $ jinjanate config.j2 data.json 314 | 315 | Or: 316 | 317 | $ cat data.json | jinjanate --format=ini config.j2 318 | 319 | ### YAML 320 | YAML data input format. 321 | 322 | #### Options 323 | 324 | * `sequence-name`: accepts a single string (e.g. `sequence-name=foo`), 325 | which must be a valid Python identifier and not a Python keyword. If 326 | this option is specified, and the YAML data provided is a `sequence` 327 | (array, list), the specified name will be used to make the data 328 | available to the Jinja2 template. Errors will be generated if 329 | `sequence` data is provided and this option is not specified, or if 330 | this option is specified and the data provided is a `mapping`. 331 | 332 | #### Usage 333 | 334 | data.yaml: 335 | 336 | ``` 337 | nginx: 338 | hostname: localhost 339 | webroot: /var/www/project 340 | logs: /var/log/nginx 341 | ``` 342 | 343 | Usage: 344 | 345 | $ jinjanate config.j2 data.yml 346 | 347 | Or: 348 | 349 | $ cat data.yml | jinjanate --format=yaml config.j2 350 | 351 | ## Filters 352 | 353 | ### `env(varname, default=None)` 354 | Use an environment variable's value in the template. 355 | 356 | This filter is available even when your data source is something other 357 | than the environment. 358 | 359 | Example: 360 | 361 | ```jinja2 362 | User: {{ user_login }} 363 | Pass: {{ "USER_PASSWORD" | env }} 364 | ``` 365 | 366 | You can provide a default value: 367 | 368 | ```jinja2 369 | Pass: {{ "USER_PASSWORD" | env("-none-") }} 370 | ``` 371 | 372 | For your convenience, it's also available as a global function: 373 | 374 | ```jinja2 375 | User: {{ user_login }} 376 | Pass: {{ env("USER_PASSWORD") }} 377 | ``` 378 | 379 | Notice that there must be quotes around the environment variable name 380 | when it is a literal string. 381 | 382 | ## Customization 383 | 384 | Jinjanator supports customizing Jinja2 template processing using two 385 | methods - via simple files containing custom filters or tests, or via 386 | a more advanced "customizations" file that allows you to do all of the 387 | above as well as modify core configuration of the Jinja2 engine. 388 | 389 | ### Using filters and tests files 390 | 391 | The simplest way to add additional filters or tests is via "filters" 392 | and "tests" files. These files contain Python source code consisting 393 | of simple functions. Each function becomes a filter or test. 394 | 395 | Examples: 396 | 397 | `filters.py` 398 | 399 | ```python 400 | # Simple filters file 401 | 402 | def parentheses(message): 403 | """ Put message in parenthesis """ 404 | return f"({message})" 405 | ``` 406 | 407 | `tests.py` 408 | 409 | ```python 410 | # Example of simple tests file 411 | 412 | def an_odd_number(number): 413 | """ test if number is odd """ 414 | return True if (number % 2) else False 415 | ``` 416 | 417 | And a template that uses them: 418 | 419 | ``` 420 | {% for x in range(4) %} 421 | {{x}} is: {% if x is an_odd_number %} 422 | {{- "odd" | parentheses }} 423 | {%- else %} 424 | {{- "even" | parentheses }} 425 | {%- endif %} 426 | {%- endfor %} 427 | ``` 428 | 429 | The output is: 430 | 431 | ``` 432 | $ jinjanate --filter ./filters.py --test ./tests.py simple.j2 433 | 434 | 0 is: (even) 435 | 1 is: (odd) 436 | 2 is: (even) 437 | 3 is: (odd) 438 | ``` 439 | 440 | You can include multiple functions in each file and/or use multiple 441 | files as needed. 442 | 443 | ### Using a customizations file 444 | 445 | A more advanced way to customize your template processing is by using 446 | a "customizations" file. 447 | 448 | Customizations files allow you to: 449 | 450 | * Pass additional keywords to the Jinja2 environment 451 | * Modify the context before it is used for rendering 452 | * Register custom filters and tests 453 | 454 | This is done through *hooks* that you implement in a customization 455 | file in Python code. Each hook is a plain function at the module 456 | level with the exact name as shown below. 457 | 458 | The following hooks are available: 459 | 460 | * `j2_environment_params() -> dict`: returns a `dict` of additional parameters for 461 | [Jinja2 Environment](https://jinja.pocoo.org/docs/2.10/api/#jinja2.Environment). 462 | 463 | * `j2_environment(env: Environment) -> Environment`: lets you 464 | customize the `Environment` object. 465 | 466 | * `alter_context(context: dict) -> dict`: lets you modify the context 467 | variables that are going to be used for template rendering. You can 468 | do all sorts of pre-processing here. 469 | 470 | * `extra_filters() -> dict`: returns a `dict` with extra filters for 471 | Jinja2 472 | 473 | * `extra_tests() -> dict`: returns a `dict` with extra tests for 474 | Jinja2 475 | 476 | All of them are optional. 477 | 478 | The example `customization.py file` for your reference: 479 | 480 | ```python 481 | # 482 | # Example customization.py file for jinjanator 483 | # Contains hooks that modify the way Jinja2 is initialized and used 484 | 485 | def j2_environment_params(): 486 | """ Extra parameters for the Jinja2 Environment """ 487 | # Jinja2 Environment configuration 488 | # https://jinja.pocoo.org/docs/2.10/api/#jinja2.Environment 489 | return dict( 490 | # Just some examples 491 | 492 | # Change block start/end strings 493 | block_start_string='<%', 494 | block_end_string='%>', 495 | # Change variable strings 496 | variable_start_string='<<', 497 | variable_end_string='>>', 498 | # Remove whitespace around blocks 499 | trim_blocks=True, 500 | lstrip_blocks=True, 501 | # Enable line statements: 502 | # http://jinja.pocoo.org/docs/2.10/templates/#line-statements 503 | line_statement_prefix='#', 504 | # Keep \n at the end of a file 505 | keep_trailing_newline=True, 506 | # Enable custom extensions 507 | # http://jinja.pocoo.org/docs/2.10/extensions/#jinja-extensions 508 | extensions=('jinja2.ext.i18n',), 509 | ) 510 | 511 | def j2_environment(env): 512 | """ Modify Jinja2 environment 513 | 514 | :param env: jinja2.environment.Environment 515 | :rtype: jinja2.environment.Environment 516 | """ 517 | env.globals.update( 518 | my_function=lambda v: 'my function says "{}"'.format(v) 519 | ) 520 | return env 521 | 522 | def alter_context(context): 523 | """ Modify the context and return it """ 524 | # An extra variable 525 | context['ADD'] = '127' 526 | return context 527 | 528 | def extra_filters(): 529 | """ Declare some custom filters. 530 | 531 | Returns: dict(name = function) 532 | """ 533 | return dict( 534 | # Example: {{ var | parentheses }} 535 | parentheses=lambda t: '(' + t + ')', 536 | ) 537 | 538 | def extra_tests(): 539 | """ Declare some custom tests 540 | 541 | Returns: dict(name = function) 542 | """ 543 | return dict( 544 | # Example: {% if a|int is custom_odd %}odd{% endif %} 545 | custom_odd=lambda n: True if (n % 2) else False 546 | ) 547 | ``` 548 | 549 | 550 | ## Chat 551 | 552 | If you'd like to chat with the jinjanator community, join us on 553 | [Matrix](https://matrix.to/#/#jinjanator:km6g.us)! 554 | 555 | ## Credits 556 | 557 | This tool was created from [j2cli](https://github.com/kolypto/j2cli), 558 | which itself was created from 559 | [jinja2-cli](https://github.com/mattrobenolt/jinja2-cli). It was 560 | created to bring the project up to 'modern' Python coding, packaging, 561 | and project-management standards, and to support plugins to provide 562 | extensibility. 563 | 564 | ["Standing on the shoulders of 565 | giants"](https://en.wikipedia.org/wiki/Standing_on_the_shoulders_of_giants) 566 | could not be more true than it is in the Python community; this 567 | project relies on many wonderful tools and libraries produced by the 568 | global open source software community, in addition to Python 569 | itself. I've listed many of them below, but if I've overlooked any 570 | please do not be offended :-) 571 | 572 | * [Attrs](https://pypi.org/project/attrs) 573 | * [Hatch-Fancy-PyPI-Readme](https://pypi.org/project/hatch-fancy-pypi-readme) 574 | * [Hatch](https://pypi.org/project/hatch) 575 | * [Jinja2](https://pypi.org/project/jinja2/) 576 | * [Mypy](https://pypi.org/project/mypy) 577 | * [Pluggy](https://pypi.org/project/pluggy) 578 | * [pyproject-fmt](https://pypi.org/project/pyproject-fmt) 579 | * [Pytest](https://pypi.org/project/pytest) 580 | * [Ruff](https://pypi.org/project/ruff) 581 | * [Towncrier](https://pypi.org/project/towncrier) 582 | -------------------------------------------------------------------------------- /changelog.d/towncrier_template.md.jinja: -------------------------------------------------------------------------------- 1 | {%- if versiondata["version"] == "main" -%} 2 | ## Changes for the Upcoming Release 3 | 4 | :::{warning} 5 | These changes reflect the current [development progress](https://github.com/kpfleming/jinjanator/tree/main) and are **not** included in a PyPI release yet. 6 | ::: 7 | {% else -%} 8 | ## [{{ versiondata["version"] }}](https://github.com/kpfleming/jinjanator/tree/{{ versiondata["version"] }}) - {{ versiondata["date"] }} 9 | {%- endif %} 10 | 11 | {% for section, _ in sections.items() %} 12 | {% if sections[section] %} 13 | {% for category, val in definitions.items() if category in sections[section] %} 14 | 15 | ### {{ definitions[category]['name'] }} 16 | 17 | {% for text, values in sections[section][category].items() %} 18 | - {{ text }} 19 | {{ values|join(',\n ') }} 20 | {% endfor %} 21 | 22 | {% endfor %} 23 | {% else %} 24 | No significant changes. 25 | 26 | 27 | {% endif %} 28 | {% endfor %} 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-fancy-pypi-readme", 5 | "hatch-vcs", 6 | "hatchling<1.27", 7 | ] 8 | 9 | [project] 10 | name = "jinjanator" 11 | description = "Command-line interface to Jinja2 for templating in shell scripts." 12 | license = { text = "Apache-2.0" } 13 | authors = [ 14 | { name = "Kevin P. Fleming", email = "jinjanator@kevin.km6g.us" }, 15 | { name = "Mark Vartanyan", email = "kolypto@gmail.com" }, 16 | ] 17 | requires-python = ">=3.9" 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Environment :: Console", 21 | "Intended Audience :: Developers", 22 | "Intended Audience :: System Administrators", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Typing :: Typed", 32 | ] 33 | dynamic = [ 34 | "readme", 35 | "version", 36 | ] 37 | dependencies = [ 38 | "attrs", 39 | "jinja2>=2.7.2", 40 | "jinjanator-plugins==24.2.*", 41 | "python-dotenv>=1", 42 | "pyyaml", 43 | "typing-extensions", 44 | ] 45 | urls."Bug Tracker" = "https://github.com/kpfleming/jinjanator/issues" 46 | urls."Homepage" = "https://github.com/kpfleming/jinjanator" 47 | scripts.j2 = "jinjanator.cli:main" 48 | 49 | scripts.jinjanate = "jinjanator.cli:main" 50 | 51 | [tool.hatch.version] 52 | source = "vcs" 53 | 54 | [tool.hatch.build.hooks.vcs] 55 | version-file = "src/jinjanator/version.py" 56 | 57 | [tool.hatch.build] 58 | artifacts = [ 59 | "src/jinjanator/version.py", 60 | ] 61 | exclude = [ 62 | "*~", 63 | ".github", 64 | ] 65 | 66 | [tool.hatch.build.targets.sdist] 67 | include = [ 68 | "src", 69 | "tests", 70 | "*.md", 71 | ] 72 | 73 | [tool.hatch.build.targets.wheel] 74 | packages = [ 75 | "src/jinjanator", 76 | ] 77 | 78 | [tool.hatch.envs.default] 79 | python = "3.12" 80 | installer = "uv" 81 | python-sources = [ 82 | "external", 83 | ] 84 | 85 | [tool.hatch.envs.changelog] 86 | detached = true 87 | dependencies = [ 88 | "towncrier", 89 | ] 90 | 91 | [tool.hatch.envs.changelog.scripts] 92 | draft = [ 93 | "rm -f changelog.d/*~", 94 | "towncrier build --version main --draft", 95 | ] 96 | release = [ 97 | "rm -f changelog.d/*~", 98 | "towncrier build --yes --version {args}", 99 | ] 100 | 101 | [tool.hatch.envs.lint] 102 | dependencies = [ 103 | "ruff>=0.2.0", 104 | "mypy", 105 | "pyproject-fmt", 106 | "pytest", # needed for type-checking tests 107 | "types-PyYAML", 108 | ] 109 | 110 | [tool.hatch.envs.lint.scripts] 111 | lint = [ 112 | "ruff format", 113 | "ruff check --output-format=full --fix --show-fixes", 114 | "mypy --package jinjanator", 115 | "mypy tests", 116 | "shellcheck workflow-support/*.sh", 117 | "pyproject-fmt pyproject.toml tests/test_plugin/pyproject.toml", 118 | ] 119 | lint-action = [ 120 | "ruff format --check --diff", 121 | "ruff check --output-format=github", 122 | "mypy --package jinjanator", 123 | "mypy tests", 124 | "shellcheck workflow-support/*.sh", 125 | "pyproject-fmt --check pyproject.toml tests/test_plugin/pyproject.toml", 126 | ] 127 | 128 | [tool.hatch.envs.ci] 129 | dependencies = [ 130 | "coverage[toml]", 131 | "pytest", 132 | "pytest-cov", 133 | "pytest-icdiff", 134 | "PyYAML", 135 | ] 136 | 137 | [[tool.hatch.envs.ci.matrix]] 138 | python = [ 139 | "3.9", 140 | "3.10", 141 | "3.11", 142 | "3.12", 143 | "3.13", 144 | ] 145 | 146 | [tool.hatch.envs.ci.scripts] 147 | ci = [ 148 | "rm -f .coverage", 149 | # run tests for 'no plugin' mode 150 | "uv pip uninstall jinjanator-test-plugin", 151 | "pytest -m 'noplugin' --verbose --cov-branch --cov=jinjanator", 152 | # run tests for 'plugin' mode 153 | "uv pip install ./tests/test_plugin", 154 | "pytest -m 'not noplugin' --verbose --cov-append --cov-branch --cov=jinjanator", 155 | "coverage report --show-missing --fail-under=98", 156 | ] 157 | 158 | [tool.hatch.metadata] 159 | allow-direct-references = true 160 | 161 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 162 | content-type = "text/markdown" 163 | 164 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 165 | text = """ 166 | # *jinjanator*: CLI tool for rendering Jinja2 templates 167 | 168 | """ 169 | 170 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 171 | path = "README.md" 172 | start-after = "" 173 | end-before = "" 174 | 175 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 176 | text = """ 177 | ## Release Information 178 | """ 179 | 180 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 181 | path = "CHANGELOG.md" 182 | start-after = "" 183 | pattern = "\n(###.+?\n)## " 184 | 185 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 186 | text = """ 187 | --- 188 | [→ Full Changelog](https://github.com/kpfleming/jinjanator/blob/main/CHANGELOG.md) 189 | """ 190 | 191 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 192 | pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' 193 | replacement = '[\1](https://github.com/kpfleming/jinjanator/tree/main/\g<2>)' 194 | 195 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 196 | pattern = "#(\\d+)" 197 | replacement = "[#\\1](https://github.com/kpfleming/jinjanator/issues/\\1)" 198 | 199 | [tool.ruff] 200 | line-length = 100 201 | 202 | src = [ 203 | "src", 204 | "tests", 205 | ] 206 | lint.select = [ 207 | "ALL", 208 | ] 209 | lint.ignore = [ 210 | "ANN", # Mypy is better at this. 211 | "C901", # Leave complexity to me. 212 | "COM812", # conflicts with formatter 213 | "D", # We have different ideas about docstrings. 214 | "FA100", # Intentionally not using '__future__' for annotations 215 | "ISC001", # conflicts with formatter 216 | "PLR0912", # Leave complexity to me. 217 | "TRY301", # Raise in try blocks can totally make sense. 218 | ] 219 | 220 | lint.per-file-ignores."src/jinjanator/cli.py" = [ 221 | "T201", 222 | ] 223 | lint.per-file-ignores."tests/*" = [ 224 | "PLC1901", # empty strings are falsey, but are less specific in tests 225 | "PT005", # we use always underscores and explicit names 226 | "S101", # assert 227 | "SIM300", # Yoda rocks in tests 228 | ] 229 | lint.per-file-ignores."tests/test_plugin/*" = [ 230 | "INP001", # we don't care that these are in implicit namespace packages 231 | ] 232 | lint.unfixable = [ 233 | "F401", 234 | ] 235 | lint.isort.lines-after-imports = 2 236 | lint.isort.lines-between-types = 1 237 | 238 | [tool.pytest.ini_options] 239 | minversion = "6.0" 240 | xfail_strict = true 241 | testpaths = [ 242 | "tests", 243 | ] 244 | addopts = [ 245 | "-ra", 246 | "--strict-markers", 247 | ] 248 | markers = [ 249 | "noplugin: tests which must run without plugins installed", 250 | ] 251 | 252 | [tool.towncrier] 253 | name = "jinjanator" 254 | package = "jinjanator" 255 | directory = "changelog.d" 256 | filename = "CHANGELOG.md" 257 | start_string = "\n" 258 | template = "changelog.d/towncrier_template.md.jinja" 259 | title_format = "" 260 | issue_format = "[#{issue}](https://github.com/kpfleming/jinjanator/issues/{issue})" 261 | underlines = [ 262 | "", 263 | "", 264 | "", 265 | ] 266 | 267 | [[tool.towncrier.section]] 268 | path = "" 269 | 270 | [[tool.towncrier.type]] 271 | directory = "breaking" 272 | name = "Backwards-incompatible Changes" 273 | showcontent = true 274 | 275 | [[tool.towncrier.type]] 276 | directory = "deprecating" 277 | name = "Deprecations" 278 | showcontent = true 279 | 280 | [[tool.towncrier.type]] 281 | directory = "adding" 282 | name = "Additions" 283 | showcontent = true 284 | 285 | [[tool.towncrier.type]] 286 | directory = "changing" 287 | name = "Changes" 288 | showcontent = true 289 | 290 | [[tool.towncrier.type]] 291 | directory = "fixing" 292 | name = "Fixes" 293 | showcontent = true 294 | 295 | [tool.mypy] 296 | python_version = 3.9 297 | namespace_packages = true 298 | explicit_package_bases = true 299 | check_untyped_defs = true 300 | disallow_any_generics = true 301 | disallow_incomplete_defs = true 302 | disallow_subclassing_any = true 303 | disallow_untyped_calls = true 304 | disallow_untyped_decorators = true 305 | disallow_untyped_defs = true 306 | follow_imports = "normal" 307 | no_implicit_optional = true 308 | strict_equality = true 309 | warn_no_return = true 310 | warn_redundant_casts = true 311 | warn_return_any = true 312 | warn_unused_ignores = true 313 | exclude = [ 314 | 'tests/test_plugin/build', 315 | ] 316 | -------------------------------------------------------------------------------- /src/jinjanator/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__, version 2 | 3 | 4 | __all__ = ("__version__", "version") 5 | -------------------------------------------------------------------------------- /src/jinjanator/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib 3 | import os 4 | import sys 5 | 6 | from collections.abc import Iterable, Mapping, Sequence 7 | from pathlib import Path 8 | from typing import ( 9 | Any, 10 | Callable, 11 | Optional, 12 | TextIO, 13 | Union, 14 | cast, 15 | ) 16 | 17 | import jinja2 18 | import jinjanator_plugins 19 | import pluggy 20 | 21 | from . import customize, filters, formats, version 22 | from .context import read_context_data 23 | from .customize import CustomizationModule 24 | 25 | 26 | class FilePathLoader(jinja2.BaseLoader): 27 | def __init__(self, cwd: Path, encoding: str = "utf-8"): 28 | self.cwd = cwd 29 | self.encoding = encoding 30 | 31 | def get_source( 32 | self, 33 | environment: jinja2.Environment, # noqa: ARG002 34 | template_name: str, 35 | ) -> tuple[str, str, Callable[[], bool]]: 36 | template_path = Path(template_name) 37 | 38 | if not template_path.is_absolute(): 39 | template_path = self.cwd / template_name 40 | 41 | if not template_path.is_file(): 42 | raise jinja2.TemplateNotFound(template_name) 43 | 44 | mtime = template_path.stat().st_mtime 45 | 46 | return ( 47 | template_path.read_text(encoding=self.encoding), 48 | str(template_path), 49 | lambda: template_path.stat().st_mtime == mtime, 50 | ) 51 | 52 | 53 | class Jinja2TemplateRenderer: 54 | ENABLED_EXTENSIONS = ( 55 | "jinja2.ext.i18n", 56 | "jinja2.ext.do", 57 | "jinja2.ext.loopcontrols", 58 | ) 59 | 60 | def __init__( 61 | self, 62 | cwd: Path, 63 | allow_undefined: bool, # noqa: FBT001 64 | j2_env_params: dict[str, Any], 65 | plugin_hook_callers: jinjanator_plugins.PluginHookCallers, 66 | ): 67 | j2_env_params.setdefault("keep_trailing_newline", True) 68 | j2_env_params.setdefault( 69 | "undefined", 70 | jinja2.Undefined if allow_undefined else jinja2.StrictUndefined, 71 | ) 72 | j2_env_params.setdefault("extensions", self.ENABLED_EXTENSIONS) 73 | j2_env_params.setdefault("loader", FilePathLoader(cwd)) 74 | 75 | self.env = jinja2.Environment(**j2_env_params, autoescape=False) # noqa: S701 76 | 77 | for plugin_globals in plugin_hook_callers.plugin_globals(): 78 | self.env.globals |= plugin_globals 79 | 80 | for plugin_filters in plugin_hook_callers.plugin_filters(): 81 | self.env.filters |= plugin_filters 82 | 83 | for plugin_tests in plugin_hook_callers.plugin_tests(): 84 | self.env.tests |= plugin_tests 85 | 86 | for plugin_extensions in plugin_hook_callers.plugin_extensions(): 87 | for extension in plugin_extensions: 88 | self.env.add_extension(extension) 89 | 90 | def render(self, template_name: str, context: Mapping[str, str]) -> str: 91 | return self.env.get_template(template_name).render(context) 92 | 93 | 94 | class UniqueStore(argparse.Action): 95 | """argparse action to restrict options to appearing only once.""" 96 | 97 | def __init__(self, option_strings: Sequence[str], dest: str, **kwargs: Any) -> None: 98 | self.already_seen = False 99 | super().__init__(option_strings, dest, **kwargs) 100 | 101 | def __call__( 102 | self, 103 | parser: argparse.ArgumentParser, 104 | namespace: argparse.Namespace, 105 | values: Union[str, Sequence[Any], None], 106 | option_string: Optional[str] = None, 107 | ) -> None: 108 | if self.already_seen and option_string: 109 | parser.error(option_string + " cannot be specified more than once.") 110 | setattr(namespace, self.dest, values) 111 | self.already_seen = True 112 | 113 | 114 | def print_version_info( 115 | stream: TextIO = sys.stderr, 116 | *, 117 | plugin_identities: Iterable[str], 118 | ) -> None: 119 | print( 120 | f"{Path(sys.argv[0]).name} {version}, Jinja2 {importlib.metadata.version('jinja2')}", 121 | file=stream, 122 | ) 123 | header_printed = False 124 | for plugin in plugin_identities: 125 | if not header_printed: 126 | print("Plugins:", file=stream) 127 | header_printed = True 128 | 129 | print(f" {plugin}", file=stream) 130 | 131 | 132 | class VersionAction(argparse.Action): 133 | def __init__( 134 | self, 135 | option_strings: list[str], 136 | plugin_identities: Iterable[str], 137 | dest: str = argparse.SUPPRESS, 138 | default: str = argparse.SUPPRESS, 139 | help: str = "", # noqa: A002 140 | ): 141 | super().__init__( 142 | option_strings=option_strings, 143 | dest=dest, 144 | default=default, 145 | nargs=0, 146 | help=help, 147 | ) 148 | self.plugin_identities = plugin_identities 149 | 150 | def __call__( 151 | self, 152 | parser: argparse.ArgumentParser, 153 | namespace: argparse.Namespace, # noqa: ARG002 154 | values: Union[str, Sequence[Any], None], # noqa: ARG002 155 | option_string: Optional[str] = None, # noqa: ARG002 156 | ) -> None: 157 | print_version_info(sys.stdout, plugin_identities=self.plugin_identities) 158 | parser.exit() 159 | 160 | 161 | def parse_args( 162 | formats: Mapping[str, type[jinjanator_plugins.Format]], 163 | plugin_identities: Iterable[str], 164 | argv: Optional[Sequence[str]] = None, 165 | ) -> argparse.Namespace: 166 | parser = argparse.ArgumentParser( 167 | prog="jinjanate", 168 | description="Command-line interface to Jinja2 for templating in shell scripts.", 169 | epilog="", 170 | ) 171 | 172 | parser.add_argument( 173 | "-v", 174 | "--version", 175 | action=VersionAction, 176 | help="display version of this program and any installed plugins", 177 | plugin_identities=plugin_identities, 178 | ) 179 | 180 | parser.add_argument( 181 | "-f", 182 | "--format", 183 | action=UniqueStore, 184 | default="?", 185 | help="Input data format", 186 | choices=["?", *list(formats.keys())], 187 | ) 188 | 189 | parser.add_argument( 190 | "--format-option", 191 | action="append", 192 | metavar="option", 193 | dest="format_options", 194 | help="Options for data parser", 195 | ) 196 | 197 | parser.add_argument( 198 | "-e", 199 | "--import-env", 200 | action=UniqueStore, 201 | default=None, 202 | metavar="VAR", 203 | dest="import_env", 204 | help=( 205 | "Import environment variables as `VAR` variable. Use empty string to import" 206 | " into the global scope" 207 | ), 208 | ) 209 | 210 | parser.add_argument( 211 | "--undefined", 212 | action="store_true", 213 | dest="undefined", 214 | help="Allow undefined variables to be used in templates (suppress errors)", 215 | ) 216 | 217 | parser.add_argument( 218 | "--quiet", 219 | action="store_true", 220 | dest="quiet", 221 | help="Suppress informational messages", 222 | ) 223 | 224 | # add args for customize support 225 | customize.add_args(parser) 226 | 227 | parser.add_argument( 228 | "-o", 229 | "--output-file", 230 | metavar="outfile", 231 | dest="output_file", 232 | type=Path, 233 | help="Output to a file instead of stdout", 234 | ) 235 | 236 | parser.add_argument("template", help="Template file to process") 237 | 238 | parser.add_argument( 239 | "data", 240 | nargs="?", 241 | default=None, 242 | type=Path, 243 | help='Input data file name/path; "-" to use stdin', 244 | ) 245 | 246 | return parser.parse_args(argv) 247 | 248 | 249 | def get_hook_callers() -> jinjanator_plugins.PluginHookCallers: 250 | pm = pluggy.PluginManager("jinjanator") 251 | pm.add_hookspecs(jinjanator_plugins.PluginHooks) 252 | pm.register(filters) 253 | pm.register(formats) 254 | pm.load_setuptools_entrypoints("jinjanator") 255 | return cast("jinjanator_plugins.PluginHookCallers", pm.hook) 256 | 257 | 258 | def validate_format_options( 259 | fmt: type[jinjanator_plugins.Format], 260 | options: Optional[Sequence[str]], 261 | ) -> jinjanator_plugins.Format: 262 | if options: 263 | if not fmt.option_names: 264 | raise jinjanator_plugins.FormatOptionUnknownError(fmt, options[0]) 265 | 266 | for opt in options: 267 | if opt.split("=")[0] not in fmt.option_names: 268 | raise jinjanator_plugins.FormatOptionUnknownError(fmt, opt) 269 | 270 | return fmt(options) 271 | 272 | 273 | def render_command( 274 | cwd: Path, 275 | environ: Mapping[str, str], 276 | stdin: Optional[TextIO], 277 | argv: Sequence[str], 278 | ) -> str: 279 | plugin_hook_callers = get_hook_callers() 280 | 281 | available_formats: dict[str, type[jinjanator_plugins.Format]] = {} 282 | 283 | for plugin_formats in plugin_hook_callers.plugin_formats(): 284 | available_formats |= plugin_formats 285 | 286 | plugin_identities = plugin_hook_callers.plugin_identities() 287 | 288 | args = parse_args(available_formats, plugin_identities, argv[1:]) 289 | 290 | if not args.quiet: 291 | print_version_info(plugin_identities=plugin_identities) 292 | 293 | if args.format == "?": 294 | if args.data is None or str(args.data) == "-": 295 | args.format = "env" 296 | else: 297 | suffix = args.data.suffix 298 | for k, v in available_formats.items(): 299 | if v.suffixes and suffix in v.suffixes: 300 | args.format = k 301 | break 302 | if args.format == "?": 303 | print( 304 | f"No format which can read '{suffix}' files available", 305 | file=sys.stderr, 306 | ) 307 | raise SystemExit(1) 308 | 309 | # We always expect a file; 310 | # unless the user wants 'env', and there's no input file provided. 311 | if args.format == "env" and args.data is None: 312 | """ 313 | With the "env" format, if no dotenv filename is provided, 314 | we have two options: 1. The user wants to use the current 315 | environment 2. The user is feeding a dotenv file at stdin. 316 | Depending on whether we have data at stdin, we'll need to 317 | choose between the two. 318 | 319 | The problem is that in Linux, you can't reliably determine 320 | whether there is any data at stdin: some environments would 321 | open the descriptor even though they're not going to feed any 322 | data in. That's why many applications would ask you to 323 | explicitly specify a '-' when stdin should be used. 324 | 325 | And this is what we're going to do here as well. The script, 326 | however, would give the user a hint that they should use '-'. 327 | """ 328 | input_data_f = None 329 | else: 330 | input_data_f = stdin if args.data is None or str(args.data) == "-" else args.data.open() 331 | 332 | fmt = validate_format_options(available_formats[args.format], args.format_options) 333 | 334 | if args.format == "env" and input_data_f is None: 335 | context = environ 336 | else: 337 | context = read_context_data( 338 | fmt, 339 | input_data_f, 340 | environ, 341 | args.import_env, 342 | ) 343 | 344 | customizations = CustomizationModule.from_file(args.customize) 345 | 346 | context = customizations.alter_context(context) 347 | 348 | renderer = Jinja2TemplateRenderer( 349 | cwd, 350 | args.undefined, 351 | j2_env_params=customizations.j2_environment_params(), 352 | plugin_hook_callers=plugin_hook_callers, 353 | ) 354 | 355 | customize.apply(customizations, renderer.env, filters=args.filters, tests=args.tests) 356 | 357 | try: 358 | result = renderer.render(args.template, context) 359 | except jinja2.exceptions.UndefinedError as e: 360 | # When there's data at stdin, tell the user they should use '-' 361 | try: 362 | stdin_has_data = stdin is not None and not stdin.isatty() 363 | if args.format == "env" and args.data is None and stdin_has_data: 364 | extra_info = ( 365 | "\n\nIf you're trying to pipe a .env file, please run me with a '-'" 366 | " as the data file name:\n$ {cmd} {argv} -".format( 367 | cmd=Path(sys.argv[0]).name, 368 | argv=" ".join(sys.argv[1:]), 369 | ) 370 | ) 371 | e.args = (e.args[0] + extra_info,) + e.args[1:] 372 | except: # noqa: E722, S110 373 | # The above code is so optional that any, ANY, error, is ignored 374 | pass 375 | 376 | # Proceed 377 | raise 378 | 379 | if args.output_file: 380 | with args.output_file.open("w") as f: 381 | f.write(result) 382 | f.close() 383 | return "" 384 | 385 | return result 386 | 387 | 388 | def main(args: Optional[list[str]] = None) -> Optional[int]: 389 | try: 390 | if args is None: # pragma: no cover 391 | args = sys.argv 392 | 393 | output = render_command(Path.cwd(), os.environ, sys.stdin, args) 394 | except jinjanator_plugins.FormatOptionUnknownError as exc: 395 | print(str(exc), file=sys.stderr) 396 | return 2 397 | except jinjanator_plugins.FormatOptionUnsupportedError as exc: 398 | print(str(exc), file=sys.stderr) 399 | return 3 400 | except jinjanator_plugins.FormatOptionValueError as exc: 401 | print(str(exc), file=sys.stderr) 402 | return 4 403 | except SystemExit as exc: 404 | if isinstance(exc.code, int): 405 | return exc.code 406 | 407 | return 1 408 | 409 | sys.stdout.write(output) 410 | 411 | return None 412 | -------------------------------------------------------------------------------- /src/jinjanator/context.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Any, Optional, TextIO 3 | 4 | from jinjanator_plugins import ( 5 | Format, 6 | ) 7 | 8 | 9 | def read_context_data( 10 | fmt: Format, 11 | f: Optional[TextIO], 12 | environ: Mapping[str, str], 13 | import_env: Optional[str] = None, 14 | ) -> Mapping[str, Any]: 15 | if not f: 16 | msg = "no input supplied" 17 | raise ValueError(msg) 18 | 19 | context: dict[str, Any] = {} 20 | 21 | result = fmt.parse(f.read()) 22 | 23 | context |= result 24 | 25 | if import_env is not None: 26 | if import_env == "": 27 | context |= environ 28 | else: 29 | context[import_env] = environ 30 | 31 | return context 32 | -------------------------------------------------------------------------------- /src/jinjanator/customize.py: -------------------------------------------------------------------------------- 1 | """ 2 | Add Customize/Filters/Tests functionality from J2CLI 3 | 4 | This code was ported from https://github.com/kolypto/j2cli 5 | """ 6 | 7 | import contextlib 8 | import inspect 9 | 10 | from argparse import ArgumentParser 11 | from collections.abc import Mapping 12 | from importlib.machinery import SourceFileLoader 13 | from types import FunctionType, ModuleType 14 | from typing import Any, ClassVar, Optional 15 | 16 | import jinja2 17 | 18 | 19 | def imp_load_source(module_name: str, module_path: str) -> ModuleType: 20 | """ 21 | Drop-in Replacement for imp.load_source() function in pre-3.12 python 22 | 23 | Source: https://github.com/python/cpython/issues/104212 24 | """ 25 | loader = SourceFileLoader(module_name, module_path) 26 | module = ModuleType(loader.name) 27 | loader.exec_module(module) 28 | return module 29 | 30 | 31 | class CustomizationModule: 32 | """The interface for customization functions, defined as module-level 33 | functions""" 34 | 35 | def __init__(self, module: Optional[ModuleType] = None): 36 | if module is not None: 37 | # Import every module function as a method on ourselves 38 | for name in self._IMPORTED_METHOD_NAMES: 39 | with contextlib.suppress(AttributeError): 40 | setattr(self, name, getattr(module, name)) 41 | 42 | # stubs 43 | 44 | def j2_environment_params(self) -> dict[str, Any]: 45 | return {} 46 | 47 | def j2_environment(self, env: jinja2.Environment) -> jinja2.Environment: 48 | return env 49 | 50 | def alter_context(self, context: Mapping[str, Any]) -> Mapping[str, Any]: 51 | return context 52 | 53 | def extra_filters(self) -> Mapping[str, FunctionType]: 54 | return {} 55 | 56 | def extra_tests(self) -> Mapping[str, FunctionType]: 57 | return {} 58 | 59 | _IMPORTED_METHOD_NAMES: ClassVar = [ 60 | f.__name__ 61 | for f in (j2_environment_params, j2_environment, alter_context, extra_filters, extra_tests) 62 | ] 63 | 64 | @classmethod 65 | def from_file(cls, filename: str) -> "CustomizationModule": 66 | """Create Customize object""" 67 | if filename is not None: 68 | return cls(imp_load_source("customize-module", filename)) 69 | return cls(None) 70 | 71 | 72 | def import_functions(filename: str) -> Mapping[str, FunctionType]: 73 | """Import functions from file, return as a dictionary""" 74 | m = imp_load_source("imported-funcs", filename) 75 | return {name: func for name, func in inspect.getmembers(m) if inspect.isfunction(func)} 76 | 77 | 78 | def register_filters(j2env: jinja2.Environment, filters: Mapping[str, FunctionType]) -> None: 79 | """Register additional filters""" 80 | j2env.filters.update(filters) 81 | 82 | 83 | def register_tests(j2env: jinja2.Environment, tests: Mapping[str, FunctionType]) -> None: 84 | """Register additional tests""" 85 | j2env.tests.update(tests) # type: ignore[arg-type] 86 | 87 | 88 | def import_filters(renderer_env: jinja2.Environment, filename: str) -> None: 89 | """Import filters from a file""" 90 | register_filters(renderer_env, import_functions(filename)) 91 | 92 | 93 | def import_tests(renderer_env: jinja2.Environment, filename: str) -> None: 94 | """Import tests from a file""" 95 | register_tests(renderer_env, import_functions(filename)) 96 | 97 | 98 | def apply( 99 | customize: CustomizationModule, 100 | renderer_env: jinja2.Environment, 101 | filters: list[str], 102 | tests: list[str], 103 | ) -> None: 104 | """Apply customizations""" 105 | customize.j2_environment(renderer_env) 106 | 107 | for fname in filters: 108 | import_filters(renderer_env, fname) 109 | 110 | for fname in tests: 111 | import_tests(renderer_env, fname) 112 | 113 | register_filters(renderer_env, customize.extra_filters()) 114 | 115 | register_tests(renderer_env, customize.extra_tests()) 116 | 117 | 118 | def add_args(parser: ArgumentParser) -> ArgumentParser: 119 | """Add args to the parser""" 120 | 121 | parser.add_argument( 122 | "--customize", 123 | default=None, 124 | metavar="python-file.py", 125 | dest="customize", 126 | help="A file of Python source code that implements hooks to fine-tune Jinja2 behavior", 127 | ) 128 | 129 | parser.add_argument( 130 | "--filters", 131 | action="append", 132 | default=[], 133 | metavar="filters-file.py", 134 | dest="filters", 135 | help="Load custom Jinja2 filters from a file of Python source code." 136 | " All top-level functions in the file are imported as Jinja2 filters.", 137 | ) 138 | 139 | parser.add_argument( 140 | "--tests", 141 | action="append", 142 | default=[], 143 | metavar="tests-file.py", 144 | dest="tests", 145 | help="Load custom Jinja2 tests from file of Python source code." 146 | " All top-level functions in the file are imported as Jinja2 tests.", 147 | ) 148 | return parser 149 | -------------------------------------------------------------------------------- /src/jinjanator/filters.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from typing import Optional 4 | 5 | from jinjanator_plugins import ( 6 | Filters, 7 | Globals, 8 | plugin_filters_hook, 9 | plugin_globals_hook, 10 | ) 11 | 12 | 13 | def env(varname: str, default: Optional[str] = None) -> str: 14 | """Use an environment variable's value inside your template. 15 | 16 | This filter is available even when your data source is something other that the environment. 17 | 18 | Example: 19 | ------- 20 | ```jinja2 21 | User: {{ user_login }} 22 | Pass: {{ "USER_PASSWORD"|env }} 23 | ``` 24 | 25 | You can provide the default value: 26 | 27 | ```jinja2 28 | Pass: {{ "USER_PASSWORD"|env("-none-") }} 29 | ``` 30 | 31 | For your convenience, it's also available as a function: 32 | 33 | ```jinja2 34 | User: {{ user_login }} 35 | Pass: {{ env("USER_PASSWORD") }} 36 | ``` 37 | 38 | Notice that there must be quotes around the environment variable name 39 | """ 40 | if default is not None: 41 | # With the default, there's never an error 42 | return os.getenv(varname, default) 43 | 44 | # Raise KeyError when not provided 45 | return os.environ[varname] 46 | 47 | 48 | @plugin_filters_hook 49 | def plugin_filters() -> Filters: 50 | return {"env": env} 51 | 52 | 53 | @plugin_globals_hook 54 | def plugin_globals() -> Globals: 55 | return {"env": env} 56 | -------------------------------------------------------------------------------- /src/jinjanator/formats.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import keyword 4 | 5 | from collections.abc import Iterable, Mapping 6 | from io import StringIO 7 | from typing import Any, Optional 8 | 9 | import yaml 10 | 11 | from dotenv import dotenv_values 12 | from jinjanator_plugins import ( 13 | FormatOptionUnsupportedError, 14 | FormatOptionValueError, 15 | Formats, 16 | plugin_formats_hook, 17 | ) 18 | 19 | 20 | class INIFormat: 21 | name = "ini" 22 | suffixes: Optional[Iterable[str]] = (".ini",) 23 | option_names: Optional[Iterable[str]] = () 24 | 25 | def __init__(self, options: Optional[Iterable[str]]) -> None: 26 | pass 27 | 28 | def parse(self, data_string: str) -> Mapping[str, Any]: 29 | """INI data input format. 30 | 31 | data.ini: 32 | 33 | ``` 34 | [nginx] 35 | hostname=localhost 36 | webroot=/var/www/project 37 | logs=/var/log/nginx 38 | ``` 39 | 40 | Usage: 41 | 42 | $ j2 config.j2 data.ini 43 | $ cat data.ini | j2 --format=ini config.j2 44 | """ 45 | 46 | class MyConfigParser(configparser.ConfigParser): 47 | def as_dict(self) -> Mapping[str, Any]: 48 | d = dict(self._sections) # type: ignore[attr-defined] 49 | for k, v in d.items(): 50 | d[k] = self._defaults | v # type: ignore[attr-defined] 51 | d[k].pop("__name__", None) 52 | return d 53 | 54 | ini = MyConfigParser() 55 | ini.read_string(data_string) 56 | 57 | return ini.as_dict() 58 | 59 | 60 | class JSONFormat: 61 | name = "json" 62 | suffixes: Optional[Iterable[str]] = (".json",) 63 | option_names: Optional[Iterable[str]] = "array-name" 64 | 65 | def __init__(self, options: Optional[Iterable[str]]) -> None: 66 | self.array_name: Optional[str] = None 67 | if options: 68 | for option in options: 69 | try: 70 | opt, val = option.split("=") 71 | except ValueError as exc: 72 | raise FormatOptionValueError( 73 | self, 74 | option, 75 | "", 76 | "contains more than one '='", 77 | ) from exc 78 | 79 | if not val.isidentifier(): 80 | raise FormatOptionValueError( 81 | self, 82 | opt, 83 | val, 84 | "is not a valid Python identifier", 85 | ) 86 | 87 | if keyword.iskeyword(val): 88 | raise FormatOptionValueError(self, opt, val, "is a Python keyword") 89 | 90 | self.array_name = val 91 | 92 | def parse(self, data_string: str) -> Mapping[str, Any]: 93 | """JSON data input format. 94 | 95 | data.json: 96 | 97 | ``` 98 | { 99 | "nginx":{ 100 | "hostname": "localhost", 101 | "webroot": "/var/www/project", 102 | "logs": "/var/log/nginx" 103 | } 104 | } 105 | ``` 106 | 107 | Usage: 108 | 109 | $ j2 config.j2 data.json 110 | $ cat data.json | j2 --format=ini config.j2 111 | """ 112 | 113 | try: 114 | context = json.loads(data_string) 115 | except json.decoder.JSONDecodeError as exc: 116 | msg = "JSON input is neither an object nor an array" 117 | raise TypeError(msg) from exc 118 | 119 | if isinstance(context, dict): 120 | if self.array_name: 121 | raise FormatOptionUnsupportedError( 122 | self, 123 | "array-name", 124 | "cannot be used with object (dictionary) input", 125 | ) 126 | 127 | return context 128 | 129 | if not self.array_name: 130 | raise FormatOptionUnsupportedError( 131 | self, 132 | "array-name", 133 | "must be specified for array (list) input", 134 | ) 135 | 136 | return {self.array_name: context} 137 | 138 | 139 | class YAMLFormat: 140 | name = "yaml" 141 | suffixes: Optional[Iterable[str]] = (".yaml", ".yml") 142 | option_names: Optional[Iterable[str]] = "sequence-name" 143 | 144 | def __init__(self, options: Optional[Iterable[str]]) -> None: 145 | self.sequence_name: Optional[str] = None 146 | if options: 147 | for option in options: 148 | try: 149 | opt, val = option.split("=") 150 | except ValueError as exc: 151 | raise FormatOptionValueError( 152 | self, 153 | option, 154 | "", 155 | "contains more than one '='", 156 | ) from exc 157 | 158 | if not val.isidentifier(): 159 | raise FormatOptionValueError( 160 | self, 161 | opt, 162 | val, 163 | "is not a valid Python identifier", 164 | ) 165 | 166 | if keyword.iskeyword(val): 167 | raise FormatOptionValueError(self, opt, val, "is a Python keyword") 168 | 169 | self.sequence_name = val 170 | 171 | def parse( 172 | self, 173 | data_string: str, 174 | ) -> Mapping[str, Any]: 175 | """YAML data input format. 176 | 177 | data.yaml: 178 | 179 | ``` 180 | nginx: 181 | hostname: localhost 182 | webroot: /var/www/project 183 | logs: /var/log/nginx 184 | ``` 185 | 186 | Usage: 187 | 188 | $ j2 config.j2 data.yml 189 | $ cat data.yml | j2 --format=yaml config.j2 190 | """ 191 | 192 | context = yaml.safe_load(data_string) 193 | 194 | if isinstance(context, dict): 195 | if self.sequence_name: 196 | raise FormatOptionUnsupportedError( 197 | self, 198 | "sequence-name", 199 | "cannot be used with mapping (dictionary) input", 200 | ) 201 | 202 | return context 203 | 204 | if isinstance(context, list): 205 | if not self.sequence_name: 206 | raise FormatOptionUnsupportedError( 207 | self, 208 | "sequence-name", 209 | "must be specified for sequence (array) input", 210 | ) 211 | 212 | return {self.sequence_name: context} 213 | 214 | msg = "YAML input is neither a mapping nor a sequence" 215 | raise TypeError(msg) 216 | 217 | 218 | class EnvFormat: 219 | name = "env" 220 | suffixes: Optional[Iterable[str]] = (".env",) 221 | option_names: Optional[Iterable[str]] = () 222 | 223 | def __init__(self, options: Optional[Iterable[str]]) -> None: 224 | pass 225 | 226 | def parse(self, data_string: str) -> Mapping[str, str]: 227 | """Data input from environment variables. 228 | 229 | Render directly from the current environment variable values: 230 | 231 | $ j2 config.j2 232 | 233 | Or alternatively, read the values from a dotenv file: 234 | 235 | ``` 236 | NGINX_HOSTNAME=localhost 237 | NGINX_WEBROOT=/var/www/project 238 | NGINX_LOGS=/var/log/nginx/ 239 | ``` 240 | 241 | And render with: 242 | 243 | $ j2 config.j2 data.env 244 | $ env | j2 --format=env config.j2 245 | 246 | If you're going to pipe a dotenv file into `j2`, you'll need to 247 | use "-" as the second argument to explicitly: 248 | 249 | $ j2 config.j2 - < data.env 250 | """ 251 | 252 | data = StringIO(data_string) 253 | results_dict = dotenv_values(stream=data) 254 | 255 | return {k: v if v is not None else "" for (k, v) in results_dict.items()} 256 | 257 | 258 | @plugin_formats_hook 259 | def plugin_formats() -> Formats: 260 | return { 261 | INIFormat.name: INIFormat, 262 | JSONFormat.name: JSONFormat, 263 | YAMLFormat.name: YAMLFormat, 264 | EnvFormat.name: EnvFormat, 265 | } 266 | -------------------------------------------------------------------------------- /src/jinjanator/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kpfleming/jinjanator/b7eda9f963d5ac73c6dd178ea930c8db1c9081ff/src/jinjanator/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping, Sequence 2 | from pathlib import Path 3 | from typing import Callable 4 | 5 | from attrs import define 6 | 7 | from jinjanator.cli import render_command 8 | 9 | 10 | @define(kw_only=True) 11 | class FilePair: 12 | template_file: Path 13 | data_file: Path 14 | 15 | 16 | FilePairFactory = Callable[[str, str, str], FilePair] 17 | 18 | 19 | def render_file( 20 | files: FilePair, 21 | options: Sequence[str], 22 | ) -> str: 23 | return render_command( 24 | Path.cwd(), 25 | {}, 26 | None, 27 | ["", *options, str(files.template_file), str(files.data_file)], 28 | ) 29 | 30 | 31 | def render_implicit_stream( 32 | files: FilePair, 33 | options: Sequence[str], 34 | ) -> str: 35 | with files.data_file.open() as data: 36 | return render_command( 37 | Path.cwd(), 38 | {}, 39 | data, 40 | ["", *options, str(files.template_file)], 41 | ) 42 | 43 | 44 | def render_explicit_stream( 45 | files: FilePair, 46 | options: Sequence[str], 47 | ) -> str: 48 | with files.data_file.open() as data: 49 | return render_command( 50 | Path.cwd(), 51 | {}, 52 | data, 53 | ["", *options, str(files.template_file), "-"], 54 | ) 55 | 56 | 57 | def render_env( 58 | files: FilePair, 59 | options: Sequence[str], 60 | env: Mapping[str, str], 61 | ) -> str: 62 | return render_command( 63 | Path.cwd(), 64 | env, 65 | None, 66 | ["", *options, str(files.template_file)], 67 | ) 68 | 69 | 70 | def render_file_env( 71 | files: FilePair, 72 | options: Sequence[str], 73 | env: Mapping[str, str], 74 | ) -> str: 75 | return render_command( 76 | Path.cwd(), 77 | env, 78 | None, 79 | ["", *options, str(files.template_file), str(files.data_file)], 80 | ) 81 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from . import FilePair, FilePairFactory 6 | 7 | 8 | @pytest.fixture 9 | def make_file_pair(tmp_path: Path) -> FilePairFactory: 10 | def _make_file_pair( 11 | template_content: str, 12 | data_content: str, 13 | data_format: str, 14 | ) -> FilePair: 15 | template_file = tmp_path / "template.j2" 16 | template_file.write_text(template_content) 17 | data_file = tmp_path / f"data.{data_format}" 18 | data_file.write_text(data_content) 19 | return FilePair(template_file=template_file, data_file=data_file) 20 | 21 | return _make_file_pair 22 | -------------------------------------------------------------------------------- /tests/test_argparse.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | from jinjanator.cli import parse_args 7 | 8 | 9 | class FakeFormat: 10 | name = "env" 11 | suffixes: Optional[Iterable[str]] = (".env",) 12 | option_names: Optional[Iterable[str]] = () 13 | 14 | def __init__(self, options: Optional[Iterable[str]]) -> None: 15 | pass 16 | 17 | def parse( 18 | self, 19 | data_string: str, # noqa: ARG002 20 | ) -> Mapping[str, str]: 21 | return {"foo": "bar"} 22 | 23 | 24 | def test_invalid_arg() -> None: 25 | """ 26 | Ensure that an invalid argument is not accepted. 27 | """ 28 | with pytest.raises(SystemExit): 29 | parse_args({}, [], ["--test-invalid-arg"]) 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "args", 34 | [ 35 | ["--format", "env"], 36 | ["--format-option", "opt"], 37 | ["--import-env", "env"], 38 | ["--output-file", "output"], 39 | ["--quiet"], 40 | ["--undefined"], 41 | ["-e", "env"], 42 | ["-f", "env"], 43 | ["-o", "output"], 44 | ["--customize", "customize.py"], 45 | ["--filters", "filename.py"], 46 | ["--tests", "filename.py"], 47 | ], 48 | ) 49 | def test_args(args: list[str]) -> None: 50 | """ 51 | Ensure that known arguments are accepted. 52 | """ 53 | parse_args({"env": FakeFormat}, [], [*args, "template"]) 54 | 55 | 56 | def test_version() -> None: 57 | """ 58 | Ensure that '--version' argument is accepted and program exits without an error. 59 | """ 60 | with pytest.raises(SystemExit) as excinfo: 61 | parse_args({}, [], ["--version"]) 62 | assert 0 == excinfo.value.code 63 | 64 | 65 | @pytest.mark.xfail 66 | @pytest.mark.parametrize( 67 | "args", 68 | [ 69 | ["--format", "env", "-f", "env"], 70 | ["--import-env", "env", "-e", "env"], 71 | ], 72 | ) 73 | def test_duplicate_args(args: list[str]) -> None: 74 | """ 75 | Ensure that duplicate arguments are not accepted. 76 | """ 77 | parse_args({"env": FakeFormat}, [], [*args, "template"]) 78 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | import jinjanator.cli 6 | 7 | from . import ( 8 | FilePairFactory, 9 | render_file, 10 | ) 11 | 12 | 13 | def test_quiet(make_file_pair: FilePairFactory, capsys: Any) -> None: 14 | files = make_file_pair("Hello {{name}}!", "name=Blart", "env") 15 | render_file(files, ["--quiet"]) 16 | captured = capsys.readouterr() 17 | assert 0 == len(captured.err) 18 | 19 | 20 | def test_unavailable_suffix(make_file_pair: FilePairFactory, capsys: Any) -> None: 21 | files = make_file_pair("Hello {{name}}!", "name=Blart", "xyz") 22 | with pytest.raises( 23 | SystemExit, 24 | ): 25 | render_file(files, []) 26 | assert "No format which can read '.xyz' files available" == capsys.readouterr().err.strip() 27 | 28 | 29 | def test_main_normal(make_file_pair: FilePairFactory, capsys: Any) -> None: 30 | files = make_file_pair("Hello {{name}}!", "name=Blart", "env") 31 | assert jinjanator.cli.main(["", str(files.template_file), str(files.data_file)]) is None 32 | assert "Hello Blart!" == capsys.readouterr().out 33 | 34 | 35 | def test_main_failure(make_file_pair: FilePairFactory) -> None: 36 | files = make_file_pair("Hello {{name}}!", "name=Blart", "xyz") 37 | assert 1 == jinjanator.cli.main( 38 | ["", str(files.template_file), str(files.data_file)], 39 | ) 40 | 41 | 42 | def test_main_version(capsys: Any) -> None: 43 | """ 44 | Ensure that the 'test' plugin's identity string is included in the '--version' output. 45 | """ 46 | assert 0 == jinjanator.cli.main(["", "--version"]) 47 | outlines = capsys.readouterr().out.splitlines() 48 | assert "Plugins:" == outlines[1] 49 | assert " test" == outlines[2] 50 | 51 | 52 | @pytest.mark.noplugin 53 | def test_main_version_no_plugins(capsys: Any) -> None: 54 | """ 55 | Ensure that the 'test' plugin's identity string is not included in the '--version' output. 56 | """ 57 | assert 0 == jinjanator.cli.main(["", "--version"]) 58 | outlines = capsys.readouterr().out.splitlines() 59 | assert 1 == len(outlines) 60 | 61 | 62 | def test_main_format_option_unknown(make_file_pair: FilePairFactory) -> None: 63 | files = make_file_pair("Hello {{name}}!", "name=Blart", "null") 64 | assert 2 == jinjanator.cli.main( # noqa: PLR2004 65 | [ 66 | "", 67 | "--format-option", 68 | "midge", 69 | str(files.template_file), 70 | str(files.data_file), 71 | ], 72 | ) 73 | 74 | 75 | def test_main_format_option_unsupported(make_file_pair: FilePairFactory) -> None: 76 | files = make_file_pair("Hello {{name}}!", "name=Blart", "null") 77 | assert 3 == jinjanator.cli.main( # noqa: PLR2004 78 | ["", "--format-option", "uns", str(files.template_file), str(files.data_file)], 79 | ) 80 | 81 | 82 | def test_main_format_option_value_error(make_file_pair: FilePairFactory) -> None: 83 | files = make_file_pair("Hello {{name}}!", "name=Blart", "null") 84 | assert 4 == jinjanator.cli.main( # noqa: PLR2004 85 | ["", "--format-option", "val", str(files.template_file), str(files.data_file)], 86 | ) 87 | -------------------------------------------------------------------------------- /tests/test_customize.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | from textwrap import dedent 5 | from typing import Callable 6 | 7 | import pytest 8 | 9 | from jinjanator.cli import render_command 10 | 11 | 12 | DirMakerTool = Callable[..., Namespace] 13 | 14 | 15 | @dataclass 16 | class FileContent: 17 | """Holds filename and content""" 18 | 19 | filename: str 20 | content: str 21 | dedent: bool = True 22 | 23 | @property 24 | def clean_content(self) -> str: 25 | """Get cleaned up content""" 26 | return dedent(self.content) if self.dedent else self.content 27 | 28 | 29 | @pytest.fixture 30 | def dir_maker(tmp_path: Path) -> DirMakerTool: 31 | """Maker""" 32 | 33 | def _dir_maker(**kwargs: FileContent) -> Namespace: 34 | result = {} 35 | for name, file in kwargs.items(): 36 | filename = tmp_path / f"{file.filename}" 37 | filename.write_text(file.clean_content) 38 | result[name] = str(filename) 39 | return Namespace(**result) 40 | 41 | return _dir_maker 42 | 43 | 44 | def test_two_custom_filters(dir_maker: DirMakerTool) -> None: 45 | files = dir_maker( 46 | template=FileContent( 47 | "template.j2", 48 | """ 49 | {{- key | with_parens | my_reverse -}} 50 | """, 51 | ), 52 | data=FileContent("data.env", "key=Hello, World!"), 53 | filter=FileContent( 54 | "filter.py", 55 | """ 56 | 57 | def with_parens(message): 58 | return f"({message})" 59 | 60 | """, 61 | ), 62 | filter2=FileContent( 63 | "filter2.py", 64 | """ 65 | 66 | def my_reverse(message): 67 | return message[::-1] 68 | 69 | """, 70 | ), 71 | ) 72 | 73 | assert ")!dlroW ,olleH(" == render_command( 74 | Path.cwd(), 75 | {}, 76 | None, 77 | ["", "--filters", files.filter, "--filters", files.filter2, files.template, files.data], 78 | ) 79 | 80 | 81 | @pytest.mark.parametrize(("key", "expected"), [("Hello, World!", "(Hello, World!)")]) 82 | def test_custom_filter(dir_maker: DirMakerTool, key: str, expected: str) -> None: 83 | files = dir_maker( 84 | template=FileContent( 85 | "template.j2", 86 | """ 87 | {{- key | with_parens -}} 88 | """, 89 | ), 90 | data=FileContent( 91 | "data.env", 92 | f""" 93 | key="{key}" 94 | """, 95 | ), 96 | filter=FileContent( 97 | "filter.py", 98 | """ 99 | 100 | def with_parens(message): 101 | return f"({message})" 102 | 103 | """, 104 | ), 105 | ) 106 | 107 | assert expected == render_command( 108 | Path.cwd(), 109 | {}, 110 | None, 111 | ["", "--filter", files.filter, files.template, files.data], 112 | ) 113 | 114 | 115 | @pytest.mark.parametrize( 116 | ("key", "expected"), 117 | [ 118 | ("value", "NO"), 119 | ("(value)", "YES"), 120 | ], 121 | ) 122 | def test_custom_test(dir_maker: DirMakerTool, key: str, expected: str) -> None: 123 | files = dir_maker( 124 | template=FileContent( 125 | "template.j2", 126 | """ 127 | {%- if key is in_parens -%} 128 | YES 129 | {%- else -%} 130 | NO 131 | {%- endif -%} 132 | """, 133 | ), 134 | data=FileContent( 135 | "data.env", 136 | f""" 137 | key={key} 138 | """, 139 | ), 140 | test=FileContent( 141 | "test.py", 142 | """ 143 | 144 | def in_parens(message): 145 | return message and message[0]=="(" and message[-1] == ")" 146 | 147 | """, 148 | ), 149 | ) 150 | 151 | assert expected == render_command( 152 | Path.cwd(), 153 | {}, 154 | None, 155 | ["", "--tests", files.test, files.template, files.data], 156 | ) 157 | 158 | 159 | @pytest.mark.parametrize(("key", "expected"), [("something", "YES"), ("(something)", "YES")]) 160 | def test_custom_filter_and_test(dir_maker: DirMakerTool, key: str, expected: str) -> None: 161 | files = dir_maker( 162 | template=FileContent( 163 | "template.j2", 164 | """ 165 | {%- if key | with_parens is in_parens -%} 166 | YES 167 | {%- else %} 168 | NO 169 | {%- endif -%} 170 | """, 171 | ), 172 | data=FileContent("data.env", f"key={key}"), 173 | filter=FileContent( 174 | "filter.py", 175 | """ 176 | 177 | def with_parens(message): 178 | return f"({message})" 179 | 180 | """, 181 | ), 182 | test=FileContent( 183 | "test.py", 184 | """ 185 | 186 | def in_parens(message): 187 | return message and message[0]=="(" and message[-1] == ")" 188 | 189 | """, 190 | ), 191 | ) 192 | 193 | assert expected == render_command( 194 | Path.cwd(), 195 | {}, 196 | None, 197 | ["", "--filters", files.filter, "--tests", files.test, files.template, files.data], 198 | ) 199 | 200 | 201 | def test_customize_file(dir_maker: DirMakerTool) -> None: 202 | files = dir_maker( 203 | template=FileContent("template.j2", "<<- key >> works, {{ key }} doesn't"), 204 | data=FileContent("data.env", "key=value"), 205 | customize=FileContent( 206 | "customize.py", 207 | """ 208 | 209 | def j2_environment_params(): 210 | return dict( 211 | # Change block start/end strings 212 | block_start_string='<%', 213 | block_end_string='%>', 214 | # Change variable strings 215 | variable_start_string='<<', 216 | variable_end_string='>>') 217 | 218 | """, 219 | ), 220 | ) 221 | 222 | assert "value works, {{ key }} doesn't" == render_command( 223 | Path.cwd(), 224 | {}, 225 | None, 226 | ["", "--customize", files.customize, files.template, files.data], 227 | ) 228 | 229 | 230 | def test_customize_file_no_discarded_functions(dir_maker: DirMakerTool) -> None: 231 | # This is a regression test for https://github.com/kpfleming/jinjanator/issues/48 232 | files = dir_maker( 233 | template=FileContent( 234 | "template.j2", 235 | """ 236 | {{- key | my_reverse -}} 237 | """, 238 | ), 239 | data=FileContent("data.env", "key=Hello, World!"), 240 | customize=FileContent( 241 | "customize.py", 242 | """ 243 | def my_reverse(message): 244 | return message[::-1] 245 | 246 | def extra_filters(): 247 | return dict( 248 | # Change block start/end strings 249 | my_reverse=my_reverse, 250 | ) 251 | """, 252 | ), 253 | ) 254 | 255 | assert "!dlroW ,olleH" == render_command( 256 | Path.cwd(), 257 | {}, 258 | None, 259 | ["", "--customize", files.customize, files.template, files.data], 260 | ) 261 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | from jinja2 import UndefinedError 8 | 9 | from . import ( 10 | FilePairFactory, 11 | render_file, 12 | render_file_env, 13 | render_implicit_stream, 14 | ) 15 | 16 | 17 | def test_import(make_file_pair: FilePairFactory) -> None: 18 | # Import environment into a variable 19 | files = make_file_pair("{{ a }}/{{ env.B }}", '{"a":1}', "json") 20 | assert "1/2" == render_file_env(files, ["--import-env=env"], env={"B": "2"}) 21 | 22 | # Import environment into global scope 23 | files = make_file_pair("{{ a }}/{{ B }}", '{"a":1, "B": 1}', "json") 24 | assert "1/2" == render_file_env(files, ["--import-env="], env={"B": "2"}) 25 | 26 | 27 | def test_equals_sign_in_file_value(make_file_pair: FilePairFactory) -> None: 28 | # A: key with out an equals sign should return an empty string. 29 | # Slight drift from python-dotenv to maintain current plugin definition 30 | # B: C should return their assigned values 31 | # D: key with an equals sign and no value should return empty string 32 | # E: should interpolate the value of C 33 | # F: should fail to interpolate the value of G due to order 34 | # G: should be assigned value 35 | # Ref: https://pypi.org/project/python-dotenv/ 36 | files = make_file_pair( 37 | "{{ A|default() }}/{{ B }}/{{ C }}/{{ D }}/{{ E }}/{{ F }}/{{ G }}", 38 | "A\nB=1\nC=val=1\nD=\nE=${C}\nF=${G}\nG=1", 39 | "env", 40 | ) 41 | assert "/1/val=1//val=1//1" == render_file(files, []) 42 | 43 | 44 | def test_filter(make_file_pair: FilePairFactory, monkeypatch: Any) -> None: 45 | assert "USER_PASS" not in os.environ 46 | 47 | files = make_file_pair( 48 | '{{ user_login }}:{{ "USER_PASS"|env }}', 49 | "user_login: kolypto", 50 | "yaml", 51 | ) 52 | 53 | # Value provided by environment 54 | monkeypatch.setenv("USER_PASS", "qwerty123") 55 | assert "kolypto:qwerty123" == render_file(files, []) 56 | 57 | # Value not provided 58 | monkeypatch.delenv("USER_PASS") 59 | with pytest.raises(KeyError): 60 | assert "kolypto:qwerty123" == render_file(files, []) 61 | 62 | # Default value 63 | files = make_file_pair( 64 | '{{ user_login }}:{{ "USER_PASS"|env("-none-") }}', 65 | "user_login: kolypto", 66 | "yaml", 67 | ) 68 | 69 | assert "kolypto:-none-" == render_file(files, []) 70 | 71 | 72 | def test_function(make_file_pair: FilePairFactory, monkeypatch: Any) -> None: 73 | assert "USER_PASS" not in os.environ 74 | 75 | files = make_file_pair( 76 | '{{ user_login }}:{{ env("USER_PASS") }}', 77 | "user_login: kolypto", 78 | "yaml", 79 | ) 80 | 81 | # Value provided by environment 82 | monkeypatch.setenv("USER_PASS", "qwerty123") 83 | assert "kolypto:qwerty123" == render_file(files, []) 84 | 85 | # Value not provided 86 | monkeypatch.delenv("USER_PASS") 87 | with pytest.raises(KeyError): 88 | assert "kolypto:qwerty123" == render_file(files, []) 89 | 90 | # Default value 91 | files = make_file_pair( 92 | '{{ user_login }}:{{ env("USER_PASS", "-none-") }}', 93 | "user_login: kolypto", 94 | "yaml", 95 | ) 96 | assert "kolypto:-none-" == render_file(files, []) 97 | 98 | 99 | def test_env_stream(make_file_pair: FilePairFactory) -> None: 100 | files = make_file_pair("{{ a }}", "foo=bar", "env") 101 | with pytest.raises(UndefinedError, match="If you're trying to pipe a .env file"): 102 | render_implicit_stream(files, ["--format=env"]) 103 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | FilePairFactory, 3 | render_file, 4 | ) 5 | 6 | 7 | def test_extension(make_file_pair: FilePairFactory) -> None: 8 | files = make_file_pair("{% do [] %}", "", "env") 9 | assert "" == render_file(files, []) 10 | -------------------------------------------------------------------------------- /tests/test_format_options.py: -------------------------------------------------------------------------------- 1 | import jinjanator_plugins 2 | import pytest 3 | 4 | from . import ( 5 | FilePairFactory, 6 | render_file, 7 | ) 8 | 9 | 10 | def test_ini_unknown(make_file_pair: FilePairFactory) -> None: 11 | files = make_file_pair("", "", "ini") 12 | 13 | with pytest.raises(jinjanator_plugins.FormatOptionUnknownError): 14 | assert render_file(files, ["--format-option", "midge"]) 15 | 16 | 17 | def test_json_unknown(make_file_pair: FilePairFactory) -> None: 18 | files = make_file_pair("", "", "json") 19 | 20 | with pytest.raises(jinjanator_plugins.FormatOptionUnknownError): 21 | assert render_file(files, ["--format-option", "midge"]) 22 | 23 | 24 | def test_yaml_unknown(make_file_pair: FilePairFactory) -> None: 25 | files = make_file_pair("", "", "yaml") 26 | 27 | with pytest.raises(jinjanator_plugins.FormatOptionUnknownError): 28 | assert render_file(files, ["--format-option", "midge"]) 29 | 30 | 31 | def test_env_unknown(make_file_pair: FilePairFactory) -> None: 32 | files = make_file_pair("", "", "env") 33 | 34 | with pytest.raises(jinjanator_plugins.FormatOptionUnknownError): 35 | assert render_file(files, ["--format-option", "midge"]) 36 | -------------------------------------------------------------------------------- /tests/test_invalid_data.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import ( 4 | FilePairFactory, 5 | render_env, 6 | render_file, 7 | ) 8 | 9 | 10 | def test_invalid_json(make_file_pair: FilePairFactory) -> None: 11 | files = make_file_pair("", "midge", "json") 12 | 13 | with pytest.raises(TypeError, match="JSON input is neither an object nor an array"): 14 | assert render_file(files, []) 15 | 16 | 17 | def test_invalid_yaml(make_file_pair: FilePairFactory) -> None: 18 | files = make_file_pair("", "midge", "yaml") 19 | 20 | with pytest.raises( 21 | TypeError, 22 | match="YAML input is neither a mapping nor a sequence", 23 | ): 24 | assert render_file(files, []) 25 | 26 | 27 | def test_no_input_data(make_file_pair: FilePairFactory) -> None: 28 | files = make_file_pair("", "", "yaml") 29 | 30 | with pytest.raises(ValueError, match="no input supplied"): 31 | assert render_env(files, ["--format", "yaml"], {}) 32 | -------------------------------------------------------------------------------- /tests/test_json_input.py: -------------------------------------------------------------------------------- 1 | import jinjanator_plugins 2 | import pytest 3 | 4 | from . import ( 5 | FilePairFactory, 6 | render_file, 7 | ) 8 | 9 | 10 | def test_mapping_normal(make_file_pair: FilePairFactory) -> None: 11 | files = make_file_pair("{{ foo }}", '{"foo": "bar"}', "json") 12 | 13 | assert "bar" == render_file(files, []) 14 | 15 | 16 | def test_mapping_with_array_name_option(make_file_pair: FilePairFactory) -> None: 17 | files = make_file_pair("{{ foo }}", '{"foo": "bar"}', "json") 18 | 19 | with pytest.raises(jinjanator_plugins.FormatOptionUnsupportedError): 20 | assert render_file(files, ["--format-option", "array-name=seq"]) 21 | 22 | 23 | def test_array_normal(make_file_pair: FilePairFactory) -> None: 24 | files = make_file_pair("{{ seq[0] }}", "[1,2,3]", "json") 25 | 26 | assert "1" == render_file(files, ["--format-option", "array-name=seq"]) 27 | 28 | 29 | def test_array_without_array_name_option(make_file_pair: FilePairFactory) -> None: 30 | files = make_file_pair("{{ seq[0] }}", "[1,2,3]", "json") 31 | 32 | with pytest.raises(jinjanator_plugins.FormatOptionUnsupportedError): 33 | assert render_file(files, []) 34 | 35 | 36 | def test_array_invalid_name(make_file_pair: FilePairFactory) -> None: 37 | files = make_file_pair("{{ seq[0] }}", "[1,2,3]", "json") 38 | 39 | with pytest.raises(jinjanator_plugins.FormatOptionValueError): 40 | render_file(files, ["--format-option", "array-name=334seq"]) 41 | 42 | 43 | def test_array_invalid_value(make_file_pair: FilePairFactory) -> None: 44 | files = make_file_pair("{{ seq[0] }}", "[1,2,3]", "json") 45 | 46 | with pytest.raises(jinjanator_plugins.FormatOptionValueError): 47 | render_file(files, ["--format-option", "array-name=abc=def"]) 48 | 49 | 50 | def test_array_keyword_name(make_file_pair: FilePairFactory) -> None: 51 | files = make_file_pair("{{ seq[0] }}", "[1,2,3]", "json") 52 | 53 | with pytest.raises(jinjanator_plugins.FormatOptionValueError): 54 | render_file(files, ["--format-option", "array-name=raise"]) 55 | -------------------------------------------------------------------------------- /tests/test_nginx_config.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | FilePairFactory, 3 | render_env, 4 | render_explicit_stream, 5 | render_file, 6 | render_implicit_stream, 7 | ) 8 | 9 | 10 | DATA_TEMPLATE = """ 11 | server { 12 | listen 80; 13 | server_name {{ nginx.hostname }}; 14 | 15 | root {{ nginx.webroot }}; 16 | index index.htm; 17 | 18 | access_log {{ nginx.logs }}/http.access.log combined; 19 | error_log {{ nginx.logs }}/http.error.log; 20 | } 21 | """ 22 | 23 | ENV_TEMPLATE = """ 24 | server { 25 | listen 80; 26 | server_name {{ NGINX_HOSTNAME }}; 27 | 28 | root {{ NGINX_WEBROOT }}; 29 | index index.htm; 30 | 31 | access_log {{ NGINX_LOGS }}/http.access.log combined; 32 | error_log {{ NGINX_LOGS }}/http.error.log; 33 | } 34 | """ 35 | 36 | EXPECTED_OUTPUT = """ 37 | server { 38 | listen 80; 39 | server_name localhost; 40 | 41 | root /var/www/project; 42 | index index.htm; 43 | 44 | access_log /var/log/nginx/http.access.log combined; 45 | error_log /var/log/nginx/http.error.log; 46 | } 47 | """ 48 | 49 | 50 | def test_ini(make_file_pair: FilePairFactory) -> None: 51 | ini_data = """ 52 | [nginx] 53 | hostname=localhost 54 | webroot=/var/www/project 55 | logs=/var/log/nginx 56 | """ 57 | 58 | files = make_file_pair(DATA_TEMPLATE, ini_data, "ini") 59 | 60 | assert EXPECTED_OUTPUT == render_file(files, []) 61 | assert EXPECTED_OUTPUT == render_file(files, ["--format=ini"]) 62 | 63 | assert EXPECTED_OUTPUT == render_implicit_stream( 64 | files, 65 | ["--format=ini"], 66 | ) 67 | assert EXPECTED_OUTPUT == render_explicit_stream( 68 | files, 69 | ["--format=ini"], 70 | ) 71 | 72 | 73 | def test_json(make_file_pair: FilePairFactory) -> None: 74 | json_data = """ 75 | { 76 | "nginx":{ 77 | "hostname": "localhost", 78 | "webroot": "/var/www/project", 79 | "logs": "/var/log/nginx" 80 | } 81 | } 82 | """ 83 | 84 | files = make_file_pair(DATA_TEMPLATE, json_data, "json") 85 | 86 | assert EXPECTED_OUTPUT == render_file(files, []) 87 | assert EXPECTED_OUTPUT == render_file(files, ["--format=json"]) 88 | 89 | assert EXPECTED_OUTPUT == render_implicit_stream( 90 | files, 91 | ["--format=json"], 92 | ) 93 | assert EXPECTED_OUTPUT == render_explicit_stream( 94 | files, 95 | ["--format=json"], 96 | ) 97 | 98 | 99 | def test_yaml(make_file_pair: FilePairFactory) -> None: 100 | yaml_data = """ 101 | nginx: 102 | hostname: localhost 103 | webroot: /var/www/project 104 | logs: /var/log/nginx 105 | """ 106 | 107 | files = make_file_pair(DATA_TEMPLATE, yaml_data, "yaml") 108 | 109 | assert EXPECTED_OUTPUT == render_file(files, []) 110 | assert EXPECTED_OUTPUT == render_file(files, ["--format=yaml"]) 111 | 112 | assert EXPECTED_OUTPUT == render_implicit_stream( 113 | files, 114 | ["--format=yaml"], 115 | ) 116 | assert EXPECTED_OUTPUT == render_explicit_stream( 117 | files, 118 | ["--format=yaml"], 119 | ) 120 | 121 | files = make_file_pair(DATA_TEMPLATE, yaml_data, "yml") 122 | 123 | assert EXPECTED_OUTPUT == render_file(files, []) 124 | 125 | 126 | def test_env(make_file_pair: FilePairFactory) -> None: 127 | env_data = """ 128 | NGINX_HOSTNAME=localhost 129 | NGINX_WEBROOT=/var/www/project 130 | NGINX_LOGS=/var/log/nginx 131 | """ 132 | 133 | files = make_file_pair(ENV_TEMPLATE, env_data, "env") 134 | 135 | assert EXPECTED_OUTPUT == render_file(files, []) 136 | assert EXPECTED_OUTPUT == render_file(files, ["--format=env"]) 137 | 138 | assert EXPECTED_OUTPUT == render_explicit_stream( 139 | files, 140 | [], 141 | ) 142 | assert EXPECTED_OUTPUT == render_explicit_stream( 143 | files, 144 | ["--format=env"], 145 | ) 146 | 147 | env = { 148 | "NGINX_HOSTNAME": "localhost", 149 | "NGINX_WEBROOT": "/var/www/project", 150 | "NGINX_LOGS": "/var/log/nginx", 151 | } 152 | 153 | assert EXPECTED_OUTPUT == render_env(files, [], env) 154 | assert EXPECTED_OUTPUT == render_env(files, ["--format=env"], env) 155 | -------------------------------------------------------------------------------- /tests/test_output_file.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from . import ( 4 | FilePairFactory, 5 | render_env, 6 | ) 7 | 8 | 9 | def test_short_option( 10 | make_file_pair: FilePairFactory, 11 | tmp_path: pathlib.Path, 12 | ) -> None: 13 | files = make_file_pair("{{ a }}", "", "json") 14 | out_file = tmp_path / "j2-out" 15 | assert "" == render_env(files, ["-o", str(out_file)], env={"a": "123"}) 16 | assert "123" == out_file.read_text() 17 | 18 | 19 | def test_long_option( 20 | make_file_pair: FilePairFactory, 21 | tmp_path: pathlib.Path, 22 | ) -> None: 23 | files = make_file_pair("{{ a }}", "", "json") 24 | out_file = tmp_path / "j2-out" 25 | assert "" == render_env(files, ["--output-file", str(out_file)], env={"a": "123"}) 26 | assert "123" == out_file.read_text() 27 | -------------------------------------------------------------------------------- /tests/test_plugin/jinjanator_test_plugin.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from typing import Optional 3 | 4 | from jinjanator_plugins import ( 5 | Extensions, 6 | Filters, 7 | FormatOptionUnsupportedError, 8 | FormatOptionValueError, 9 | Formats, 10 | Globals, 11 | Identity, 12 | Tests, 13 | plugin_extensions_hook, 14 | plugin_filters_hook, 15 | plugin_formats_hook, 16 | plugin_globals_hook, 17 | plugin_identity_hook, 18 | plugin_tests_hook, 19 | ) 20 | 21 | 22 | def null_filter( 23 | value: str, # noqa: ARG001 24 | ) -> str: 25 | return "" 26 | 27 | 28 | def null_test( 29 | value: str, # noqa: ARG001 30 | ) -> bool: 31 | return False 32 | 33 | 34 | class NullFormat: 35 | name = "null" 36 | suffixes: Optional[Iterable[str]] = (".null",) 37 | option_names: Optional[Iterable[str]] = ("val", "uns") 38 | 39 | def __init__(self, options: Optional[Iterable[str]]) -> None: 40 | if options: 41 | for option in options: 42 | if option == "val": 43 | raise FormatOptionValueError(self, option, "", "") 44 | if option == "uns": 45 | raise FormatOptionUnsupportedError(self, option, "") 46 | 47 | def parse( 48 | self, 49 | data_string: str, # noqa: ARG002 50 | ) -> Mapping[str, str]: 51 | return {} 52 | 53 | 54 | @plugin_identity_hook 55 | def plugin_identities() -> Identity: 56 | return "test" 57 | 58 | 59 | @plugin_filters_hook 60 | def plugin_filters() -> Filters: 61 | return {"null": null_filter} 62 | 63 | 64 | @plugin_tests_hook 65 | def plugin_tests() -> Tests: 66 | return {"null": null_test} 67 | 68 | 69 | @plugin_formats_hook 70 | def plugin_formats() -> Formats: 71 | return {NullFormat.name: NullFormat} 72 | 73 | 74 | @plugin_globals_hook 75 | def plugin_globals() -> Globals: 76 | return {"null": null_filter} 77 | 78 | 79 | @plugin_extensions_hook 80 | def plugin_extensions() -> Extensions: 81 | return ["jinja2.ext.do"] 82 | -------------------------------------------------------------------------------- /tests/test_plugin/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools>=61", 5 | ] 6 | 7 | [project] 8 | name = "jinjanator-test-plugin" 9 | version = "0.0.0" 10 | requires-python = ">=3.9" 11 | classifiers = [ 12 | "Programming Language :: Python :: 3 :: Only", 13 | "Programming Language :: Python :: 3.9", 14 | "Programming Language :: Python :: 3.10", 15 | "Programming Language :: Python :: 3.11", 16 | "Programming Language :: Python :: 3.12", 17 | "Programming Language :: Python :: 3.13", 18 | ] 19 | 20 | entry-points.jinjanator.test = "jinjanator_test_plugin" 21 | 22 | [tool.setuptools] 23 | py-modules = [ 24 | "jinjanator_test_plugin", 25 | ] 26 | -------------------------------------------------------------------------------- /tests/test_template_not_found.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from jinja2.exceptions import TemplateNotFound 6 | 7 | from . import ( 8 | FilePairFactory, 9 | render_file, 10 | ) 11 | 12 | 13 | def test_template_not_found( 14 | make_file_pair: FilePairFactory, 15 | ) -> None: 16 | files = make_file_pair("{{name}}", "", "env") 17 | files.template_file = Path("does-not-exist.j2") 18 | with pytest.raises(TemplateNotFound): 19 | render_file(files, []) 20 | -------------------------------------------------------------------------------- /tests/test_undefined.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jinja2.exceptions import UndefinedError 4 | 5 | from . import ( 6 | FilePairFactory, 7 | render_file, 8 | ) 9 | 10 | 11 | def test_normal(make_file_pair: FilePairFactory) -> None: 12 | files = make_file_pair("{{name}}", "", "env") 13 | with pytest.raises(UndefinedError): 14 | render_file(files, []) 15 | 16 | 17 | def test_suppressed(make_file_pair: FilePairFactory) -> None: 18 | files = make_file_pair("{{name}}", "", "env") 19 | assert "" == render_file(files, ["--undefined"]) 20 | -------------------------------------------------------------------------------- /tests/test_unicode.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | FilePairFactory, 3 | render_env, 4 | render_file, 5 | ) 6 | 7 | 8 | def test_json(make_file_pair: FilePairFactory) -> None: 9 | # I'm using Russian language for unicode :) 10 | files = make_file_pair( 11 | "Проверка {{ a }} связи!", 12 | '{"a": "широкополосной"}', 13 | "json", 14 | ) 15 | assert "Проверка широкополосной связи!" == render_file(files, []) 16 | 17 | 18 | def test_env(make_file_pair: FilePairFactory) -> None: 19 | files = make_file_pair("Hello {{name}}!", "", "env") 20 | # Test case from issue #17 (in j2cli repo): unicode environment variables 21 | assert "Hello Jürgen!" == render_env(files, [], env={"name": "Jürgen"}) 22 | -------------------------------------------------------------------------------- /tests/test_yaml_input.py: -------------------------------------------------------------------------------- 1 | import jinjanator_plugins 2 | import pytest 3 | 4 | from . import ( 5 | FilePairFactory, 6 | render_file, 7 | ) 8 | 9 | 10 | def test_mapping_normal(make_file_pair: FilePairFactory) -> None: 11 | files = make_file_pair("{{ foo }}", "foo: bar", "yaml") 12 | 13 | assert "bar" == render_file(files, []) 14 | 15 | 16 | def test_mapping_with_sequence_name_option(make_file_pair: FilePairFactory) -> None: 17 | files = make_file_pair("{{ foo }}", "foo: bar", "yaml") 18 | 19 | with pytest.raises(jinjanator_plugins.FormatOptionUnsupportedError): 20 | assert render_file(files, ["--format-option", "sequence-name=seq"]) 21 | 22 | 23 | def test_sequence_normal(make_file_pair: FilePairFactory) -> None: 24 | files = make_file_pair("{{ seq[0] }}", "[ bar ]", "yaml") 25 | 26 | assert "bar" == render_file(files, ["--format-option", "sequence-name=seq"]) 27 | 28 | 29 | def test_sequence_without_sequence_name_option(make_file_pair: FilePairFactory) -> None: 30 | files = make_file_pair("{{ seq[0] }}", "[ bar ]", "yaml") 31 | 32 | with pytest.raises(jinjanator_plugins.FormatOptionUnsupportedError): 33 | assert render_file(files, []) 34 | 35 | 36 | def test_sequence_invalid_name(make_file_pair: FilePairFactory) -> None: 37 | files = make_file_pair("{{ seq[0] }}", "[ bar ]", "yaml") 38 | 39 | with pytest.raises(jinjanator_plugins.FormatOptionValueError): 40 | render_file(files, ["--format-option", "sequence-name=334seq"]) 41 | 42 | 43 | def test_sequence_invalid_value(make_file_pair: FilePairFactory) -> None: 44 | files = make_file_pair("{{ seq[0] }}", "[ bar ]", "yaml") 45 | 46 | with pytest.raises(jinjanator_plugins.FormatOptionValueError): 47 | render_file(files, ["--format-option", "sequence-name=abc=def"]) 48 | 49 | 50 | def test_sequence_keyword_name(make_file_pair: FilePairFactory) -> None: 51 | files = make_file_pair("{{ seq[0] }}", "[ bar ]", "yaml") 52 | 53 | with pytest.raises(jinjanator_plugins.FormatOptionValueError): 54 | render_file(files, ["--format-option", "sequence-name=raise"]) 55 | -------------------------------------------------------------------------------- /workflow-support/ci_paths.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | - 'src/**/*.py' 3 | - 'tests/**/*.py' 4 | - 'pyproject.toml' 5 | - 'workflow-support/versions.json' 6 | - '.github/workflows/ci.yml' 7 | - '.github/workflows/test.yml' 8 | -------------------------------------------------------------------------------- /workflow-support/lint_paths.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | - 'src/**/*.py' 3 | - 'tests/**/*.py' 4 | - 'pyproject.toml' 5 | - '.github/workflows/lint.yml' 6 | -------------------------------------------------------------------------------- /workflow-support/make_ci_image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | # Arguments: 6 | # 7 | # 1: registry, name, and tag of base image 8 | # 2: registry, name, and tag of image to be created 9 | # 3: name of Python distribution produced by this repo 10 | 11 | scriptdir=$(realpath "$(dirname "${BASH_SOURCE[0]}")") 12 | # source directory must be mounted at the same path inside the build 13 | # container as it will be mounted when GitHub Actions launches the 14 | # container, because Hatch uses the path as part of the identity of 15 | # the virtual environments it creates 16 | containersrcdir="/__w/${GITHUB_REPOSITORY##*/}/${GITHUB_REPOSITORY##*/}" 17 | base_image=${1}; shift 18 | image_name=${1}; shift 19 | dist_name=${1}; shift 20 | 21 | lint_deps=(shellcheck) 22 | proj_deps=() 23 | proj_build_deps=() 24 | 25 | hatchenvs=(lint ci) 26 | 27 | c=$(buildah from "${base_image}") 28 | 29 | build_cmd() { 30 | buildah run --network host "${c}" -- "$@" 31 | } 32 | 33 | build_cmd_with_source() { 34 | buildah run --network host --volume "$(realpath "${scriptdir}/.."):${containersrcdir}" --workingdir "${containersrcdir}" "${c}" -- "$@" 35 | } 36 | 37 | build_cmd apt update --quiet=2 38 | build_cmd apt install --yes --quiet=2 "${lint_deps[@]}" "${proj_deps[@]}" "${proj_build_deps[@]}" 39 | 40 | for env in "${hatchenvs[@]}"; do 41 | # this looks weird... but it causes Hatch to create the env, 42 | # install all of the project's dependencies and the project, 43 | # then runs 'uv pip' to uninstall the project, leaving the env 44 | # in place with the dependencies 45 | build_cmd_with_source hatch env create "${env}" 46 | build_cmd_with_source hatch -e "${env}" run uv pip uninstall "${dist_name}" 47 | done 48 | 49 | if [ -n "${proj_build_deps[*]}" ] 50 | then 51 | build_cmd apt remove --yes --purge "${proj_build_deps[@]}" 52 | fi 53 | build_cmd apt autoremove --yes --purge 54 | build_cmd apt clean autoclean 55 | build_cmd sh -c "rm -rf /var/lib/apt/lists/*" 56 | build_cmd uv cache clean 57 | 58 | if buildah images --quiet "${image_name}"; then 59 | buildah rmi "${image_name}" 60 | fi 61 | buildah commit --squash --rm "${c}" "${image_name}" 62 | -------------------------------------------------------------------------------- /workflow-support/versions.json: -------------------------------------------------------------------------------- 1 | versions={"python": ["3.9", "3.10", "3.11", "3.12", "3.13"]} 2 | --------------------------------------------------------------------------------