├── .copier-answers.yml ├── .envrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-bug.md │ ├── 2-feature.md │ ├── 3-docs.md │ ├── 4-change.md │ └── config.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── config ├── coverage.ini ├── git-changelog.toml ├── mypy.ini ├── pytest.ini ├── ruff.toml └── vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── docs ├── .overrides │ ├── main.html │ └── partials │ │ ├── comments.html │ │ └── path-item.html ├── changelog.md ├── code_of_conduct.md ├── contributing.md ├── credits.md ├── css │ ├── material.css │ └── mkdocstrings.css ├── gen_credits.py ├── index.md ├── js │ └── feedback.js ├── license.md └── reference │ └── copier_templates_extensions.md ├── duties.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── gen_api_ref.py ├── gen_credits.py ├── get_version.py ├── make └── make.py ├── src └── copier_templates_extensions │ ├── __init__.py │ ├── _internal │ ├── __init__.py │ ├── context.py │ ├── debug.py │ └── loader.py │ └── py.typed └── tests ├── __init__.py ├── fixtures ├── answer_access │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja ├── cant_find_extension_class │ ├── copier.yml │ └── extensions.py ├── cant_find_extension_file │ └── copier.yml ├── cant_find_extension_module │ └── copier.yml ├── cant_find_extension_package │ └── copier.yml ├── dataclass │ ├── copier.yml │ ├── extensions │ │ └── ctx.py │ └── template │ │ └── result.txt.jinja ├── deprecation_warning_hook_return │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja ├── deprecation_warning_update_attr │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja ├── loading_normal_extension │ ├── copier.yml │ └── result.txt.jinja ├── modifying_context │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja ├── not_updating_context_for_prompts │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja ├── not_using_loader │ └── result.txt.jinja ├── raising_exception_while_loading │ ├── copier.yml │ └── extensions.py ├── updating_context │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja └── using_loader │ ├── copier.yml │ ├── extensions.py │ └── result.txt.jinja ├── test_api.py └── test_extensions.py /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier. 2 | _commit: 1.7.1 3 | _src_path: gh:pawamoy/copier-uv 4 | author_email: dev@pawamoy.fr 5 | author_fullname: Timothée Mazzucotelli 6 | author_username: pawamoy 7 | copyright_date: '2021' 8 | copyright_holder: Timothée Mazzucotelli 9 | copyright_holder_email: dev@pawamoy.fr 10 | copyright_license: ISC 11 | insiders: false 12 | project_description: Special Jinja2 extension for Copier that allows to load extensions 13 | using file paths relative to the template root instead of Python dotted paths. 14 | project_name: Copier Templates Extensions 15 | python_package_command_line_name: '' 16 | python_package_distribution_name: copier-templates-extensions 17 | python_package_import_name: copier_templates_extensions 18 | repository_name: copier-templates-extensions 19 | repository_namespace: copier-org 20 | repository_provider: github.com 21 | 22 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | PATH_add scripts 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pawamoy 2 | polar: pawamoy 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. 4 | title: "bug: " 5 | labels: unconfirmed 6 | assignees: [pawamoy] 7 | --- 8 | 9 | ### Description of the bug 10 | 11 | 12 | ### To Reproduce 13 | 28 | 29 | ``` 30 | WRITE MRE / INSTRUCTIONS HERE 31 | ``` 32 | 33 | ### Full traceback 34 | 36 | 37 |
Full traceback 38 | 39 | ```python 40 | PASTE TRACEBACK HERE 41 | ``` 42 | 43 |
44 | 45 | ### Expected behavior 46 | 47 | 48 | ### Environment information 49 | 51 | 52 | ```bash 53 | python -m copier_templates_extensions._internal.debug # | xclip -selection clipboard 54 | ``` 55 | 56 | PASTE MARKDOWN OUTPUT HERE 57 | 58 | ### Additional context 59 | 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: "feature: " 5 | labels: feature 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation update 3 | about: Point at unclear, missing or outdated documentation. 4 | title: "docs: " 5 | labels: docs 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is something unclear, missing or outdated in our documentation? 10 | 11 | 12 | ### Relevant code snippets 13 | 14 | 15 | ### Link to the relevant documentation section 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-change.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change request 3 | about: Suggest any other kind of change for this project. 4 | title: "change: " 5 | assignees: pawamoy 6 | --- 7 | 8 | ### Is your change request related to a problem? Please describe. 9 | 10 | 11 | ### Describe the solution you'd like 12 | 13 | 14 | ### Describe alternatives you've considered 15 | 16 | 17 | ### Additional context 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question / I need help 4 | url: https://github.com/copier-org/copier-templates-extensions/discussions/new?category=q-a 5 | about: Ask and answer questions in the Discussions tab. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | env: 14 | LANG: en_US.utf-8 15 | LC_ALL: en_US.utf-8 16 | PYTHONIOENCODING: UTF-8 17 | PYTHON_VERSIONS: "" 18 | 19 | jobs: 20 | 21 | quality: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | fetch-tags: true 31 | 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.12" 36 | 37 | - name: Setup uv 38 | uses: astral-sh/setup-uv@v5 39 | with: 40 | enable-cache: true 41 | cache-dependency-glob: pyproject.toml 42 | 43 | - name: Install dependencies 44 | run: make setup 45 | 46 | - name: Check if the documentation builds correctly 47 | run: make check-docs 48 | 49 | - name: Check the code quality 50 | run: make check-quality 51 | 52 | - name: Check if the code is correctly typed 53 | run: make check-types 54 | 55 | - name: Check for breaking changes in the API 56 | run: make check-api 57 | 58 | - name: Store objects inventory for tests 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: objects.inv 62 | path: site/objects.inv 63 | 64 | tests: 65 | 66 | needs: 67 | - quality 68 | strategy: 69 | matrix: 70 | os: 71 | - ubuntu-latest 72 | - macos-latest 73 | - windows-latest 74 | python-version: 75 | - "3.9" 76 | - "3.10" 77 | - "3.11" 78 | - "3.12" 79 | - "3.13" 80 | - "3.14" 81 | resolution: 82 | - highest 83 | - lowest-direct 84 | exclude: 85 | - os: macos-latest 86 | resolution: lowest-direct 87 | - os: windows-latest 88 | resolution: lowest-direct 89 | runs-on: ${{ matrix.os }} 90 | continue-on-error: ${{ matrix.python-version == '3.14' }} 91 | 92 | steps: 93 | - name: Checkout 94 | uses: actions/checkout@v4 95 | with: 96 | fetch-depth: 0 97 | fetch-tags: true 98 | 99 | - name: Setup Python 100 | uses: actions/setup-python@v5 101 | with: 102 | python-version: ${{ matrix.python-version }} 103 | allow-prereleases: true 104 | 105 | - name: Setup uv 106 | uses: astral-sh/setup-uv@v5 107 | with: 108 | enable-cache: true 109 | cache-dependency-glob: pyproject.toml 110 | cache-suffix: ${{ matrix.resolution }} 111 | 112 | - name: Install dependencies 113 | env: 114 | UV_RESOLUTION: ${{ matrix.resolution }} 115 | run: make setup 116 | 117 | - name: Download objects inventory 118 | uses: actions/download-artifact@v4 119 | with: 120 | name: objects.inv 121 | path: site/ 122 | 123 | - name: Run the test suite 124 | run: make test 125 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: startsWith(github.ref, 'refs/tags/') 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | fetch-tags: true 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | - name: Setup uv 22 | uses: astral-sh/setup-uv@v5 23 | - name: Prepare release notes 24 | run: uv tool run git-changelog --release-notes > release-notes.md 25 | - name: Create release 26 | uses: softprops/action-gh-release@v2 27 | with: 28 | body_path: release-notes.md 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea/ 3 | .vscode/ 4 | 5 | # python 6 | *.egg-info/ 7 | *.py[cod] 8 | .venv/ 9 | .venvs/ 10 | /build/ 11 | /dist/ 12 | 13 | # tools 14 | .coverage* 15 | /.pdm-build/ 16 | /htmlcov/ 17 | /site/ 18 | uv.lock 19 | 20 | # cache 21 | .cache/ 22 | .pytest_cache/ 23 | .mypy_cache/ 24 | .ruff_cache/ 25 | __pycache__/ 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## [0.3.1](https://github.com/pawamoy/copier-templates-extensions/releases/tag/0.3.1) - 2025-03-10 9 | 10 | [Compare with 0.3.0](https://github.com/pawamoy/copier-templates-extensions/compare/0.3.0...0.3.1) 11 | 12 | ### Dependencies 13 | 14 | - Depend on Copier 9.2+ ([43679a9](https://github.com/pawamoy/copier-templates-extensions/commit/43679a9719b2d05e041e674bde1f10dee21984a6) by Timothée Mazzucotelli). 15 | 16 | ### Bug Fixes 17 | 18 | - Add loaded module to `sys.modules` to help libraries using data from annotation scopes ([a290f27](https://github.com/pawamoy/copier-templates-extensions/commit/a290f271e302db88bcc755d57c647e4f3a8043ff) by Axel H.). [PR-12](https://github.com/copier-org/copier-templates-extensions/pull/12) 19 | 20 | ### Code Refactoring 21 | 22 | - Move modules into internal folder (and simplify API docs) ([a8c0377](https://github.com/pawamoy/copier-templates-extensions/commit/a8c03770298f51fa07667598fd7d0b72196aecad) by Timothée Mazzucotelli). 23 | - Pass globals to parent constructor ([ea4567b](https://github.com/pawamoy/copier-templates-extensions/commit/ea4567bcfad0f17764e17d6b7fd12a7fd7a33cba) by Timothée Mazzucotelli). 24 | - Deprecate the "update" aspect of context hooks ([876c055](https://github.com/pawamoy/copier-templates-extensions/commit/876c0554e34b630ab255111b69da74db16439dcb) by Timothée Mazzucotelli). [Issue-4](https://github.com/copier-org/copier-templates-extensions/issues/4) 25 | 26 | ## [0.3.0](https://github.com/copier-org/copier-templates-extensions/releases/tag/0.3.0) - 2022-09-12 27 | 28 | [Compare with 0.2.0](https://github.com/copier-org/copier-templates-extensions/compare/0.2.0...0.3.0) 29 | 30 | ### Build 31 | - Require Copier v6+ (Python >= 3.7) ([6380c90](https://github.com/copier-org/copier-templates-extensions/commit/6380c90a3e26c596790d9a083673dcf1dd613678) by Timothée Mazzucotelli). 32 | 33 | 34 | ## [0.2.0](https://github.com/copier-org/copier-templates-extensions/releases/tag/0.2.0) - 2021-12-27 35 | 36 | [Compare with 0.1.1](https://github.com/copier-org/copier-templates-extensions/compare/0.1.1...0.2.0) 37 | 38 | ### Build 39 | - Match Python cap to Copier's ([a04f938](https://github.com/copier-org/copier-templates-extensions/commit/a04f93813f9795b64cbd9d21209dd5e5127b3eb4) by Timothée Mazzucotelli). 40 | 41 | ### Features 42 | - Support Jinja2 version 3.x ([55211df](https://github.com/copier-org/copier-templates-extensions/commit/55211df29c4118c2603cc18ea4a1bc4247e41176) by Timothée Mazzucotelli). 43 | 44 | 45 | ## [0.1.1](https://github.com/copier-org/copier-templates-extensions/releases/tag/0.1.1) - 2021-03-29 46 | 47 | [Compare with 0.1.0](https://github.com/copier-org/copier-templates-extensions/compare/0.1.0...0.1.1) 48 | 49 | ### Bug Fixes 50 | - Fix loader extension ([7d8b9e8](https://github.com/copier-org/copier-templates-extensions/commit/7d8b9e8cf2de66fafc9953ff5d4ba4e210470649) by Timothée Mazzucotelli). 51 | 52 | ### Code Refactoring 53 | - Raise error from abstract hook ([52494f8](https://github.com/copier-org/copier-templates-extensions/commit/52494f8d02505027da0733a7beb9269fc2ba8c3e) by Timothée Mazzucotelli). 54 | - Raise Copier errors from loader extension ([c136feb](https://github.com/copier-org/copier-templates-extensions/commit/c136febaa4ed719f5a0919e2fa073862258e7143) by Timothée Mazzucotelli). 55 | - Rename context module, inherit from default context class ([855284a](https://github.com/copier-org/copier-templates-extensions/commit/855284ad2112feecd6b2a8dfc38dbbd397d29275) by Timothée Mazzucotelli). 56 | 57 | 58 | ## [0.1.0](https://github.com/copier-org/copier-templates-extensions/releases/tag/0.1.0) - 2021-03-29 59 | 60 | [Compare with first commit](https://github.com/copier-org/copier-templates-extensions/compare/e74578393a7cffe4e502640a6092e789d2cc6f29...0.1.0) 61 | 62 | ### Code Refactoring 63 | - Reorganize code ([474b617](https://github.com/copier-org/copier-templates-extensions/commit/474b6173f3970c7713643b7cbc16f11f32a70e5c) by Timothée Mazzucotelli). 64 | 65 | ### Features 66 | - Add context hook extension ([ce95212](https://github.com/copier-org/copier-templates-extensions/commit/ce952126afb282cfa1fbf18366a6f857426eeaa1) by Timothée Mazzucotelli). 67 | - Add initial contents ([e745783](https://github.com/copier-org/copier-templates-extensions/commit/e74578393a7cffe4e502640a6092e789d2cc6f29) by Timothée Mazzucotelli). 68 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | * Trolling, insulting or derogatory comments, and personal or political attacks 23 | * Public or private harassment 24 | * Publishing others' private information, such as a physical or email address, without their explicit permission 25 | * Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at dev@pawamoy.fr. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 76 | 77 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 78 | 79 | [homepage]: https://www.contributor-covenant.org 80 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 81 | [Mozilla CoC]: https://github.com/mozilla/diversity 82 | [FAQ]: https://www.contributor-covenant.org/faq 83 | [translations]: https://www.contributor-covenant.org/translations 84 | 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 4 | 5 | ## Environment setup 6 | 7 | Nothing easier! 8 | 9 | Fork and clone the repository, then: 10 | 11 | ```bash 12 | cd copier-templates-extensions 13 | make setup 14 | ``` 15 | 16 | > NOTE: If it fails for some reason, you'll need to install [uv](https://github.com/astral-sh/uv) manually. 17 | > 18 | > You can install it with: 19 | > 20 | > ```bash 21 | > curl -LsSf https://astral.sh/uv/install.sh | sh 22 | > ``` 23 | > 24 | > Now you can try running `make setup` again, or simply `uv sync`. 25 | 26 | You now have the dependencies installed. 27 | 28 | Run `make help` to see all the available actions! 29 | 30 | ## Tasks 31 | 32 | The entry-point to run commands and tasks is the `make` Python script, located in the `scripts` directory. Try running `make` to show the available commands and tasks. The *commands* do not need the Python dependencies to be installed, 33 | while the *tasks* do. The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). 34 | 35 | If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) for the project. 36 | 37 | ## Development 38 | 39 | As usual: 40 | 41 | 1. create a new branch: `git switch -c feature-or-bugfix-name` 42 | 1. edit the code and/or the documentation 43 | 44 | **Before committing:** 45 | 46 | 1. run `make format` to auto-format the code 47 | 1. run `make check` to check everything (fix any warning) 48 | 1. run `make test` to run the tests (fix any issue) 49 | 1. if you updated the documentation or the project dependencies: 50 | 1. run `make docs` 51 | 1. go to http://localhost:8000 and check that everything looks good 52 | 1. follow our [commit message convention](#commit-message-convention) 53 | 54 | If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, and we will help you during review. 55 | 56 | Don't bother updating the changelog, we will take care of this. 57 | 58 | ## Commit message convention 59 | 60 | Commit messages must follow our convention based on the [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): 61 | 62 | ``` 63 | [(scope)]: Subject 64 | 65 | [Body] 66 | ``` 67 | 68 | **Subject and body must be valid Markdown.** Subject must have proper casing (uppercase for first letter if it makes sense), but no dot at the end, and no punctuation in general. 69 | 70 | Scope and body are optional. Type can be: 71 | 72 | - `build`: About packaging, building wheels, etc. 73 | - `chore`: About packaging or repo/files management. 74 | - `ci`: About Continuous Integration. 75 | - `deps`: Dependencies update. 76 | - `docs`: About documentation. 77 | - `feat`: New feature. 78 | - `fix`: Bug fix. 79 | - `perf`: About performance. 80 | - `refactor`: Changes that are not features or bug fixes. 81 | - `style`: A change in code style/format. 82 | - `tests`: About tests. 83 | 84 | If you write a body, please add trailers at the end (for example issues and PR references, or co-authors), without relying on GitHub's flavored Markdown: 85 | 86 | ``` 87 | Body. 88 | 89 | Issue #10: https://github.com/namespace/project/issues/10 90 | Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 91 | ``` 92 | 93 | These "trailers" must appear at the end of the body, without any blank lines between them. The trailer title can contain any character except colons `:`. We expect a full URI for each trailer, not just GitHub autolinks (for example, full GitHub URLs for commits and issues, not the hash or the #issue-number). 94 | 95 | We do not enforce a line length on commit messages summary and body, but please avoid very long summaries, and very long lines in the body, unless they are part of code blocks that must not be wrapped. 96 | 97 | ## Pull requests guidelines 98 | 99 | Link to any related issue in the Pull Request message. 100 | 101 | During the review, we recommend using fixups: 102 | 103 | ```bash 104 | # SHA is the SHA of the commit you want to fix 105 | git commit --fixup=SHA 106 | ``` 107 | 108 | Once all the changes are approved, you can squash your commits: 109 | 110 | ```bash 111 | git rebase -i --autosquash main 112 | ``` 113 | 114 | And force-push: 115 | 116 | ```bash 117 | git push -f 118 | ``` 119 | 120 | If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging. 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021, Timothée Mazzucotelli 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # If you have `direnv` loaded in your shell, and allow it in the repository, 2 | # the `make` command will point at the `scripts/make` shell script. 3 | # This Makefile is just here to allow auto-completion in the terminal. 4 | 5 | actions = \ 6 | allrun \ 7 | changelog \ 8 | check \ 9 | check-api \ 10 | check-docs \ 11 | check-quality \ 12 | check-types \ 13 | clean \ 14 | coverage \ 15 | docs \ 16 | docs-deploy \ 17 | format \ 18 | help \ 19 | multirun \ 20 | release \ 21 | run \ 22 | setup \ 23 | test \ 24 | vscode 25 | 26 | .PHONY: $(actions) 27 | $(actions): 28 | @python scripts/make "$@" 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Copier Templates Extensions 2 | 3 | [![ci](https://github.com/copier-org/copier-templates-extensions/workflows/ci/badge.svg)](https://github.com/copier-org/copier-templates-extensions/actions?query=workflow%3Aci) 4 | [![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://copier-org.github.io/copier-templates-extensions/) 5 | [![pypi version](https://img.shields.io/pypi/v/copier-templates-extensions.svg)](https://pypi.org/project/copier-templates-extensions/) 6 | 7 | Special Jinja2 extension for Copier that allows to load extensions using file paths relative to the template root instead of Python dotted paths. 8 | 9 | ## Installation 10 | 11 | With pip: 12 | 13 | ```bash 14 | pip install copier-templates-extensions 15 | ``` 16 | 17 | With uv: 18 | 19 | ```bash 20 | uv tool install copier --with copier-templates-extensions 21 | ``` 22 | 23 | With pipx: 24 | 25 | ```bash 26 | pipx install copier 27 | pipx inject copier copier-templates-extensions 28 | ``` 29 | 30 | ## Usage 31 | 32 | In your template configuration, 33 | first add our loader extension, 34 | then add your templates extensions 35 | using relative file paths, 36 | and the class name after a colon: 37 | 38 | ```yaml 39 | _jinja_extensions: 40 | - copier_templates_extensions.TemplateExtensionLoader 41 | - extensions/context.py:ContextUpdater 42 | - extensions/slugify.py:SlugifyExtension 43 | ``` 44 | 45 | With this example, you are supposed to have an `extensions` 46 | directory at the root of your template containing two modules: 47 | `context.py` and `slugify.py`. 48 | 49 | ``` 50 | 📁 template_root 51 | ├── 📄 abc.txt.jinja 52 | ├── 📄 copier.yml 53 | └── 📁 extensions 54 |    ├── 📄 context.py 55 |    └── 📄 slugify.py 56 | ``` 57 | 58 | See [Context hook extension](#context-hook-extension) 59 | to see how the `ContextUpdater` class can be written. 60 | 61 | The `SlugifyExtension` class could be written like this: 62 | 63 | ```python 64 | import re 65 | import unicodedata 66 | 67 | from jinja2.ext import Extension 68 | 69 | 70 | # taken from Django 71 | # https://github.com/django/django/blob/main/django/utils/text.py 72 | def slugify(value, allow_unicode=False): 73 | """ 74 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated 75 | dashes to single dashes. Remove characters that aren't alphanumerics, 76 | underscores, or hyphens. Convert to lowercase. Also strip leading and 77 | trailing whitespace, dashes, and underscores. 78 | """ 79 | value = str(value) 80 | if allow_unicode: 81 | value = unicodedata.normalize('NFKC', value) 82 | else: 83 | value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') 84 | value = re.sub(r'[^\w\s-]', '', value.lower()) 85 | return re.sub(r'[-\s]+', '-', value).strip('-_') 86 | 87 | 88 | class SlugifyExtension(Extension): 89 | def __init__(self, environment): 90 | super().__init__(environment) 91 | environment.filters["slugify"] = slugify 92 | ``` 93 | 94 | ### Context hook extension 95 | 96 | This package also provides a convenient extension class 97 | allowing template writers to update the context used 98 | to render templates, in order to add, modify or remove 99 | items of the context. 100 | 101 | In one of your relative path extensions modules, 102 | create a class that inherits from `ContextHook`, 103 | and override its `hook` method: 104 | 105 | ```python 106 | from copier_templates_extensions import ContextHook 107 | 108 | 109 | class ContextUpdater(ContextHook): 110 | def hook(self, context): 111 | context["say"] = "hello " + context["name"] 112 | ``` 113 | 114 | In your Jinja templates, you will now have access 115 | to the `{{ say }}` variable directly. 116 | 117 | This can be extremely useful in template projects 118 | where you don't want to ask too many questions to the users 119 | and instead infer some values from their answers. 120 | 121 | Consider the following example: 122 | you ask your users if they want to generate 123 | a CLI app or a web API. 124 | Depending on their answer, 125 | the main Python module should be named 126 | `cli.py` or `app.py`. 127 | 128 | Without the context hook, 129 | you would need to write a Jinja macro somewhere, 130 | or update the context directly in Jinja, 131 | and import this file (still using Jinja) 132 | *in the filename of the module*: 133 | 134 | ```jinja 135 | {# using macros #} 136 | {%- macro module_name() %} 137 | {%- if project_type == "webapi" %}app{% else %}cli{% endif %} 138 | {%- endmacro %} 139 | ``` 140 | 141 | ```jinja 142 | {# or enhancing the context #} 143 | {#- Initiate context with a copy of Copier answers -#} 144 | {%- set ctx = _copier_answers.copy() -%} 145 | 146 | {#- Populate our new variables -#} 147 | {%- set _ = ctx.update({"module_name": ("app" if project_type == "webapi" else "cli") -%} 148 | ``` 149 | 150 | ``` 151 | 📁 template_root 152 | ├── 📄 copier.yml 153 | ├── 📄 macros # the macros file 154 | ├── 📄 context # the context file 155 | ├── 📁 extensions 156 | │   └── 📄 slugify.py 157 | └── 📁 {{project_name|slugify}} 158 | │ 159 | │ # using the macros 160 | ├── 📄 {% import 'macros' as macros with context %}{{macros.module_name()}}.py.jinja 161 | │ 162 | │ # or using the enhanced context 163 | └── 📄 {% from 'context' import ctx with context %}{{ctx.module_name}}.py.jinja 164 | ``` 165 | 166 | As you can see, both forms are really ugly to write: 167 | 168 | - the `macros` or `context` can only be placed in the root, 169 | as slashes `/` are not allowed in filenames 170 | - you must use spaces and single-quotes 171 | (double-quotes are not valid filename characters on Windows) 172 | in your templated filenames, which is not clean 173 | - filenames are very long 174 | 175 | **Using our context hook instead makes it so easy and clean!** 176 | 177 | ```python 178 | from copier_templates_extensions import ContextHook 179 | 180 | 181 | class ContextUpdater(ContextHook): 182 | def hook(self, context): 183 | context["module_name"] = "app" if context["project_type"] == "webapi" else "cli" 184 | ``` 185 | 186 | ``` 187 | 📁 template_root 188 | ├── 📄 copier.yml 189 | ├── 📁 extensions 190 | │   ├── 📄 slugify.py 191 | │   └── 📄 context.py 192 | └── 📁 {{project_name|slugify}} 193 | └── 📄 {{module_name}}.py.jinja 194 | ``` 195 | 196 | You can do many more things with a context hook, 197 | like downloading data from external resources 198 | to include it in the context, etc. 199 | 200 | > [!TIP] 201 | > **Context hooks run during every Copier rendering phase.** 202 | > During project generation or project updates, Copier passes 203 | > through several rendering phases: when prompting (questions / answers), 204 | > when rendering files, when running tasks, and when running migrations. 205 | > 206 | > By default, a context hook runs during all these phases, 207 | > possibly multiple times, for example once per prompted question, 208 | > or once per rendered file. The task of running the hook only once, 209 | > or during a specific phase only, is left to you. 210 | > 211 | > To run only once, you can use caching within your class 212 | > (for example by storing computed values in class variables). 213 | > 214 | > To run during a specific phase only, you can check the value 215 | > of `context["_copier_phase"]` (Copier 9.6+), which is one of: 216 | > `"prompt"`, `"render"`, `"tasks"`, `"migrate"`. 217 | > 218 | > Other key-value pairs can be found in the context 219 | > that you might find useful (Copier configuration, etc.). 220 | -------------------------------------------------------------------------------- /config/coverage.ini: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | parallel = true 4 | source = 5 | src/ 6 | tests/ 7 | 8 | [coverage:paths] 9 | equivalent = 10 | src/ 11 | .venv/lib/*/site-packages/ 12 | .venvs/*/lib/*/site-packages/ 13 | 14 | [coverage:report] 15 | precision = 2 16 | omit = 17 | src/*/__init__.py 18 | src/*/__main__.py 19 | tests/__init__.py 20 | exclude_lines = 21 | pragma: no cover 22 | if TYPE_CHECKING 23 | 24 | [coverage:json] 25 | output = htmlcov/coverage.json 26 | -------------------------------------------------------------------------------- /config/git-changelog.toml: -------------------------------------------------------------------------------- 1 | bump = "auto" 2 | convention = "angular" 3 | in-place = true 4 | output = "CHANGELOG.md" 5 | parse-refs = false 6 | parse-trailers = true 7 | sections = ["build", "deps", "feat", "fix", "refactor"] 8 | template = "keepachangelog" 9 | versioning = "pep440" 10 | -------------------------------------------------------------------------------- /config/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | exclude = tests/fixtures/ 4 | warn_unused_ignores = true 5 | show_error_codes = true 6 | -------------------------------------------------------------------------------- /config/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = 3 | test_*.py 4 | addopts = 5 | --cov 6 | --cov-config config/coverage.ini 7 | testpaths = 8 | tests 9 | 10 | # action:message_regex:warning_class:module_regex:line 11 | filterwarnings = 12 | error 13 | # TODO: remove once pytest-xdist 4 is released 14 | ignore:.*rsyncdir:DeprecationWarning:xdist 15 | -------------------------------------------------------------------------------- /config/ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | line-length = 120 3 | 4 | [lint] 5 | exclude = [ 6 | "tests/fixtures/*.py", 7 | ] 8 | select = [ 9 | "A", "ANN", "ARG", 10 | "B", "BLE", 11 | "C", "C4", 12 | "COM", 13 | "D", "DTZ", 14 | "E", "ERA", "EXE", 15 | "F", "FBT", 16 | "G", 17 | "I", "ICN", "INP", "ISC", 18 | "N", 19 | "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", 20 | "Q", 21 | "RUF", "RSE", "RET", 22 | "S", "SIM", "SLF", 23 | "T", "T10", "T20", "TCH", "TID", "TRY", 24 | "UP", 25 | "W", 26 | "YTT", 27 | ] 28 | ignore = [ 29 | "A001", # Variable is shadowing a Python builtin 30 | "ANN101", # Missing type annotation for self 31 | "ANN102", # Missing type annotation for cls 32 | "ANN204", # Missing return type annotation for special method __str__ 33 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 34 | "ARG005", # Unused lambda argument 35 | "C901", # Too complex 36 | "D105", # Missing docstring in magic method 37 | "D417", # Missing argument description in the docstring 38 | "E501", # Line too long 39 | "ERA001", # Commented out code 40 | "G004", # Logging statement uses f-string 41 | "PLR0911", # Too many return statements 42 | "PLR0912", # Too many branches 43 | "PLR0913", # Too many arguments to function call 44 | "PLR0915", # Too many statements 45 | "SLF001", # Private member accessed 46 | "TRY003", # Avoid specifying long messages outside the exception class 47 | ] 48 | 49 | [lint.per-file-ignores] 50 | "src/**/cli.py" = [ 51 | "T201", # Print statement 52 | ] 53 | "src/*/debug.py" = [ 54 | "T201", # Print statement 55 | ] 56 | "!src/*/*.py" = [ 57 | "D100", # Missing docstring in public module 58 | ] 59 | "!src/**.py" = [ 60 | "D101", # Missing docstring in public class 61 | "D103", # Missing docstring in public function 62 | ] 63 | "scripts/*.py" = [ 64 | "INP001", # File is part of an implicit namespace package 65 | "T201", # Print statement 66 | ] 67 | "tests/**.py" = [ 68 | "ARG005", # Unused lambda argument 69 | "FBT001", # Boolean positional arg in function definition 70 | "PLR2004", # Magic value used in comparison 71 | "S101", # Use of assert detected 72 | ] 73 | 74 | [lint.flake8-quotes] 75 | docstring-quotes = "double" 76 | 77 | [lint.flake8-tidy-imports] 78 | ban-relative-imports = "all" 79 | 80 | [lint.isort] 81 | known-first-party = ["copier_templates_extensions"] 82 | 83 | [lint.pydocstyle] 84 | convention = "google" 85 | 86 | [format] 87 | exclude = [ 88 | "tests/fixtures/*.py", 89 | ] 90 | docstring-code-format = true 91 | docstring-code-line-length = 80 92 | -------------------------------------------------------------------------------- /config/vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "python (current file)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false, 11 | "args": "${command:pickArgs}" 12 | }, 13 | { 14 | "name": "run", 15 | "type": "debugpy", 16 | "request": "launch", 17 | "module": "copier_templates_extensions", 18 | "console": "integratedTerminal", 19 | "justMyCode": false, 20 | "args": "${command:pickArgs}" 21 | }, 22 | { 23 | "name": "docs", 24 | "type": "debugpy", 25 | "request": "launch", 26 | "module": "mkdocs", 27 | "justMyCode": false, 28 | "args": [ 29 | "serve", 30 | "-v" 31 | ] 32 | }, 33 | { 34 | "name": "test", 35 | "type": "debugpy", 36 | "request": "launch", 37 | "module": "pytest", 38 | "justMyCode": false, 39 | "args": [ 40 | "-c=config/pytest.ini", 41 | "-vvv", 42 | "--no-cov", 43 | "--dist=no", 44 | "tests", 45 | "-k=${input:tests_selection}" 46 | ] 47 | } 48 | ], 49 | "inputs": [ 50 | { 51 | "id": "tests_selection", 52 | "type": "promptString", 53 | "description": "Tests selection", 54 | "default": "" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /config/vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/.venv*/**": true, 4 | "**/.venvs*/**": true, 5 | "**/venv*/**": true 6 | }, 7 | "mypy-type-checker.args": [ 8 | "--config-file=config/mypy.ini" 9 | ], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.pytestEnabled": true, 12 | "python.testing.pytestArgs": [ 13 | "--config-file=config/pytest.ini" 14 | ], 15 | "ruff.enable": true, 16 | "ruff.format.args": [ 17 | "--config=config/ruff.toml" 18 | ], 19 | "ruff.lint.args": [ 20 | "--config=config/ruff.toml" 21 | ], 22 | "yaml.schemas": { 23 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" 24 | }, 25 | "yaml.customTags": [ 26 | "!ENV scalar", 27 | "!ENV sequence", 28 | "!relative scalar", 29 | "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", 30 | "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", 31 | "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" 32 | ] 33 | } -------------------------------------------------------------------------------- /config/vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "changelog", 6 | "type": "process", 7 | "command": "scripts/make", 8 | "args": ["changelog"] 9 | }, 10 | { 11 | "label": "check", 12 | "type": "process", 13 | "command": "scripts/make", 14 | "args": ["check"] 15 | }, 16 | { 17 | "label": "check-quality", 18 | "type": "process", 19 | "command": "scripts/make", 20 | "args": ["check-quality"] 21 | }, 22 | { 23 | "label": "check-types", 24 | "type": "process", 25 | "command": "scripts/make", 26 | "args": ["check-types"] 27 | }, 28 | { 29 | "label": "check-docs", 30 | "type": "process", 31 | "command": "scripts/make", 32 | "args": ["check-docs"] 33 | }, 34 | { 35 | "label": "check-api", 36 | "type": "process", 37 | "command": "scripts/make", 38 | "args": ["check-api"] 39 | }, 40 | { 41 | "label": "clean", 42 | "type": "process", 43 | "command": "scripts/make", 44 | "args": ["clean"] 45 | }, 46 | { 47 | "label": "docs", 48 | "type": "process", 49 | "command": "scripts/make", 50 | "args": ["docs"] 51 | }, 52 | { 53 | "label": "docs-deploy", 54 | "type": "process", 55 | "command": "scripts/make", 56 | "args": ["docs-deploy"] 57 | }, 58 | { 59 | "label": "format", 60 | "type": "process", 61 | "command": "scripts/make", 62 | "args": ["format"] 63 | }, 64 | { 65 | "label": "release", 66 | "type": "process", 67 | "command": "scripts/make", 68 | "args": ["release", "${input:version}"] 69 | }, 70 | { 71 | "label": "setup", 72 | "type": "process", 73 | "command": "scripts/make", 74 | "args": ["setup"] 75 | }, 76 | { 77 | "label": "test", 78 | "type": "process", 79 | "command": "scripts/make", 80 | "args": ["test", "coverage"], 81 | "group": "test" 82 | }, 83 | { 84 | "label": "vscode", 85 | "type": "process", 86 | "command": "scripts/make", 87 | "args": ["vscode"] 88 | } 89 | ], 90 | "inputs": [ 91 | { 92 | "id": "version", 93 | "type": "promptString", 94 | "description": "Version" 95 | } 96 | ] 97 | } -------------------------------------------------------------------------------- /docs/.overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | 5 | Follow 6 | @pawamoy on 7 | 8 | 9 | {% include ".icons/fontawesome/brands/mastodon.svg" %} 10 | 11 | Fosstodon 12 | 13 | for updates 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /docs/.overrides/partials/comments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/.overrides/partials/path-item.html: -------------------------------------------------------------------------------- 1 | {# Fix breadcrumbs for when mkdocs-section-index is used. #} 2 | {# See https://github.com/squidfunk/mkdocs-material/issues/7614. #} 3 | 4 | 5 | {% macro render_content(nav_item) %} 6 | 7 | {{ nav_item.title }} 8 | 9 | {% endmacro %} 10 | 11 | 12 | {% macro render(nav_item, ref=nav_item) %} 13 | {% if nav_item.is_page %} 14 |
  • 15 | 16 | {{ render_content(ref) }} 17 | 18 |
  • 19 | {% elif nav_item.children %} 20 | {{ render(nav_item.children | first, ref) }} 21 | {% endif %} 22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | --8<-- "CHANGELOG.md" 6 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code of Conduct 3 | --- 4 | 5 | --8<-- "CODE_OF_CONDUCT.md" 6 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | --8<-- "CONTRIBUTING.md" 6 | -------------------------------------------------------------------------------- /docs/credits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | hide: 4 | - toc 5 | --- 6 | 7 | ```python exec="yes" 8 | --8<-- "scripts/gen_credits.py" 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/css/material.css: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page. */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /docs/css/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | /* Indentation. */ 2 | div.doc-contents:not(.first) { 3 | padding-left: 25px; 4 | border-left: .05rem solid var(--md-typeset-table-color); 5 | } 6 | 7 | /* Mark external links as such. */ 8 | a.external::after, 9 | a.autorefs-external::after { 10 | /* https://primer.style/octicons/arrow-up-right-24 */ 11 | mask-image: url('data:image/svg+xml,'); 12 | -webkit-mask-image: url('data:image/svg+xml,'); 13 | content: ' '; 14 | 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | 19 | height: 1em; 20 | width: 1em; 21 | background-color: currentColor; 22 | } 23 | 24 | a.external:hover::after, 25 | a.autorefs-external:hover::after { 26 | background-color: var(--md-accent-fg-color); 27 | } -------------------------------------------------------------------------------- /docs/gen_credits.py: -------------------------------------------------------------------------------- 1 | """Generate the credits page.""" 2 | 3 | import functools 4 | import re 5 | from itertools import chain 6 | from pathlib import Path 7 | from urllib.request import urlopen 8 | 9 | import mkdocs_gen_files 10 | import toml 11 | from jinja2 import StrictUndefined 12 | from jinja2.sandbox import SandboxedEnvironment 13 | 14 | 15 | def get_credits_data() -> dict: 16 | """Return data used to generate the credits file. 17 | 18 | Returns: 19 | Data required to render the credits template. 20 | """ 21 | project_dir = Path(__file__).parent.parent 22 | metadata = toml.load(project_dir / "pyproject.toml")["project"] 23 | metadata_pdm = toml.load(project_dir / "pyproject.toml")["tool"]["pdm"] 24 | lock_data = toml.load(project_dir / "pdm.lock") 25 | project_name = metadata["name"] 26 | 27 | all_dependencies = chain( 28 | metadata.get("dependencies", []), 29 | chain(*metadata.get("optional-dependencies", {}).values()), 30 | chain(*metadata_pdm.get("dev-dependencies", {}).values()), 31 | ) 32 | direct_dependencies = {re.sub(r"[^\w-].*$", "", dep) for dep in all_dependencies} 33 | direct_dependencies = {dep.lower() for dep in direct_dependencies} 34 | indirect_dependencies = {pkg["name"].lower() for pkg in lock_data["package"]} 35 | indirect_dependencies -= direct_dependencies 36 | 37 | return { 38 | "project_name": project_name, 39 | "direct_dependencies": sorted(direct_dependencies), 40 | "indirect_dependencies": sorted(indirect_dependencies), 41 | "more_credits": "http://pawamoy.github.io/credits/", 42 | } 43 | 44 | 45 | @functools.lru_cache(maxsize=None) 46 | def get_credits(): 47 | """Return credits as Markdown. 48 | 49 | Returns: 50 | The credits page Markdown. 51 | """ 52 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined) 53 | commit = "c78c29caa345b6ace19494a98b1544253cbaf8c1" 54 | template_url = f"https://raw.githubusercontent.com/pawamoy/jinja-templates/{commit}/credits.md" 55 | template_data = get_credits_data() 56 | template_text = urlopen(template_url).read().decode("utf8") # noqa: S310 57 | return jinja_env.from_string(template_text).render(**template_data) 58 | 59 | 60 | with mkdocs_gen_files.open("credits.md", "w") as fd: 61 | fd.write(get_credits()) 62 | mkdocs_gen_files.set_edit_path("credits.md", "gen_credits.py") 63 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | hide: 4 | - feedback 5 | --- 6 | 7 | --8<-- "README.md" 8 | -------------------------------------------------------------------------------- /docs/js/feedback.js: -------------------------------------------------------------------------------- 1 | const feedback = document.forms.feedback; 2 | feedback.hidden = false; 3 | 4 | feedback.addEventListener("submit", function(ev) { 5 | ev.preventDefault(); 6 | const commentElement = document.getElementById("feedback"); 7 | commentElement.style.display = "block"; 8 | feedback.firstElementChild.disabled = true; 9 | const data = ev.submitter.getAttribute("data-md-value"); 10 | const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); 11 | if (note) { 12 | note.hidden = false; 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: License 3 | hide: 4 | - feedback 5 | --- 6 | 7 | # License 8 | 9 | ``` 10 | --8<-- "LICENSE" 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/reference/copier_templates_extensions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API reference 3 | hide: 4 | - navigation 5 | --- 6 | 7 | # ::: copier_templates_extensions 8 | -------------------------------------------------------------------------------- /duties.py: -------------------------------------------------------------------------------- 1 | """Development tasks.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import re 7 | import sys 8 | from contextlib import contextmanager 9 | from importlib.metadata import version as pkgversion 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING 12 | 13 | from duty import duty, tools 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | from duty.context import Context 19 | 20 | 21 | PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 22 | PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 23 | PY_SRC = " ".join(PY_SRC_LIST) 24 | CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} 25 | WINDOWS = os.name == "nt" 26 | PTY = not WINDOWS and not CI 27 | MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" 28 | 29 | 30 | def pyprefix(title: str) -> str: 31 | if MULTIRUN: 32 | prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" 33 | return f"{prefix:14}{title}" 34 | return title 35 | 36 | 37 | @contextmanager 38 | def material_insiders() -> Iterator[bool]: 39 | if "+insiders" in pkgversion("mkdocs-material"): 40 | os.environ["MATERIAL_INSIDERS"] = "true" 41 | try: 42 | yield True 43 | finally: 44 | os.environ.pop("MATERIAL_INSIDERS") 45 | else: 46 | yield False 47 | 48 | 49 | def _get_changelog_version() -> str: 50 | changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 51 | with Path(__file__).parent.joinpath("CHANGELOG.md").open("r", encoding="utf8") as file: 52 | return next(filter(bool, map(changelog_version_re.match, file))).group(1) # type: ignore[union-attr] 53 | 54 | 55 | @duty 56 | def changelog(ctx: Context, bump: str = "") -> None: 57 | """Update the changelog in-place with latest commits. 58 | 59 | Parameters: 60 | bump: Bump option passed to git-changelog. 61 | """ 62 | ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 63 | ctx.run(tools.yore.check(bump=bump or _get_changelog_version()), title="Checking legacy code") 64 | 65 | 66 | @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 67 | def check(ctx: Context) -> None: 68 | """Check it all!""" 69 | 70 | 71 | @duty 72 | def check_quality(ctx: Context) -> None: 73 | """Check the code quality.""" 74 | ctx.run( 75 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 76 | title=pyprefix("Checking code quality"), 77 | ) 78 | 79 | 80 | @duty 81 | def check_docs(ctx: Context) -> None: 82 | """Check if the documentation builds correctly.""" 83 | Path("htmlcov").mkdir(parents=True, exist_ok=True) 84 | Path("htmlcov/index.html").touch(exist_ok=True) 85 | with material_insiders(): 86 | ctx.run( 87 | tools.mkdocs.build(strict=True, verbose=True), 88 | title=pyprefix("Building documentation"), 89 | ) 90 | 91 | 92 | @duty 93 | def check_types(ctx: Context) -> None: 94 | """Check that the code is correctly typed.""" 95 | os.environ["FORCE_COLOR"] = "1" 96 | ctx.run( 97 | tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 98 | title=pyprefix("Type-checking"), 99 | ) 100 | 101 | 102 | @duty 103 | def check_api(ctx: Context, *cli_args: str) -> None: 104 | """Check for API breaking changes.""" 105 | ctx.run( 106 | tools.griffe.check("copier_templates_extensions", search=["src"], color=True).add_args(*cli_args), 107 | title="Checking for API breaking changes", 108 | nofail=True, 109 | ) 110 | 111 | 112 | @duty 113 | def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 114 | """Serve the documentation (localhost:8000). 115 | 116 | Parameters: 117 | host: The host to serve the docs from. 118 | port: The port to serve the docs on. 119 | """ 120 | with material_insiders(): 121 | ctx.run( 122 | tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 123 | title="Serving documentation", 124 | capture=False, 125 | ) 126 | 127 | 128 | @duty 129 | def docs_deploy(ctx: Context) -> None: 130 | """Deploy the documentation on GitHub pages. 131 | 132 | Parameters: 133 | ctx: The context instance (passed automatically). 134 | """ 135 | # os.environ["DEPLOY"] = "true" 136 | # with material_insiders() as insiders: 137 | # if not insiders: 138 | # ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") 139 | # ctx.run(mkdocs.gh_deploy(), title="Deploying documentation") 140 | ctx.run("false", title="We do not deploy docs for this project yet.", nofail=True) 141 | 142 | 143 | @duty 144 | def format(ctx: Context) -> None: 145 | """Run formatting tools on the code.""" 146 | ctx.run( 147 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 148 | title="Auto-fixing code", 149 | ) 150 | ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 151 | 152 | 153 | @duty 154 | def build(ctx: Context) -> None: 155 | """Build source and wheel distributions.""" 156 | ctx.run( 157 | tools.build(), 158 | title="Building source and wheel distributions", 159 | pty=PTY, 160 | ) 161 | 162 | 163 | @duty 164 | def publish(ctx: Context) -> None: 165 | """Publish source and wheel distributions to PyPI.""" 166 | if not Path("dist").exists(): 167 | ctx.run("false", title="No distribution files found") 168 | dists = [str(dist) for dist in Path("dist").iterdir()] 169 | ctx.run( 170 | tools.twine.upload(*dists, skip_existing=True), 171 | title="Publishing source and wheel distributions to PyPI", 172 | pty=PTY, 173 | ) 174 | 175 | 176 | @duty(post=["build", "publish", "docs-deploy"]) 177 | def release(ctx: Context, version: str = "") -> None: 178 | """Release a new Python package. 179 | 180 | Parameters: 181 | version: The new version number to use. 182 | """ 183 | if not (version := (version or input("> Version to release: ")).strip()): 184 | ctx.run("false", title="A version must be provided") 185 | ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 186 | ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 187 | ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 188 | ctx.run("git push", title="Pushing commits", pty=False) 189 | ctx.run("git push --tags", title="Pushing tags", pty=False) 190 | 191 | 192 | @duty(silent=True, aliases=["cov"]) 193 | def coverage(ctx: Context) -> None: 194 | """Report coverage as text and HTML.""" 195 | ctx.run(tools.coverage.combine(), nofail=True) 196 | ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 197 | ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 198 | 199 | 200 | @duty 201 | def test(ctx: Context, *cli_args: str, match: str = "") -> None: 202 | """Run the test suite. 203 | 204 | Parameters: 205 | match: A pytest expression to filter selected tests. 206 | """ 207 | py_version = f"{sys.version_info.major}{sys.version_info.minor}" 208 | os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 209 | ctx.run( 210 | tools.pytest( 211 | "tests", 212 | config_file="config/pytest.ini", 213 | select=match, 214 | color="yes", 215 | ).add_args("-n", "auto", *cli_args), 216 | title=pyprefix("Running tests"), 217 | ) 218 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "Copier Templates Extensions" 2 | site_description: "Special Jinja2 extension for Copier that allows to load extensions using file paths relative to the template root instead of Python dotted paths." 3 | site_url: "https://copier-org.github.io/copier-templates-extensions" 4 | repo_url: "https://github.com/copier-org/copier-templates-extensions" 5 | repo_name: "copier-org/copier-templates-extensions" 6 | site_dir: "site" 7 | watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/copier_templates_extensions] 8 | copyright: Copyright © 2021 Timothée Mazzucotelli 9 | edit_uri: edit/main/docs/ 10 | 11 | validation: 12 | omitted_files: warn 13 | absolute_links: warn 14 | unrecognized_links: warn 15 | 16 | nav: 17 | - Home: 18 | - Overview: index.md 19 | - Changelog: changelog.md 20 | - Credits: credits.md 21 | - License: license.md 22 | - API reference: reference/copier_templates_extensions.md 23 | - Development: 24 | - Contributing: contributing.md 25 | - Code of Conduct: code_of_conduct.md 26 | - Coverage report: coverage.md 27 | - Author's website: https://pawamoy.github.io/ 28 | 29 | theme: 30 | name: material 31 | custom_dir: docs/.overrides 32 | icon: 33 | logo: material/currency-sign 34 | features: 35 | - announce.dismiss 36 | - content.action.edit 37 | - content.action.view 38 | - content.code.annotate 39 | - content.code.copy 40 | - content.tooltips 41 | - navigation.footer 42 | - navigation.instant.preview 43 | - navigation.path 44 | - navigation.sections 45 | - navigation.tabs 46 | - navigation.tabs.sticky 47 | - navigation.top 48 | - search.highlight 49 | - search.suggest 50 | - toc.follow 51 | palette: 52 | - media: "(prefers-color-scheme)" 53 | toggle: 54 | icon: material/brightness-auto 55 | name: Switch to light mode 56 | - media: "(prefers-color-scheme: light)" 57 | scheme: default 58 | primary: teal 59 | accent: purple 60 | toggle: 61 | icon: material/weather-sunny 62 | name: Switch to dark mode 63 | - media: "(prefers-color-scheme: dark)" 64 | scheme: slate 65 | primary: black 66 | accent: lime 67 | toggle: 68 | icon: material/weather-night 69 | name: Switch to system preference 70 | 71 | extra_css: 72 | - css/material.css 73 | - css/mkdocstrings.css 74 | 75 | extra_javascript: 76 | - js/feedback.js 77 | 78 | markdown_extensions: 79 | - attr_list 80 | - admonition 81 | - callouts 82 | - footnotes 83 | - pymdownx.emoji: 84 | emoji_index: !!python/name:material.extensions.emoji.twemoji 85 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 86 | - pymdownx.magiclink 87 | - pymdownx.snippets: 88 | base_path: [!relative $config_dir] 89 | check_paths: true 90 | - pymdownx.superfences 91 | - pymdownx.tabbed: 92 | alternate_style: true 93 | slugify: !!python/object/apply:pymdownx.slugs.slugify 94 | kwds: 95 | case: lower 96 | - pymdownx.tasklist: 97 | custom_checkbox: true 98 | - toc: 99 | permalink: "¤" 100 | 101 | plugins: 102 | - search 103 | - markdown-exec 104 | - section-index 105 | - coverage 106 | - mkdocstrings: 107 | handlers: 108 | python: 109 | inventories: 110 | - https://docs.python.org/3/objects.inv 111 | paths: [src] 112 | options: 113 | docstring_options: 114 | ignore_init_summary: true 115 | docstring_section_style: list 116 | filters: ["!^_"] 117 | heading_level: 1 118 | inherited_members: true 119 | merge_init_into_class: true 120 | separate_signature: true 121 | show_root_heading: true 122 | show_root_full_path: false 123 | show_signature_annotations: true 124 | show_source: true 125 | show_symbol_type_heading: true 126 | show_symbol_type_toc: true 127 | signature_crossrefs: true 128 | summary: true 129 | - llmstxt: 130 | files: 131 | - output: llms-full.txt 132 | inputs: 133 | - index.md 134 | - reference/**.md 135 | - git-revision-date-localized: 136 | enabled: !ENV [DEPLOY, false] 137 | enable_creation_date: true 138 | type: timeago 139 | - minify: 140 | minify_html: !ENV [DEPLOY, false] 141 | - group: 142 | enabled: !ENV [MATERIAL_INSIDERS, false] 143 | plugins: 144 | - typeset 145 | 146 | extra: 147 | social: 148 | - icon: fontawesome/brands/github 149 | link: https://github.com/pawamoy 150 | - icon: fontawesome/brands/mastodon 151 | link: https://fosstodon.org/@pawamoy 152 | - icon: fontawesome/brands/twitter 153 | link: https://twitter.com/pawamoy 154 | - icon: fontawesome/brands/gitter 155 | link: https://gitter.im/copier-templates-extensions/community 156 | - icon: fontawesome/brands/python 157 | link: https://pypi.org/project/copier-templates-extensions/ 158 | analytics: 159 | feedback: 160 | title: Was this page helpful? 161 | ratings: 162 | - icon: material/emoticon-happy-outline 163 | name: This page was helpful 164 | data: 1 165 | note: Thanks for your feedback! 166 | - icon: material/emoticon-sad-outline 167 | name: This page could be improved 168 | data: 0 169 | note: Let us know how we can improve this page. 170 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "copier-templates-extensions" 7 | description = "Special Jinja2 extension for Copier that allows to load extensions using file paths relative to the template root instead of Python dotted paths." 8 | authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] 9 | license = "ISC" 10 | license-files = ["LICENSE"] 11 | readme = "README.md" 12 | requires-python = ">=3.9" 13 | keywords = ["copier", "templates", "extension"] 14 | dynamic = ["version"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Topic :: Software Development", 28 | "Topic :: Utilities", 29 | "Typing :: Typed", 30 | ] 31 | dependencies = [ 32 | "copier>=9.2", 33 | ] 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/copier-org/copier-templates-extensions" 37 | Documentation = "https://github.com/copier-org/copier-templates-extensions" 38 | Changelog = "https://github.com/copier-org/copier-templates-extensions/blob/main/CHANGELOG.md" 39 | Repository = "https://github.com/copier-org/copier-templates-extensions" 40 | Issues = "https://github.com/copier-org/copier-templates-extensions/issues" 41 | Discussions = "https://github.com/copier-org/copier-templates-extensions/discussions" 42 | Funding = "https://github.com/sponsors/pawamoy" 43 | 44 | [tool.pdm.version] 45 | source = "call" 46 | getter = "scripts.get_version:get_version" 47 | 48 | [tool.pdm.build] 49 | # Include as much as possible in the source distribution, to help redistributors. 50 | excludes = ["**/.pytest_cache"] 51 | source-includes = [ 52 | "config", 53 | "docs", 54 | "scripts", 55 | "share", 56 | "tests", 57 | "duties.py", 58 | "mkdocs.yml", 59 | "*.md", 60 | "LICENSE", 61 | ] 62 | 63 | [tool.pdm.build.wheel-data] 64 | # Manual pages can be included in the wheel. 65 | # Depending on the installation tool, they will be accessible to users. 66 | # pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731. 67 | data = [ 68 | {path = "share/**/*", relative-to = "."}, 69 | ] 70 | 71 | [dependency-groups] 72 | maintain = [ 73 | "build>=1.2", 74 | "git-changelog>=2.5", 75 | "twine>=5.1", 76 | "yore>=0.3.3", 77 | ] 78 | ci = [ 79 | "duty>=1.6", 80 | "ruff>=0.4", 81 | "pytest>=8.2", 82 | "pytest-cov>=5.0", 83 | "pytest-randomly>=3.15", 84 | "pytest-xdist>=3.6", 85 | "mypy>=1.10", 86 | "types-markdown>=3.6", 87 | "types-pyyaml>=6.0", 88 | ] 89 | docs = [ 90 | "markdown-callouts>=0.4", 91 | "markdown-exec>=1.8", 92 | "mkdocs>=1.6", 93 | "mkdocs-coverage>=1.0", 94 | "mkdocs-git-revision-date-localized-plugin>=1.2", 95 | "mkdocs-llmstxt>=0.1", 96 | "mkdocs-material>=9.5", 97 | "mkdocs-minify-plugin>=0.8", 98 | "mkdocs-section-index>=0.3", 99 | "mkdocstrings[python]>=0.25", 100 | # YORE: EOL 3.10: Remove line. 101 | "tomli>=2.0; python_version < '3.11'", 102 | ] 103 | 104 | [tool.uv] 105 | default-groups = ["maintain", "ci", "docs"] 106 | -------------------------------------------------------------------------------- /scripts/gen_api_ref.py: -------------------------------------------------------------------------------- 1 | # Generate the API reference pages and navigation. 2 | 3 | from pathlib import Path 4 | 5 | import mkdocs_gen_files 6 | 7 | nav = mkdocs_gen_files.Nav() 8 | mod_symbol = '' 9 | 10 | root = Path(__file__).parent.parent 11 | src = root / "src" 12 | 13 | for path in sorted(src.rglob("*.py")): 14 | module_path = path.relative_to(src).with_suffix("") 15 | doc_path = path.relative_to(src).with_suffix(".md") 16 | full_doc_path = Path("reference", doc_path) 17 | 18 | parts = tuple(module_path.parts) 19 | 20 | if parts[-1] == "__init__": 21 | parts = parts[:-1] 22 | doc_path = doc_path.with_name("index.md") 23 | full_doc_path = full_doc_path.with_name("index.md") 24 | 25 | if any(part.startswith("_") for part in parts): 26 | continue 27 | 28 | nav_parts = [f"{mod_symbol} {part}" for part in parts] 29 | nav[tuple(nav_parts)] = doc_path.as_posix() 30 | 31 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 32 | ident = ".".join(parts) 33 | fd.write(f"---\ntitle: {ident}\n---\n\n::: {ident}") 34 | 35 | mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) 36 | 37 | with mkdocs_gen_files.open("reference/SUMMARY.txt", "w") as nav_file: 38 | nav_file.writelines(nav.build_literate_nav()) 39 | -------------------------------------------------------------------------------- /scripts/gen_credits.py: -------------------------------------------------------------------------------- 1 | # Script to generate the project's credits. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | from collections import defaultdict 8 | from collections.abc import Iterable 9 | from importlib.metadata import distributions 10 | from itertools import chain 11 | from pathlib import Path 12 | from textwrap import dedent 13 | from typing import Union 14 | 15 | from jinja2 import StrictUndefined 16 | from jinja2.sandbox import SandboxedEnvironment 17 | from packaging.requirements import Requirement 18 | 19 | # YORE: EOL 3.10: Replace block with line 2. 20 | if sys.version_info >= (3, 11): 21 | import tomllib 22 | else: 23 | import tomli as tomllib 24 | 25 | project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) 26 | with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: 27 | pyproject = tomllib.load(pyproject_file) 28 | project = pyproject["project"] 29 | project_name = project["name"] 30 | devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")] 31 | 32 | PackageMetadata = dict[str, Union[str, Iterable[str]]] 33 | Metadata = dict[str, PackageMetadata] 34 | 35 | 36 | def _merge_fields(metadata: dict) -> PackageMetadata: 37 | fields = defaultdict(list) 38 | for header, value in metadata.items(): 39 | fields[header.lower()].append(value.strip()) 40 | return { 41 | field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] 42 | for field, value in fields.items() 43 | } 44 | 45 | 46 | def _norm_name(name: str) -> str: 47 | return name.replace("_", "-").replace(".", "-").lower() 48 | 49 | 50 | def _requirements(deps: list[str]) -> dict[str, Requirement]: 51 | return {_norm_name((req := Requirement(dep)).name): req for dep in deps} 52 | 53 | 54 | def _extra_marker(req: Requirement) -> str | None: 55 | if not req.marker: 56 | return None 57 | try: 58 | return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") 59 | except StopIteration: 60 | return None 61 | 62 | 63 | def _get_metadata() -> Metadata: 64 | metadata = {} 65 | for pkg in distributions(): 66 | name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] 67 | metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] 68 | metadata[name]["spec"] = set() 69 | metadata[name]["extras"] = set() 70 | metadata[name].setdefault("summary", "") 71 | _set_license(metadata[name]) 72 | return metadata 73 | 74 | 75 | def _set_license(metadata: PackageMetadata) -> None: 76 | license_field = metadata.get("license-expression", metadata.get("license", "")) 77 | license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) 78 | check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") 79 | if check_classifiers: 80 | license_names = [] 81 | for classifier in metadata["classifier"]: 82 | if classifier.startswith("License ::"): 83 | license_names.append(classifier.rsplit("::", 1)[1].strip()) 84 | license_name = " + ".join(license_names) 85 | metadata["license"] = license_name or "?" 86 | 87 | 88 | def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: 89 | deps = {} 90 | for dep_name, dep_req in base_deps.items(): 91 | if dep_name not in metadata or dep_name == "copier-templates-extensions": 92 | continue 93 | metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] 94 | metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] 95 | deps[dep_name] = metadata[dep_name] 96 | 97 | again = True 98 | while again: 99 | again = False 100 | for pkg_name in metadata: 101 | if pkg_name in deps: 102 | for pkg_dependency in metadata[pkg_name].get("requires-dist", []): 103 | requirement = Requirement(pkg_dependency) 104 | dep_name = _norm_name(requirement.name) 105 | extra_marker = _extra_marker(requirement) 106 | if ( 107 | dep_name in metadata 108 | and dep_name not in deps 109 | and dep_name != project["name"] 110 | and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) 111 | ): 112 | metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] 113 | deps[dep_name] = metadata[dep_name] 114 | again = True 115 | 116 | return deps 117 | 118 | 119 | def _render_credits() -> str: 120 | metadata = _get_metadata() 121 | dev_dependencies = _get_deps(_requirements(devdeps), metadata) 122 | prod_dependencies = _get_deps( 123 | _requirements( 124 | chain( # type: ignore[arg-type] 125 | project.get("dependencies", []), 126 | chain(*project.get("optional-dependencies", {}).values()), 127 | ), 128 | ), 129 | metadata, 130 | ) 131 | 132 | template_data = { 133 | "project_name": project_name, 134 | "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 135 | "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 136 | "more_credits": "http://pawamoy.github.io/credits/", 137 | } 138 | template_text = dedent( 139 | """ 140 | # Credits 141 | 142 | These projects were used to build *{{ project_name }}*. **Thank you!** 143 | 144 | [Python](https://www.python.org/) | 145 | [uv](https://github.com/astral-sh/uv) | 146 | [copier-uv](https://github.com/pawamoy/copier-uv) 147 | 148 | {% macro dep_line(dep) -%} 149 | [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} 150 | {%- endmacro %} 151 | 152 | {% if prod_dependencies -%} 153 | ### Runtime dependencies 154 | 155 | Project | Summary | Version (accepted) | Version (last resolved) | License 156 | ------- | ------- | ------------------ | ----------------------- | ------- 157 | {% for dep in prod_dependencies -%} 158 | {{ dep_line(dep) }} 159 | {% endfor %} 160 | 161 | {% endif -%} 162 | {% if dev_dependencies -%} 163 | ### Development dependencies 164 | 165 | Project | Summary | Version (accepted) | Version (last resolved) | License 166 | ------- | ------- | ------------------ | ----------------------- | ------- 167 | {% for dep in dev_dependencies -%} 168 | {{ dep_line(dep) }} 169 | {% endfor %} 170 | 171 | {% endif -%} 172 | {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} 173 | """, 174 | ) 175 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined) 176 | return jinja_env.from_string(template_text).render(**template_data) 177 | 178 | 179 | print(_render_credits()) 180 | -------------------------------------------------------------------------------- /scripts/get_version.py: -------------------------------------------------------------------------------- 1 | # Get current project version from Git tags or changelog. 2 | 3 | import re 4 | from contextlib import suppress 5 | from pathlib import Path 6 | 7 | from pdm.backend.hooks.version import SCMVersion, Version, default_version_formatter, get_version_from_scm 8 | 9 | _root = Path(__file__).parent.parent 10 | _changelog = _root / "CHANGELOG.md" 11 | _changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 12 | _default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003 13 | 14 | 15 | def get_version() -> str: 16 | scm_version = get_version_from_scm(_root) or _default_scm_version 17 | if scm_version.version <= Version("0.1"): # Missing Git tags? 18 | with suppress(OSError, StopIteration): # noqa: SIM117 19 | with _changelog.open("r", encoding="utf8") as file: 20 | match = next(filter(None, map(_changelog_version_re.match, file))) 21 | scm_version = scm_version._replace(version=Version(match.group(1))) 22 | return default_version_formatter(scm_version) 23 | 24 | 25 | if __name__ == "__main__": 26 | print(get_version()) 27 | -------------------------------------------------------------------------------- /scripts/make: -------------------------------------------------------------------------------- 1 | make.py -------------------------------------------------------------------------------- /scripts/make.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from textwrap import dedent 11 | from typing import TYPE_CHECKING, Any 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator 15 | 16 | 17 | PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13").split() 18 | 19 | 20 | def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None: 21 | """Run a shell command.""" 22 | if capture_output: 23 | return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 24 | subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 25 | return None 26 | 27 | 28 | @contextmanager 29 | def environ(**kwargs: str) -> Iterator[None]: 30 | """Temporarily set environment variables.""" 31 | original = dict(os.environ) 32 | os.environ.update(kwargs) 33 | try: 34 | yield 35 | finally: 36 | os.environ.clear() 37 | os.environ.update(original) 38 | 39 | 40 | def uv_install(venv: Path) -> None: 41 | """Install dependencies using uv.""" 42 | with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): 43 | if "CI" in os.environ: 44 | shell("uv sync --no-editable") 45 | else: 46 | shell("uv sync") 47 | 48 | 49 | def setup() -> None: 50 | """Setup the project.""" 51 | if not shutil.which("uv"): 52 | raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") 53 | 54 | print("Installing dependencies (default environment)") 55 | default_venv = Path(".venv") 56 | if not default_venv.exists(): 57 | shell("uv venv") 58 | uv_install(default_venv) 59 | 60 | if PYTHON_VERSIONS: 61 | for version in PYTHON_VERSIONS: 62 | print(f"\nInstalling dependencies (python{version})") 63 | venv_path = Path(f".venvs/{version}") 64 | if not venv_path.exists(): 65 | shell(f"uv venv --python {version} {venv_path}") 66 | with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): 67 | uv_install(venv_path) 68 | 69 | 70 | def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: 71 | """Run a command in a virtual environment.""" 72 | kwargs = {"check": True, **kwargs} 73 | uv_run = ["uv", "run", "--no-sync"] 74 | if version == "default": 75 | with environ(UV_PROJECT_ENVIRONMENT=".venv"): 76 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 77 | else: 78 | with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): 79 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 80 | 81 | 82 | def multirun(cmd: str, *args: str, **kwargs: Any) -> None: 83 | """Run a command for all configured Python versions.""" 84 | if PYTHON_VERSIONS: 85 | for version in PYTHON_VERSIONS: 86 | run(version, cmd, *args, **kwargs) 87 | else: 88 | run("default", cmd, *args, **kwargs) 89 | 90 | 91 | def allrun(cmd: str, *args: str, **kwargs: Any) -> None: 92 | """Run a command in all virtual environments.""" 93 | run("default", cmd, *args, **kwargs) 94 | if PYTHON_VERSIONS: 95 | multirun(cmd, *args, **kwargs) 96 | 97 | 98 | def clean() -> None: 99 | """Delete build artifacts and cache files.""" 100 | paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] 101 | for path in paths_to_clean: 102 | shutil.rmtree(path, ignore_errors=True) 103 | 104 | cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} 105 | for dirpath in Path(".").rglob("*/"): 106 | if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: 107 | shutil.rmtree(dirpath, ignore_errors=True) 108 | 109 | 110 | def vscode() -> None: 111 | """Configure VSCode to work on this project.""" 112 | shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True) 113 | 114 | 115 | def main() -> int: 116 | """Main entry point.""" 117 | args = list(sys.argv[1:]) 118 | if not args or args[0] == "help": 119 | if len(args) > 1: 120 | run("default", "duty", "--help", args[1]) 121 | else: 122 | print( 123 | dedent( 124 | """ 125 | Available commands 126 | help Print this help. Add task name to print help. 127 | setup Setup all virtual environments (install dependencies). 128 | run Run a command in the default virtual environment. 129 | multirun Run a command for all configured Python versions. 130 | allrun Run a command in all virtual environments. 131 | 3.x Run a command in the virtual environment for Python 3.x. 132 | clean Delete build artifacts and cache files. 133 | vscode Configure VSCode to work on this project. 134 | """, 135 | ), 136 | flush=True, 137 | ) 138 | if os.path.exists(".venv"): 139 | print("\nAvailable tasks", flush=True) 140 | run("default", "duty", "--list") 141 | return 0 142 | 143 | while args: 144 | cmd = args.pop(0) 145 | 146 | if cmd == "run": 147 | run("default", *args) 148 | return 0 149 | 150 | if cmd == "multirun": 151 | multirun(*args) 152 | return 0 153 | 154 | if cmd == "allrun": 155 | allrun(*args) 156 | return 0 157 | 158 | if cmd.startswith("3."): 159 | run(cmd, *args) 160 | return 0 161 | 162 | opts = [] 163 | while args and (args[0].startswith("-") or "=" in args[0]): 164 | opts.append(args.pop(0)) 165 | 166 | if cmd == "clean": 167 | clean() 168 | elif cmd == "setup": 169 | setup() 170 | elif cmd == "vscode": 171 | vscode() 172 | elif cmd == "check": 173 | multirun("duty", "check-quality", "check-types", "check-docs") 174 | run("default", "duty", "check-api") 175 | elif cmd in {"check-quality", "check-docs", "check-types", "test"}: 176 | multirun("duty", cmd, *opts) 177 | else: 178 | run("default", "duty", cmd, *opts) 179 | 180 | return 0 181 | 182 | 183 | if __name__ == "__main__": 184 | try: 185 | sys.exit(main()) 186 | except subprocess.CalledProcessError as process: 187 | if process.output: 188 | print(process.output, file=sys.stderr) 189 | sys.exit(process.returncode) 190 | -------------------------------------------------------------------------------- /src/copier_templates_extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """Copier Templates Extensions package. 2 | 3 | Special Jinja2 extension for Copier that allows to load extensions 4 | using file paths relative to the template root instead of Python dotted paths. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from copier_templates_extensions._internal.context import ContextHook 10 | from copier_templates_extensions._internal.loader import TemplateExtensionLoader 11 | 12 | __all__: list[str] = ["ContextHook", "TemplateExtensionLoader"] 13 | -------------------------------------------------------------------------------- /src/copier_templates_extensions/_internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier-templates-extensions/e3fe3793fe44f41e198ce90e5a63723bf9571aa8/src/copier_templates_extensions/_internal/__init__.py -------------------------------------------------------------------------------- /src/copier_templates_extensions/_internal/context.py: -------------------------------------------------------------------------------- 1 | # Extension allowing to modify the Copier context. 2 | 3 | from __future__ import annotations 4 | 5 | import warnings 6 | from typing import TYPE_CHECKING, Any, Callable 7 | 8 | from jinja2.ext import Extension 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import MutableMapping 12 | 13 | from jinja2 import Environment 14 | 15 | 16 | _sentinel = object() 17 | 18 | 19 | class ContextHook(Extension): 20 | """Extension allowing to modify the Copier context.""" 21 | 22 | update = _sentinel 23 | 24 | def __init__(extension_self: Extension, environment: Environment) -> None: # noqa: N805 25 | """Initialize the object. 26 | 27 | Arguments: 28 | environment: The Jinja environment. 29 | """ 30 | super().__init__(environment) # type: ignore[misc] 31 | 32 | class ContextClass(environment.context_class): # type: ignore[name-defined] 33 | def __init__( 34 | self, 35 | env: Environment, 36 | parent: dict[str, Any], 37 | name: str | None, 38 | blocks: dict[str, Callable], 39 | globals: MutableMapping[str, Any] | None = None, # noqa: A002 40 | ): 41 | if extension_self.update is not _sentinel: # type: ignore[attr-defined] 42 | warnings.warn( 43 | "The `update` attribute of `ContextHook` subclasses is deprecated. " 44 | "The `hook` method should now always modify the `context` in place.", 45 | DeprecationWarning, 46 | stacklevel=1, 47 | ) 48 | if "_copier_conf" in parent and (context := extension_self.hook(parent)) is not None: # type: ignore[attr-defined] 49 | parent.update(context) 50 | warnings.warn( 51 | "Returning a dict from the `hook` method is deprecated. " 52 | "It should now always modify the `context` in place.", 53 | DeprecationWarning, 54 | stacklevel=1, 55 | ) 56 | 57 | super().__init__(env, parent, name, blocks, globals) 58 | 59 | environment.context_class = ContextClass 60 | 61 | def hook(self, context: dict) -> dict: 62 | """Abstract hook. Does nothing. 63 | 64 | Override this method to either return 65 | a new context dictionary that will be used 66 | to update the original one, 67 | or modify the context object in-place. 68 | 69 | Arguments: 70 | context: The context to modify. 71 | 72 | Raises: 73 | NotImplementedError: This method must be overridden in a subclass, 74 | and instead return either the same context instance modified, 75 | or new context instance (dictionary). 76 | """ 77 | raise NotImplementedError 78 | -------------------------------------------------------------------------------- /src/copier_templates_extensions/_internal/debug.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | from dataclasses import dataclass 7 | from importlib import metadata 8 | 9 | 10 | @dataclass 11 | class _Variable: 12 | """Dataclass describing an environment variable.""" 13 | 14 | name: str 15 | """Variable name.""" 16 | value: str 17 | """Variable value.""" 18 | 19 | 20 | @dataclass 21 | class _Package: 22 | """Dataclass describing a Python package.""" 23 | 24 | name: str 25 | """Package name.""" 26 | version: str 27 | """Package version.""" 28 | 29 | 30 | @dataclass 31 | class _Environment: 32 | """Dataclass to store environment information.""" 33 | 34 | interpreter_name: str 35 | """Python interpreter name.""" 36 | interpreter_version: str 37 | """Python interpreter version.""" 38 | interpreter_path: str 39 | """Path to Python executable.""" 40 | platform: str 41 | """Operating System.""" 42 | packages: list[_Package] 43 | """Installed packages.""" 44 | variables: list[_Variable] 45 | """Environment variables.""" 46 | 47 | 48 | def _interpreter_name_version() -> tuple[str, str]: 49 | if hasattr(sys, "implementation"): 50 | impl = sys.implementation.version 51 | version = f"{impl.major}.{impl.minor}.{impl.micro}" 52 | kind = impl.releaselevel 53 | if kind != "final": 54 | version += kind[0] + str(impl.serial) 55 | return sys.implementation.name, version 56 | return "", "0.0.0" 57 | 58 | 59 | def _get_version(dist: str = "copier-templates-extensions") -> str: 60 | """Get version of the given distribution. 61 | 62 | Parameters: 63 | dist: A distribution name. 64 | 65 | Returns: 66 | A version number. 67 | """ 68 | try: 69 | return metadata.version(dist) 70 | except metadata.PackageNotFoundError: 71 | return "0.0.0" 72 | 73 | 74 | def _get_debug_info() -> _Environment: 75 | """Get debug/environment information. 76 | 77 | Returns: 78 | Environment information. 79 | """ 80 | py_name, py_version = _interpreter_name_version() 81 | packages = ["copier-templates-extensions"] 82 | variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("COPIER_TEMPLATES_EXTENSIONS")]] 83 | return _Environment( 84 | interpreter_name=py_name, 85 | interpreter_version=py_version, 86 | interpreter_path=sys.executable, 87 | platform=platform.platform(), 88 | variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))], 89 | packages=[_Package(pkg, _get_version(pkg)) for pkg in packages], 90 | ) 91 | 92 | 93 | def _print_debug_info() -> None: 94 | """Print debug/environment information.""" 95 | info = _get_debug_info() 96 | print(f"- __System__: {info.platform}") 97 | print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") 98 | print("- __Environment variables__:") 99 | for var in info.variables: 100 | print(f" - `{var.name}`: `{var.value}`") 101 | print("- __Installed packages__:") 102 | for pkg in info.packages: 103 | print(f" - `{pkg.name}` v{pkg.version}") 104 | 105 | 106 | if __name__ == "__main__": 107 | _print_debug_info() 108 | -------------------------------------------------------------------------------- /src/copier_templates_extensions/_internal/loader.py: -------------------------------------------------------------------------------- 1 | # Extension allowing to load other extensions using relative file paths. 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from importlib.util import module_from_spec, spec_from_file_location 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, Any 9 | 10 | from copier.errors import UserMessageError 11 | from jinja2 import Environment 12 | from jinja2 import environment as jinja_env_module 13 | from jinja2.ext import Extension 14 | 15 | if TYPE_CHECKING: 16 | from types import ModuleType 17 | 18 | 19 | class TemplateExtensionLoader(Extension): 20 | """Extension allowing to load other extensions using relative file paths.""" 21 | 22 | def __init__(self, environment: Environment): 23 | """Initialize the object. 24 | 25 | Arguments: 26 | environment: The Jinja environment. 27 | """ 28 | super().__init__(environment) 29 | # patch jinja's extension loading mechanism 30 | jinja_env_module.import_string = self._patched_import_string # type: ignore[assignment] 31 | 32 | def _patched_import_string(self, import_name: str, *, silent: bool = False) -> Any: 33 | try: 34 | return self._import_string(import_name) 35 | except Exception: 36 | if not silent: 37 | raise 38 | 39 | def _import_string(self, import_name: str) -> Any: 40 | if ":" in import_name: 41 | module_name, obj_name = import_name.split(":", 1) 42 | module = self._import_module(module_name, obj_name, try_filepath=True) 43 | return self._get_module_attribute(module, obj_name) 44 | 45 | if "." in import_name: 46 | module_name, _, obj_name = import_name.rpartition(".") 47 | module = self._import_module(module_name, obj_name) 48 | return self._get_module_attribute(module, obj_name) 49 | 50 | try: 51 | return __import__(import_name) 52 | except ImportError as error: 53 | raise UserMessageError(f"Could not import extension '{import_name}'") from error 54 | 55 | def _get_module_attribute(self, module: ModuleType, obj_name: str) -> Any: 56 | try: 57 | return getattr(module, obj_name) 58 | except AttributeError as error: 59 | raise UserMessageError( 60 | f"Module '{module.__name__}' does not have the '{obj_name}' attribute.\n" 61 | "Please report this issue to the template maintainers.", 62 | ) from error 63 | 64 | def _import_module(self, module_name: str, obj_name: str, *, try_filepath: bool = False) -> ModuleType: 65 | try: 66 | return __import__(module_name, None, None, [obj_name]) 67 | except ImportError as error: 68 | if try_filepath: 69 | return self._import_template_module(module_name) 70 | raise UserMessageError(f"Could not import extension '{obj_name}' from '{module_name}'") from error 71 | 72 | def _import_template_module(self, relative_path: str | Path) -> ModuleType: 73 | module_name = Path(relative_path).stem 74 | for search_path in self.environment.loader.searchpath: # type: ignore[union-attr] 75 | template_relative_path = Path(search_path) / relative_path 76 | if template_relative_path.exists(): 77 | break 78 | else: 79 | raise UserMessageError( 80 | f"Could not resolve path to local extension module '{relative_path}'\n" 81 | "Please report this issue to the template maintainers.", 82 | ) 83 | spec = spec_from_file_location( 84 | module_full_name := f"copier_templates_extensions.{module_name}", 85 | template_relative_path, 86 | ) 87 | module = module_from_spec(spec) # type: ignore[arg-type] 88 | sys.modules[module_full_name] = module 89 | spec.loader.exec_module(module) # type: ignore[union-attr] 90 | return module 91 | -------------------------------------------------------------------------------- /src/copier_templates_extensions/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier-templates-extensions/e3fe3793fe44f41e198ce90e5a63723bf9571aa8/src/copier_templates_extensions/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests suite for `copier_templates_extensions`.""" 2 | 3 | from pathlib import Path 4 | 5 | TESTS_DIR = Path(__file__).parent 6 | TMP_DIR = TESTS_DIR / "tmp" 7 | FIXTURES_DIR = TESTS_DIR / "fixtures" 8 | -------------------------------------------------------------------------------- /tests/fixtures/answer_access/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ContextUpdater 4 | -------------------------------------------------------------------------------- /tests/fixtures/answer_access/extensions.py: -------------------------------------------------------------------------------- 1 | from copier_templates_extensions import ContextHook 2 | 3 | 4 | class ContextUpdater(ContextHook): 5 | def hook(self, context): 6 | context["success"] = context["foo"] == "bar" 7 | -------------------------------------------------------------------------------- /tests/fixtures/answer_access/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success }} -------------------------------------------------------------------------------- /tests/fixtures/cant_find_extension_class/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ImNotHere -------------------------------------------------------------------------------- /tests/fixtures/cant_find_extension_class/extensions.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier-templates-extensions/e3fe3793fe44f41e198ce90e5a63723bf9571aa8/tests/fixtures/cant_find_extension_class/extensions.py -------------------------------------------------------------------------------- /tests/fixtures/cant_find_extension_file/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - im_not_here.py:Success -------------------------------------------------------------------------------- /tests/fixtures/cant_find_extension_module/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - im.not.here -------------------------------------------------------------------------------- /tests/fixtures/cant_find_extension_package/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - not_here -------------------------------------------------------------------------------- /tests/fixtures/dataclass/copier.yml: -------------------------------------------------------------------------------- 1 | _subdirectory: template 2 | _jinja_extensions: 3 | - copier_templates_extensions.TemplateExtensionLoader 4 | - extensions/ctx.py:Success 5 | -------------------------------------------------------------------------------- /tests/fixtures/dataclass/extensions/ctx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | from jinja2.ext import Extension 4 | 5 | @dataclass 6 | class Test: 7 | key: str 8 | value: str 9 | 10 | class Success(Extension): 11 | def __init__(self, environment): 12 | super().__init__(environment) 13 | environment.globals.update(success=True) 14 | -------------------------------------------------------------------------------- /tests/fixtures/dataclass/template/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/deprecation_warning_hook_return/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ContextUpdater 4 | -------------------------------------------------------------------------------- /tests/fixtures/deprecation_warning_hook_return/extensions.py: -------------------------------------------------------------------------------- 1 | from copier_templates_extensions import ContextHook 2 | 3 | 4 | class ContextUpdater(ContextHook): 5 | def hook(self, context): 6 | return {"success": True} 7 | -------------------------------------------------------------------------------- /tests/fixtures/deprecation_warning_hook_return/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/deprecation_warning_update_attr/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ContextUpdater -------------------------------------------------------------------------------- /tests/fixtures/deprecation_warning_update_attr/extensions.py: -------------------------------------------------------------------------------- 1 | from copier_templates_extensions import ContextHook 2 | 3 | 4 | class ContextUpdater(ContextHook): 5 | update = "not sentinel" 6 | 7 | def hook(self, context): 8 | context["success"] = True 9 | -------------------------------------------------------------------------------- /tests/fixtures/deprecation_warning_update_attr/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/loading_normal_extension/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - copier_templates_extensions.TemplateExtensionLoader -------------------------------------------------------------------------------- /tests/fixtures/loading_normal_extension/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/modifying_context/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ContextUpdater -------------------------------------------------------------------------------- /tests/fixtures/modifying_context/extensions.py: -------------------------------------------------------------------------------- 1 | from copier_templates_extensions import ContextHook 2 | 3 | 4 | class ContextUpdater(ContextHook): 5 | def hook(self, context): 6 | context["success"] = True 7 | -------------------------------------------------------------------------------- /tests/fixtures/modifying_context/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/not_updating_context_for_prompts/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ContextUpdater 4 | 5 | name: 6 | type: str 7 | default: "pawamoy" 8 | -------------------------------------------------------------------------------- /tests/fixtures/not_updating_context_for_prompts/extensions.py: -------------------------------------------------------------------------------- 1 | from copier_templates_extensions import ContextHook 2 | 3 | 4 | class ContextUpdater(ContextHook): 5 | def hook(self, context): 6 | context["success"] = True 7 | -------------------------------------------------------------------------------- /tests/fixtures/not_updating_context_for_prompts/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/not_using_loader/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/raising_exception_while_loading/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:HellRaiser -------------------------------------------------------------------------------- /tests/fixtures/raising_exception_while_loading/extensions.py: -------------------------------------------------------------------------------- 1 | from jinja2.ext import Extension 2 | 3 | 4 | class HellRaiser(Extension): 5 | def __init__(self, environment): 6 | super().__init__(environment) 7 | raise RuntimeError("Hell raising!") 8 | -------------------------------------------------------------------------------- /tests/fixtures/updating_context/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:ContextUpdater -------------------------------------------------------------------------------- /tests/fixtures/updating_context/extensions.py: -------------------------------------------------------------------------------- 1 | from copier_templates_extensions import ContextHook 2 | 3 | 4 | class ContextUpdater(ContextHook): 5 | def hook(self, context): 6 | context["success"] = True 7 | -------------------------------------------------------------------------------- /tests/fixtures/updating_context/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/fixtures/using_loader/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - copier_templates_extensions.TemplateExtensionLoader 3 | - extensions.py:Success -------------------------------------------------------------------------------- /tests/fixtures/using_loader/extensions.py: -------------------------------------------------------------------------------- 1 | from jinja2.ext import Extension 2 | 3 | 4 | class Success(Extension): 5 | def __init__(self, environment): 6 | super().__init__(environment) 7 | environment.globals.update(success=True) 8 | -------------------------------------------------------------------------------- /tests/fixtures/using_loader/result.txt.jinja: -------------------------------------------------------------------------------- 1 | Success variable: {{ success|default("not set") }} -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for our own API exposition.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import defaultdict 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | import griffe 10 | import pytest 11 | from mkdocstrings import Inventory 12 | 13 | import copier_templates_extensions 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | 19 | @pytest.fixture(name="loader", scope="module") 20 | def _fixture_loader() -> griffe.GriffeLoader: 21 | loader = griffe.GriffeLoader() 22 | loader.load("copier_templates_extensions") 23 | loader.resolve_aliases() 24 | return loader 25 | 26 | 27 | @pytest.fixture(name="internal_api", scope="module") 28 | def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: 29 | return loader.modules_collection["copier_templates_extensions._internal"] 30 | 31 | 32 | @pytest.fixture(name="public_api", scope="module") 33 | def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: 34 | return loader.modules_collection["copier_templates_extensions"] 35 | 36 | 37 | def _yield_public_objects( 38 | obj: griffe.Module | griffe.Class, 39 | *, 40 | modules: bool = False, 41 | modulelevel: bool = True, 42 | inherited: bool = False, 43 | special: bool = False, 44 | ) -> Iterator[griffe.Object | griffe.Alias]: 45 | for member in obj.all_members.values() if inherited else obj.members.values(): 46 | try: 47 | if member.is_module: 48 | if member.is_alias or not member.is_public: 49 | continue 50 | if modules: 51 | yield member 52 | yield from _yield_public_objects( 53 | member, # type: ignore[arg-type] 54 | modules=modules, 55 | modulelevel=modulelevel, 56 | inherited=inherited, 57 | special=special, 58 | ) 59 | elif member.is_public and (special or not member.is_special): 60 | yield member 61 | else: 62 | continue 63 | if member.is_class and not modulelevel: 64 | yield from _yield_public_objects( 65 | member, # type: ignore[arg-type] 66 | modules=modules, 67 | modulelevel=False, 68 | inherited=inherited, 69 | special=special, 70 | ) 71 | except (griffe.AliasResolutionError, griffe.CyclicAliasError): 72 | continue 73 | 74 | 75 | @pytest.fixture(name="modulelevel_internal_objects", scope="module") 76 | def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 77 | return list(_yield_public_objects(internal_api, modulelevel=True)) 78 | 79 | 80 | @pytest.fixture(name="internal_objects", scope="module") 81 | def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 82 | return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) 83 | 84 | 85 | @pytest.fixture(name="public_objects", scope="module") 86 | def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 87 | return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) 88 | 89 | 90 | @pytest.fixture(name="inventory", scope="module") 91 | def _fixture_inventory() -> Inventory: 92 | inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" 93 | if not inventory_file.exists(): 94 | raise pytest.skip("The objects inventory is not available.") 95 | with inventory_file.open("rb") as file: 96 | return Inventory.parse_sphinx(file) 97 | 98 | 99 | def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 100 | """All public objects in the internal API are exposed under `copier_templates_extensions`.""" 101 | not_exposed = [ 102 | obj.path 103 | for obj in modulelevel_internal_objects 104 | if obj.name not in copier_templates_extensions.__all__ or not hasattr(copier_templates_extensions, obj.name) 105 | ] 106 | assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) 107 | 108 | 109 | def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 110 | """All internal objects have unique names.""" 111 | names_to_paths = defaultdict(list) 112 | for obj in modulelevel_internal_objects: 113 | names_to_paths[obj.name].append(obj.path) 114 | non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] 115 | assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 116 | 117 | 118 | def test_single_locations(public_api: griffe.Module) -> None: 119 | """All objects have a single public location.""" 120 | 121 | def _public_path(obj: griffe.Object | griffe.Alias) -> bool: 122 | return obj.is_public and (obj.parent is None or _public_path(obj.parent)) 123 | 124 | multiple_locations = {} 125 | for obj_name in copier_templates_extensions.__all__: 126 | obj = public_api[obj_name] 127 | if obj.aliases and ( 128 | public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] 129 | ): 130 | multiple_locations[obj.path] = public_aliases 131 | assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 132 | f"{path}: {aliases}" for path, aliases in multiple_locations.items() 133 | ) 134 | 135 | 136 | def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: 137 | """All public objects are added to the inventory.""" 138 | ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} 139 | not_in_inventory = [ 140 | obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory 141 | ] 142 | msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}" 143 | assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) 144 | 145 | 146 | def test_inventory_matches_api( 147 | inventory: Inventory, 148 | public_objects: list[griffe.Object | griffe.Alias], 149 | loader: griffe.GriffeLoader, 150 | ) -> None: 151 | """The inventory doesn't contain any additional Python object.""" 152 | not_in_api = [] 153 | public_api_paths = {obj.path for obj in public_objects} 154 | public_api_paths.add("copier_templates_extensions") 155 | for item in inventory.values(): 156 | if item.domain == "py" and "(" not in item.name: 157 | obj = loader.modules_collection[item.name] 158 | if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 159 | not_in_api.append(item.name) 160 | msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}" 161 | assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) 162 | 163 | 164 | def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: 165 | """No module docstrings should be written in our internal API. 166 | 167 | The reasoning is that docstrings are addressed to users of the public API, 168 | but internal modules are not exposed to users, so they should not have docstrings. 169 | """ 170 | 171 | def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 172 | for member in obj.modules.values(): 173 | yield member 174 | yield from _modules(member) 175 | 176 | for obj in _modules(internal_api): 177 | assert not obj.docstring 178 | -------------------------------------------------------------------------------- /tests/test_extensions.py: -------------------------------------------------------------------------------- 1 | """Tests for the `extensions` module.""" 2 | 3 | from pathlib import Path 4 | 5 | import copier 6 | import pytest 7 | from copier.errors import UserMessageError 8 | 9 | TEMPLATES_DIRECTORY = Path(__file__).parent / "fixtures" 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ("template_name", "expected"), 14 | [ 15 | ("not_using_loader", "not set"), 16 | ("using_loader", "True"), 17 | ("updating_context", "True"), 18 | ("modifying_context", "True"), 19 | ("not_updating_context_for_prompts", "True"), 20 | ("loading_normal_extension", "not set"), 21 | ("dataclass", "True"), 22 | ], 23 | ) 24 | def test_extensions(tmp_path: Path, template_name: str, expected: str) -> None: 25 | """Test loader and context extensions. 26 | 27 | Arguments: 28 | tmp_path: A pytest fixture. 29 | template_name: The parametrized template to use. 30 | expected: The parametrized value we expect. 31 | """ 32 | template_path = TEMPLATES_DIRECTORY / template_name 33 | copier.run_copy(str(template_path), tmp_path, defaults=True, overwrite=True, unsafe=True) 34 | result_file = tmp_path / "result.txt" 35 | assert result_file.exists() 36 | assert result_file.read_text() == f"Success variable: {expected}" 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ("template_name", "exception"), 41 | [ 42 | ("raising_exception_while_loading", RuntimeError), 43 | ("cant_find_extension_file", UserMessageError), 44 | ("cant_find_extension_class", UserMessageError), 45 | ("cant_find_extension_module", UserMessageError), 46 | ("cant_find_extension_package", UserMessageError), 47 | ], 48 | ) 49 | def test_extensions_raising_exceptions(tmp_path: Path, template_name: str, exception: type) -> None: 50 | """See what happens when an extension raises an exception. 51 | 52 | Arguments: 53 | tmp_path: A pytest fixture. 54 | template_name: The parametrized template to use. 55 | exception: The exception expected to be raised. 56 | """ 57 | template_path = TEMPLATES_DIRECTORY / template_name 58 | with pytest.raises(exception): 59 | copier.run_copy(str(template_path), tmp_path, defaults=True, overwrite=True, unsafe=True) 60 | assert not (tmp_path / "result.txt").exists() 61 | assert not (tmp_path / "extensions.py").exists() 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "template_name", 66 | ["deprecation_warning_hook_return", "deprecation_warning_update_attr"], 67 | ) 68 | def test_deprecated_usage(tmp_path: Path, template_name: str) -> None: 69 | """Test deprecation warnings. 70 | 71 | Arguments: 72 | tmp_path: A pytest fixture. 73 | template_name: The parametrized template to use. 74 | """ 75 | template_path = TEMPLATES_DIRECTORY / template_name 76 | with pytest.warns(DeprecationWarning): 77 | copier.run_copy(str(template_path), tmp_path, defaults=True, overwrite=True, unsafe=True) 78 | result_file = tmp_path / "result.txt" 79 | assert result_file.exists() 80 | assert result_file.read_text() == "Success variable: True" 81 | 82 | 83 | def test_answer_access(tmp_path: Path) -> None: 84 | """Test accessing an answer. 85 | 86 | Arguments: 87 | tmp_path: A pytest fixture. 88 | """ 89 | template_path = TEMPLATES_DIRECTORY / "answer_access" 90 | copier.run_copy(str(template_path), tmp_path, data={"foo": "bar"}, defaults=True, overwrite=True, unsafe=True) 91 | result_file = tmp_path / "result.txt" 92 | assert result_file.exists() 93 | assert result_file.read_text() == "Success variable: True" 94 | --------------------------------------------------------------------------------