├── tests ├── __init__.py ├── demo │ ├── doc │ │ ├── manana.txt │ │ ├── images │ │ │ └── nslogo.gif │ │ ├── mañana.txt │ │ └── mañana.txt │ ├── symlink.txt │ ├── aaaa.txt │ ├── [[ myvar ]] │ │ └── hello.txt │ ├── [% if py3 %]py3_only.py[% endif %] │ ├── [[ myvar ]].txt │ ├── [% if not py3 %]py2_only.py[% endif %] │ ├── .svn │ ├── README.txt │ ├── README.txt.tmpl │ ├── [% if py3 %]py3_folder[% endif %] │ │ └── thing.py.tmpl │ ├── [% if not py3 %]py2_folder[% endif %] │ │ └── thing.py.tmpl │ ├── .gitignore │ ├── config.py.tmpl │ ├── copier.yaml │ └── pyproject.toml.tmpl ├── demo_config_empty │ └── copier.yml ├── demo_exclude_negate │ ├── copy_me.txt │ ├── do_not_copy_me.txt │ └── copier.yml ├── demo_invalid │ └── copier.yml ├── demo_multi_config │ ├── copier.yaml │ └── copier.yml ├── demo_skip_dst │ ├── a.noeof.txt │ ├── b.noeof.txt │ └── meh │ │ └── c.noeof.txt ├── demo_skip_src │ ├── a.noeof.txt │ ├── b.noeof.txt │ ├── meh │ │ └── c.noeof.txt │ └── copier.yml ├── demo_cleanup │ ├── test.txt │ └── copier.yml ├── demo_exclude │ ├── copier.yml │ └── bad │ │ └── subdir │ │ └── file.txt ├── demo_transclude │ ├── demo │ │ └── copier.yml │ └── include_me.yml ├── demo_transclude_invalid │ ├── include_me.yml │ └── demo │ │ └── copier.yml ├── demo_extensions_default │ └── super_file.md.jinja ├── demo_legacy_migrations │ ├── delete-in-tasks.txt │ ├── delete-in-migration-v2.txt │ ├── [[ _copier_conf.answers_file ]].tmpl │ ├── tasks.py │ ├── migrations.py │ └── copier.yaml ├── demo_transclude_invalid_multi │ ├── include_me.yml │ ├── include_me_too.yml │ └── demo │ │ └── copier.yml ├── demo_extensions_additional │ ├── super_file.md.jinja │ └── copier.yml ├── demo_transclude_nested_include │ └── demo │ │ ├── include_me_also.yml │ │ ├── copier.yml │ │ └── include_me.yml ├── demo_updatediff_repo.bundle ├── demo_merge_options_from_answerfiles │ ├── include1.yml │ ├── include2.yml │ └── copier.yml ├── demo_data │ └── copier.yml ├── reference_files │ └── pyproject.toml ├── test_cleanup.py ├── test_tools.py ├── test_empty_suffix.py ├── test_demo_update_tasks.py ├── test_jinja2_extensions.py ├── test_interrupts.py ├── test_normal_jinja2.py ├── test_minimum_version.py ├── test_tmpdir.py ├── test_conditional_file_name.py ├── conftest.py ├── test_answersfile_templating.py ├── test_settings.py ├── test_imports.py ├── test_tasks.py ├── helpers.py ├── test_recopy.py ├── test_context.py └── test_dynamic_file_structures.py ├── copier ├── py.typed ├── __main__.py ├── cli.py ├── vcs.py ├── tools.py ├── types.py ├── template.py ├── jinja_ext.py ├── user_data.py ├── subproject.py ├── main.py ├── __init__.py ├── _deprecation.py ├── settings.py ├── _subproject.py ├── _types.py ├── _jinja_ext.py ├── errors.py └── _vcs.py ├── .python-version ├── docs ├── index.md ├── changelog.md ├── contributing.md ├── reference │ ├── cli.md │ ├── main.md │ ├── vcs.md │ ├── errors.md │ ├── tools.md │ ├── types.md │ ├── jinja_ext.md │ ├── settings.md │ ├── template.md │ ├── user_data.md │ └── subproject.md ├── css │ └── mkdocstrings.css ├── settings.md ├── generating.md ├── comparisons.md └── faq.md ├── .prettierrc.yml ├── img ├── copier-output.png ├── copier-logotype.png ├── logo.svg └── badge │ ├── badge-grayscale-border.json │ ├── badge-grayscale-inverted-border.json │ ├── badge-grayscale-inverted-border-red.json │ ├── badge-grayscale-inverted-border-teal.json │ ├── badge-grayscale-inverted-border-orange.json │ ├── badge-grayscale-inverted-border-purple.json │ ├── badge-black.json │ └── black-badge.json ├── .vscode ├── extensions.json ├── launch.json ├── tasks.json └── settings.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── problem.yml └── workflows │ ├── autofix.yml │ ├── release.yml │ └── ci.yml ├── .envrc ├── .editorconfig ├── .readthedocs.yml ├── .gitpod.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── devbox.json ├── .gitignore ├── LICENSE ├── devtasks.py ├── renovate.json ├── .pre-commit-config.yaml ├── mkdocs.yml └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /copier/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /tests/demo/doc/manana.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /tests/demo/symlink.txt: -------------------------------------------------------------------------------- 1 | aaaa.txt -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /tests/demo/aaaa.txt: -------------------------------------------------------------------------------- 1 | lorem ipsum 2 | -------------------------------------------------------------------------------- /tests/demo_config_empty/copier.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demo_exclude_negate/copy_me.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demo_invalid/copier.yml: -------------------------------------------------------------------------------- 1 | %343 2 | -------------------------------------------------------------------------------- /tests/demo_multi_config/copier.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demo_multi_config/copier.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demo_skip_dst/a.noeof.txt: -------------------------------------------------------------------------------- 1 | SKIPPED -------------------------------------------------------------------------------- /tests/demo_skip_dst/b.noeof.txt: -------------------------------------------------------------------------------- 1 | SKIPPED -------------------------------------------------------------------------------- /docs/reference/cli.md: -------------------------------------------------------------------------------- 1 | ::: copier._cli 2 | -------------------------------------------------------------------------------- /docs/reference/main.md: -------------------------------------------------------------------------------- 1 | ::: copier._main 2 | -------------------------------------------------------------------------------- /docs/reference/vcs.md: -------------------------------------------------------------------------------- 1 | ::: copier._vcs 2 | -------------------------------------------------------------------------------- /tests/demo/[[ myvar ]]/hello.txt: -------------------------------------------------------------------------------- 1 | world 2 | -------------------------------------------------------------------------------- /tests/demo_skip_dst/meh/c.noeof.txt: -------------------------------------------------------------------------------- 1 | SKIPPED -------------------------------------------------------------------------------- /tests/demo_skip_src/a.noeof.txt: -------------------------------------------------------------------------------- 1 | OVERWRITTEN -------------------------------------------------------------------------------- /tests/demo_skip_src/b.noeof.txt: -------------------------------------------------------------------------------- 1 | OVERWRITTEN -------------------------------------------------------------------------------- /docs/reference/errors.md: -------------------------------------------------------------------------------- 1 | ::: copier.errors 2 | -------------------------------------------------------------------------------- /docs/reference/tools.md: -------------------------------------------------------------------------------- 1 | ::: copier._tools 2 | -------------------------------------------------------------------------------- /docs/reference/types.md: -------------------------------------------------------------------------------- 1 | ::: copier._types 2 | -------------------------------------------------------------------------------- /tests/demo/[% if py3 %]py3_only.py[% endif %]: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demo/[[ myvar ]].txt: -------------------------------------------------------------------------------- 1 | This is a triumph 2 | -------------------------------------------------------------------------------- /tests/demo_cleanup/test.txt: -------------------------------------------------------------------------------- 1 | RANDOM_CONTENT 2 | -------------------------------------------------------------------------------- /tests/demo_exclude_negate/do_not_copy_me.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/reference/jinja_ext.md: -------------------------------------------------------------------------------- 1 | ::: copier._jinja_ext 2 | -------------------------------------------------------------------------------- /docs/reference/settings.md: -------------------------------------------------------------------------------- 1 | ::: copier.settings 2 | -------------------------------------------------------------------------------- /docs/reference/template.md: -------------------------------------------------------------------------------- 1 | ::: copier._template 2 | -------------------------------------------------------------------------------- /docs/reference/user_data.md: -------------------------------------------------------------------------------- 1 | ::: copier._user_data 2 | -------------------------------------------------------------------------------- /tests/demo/[% if not py3 %]py2_only.py[% endif %]: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demo_skip_src/meh/c.noeof.txt: -------------------------------------------------------------------------------- 1 | OVERWRITTEN 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 88 2 | proseWrap: always 3 | -------------------------------------------------------------------------------- /docs/reference/subproject.md: -------------------------------------------------------------------------------- 1 | ::: copier._subproject 2 | -------------------------------------------------------------------------------- /tests/demo/.svn: -------------------------------------------------------------------------------- 1 | This file should be normally ignored 2 | -------------------------------------------------------------------------------- /tests/demo/README.txt: -------------------------------------------------------------------------------- 1 | This is the template README. 2 | -------------------------------------------------------------------------------- /tests/demo_cleanup/copier.yml: -------------------------------------------------------------------------------- 1 | _tasks: 2 | - exit 1 3 | -------------------------------------------------------------------------------- /tests/demo_exclude/copier.yml: -------------------------------------------------------------------------------- 1 | _exclude: 2 | - bad 3 | -------------------------------------------------------------------------------- /tests/demo_transclude/demo/copier.yml: -------------------------------------------------------------------------------- 1 | !include ../include_me.yml 2 | -------------------------------------------------------------------------------- /tests/demo/README.txt.tmpl: -------------------------------------------------------------------------------- 1 | This is the README for [[project_name]]. 2 | -------------------------------------------------------------------------------- /tests/demo_skip_src/copier.yml: -------------------------------------------------------------------------------- 1 | _skip_if_exists: 2 | - "a.noeof.txt" 3 | -------------------------------------------------------------------------------- /tests/demo_exclude/bad/subdir/file.txt: -------------------------------------------------------------------------------- 1 | This file must not exist in copy. 2 | -------------------------------------------------------------------------------- /tests/demo_transclude_invalid/include_me.yml: -------------------------------------------------------------------------------- 1 | key_one: "i_am_from_include_me" 2 | -------------------------------------------------------------------------------- /tests/demo/[% if py3 %]py3_folder[% endif %]/thing.py.tmpl: -------------------------------------------------------------------------------- 1 | py3_enabled = [[ py3 ]] 2 | -------------------------------------------------------------------------------- /tests/demo_extensions_default/super_file.md.jinja: -------------------------------------------------------------------------------- 1 | {{ "/absolute/path"|basename }} 2 | -------------------------------------------------------------------------------- /tests/demo_legacy_migrations/delete-in-tasks.txt: -------------------------------------------------------------------------------- 1 | This file will be deleted in tasks. 2 | -------------------------------------------------------------------------------- /tests/demo_transclude/include_me.yml: -------------------------------------------------------------------------------- 1 | _exclude: 2 | - "exclude1" 3 | - "exclude2" 4 | -------------------------------------------------------------------------------- /tests/demo_transclude_invalid_multi/include_me.yml: -------------------------------------------------------------------------------- 1 | key_one: "i_am_from_include_me" 2 | -------------------------------------------------------------------------------- /tests/demo/[% if not py3 %]py2_folder[% endif %]/thing.py.tmpl: -------------------------------------------------------------------------------- 1 | py3_enabled = [[ py3 ]] 2 | -------------------------------------------------------------------------------- /tests/demo_exclude_negate/copier.yml: -------------------------------------------------------------------------------- 1 | _exclude: 2 | - "*.txt" 3 | - "!copy_me.txt" 4 | -------------------------------------------------------------------------------- /img/copier-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier/HEAD/img/copier-output.png -------------------------------------------------------------------------------- /tests/demo_extensions_additional/super_file.md.jinja: -------------------------------------------------------------------------------- 1 | {{ super_func(super_var)|super_filter }} 2 | -------------------------------------------------------------------------------- /tests/demo_transclude_invalid_multi/include_me_too.yml: -------------------------------------------------------------------------------- 1 | key_one: "i_am_from_include_me_too" 2 | -------------------------------------------------------------------------------- /img/copier-logotype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier/HEAD/img/copier-logotype.png -------------------------------------------------------------------------------- /tests/demo/.gitignore: -------------------------------------------------------------------------------- 1 | ~* 2 | .DS_Store 3 | *.pyc 4 | build 5 | dist 6 | *.egg* 7 | .coverage 8 | .tox 9 | -------------------------------------------------------------------------------- /tests/demo_legacy_migrations/delete-in-migration-v2.txt: -------------------------------------------------------------------------------- 1 | This file will be deleted after migrating to v2. 2 | -------------------------------------------------------------------------------- /tests/demo_transclude_nested_include/demo/include_me_also.yml: -------------------------------------------------------------------------------- 1 | _exclude: 2 | - "exclude1" 3 | - "exclude2" 4 | -------------------------------------------------------------------------------- /tests/demo/doc/images/nslogo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier/HEAD/tests/demo/doc/images/nslogo.gif -------------------------------------------------------------------------------- /tests/demo_transclude_invalid/demo/copier.yml: -------------------------------------------------------------------------------- 1 | !include "../include_me.yml" 2 | 3 | key_one: "i_am_from_copier.yml" 4 | -------------------------------------------------------------------------------- /tests/demo_transclude_invalid_multi/demo/copier.yml: -------------------------------------------------------------------------------- 1 | !include "../include_me.yml" 2 | !include "../include_me_too.yml" 3 | -------------------------------------------------------------------------------- /tests/demo_updatediff_repo.bundle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/copier-org/copier/HEAD/tests/demo_updatediff_repo.bundle -------------------------------------------------------------------------------- /copier/__main__.py: -------------------------------------------------------------------------------- 1 | """Copier CLI entrypoint.""" 2 | 3 | from ._cli import CopierApp 4 | 5 | if __name__ == "__main__": 6 | CopierApp.run() 7 | -------------------------------------------------------------------------------- /tests/demo_legacy_migrations/[[ _copier_conf.answers_file ]].tmpl: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | [[ _copier_answers|to_nice_yaml ]] 3 | -------------------------------------------------------------------------------- /tests/demo/config.py.tmpl: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | DEBUG = True 5 | RELOAD = True 6 | 7 | SECRET_KEY = os.getenv('SECRET_KEY', '[[ make_secret() ]]') 8 | -------------------------------------------------------------------------------- /tests/demo_extensions_additional/copier.yml: -------------------------------------------------------------------------------- 1 | _jinja_extensions: 2 | - tests.test_jinja2_extensions.FilterExtension 3 | - tests.test_jinja2_extensions.GlobalsExtension 4 | -------------------------------------------------------------------------------- /tests/demo_transclude_nested_include/demo/copier.yml: -------------------------------------------------------------------------------- 1 | --- 2 | !include ./include_me.yml 3 | --- 4 | 5 | project_name: 6 | type: str 7 | help: What is the project name 8 | -------------------------------------------------------------------------------- /tests/demo_merge_options_from_answerfiles/include1.yml: -------------------------------------------------------------------------------- 1 | _skip_if_exists: 2 | - skip_if_exists1 3 | 4 | _secret_questions: 5 | - question1 6 | 7 | _exclude: 8 | - exclude1 9 | -------------------------------------------------------------------------------- /tests/demo_transclude_nested_include/demo/include_me.yml: -------------------------------------------------------------------------------- 1 | --- 2 | !include ./include_me_also.yml 3 | --- 4 | 5 | project_url: 6 | type: str 7 | help: What is the project URL? 8 | -------------------------------------------------------------------------------- /tests/demo_merge_options_from_answerfiles/include2.yml: -------------------------------------------------------------------------------- 1 | _skip_if_exists: 2 | - skip_if_exists2 3 | 4 | _jinja_extensions: 5 | - jinja2.ext.2 6 | 7 | _exclude: 8 | - exclude21 9 | - exclude22 10 | -------------------------------------------------------------------------------- /tests/demo_data/copier.yml: -------------------------------------------------------------------------------- 1 | _exclude: 2 | - "exclude1" 3 | - "exclude2" 4 | 5 | _skip_if_exists: 6 | - "skip_if_exists1" 7 | - "skip_if_exists2" 8 | 9 | _tasks: 10 | - "touch 1" 11 | - "touch 2" 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bpruitt-goddard.mermaid-markdown-syntax-highlighting", 4 | "editorconfig.editorconfig", 5 | "elagil.pre-commit-helper", 6 | "ms-python.python" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tests/demo_merge_options_from_answerfiles/copier.yml: -------------------------------------------------------------------------------- 1 | _skip_if_exists: 2 | - skip_if_exists0 3 | 4 | _jinja_extensions: 5 | - jinja2.ext.0 6 | 7 | --- 8 | !include ./include1.yml 9 | --- 10 | !include ./include2.yml 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question, idea or general talk 4 | url: https://github.com/copier-org/copier/discussions 5 | about: Please share your thoughts or ask and answer questions here. 6 | -------------------------------------------------------------------------------- /tests/demo/doc/mañana.txt: -------------------------------------------------------------------------------- 1 | UTF-8(ユーティーエフはち、ユーティーエフエイト)はISO/IEC 10646(UCS)とUnicodeで使える8ビット符号単位の文字符号化形式及び文字符号化スキーム。 2 | 正式名称は、ISO/IEC 10646では‘UCS Transformation Format 8’、Unicodeでは‘Unicode Transformation Format-8’という。両者はISO/IEC 10646とUnicodeのコード重複範囲で互換性がある。RFCにも仕様がある[1]。 3 | -------------------------------------------------------------------------------- /tests/demo/doc/mañana.txt: -------------------------------------------------------------------------------- 1 | UTF-8(ユーティーエフはち、ユーティーエフエイト)はISO/IEC 10646(UCS)とUnicodeで使える8ビット符号単位の文字符号化形式及び文字符号化スキーム。 2 | 正式名称は、ISO/IEC 10646では‘UCS Transformation Format 8’、Unicodeでは‘Unicode Transformation Format-8’という。両者はISO/IEC 10646とUnicodeのコード重複範囲で互換性がある。RFCにも仕様がある[1]。 3 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Automatically sets up your devbox environment whenever you cd into this 4 | # directory via our direnv integration: 5 | 6 | eval "$(devbox generate direnv --print-envrc)" 7 | 8 | # check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ 9 | # for more details 10 | -------------------------------------------------------------------------------- /tests/demo/copier.yaml: -------------------------------------------------------------------------------- 1 | _templates_suffix: .tmpl 2 | _envops: 3 | autoescape: false 4 | block_end_string: "%]" 5 | block_start_string: "[%" 6 | comment_end_string: "#]" 7 | comment_start_string: "[#" 8 | keep_trailing_newline: true 9 | variable_end_string: "]]" 10 | variable_start_string: "[[" 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: debug tests", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "internalConsole", 10 | "purpose": ["debug-test"], 11 | "justMyCode": false 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | # For isort 13 | profile = black 14 | 15 | [*.{code-snippets,code-workspace,json,lock,nix,toml,tf,yaml,yml}{,.jinja}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "local docs server", 8 | "type": "process", 9 | "command": "uv", 10 | "args": ["run", "poe", "docs"], 11 | "problemMatcher": [] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tests/demo_legacy_migrations/tasks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from contextlib import suppress 5 | from pathlib import Path 6 | 7 | with Path("created-with-tasks.txt").open("a", newline="\n") as cwt: 8 | cwt.write(" ".join([os.environ["STAGE"]] + sys.argv[1:]) + "\n") 9 | with suppress(FileNotFoundError): 10 | Path("delete-in-tasks.txt").unlink() 11 | -------------------------------------------------------------------------------- /tests/demo/pyproject.toml.tmpl: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry>=0.12"] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "[[ project_name ]]" 7 | version = "[[ version ]]" 8 | description = "[[ description ]]" 9 | license = "MIT" 10 | 11 | readme = "README.md" 12 | homepage = "https://github.com/jpscaletti/copier" 13 | 14 | # Requirements 15 | [tool.poetry.dependencies] 16 | python = "^3.6" 17 | -------------------------------------------------------------------------------- /tests/reference_files/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry>=0.12"] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "Copier" 7 | version = "2.0.0" 8 | description = "A library for rendering projects templates" 9 | license = "MIT" 10 | 11 | readme = "README.md" 12 | homepage = "https://github.com/jpscaletti/copier" 13 | 14 | # Requirements 15 | [tool.poetry.dependencies] 16 | python = "^3.6" 17 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | version: 2 4 | mkdocs: 5 | configuration: mkdocs.yml 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.12" 10 | jobs: 11 | post_install: 12 | - pip install uv 13 | - | 14 | VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH \ 15 | uv sync --active --frozen --only-group docs 16 | -------------------------------------------------------------------------------- /tests/demo_legacy_migrations/migrations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | NAMES = ( 8 | "{VERSION_FROM}-{VERSION_CURRENT}-{VERSION_TO}-{STAGE}.json", 9 | "PEP440-{VERSION_PEP440_FROM}-{VERSION_PEP440_CURRENT}-{VERSION_PEP440_TO}-{STAGE}.json", 10 | ) 11 | 12 | for name in NAMES: 13 | with Path(name.format(**os.environ)).open("w") as fd: 14 | json.dump(sys.argv, fd) 15 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | github: 2 | prebuilds: 3 | addBadge: true 4 | addCheck: prevent-merge-on-error 5 | master: true 6 | pullRequests: true 7 | 8 | vscode: 9 | extensions: 10 | - bpruitt-goddard.mermaid-markdown-syntax-highlighting 11 | - editorconfig.editorconfig 12 | - esbenp.prettier-vscode 13 | - ms-python.python 14 | 15 | ports: 16 | # Mkdocs local server; start it with `poe docs` 17 | - port: 8000 18 | onOpen: notify 19 | 20 | tasks: 21 | - init: direnv allow 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "python.testing.pytestEnabled": true, 4 | "markdown.extension.toc.updateOnSave": false, 5 | "[python]": { 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll": "explicit", 8 | "source.organizeImports": "explicit" 9 | }, 10 | "editor.defaultFormatter": "charliermarsh.ruff", 11 | "editor.formatOnSave": true 12 | }, 13 | "[markdown]": { 14 | "editor.formatOnSave": true, 15 | "editor.formatOnSaveMode": "file", 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # renovate: datasource=github-tags depName=devbox packageName=jetify-com/devbox 2 | ARG DEVBOX_VERSION=0.16.0 3 | FROM jetpackio/devbox:${DEVBOX_VERSION} 4 | 5 | WORKDIR /code 6 | 7 | COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} devbox.json devbox.json 8 | COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} devbox.lock devbox.lock 9 | 10 | # Install the Devbox environment 11 | RUN devbox install && nix-store --gc 12 | 13 | # Configure the project's virtual env path to be outside the mounted workspace 14 | # to avoid conflicts with the host env. 15 | ENV UV_PROJECT_ENVIRONMENT=/code/.venv 16 | -------------------------------------------------------------------------------- /copier/cli.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _cli 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._cli import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_cli, name) 24 | -------------------------------------------------------------------------------- /copier/vcs.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _vcs 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._vcs import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_vcs, name) 24 | -------------------------------------------------------------------------------- /copier/tools.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _tools 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._tools import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_tools, name) 24 | -------------------------------------------------------------------------------- /copier/types.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _types 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._types import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_types, name) 24 | -------------------------------------------------------------------------------- /copier/template.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _template 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._template import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_template, name) 24 | -------------------------------------------------------------------------------- /copier/jinja_ext.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _jinja_ext 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._jinja_ext import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_jinja_ext, name) 24 | -------------------------------------------------------------------------------- /copier/user_data.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _user_data 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._user_data import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_user_data, name) 24 | -------------------------------------------------------------------------------- /copier/subproject.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _subproject 8 | from copier._deprecation import ( 9 | deprecate_member_as_internal, 10 | deprecate_module_as_internal, 11 | ) 12 | 13 | if TYPE_CHECKING: 14 | from copier._subproject import * # noqa: F403 15 | 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if not name.startswith("_"): 22 | deprecate_member_as_internal(name, __name__) 23 | return getattr(_subproject, name) 24 | -------------------------------------------------------------------------------- /docs/css/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | div.doc-contents:not(.first) { 2 | padding-left: 25px; 3 | border-left: 4px solid rgba(230, 230, 230); 4 | margin-bottom: 80px; 5 | } 6 | 7 | h5.doc-heading { 8 | text-transform: none !important; 9 | } 10 | 11 | h6.hidden-toc { 12 | margin: 0 !important; 13 | position: relative; 14 | top: -70px; 15 | } 16 | 17 | h6.hidden-toc::before { 18 | margin-top: 0 !important; 19 | padding-top: 0 !important; 20 | } 21 | 22 | h6.hidden-toc a.headerlink { 23 | display: none; 24 | } 25 | 26 | td code { 27 | word-break: normal !important; 28 | } 29 | 30 | td p { 31 | margin-top: 0 !important; 32 | margin-bottom: 0 !important; 33 | } 34 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", 3 | "packages": [ 4 | "difftastic@0.67.0", 5 | "editorconfig-checker@3.6.0", 6 | "git@2.51.2", 7 | "nodePackages.prettier@3.3.3", 8 | "uv@0.9.18" 9 | ], 10 | "env": { 11 | "GIT_EXTERNAL_DIFF": "difft" 12 | }, 13 | "shell": { 14 | "init_hook": ["uv sync --frozen", "uv run pre-commit install"], 15 | "scripts": { 16 | "test": [ 17 | "env GIT_AUTHOR_EMAIL=copier@example.com GIT_AUTHOR_NAME=copier GIT_COMMITTER_EMAIL=copier@example.com GIT_COMMITTER_NAME=copier PYTHONOPTIMIZE= uv run poe test $@" 18 | ] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "dockerfile": "./Dockerfile", 4 | "context": ".." 5 | }, 6 | "remoteUser": "devbox", 7 | "mounts": [ 8 | "type=bind,source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/devbox/.ssh,readonly" 9 | ], 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "bpruitt-goddard.mermaid-markdown-syntax-highlighting", 14 | "editorconfig.editorconfig", 15 | "elagil.pre-commit-helper", 16 | "jetpack-io.devbox", 17 | "ms-python.python" 18 | ], 19 | "settings": { 20 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /copier/main.py: -------------------------------------------------------------------------------- 1 | """Deprecated: module is intended for internal use only.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from copier import _main 8 | from copier._deprecation import ( 9 | deprecate_member, 10 | deprecate_member_as_internal, 11 | deprecate_module_as_internal, 12 | ) 13 | 14 | if TYPE_CHECKING: 15 | from copier._main import * # noqa: F403 16 | 17 | deprecate_module_as_internal(__name__) 18 | 19 | 20 | def __getattr__(name: str) -> Any: 21 | if name in {"run_copy", "run_recopy", "run_update"}: 22 | deprecate_member(name, __name__, f"from copier import {name}") 23 | elif not name.startswith("_"): 24 | deprecate_member_as_internal(name, __name__) 25 | return getattr(_main, name) 26 | -------------------------------------------------------------------------------- /copier/__init__.py: -------------------------------------------------------------------------------- 1 | """Copier. 2 | 3 | Docs: https://copier.readthedocs.io/ 4 | """ 5 | 6 | import importlib.metadata 7 | from typing import TYPE_CHECKING, Any 8 | 9 | from . import _main 10 | from ._deprecation import deprecate_member_as_internal 11 | from ._types import VcsRef as VcsRef 12 | 13 | if TYPE_CHECKING: 14 | from ._main import * # noqa: F403 15 | 16 | try: 17 | __version__ = importlib.metadata.version(__name__) 18 | except importlib.metadata.PackageNotFoundError: 19 | __version__ = "0.0.0" 20 | 21 | 22 | def __getattr__(name: str) -> Any: 23 | if not name.startswith("_") and name not in { 24 | "run_copy", 25 | "run_recopy", 26 | "run_update", 27 | }: 28 | deprecate_member_as_internal(name, __name__) 29 | return getattr(_main, name) 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | 7 | # Distribution / packaging 8 | .Python 9 | build/ 10 | develop-eggs/ 11 | dist/ 12 | downloads/ 13 | eggs/ 14 | .eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # Environments 27 | __pypackages__ 28 | .pdm.toml 29 | poetry.toml 30 | .env 31 | .venv 32 | covreport 33 | pip-wheel-metadata 34 | .tox 35 | 36 | # Documentation 37 | */_build/* 38 | */site/* 39 | /site 40 | .mypy_cache/* 41 | .cache 42 | *.log 43 | 44 | # Editors 45 | .vscode/* 46 | !.vscode/settings.json 47 | !.vscode/tasks.json 48 | !.vscode/launch.json 49 | !.vscode/extensions.json 50 | *.code-workspace 51 | *.sublime-workspace 52 | *.sublime-project 53 | .idea/ 54 | 55 | # Tests artifacts 56 | .coverage* 57 | htmlcov/ 58 | 59 | # macOS 60 | .DS_Store 61 | 62 | # Nix 63 | /.direnv 64 | -------------------------------------------------------------------------------- /tests/demo_legacy_migrations/copier.yaml: -------------------------------------------------------------------------------- 1 | _templates_suffix: .tmpl 2 | _envops: 3 | autoescape: false 4 | block_end_string: "%]" 5 | block_start_string: "[%" 6 | comment_end_string: "#]" 7 | comment_start_string: "[#" 8 | keep_trailing_newline: true 9 | variable_end_string: "]]" 10 | variable_start_string: "[[" 11 | 12 | _exclude: 13 | - tasks.py 14 | - migrations.py 15 | - .git 16 | 17 | _tasks: 18 | - "python [[ _copier_conf.src_path / 'tasks.py' ]] 1" 19 | - [python, "[[ _copier_conf.src_path / 'tasks.py' ]]", "2"] 20 | 21 | _migrations: 22 | # This migration is never executed because it's the 1st version copied, and 23 | # migrations are only executed when updating 24 | - version: v1.0.0 25 | before: 26 | - &mig 27 | - "[[ _copier_conf.src_path / 'migrations.py' ]]" 28 | - "[[ _copier_conf|to_json ]]" 29 | after: 30 | - *mig 31 | - version: v2 32 | before: [*mig] 33 | after: 34 | - *mig 35 | - "rm delete-in-migration-$VERSION_CURRENT.txt" 36 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["master"] 7 | permissions: 8 | contents: read 9 | 10 | env: 11 | # renovate: datasource=github-tags depName=devbox packageName=jetify-com/devbox 12 | DEVBOX_VERSION: "0.16.0" 13 | 14 | jobs: 15 | autofix: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | 20 | # Install Devbox 21 | - name: Install devbox 22 | uses: jetify-com/devbox-install-action@v0.14.0 23 | with: 24 | devbox-version: ${{ env.DEVBOX_VERSION }} 25 | enable-cache: "true" 26 | 27 | # Autoformat and try to push back changes 28 | - run: devbox run -- uv run pre-commit run -a --show-diff-on-failure 29 | continue-on-error: true 30 | # HACK https://github.com/autofix-ci/action/pull/15 31 | - run: devbox run -- uv run pre-commit uninstall -t pre-commit -t commit-msg 32 | - uses: autofix-ci/action@v1.3 33 | with: 34 | commit-message: "style: autoformat with pre-commit" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2011 Juan-Pablo Scaletti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_cleanup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from plumbum import local 5 | 6 | import copier 7 | 8 | 9 | def test_cleanup(tmp_path: Path) -> None: 10 | """Copier creates dst_path, fails to copy and removes it.""" 11 | dst = tmp_path / "new_folder" 12 | with pytest.raises(copier.errors.TaskError): 13 | copier.run_copy("./tests/demo_cleanup", dst, quiet=True, unsafe=True) 14 | assert not dst.exists() 15 | 16 | 17 | def test_do_not_cleanup(tmp_path: Path) -> None: 18 | """Copier creates dst_path, fails to copy and keeps it.""" 19 | dst = tmp_path / "new_folder" 20 | with pytest.raises(copier.errors.TaskError): 21 | copier.run_copy( 22 | "./tests/demo_cleanup", dst, quiet=True, unsafe=True, cleanup_on_error=False 23 | ) 24 | assert dst.exists() 25 | 26 | 27 | def test_no_cleanup_when_folder_existed(tmp_path: Path) -> None: 28 | """Copier will not delete a folder if it didn't create it.""" 29 | preexisting_file = tmp_path / "something" 30 | preexisting_file.touch() 31 | with pytest.raises(copier.errors.TaskError): 32 | copier.run_copy( 33 | "./tests/demo_cleanup", 34 | tmp_path, 35 | quiet=True, 36 | unsafe=True, 37 | cleanup_on_error=True, 38 | ) 39 | assert tmp_path.exists() 40 | assert preexisting_file.exists() 41 | 42 | 43 | def test_no_cleanup_when_template_in_parent_folder(tmp_path: Path) -> None: 44 | """Copier will not delete a local template in a parent folder.""" 45 | src = tmp_path / "src" 46 | src.mkdir() 47 | dst = tmp_path / "dst" 48 | dst.mkdir() 49 | cwd = tmp_path / "cwd" 50 | cwd.mkdir() 51 | with local.cwd(cwd): 52 | copier.run_copy(str(Path("..", "src")), dst, quiet=True) 53 | assert src.exists() 54 | -------------------------------------------------------------------------------- /devtasks.py: -------------------------------------------------------------------------------- 1 | """Development helper tasks.""" 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | from plumbum import TEE, CommandNotFound, ProcessExecutionError, local 7 | 8 | _logger = logging.getLogger(__name__) 9 | HERE = Path(__file__).parent 10 | 11 | 12 | def dev_setup() -> None: 13 | """Set up a development environment.""" 14 | with local.cwd(HERE): 15 | local["direnv"]("allow") 16 | local["uv"]("sync", "--frozen") 17 | 18 | 19 | def lint() -> None: 20 | """Lint and format the project.""" 21 | args = [ 22 | "run", 23 | "--", 24 | "uv", 25 | "run", 26 | "pre-commit", 27 | "run", 28 | "--color=always", 29 | "--all-files", 30 | ] 31 | try: 32 | local["devbox"].with_cwd(HERE)[args] & TEE 33 | except CommandNotFound: 34 | _logger.warning("Devbox not found; fallback to a container") 35 | runner = local.get("podman", "docker") 36 | try: 37 | ( 38 | runner[ 39 | "container", 40 | "create", 41 | "--name=copier-lint-v1", 42 | f"--volume={HERE}:{HERE}:rw,z", 43 | f"--workdir={HERE}", 44 | "docker.io/jetpackio/devbox:0.16.0", 45 | "devbox", 46 | ][args] 47 | & TEE 48 | ) 49 | except ProcessExecutionError: 50 | _logger.info( 51 | "Couldn't create copier-lint-v1 container, probably because a previous one exists. " 52 | "Remove it if you want to recycle it. Otherwise, this is OK." 53 | ) 54 | runner["container", "start", "--attach", "copier-lint-v1"] & TEE 55 | except ProcessExecutionError as error: 56 | raise SystemExit(error.errno) 57 | -------------------------------------------------------------------------------- /tests/test_tools.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from stat import S_IREAD 3 | from tempfile import TemporaryDirectory 4 | 5 | import pytest 6 | from poethepoet.app import PoeThePoet 7 | 8 | from copier._tools import normalize_git_path 9 | 10 | from .helpers import git 11 | 12 | 13 | def test_types() -> None: 14 | """Ensure source code static typing.""" 15 | result = PoeThePoet(Path())(["types"]) 16 | assert result == 0 17 | 18 | 19 | def test_temporary_directory_with_readonly_files_deletion() -> None: 20 | """Ensure temporary directories containing read-only files are properly deleted, whatever the OS.""" 21 | with TemporaryDirectory() as tmp_dir: 22 | ro_file = Path(tmp_dir) / "readonly.txt" 23 | with ro_file.open("w") as fp: 24 | fp.write("don't touch me!") 25 | ro_file.chmod(S_IREAD) 26 | assert not Path(tmp_dir).exists() 27 | 28 | 29 | def test_temporary_directory_with_git_repo_deletion() -> None: 30 | """Ensure temporary directories containing git repositories are properly deleted, whatever the OS.""" 31 | with TemporaryDirectory() as tmp_dir: 32 | git("init") 33 | assert not Path(tmp_dir).exists() 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ("path", "normalized"), 38 | [ 39 | ("readme.md", "readme.md"), 40 | ('quo\\"tes', 'quo"tes'), 41 | ('"surrounded"', "surrounded"), 42 | ("m4\\303\\2424\\303\\2614a", "m4â4ñ4a"), 43 | ("tab\\t", "tab\t"), 44 | ("lf\\n", "lf\n"), 45 | ("crlf\\r\\n", "crlf\r\n"), 46 | ("back\\\\slash", "back\\slash"), 47 | ( 48 | "\\a\\b\\f\\n\\t\\vcontrol\\a\\b\\f\\n\\t\\vcharacters\\a\\b\\f\\n\\t\\v", 49 | "\a\b\f\n\t\vcontrol\a\b\f\n\t\vcharacters\a\b\f\n\t\v", 50 | ), 51 | ], 52 | ) 53 | def test_normalizing_git_paths(path: str, normalized: str) -> None: 54 | assert normalize_git_path(path) == normalized 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | 5 | body: 6 | - type: textarea 7 | id: actual-situation 8 | attributes: 9 | label: Actual Situation 10 | description: | 11 | How does the user work? 💻 What is happening, why is he/she working like this, what problem is he/she encountering, why is it causing him/her frustration? 12 | 13 | If you want, you can put a 📼 video where you record the use case that is problematic, or just some 📸 screenshots. 14 | 15 | Don't just state the problem in the program itself. **Also explain how it impacts the user's real world**. 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: desired-situation 21 | attributes: 22 | label: Desired Situation 23 | description: | 24 | What would the user want to happen instead of what is happening? 25 | 26 | Explain it broadly, without going into details of how you would implement the solution. 27 | 28 | Don't just think about what would **solve the problem** 💪, but also think about what would **reduce frustration** 🤩. 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: proposed-solution 34 | attributes: 35 | label: Proposed solution 36 | description: | 37 | As humans, when we see a problem, it's hard not to think of a solution - and that's a good thing! 👷 38 | 39 | In this section, you can propose what you think would be the ideal solution. 40 | 41 | - You can give as many technical details as you see fit. 42 | - You can design a draft interface of the solution. 43 | - The most valued solutions are the MVP solutions. 44 | 45 | But, important: your solution **is just a proposal**; don't get upset if it doesn't end up being the final solution. 😃 46 | -------------------------------------------------------------------------------- /copier/_deprecation.py: -------------------------------------------------------------------------------- 1 | """Deprecation utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import warnings 6 | 7 | _issue_note = ( 8 | "If you have any questions or concerns, please raise an issue at " 9 | "." 10 | ) 11 | 12 | 13 | def deprecate_module_as_internal(name: str) -> None: 14 | """Deprecate a module as internal with a warning. 15 | 16 | Args: 17 | name: The module name. 18 | """ 19 | warnings.warn( 20 | f"Importing from `{name}` is deprecated. This module is intended for internal " 21 | f"use only and will become inaccessible in the future. {_issue_note}", 22 | DeprecationWarning, 23 | stacklevel=3, 24 | ) 25 | 26 | 27 | def deprecate_member_as_internal(member: str, module: str) -> None: 28 | """Deprecate a module member as internal with a warning. 29 | 30 | Args: 31 | member: The module member name. 32 | module: The module name. 33 | """ 34 | warnings.warn( 35 | f"Importing `{member}` from `{module}` is deprecated. This module member is " 36 | "intended for internal use only and will become inaccessible in the future. " 37 | f"{_issue_note}", 38 | DeprecationWarning, 39 | stacklevel=3, 40 | ) 41 | 42 | 43 | def deprecate_member(member: str, module: str, new_import: str) -> None: 44 | """Deprecate a module member with a new import statement with a warning. 45 | 46 | Args: 47 | member: The module member name. 48 | module: The module name. 49 | new_import: The new import statement. 50 | """ 51 | warnings.warn( 52 | f"Importing `{member}` from `{module}` is deprecated. Please update the import " 53 | f"to `{new_import}`. The deprecated import will become invalid in the future. " 54 | f"{_issue_note}", 55 | DeprecationWarning, 56 | stacklevel=3, 57 | ) 58 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "customManagers:dockerfileVersions", 6 | "customManagers:githubActionsVersions", 7 | ":semanticCommitTypeAll(build)" 8 | ], 9 | "lockFileMaintenance": { 10 | "enabled": true, 11 | "schedule": "every 4 week on Monday" 12 | }, 13 | "pyenv": { 14 | "enabled": false 15 | }, 16 | "rangeStrategy": "update-lockfile", 17 | "labels": ["dependencies"], 18 | "packageRules": [ 19 | { 20 | "matchManagers": ["pep621"], 21 | "matchDepTypes": ["dependency-groups"], 22 | "rangeStrategy": "pin" 23 | }, 24 | { 25 | "description": "Don't update Prettier to v3.4.0+ because it breaks some Markdown syntax extensions (https://github.com/prettier/prettier/issues/16929)", 26 | "matchDatasources": ["devbox"], 27 | "matchPackageNames": ["nodePackages.prettier"], 28 | "allowedVersions": "<3.4.0" 29 | }, 30 | { 31 | "matchCategories": ["python"], 32 | "addLabels": ["python"] 33 | }, 34 | { 35 | "matchManagers": ["github-actions"], 36 | "addLabels": ["github_actions"] 37 | } 38 | ], 39 | "customManagers": [ 40 | { 41 | "customType": "regex", 42 | "managerFilePatterns": ["/(^|/)devbox\\.json$/"], 43 | "matchStrings": ["/(?\\d+(\\.\\d+)*)/.schema/devbox.schema.json"], 44 | "datasourceTemplate": "github-tags", 45 | "packageNameTemplate": "jetify-com/devbox", 46 | "depNameTemplate": "devbox", 47 | "versioningTemplate": "semver" 48 | }, 49 | { 50 | "customType": "regex", 51 | "managerFilePatterns": ["**/*"], 52 | "matchStrings": ["docker.io/jetpackio/devbox:(?\\d+(\\.\\d+)*)"], 53 | "datasourceTemplate": "github-tags", 54 | "packageNameTemplate": "jetify-com/devbox", 55 | "depNameTemplate": "devbox", 56 | "versioningTemplate": "semver" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: codespell 5 | name: codespell 6 | entry: uv run codespell 7 | language: system 8 | types: [text] 9 | 10 | - id: commitizen 11 | name: commitizen check 12 | entry: uv run cz check 13 | args: [--allow-abort, --commit-msg-file] 14 | stages: [commit-msg] 15 | language: system 16 | 17 | - id: editorconfig-checker 18 | name: editorconfig-checker 19 | entry: editorconfig-checker 20 | language: system 21 | types: [text] 22 | exclude: \.md$|\.noeof\.|\.bundle$ 23 | 24 | - id: prettier 25 | name: prettier 26 | entry: prettier --write --list-different --ignore-unknown 27 | stages: [pre-commit] 28 | language: system 29 | types: [text] 30 | exclude: | 31 | (?x)( 32 | # Some API reference identifiers are dotted paths involving 33 | # internal modules prefixed with `_` which are converted by 34 | # Prettier to `\_`, making them invalid. 35 | ^docs/reference/.+\.md$| 36 | # Those files have wrong syntax and would fail 37 | ^tests/demo_invalid/copier\.yml$| 38 | ^tests/demo_transclude_invalid(_multi)?/demo/copier\.yml$| 39 | # HACK https://github.com/prettier/prettier/issues/9430 40 | ^tests/demo 41 | ) 42 | 43 | - id: ruff-check 44 | name: ruff-check 45 | entry: uv run ruff check --fix 46 | language: system 47 | types_or: [python, pyi] 48 | require_serial: true 49 | 50 | - id: ruff-format 51 | name: ruff-format 52 | entry: uv run ruff format 53 | language: system 54 | types_or: [python, pyi] 55 | require_serial: true 56 | 57 | - id: taplo 58 | name: taplo 59 | entry: uv run taplo format 60 | language: system 61 | types: [toml] 62 | require_serial: true 63 | -------------------------------------------------------------------------------- /tests/test_empty_suffix.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import copier 4 | 5 | from .helpers import build_file_tree 6 | 7 | 8 | def test_empty_suffix(tmp_path_factory: pytest.TempPathFactory) -> None: 9 | root, dest = map(tmp_path_factory.mktemp, ("src", "dst")) 10 | build_file_tree( 11 | { 12 | (root / "copier.yaml"): ( 13 | """\ 14 | _templates_suffix: "" 15 | name: 16 | type: str 17 | default: pingu 18 | """ 19 | ), 20 | (root / "render_me"): "Hello {{name}}!", 21 | (root / "{{name}}.txt"): "Hello {{name}}!", 22 | (root / "{{name}}" / "render_me.txt"): "Hello {{name}}!", 23 | } 24 | ) 25 | copier.run_copy(str(root), dest, defaults=True, overwrite=True) 26 | 27 | assert not (dest / "copier.yaml").exists() 28 | 29 | assert (dest / "render_me").exists() 30 | assert (dest / "pingu.txt").exists() 31 | assert (dest / "pingu" / "render_me.txt").exists() 32 | 33 | expected = "Hello pingu!" 34 | assert (dest / "render_me").read_text() == expected 35 | assert (dest / "pingu.txt").read_text() == expected 36 | assert (dest / "pingu" / "render_me.txt").read_text() == expected 37 | 38 | 39 | def test_binary_file_fallback_to_copy(tmp_path_factory: pytest.TempPathFactory) -> None: 40 | root, dest = map(tmp_path_factory.mktemp, ("src", "dst")) 41 | build_file_tree( 42 | { 43 | (root / "copier.yaml"): ( 44 | """\ 45 | _templates_suffix: "" 46 | name: 47 | type: str 48 | default: pingu 49 | """ 50 | ), 51 | (root / "logo.png"): ( 52 | b"\x89PNG\r\n\x1a\n\x00\rIHDR\x00\xec\n{{name}}\n\x00\xec" 53 | ), 54 | } 55 | ) 56 | copier.run_copy(str(root), dest, defaults=True, overwrite=True) 57 | logo = dest / "logo.png" 58 | assert logo.exists() 59 | logo_bytes = logo.read_bytes() 60 | assert b"{{name}}" in logo_bytes 61 | assert b"pingu" not in logo_bytes 62 | -------------------------------------------------------------------------------- /tests/test_demo_update_tasks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from plumbum import local 3 | 4 | from copier import run_copy, run_update 5 | 6 | from .helpers import build_file_tree, git 7 | 8 | 9 | def test_update_tasks(tmp_path_factory: pytest.TempPathFactory) -> None: 10 | """Test that updating a template runs tasks from the expected version.""" 11 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 12 | # Prepare repo bundle 13 | repo = src / "repo" 14 | bundle = src / "demo_update_tasks.bundle" 15 | build_file_tree( 16 | { 17 | (repo / ".copier-answers.yml.jinja"): ( 18 | """\ 19 | # Changes here will be overwritten by Copier 20 | {{ _copier_answers|to_nice_yaml }} 21 | """ 22 | ), 23 | (repo / "copier.yaml"): ( 24 | """\ 25 | _tasks: 26 | - cat v1.txt 27 | """ 28 | ), 29 | (repo / "v1.txt"): "file only in v1", 30 | } 31 | ) 32 | with local.cwd(repo): 33 | git("init") 34 | git("add", ".") 35 | git("commit", "-m1") 36 | git("tag", "v1") 37 | build_file_tree( 38 | { 39 | (repo / "copier.yaml"): ( 40 | """\ 41 | _tasks: 42 | - cat v2.txt 43 | """ 44 | ), 45 | (repo / "v2.txt"): "file only in v2", 46 | } 47 | ) 48 | (repo / "v1.txt").unlink() 49 | with local.cwd(repo): 50 | git("init") 51 | git("add", ".") 52 | git("commit", "-m2") 53 | git("tag", "v2") 54 | git("bundle", "create", bundle, "--all") 55 | # Copy the 1st version 56 | run_copy(str(bundle), dst, defaults=True, overwrite=True, vcs_ref="v1", unsafe=True) 57 | # Init destination as a new independent git repo 58 | with local.cwd(dst): 59 | git("init") 60 | # Commit changes 61 | git("add", ".") 62 | git("commit", "-m", "hello world") 63 | # Update target to v2 64 | run_update(dst_path=dst, defaults=True, overwrite=True, unsafe=True) 65 | -------------------------------------------------------------------------------- /img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/test_jinja2_extensions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | import pytest 6 | from jinja2 import Environment 7 | from jinja2.ext import Extension 8 | 9 | import copier 10 | 11 | from .helpers import PROJECT_TEMPLATE, build_file_tree 12 | 13 | 14 | class FilterExtension(Extension): 15 | """Jinja2 extension to add a filter to the Jinja2 environment.""" 16 | 17 | def __init__(self, environment: Environment) -> None: 18 | super().__init__(environment) 19 | 20 | def super_filter(obj: Any) -> str: 21 | return str(obj) + " super filter!" 22 | 23 | environment.filters["super_filter"] = super_filter 24 | 25 | 26 | class GlobalsExtension(Extension): 27 | """Jinja2 extension to add global variables to the Jinja2 environment.""" 28 | 29 | def __init__(self, environment: Environment) -> None: 30 | super().__init__(environment) 31 | 32 | def super_func(argument: Any) -> str: 33 | return str(argument) + " super func!" 34 | 35 | environment.globals.update(super_func=super_func) 36 | environment.globals.update(super_var="super var!") 37 | 38 | 39 | def test_default_jinja2_extensions(tmp_path: Path) -> None: 40 | copier.run_copy(str(PROJECT_TEMPLATE) + "_extensions_default", tmp_path) 41 | super_file = tmp_path / "super_file.md" 42 | assert super_file.exists() 43 | assert super_file.read_text() == "path\n" 44 | 45 | 46 | def test_additional_jinja2_extensions(tmp_path: Path) -> None: 47 | copier.run_copy( 48 | str(PROJECT_TEMPLATE) + "_extensions_additional", tmp_path, unsafe=True 49 | ) 50 | super_file = tmp_path / "super_file.md" 51 | assert super_file.exists() 52 | assert super_file.read_text() == "super var! super func! super filter!\n" 53 | 54 | 55 | def test_to_json_filter_with_conf(tmp_path_factory: pytest.TempPathFactory) -> None: 56 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 57 | build_file_tree( 58 | { 59 | src / "conf.json.jinja": "{{ _copier_conf|to_json }}", 60 | } 61 | ) 62 | copier.run_copy(str(src), dst) 63 | conf_file = dst / "conf.json" 64 | assert conf_file.exists() 65 | # must not raise an error 66 | assert json.loads(conf_file.read_text()) 67 | -------------------------------------------------------------------------------- /img/badge/badge-grayscale-border.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "white", 7 | "color": "#000000" 8 | } 9 | -------------------------------------------------------------------------------- /img/badge/badge-grayscale-inverted-border.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "white", 7 | "color": "#000000" 8 | } 9 | -------------------------------------------------------------------------------- /img/badge/badge-grayscale-inverted-border-red.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "white", 7 | "color": "#e91414" 8 | } 9 | -------------------------------------------------------------------------------- /img/badge/badge-grayscale-inverted-border-teal.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "white", 7 | "color": "#3bb3c4" 8 | } 9 | -------------------------------------------------------------------------------- /tests/test_interrupts.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from copier._main import Worker 6 | from copier.errors import CopierAnswersInterrupt 7 | 8 | from .helpers import build_file_tree 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "side_effect", 13 | [ 14 | # We override the prompt method from questionary to raise this 15 | # exception and expect our surrounding machinery to re-raise 16 | # it as a CopierAnswersInterrupt. 17 | CopierAnswersInterrupt(Mock(), Mock(), Mock()), 18 | KeyboardInterrupt, 19 | ], 20 | ) 21 | def test_keyboard_interrupt( 22 | tmp_path_factory: pytest.TempPathFactory, side_effect: KeyboardInterrupt 23 | ) -> None: 24 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 25 | build_file_tree( 26 | { 27 | (src / "copier.yml"): ( 28 | """\ 29 | question: 30 | type: str 31 | """ 32 | ), 33 | } 34 | ) 35 | worker = Worker(str(src), dst, defaults=False) 36 | 37 | with patch("copier._main.unsafe_prompt", side_effect=side_effect): 38 | with pytest.raises(KeyboardInterrupt): 39 | worker.run_copy() 40 | 41 | 42 | def test_multiple_questions_interrupt(tmp_path_factory: pytest.TempPathFactory) -> None: 43 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 44 | build_file_tree( 45 | { 46 | (src / "copier.yml"): ( 47 | """\ 48 | question1: 49 | type: str 50 | question2: 51 | type: str 52 | question3: 53 | type: str 54 | """ 55 | ), 56 | } 57 | ) 58 | worker = Worker(str(src), dst, defaults=False) 59 | 60 | with patch( 61 | "copier._main.unsafe_prompt", 62 | side_effect=[ 63 | {"question1": "foobar"}, 64 | {"question2": "yosemite"}, 65 | KeyboardInterrupt, 66 | ], 67 | ): 68 | with pytest.raises(CopierAnswersInterrupt) as err: 69 | worker.run_copy() 70 | assert err.value.answers.user == { 71 | "question1": "foobar", 72 | "question2": "yosemite", 73 | } 74 | assert err.value.template == worker.template 75 | -------------------------------------------------------------------------------- /img/badge/badge-grayscale-inverted-border-orange.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "white", 7 | "color": "#f8c200" 8 | } 9 | -------------------------------------------------------------------------------- /img/badge/badge-grayscale-inverted-border-purple.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "white", 7 | "color": "#b74f8e" 8 | } 9 | -------------------------------------------------------------------------------- /img/badge/badge-black.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "black", 7 | "color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /img/badge/black-badge.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Made with Copier", 4 | "logoSvg": "", 5 | "logoWidth": 16, 6 | "labelColor": "black", 7 | "color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: copier 2 | site_description: Library and command-line utility for rendering projects templates. 3 | site_url: https://copier.readthedocs.io/ 4 | repo_url: https://github.com/copier-org/copier 5 | repo_name: copier-org/copier 6 | watch: [copier] 7 | 8 | nav: 9 | - Overview: "index.md" 10 | - Creating a template: "creating.md" 11 | - Configuring a template: "configuring.md" 12 | - Generating a project: "generating.md" 13 | - Updating a project: "updating.md" 14 | - Settings: "settings.md" 15 | - Reference: 16 | - cli.py: "reference/cli.md" 17 | - errors.py: "reference/errors.md" 18 | - jinja_ext.py: "reference/jinja_ext.md" 19 | - main.py: "reference/main.md" 20 | - settings.py: "reference/settings.md" 21 | - subproject.py: "reference/subproject.md" 22 | - template.py: "reference/template.md" 23 | - tools.py: "reference/tools.md" 24 | - types.py: "reference/types.md" 25 | - user_data.py: "reference/user_data.md" 26 | - vcs.py: "reference/vcs.md" 27 | - Comparisons: comparisons.md 28 | - Frequently Asked Questions: faq.md 29 | - Contributing: "contributing.md" 30 | - Changelog: "changelog.md" 31 | 32 | theme: 33 | name: material 34 | features: 35 | - content.code.annotate 36 | - navigation.top 37 | - navigation.tracking 38 | palette: 39 | - media: "(prefers-color-scheme)" 40 | toggle: 41 | icon: material/brightness-auto 42 | name: Switch to light mode 43 | - media: "(prefers-color-scheme: light)" 44 | scheme: default 45 | primary: deep orange 46 | accent: amber 47 | toggle: 48 | icon: material/weather-sunny 49 | name: Switch to dark mode 50 | - media: "(prefers-color-scheme: dark)" 51 | scheme: slate 52 | primary: amber 53 | accent: orange 54 | toggle: 55 | icon: material/weather-night 56 | name: Switch to system preference 57 | 58 | extra_css: 59 | - css/mkdocstrings.css 60 | 61 | markdown_extensions: 62 | - admonition 63 | - attr_list 64 | - pymdownx.highlight: 65 | use_pygments: true 66 | - pymdownx.inlinehilite 67 | - pymdownx.superfences: 68 | custom_fences: 69 | - name: mermaid 70 | class: mermaid 71 | format: !!python/name:pymdownx.superfences.fence_code_format 72 | - pymdownx.emoji 73 | - pymdownx.magiclink 74 | - toc: 75 | permalink: true 76 | - footnotes 77 | 78 | plugins: 79 | - autorefs 80 | - search 81 | - markdown-exec 82 | - mkdocstrings 83 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | Copier settings are stored in `/settings.yml` where `` is the 4 | standard configuration directory for your platform: 5 | 6 | - `$XDG_CONFIG_HOME/copier` (`~/.config/copier ` in most cases) on Linux as defined by 7 | [XDG Base Directory Specifications](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) 8 | - `~/Library/Application Support/copier` on macOS as defined by 9 | [Apple File System Basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html) 10 | - `%USERPROFILE%\AppData\Local\copier` on Windows as defined in 11 | [Known folders](https://docs.microsoft.com/en-us/windows/win32/shell/known-folders) 12 | 13 | !!! note 14 | 15 | Windows only: `%USERPROFILE%\AppData\Local\copier\copier` was the standard configuration directory until v9.6.0. This standard configuration directory is deprecated and will be removed in a future version. 16 | 17 | This location can be overridden by setting the `COPIER_SETTINGS_PATH` environment 18 | variable. 19 | 20 | ## User defaults 21 | 22 | Users may define some reusable default variables in the `defaults` section of the 23 | configuration file. 24 | 25 | ```yaml title="/settings.yml" 26 | defaults: 27 | user_name: "John Doe" 28 | user_email: john.doe@acme.com 29 | ``` 30 | 31 | This user data will replace the default value of fields of the same name. 32 | 33 | ### Well-known variables 34 | 35 | To ensure templates efficiently reuse user-defined variables, we invite template authors 36 | to use the following well-known variables: 37 | 38 | | Variable name | Type | Description | 39 | | ------------- | ----- | ---------------------- | 40 | | `user_name` | `str` | User's full name | 41 | | `user_email` | `str` | User's email address | 42 | | `github_user` | `str` | User's GitHub username | 43 | | `gitlab_user` | `str` | User's GitLab username | 44 | 45 | ## Trusted locations 46 | 47 | Users may define trusted locations in the `trust` setting. It should be a list of Copier 48 | template repositories, or repositories prefix. 49 | 50 | ```yaml 51 | trust: 52 | - https://github.com/your_account/your_template.git 53 | - https://github.com/your_account/ 54 | - ~/templates/ 55 | ``` 56 | 57 | !!! warning "Security considerations" 58 | 59 | Locations ending with `/` will be matched as prefixes, trusting all templates starting with that path. 60 | Locations not ending with `/` will be matched exactly. 61 | -------------------------------------------------------------------------------- /tests/test_normal_jinja2.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | 5 | import copier 6 | 7 | from .helpers import build_file_tree 8 | 9 | 10 | def test_normal_jinja2(tmp_path_factory: pytest.TempPathFactory) -> None: 11 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 12 | build_file_tree( 13 | { 14 | (src / "copier.yml"): ( 15 | """\ 16 | _templates_suffix: .jinja 17 | _envops: 18 | autoescape: false 19 | variable_start_string: "{{" 20 | variable_end_string: "}}" 21 | block_start_string: "{%" 22 | block_end_string: "%}" 23 | comment_start_string: "{#" 24 | comment_end_string: "#}" 25 | lstrip_blocks: true 26 | trim_blocks: true 27 | name: Guybrush 28 | todo: Become a pirate 29 | """ 30 | ), 31 | (src / "TODO.txt.jinja"): ( 32 | """\ 33 | [[ {{ name }} TODO LIST]] 34 | {# Let's put an ugly not-comment below #} 35 | [# GROG #] 36 | {% if name == 'Guybrush' %} 37 | - {{ todo }} 38 | {% endif %} 39 | """ 40 | ), 41 | } 42 | ) 43 | # No warnings, because template is explicit 44 | with warnings.catch_warnings(): 45 | warnings.simplefilter("error") 46 | copier.run_copy(str(src), dst, defaults=True, overwrite=True) 47 | todo = (dst / "TODO.txt").read_text() 48 | expected = "[[ Guybrush TODO LIST]]\n[# GROG #]\n - Become a pirate\n" 49 | assert todo == expected 50 | 51 | 52 | def test_to_not_keep_trailing_newlines_in_jinja2( 53 | tmp_path_factory: pytest.TempPathFactory, 54 | ) -> None: 55 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 56 | build_file_tree( 57 | { 58 | (src / "copier.yml"): ( 59 | """\ 60 | _templates_suffix: .jinja 61 | _envops: 62 | keep_trailing_newline: false 63 | data: foo 64 | """ 65 | ), 66 | (src / "data.txt.jinja"): "This is {{ data }}.\n", 67 | } 68 | ) 69 | # No warnings, because template is explicit 70 | with warnings.catch_warnings(): 71 | warnings.simplefilter("error") 72 | copier.run_copy(str(src), dst, defaults=True, overwrite=True) 73 | assert (dst / "data.txt").read_text() == "This is foo." 74 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: ["*"] 7 | 8 | env: 9 | # renovate: datasource=devbox depName=uv 10 | UV_VERSION: "0.9.18" 11 | 12 | jobs: 13 | build: 14 | name: Build project for distribution 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v7 24 | with: 25 | version: ${{ env.UV_VERSION }} 26 | 27 | # See https://peps.python.org/pep-0440/#local-version-identifiers 28 | - name: Omit local version for publishing to test.pypi.org 29 | if: github.ref == 'refs/heads/master' 30 | # HACK: https://github.com/ofek/hatch-vcs/issues/43 31 | run: | 32 | cat << EOF >> pyproject.toml 33 | 34 | [tool.hatch.version.raw-options] 35 | local_scheme = "no-local-version" 36 | EOF 37 | 38 | - name: Build project for distribution 39 | run: uv build 40 | 41 | - name: Upload artifact containing distribution files 42 | uses: actions/upload-artifact@v6 43 | with: 44 | name: dist 45 | path: dist/ 46 | if-no-files-found: error 47 | 48 | publish-test: 49 | name: Publish package distributions to test.pypi.org 50 | runs-on: ubuntu-latest 51 | needs: [build] 52 | if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') }} 53 | environment: 54 | name: pypi-test 55 | url: https://test.pypi.org/p/copier 56 | permissions: 57 | id-token: write 58 | 59 | steps: 60 | - name: Download artifact containing distribution files 61 | uses: actions/download-artifact@v7 62 | with: 63 | name: dist 64 | path: dist/ 65 | 66 | - name: Upload package distributions 67 | uses: pypa/gh-action-pypi-publish@release/v1 68 | with: 69 | packages-dir: dist/ 70 | repository-url: https://test.pypi.org/legacy/ 71 | 72 | publish: 73 | name: Publish package distributions to pypi.org 74 | runs-on: ubuntu-latest 75 | needs: [publish-test] 76 | if: startsWith(github.ref, 'refs/tags/') 77 | environment: 78 | name: pypi 79 | url: https://pypi.org/p/copier 80 | permissions: 81 | id-token: write 82 | 83 | steps: 84 | - name: Download artifact containing distribution files 85 | uses: actions/download-artifact@v7 86 | with: 87 | name: dist 88 | path: dist/ 89 | 90 | - name: Upload package distributions 91 | uses: pypa/gh-action-pypi-publish@release/v1 92 | with: 93 | packages-dir: dist/ 94 | -------------------------------------------------------------------------------- /copier/settings.py: -------------------------------------------------------------------------------- 1 | """User settings models and helper functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import warnings 7 | from os.path import expanduser 8 | from pathlib import Path 9 | from typing import Any 10 | 11 | import yaml 12 | from platformdirs import user_config_path 13 | from pydantic import BaseModel, Field 14 | 15 | from ._tools import OS 16 | from .errors import MissingSettingsWarning 17 | 18 | ENV_VAR = "COPIER_SETTINGS_PATH" 19 | 20 | 21 | class Settings(BaseModel): 22 | """User settings model.""" 23 | 24 | defaults: dict[str, Any] = Field( 25 | default_factory=dict, description="Default values for questions" 26 | ) 27 | trust: set[str] = Field( 28 | default_factory=set, description="List of trusted repositories or prefixes" 29 | ) 30 | 31 | @staticmethod 32 | def _default_settings_path() -> Path: 33 | return user_config_path("copier", appauthor=False) / "settings.yml" 34 | 35 | @classmethod 36 | def from_file(cls, settings_path: Path | None = None) -> Settings: 37 | """Load settings from a file.""" 38 | env_path = os.getenv(ENV_VAR) 39 | if settings_path is None: 40 | if env_path: 41 | settings_path = Path(env_path) 42 | else: 43 | settings_path = cls._default_settings_path() 44 | 45 | # NOTE: Remove after a sufficiently long deprecation period. 46 | if OS == "windows": 47 | old_settings_path = user_config_path("copier") / "settings.yml" 48 | if old_settings_path.is_file(): 49 | warnings.warn( 50 | f"Settings path {old_settings_path} is deprecated. " 51 | f"Please migrate to {settings_path}.", 52 | DeprecationWarning, 53 | stacklevel=2, 54 | ) 55 | settings_path = old_settings_path 56 | if settings_path.is_file(): 57 | data = yaml.safe_load(settings_path.read_bytes()) 58 | return cls.model_validate(data) 59 | elif env_path: 60 | warnings.warn( 61 | f"Settings file not found at {env_path}", MissingSettingsWarning 62 | ) 63 | return cls() 64 | 65 | def is_trusted(self, repository: str) -> bool: 66 | """Check if a repository is trusted.""" 67 | return any( 68 | repository.startswith(self.normalize(trusted)) 69 | if trusted.endswith("/") 70 | else repository == self.normalize(trusted) 71 | for trusted in self.trust 72 | ) 73 | 74 | def normalize(self, url: str) -> str: 75 | """Normalize an URL using user settings.""" 76 | if url.startswith("~"): # Only expand on str to avoid messing with URLs 77 | url = expanduser(url) # noqa: PTH111 78 | return url 79 | -------------------------------------------------------------------------------- /docs/generating.md: -------------------------------------------------------------------------------- 1 | # Generating a project 2 | 3 | !!! warning 4 | 5 | Generate projects only from trusted templates as their tasks run with the 6 | same level of access as your user. 7 | 8 | As seen in the quick usage section, you can generate a project from a template using the 9 | `copier` command-line tool: 10 | 11 | ```shell 12 | copier copy path/to/project/template path/to/destination 13 | ``` 14 | 15 | Or within Python code: 16 | 17 | ```python 18 | copier.run_copy("path/to/project/template", "path/to/destination") 19 | ``` 20 | 21 | If `path/to/destination` doesn't exist, Copier will create the directory and populate it 22 | with the generated files. If `path/to/destination` exists, it must be writable (not 23 | read-only). 24 | 25 | The "template" parameter can be a local path, an URL, or a shortcut URL: 26 | 27 | - GitHub: `gh:namespace/project` 28 | - GitLab: `gl:namespace/project` 29 | 30 | If Copier doesn't detect your remote URL as a Git repository, make sure it starts with 31 | one of `git+https://`, `git+ssh://`, `git@` or `git://`, or it ends with `.git`. 32 | 33 | Use the `--data` command-line argument or the `data` parameter of the 34 | `copier.run_copy()` function to pass whatever extra context you want to be available in 35 | the templates. The arguments can be any valid Python value, even a function. 36 | 37 | Use the `--vcs-ref` command-line argument to checkout a particular Git ref before 38 | generating the project. 39 | 40 | [All the available options][copier.cli] are described with the `--help-all` option. 41 | 42 | ## Templates versions 43 | 44 | By default, Copier will copy from the last release found in template Git tags, sorted as 45 | [PEP 440](https://peps.python.org/pep-0440/), regardless of whether the template is from 46 | a URL or a local clone of a Git repository. 47 | 48 | ### Copying dirty changes 49 | 50 | If you use a local clone of a template repository that has had any uncommitted 51 | modifications made, Copier will use this modified working copy of the template to aid 52 | development of new template features. 53 | 54 | If you would like to override the version of template being installed, the 55 | [`--vcs-ref`][vcs_ref] argument can be used to specify a branch, tag or other reference 56 | to use. 57 | 58 | For example to use the latest master branch from a public repository: 59 | 60 | ```shell 61 | copier copy --vcs-ref master https://github.com/foo/copier-template.git ./path/to/destination 62 | ``` 63 | 64 | Or to work from the current checked out revision of a local template (including dirty 65 | changes): 66 | 67 | ```shell 68 | copier copy --vcs-ref HEAD path/to/project/template path/to/destination 69 | ``` 70 | 71 | ## Regenerating a project 72 | 73 | When you execute `copier recopy $project` again over a preexisting `$project`, Copier 74 | will just reapply the template on it, keeping answers but ignoring previous history. 75 | 76 | !!! warning 77 | 78 | This is not [the recommended approach for updating a project][updating-a-project], 79 | where you usually want Copier to respect the project evolution wherever it doesn't 80 | conflict with the template evolution. 81 | -------------------------------------------------------------------------------- /copier/_subproject.py: -------------------------------------------------------------------------------- 1 | """Objects to interact with subprojects. 2 | 3 | A *subproject* is a project that gets rendered and/or updated with Copier. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from collections.abc import Callable 9 | from dataclasses import field 10 | from functools import cached_property 11 | from pathlib import Path 12 | 13 | from plumbum.machines import local 14 | from pydantic.dataclasses import dataclass 15 | 16 | from ._template import Template 17 | from ._types import AbsolutePath, AnyByStrDict, VCSTypes 18 | from ._user_data import load_answersfile_data 19 | from ._vcs import get_git, is_in_git_repo 20 | 21 | 22 | @dataclass 23 | class Subproject: 24 | """Object that represents the subproject and its current state. 25 | 26 | Attributes: 27 | local_abspath: 28 | Absolute path on local disk pointing to the subproject root folder. 29 | 30 | answers_relpath: 31 | Relative path to [the answers file][the-copier-answersyml-file]. 32 | """ 33 | 34 | local_abspath: AbsolutePath 35 | answers_relpath: Path = Path(".copier-answers.yml") 36 | 37 | _cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False) 38 | 39 | def is_dirty(self) -> bool: 40 | """Indicate if the local template root is dirty. 41 | 42 | Only applicable for VCS-tracked templates. 43 | """ 44 | if self.vcs == "git": 45 | with local.cwd(self.local_abspath): 46 | return bool( 47 | get_git()("status", self.local_abspath, "--porcelain").strip() 48 | ) 49 | return False 50 | 51 | def _cleanup(self) -> None: 52 | """Remove temporary files and folders created by the subproject.""" 53 | for method in self._cleanup_hooks: 54 | method() 55 | 56 | @property 57 | def _raw_answers(self) -> AnyByStrDict: 58 | """Get last answers, loaded raw as yaml.""" 59 | try: 60 | return load_answersfile_data(self.local_abspath, self.answers_relpath) 61 | except OSError: 62 | return {} 63 | 64 | @cached_property 65 | def last_answers(self) -> AnyByStrDict: 66 | """Last answers, excluding private ones (except _src_path and _commit).""" 67 | return { 68 | key: value 69 | for key, value in self._raw_answers.items() 70 | if key in {"_src_path", "_commit"} or not key.startswith("_") 71 | } 72 | 73 | @cached_property 74 | def template(self) -> Template | None: 75 | """Template, as it was used the last time.""" 76 | last_url = self.last_answers.get("_src_path") 77 | last_ref = self.last_answers.get("_commit") 78 | if last_url: 79 | result = Template(url=last_url, ref=last_ref) 80 | self._cleanup_hooks.append(result._cleanup) 81 | return result 82 | return None 83 | 84 | @cached_property 85 | def vcs(self) -> VCSTypes | None: 86 | """VCS type of the subproject.""" 87 | if is_in_git_repo(self.local_abspath): 88 | return "git" 89 | return None 90 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/problem.yml: -------------------------------------------------------------------------------- 1 | name: Problem 2 | description: Create a report to help us improve 3 | labels: [bug, triage] 4 | 5 | body: 6 | - type: textarea 7 | id: problem 8 | attributes: 9 | label: Describe the problem 10 | description: A clear and concise description of what the bug is. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: template 16 | attributes: 17 | label: Template 18 | description: 19 | Many problems are related to a specific Copier template. If you can provide a 20 | template, please do it. It can be a Git repo URL, a .zip file containing the 21 | template, or instructions to build it. 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | id: reproduce 27 | attributes: 28 | label: To Reproduce 29 | description: Steps to reproduce the behavior. 30 | placeholder: | 31 | 1. Run `copier ...` 32 | 2. Answer ... 33 | 3. Choose ... 34 | 4. See error 35 | 36 | - type: textarea 37 | id: logs 38 | attributes: 39 | label: Logs 40 | description: 41 | Provide logs of the problem. These will typically happen when getting to the 42 | latest step explained above if Copier produces an error. It helps too if you 43 | paste your copying session. 44 | render: console 45 | 46 | - type: textarea 47 | id: expected 48 | attributes: 49 | label: Expected behavior 50 | description: A clear and concise description of what you expected to happen. 51 | validations: 52 | required: true 53 | 54 | - type: textarea 55 | id: additional_info 56 | attributes: 57 | label: Screenshots/screencasts/logs 58 | description: 59 | If applicable, add screenshots/screencasts/logs to help explain your problem. 60 | 61 | - type: dropdown 62 | id: env-os 63 | attributes: 64 | label: Operating system 65 | options: 66 | - macOS 67 | - Linux 68 | - Windows 69 | validations: 70 | required: true 71 | 72 | - type: input 73 | id: env-os-distro 74 | attributes: 75 | label: Operating system distribution and version 76 | description: E.g. Ubuntu 22.04 77 | validations: 78 | required: true 79 | 80 | - type: input 81 | id: env-copier 82 | attributes: 83 | label: Copier version 84 | description: | 85 | Please run the following command and copy the output below: 86 | 87 | ```shell 88 | copier --version 89 | ``` 90 | 91 | validations: 92 | required: true 93 | 94 | - type: input 95 | id: env-python 96 | attributes: 97 | label: Python version 98 | description: E.g. CPython 3.11 99 | validations: 100 | required: true 101 | 102 | - type: dropdown 103 | id: env-install 104 | attributes: 105 | label: Installation method 106 | options: 107 | - uvx+pypi 108 | - uvx+git 109 | - pipx+pypi 110 | - pipx+git 111 | - pip+pypi 112 | - pip+git 113 | - local build 114 | - distro package 115 | validations: 116 | required: true 117 | 118 | - type: textarea 119 | id: context 120 | attributes: 121 | label: Additional context 122 | description: Add any other context about the problem here. 123 | -------------------------------------------------------------------------------- /tests/test_minimum_version.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from pathlib import Path 3 | 4 | import pytest 5 | from packaging.version import Version 6 | from plumbum import local 7 | 8 | import copier 9 | from copier.errors import ( 10 | OldTemplateWarning, 11 | UnknownCopierVersionWarning, 12 | UnsupportedVersionError, 13 | ) 14 | 15 | from .helpers import build_file_tree, git 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: 20 | root = tmp_path_factory.mktemp("template") 21 | build_file_tree( 22 | { 23 | (root / "copier.yaml"): ( 24 | """\ 25 | _min_copier_version: "10.5.1" 26 | """ 27 | ), 28 | (root / "README.md"): "", 29 | } 30 | ) 31 | return str(root) 32 | 33 | 34 | def test_version_less_than_required( 35 | template_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 36 | ) -> None: 37 | monkeypatch.setattr("copier.__version__", "0.0.0a0") 38 | with pytest.raises(UnsupportedVersionError): 39 | copier.run_copy(template_path, tmp_path) 40 | 41 | 42 | def test_version_equal_required( 43 | template_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 44 | ) -> None: 45 | monkeypatch.setattr("copier.__version__", "10.5.1") 46 | # assert no error 47 | copier.run_copy(template_path, tmp_path) 48 | 49 | 50 | def test_version_greater_than_required( 51 | template_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 52 | ) -> None: 53 | monkeypatch.setattr("copier.__version__", "99.99.99") 54 | # assert no error 55 | with pytest.warns(OldTemplateWarning): 56 | copier.run_copy(template_path, tmp_path) 57 | 58 | 59 | def test_minimum_version_update( 60 | template_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 61 | ) -> None: 62 | monkeypatch.setattr("copier.__version__", "11.0.0") 63 | with pytest.warns(OldTemplateWarning): 64 | copier.run_copy(template_path, tmp_path) 65 | 66 | with local.cwd(tmp_path): 67 | git("init") 68 | git("add", ".") 69 | git("commit", "-m", "hello world") 70 | 71 | monkeypatch.setattr("copier.__version__", "0.0.0.post0") 72 | with pytest.raises(UnsupportedVersionError): 73 | copier.run_copy(template_path, tmp_path) 74 | 75 | monkeypatch.setattr("copier.__version__", "10.5.1") 76 | # assert no error 77 | copier.run_copy(template_path, tmp_path) 78 | 79 | monkeypatch.setattr("copier.__version__", "99.99.99") 80 | # assert no error 81 | with pytest.warns(OldTemplateWarning): 82 | copier.run_copy(template_path, tmp_path) 83 | 84 | 85 | def test_version_0_0_0_ignored( 86 | template_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 87 | ) -> None: 88 | monkeypatch.setattr("copier._template.copier_version", lambda: Version("0.0.0")) 89 | # assert no error 90 | with warnings.catch_warnings(): 91 | warnings.simplefilter("error") 92 | with pytest.raises(UnknownCopierVersionWarning): 93 | copier.run_copy(template_path, tmp_path) 94 | 95 | 96 | def test_version_bigger_major_warning( 97 | template_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 98 | ) -> None: 99 | monkeypatch.setattr("copier.__version__", "11.0.0a0") 100 | with warnings.catch_warnings(): 101 | warnings.simplefilter("error") 102 | with pytest.raises(OldTemplateWarning): 103 | copier.run_copy(template_path, tmp_path) 104 | -------------------------------------------------------------------------------- /tests/test_tmpdir.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from copier._cli import CopierApp 6 | from copier._main import run_copy, run_recopy, run_update 7 | 8 | from .helpers import build_file_tree, git 9 | 10 | 11 | @pytest.fixture(scope="module") 12 | def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: 13 | # V1 of the template 14 | root = tmp_path_factory.mktemp("template") 15 | build_file_tree( 16 | { 17 | root / "copier.yml": "favorite_app: Copier", 18 | root / "fav.txt.jinja": "{{ favorite_app }}", 19 | root 20 | / "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", 21 | } 22 | ) 23 | _git = git["-C", root] 24 | _git("init") 25 | _git("add", "-A") 26 | _git("commit", "-m", "Initial commit") 27 | _git("tag", "v1") 28 | # V2 of the template 29 | build_file_tree({root / "v2": "true"}) 30 | _git("add", "-A") 31 | _git("commit", "-m", "Second commit") 32 | _git("tag", "v2") 33 | return str(root) 34 | 35 | 36 | def empty_dir(dir: Path) -> None: 37 | assert dir.is_dir() 38 | assert dir.exists() 39 | assert len(list(dir.iterdir())) == 0 40 | 41 | 42 | def test_api( 43 | template_path: str, 44 | tmp_path_factory: pytest.TempPathFactory, 45 | monkeypatch: pytest.MonkeyPatch, 46 | ) -> None: 47 | tmp, dst = map(tmp_path_factory.mktemp, ["tmp", "dst"]) 48 | _git = git["-C", dst] 49 | # Mock tmp dir to assert it ends up clean 50 | monkeypatch.setattr("tempfile.tempdir", str(tmp)) 51 | # Copy 52 | run_copy(template_path, dst, vcs_ref="v1", quiet=True, defaults=True) 53 | assert (dst / "fav.txt").read_text() == "Copier" 54 | assert not (dst / "v2").exists() 55 | _git("init") 56 | _git("add", "-A") 57 | _git("commit", "-m", "Initial commit") 58 | empty_dir(tmp) 59 | # Recopy 60 | run_recopy(dst, vcs_ref="v1", quiet=True, defaults=True) 61 | assert (dst / "fav.txt").read_text() == "Copier" 62 | assert not (dst / "v2").exists() 63 | empty_dir(tmp) 64 | # Update 65 | run_update(dst, quiet=True, defaults=True, overwrite=True) 66 | assert (dst / "fav.txt").read_text() == "Copier" 67 | assert (dst / "v2").read_text() == "true" 68 | empty_dir(tmp) 69 | 70 | 71 | def test_cli( 72 | template_path: str, 73 | tmp_path_factory: pytest.TempPathFactory, 74 | monkeypatch: pytest.MonkeyPatch, 75 | ) -> None: 76 | tmp, dst = map(tmp_path_factory.mktemp, ["tmp", "dst"]) 77 | _git = git["-C", dst] 78 | # Mock tmp dir to assert it ends up clean 79 | monkeypatch.setattr("tempfile.tempdir", str(tmp)) 80 | # Copy 81 | run_result = CopierApp.run( 82 | [ 83 | "copier", 84 | "copy", 85 | "-fqrv1", 86 | template_path, 87 | str(dst), 88 | ], 89 | exit=False, 90 | ) 91 | assert run_result[1] == 0 92 | assert (dst / "fav.txt").read_text() == "Copier" 93 | assert not (dst / "v2").exists() 94 | empty_dir(tmp) 95 | _git("init") 96 | _git("add", "-A") 97 | _git("commit", "-m", "Initial commit") 98 | # Recopy 99 | run_result = CopierApp.run(["copier", "recopy", "-fqrv1", str(dst)], exit=False) 100 | assert run_result[1] == 0 101 | assert (dst / "fav.txt").read_text() == "Copier" 102 | assert not (dst / "v2").exists() 103 | empty_dir(tmp) 104 | # Update 105 | run_result = CopierApp.run(["copier", "update", "-fq", str(dst)], exit=False) 106 | assert run_result[1] == 0 107 | assert (dst / "fav.txt").read_text() == "Copier" 108 | assert (dst / "v2").read_text() == "true" 109 | empty_dir(tmp) 110 | -------------------------------------------------------------------------------- /docs/comparisons.md: -------------------------------------------------------------------------------- 1 | # Comparing Copier to other project generators 2 | 3 | The subject of 4 | [code scaffolding]() has been 5 | around for some time, and there are long established good projects. 6 | 7 | Here's a simple comparison. If you find something wrong, please open a PR and fix these 8 | docs! We don't want to be biased, but it's easy that we tend to be: 9 | 10 | !!! important 11 | 12 | Although Copier was born as a code scaffolding tool, it is today a code lifecycle 13 | management tool. This makes it somehow unique. Most tools below are only scaffolders 14 | and the comparison is not complete due to that. 15 | 16 | 17 | 18 | | Feature | Copier | Cookiecutter | Yeoman | 19 | | ---------------------------------------- | -------------------------------- | ------------------------------- | ------------- | 20 | | Can template file names | Yes | Yes | Yes | 21 | | Can generate file structures in loops | Yes | No | No | 22 | | Configuration | Single YAML file[^1] | Single JSON file | JS module | 23 | | Migrations | Yes | No | No | 24 | | Programmed in | Python | Python | NodeJS | 25 | | Requires handwriting JSON | No | Yes | Yes | 26 | | Requires installing templates separately | No | No | Yes | 27 | | Requires programming | No | No | Yes, JS | 28 | | Requires templates to have a suffix | Yes by default, configurable[^3] | No, not configurable | You choose | 29 | | Task hooks | Yes | Yes | Yes | 30 | | Context hooks | Yes[^5] | Yes | ? | 31 | | Template in a subfolder | Not required, but you choose | Yes, required | Yes, required | 32 | | Template package format | Git repo[^2], Git bundle, folder | Git or Mercurial repo, Zip file | NPM package | 33 | | Template updates | **Yes**[^4] | No[^6] | No | 34 | | Templating engine | [Jinja][] | [Jinja][] | [EJS][] | 35 | 36 | [jinja]: https://jinja.palletsprojects.com/ 37 | [ejs]: https://ejs.co/ 38 | 39 | [^1]: The file itself can [include other YAML files][include-other-yaml-files]. 40 | 41 | [^2]: 42 | Git repo is recommended to be able to use advanced features such as template tagging 43 | and smart updates. 44 | 45 | [^3]: 46 | A suffix is required by default. Defaults to `.jinja`, but can be configured to use 47 | a different suffix, or to use none. 48 | 49 | [^4]: 50 | Only for Git templates, because Copier uses Git tags to obtain available versions 51 | and extract smart diffs between them. 52 | 53 | [^5]: Context hooks are provided through the [`ContextHook` extension][context-hook]. 54 | 55 | [^6]: Updates are possible through [Cruft][cruft]. 56 | 57 | [context-hook]: 58 | https://github.com/copier-org/copier-templates-extensions#context-hook-extension 59 | [cruft]: https://github.com/cruft/cruft 60 | -------------------------------------------------------------------------------- /copier/_types.py: -------------------------------------------------------------------------------- 1 | """Complex types, annotations, validators.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence 6 | from contextlib import contextmanager 7 | from contextvars import ContextVar 8 | from enum import Enum 9 | from pathlib import Path 10 | from typing import ( 11 | Annotated, 12 | Any, 13 | Literal, 14 | NewType, 15 | TypeVar, 16 | ) 17 | 18 | from pydantic import AfterValidator 19 | 20 | # simple types 21 | StrOrPath = str | Path 22 | AnyByStrDict = dict[str, Any] 23 | AnyByStrMutableMapping = MutableMapping[str, Any] 24 | 25 | # sequences 26 | IntSeq = Sequence[int] 27 | PathSeq = Sequence[Path] 28 | 29 | # optional types 30 | OptBool = bool | None 31 | OptStrOrPath = StrOrPath | None 32 | 33 | # miscellaneous 34 | T = TypeVar("T") 35 | JSONSerializable = (dict, list, str, int, float, bool, type(None)) 36 | VCSTypes = Literal["git"] 37 | Env = Mapping[str, str] 38 | MissingType = NewType("MissingType", object) 39 | MISSING = MissingType(object()) 40 | Operation = Literal["copy", "update"] 41 | 42 | 43 | # Validators 44 | def path_is_absolute(value: Path) -> Path: 45 | """Require absolute paths in an argument.""" 46 | if not value.is_absolute(): 47 | from .errors import PathNotAbsoluteError 48 | 49 | raise PathNotAbsoluteError(path=value) 50 | return value 51 | 52 | 53 | def path_is_relative(value: Path) -> Path: 54 | """Require relative paths in an argument.""" 55 | if value.is_absolute(): 56 | from .errors import PathNotRelativeError 57 | 58 | raise PathNotRelativeError(path=value) 59 | return value 60 | 61 | 62 | AbsolutePath = Annotated[Path, AfterValidator(path_is_absolute)] 63 | RelativePath = Annotated[Path, AfterValidator(path_is_relative)] 64 | 65 | 66 | _K = TypeVar("_K") 67 | _V = TypeVar("_V") 68 | 69 | 70 | # HACK https://github.com/copier-org/copier/pull/1880#discussion_r1887491497 71 | class LazyDict(MutableMapping[_K, _V]): 72 | """A dict where values are functions that get evaluated only once when requested.""" 73 | 74 | def __init__(self, mapping: Mapping[_K, Callable[[], _V]] | None = None): 75 | self._pending = dict(mapping or {}) 76 | self._done: dict[_K, _V] = {} 77 | 78 | def __getitem__(self, key: _K) -> _V: 79 | if key not in self._done: 80 | self._done[key] = self._pending[key]() 81 | return self._done[key] 82 | 83 | def __setitem__(self, key: _K, value: _V) -> None: 84 | self._pending[key] = lambda: value 85 | self._done.pop(key, None) 86 | 87 | def __delitem__(self, key: _K) -> None: 88 | del self._pending[key] 89 | del self._done[key] 90 | 91 | def __iter__(self) -> Iterator[_K]: 92 | return iter(self._pending) 93 | 94 | def __len__(self) -> int: 95 | return len(self._pending) 96 | 97 | 98 | class Phase(str, Enum): 99 | """The known execution phases.""" 100 | 101 | PROMPT = "prompt" 102 | TASKS = "tasks" 103 | MIGRATE = "migrate" 104 | RENDER = "render" 105 | UNDEFINED = "undefined" 106 | 107 | def __str__(self) -> str: 108 | return str(self.value) 109 | 110 | @classmethod 111 | @contextmanager 112 | def use(cls, phase: Phase) -> Iterator[None]: 113 | """Set the current phase for the duration of a context.""" 114 | token = _phase.set(phase) 115 | try: 116 | yield 117 | finally: 118 | _phase.reset(token) 119 | 120 | @classmethod 121 | def current(cls) -> Phase: 122 | """Get the current phase.""" 123 | return _phase.get() 124 | 125 | 126 | _phase: ContextVar[Phase] = ContextVar("phase", default=Phase.UNDEFINED) 127 | 128 | 129 | class VcsRef(Enum): 130 | CURRENT = ":current:" 131 | """A special value to indicate that the current ref of the existing 132 | template should be used. 133 | """ 134 | -------------------------------------------------------------------------------- /tests/test_conditional_file_name.py: -------------------------------------------------------------------------------- 1 | import pexpect 2 | import pytest 3 | from plumbum import local 4 | from pytest import TempPathFactory 5 | 6 | import copier 7 | 8 | from .helpers import COPIER_PATH, Spawn, build_file_tree, expect_prompt, git 9 | 10 | 11 | def test_render_conditional(tmp_path_factory: TempPathFactory) -> None: 12 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 13 | build_file_tree( 14 | { 15 | (src / "{% if conditional %}file.txt{% endif %}.jinja"): ( 16 | "This is {{ conditional.variable }}." 17 | ), 18 | } 19 | ) 20 | copier.run_copy(str(src), dst, data={"conditional": {"variable": True}}) 21 | assert (dst / "file.txt").read_text() == "This is True." 22 | 23 | 24 | def test_dont_render_conditional(tmp_path_factory: TempPathFactory) -> None: 25 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 26 | build_file_tree( 27 | { 28 | (src / "{% if conditional %}file.txt{% endif %}.jinja"): ( 29 | "This is {{ conditional.variable }}." 30 | ), 31 | } 32 | ) 33 | copier.run_copy(str(src), dst) 34 | assert not (dst / "file.txt").exists() 35 | 36 | 37 | def test_render_conditional_subdir(tmp_path_factory: TempPathFactory) -> None: 38 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 39 | build_file_tree( 40 | { 41 | (src / "subdir" / "{% if conditional %}file.txt{% endif %}.jinja"): ( 42 | "This is {{ conditional.variable }}." 43 | ), 44 | } 45 | ) 46 | copier.run_copy(str(src), dst, data={"conditional": {"variable": True}}) 47 | assert (dst / "subdir" / "file.txt").read_text() == "This is True." 48 | 49 | 50 | def test_dont_render_conditional_subdir(tmp_path_factory: TempPathFactory) -> None: 51 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 52 | build_file_tree( 53 | { 54 | (src / "subdir" / "{% if conditional %}file.txt{% endif %}.jinja"): ( 55 | "This is a {{ conditional.variable }}." 56 | ), 57 | } 58 | ) 59 | copier.run_copy(str(src), dst) 60 | assert not (dst / "subdir" / "file.txt").exists() 61 | 62 | 63 | @pytest.mark.parametrize("interactive", [False, True]) 64 | def test_answer_changes( 65 | tmp_path_factory: TempPathFactory, spawn: Spawn, interactive: bool 66 | ) -> None: 67 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 68 | 69 | with local.cwd(src): 70 | build_file_tree( 71 | { 72 | "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", 73 | "copier.yml": """ 74 | condition: 75 | type: bool 76 | """, 77 | "{% if condition %}file.txt{% endif %}.jinja": "", 78 | } 79 | ) 80 | git("init") 81 | git("add", ".") 82 | git("commit", "-mv1") 83 | git("tag", "v1") 84 | 85 | if interactive: 86 | tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) 87 | expect_prompt(tui, "condition", "bool") 88 | tui.expect_exact("(y/N)") 89 | tui.sendline("y") 90 | tui.expect_exact("Yes") 91 | tui.expect_exact(pexpect.EOF) 92 | else: 93 | copier.run_copy(str(src), dst, data={"condition": True}) 94 | 95 | assert (dst / "file.txt").exists() 96 | 97 | with local.cwd(dst): 98 | git("init") 99 | git("add", ".") 100 | git("commit", "-mv1") 101 | 102 | if interactive: 103 | tui = spawn(COPIER_PATH + ("update", str(dst))) 104 | expect_prompt(tui, "condition", "bool") 105 | tui.expect_exact("(Y/n)") 106 | tui.sendline("n") 107 | tui.expect_exact("No") 108 | tui.expect_exact(pexpect.EOF) 109 | else: 110 | copier.run_update(dst_path=dst, data={"condition": False}, overwrite=True) 111 | 112 | assert not (dst / "file.txt").exists() 113 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | workflow_dispatch: 8 | inputs: 9 | pytest_addopts: 10 | description: 11 | Extra options for pytest; use -vv for full details; see 12 | https://docs.pytest.org/en/latest/example/simple.html#how-to-change-command-line-options-defaults 13 | required: false 14 | 15 | env: 16 | LANG: "en_US.utf-8" 17 | LC_ALL: "en_US.utf-8" 18 | PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit 19 | PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }} 20 | PYTHONIOENCODING: "UTF-8" 21 | # renovate: datasource=devbox depName=uv 22 | UV_VERSION: "0.9.18" 23 | # renovate: datasource=github-tags depName=devbox packageName=jetify-com/devbox 24 | DEVBOX_VERSION: "0.16.0" 25 | 26 | jobs: 27 | build: 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | os: 32 | - macos-latest 33 | - ubuntu-latest 34 | - windows-latest 35 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 36 | include: 37 | # HACK: Limit the number of pytest workers on macOS to 3 to avoid a 38 | # timeout for a few test cases. For unknown reasons, the number of 39 | # auto-configured workers on macOS runners increased from 3 to 6 on 40 | # 2025-07-08. 41 | # FIXME: Remove this hack when the issue has been fixed. 42 | - os: macos-latest 43 | pytest-xdist-maxprocesses: 3 44 | runs-on: ${{ matrix.os }} 45 | steps: 46 | - uses: actions/checkout@v6 47 | with: 48 | fetch-depth: 0 # Needs all tags to compute dynamic version 49 | - name: Install uv 50 | uses: astral-sh/setup-uv@v7 51 | with: 52 | version: ${{ env.UV_VERSION }} 53 | python-version: ${{ matrix.python-version }} 54 | enable-cache: "true" 55 | cache-suffix: ${{ matrix.python-version }} 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v6 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | - name: Install dependencies 61 | run: uv sync --frozen 62 | - name: Run pytest 63 | run: 64 | uv run poe test --cov=./ --cov-report=xml -ra 65 | ${PYTEST_XDIST_MAXPROCESSES:+--maxprocesses=$PYTEST_XDIST_MAXPROCESSES} . 66 | env: 67 | PYTEST_XDIST_MAXPROCESSES: ${{ matrix.pytest-xdist-maxprocesses }} 68 | - name: Upload coverage to Codecov 69 | continue-on-error: true 70 | uses: codecov/codecov-action@v5 71 | env: 72 | OS: ${{ matrix.os }} 73 | PYTHON: ${{ matrix.python-version }} 74 | with: 75 | env_vars: OS,PYTHON 76 | fail_ci_if_error: true 77 | file: ./coverage.xml 78 | flags: unittests 79 | name: copier 80 | token: ${{ secrets.CODECOV_TOKEN }} 81 | 82 | devbox: 83 | strategy: 84 | fail-fast: false 85 | matrix: 86 | os: 87 | - macos-latest 88 | - ubuntu-latest 89 | include: 90 | # HACK: Limit the number of pytest workers on macOS to 3 to avoid a 91 | # timeout for a few test cases. For unknown reasons, the number of 92 | # auto-configured workers on macOS runners increased from 3 to 6 on 93 | # 2025-07-08. 94 | # FIXME: Remove this hack when the issue has been fixed. 95 | - os: macos-latest 96 | pytest-xdist-maxprocesses: 3 97 | runs-on: ${{ matrix.os }} 98 | permissions: 99 | contents: write # Allow pushing back pre-commit changes 100 | steps: 101 | - uses: actions/checkout@v6 102 | - name: Install devbox 103 | uses: jetify-com/devbox-install-action@v0.14.0 104 | with: 105 | devbox-version: ${{ env.DEVBOX_VERSION }} 106 | enable-cache: "true" 107 | - run: devbox run -- uv run copier --version 108 | - run: devbox run -- uv run pre-commit run --all-files --show-diff-on-failure 109 | - run: 110 | devbox run test 111 | ${PYTEST_XDIST_MAXPROCESSES:+--maxprocesses=$PYTEST_XDIST_MAXPROCESSES} 112 | env: 113 | PYTEST_XDIST_MAXPROCESSES: ${{ matrix.pytest-xdist-maxprocesses }} 114 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | import sys 5 | from argparse import ArgumentTypeError 6 | from collections.abc import Iterator 7 | from pathlib import Path 8 | from typing import Any 9 | from unittest.mock import patch 10 | 11 | import pytest 12 | from coverage.tracer import CTracer 13 | from pexpect.popen_spawn import PopenSpawn 14 | from plumbum import local 15 | from pytest import FixtureRequest, Parser 16 | from pytest_gitconfig.plugin import DELETE, GitConfig 17 | 18 | from .helpers import Spawn 19 | 20 | 21 | def pytest_addoption(parser: Parser) -> None: 22 | def timeout_type(value: Any) -> int: 23 | _value = int(value) 24 | if _value >= 0: 25 | return _value 26 | raise ArgumentTypeError(f"Timeout must be a non-negative integer: '{_value}'") 27 | 28 | parser.addoption( 29 | "--spawn-timeout", 30 | action="store", 31 | default=10, 32 | help="timeout for spawn of pexpect", 33 | type=timeout_type, 34 | ) 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def spawn_timeout(request: FixtureRequest) -> int: 39 | return int(request.config.getoption("--spawn-timeout")) 40 | 41 | 42 | @pytest.fixture 43 | def spawn(spawn_timeout: int) -> Spawn: 44 | """Spawn a copier process TUI to interact with.""" 45 | if platform.system() == "Windows": 46 | # HACK https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1243#issuecomment-706668723 47 | # FIXME Use pexpect or wexpect somehow to fix this 48 | pytest.xfail( 49 | "pexpect fails on Windows", 50 | ) 51 | 52 | def _spawn( 53 | cmd: tuple[str, ...], *, timeout: int | None = spawn_timeout 54 | ) -> PopenSpawn: 55 | # Disable subprocess timeout if debugging (except coverage), for commodity 56 | # See https://stackoverflow.com/a/67065084/1468388 57 | tracer = getattr(sys, "gettrace", lambda: None)() 58 | if not isinstance(tracer, (CTracer, type(None))) or timeout == 0: 59 | timeout = None 60 | # Using PopenSpawn, although probably it would be best to use pexpect.spawn 61 | # instead. However, it's working fine and it seems easier to fix in the 62 | # future to work on Windows (where, this way, spawning actually works; it's just 63 | # python-prompt-toolkit that rejects displaying a TUI) 64 | return PopenSpawn(cmd, timeout, logfile=sys.stderr, encoding="utf-8") 65 | 66 | return _spawn 67 | 68 | 69 | @pytest.fixture(scope="session") 70 | def default_git_user_name() -> Any: 71 | """Unset the default Git user name.""" 72 | return DELETE 73 | 74 | 75 | @pytest.fixture(scope="session") 76 | def default_git_user_email() -> Any: 77 | """Unset the default Git user email.""" 78 | return DELETE 79 | 80 | 81 | @pytest.fixture(scope="session", autouse=True) 82 | def default_gitconfig(default_gitconfig: GitConfig) -> GitConfig: 83 | """ 84 | Use a clean and isolated default gitconfig avoiding user settings to break some tests. 85 | 86 | Add plumbum support to the original session-scoped fixture. 87 | """ 88 | # local.env is a snapshot frozen at Python startup requiring its own monkeypatching 89 | for var in list(local.env.keys()): 90 | if var.startswith("GIT_"): 91 | del local.env[var] 92 | local.env["GIT_CONFIG_GLOBAL"] = str(default_gitconfig) 93 | default_gitconfig.set({"core.autocrlf": "input"}) 94 | return default_gitconfig 95 | 96 | 97 | @pytest.fixture 98 | def gitconfig(gitconfig: GitConfig) -> Iterator[GitConfig]: 99 | """ 100 | Use a clean and isolated gitconfig to test some specific user settings. 101 | 102 | Add plumbum support to the original function-scoped fixture. 103 | """ 104 | with local.env(GIT_CONFIG_GLOBAL=str(gitconfig)): 105 | yield gitconfig 106 | 107 | 108 | @pytest.fixture 109 | def config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: 110 | config_path = tmp_path / "config" 111 | monkeypatch.delenv("COPIER_SETTINGS_PATH", raising=False) 112 | with patch("copier.settings.user_config_path", return_value=config_path): 113 | yield config_path 114 | 115 | 116 | @pytest.fixture 117 | def settings_path(config_path: Path) -> Path: 118 | config_path.mkdir() 119 | settings_path = config_path / "settings.yml" 120 | return settings_path 121 | -------------------------------------------------------------------------------- /copier/_jinja_ext.py: -------------------------------------------------------------------------------- 1 | """Jinja2 extensions built for Copier.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Iterable 6 | from typing import Any 7 | 8 | from jinja2 import nodes 9 | from jinja2.exceptions import UndefinedError 10 | from jinja2.ext import Extension 11 | from jinja2.parser import Parser 12 | from jinja2.sandbox import SandboxedEnvironment 13 | 14 | from copier.errors import MultipleYieldTagsError 15 | 16 | 17 | class YieldEnvironment(SandboxedEnvironment): 18 | """Jinja2 environment with attributes from the YieldExtension. 19 | 20 | This is simple environment class that extends the SandboxedEnvironment 21 | for use with the YieldExtension, mainly for avoiding type errors. 22 | 23 | We use the SandboxedEnvironment because we want to minimize the risk of hidden malware 24 | in the templates. Of course we still have the post-copy tasks to worry about, but at least 25 | they are more visible to the final user. 26 | """ 27 | 28 | yield_name: str | None 29 | yield_iterable: Iterable[Any] | None 30 | 31 | def __init__(self, *args: Any, **kwargs: Any): 32 | super().__init__(*args, **kwargs) 33 | self.extend(yield_name=None, yield_iterable=None) 34 | 35 | 36 | class YieldExtension(Extension): 37 | """Jinja2 extension for the `yield` tag. 38 | 39 | If `yield` tag is used in a template, this extension sets following attribute to the 40 | jinja environment: 41 | 42 | - `yield_name`: The name of the variable that will be yielded. 43 | - `yield_iterable`: The variable that will be looped over. 44 | 45 | Note that this extension just sets the attributes but renders templates as usual. 46 | It is the caller's responsibility to use the `yield_context` attribute in the template to 47 | generate the desired output. 48 | 49 | !!! example 50 | 51 | ```pycon 52 | >>> from copier.jinja_ext import YieldEnvironment, YieldExtension 53 | >>> env = YieldEnvironment(extensions=[YieldExtension]) 54 | >>> template = env.from_string("{% yield single_var from looped_var %}{{ single_var }}{% endyield %}") 55 | >>> template.render({"looped_var": [1, 2, 3]}) 56 | '' 57 | >>> env.yield_name 58 | 'single_var' 59 | >>> env.yield_iterable 60 | [1, 2, 3] 61 | ``` 62 | """ 63 | 64 | tags = {"yield"} 65 | 66 | environment: YieldEnvironment 67 | 68 | def preprocess( 69 | self, source: str, _name: str | None, _filename: str | None = None 70 | ) -> str: 71 | """Preprocess hook to reset attributes before rendering.""" 72 | self.environment.yield_name = self.environment.yield_iterable = None 73 | 74 | return source 75 | 76 | def parse(self, parser: Parser) -> nodes.Node: 77 | """Parse the `yield` tag.""" 78 | lineno = next(parser.stream).lineno 79 | 80 | yield_name: nodes.Name = parser.parse_assign_target(name_only=True) 81 | parser.stream.expect("name:from") 82 | yield_iterable = parser.parse_expression() 83 | body = parser.parse_statements(("name:endyield",), drop_needle=True) 84 | 85 | return nodes.CallBlock( 86 | self.call_method( 87 | "_yield_support", 88 | [nodes.Const(yield_name.name), yield_iterable], 89 | ), 90 | [], 91 | [], 92 | body, 93 | lineno=lineno, 94 | ) 95 | 96 | def _yield_support( 97 | self, yield_name: str, yield_iterable: Iterable[Any], caller: Callable[[], str] 98 | ) -> str: 99 | """Support function for the yield tag. 100 | 101 | Sets the `yield_name` and `yield_iterable` attributes in the environment then calls 102 | the provided caller function. If an UndefinedError is raised, it returns an empty string. 103 | """ 104 | if ( 105 | self.environment.yield_name is not None 106 | or self.environment.yield_iterable is not None 107 | ): 108 | raise MultipleYieldTagsError( 109 | "Attempted to parse the yield tag twice. Only one yield tag is allowed per path name.\n" 110 | f'A yield tag with the name: "{self.environment.yield_name}" and iterable: "{self.environment.yield_iterable}" already exists.' 111 | ) 112 | 113 | self.environment.yield_name = yield_name 114 | self.environment.yield_iterable = yield_iterable 115 | 116 | try: 117 | res = caller() 118 | 119 | # expression like `dict.attr` will always raise UndefinedError 120 | # so we catch it here and return an empty string 121 | except UndefinedError: 122 | res = "" 123 | 124 | return res 125 | 126 | 127 | class UnsetError(UndefinedError): ... 128 | -------------------------------------------------------------------------------- /tests/test_answersfile_templating.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | import copier 8 | from copier._user_data import load_answersfile_data 9 | 10 | from .helpers import build_file_tree 11 | 12 | 13 | @pytest.fixture(scope="module") 14 | def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: 15 | root = tmp_path_factory.mktemp("template") 16 | build_file_tree( 17 | { 18 | root / "{{ _copier_conf.answers_file }}.jinja": """\ 19 | # Changes here will be overwritten by Copier 20 | {{ _copier_answers|to_nice_yaml }} 21 | """, 22 | root / "copier.yml": """\ 23 | _answers_file: ".copier-answers-{{ module_name }}.yml" 24 | 25 | module_name: 26 | type: str 27 | """, 28 | } 29 | ) 30 | return str(root) 31 | 32 | 33 | @pytest.mark.parametrize("answers_file", [None, ".changed-by-user.yml"]) 34 | def test_answersfile_templating( 35 | template_path: str, tmp_path: Path, answers_file: str | None 36 | ) -> None: 37 | """ 38 | Test copier behaves properly when _answers_file contains a template 39 | 40 | Checks that template is resolved successfully and that a subsequent 41 | copy that resolves to a different answers file doesn't clobber the 42 | old answers file. 43 | """ 44 | copier.run_copy( 45 | template_path, 46 | tmp_path, 47 | {"module_name": "mymodule"}, 48 | answers_file=answers_file, 49 | defaults=True, 50 | overwrite=True, 51 | unsafe=True, 52 | ) 53 | first_answers_file = ( 54 | ".copier-answers-mymodule.yml" 55 | if answers_file is None 56 | else ".changed-by-user.yml" 57 | ) 58 | assert (tmp_path / first_answers_file).exists() 59 | answers = load_answersfile_data(tmp_path, first_answers_file) 60 | assert answers["module_name"] == "mymodule" 61 | 62 | copier.run_copy( 63 | template_path, 64 | tmp_path, 65 | {"module_name": "anothermodule"}, 66 | defaults=True, 67 | overwrite=True, 68 | unsafe=True, 69 | ) 70 | 71 | # Assert second one created 72 | second_answers_file = ".copier-answers-anothermodule.yml" 73 | assert (tmp_path / second_answers_file).exists() 74 | answers = load_answersfile_data(tmp_path, second_answers_file) 75 | assert answers["module_name"] == "anothermodule" 76 | 77 | # Assert first one still exists 78 | assert (tmp_path / first_answers_file).exists() 79 | answers = load_answersfile_data(tmp_path, first_answers_file) 80 | assert answers["module_name"] == "mymodule" 81 | 82 | 83 | def test_answersfile_templating_with_message_before_copy( 84 | tmp_path_factory: pytest.TempPathFactory, 85 | ) -> None: 86 | """Test templated `_answers_file` setting with `_message_before_copy`. 87 | 88 | Checks that the templated answers file name is rendered correctly while 89 | having printing a message before the "copy" operation, which uses the render 90 | context before including any answers from the questionnaire. 91 | """ 92 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 93 | build_file_tree( 94 | { 95 | (src / "copier.yml"): ( 96 | """\ 97 | _answers_file: ".copier-answers-{{ module_name }}.yml" 98 | _message_before_copy: "Hello world" 99 | 100 | module_name: 101 | type: str 102 | """ 103 | ), 104 | (src / "{{ _copier_conf.answers_file }}.jinja"): ( 105 | """\ 106 | # Changes here will be overwritten by Copier 107 | {{ _copier_answers|to_nice_yaml }} 108 | """ 109 | ), 110 | (src / "result.txt.jinja"): "{{ module_name }}", 111 | } 112 | ) 113 | copier.run_copy( 114 | str(src), dst, data={"module_name": "mymodule"}, overwrite=True, unsafe=True 115 | ) 116 | assert (dst / ".copier-answers-mymodule.yml").exists() 117 | answers = load_answersfile_data(dst, ".copier-answers-mymodule.yml") 118 | assert answers["module_name"] == "mymodule" 119 | assert (dst / "result.txt").exists() 120 | assert (dst / "result.txt").read_text() == "mymodule" 121 | 122 | 123 | def test_answersfile_templating_phase(tmp_path_factory: pytest.TempPathFactory) -> None: 124 | """ 125 | Ensure `_copier_phase` is available while render `answers_relpath`. 126 | Not because it is directly useful, but because some extensions might need it. 127 | """ 128 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 129 | build_file_tree( 130 | { 131 | src / "copier.yml": """\ 132 | _answers_file: ".copier-answers-{{ _copier_phase }}.yml" 133 | """, 134 | src / "{{ _copier_conf.answers_file }}.jinja": "", 135 | } 136 | ) 137 | copier.run_copy(str(src), dst, overwrite=True, unsafe=True) 138 | assert (dst / ".copier-answers-render.yml").exists() 139 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | from pathlib import Path 6 | 7 | import pytest 8 | import yaml 9 | 10 | from copier.errors import MissingSettingsWarning 11 | from copier.settings import Settings 12 | 13 | 14 | def test_default_settings() -> None: 15 | settings = Settings() 16 | 17 | assert settings.defaults == {} 18 | assert settings.trust == set() 19 | 20 | 21 | def test_settings_from_default_location(settings_path: Path) -> None: 22 | settings_path.write_text("defaults:\n foo: bar") 23 | 24 | settings = Settings.from_file() 25 | 26 | assert settings.defaults == {"foo": "bar"} 27 | 28 | 29 | @pytest.mark.skipif(platform.system() != "Windows", reason="Windows-only test") 30 | def test_default_windows_settings_path() -> None: 31 | settings = Settings() 32 | assert settings._default_settings_path() == Path( 33 | os.getenv("USERPROFILE", default=""), 34 | "AppData", 35 | "Local", 36 | "copier", 37 | "settings.yml", 38 | ) 39 | 40 | 41 | @pytest.mark.usefixtures("config_path") 42 | def test_settings_from_default_location_dont_exists() -> None: 43 | settings = Settings.from_file() 44 | 45 | assert settings.defaults == {} 46 | 47 | 48 | def test_settings_from_env_location( 49 | settings_path: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 50 | ) -> None: 51 | settings_path.write_text("defaults:\n foo: bar") 52 | 53 | settings_from_env_path = tmp_path / "settings.yml" 54 | settings_from_env_path.write_text("defaults:\n foo: baz") 55 | 56 | monkeypatch.setenv("COPIER_SETTINGS_PATH", str(settings_from_env_path)) 57 | 58 | settings = Settings.from_file() 59 | 60 | assert settings.defaults == {"foo": "baz"} 61 | 62 | 63 | def test_settings_from_param( 64 | settings_path: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 65 | ) -> None: 66 | settings_path.write_text("defaults:\n foo: bar") 67 | 68 | settings_from_env_path = tmp_path / "settings.yml" 69 | settings_from_env_path.write_text("defaults:\n foo: baz") 70 | 71 | monkeypatch.setenv("COPIER_SETTINGS_PATH", str(settings_from_env_path)) 72 | 73 | file_path = tmp_path / "file.yml" 74 | file_path.write_text("defaults:\n from: file") 75 | 76 | settings = Settings.from_file(file_path) 77 | 78 | assert settings.defaults == {"from": "file"} 79 | 80 | 81 | def test_settings_defined_but_missing( 82 | settings_path: Path, monkeypatch: pytest.MonkeyPatch 83 | ) -> None: 84 | monkeypatch.setenv("COPIER_SETTINGS_PATH", str(settings_path)) 85 | 86 | with pytest.warns(MissingSettingsWarning): 87 | Settings.from_file() 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "encoding", 92 | ["utf-8", "utf-8-sig", "utf-16-le", "utf-16-be"], 93 | ) 94 | def test_settings_from_utf_file( 95 | settings_path: Path, monkeypatch: pytest.MonkeyPatch, encoding: str 96 | ) -> None: 97 | def _encode(data: str) -> bytes: 98 | if encoding.startswith("utf-16"): 99 | data = f"\ufeff{data}" 100 | return data.encode(encoding) 101 | 102 | defaults = { 103 | "foo": "\u3053\u3093\u306b\u3061\u306f", # japanese hiragana 104 | "bar": "\U0001f60e", # smiling face with sunglasses 105 | } 106 | 107 | settings_path.write_bytes( 108 | _encode(yaml.dump({"defaults": defaults}, allow_unicode=True)) 109 | ) 110 | 111 | with monkeypatch.context() as m: 112 | # Override the factor that determines the default encoding when opening files. 113 | m.setattr("io.text_encoding", lambda *_args: "cp932") 114 | settings = Settings.from_file() 115 | 116 | assert settings.defaults == defaults 117 | 118 | 119 | @pytest.mark.parametrize( 120 | ("repository", "trust", "is_trusted"), 121 | [ 122 | ("https://github.com/user/repo.git", set(), False), 123 | ( 124 | "https://github.com/user/repo.git", 125 | {"https://github.com/user/repo.git"}, 126 | True, 127 | ), 128 | ("https://github.com/user/repo", {"https://github.com/user/repo.git"}, False), 129 | ("https://github.com/user/repo.git", {"https://github.com/user/"}, True), 130 | ("https://github.com/user/repo.git", {"https://github.com/user/repo"}, False), 131 | ("https://github.com/user/repo.git", {"https://github.com/user"}, False), 132 | ("https://github.com/user/repo.git", {"https://github.com/"}, True), 133 | ("https://github.com/user/repo.git", {"https://github.com"}, False), 134 | (f"{Path.home()}/template", set(), False), 135 | (f"{Path.home()}/template", {f"{Path.home()}/template"}, True), 136 | (f"{Path.home()}/template", {"~/template"}, True), 137 | (f"{Path.home()}/path/to/template", {"~/path/to/template"}, True), 138 | (f"{Path.home()}/path/to/template", {"~/path/to/"}, True), 139 | (f"{Path.home()}/path/to/template", {"~/path/to"}, False), 140 | ], 141 | ) 142 | def test_is_trusted(repository: str, trust: set[str], is_trusted: bool) -> None: 143 | settings = Settings(trust=trust) 144 | 145 | assert settings.is_trusted(repository) == is_trusted 146 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "copier" 3 | dynamic = ["version"] 4 | description = "A library for rendering project templates." 5 | license = { text = "MIT" } 6 | requires-python = ">=3.10" 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Intended Audience :: Developers", 10 | "License :: OSI Approved :: MIT License", 11 | "Programming Language :: Python :: 3", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | "Programming Language :: Python :: 3.14", 17 | ] 18 | authors = [{ name = "Ben Felder", email = "ben@felder.io" }] 19 | readme = "README.md" 20 | dependencies = [ 21 | "colorama>=0.4.6", 22 | "dunamai>=1.7.0", 23 | "funcy>=1.17", 24 | "jinja2>=3.1.5", 25 | "jinja2-ansible-filters>=1.3.1", 26 | "packaging>=23.0", 27 | "pathspec>=0.9.0", 28 | "plumbum>=1.6.9", 29 | "pydantic>=2.4.2", 30 | "pygments>=2.7.1", 31 | "pyyaml>=5.3.1", 32 | "questionary>=1.8.1", 33 | "platformdirs>=4.3.6", 34 | "typing-extensions>=4.0.0,<5.0.0; python_version < '3.11'", 35 | ] 36 | 37 | [project.urls] 38 | homepage = "https://github.com/copier-org/copier" 39 | repository = "https://github.com/copier-org/copier" 40 | "Bug Tracker" = "https://github.com/copier-org/copier/issues" 41 | 42 | [project.scripts] 43 | copier = "copier.__main__:CopierApp.run" 44 | 45 | [dependency-groups] 46 | dev = [ 47 | "codespell[toml]==2.4.1", 48 | "commitizen==4.10.1", 49 | "mypy==1.18.2", 50 | "pexpect==4.9.0", 51 | "poethepoet==0.38.0", 52 | "pre-commit==4.5.1", 53 | "pytest==9.0.2", 54 | "pytest-cov==7.0.0", 55 | "pytest-gitconfig==0.8.0", 56 | "pytest-xdist==3.8.0", 57 | "ruff==0.14.10", 58 | "taplo==0.9.3", 59 | "types-backports==0.1.3", 60 | "types-colorama==0.4.15.20250801", 61 | "types-pygments==2.19.0.20251121", 62 | "types-pyyaml==6.0.12.20250915", 63 | "typing-extensions==4.15.0; python_version < '<3.10'", 64 | ] 65 | docs = [ 66 | "markdown-exec==1.12.1", 67 | "mkdocs-material==9.7.1", 68 | "mkdocstrings[python]==1.0.0", 69 | ] 70 | 71 | [tool.poe.tasks.coverage] 72 | cmd = "pytest --cov-report html --cov copier copier tests" 73 | help = "generate an HTML report of the coverage" 74 | 75 | [tool.poe.tasks.dev-setup] 76 | script = "devtasks:dev_setup" 77 | help = "set up local development environment" 78 | 79 | [tool.poe.tasks.docs] 80 | cmd = "mkdocs serve" 81 | help = "start local docs server" 82 | 83 | [tool.poe.tasks.lint] 84 | script = "devtasks:lint" 85 | help = "check (and auto-fix) style with pre-commit" 86 | 87 | [tool.poe.tasks.test] 88 | cmd = "pytest --color=yes" 89 | help = "run tests" 90 | 91 | [tool.poe.tasks.types] 92 | cmd = "mypy ." 93 | help = "run the type (mypy) checker on the codebase" 94 | 95 | [tool.ruff.lint] 96 | extend-select = [ 97 | "ARG", 98 | "B", 99 | "C90", 100 | "D", 101 | "E", 102 | "F", 103 | "FA", 104 | "I", 105 | "PERF", 106 | "PGH", 107 | "PTH", 108 | "UP", 109 | ] 110 | extend-ignore = ['B028', "B904", "D105", "D107", "E501"] 111 | 112 | [tool.ruff.lint.per-file-ignores] 113 | "tests/**" = ["D"] 114 | 115 | [tool.ruff.lint.isort] 116 | combine-as-imports = true 117 | known-first-party = ["copier"] 118 | 119 | [tool.ruff.lint.pydocstyle] 120 | convention = "google" 121 | 122 | [tool.ruff.lint.pyupgrade] 123 | keep-runtime-typing = true 124 | 125 | [tool.mypy] 126 | strict = true 127 | plugins = ["pydantic.mypy"] 128 | 129 | [[tool.mypy.overrides]] 130 | module = [ 131 | "coverage.tracer", 132 | "funcy", 133 | "pexpect.*", 134 | "plumbum.*", 135 | "poethepoet.app", 136 | ] 137 | ignore_missing_imports = true 138 | 139 | [[tool.mypy.overrides]] 140 | module = [ 141 | "copier._subproject", 142 | "copier._template", 143 | "copier._tools", 144 | "copier._user_data", 145 | "copier._vcs", 146 | "copier.subproject", 147 | "copier.template", 148 | "copier.tools", 149 | "copier.user_data", 150 | "copier.vcs", 151 | ] 152 | warn_return_any = false 153 | 154 | [[tool.mypy.overrides]] 155 | module = ["tests.test_cli", "tests.test_prompt"] 156 | disable_error_code = ["no-untyped-def"] 157 | 158 | [tool.pytest.ini_options] 159 | addopts = "-n auto -ra" 160 | markers = ["impure: needs network or is not 100% reproducible"] 161 | 162 | [tool.coverage.run] 163 | omit = [ 164 | # Ignore deprecated modules 165 | "copier/cli.py", 166 | "copier/jinja_ext.py", 167 | "copier/main.py", 168 | "copier/subproject.py", 169 | "copier/template.py", 170 | "copier/tools.py", 171 | "copier/types.py", 172 | "copier/user_data.py", 173 | "copier/vcs.py", 174 | ] 175 | 176 | [tool.commitizen] 177 | annotated_tag = true 178 | changelog_incremental = true 179 | tag_format = "v$version" 180 | update_changelog_on_bump = true 181 | version = "9.11.0" 182 | 183 | [tool.codespell] 184 | # Ref: https://github.com/codespell-project/codespell#using-a-config-file 185 | skip = '.git*,*.svg,*.lock,*.css,pyproject.toml,.mypy_cache,.venv' 186 | check-hidden = true 187 | ignore-regex = '\bla vie\b' 188 | ignore-words-list = 'ans' 189 | 190 | [tool.hatch.version] 191 | source = "vcs" 192 | 193 | [build-system] 194 | requires = ["hatchling", "hatch-vcs"] 195 | build-backend = "hatchling.build" 196 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import yaml 3 | 4 | import copier 5 | from copier._types import AnyByStrDict 6 | 7 | from .helpers import build_file_tree 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "subdir, settings", 12 | [ 13 | ( 14 | "", 15 | { 16 | "_exclude": ["includes"], 17 | }, 18 | ), 19 | ( 20 | "template", 21 | { 22 | "_subdirectory": "template", 23 | }, 24 | ), 25 | ], 26 | ) 27 | def test_include( 28 | tmp_path_factory: pytest.TempPathFactory, subdir: str, settings: AnyByStrDict 29 | ) -> None: 30 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 31 | build_file_tree( 32 | { 33 | (src / "copier.yml"): yaml.safe_dump( 34 | { 35 | **settings, 36 | "name": { 37 | "type": "str", 38 | "default": "The Name", 39 | }, 40 | "slug": { 41 | "type": "str", 42 | "default": "{% include 'includes/name-slug.jinja' %}", 43 | }, 44 | } 45 | ), 46 | (src / "includes" / "name-slug.jinja"): ( 47 | "{{ name|lower|replace(' ', '-') }}" 48 | ), 49 | # File for testing the Jinja include statement in `copier.yml`. 50 | (src / subdir / "slug-answer.txt.jinja"): "{{ slug }}", 51 | # File for testing the Jinja include statement as content. 52 | (src / subdir / "slug-from-include.txt.jinja"): ( 53 | "{% include 'includes/name-slug.jinja' %}" 54 | ), 55 | # File for testing the Jinja include statement in the file name. 56 | ( 57 | src 58 | / subdir 59 | / "{% include pathjoin('includes', 'name-slug.jinja') %}.txt" 60 | ): "", 61 | # File for testing the Jinja include statement in the folder name. 62 | ( 63 | src 64 | / subdir 65 | / "{% include pathjoin('includes', 'name-slug.jinja') %}" 66 | / "test.txt" 67 | ): "", 68 | } 69 | ) 70 | copier.run_copy(str(src), dst, defaults=True) 71 | assert (dst / "slug-answer.txt").read_text() == "the-name" 72 | assert (dst / "slug-from-include.txt").read_text() == "the-name" 73 | assert (dst / "the-name.txt").exists() 74 | assert (dst / "the-name" / "test.txt").exists() 75 | assert not (dst / "includes").exists() 76 | 77 | 78 | @pytest.mark.parametrize( 79 | "subdir, settings", 80 | [ 81 | ( 82 | "", 83 | { 84 | "_exclude": ["includes"], 85 | }, 86 | ), 87 | ( 88 | "template", 89 | { 90 | "_subdirectory": "template", 91 | }, 92 | ), 93 | ], 94 | ) 95 | def test_import_macro( 96 | tmp_path_factory: pytest.TempPathFactory, subdir: str, settings: AnyByStrDict 97 | ) -> None: 98 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 99 | build_file_tree( 100 | { 101 | (src / "copier.yml"): yaml.safe_dump( 102 | { 103 | **settings, 104 | "name": { 105 | "type": "str", 106 | "default": "The Name", 107 | }, 108 | "slug": { 109 | "type": "str", 110 | "default": ( 111 | "{% from 'includes/slugify.jinja' import slugify %}" 112 | "{{ slugify(name) }}" 113 | ), 114 | }, 115 | } 116 | ), 117 | (src / "includes" / "slugify.jinja"): ( 118 | """\ 119 | {% macro slugify(value) -%} 120 | {{ value|lower|replace(' ', '-') }} 121 | {%- endmacro %} 122 | """ 123 | ), 124 | # File for testing the Jinja import statement in `copier.yml`. 125 | (src / subdir / "slug-answer.txt.jinja"): "{{ slug }}", 126 | # File for testing the Jinja import statement as content. 127 | (src / subdir / "slug-from-macro.txt.jinja"): ( 128 | "{% from 'includes/slugify.jinja' import slugify %}{{ slugify(name) }}" 129 | ), 130 | # File for testing the Jinja import statement in the file name. 131 | ( 132 | src 133 | / subdir 134 | / "{% from pathjoin('includes', 'slugify.jinja') import slugify %}{{ slugify(name) }}.txt" 135 | ): "", 136 | # File for testing the Jinja import statement in the folder name. 137 | ( 138 | src 139 | / subdir 140 | / "{% from pathjoin('includes', 'slugify.jinja') import slugify %}{{ slugify(name) }}" 141 | / "test.txt" 142 | ): "", 143 | } 144 | ) 145 | copier.run_copy(str(src), dst, defaults=True) 146 | assert (dst / "slug-answer.txt").read_text() == "the-name" 147 | assert (dst / "slug-from-macro.txt").read_text() == "the-name" 148 | assert (dst / "the-name.txt").exists() 149 | assert (dst / "the-name" / "test.txt").exists() 150 | assert not (dst / "includes").exists() 151 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Literal 5 | 6 | import pytest 7 | import yaml 8 | 9 | import copier 10 | from copier._cli import CopierApp 11 | 12 | from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: 17 | root = tmp_path_factory.mktemp("demo_tasks") 18 | build_file_tree( 19 | { 20 | (root / "copier.yaml"): ( 21 | f"""\ 22 | _templates_suffix: {SUFFIX_TMPL} 23 | _envops: {BRACKET_ENVOPS_JSON} 24 | 25 | other_file: bye 26 | condition: true 27 | 28 | # This tests two things: 29 | # 1. That the tasks are being executed in the destination folder; and 30 | # 2. That the tasks are being executed in order, one after another 31 | _tasks: 32 | - mkdir hello 33 | - command: touch world 34 | working_directory: ./hello 35 | - touch [[ other_file ]] 36 | - ["[[ _copier_python ]]", "-c", "open('pyfile', 'w').close()"] 37 | - command: touch true 38 | when: "[[ condition ]]" 39 | - command: touch false 40 | when: "[[ not condition ]]" 41 | """ 42 | ) 43 | } 44 | ) 45 | return str(root) 46 | 47 | 48 | def test_render_tasks(template_path: str, tmp_path: Path) -> None: 49 | copier.run_copy( 50 | template_path, 51 | tmp_path, 52 | data={"other_file": "custom", "condition": "true"}, 53 | unsafe=True, 54 | ) 55 | assert (tmp_path / "custom").is_file() 56 | 57 | 58 | def test_copy_tasks(template_path: str, tmp_path: Path) -> None: 59 | copier.run_copy( 60 | template_path, tmp_path, quiet=True, defaults=True, overwrite=True, unsafe=True 61 | ) 62 | assert (tmp_path / "hello").exists() 63 | assert (tmp_path / "hello").is_dir() 64 | assert (tmp_path / "hello" / "world").exists() 65 | assert (tmp_path / "bye").is_file() 66 | assert (tmp_path / "pyfile").is_file() 67 | assert (tmp_path / "true").is_file() 68 | assert not (tmp_path / "false").exists() 69 | 70 | 71 | def test_copy_skip_tasks(template_path: str, tmp_path: Path) -> None: 72 | copier.run_copy( 73 | template_path, 74 | tmp_path, 75 | quiet=True, 76 | defaults=True, 77 | overwrite=True, 78 | skip_tasks=True, 79 | ) 80 | assert not (tmp_path / "hello").exists() 81 | assert not (tmp_path / "hello").is_dir() 82 | assert not (tmp_path / "hello" / "world").exists() 83 | assert not (tmp_path / "bye").is_file() 84 | assert not (tmp_path / "pyfile").is_file() 85 | 86 | 87 | @pytest.mark.parametrize("skip_tasks", [False, True]) 88 | def test_copy_cli_skip_tasks( 89 | tmp_path_factory: pytest.TempPathFactory, 90 | skip_tasks: bool, 91 | ) -> None: 92 | src, dst = map(tmp_path_factory.mktemp, ["src", "dst"]) 93 | build_file_tree( 94 | {(src / "copier.yaml"): yaml.safe_dump({"_tasks": ["touch task.txt"]})} 95 | ) 96 | _, retcode = CopierApp.run( 97 | [ 98 | "copier", 99 | "copy", 100 | "--UNSAFE", 101 | *(["--skip-tasks"] if skip_tasks else []), 102 | str(src), 103 | str(dst), 104 | ], 105 | exit=False, 106 | ) 107 | assert retcode == 0 108 | assert (dst / "task.txt").exists() is (not skip_tasks) 109 | 110 | 111 | def test_pretend_mode(tmp_path_factory: pytest.TempPathFactory) -> None: 112 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 113 | build_file_tree( 114 | { 115 | (src / "copier.yml"): ( 116 | """ 117 | _tasks: 118 | - touch created-by-task.txt 119 | """ 120 | ) 121 | } 122 | ) 123 | copier.run_copy(str(src), dst, pretend=True, unsafe=True) 124 | assert not (dst / "created-by-task.txt").exists() 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "os, filename", 129 | [ 130 | ("linux", "linux.txt"), 131 | ("macos", "macos.txt"), 132 | ("windows", "windows.txt"), 133 | (None, "unsupported.txt"), 134 | ], 135 | ) 136 | def test_os_specific_tasks( 137 | tmp_path_factory: pytest.TempPathFactory, 138 | monkeypatch: pytest.MonkeyPatch, 139 | os: Literal["linux", "macos", "windows"] | None, 140 | filename: str, 141 | ) -> None: 142 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 143 | build_file_tree( 144 | { 145 | (src / "copier.yml"): ( 146 | """\ 147 | _tasks: 148 | - >- 149 | {% if _copier_conf.os == 'linux' %} 150 | touch linux.txt 151 | {% elif _copier_conf.os == 'macos' %} 152 | touch macos.txt 153 | {% elif _copier_conf.os == 'windows' %} 154 | touch windows.txt 155 | {% elif _copier_conf.os is none %} 156 | touch unsupported.txt 157 | {% else %} 158 | touch never.txt 159 | {% endif %} 160 | """ 161 | ) 162 | } 163 | ) 164 | monkeypatch.setattr("copier._main.OS", os) 165 | copier.run_copy(str(src), dst, unsafe=True) 166 | assert (dst / filename).exists() 167 | 168 | 169 | def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None: 170 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 171 | build_file_tree( 172 | { 173 | (src / "copier.yml"): ( 174 | """ 175 | _tasks: 176 | - touch {{ _copier_phase }} 177 | """ 178 | ) 179 | } 180 | ) 181 | copier.run_copy(str(src), dst, unsafe=True) 182 | assert (dst / "tasks").exists() 183 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import filecmp 4 | import json 5 | import os 6 | import sys 7 | import textwrap 8 | from collections.abc import Mapping 9 | from enum import Enum 10 | from hashlib import sha1 11 | from pathlib import Path 12 | from typing import TYPE_CHECKING, Any, Protocol 13 | 14 | from pexpect.popen_spawn import PopenSpawn 15 | from plumbum import local 16 | from plumbum.cmd import git as _git 17 | from plumbum.machines import LocalCommand 18 | from prompt_toolkit.input.ansi_escape_sequences import REVERSE_ANSI_SEQUENCES 19 | from prompt_toolkit.keys import Keys 20 | from pytest_gitconfig.plugin import DEFAULT_GIT_USER_EMAIL, DEFAULT_GIT_USER_NAME 21 | 22 | import copier 23 | from copier._types import StrOrPath 24 | 25 | if TYPE_CHECKING: 26 | from pexpect.spawnbase import SpawnBase 27 | 28 | PROJECT_TEMPLATE = Path(__file__).parent / "demo" 29 | 30 | DATA = { 31 | "py3": True, 32 | "make_secret": lambda: sha1(os.urandom(48)).hexdigest(), 33 | "myvar": "awesome", 34 | "what": "world", 35 | "project_name": "Copier", 36 | "version": "2.0.0", 37 | "description": "A library for rendering projects templates", 38 | } 39 | 40 | COPIER_CMD = local.get( 41 | # Allow debugging in VSCode 42 | # HACK https://github.com/microsoft/vscode-python/issues/14222 43 | str(Path(sys.executable).parent / "copier.cmd"), 44 | str(Path(sys.executable).parent / "copier"), 45 | # uv installs the executable as copier.cmd in Windows 46 | "copier.cmd", 47 | "copier", 48 | ) 49 | 50 | # Executing copier this way allows to debug subprocesses using debugpy 51 | # See https://github.com/microsoft/debugpy/issues/596#issuecomment-824643237 52 | COPIER_PATH = (sys.executable, "-m", "copier") 53 | 54 | # Helpers to use with tests designed for old copier bracket envops defaults 55 | BRACKET_ENVOPS = { 56 | "autoescape": False, 57 | "block_end_string": "%]", 58 | "block_start_string": "[%", 59 | "comment_end_string": "#]", 60 | "comment_start_string": "[#", 61 | "keep_trailing_newline": True, 62 | "variable_end_string": "]]", 63 | "variable_start_string": "[[", 64 | } 65 | BRACKET_ENVOPS_JSON = json.dumps(BRACKET_ENVOPS) 66 | SUFFIX_TMPL = ".tmpl" 67 | 68 | COPIER_ANSWERS_FILE: Mapping[StrOrPath, str | bytes | Path] = { 69 | "{{ _copier_conf.answers_file }}.jinja": ("{{ _copier_answers|tojson }}") 70 | } 71 | 72 | 73 | class Spawn(Protocol): 74 | def __call__( 75 | self, cmd: tuple[str, ...], *, timeout: int | None = ... 76 | ) -> PopenSpawn: ... 77 | 78 | 79 | class Keyboard(str, Enum): 80 | ControlH = REVERSE_ANSI_SEQUENCES[Keys.ControlH] 81 | ControlI = REVERSE_ANSI_SEQUENCES[Keys.ControlI] 82 | ControlC = REVERSE_ANSI_SEQUENCES[Keys.ControlC] 83 | Enter = "\r" 84 | Esc = REVERSE_ANSI_SEQUENCES[Keys.Escape] 85 | 86 | Home = REVERSE_ANSI_SEQUENCES[Keys.Home] 87 | End = REVERSE_ANSI_SEQUENCES[Keys.End] 88 | 89 | Up = REVERSE_ANSI_SEQUENCES[Keys.Up] 90 | Down = REVERSE_ANSI_SEQUENCES[Keys.Down] 91 | Right = REVERSE_ANSI_SEQUENCES[Keys.Right] 92 | Left = REVERSE_ANSI_SEQUENCES[Keys.Left] 93 | 94 | # Equivalent keystrokes in terminals; see python-prompt-toolkit for 95 | # further explanations 96 | Alt = Esc 97 | Backspace = ControlH 98 | Tab = ControlI 99 | 100 | 101 | def render(tmp_path: Path, **kwargs: Any) -> None: 102 | kwargs.setdefault("quiet", True) 103 | copier.run_copy(str(PROJECT_TEMPLATE), tmp_path, data=DATA, **kwargs) 104 | 105 | 106 | def assert_file(tmp_path: Path, *path: str) -> None: 107 | p1 = tmp_path.joinpath(*path) 108 | p2 = PROJECT_TEMPLATE.joinpath(*path) 109 | assert filecmp.cmp(p1, p2) 110 | 111 | 112 | def build_file_tree( 113 | spec: Mapping[StrOrPath, str | bytes | Path], 114 | dedent: bool = True, 115 | encoding: str = "utf-8", 116 | ) -> None: 117 | """Builds a file tree based on the received spec. 118 | 119 | Params: 120 | spec: 121 | A mapping from filesystem paths to file contents. If the content is 122 | a Path object a symlink to the path will be created instead. 123 | 124 | dedent: Dedent file contents. 125 | """ 126 | for path, contents in spec.items(): 127 | path = Path(path) 128 | path.parent.mkdir(parents=True, exist_ok=True) 129 | if isinstance(contents, Path): 130 | path.symlink_to(contents) 131 | else: 132 | binary = isinstance(contents, bytes) 133 | if not binary and dedent: 134 | assert isinstance(contents, str) 135 | contents = textwrap.dedent(contents) 136 | mode = "wb" if binary else "w" 137 | enc = None if binary else encoding 138 | with Path(path).open(mode, encoding=enc) as fd: 139 | fd.write(contents) 140 | 141 | 142 | def expect_prompt( 143 | tui: SpawnBase, 144 | name: str, 145 | expected_type: str, 146 | help: str | None = None, 147 | ) -> None: 148 | """Check that we get a prompt in the standard form""" 149 | if help: 150 | tui.expect_exact(help) 151 | else: 152 | tui.expect_exact(name) 153 | if expected_type != "str": 154 | tui.expect_exact(f"({expected_type})") 155 | 156 | 157 | git: LocalCommand = _git.with_env( 158 | GIT_AUTHOR_NAME=DEFAULT_GIT_USER_NAME, 159 | GIT_AUTHOR_EMAIL=DEFAULT_GIT_USER_EMAIL, 160 | GIT_COMMITTER_NAME=DEFAULT_GIT_USER_NAME, 161 | GIT_COMMITTER_EMAIL=DEFAULT_GIT_USER_EMAIL, 162 | ) 163 | 164 | 165 | def git_save( 166 | dst: StrOrPath = ".", 167 | message: str = "Test commit", 168 | tag: str | None = None, 169 | allow_empty: bool = False, 170 | ) -> None: 171 | """Save the current repo state in git. 172 | 173 | Args: 174 | dst: Path to the repo to save. 175 | message: Commit message. 176 | tag: Tag to create, optionally. 177 | allow_empty: Allow creating a commit with no changes 178 | """ 179 | with local.cwd(dst): 180 | git("init") 181 | git("add", ".") 182 | git("commit", "-m", message, *(["--allow-empty"] if allow_empty else [])) 183 | if tag: 184 | git("tag", tag) 185 | 186 | 187 | def git_init(message: str = "hello world") -> None: 188 | """Initialize a Git repository with a first commit. 189 | 190 | Args: 191 | message: The first commit message. 192 | """ 193 | git("init") 194 | git("add", ".") 195 | git("commit", "-m", message) 196 | -------------------------------------------------------------------------------- /tests/test_recopy.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from textwrap import dedent 3 | 4 | import pytest 5 | from plumbum import local 6 | 7 | from copier import run_copy, run_recopy 8 | from copier._cli import CopierApp 9 | from copier._user_data import load_answersfile_data 10 | from copier._vcs import get_git 11 | 12 | from .helpers import build_file_tree, git_save 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def tpl(tmp_path_factory: pytest.TempPathFactory) -> str: 17 | """A simple template that supports updates.""" 18 | dst = tmp_path_factory.mktemp("tpl") 19 | with local.cwd(dst): 20 | build_file_tree( 21 | { 22 | "copier.yml": "your_name: Mario", 23 | "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", 24 | "name.txt.jinja": "This is your name: {{ your_name }}.", 25 | } 26 | ) 27 | git_save() 28 | return str(dst) 29 | 30 | 31 | def test_recopy_discards_evolution_api(tpl: str, tmp_path: Path) -> None: 32 | # First copy 33 | run_copy(tpl, tmp_path, data={"your_name": "Luigi"}, defaults=True, overwrite=True) 34 | git_save(tmp_path) 35 | name_path = tmp_path / "name.txt" 36 | assert name_path.read_text() == "This is your name: Luigi." 37 | # Evolve subproject 38 | name_path.write_text("This is your name: Luigi. Welcome.") 39 | git_save(tmp_path) 40 | # Recopy 41 | run_recopy(tmp_path, defaults=True, overwrite=True) 42 | assert name_path.read_text() == "This is your name: Luigi." 43 | 44 | 45 | def test_recopy_discards_evolution_cli(tpl: str, tmp_path: Path) -> None: 46 | # First copy 47 | run_copy(tpl, tmp_path, data={"your_name": "Peach"}, defaults=True, overwrite=True) 48 | git_save(tmp_path) 49 | name_path = tmp_path / "name.txt" 50 | assert name_path.read_text() == "This is your name: Peach." 51 | # Evolve subproject 52 | name_path.write_text("This is your name: Peach. Welcome.") 53 | git_save(tmp_path) 54 | # Recopy 55 | with local.cwd(tmp_path): 56 | _, retcode = CopierApp.run(["copier", "recopy", "-f"], exit=False) 57 | assert retcode == 0 58 | assert name_path.read_text() == "This is your name: Peach." 59 | 60 | 61 | def test_recopy_works_without_replay(tpl: str, tmp_path: Path) -> None: 62 | # First copy 63 | run_copy(tpl, tmp_path, defaults=True, overwrite=True) 64 | git_save(tmp_path) 65 | assert (tmp_path / "name.txt").read_text() == "This is your name: Mario." 66 | # Modify template altering git history 67 | Path(tpl, "name.txt.jinja").write_text("This is my name: {{ your_name }}.") 68 | tpl_git = get_git(tpl) 69 | tpl_git("commit", "-a", "--amend", "--no-edit") 70 | # Make sure old dangling commit is lost 71 | # DOCS https://stackoverflow.com/a/63209363/1468388 72 | tpl_git("reflog", "expire", "--expire=now", "--all") 73 | tpl_git("gc", "--prune=now", "--aggressive") 74 | # Recopy 75 | run_recopy(tmp_path, skip_answered=True, overwrite=True) 76 | assert (tmp_path / "name.txt").read_text() == "This is my name: Mario." 77 | 78 | 79 | def test_recopy_with_skip_answered_and_new_answer( 80 | tmp_path_factory: pytest.TempPathFactory, 81 | ) -> None: 82 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 83 | build_file_tree( 84 | { 85 | src / "copier.yml": "boolean: false", 86 | src / "{{ _copier_conf.answers_file }}.jinja": ( 87 | "{{ _copier_answers|to_nice_yaml }}" 88 | ), 89 | } 90 | ) 91 | git_save(src) 92 | # First copy 93 | run_copy(str(src), dst, defaults=True, overwrite=True) 94 | git_save(dst) 95 | answers = load_answersfile_data(dst) 96 | assert answers["boolean"] is False 97 | # Recopy with different answer and `skip_answered=True` 98 | run_recopy(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True) 99 | answers = load_answersfile_data(dst) 100 | assert answers["boolean"] is True 101 | 102 | 103 | def test_recopy_dont_validate_computed_value( 104 | tmp_path_factory: pytest.TempPathFactory, 105 | ) -> None: 106 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 107 | build_file_tree( 108 | { 109 | src / "copier.yml": dedent( 110 | """\ 111 | computed: 112 | type: str 113 | default: foo 114 | when: false 115 | validator: "This validator should never be rendered" 116 | """ 117 | ), 118 | src / "{{ _copier_conf.answers_file }}.jinja": ( 119 | "{{ _copier_answers|to_nice_yaml }}" 120 | ), 121 | } 122 | ) 123 | git_save(src) 124 | # First copy 125 | run_copy(str(src), dst, defaults=True, overwrite=True) 126 | git_save(dst) 127 | answers = load_answersfile_data(dst) 128 | assert "computed" not in answers 129 | # Recopy 130 | run_recopy(dst, overwrite=True) 131 | answers = load_answersfile_data(dst) 132 | assert "computed" not in answers 133 | 134 | 135 | def test_conditional_computed_value(tmp_path_factory: pytest.TempPathFactory) -> None: 136 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 137 | 138 | build_file_tree( 139 | { 140 | src / "copier.yml": ( 141 | """\ 142 | first: 143 | type: bool 144 | 145 | second: 146 | type: bool 147 | default: "{{ first }}" 148 | when: "{{ first }}" 149 | """ 150 | ), 151 | src / "{{ _copier_conf.answers_file }}.jinja": ( 152 | "{{ _copier_answers|to_nice_yaml }}" 153 | ), 154 | src / "log.txt.jinja": "{{ first }} {{ second }}", 155 | } 156 | ) 157 | git_save(src) 158 | 159 | run_copy(str(src), dst, data={"first": True}, defaults=True) 160 | answers = load_answersfile_data(dst) 161 | assert answers["first"] is True 162 | assert answers["second"] is True 163 | assert (dst / "log.txt").read_text() == "True True" 164 | 165 | git_save(dst, "v1") 166 | 167 | run_recopy(dst, data={"first": False}, overwrite=True) 168 | answers = load_answersfile_data(dst) 169 | assert answers["first"] is False 170 | assert "second" not in answers 171 | assert (dst / "log.txt").read_text() == "False False" 172 | 173 | git_save(dst, "v2") 174 | 175 | run_recopy(dst, data={"first": True}, defaults=True, overwrite=True) 176 | answers = load_answersfile_data(dst) 177 | assert answers["first"] is True 178 | assert answers["second"] is True 179 | assert (dst / "log.txt").read_text() == "True True" 180 | -------------------------------------------------------------------------------- /copier/errors.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions used by Copier.""" 2 | 3 | from __future__ import annotations 4 | 5 | import subprocess 6 | import sys 7 | from collections.abc import Sequence 8 | from pathlib import Path 9 | from subprocess import CompletedProcess 10 | from typing import TYPE_CHECKING 11 | 12 | from ._tools import printf_exception 13 | from ._types import PathSeq 14 | 15 | if TYPE_CHECKING: # always false 16 | from ._template import Template 17 | from ._user_data import AnswersMap, Question 18 | 19 | if sys.version_info < (3, 11): 20 | from typing_extensions import Self 21 | else: 22 | from typing import Self 23 | 24 | 25 | # Errors 26 | class CopierError(Exception): 27 | """Base class for all other Copier errors.""" 28 | 29 | 30 | class UserMessageError(CopierError): 31 | """Exit the program giving a message to the user.""" 32 | 33 | def __init__(self, message: str): 34 | self.message = message 35 | 36 | def __str__(self) -> str: 37 | return self.message 38 | 39 | 40 | class UnsupportedVersionError(UserMessageError): 41 | """Copier version does not support template version.""" 42 | 43 | 44 | class ConfigFileError(ValueError, CopierError): 45 | """Parent class defining problems with the config file.""" 46 | 47 | 48 | class InvalidConfigFileError(ConfigFileError): 49 | """Indicates that the config file is wrong.""" 50 | 51 | def __init__(self, conf_path: Path, quiet: bool): 52 | msg = str(conf_path) 53 | printf_exception(self, "INVALID CONFIG FILE", msg=msg, quiet=quiet) 54 | super().__init__(msg) 55 | 56 | 57 | class MultipleConfigFilesError(ConfigFileError): 58 | """Both copier.yml and copier.yaml found, and that's an error.""" 59 | 60 | def __init__(self, conf_paths: PathSeq): 61 | msg = str(conf_paths) 62 | printf_exception(self, "MULTIPLE CONFIG FILES", msg=msg) 63 | super().__init__(msg) 64 | 65 | 66 | class InvalidTypeError(TypeError, CopierError): 67 | """The question type is not among the supported ones.""" 68 | 69 | 70 | class PathError(CopierError, ValueError): 71 | """The path is invalid in the given context.""" 72 | 73 | 74 | class PathNotAbsoluteError(PathError): 75 | """The path is not absolute, but it should be.""" 76 | 77 | def __init__(self, *, path: Path) -> None: 78 | super().__init__(f'"{path}" is not an absolute path') 79 | 80 | 81 | class PathNotRelativeError(PathError): 82 | """The path is not relative, but it should be.""" 83 | 84 | def __init__(self, *, path: Path) -> None: 85 | super().__init__(f'"{path}" is not a relative path') 86 | 87 | 88 | class ForbiddenPathError(PathError): 89 | """The path is forbidden in the given context.""" 90 | 91 | def __init__(self, *, path: Path) -> None: 92 | super().__init__(f'"{path}" is forbidden') 93 | 94 | 95 | class ExtensionNotFoundError(UserMessageError): 96 | """Extensions listed in the configuration could not be loaded.""" 97 | 98 | 99 | class CopierAnswersInterrupt(CopierError, KeyboardInterrupt): 100 | """CopierAnswersInterrupt is raised during interactive question prompts. 101 | 102 | It typically follows a KeyboardInterrupt (i.e. ctrl-c) and provides an 103 | opportunity for the caller to conduct additional cleanup, such as writing 104 | the partially completed answers to a file. 105 | 106 | Attributes: 107 | answers: 108 | AnswersMap that contains the partially completed answers object. 109 | 110 | last_question: 111 | Question representing the last_question that was asked at the time 112 | the interrupt was raised. 113 | 114 | template: 115 | Template that was being processed for answers. 116 | 117 | """ 118 | 119 | def __init__( 120 | self, answers: AnswersMap, last_question: Question, template: Template 121 | ) -> None: 122 | self.answers = answers 123 | self.last_question = last_question 124 | self.template = template 125 | 126 | 127 | class UnsafeTemplateError(CopierError): 128 | """Unsafe Copier template features are used without explicit consent.""" 129 | 130 | def __init__(self, features: Sequence[str]): 131 | assert features 132 | s = "s" if len(features) > 1 else "" 133 | super().__init__( 134 | f"Template uses potentially unsafe feature{s}: {', '.join(features)}.\n" 135 | "If you trust this template, consider adding the `--trust` option when running `copier copy/update`." 136 | ) 137 | 138 | 139 | class YieldTagInFileError(CopierError): 140 | """A yield tag is used in the file content, but it is not allowed.""" 141 | 142 | 143 | class MultipleYieldTagsError(CopierError): 144 | """Multiple yield tags are used in one path name, but it is not allowed.""" 145 | 146 | 147 | class TaskError(subprocess.CalledProcessError, UserMessageError): 148 | """Exception raised when a task fails.""" 149 | 150 | def __init__( 151 | self, 152 | command: str | Sequence[str], 153 | returncode: int, 154 | stdout: str | bytes | None, 155 | stderr: str | bytes | None, 156 | ): 157 | subprocess.CalledProcessError.__init__( 158 | self, returncode=returncode, cmd=command, output=stdout, stderr=stderr 159 | ) 160 | message = f"Task {command!r} returned non-zero exit status {returncode}." 161 | UserMessageError.__init__(self, message) 162 | 163 | @classmethod 164 | def from_process( 165 | cls, process: CompletedProcess[str] | CompletedProcess[bytes] 166 | ) -> Self: 167 | """Create a TaskError from a CompletedProcess.""" 168 | return cls( 169 | command=process.args, 170 | returncode=process.returncode, 171 | stdout=process.stdout, 172 | stderr=process.stderr, 173 | ) 174 | 175 | 176 | # Warnings 177 | class CopierWarning(Warning): 178 | """Base class for all other Copier warnings.""" 179 | 180 | 181 | class UnknownCopierVersionWarning(UserWarning, CopierWarning): 182 | """Cannot determine installed Copier version.""" 183 | 184 | 185 | class OldTemplateWarning(UserWarning, CopierWarning): 186 | """Template was designed for an older Copier version.""" 187 | 188 | 189 | class DirtyLocalWarning(UserWarning, CopierWarning): 190 | """Changes and untracked files present in template.""" 191 | 192 | 193 | class ShallowCloneWarning(UserWarning, CopierWarning): 194 | """The template repository is a shallow clone.""" 195 | 196 | 197 | class MissingSettingsWarning(UserWarning, CopierWarning): 198 | """Settings path has been defined but file is missing.""" 199 | 200 | 201 | class MissingFileWarning(UserWarning, CopierWarning): 202 | """I still couldn't find what I'm looking for.""" 203 | 204 | 205 | class InteractiveSessionError(UserMessageError): 206 | """An interactive session is required to run this program.""" 207 | 208 | def __init__(self, message: str) -> None: 209 | super().__init__(f"Interactive session required: {message}") 210 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from pathlib import Path 4 | from uuid import uuid4 5 | 6 | import pytest 7 | from plumbum import local 8 | 9 | import copier 10 | 11 | from .helpers import build_file_tree, git_save 12 | 13 | 14 | def test_no_path_variables( 15 | tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch 16 | ) -> None: 17 | """Test that there are no context variables of type `pathlib.Path`.""" 18 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 19 | ext_module_name = uuid4() 20 | build_file_tree( 21 | { 22 | src / f"{ext_module_name}.py": ( 23 | """\ 24 | from pathlib import Path 25 | from typing import Any, Mapping 26 | 27 | from jinja2 import Environment, pass_context 28 | from jinja2.ext import Extension 29 | from jinja2.runtime import Context 30 | from pydantic import BaseModel 31 | 32 | 33 | class ContextExtension(Extension): 34 | def __init__(self, environment: Environment) -> None: 35 | super().__init__(environment) 36 | environment.globals["__assert"] = self._assert 37 | 38 | @pass_context 39 | def _assert(self, ctx: Context) -> None: 40 | items: list[tuple[str, Any]] = list(dict(ctx).items()) 41 | for k, v in items: 42 | if isinstance(v, Path): 43 | raise AssertionError( 44 | f"{k} must not be a `pathlib.Path` object" 45 | ) 46 | if isinstance(v, BaseModel): 47 | v = dict(v) 48 | if isinstance(v, Mapping): 49 | items.extend((f"{k}.{k2}", v2) for k2, v2 in v.items()) 50 | elif isinstance(v, (list, tuple, set)): 51 | items.extend((f"{k}[{i}]", v2) for i, v2 in enumerate(v)) 52 | """ 53 | ), 54 | src / "copier.yml": ( 55 | f"""\ 56 | _jinja_extensions: 57 | - {ext_module_name}.ContextExtension 58 | """ 59 | ), 60 | src / "test.txt.jinja": "{{ __assert() | default('', true) }}", 61 | } 62 | ) 63 | monkeypatch.setattr("sys.path", [str(src), *sys.path]) 64 | copier.run_copy(str(src), dst, unsafe=True) 65 | assert (dst / "test.txt").read_text("utf-8") == "" 66 | 67 | 68 | def test_exclude_templating_with_operation( 69 | tmp_path_factory: pytest.TempPathFactory, 70 | ) -> None: 71 | """ 72 | Ensure it's possible to create one-off boilerplate files that are not 73 | managed during updates via `_exclude` using the `_copier_operation` context variable. 74 | """ 75 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 76 | 77 | template = "{% if _copier_operation == 'update' %}copy-only{% endif %}" 78 | with local.cwd(src): 79 | build_file_tree( 80 | { 81 | "copier.yml": f'_exclude:\n - "{template}"', 82 | "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}", 83 | "copy-only": "foo", 84 | "copy-and-update": "foo", 85 | } 86 | ) 87 | git_save(tag="1.0.0") 88 | build_file_tree( 89 | { 90 | "copy-only": "bar", 91 | "copy-and-update": "bar", 92 | } 93 | ) 94 | git_save(tag="2.0.0") 95 | copy_only = dst / "copy-only" 96 | copy_and_update = dst / "copy-and-update" 97 | 98 | copier.run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="1.0.0") 99 | for file in (copy_only, copy_and_update): 100 | assert file.exists() 101 | assert file.read_text() == "foo" 102 | 103 | with local.cwd(dst): 104 | git_save() 105 | 106 | copier.run_update(str(dst), overwrite=True) 107 | assert copy_only.read_text() == "foo" 108 | assert copy_and_update.read_text() == "bar" 109 | 110 | 111 | def test_exclude_templating_with_operation_added_in_new_version( 112 | tmp_path_factory: pytest.TempPathFactory, 113 | ) -> None: 114 | """ 115 | Ensure it's possible to create one-off boilerplate files that are not 116 | managed during updates via `_exclude` using the `_copier_operation` context variable 117 | when the `_exclude` item is added in the new template version. 118 | """ 119 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 120 | 121 | template = "{% if _copier_operation == 'update' %}copy-only{% endif %}" 122 | with local.cwd(src): 123 | build_file_tree( 124 | { 125 | "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}", 126 | "copy-only": "foo", 127 | "copy-and-update": "foo", 128 | } 129 | ) 130 | git_save(tag="1.0.0") 131 | build_file_tree( 132 | { 133 | "copier.yml": f'_exclude:\n - "{template}"', 134 | "copy-only": "bar", 135 | "copy-and-update": "bar", 136 | } 137 | ) 138 | git_save(tag="2.0.0") 139 | copy_only = dst / "copy-only" 140 | copy_and_update = dst / "copy-and-update" 141 | 142 | copier.run_copy(str(src), dst, defaults=True, overwrite=True, vcs_ref="1.0.0") 143 | for file in (copy_only, copy_and_update): 144 | assert file.exists() 145 | assert file.read_text() == "foo" 146 | 147 | with local.cwd(dst): 148 | git_save() 149 | 150 | copier.run_update(str(dst), overwrite=True) 151 | assert copy_only.read_text() == "foo" 152 | assert copy_and_update.read_text() == "bar" 153 | 154 | 155 | def test_task_templating_with_operation( 156 | tmp_path_factory: pytest.TempPathFactory, tmp_path: Path 157 | ) -> None: 158 | """ 159 | Ensure that it is possible to define tasks that are only executed when copying. 160 | """ 161 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 162 | # Use a file outside the Copier working directories to ensure accurate tracking 163 | task_counter = tmp_path / "task_calls.txt" 164 | with local.cwd(src): 165 | build_file_tree( 166 | { 167 | "copier.yml": ( 168 | f"""\ 169 | _tasks: 170 | - command: echo {{{{ _copier_operation }}}} >> {json.dumps(str(task_counter))} 171 | when: "{{{{ _copier_operation == 'copy' }}}}" 172 | """ 173 | ), 174 | "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}", 175 | } 176 | ) 177 | git_save(tag="1.0.0") 178 | 179 | copier.run_copy(str(src), dst, defaults=True, overwrite=True, unsafe=True) 180 | assert task_counter.exists() 181 | assert len(task_counter.read_text().splitlines()) == 1 182 | 183 | with local.cwd(dst): 184 | git_save() 185 | 186 | copier.run_recopy(dst, defaults=True, overwrite=True, unsafe=True) 187 | assert len(task_counter.read_text().splitlines()) == 2 188 | 189 | copier.run_update(dst, defaults=True, overwrite=True, unsafe=True) 190 | assert len(task_counter.read_text().splitlines()) == 2 191 | -------------------------------------------------------------------------------- /copier/_vcs.py: -------------------------------------------------------------------------------- 1 | """Utilities related to VCS.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | import sys 7 | from contextlib import suppress 8 | from pathlib import Path 9 | from tempfile import TemporaryDirectory, mkdtemp 10 | from warnings import warn 11 | 12 | from packaging import version 13 | from packaging.version import InvalidVersion, Version 14 | from plumbum import TF, ProcessExecutionError, colors, local 15 | from plumbum.machines import LocalCommand 16 | 17 | from ._types import OptBool, OptStrOrPath, StrOrPath 18 | from .errors import DirtyLocalWarning, ShallowCloneWarning 19 | 20 | GIT_USER_NAME = "Copier" 21 | GIT_USER_EMAIL = "copier@copier" 22 | 23 | 24 | def get_git(context_dir: OptStrOrPath = None) -> LocalCommand: 25 | """Gets `git` command, or fails if it's not available.""" 26 | command = local["git"].with_env( 27 | GIT_AUTHOR_NAME=GIT_USER_NAME, 28 | GIT_AUTHOR_EMAIL=GIT_USER_EMAIL, 29 | GIT_COMMITTER_NAME=GIT_USER_NAME, 30 | GIT_COMMITTER_EMAIL=GIT_USER_EMAIL, 31 | ) 32 | if context_dir: 33 | command = command["-C", context_dir] 34 | return command 35 | 36 | 37 | def get_git_version() -> Version: 38 | """Get the installed git version.""" 39 | git = get_git() 40 | 41 | return Version(re.findall(r"\d+\.\d+\.\d+", git("version"))[0]) 42 | 43 | 44 | GIT_PREFIX = ("git@", "git://", "git+", "https://github.com/", "https://gitlab.com/") 45 | GIT_POSTFIX = ".git" 46 | REPLACEMENTS = ( 47 | (re.compile(r"^gh:/?(.*\.git)$"), r"https://github.com/\1"), 48 | (re.compile(r"^gh:/?(.*)$"), r"https://github.com/\1.git"), 49 | (re.compile(r"^gl:/?(.*\.git)$"), r"https://gitlab.com/\1"), 50 | (re.compile(r"^gl:/?(.*)$"), r"https://gitlab.com/\1.git"), 51 | ) 52 | 53 | 54 | def is_git_repo_root(path: StrOrPath) -> bool: 55 | """Indicate if a given path is a git repo root directory.""" 56 | try: 57 | with local.cwd(Path(path, ".git")): 58 | return get_git()("rev-parse", "--is-inside-git-dir").strip() == "true" 59 | except OSError: 60 | return False 61 | 62 | 63 | def is_in_git_repo(path: StrOrPath) -> bool: 64 | """Indicate if a given path is in a git repo directory.""" 65 | try: 66 | get_git()("-C", path, "rev-parse", "--show-toplevel") 67 | return True 68 | except (OSError, ProcessExecutionError): 69 | return False 70 | 71 | 72 | def is_git_shallow_repo(path: StrOrPath) -> bool: 73 | """Indicate if a given path is a git shallow repo directory.""" 74 | try: 75 | return ( 76 | get_git()("-C", path, "rev-parse", "--is-shallow-repository").strip() 77 | == "true" 78 | ) 79 | except (OSError, ProcessExecutionError): 80 | return False 81 | 82 | 83 | def is_git_bundle(path: Path) -> bool: 84 | """Indicate if a path is a valid git bundle.""" 85 | with suppress(OSError): 86 | path = path.resolve() 87 | with TemporaryDirectory(prefix=f"{__name__}.is_git_bundle.") as dirname: 88 | with local.cwd(dirname): 89 | get_git()("init") 90 | return bool(get_git()["bundle", "verify", path] & TF) 91 | 92 | 93 | def get_repo(url: str) -> str | None: 94 | """Transform `url` into a git-parseable origin URL. 95 | 96 | Args: 97 | url: 98 | Valid examples: 99 | 100 | - gh:copier-org/copier 101 | - gl:copier-org/copier 102 | - git@github.com:copier-org/copier.git 103 | - git+https://mywebsiteisagitrepo.example.com/ 104 | - /local/path/to/git/repo 105 | - /local/path/to/git/bundle/file.bundle 106 | - ~/path/to/git/repo 107 | - ~/path/to/git/repo.bundle 108 | """ 109 | for pattern, replacement in REPLACEMENTS: 110 | url = re.sub(pattern, replacement, url) 111 | 112 | if url.endswith(GIT_POSTFIX) or url.startswith(GIT_PREFIX): 113 | if url.startswith("git+"): 114 | return url[4:] 115 | if url.startswith("https://") and not url.endswith(GIT_POSTFIX): 116 | return "".join((url, GIT_POSTFIX)) 117 | return url 118 | 119 | url_path = Path(url) 120 | if url.startswith("~"): 121 | url_path = url_path.expanduser() 122 | 123 | if is_git_repo_root(url_path) or is_git_bundle(url_path): 124 | return url_path.as_posix() 125 | 126 | return None 127 | 128 | 129 | def checkout_latest_tag(local_repo: StrOrPath, use_prereleases: OptBool = False) -> str: 130 | """Checkout latest git tag and check it out, sorted by PEP 440. 131 | 132 | Parameters: 133 | local_repo: 134 | A git repository in the local filesystem. 135 | use_prereleases: 136 | If `False`, skip prerelease git tags. 137 | """ 138 | git = get_git() 139 | with local.cwd(local_repo): 140 | all_tags = filter(valid_version, git("tag").split()) 141 | if not use_prereleases: 142 | all_tags = filter( 143 | lambda tag: not version.parse(tag).is_prerelease, all_tags 144 | ) 145 | sorted_tags = sorted(all_tags, key=version.parse, reverse=True) 146 | try: 147 | latest_tag = str(sorted_tags[0]) 148 | except IndexError: 149 | print( 150 | colors.warn | "No git tags found in template; using HEAD as ref", 151 | file=sys.stderr, 152 | ) 153 | latest_tag = "HEAD" 154 | git("checkout", "--force", latest_tag) 155 | git("submodule", "update", "--checkout", "--init", "--recursive", "--force") 156 | return latest_tag 157 | 158 | 159 | def clone(url: str, ref: str | None = None) -> str: 160 | """Clone repo into some temporary destination. 161 | 162 | Includes dirty changes for local templates by copying into a temp 163 | directory and applying a wip commit there. 164 | 165 | Args: 166 | url: 167 | Git-parseable URL of the repo. As returned by 168 | [get_repo][copier.vcs.get_repo]. 169 | ref: 170 | Reference to checkout. For Git repos, defaults to `HEAD`. 171 | """ 172 | git = get_git() 173 | git_version = get_git_version() 174 | location = mkdtemp(prefix=f"{__name__}.clone.") 175 | _clone = git["clone", "--no-checkout", url, location] 176 | # Faster clones if possible 177 | if git_version >= Version("2.27"): 178 | if url_match := re.match("(file://)?(.*)", url): 179 | file_url = url_match.groups()[-1] 180 | else: 181 | file_url = url 182 | if is_git_shallow_repo(file_url): 183 | warn( 184 | f"The repository '{url}' is a shallow clone, this might lead to unexpected " 185 | "failure or unusually high resource consumption.", 186 | ShallowCloneWarning, 187 | ) 188 | else: 189 | _clone = _clone["--filter=blob:none"] 190 | _clone() 191 | # Include dirty changes if checking out a local HEAD 192 | url_abspath = Path(url).absolute() 193 | if ref in {None, "HEAD"} and url_abspath.is_dir(): 194 | is_dirty = False 195 | with local.cwd(url): 196 | is_dirty = bool(git("status", "--porcelain").strip()) 197 | if is_dirty: 198 | with local.cwd(location): 199 | git("--git-dir=.git", f"--work-tree={url_abspath}", "add", "-A") 200 | git( 201 | "--git-dir=.git", 202 | f"--work-tree={url_abspath}", 203 | "commit", 204 | "-m", 205 | "Copier automated commit for draft changes", 206 | "--no-verify", 207 | "--no-gpg-sign", 208 | ) 209 | warn( 210 | "Dirty template changes included automatically.", 211 | DirtyLocalWarning, 212 | ) 213 | 214 | with local.cwd(location): 215 | ## The `git checkout -f ` command doesn't works when repo is local, dirty and core.fsmonitor is enabled 216 | ## ref: https://github.com/copier-org/copier/issues/1887 217 | git("-c", "core.fsmonitor=false", "checkout", "-f", ref or "HEAD") 218 | git("submodule", "update", "--checkout", "--init", "--recursive", "--force") 219 | 220 | return location 221 | 222 | 223 | def valid_version(version_: str) -> bool: 224 | """Tell if a string is a valid [PEP 440][] version specifier. 225 | 226 | [PEP 440]: https://peps.python.org/pep-0440/ 227 | """ 228 | try: 229 | version.parse(version_) 230 | except InvalidVersion: 231 | return False 232 | return True 233 | -------------------------------------------------------------------------------- /tests/test_dynamic_file_structures.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | 5 | import copier 6 | from copier.errors import MultipleYieldTagsError, YieldTagInFileError 7 | from tests.helpers import build_file_tree 8 | 9 | 10 | def test_folder_loop(tmp_path_factory: pytest.TempPathFactory) -> None: 11 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 12 | build_file_tree( 13 | { 14 | src / "copier.yml": "", 15 | src 16 | / "folder_loop" 17 | / "{% yield item from strings %}{{ item }}{% endyield %}" 18 | / "{{ item }}.txt.jinja": "Hello {{ item }}", 19 | } 20 | ) 21 | with warnings.catch_warnings(): 22 | warnings.simplefilter("error") 23 | copier.run_copy( 24 | str(src), 25 | dst, 26 | data={ 27 | "strings": ["a", "b", "c"], 28 | }, 29 | defaults=True, 30 | overwrite=True, 31 | ) 32 | 33 | expected_files = [dst / f"folder_loop/{i}/{i}.txt" for i in ["a", "b", "c"]] 34 | 35 | for f in expected_files: 36 | assert f.exists() 37 | assert f.read_text() == f"Hello {f.parent.name}" 38 | 39 | all_files = [p for p in dst.rglob("*") if p.is_file()] 40 | unexpected_files = set(all_files) - set(expected_files) 41 | 42 | assert not unexpected_files, f"Unexpected files found: {unexpected_files}" 43 | 44 | 45 | def test_nested_folder_loop(tmp_path_factory: pytest.TempPathFactory) -> None: 46 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 47 | build_file_tree( 48 | { 49 | src / "copier.yml": "", 50 | src 51 | / "nested_folder_loop" 52 | / "{% yield string_item from strings %}{{ string_item }}{% endyield %}" 53 | / "{% yield integer_item from integers %}{{ integer_item }}{% endyield %}" 54 | / "{{ string_item }}_{{ integer_item }}.txt.jinja": "Hello {{ string_item }} {{ integer_item }}", 55 | } 56 | ) 57 | with warnings.catch_warnings(): 58 | warnings.simplefilter("error") 59 | copier.run_copy( 60 | str(src), 61 | dst, 62 | data={ 63 | "strings": ["a", "b"], 64 | "integers": [1, 2, 3], 65 | }, 66 | defaults=True, 67 | overwrite=True, 68 | ) 69 | 70 | expected_files = [ 71 | dst / f"nested_folder_loop/{s}/{i}/{s}_{i}.txt" 72 | for s in ["a", "b"] 73 | for i in [1, 2, 3] 74 | ] 75 | 76 | for f in expected_files: 77 | assert f.exists() 78 | assert f.read_text() == f"Hello {f.parent.parent.name} {f.parent.name}" 79 | 80 | all_files = [p for p in dst.rglob("*") if p.is_file()] 81 | unexpected_files = set(all_files) - set(expected_files) 82 | 83 | assert not unexpected_files, f"Unexpected files found: {unexpected_files}" 84 | 85 | 86 | def test_file_loop(tmp_path_factory: pytest.TempPathFactory) -> None: 87 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 88 | build_file_tree( 89 | { 90 | src / "copier.yml": "", 91 | src 92 | / "file_loop" 93 | / "{% yield string_item from strings %}{{ string_item }}{% endyield %}.jinja": "Hello {{ string_item }}", 94 | } 95 | ) 96 | with warnings.catch_warnings(): 97 | warnings.simplefilter("error") 98 | copier.run_copy( 99 | str(src), 100 | dst, 101 | data={ 102 | "strings": [ 103 | "a.txt", 104 | "b.txt", 105 | "c.txt", 106 | "", 107 | ], # if rendred as '.jinja', it will not be created 108 | }, 109 | defaults=True, 110 | overwrite=True, 111 | ) 112 | 113 | expected_files = [dst / f"file_loop/{i}.txt" for i in ["a", "b", "c"]] 114 | for f in expected_files: 115 | assert f.exists() 116 | assert f.read_text() == f"Hello {f.stem}.txt" 117 | 118 | all_files = [p for p in dst.rglob("*") if p.is_file()] 119 | unexpected_files = set(all_files) - set(expected_files) 120 | 121 | assert not unexpected_files, f"Unexpected files found: {unexpected_files}" 122 | 123 | 124 | def test_folder_loop_dict_items(tmp_path_factory: pytest.TempPathFactory) -> None: 125 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 126 | build_file_tree( 127 | { 128 | src / "copier.yml": "", 129 | src 130 | / "folder_loop_dict_items" 131 | / "{% yield dict_item from dicts %}{{ dict_item.folder_name }}{% endyield %}" 132 | / "{{ dict_item.file_name }}.txt.jinja": "Hello {{ '-'.join(dict_item.content) }}", 133 | } 134 | ) 135 | 136 | dicts = [ 137 | { 138 | "folder_name": "folder_a", 139 | "file_name": "file_a", 140 | "content": ["folder_a", "file_a"], 141 | }, 142 | { 143 | "folder_name": "folder_b", 144 | "file_name": "file_b", 145 | "content": ["folder_b", "file_b"], 146 | }, 147 | { 148 | "folder_name": "folder_c", 149 | "file_name": "file_c", 150 | "content": ["folder_c", "file_c"], 151 | }, 152 | ] 153 | 154 | with warnings.catch_warnings(): 155 | warnings.simplefilter("error") 156 | 157 | copier.run_copy( 158 | str(src), 159 | dst, 160 | data={"dicts": dicts}, 161 | defaults=True, 162 | overwrite=True, 163 | ) 164 | 165 | expected_files = [ 166 | dst / f"folder_loop_dict_items/{d['folder_name']}/{d['file_name']}.txt" 167 | for d in [ 168 | {"folder_name": "folder_a", "file_name": "file_a"}, 169 | {"folder_name": "folder_b", "file_name": "file_b"}, 170 | {"folder_name": "folder_c", "file_name": "file_c"}, 171 | ] 172 | ] 173 | 174 | for f in expected_files: 175 | assert f.exists() 176 | assert f.read_text() == f"Hello {'-'.join([f.parts[-2], f.stem])}" 177 | 178 | all_files = [p for p in dst.rglob("*") if p.is_file()] 179 | unexpected_files = set(all_files) - set(expected_files) 180 | 181 | assert not unexpected_files, f"Unexpected files found: {unexpected_files}" 182 | 183 | 184 | def test_raise_yield_tag_in_file(tmp_path_factory: pytest.TempPathFactory) -> None: 185 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 186 | build_file_tree( 187 | { 188 | src / "copier.yml": "", 189 | src 190 | / "file.txt.jinja": "{% yield item from strings %}{{ item }}{% endyield %}", 191 | } 192 | ) 193 | 194 | with pytest.raises(YieldTagInFileError, match="file.txt.jinja"): 195 | copier.run_copy( 196 | str(src), 197 | dst, 198 | data={ 199 | "strings": ["a", "b", "c"], 200 | }, 201 | defaults=True, 202 | overwrite=True, 203 | ) 204 | 205 | 206 | def test_raise_multiple_yield_tags(tmp_path_factory: pytest.TempPathFactory) -> None: 207 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 208 | # multiple yield tags, not nested 209 | file_name = "{% yield item1 from strings %}{{ item1 }}{% endyield %}{% yield item2 from strings %}{{ item2 }}{% endyield %}" 210 | 211 | build_file_tree( 212 | { 213 | src / "copier.yml": "", 214 | src / file_name: "", 215 | } 216 | ) 217 | 218 | with pytest.raises(MultipleYieldTagsError, match="item"): 219 | copier.run_copy( 220 | str(src), 221 | dst, 222 | data={ 223 | "strings": ["a", "b", "c"], 224 | }, 225 | defaults=True, 226 | overwrite=True, 227 | ) 228 | 229 | src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) 230 | # multiple yield tags, nested 231 | file_name = "{% yield item1 from strings %}{% yield item2 from strings %}{{ item1 }}{{ item2 }}{% endyield %}{% endyield %}" 232 | 233 | build_file_tree( 234 | { 235 | src / "copier.yml": "", 236 | src / file_name: "", 237 | } 238 | ) 239 | 240 | with pytest.raises(MultipleYieldTagsError, match="item"): 241 | copier.run_copy( 242 | str(src), 243 | dst, 244 | data={ 245 | "strings": ["a", "b", "c"], 246 | }, 247 | defaults=True, 248 | overwrite=True, 249 | ) 250 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Can Copier be applied over a preexisting project? 4 | 5 | Yes, of course. Copier understands this use case out of the box. That's actually what 6 | powers features such as [updating](updating.md) or the ability of [applying multiple 7 | templates to the same subproject][applying-multiple-templates-to-the-same-subproject]. 8 | 9 | !!! example 10 | 11 | ```shell 12 | copier copy https://github.com/me/my-template.git ./my-preexisting-git-project 13 | ``` 14 | 15 | ## How to use Copier from Docker or Podman? 16 | 17 | Copier doesn't provide an image by default. However, it does provide a nix package, so 18 | you can use Nix to run Copier reproducibly from within a container: 19 | 20 | ```shell 21 | # Change for docker if needed 22 | engine=podman 23 | 24 | # You can pin the version; example: github:copier-org/copier/v8.0.0 25 | copier=github:copier-org/copier 26 | 27 | $engine container run --rm -it docker.io/nixos/nix \ 28 | nix --extra-experimental-features 'nix-command flakes' --accept-flake-config \ 29 | run $copier -- --help 30 | ``` 31 | 32 | You can even generate a reproducible minimal docker image with just Copier inside, with: 33 | 34 | ```shell 35 | nix bundle --bundler github:NixOS/bundlers#toDockerImage \ 36 | github:copier-org/copier#packages.x86_64-linux.default 37 | docker load < python*copier*.tar.gz 38 | ``` 39 | 40 | ## How to create computed values? 41 | 42 | Combine `default` and `when: false`. 43 | 44 | !!! example 45 | 46 | ```yaml title="copier.yaml" 47 | copyright_year: 48 | type: int 49 | default: 2024 50 | 51 | next_year: 52 | type: int 53 | default: "{{ copyright_year + 1 }}" # This computes the value 54 | when: false # This makes sure it isn't asked nor stored 55 | ``` 56 | 57 | See [advanced prompt formatting docs][advanced-prompt-formatting]. If you need more 58 | power, see [below][how-can-i-alter-the-context-before-rendering-the-project]. 59 | 60 | ## How to "lock" a computed value? 61 | 62 | When you want to ensure that a computed value is set or locked during project 63 | initialization, for example if you want to store a dynamically computed `copyright_year` 64 | but ensure that it doesn't change upon later Copier template updates, you can combine 65 | the `default` and `when: false` configuration while also explicitly dumping the value to 66 | YAML with the [answers file][the-copier-answersyml-file]. 67 | 68 | !!! example 69 | 70 | ```yaml title="copier.yaml" 71 | copyright_year: 72 | type: str 73 | default: "{{ copyright_year | default('%Y' | strftime) }}" 74 | when: false 75 | ``` 76 | 77 | ```yaml title="{{ _copier_conf.answers_file }}.jinja" 78 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 79 | {{ dict(_copier_answers, copyright_year=copyright_year) | to_nice_yaml -}} 80 | ``` 81 | 82 | ## How can I alter the context before rendering the project? 83 | 84 | **Use the [`ContextHook` extension][context-hook].** It lets you modify the context used 85 | to render templates, so that you can add, change or remove variables. Since it is a 86 | Python extension, you have the full power of Python at your fingertips, at the cost of 87 | having to mark the template as [unsafe][]. 88 | 89 | [context-hook]: 90 | https://github.com/copier-org/copier-templates-extensions#context-hook-extension 91 | 92 | In order for Copier to be able to load and use the extension when generating a project, 93 | it must be installed alongside Copier itself. More details in the [`jinja_extensions` 94 | docs][jinja_extensions]. 95 | 96 | You can then configure your Jinja extensions in Copier's configuration file: 97 | 98 | ```yaml title="copier.yaml" 99 | _jinja_extensions: 100 | - copier_templates_extensions.TemplateExtensionLoader 101 | - extensions/context.py:ContextUpdater 102 | ``` 103 | 104 | Following this example, you are supposed to provide a `context.py` file in the 105 | `extensions` folder at the root of your template to modify the context. If for example 106 | your `copier.yaml` contains a multiple-choice variable like this: 107 | 108 | ```yaml title="copier.yaml" 109 | flavor: 110 | type: str 111 | choices: 112 | - docker 113 | - instances 114 | - kubernetes 115 | - none 116 | ``` 117 | 118 | The `context.py` file contains your context hook which could look like: 119 | 120 | ```python title="extensions/context.py" 121 | from copier_templates_extensions import ContextHook 122 | 123 | 124 | class ContextUpdater(ContextHook): 125 | def hook(self, context): 126 | flavor = context["flavor"] # user's answer to the "flavor" question 127 | return { 128 | "isDocker": flavor == "docker", 129 | "isK8s": flavor == "kubernetes", 130 | "isInstances": flavor == "instances", 131 | "isLite": flavor == "none", 132 | "isNotDocker": flavor != "docker", 133 | "isNotK8s": flavor != "kubernetes", 134 | "isNotInstances": flavor != "instances", 135 | "isNotLite": flavor != "none", 136 | "hasContainers": flavor in {"docker", "kubernetes"}, 137 | } 138 | ``` 139 | 140 | Before rendering each templated file/folder, the context will be updated with this new 141 | context object that you return from the hook. If you wish to update the context in-place 142 | rather than update it, set the `update` class attribute to false: 143 | 144 | ```python title="extensions/context.py" 145 | from copier_templates_extensions import ContextHook 146 | 147 | 148 | class ContextUpdater(ContextHook): 149 | update = False 150 | 151 | def hook(self, context): 152 | flavor = context["flavor"] # user's answer to the "flavor" question 153 | 154 | context["isDocker"] = flavor == "docker" 155 | context["isK8s"] = flavor == "kubernetes" 156 | context["isInstances"] = flavor == "instances" 157 | context["isLite"] = flavor == "none" 158 | 159 | context["isNotDocker"] = flavor != "docker" 160 | context["isNotK8s"] = flavor != "kubernetes" 161 | context["isNotInstances"] = flavor != "instances" 162 | context["isNotLite"] = flavor != "none" 163 | 164 | context["hasContainers"] = context["isDocker"] or context["isK8s"] 165 | 166 | # you can now actually remove items from the context 167 | del context["flavor"] 168 | ``` 169 | 170 | Now you can use these added variables in your Jinja templates, and in files and folders 171 | names! 172 | 173 | ## Why Copier consumes a lot of resources? 174 | 175 | If the repository containing the template is a shallow clone, the git process called by 176 | Copier might consume unusually high resources. To avoid that, use a fully-cloned 177 | repository. 178 | 179 | ## While developing, why doesn't the template include dirty changes? 180 | 181 | Copier follows [a specific algorithm][templates-versions] to choose what reference to 182 | use from the template. It also [includes dirty changes in the `HEAD` ref while 183 | developing locally][copying-dirty-changes]. 184 | 185 | However, did you make sure you are selecting the `HEAD` ref for copying? 186 | 187 | Imagine this is the status of your dirty template in `./src`: 188 | 189 | ```shell 190 | $ git -C ./src status --porcelain=v1 191 | ?? new-file.txt 192 | 193 | $ git -C ./src tag 194 | v1.0.0 195 | v2.0.0 196 | ``` 197 | 198 | Now, if you copy that template into a folder like this: 199 | 200 | ```shell 201 | $ copier copy ./src ./dst 202 | ``` 203 | 204 | ... you'll notice there's no `new-file.txt`. Why? 205 | 206 | Well, Copier indeed included that into the `HEAD` ref. However, it still selected 207 | `v2.0.0` as the ref to copy, because that's what Copier does. 208 | 209 | However, if you do this: 210 | 211 | ```shell 212 | $ copier copy -r HEAD ./src ./dst 213 | ``` 214 | 215 | ... then you'll notice `new-file.txt` does exist. You passed a specific ref to copy, so 216 | Copier skips its autodetection and just goes for the `HEAD` you already chose. 217 | 218 | ## How to pass credentials to Git? 219 | 220 | If you do something like this, and the template supports updates, you'll notice that the 221 | credentials will end up stored in [the answers file][file][the-copier-answersyml-file]: 222 | 223 | ```shell 224 | copier copy https://myuser:example.com/repo.git . 225 | ``` 226 | 227 | To avoid that, the simplest fix is to clone using SSH with cryptographic key 228 | authentication. If you cannot do that, then check out these links for strategies on 229 | passing HTTPS credentials to Git: 230 | 231 | - https://github.com/copier-org/copier/issues/466#issuecomment-2338160284 232 | - https://stackoverflow.com/q/35942754 233 | - https://git-scm.com/docs/gitcredentials 234 | - https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage#_credential_caching 235 | - https://github.com/topics/git-credential-helper 236 | --------------------------------------------------------------------------------