[(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 | [](https://github.com/copier-org/copier-templates-extensions/actions?query=workflow%3Aci)
4 | [](https://copier-org.github.io/copier-templates-extensions/)
5 | [](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 |
4 |
5 |
21 |
22 |
23 |
57 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------