Skip to content

template.py

Tools related to template management.

Task

Object that represents a task to execute.

Attributes:

Name Type Description
cmd str | Sequence[str]

Command to execute.

extra_env Env

Additional environment variables to set while executing the command.

Source code in copier/template.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
@dataclass
class Task:
    """Object that represents a task to execute.

    Attributes:
        cmd:
            Command to execute.

        extra_env:
            Additional environment variables to set while executing the command.
    """

    cmd: str | Sequence[str]
    extra_env: Env = field(default_factory=dict)

Template

Object that represents a template and its current state.

See configuring a template.

Attributes:

Name Type Description
url str

Absolute origin that points to the template.

It can be:

  • A local path.
  • A Git url. Note: if something fails, prefix the URL with git+.
ref str | None

The tag to checkout in the template.

Only used if url points to a VCS-tracked template.

If None, then it will checkout the latest tag, sorted by PEP440. Otherwise it will checkout the reference used here.

Usually it should be a tag, or None.

use_prereleases bool

When True, the template's latest release will consider prereleases.

Only used if:

  • url points to a VCS-tracked template
  • ref is None.

Helpful if you want to test templates before doing a proper release, but you need some features that require a proper PEP440 version identifier.

Source code in copier/template.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
@dataclass
class Template:
    """Object that represents a template and its current state.

    See [configuring a template][configuring-a-template].

    Attributes:
        url:
            Absolute origin that points to the template.

            It can be:

            - A local path.
            - A Git url. Note: if something fails, prefix the URL with `git+`.

        ref:
            The tag to checkout in the template.

            Only used if `url` points to a VCS-tracked template.

            If `None`, then it will checkout the latest tag, sorted by PEP440.
            Otherwise it will checkout the reference used here.

            Usually it should be a tag, or `None`.

        use_prereleases:
            When `True`, the template's *latest* release will consider prereleases.

            Only used if:

            - `url` points to a VCS-tracked template
            - `ref` is `None`.

            Helpful if you want to test templates before doing a proper release, but you
            need some features that require a proper PEP440 version identifier.
    """

    url: str
    ref: str | None = None
    use_prereleases: bool = False

    def _cleanup(self) -> None:
        if temp_clone := self._temp_clone():
            if sys.version_info >= (3, 12):
                rmtree(
                    temp_clone,
                    ignore_errors=False,
                    onexc=handle_remove_readonly,
                )
            else:
                rmtree(
                    temp_clone,
                    ignore_errors=False,
                    onerror=handle_remove_readonly,
                )

    def _temp_clone(self) -> Path | None:
        """Get the path to the temporary clone of the template.

        If the template hasn't yet been cloned, or if it was a local template,
        then there's no temporary clone and this will return `None`.
        """
        if "local_abspath" not in self.__dict__:
            return None
        original_path = Path(self.url).expanduser()
        with suppress(OSError):  # triggered for URLs on Windows
            original_path = original_path.resolve()
        if (clone_path := self.local_abspath) != original_path:
            return clone_path
        return None

    @cached_property
    def _raw_config(self) -> AnyByStrDict:
        """Get template configuration, raw.

        It reads [the `copier.yml` file][the-copieryml-file].
        """
        conf_paths = [
            p
            for p in self.local_abspath.glob("copier.*")
            if p.is_file() and re.match(r"\.ya?ml", p.suffix, re.I)
        ]
        if len(conf_paths) > 1:
            raise MultipleConfigFilesError(conf_paths)
        elif len(conf_paths) == 1:
            return load_template_config(conf_paths[0])
        return {}

    @cached_property
    def answers_relpath(self) -> Path:
        """Get the answers file relative path, as specified in the template.

        If not specified, returns the default `.copier-answers.yml`.

        See [answers_file][].
        """
        result = Path(self.config_data.get("answers_file", ".copier-answers.yml"))
        assert not result.is_absolute()
        return result

    @cached_property
    def commit(self) -> str | None:
        """If the template is VCS-tracked, get its commit description."""
        if self.vcs == "git":
            with local.cwd(self.local_abspath):
                return get_git()("describe", "--tags", "--always").strip()
        return None

    @cached_property
    def commit_hash(self) -> str | None:
        """If the template is VCS-tracked, get its commit full hash."""
        if self.vcs == "git":
            return get_git()("-C", self.local_abspath, "rev-parse", "HEAD").strip()
        return None

    @cached_property
    def config_data(self) -> AnyByStrDict:
        """Get config from the template.

        It reads [the `copier.yml` file][the-copieryml-file] to get its
        [settings][available-settings].
        """
        result = filter_config(self._raw_config)[0]
        with suppress(KeyError):
            verify_copier_version(result["min_copier_version"])
        return result

    @cached_property
    def envops(self) -> Mapping[str, Any]:
        """Get the Jinja configuration specified in the template, or default values.

        See [envops][].
        """
        result = self.config_data.get("envops", {})
        if "keep_trailing_newline" not in result:
            # NOTE: we want to keep trailing newlines in templates as this is what a
            #       user will most likely expects as a default.
            #       See https://github.com/copier-org/copier/issues/464
            result["keep_trailing_newline"] = True
        return result

    @cached_property
    def exclude(self) -> tuple[str, ...]:
        """Get exclusions specified in the template, or default ones.

        See [exclude][].
        """
        return tuple(
            self.config_data.get(
                "exclude",
                DEFAULT_EXCLUDE if Path(self.subdirectory) == Path(".") else [],
            )
        )

    @cached_property
    def jinja_extensions(self) -> tuple[str, ...]:
        """Get Jinja2 extensions specified in the template, or `()`.

        See [jinja_extensions][].
        """
        return tuple(self.config_data.get("jinja_extensions", ()))

    @cached_property
    def message_after_copy(self) -> str:
        """Get message to print after copy action specified in the template."""
        return self.config_data.get("message_after_copy", "")

    @cached_property
    def message_after_update(self) -> str:
        """Get message to print after update action specified in the template."""
        return self.config_data.get("message_after_update", "")

    @cached_property
    def message_before_copy(self) -> str:
        """Get message to print before copy action specified in the template."""
        return self.config_data.get("message_before_copy", "")

    @cached_property
    def message_before_update(self) -> str:
        """Get message to print before update action specified in the template."""
        return self.config_data.get("message_before_update", "")

    @cached_property
    def metadata(self) -> AnyByStrDict:
        """Get template metadata.

        This data, if any, should be saved in the answers file to be able to
        restore the template to this same state.
        """
        result: AnyByStrDict = {"_src_path": self.url}
        if self.commit:
            result["_commit"] = self.commit
        return result

    def migration_tasks(
        self, stage: Literal["before", "after"], from_template: Template
    ) -> Sequence[Task]:
        """Get migration objects that match current version spec.

        Versions are compared using PEP 440.

        See [migrations][].

        Args:
            stage: A valid stage name to find tasks for.
            from_template: Original template, from which we are migrating.
        """
        result: list[Task] = []
        if not (self.version and from_template.version):
            return result
        extra_env: Env = {
            "STAGE": stage,
            "VERSION_FROM": str(from_template.commit),
            "VERSION_TO": str(self.commit),
            "VERSION_PEP440_FROM": str(from_template.version),
            "VERSION_PEP440_TO": str(self.version),
        }
        migration: dict[str, Any]
        for migration in self._raw_config.get("_migrations", []):
            current = parse(migration["version"])
            if self.version >= current > from_template.version:
                extra_env = {
                    **extra_env,
                    "VERSION_CURRENT": migration["version"],
                    "VERSION_PEP440_CURRENT": str(current),
                }
                result.extend(
                    Task(cmd=cmd, extra_env=extra_env)
                    for cmd in migration.get(stage, [])
                )
        return result

    @cached_property
    def min_copier_version(self) -> Version | None:
        """Get minimal copier version for the template and validates it.

        See [min_copier_version][].
        """
        try:
            return Version(self.config_data["min_copier_version"])
        except KeyError:
            return None

    @cached_property
    def questions_data(self) -> AnyByStrDict:
        """Get questions from the template.

        See [questions][].
        """
        result = filter_config(self._raw_config)[1]
        for key in set(self.config_data.get("secret_questions", [])):
            if key in result:
                result[key]["secret"] = True
        return result

    @cached_property
    def secret_questions(self) -> set[str]:
        """Get names of secret questions from the template.

        These questions shouldn't be saved into the answers file.
        """
        result = set(self.config_data.get("secret_questions", []))
        for key, value in self.questions_data.items():
            if value.get("secret"):
                result.add(key)
        return result

    @cached_property
    def skip_if_exists(self) -> Sequence[str]:
        """Get skip patterns from the template.

        These files will never be rewritten when rendering the template.

        See [skip_if_exists][].
        """
        return self.config_data.get("skip_if_exists", ())

    @cached_property
    def subdirectory(self) -> str:
        """Get the subdirectory as specified in the template.

        The subdirectory points to the real template code, allowing the
        templater to separate it from other template assets, such as docs,
        tests, etc.

        See [subdirectory][].
        """
        return self.config_data.get("subdirectory", "")

    @cached_property
    def tasks(self) -> Sequence[Task]:
        """Get tasks defined in the template.

        See [tasks][].
        """
        return [
            Task(cmd=cmd, extra_env={"STAGE": "task"})
            for cmd in self.config_data.get("tasks", [])
        ]

    @cached_property
    def templates_suffix(self) -> str:
        """Get the suffix defined for templates.

        By default: `.jinja`.

        See [templates_suffix][].
        """
        result = self.config_data.get("templates_suffix")
        if result is None:
            return DEFAULT_TEMPLATES_SUFFIX
        return result

    @cached_property
    def preserve_symlinks(self) -> bool:
        """Know if Copier should preserve symlinks when rendering the template.

        See [preserve_symlinks][].
        """
        return bool(self.config_data.get("preserve_symlinks", False))

    @cached_property
    def local_abspath(self) -> Path:
        """Get the absolute path to the template on disk.

        This may clone it if `url` points to a VCS-tracked template.
        Dirty changes for local VCS-tracked templates will be copied.
        """
        result = Path(self.url)
        if self.vcs == "git":
            result = Path(clone(self.url_expanded, self.ref))
            if self.ref is None:
                checkout_latest_tag(result, self.use_prereleases)
        if not result.is_dir():
            raise ValueError("Local template must be a directory.")
        with suppress(OSError):
            result = result.resolve()
        return result

    @cached_property
    def url_expanded(self) -> str:
        """Get usable URL.

        `url` can be specified in shortcut
        format, which wouldn't be understood by the underlying VCS system. This
        property returns the expanded version, which should work properly.
        """
        return get_repo(self.url) or self.url

    @cached_property
    def version(self) -> Version | None:
        """PEP440-compliant version object."""
        if self.vcs != "git" or not self.commit:
            return None
        try:
            with local.cwd(self.local_abspath):
                # Leverage dunamai by default; usually it gets best results.
                # `dunamai.Version.from_git` needs `Pattern.DefaultUnprefixed`
                # to be PEP440 compliant on version reading
                return Version(
                    dunamai.Version.from_git(
                        pattern=dunamai.Pattern.DefaultUnprefixed
                    ).serialize(style=dunamai.Style.Pep440)
                )
        except ValueError:
            # A fully descriptive commit can be easily detected converted into a
            # PEP440 version, because it has the format "<tag>-<count>-g<hash>"
            if re.match(r"^.+-\d+-g\w+$", self.commit):
                base, count, git_hash = self.commit.rsplit("-", 2)
                return Version(f"{base}.post{count}+{git_hash}")
        # If we get here, the commit string is a tag
        try:
            return Version(self.commit)
        except packaging.version.InvalidVersion:
            # appears to not be a version
            return None

    @cached_property
    def vcs(self) -> VCSTypes | None:
        """Get VCS system used by the template, if any."""
        if get_repo(self.url):
            return "git"
        return None

answers_relpath: Path property cached

Get the answers file relative path, as specified in the template.

If not specified, returns the default .copier-answers.yml.

See answers_file.

commit: str | None property cached

If the template is VCS-tracked, get its commit description.

commit_hash: str | None property cached

If the template is VCS-tracked, get its commit full hash.

config_data: AnyByStrDict property cached

Get config from the template.

It reads the copier.yml file to get its settings.

envops: Mapping[str, Any] property cached

Get the Jinja configuration specified in the template, or default values.

See envops.

exclude: tuple[str, ...] property cached

Get exclusions specified in the template, or default ones.

See exclude.

jinja_extensions: tuple[str, ...] property cached

Get Jinja2 extensions specified in the template, or ().

See jinja_extensions.

local_abspath: Path property cached

Get the absolute path to the template on disk.

This may clone it if url points to a VCS-tracked template. Dirty changes for local VCS-tracked templates will be copied.

message_after_copy: str property cached

Get message to print after copy action specified in the template.

message_after_update: str property cached

Get message to print after update action specified in the template.

message_before_copy: str property cached

Get message to print before copy action specified in the template.

message_before_update: str property cached

Get message to print before update action specified in the template.

metadata: AnyByStrDict property cached

Get template metadata.

This data, if any, should be saved in the answers file to be able to restore the template to this same state.

min_copier_version: Version | None property cached

Get minimal copier version for the template and validates it.

See min_copier_version.

Know if Copier should preserve symlinks when rendering the template.

See preserve_symlinks.

questions_data: AnyByStrDict property cached

Get questions from the template.

See questions.

secret_questions: set[str] property cached

Get names of secret questions from the template.

These questions shouldn't be saved into the answers file.

skip_if_exists: Sequence[str] property cached

Get skip patterns from the template.

These files will never be rewritten when rendering the template.

See skip_if_exists.

subdirectory: str property cached

Get the subdirectory as specified in the template.

The subdirectory points to the real template code, allowing the templater to separate it from other template assets, such as docs, tests, etc.

See subdirectory.

tasks: Sequence[Task] property cached

Get tasks defined in the template.

See tasks.

templates_suffix: str property cached

Get the suffix defined for templates.

By default: .jinja.

See templates_suffix.

url_expanded: str property cached

Get usable URL.

url can be specified in shortcut format, which wouldn't be understood by the underlying VCS system. This property returns the expanded version, which should work properly.

vcs: VCSTypes | None property cached

Get VCS system used by the template, if any.

version: Version | None property cached

PEP440-compliant version object.

migration_tasks(stage, from_template)

Get migration objects that match current version spec.

Versions are compared using PEP 440.

See migrations.

Parameters:

Name Type Description Default
stage Literal['before', 'after']

A valid stage name to find tasks for.

required
from_template Template

Original template, from which we are migrating.

required
Source code in copier/template.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
def migration_tasks(
    self, stage: Literal["before", "after"], from_template: Template
) -> Sequence[Task]:
    """Get migration objects that match current version spec.

    Versions are compared using PEP 440.

    See [migrations][].

    Args:
        stage: A valid stage name to find tasks for.
        from_template: Original template, from which we are migrating.
    """
    result: list[Task] = []
    if not (self.version and from_template.version):
        return result
    extra_env: Env = {
        "STAGE": stage,
        "VERSION_FROM": str(from_template.commit),
        "VERSION_TO": str(self.commit),
        "VERSION_PEP440_FROM": str(from_template.version),
        "VERSION_PEP440_TO": str(self.version),
    }
    migration: dict[str, Any]
    for migration in self._raw_config.get("_migrations", []):
        current = parse(migration["version"])
        if self.version >= current > from_template.version:
            extra_env = {
                **extra_env,
                "VERSION_CURRENT": migration["version"],
                "VERSION_PEP440_CURRENT": str(current),
            }
            result.extend(
                Task(cmd=cmd, extra_env=extra_env)
                for cmd in migration.get(stage, [])
            )
    return result

filter_config(data)

Separates config and questions data.

Source code in copier/template.py
49
50
51
52
53
54
55
56
57
58
59
60
61
def filter_config(data: AnyByStrDict) -> tuple[AnyByStrDict, AnyByStrDict]:
    """Separates config and questions data."""
    config_data: AnyByStrDict = {}
    questions_data = {}
    for k, v in data.items():
        if k.startswith("_"):
            config_data[k[1:]] = v
        else:
            # Transform simplified questions format into complex
            if not isinstance(v, dict):
                v = {"default": v}
            questions_data[k] = v
    return config_data, questions_data

load_template_config(conf_path, quiet=False)

Load the copier.yml file.

This is like a simple YAML load, but applying all specific quirks needed for the copier.yml file.

For example, it supports the !include tag with glob includes, and merges multiple sections.

Parameters:

Name Type Description Default
conf_path Path

The path to the copier.yml file.

required
quiet bool

Used to configure the exception.

False

Raises:

Type Description
InvalidConfigFileError

When the file is formatted badly.

Source code in copier/template.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def load_template_config(conf_path: Path, quiet: bool = False) -> AnyByStrDict:
    """Load the `copier.yml` file.

    This is like a simple YAML load, but applying all specific quirks needed
    for [the `copier.yml` file][the-copieryml-file].

    For example, it supports the `!include` tag with glob includes, and
    merges multiple sections.

    Params:
        conf_path: The path to the `copier.yml` file.
        quiet: Used to configure the exception.

    Raises:
        InvalidConfigFileError: When the file is formatted badly.
    """

    class _Loader(yaml.FullLoader):
        """Intermediate class to avoid monkey-patching main loader."""

    def _include(loader: yaml.Loader, node: yaml.Node) -> Any:
        if not isinstance(node, yaml.ScalarNode):
            raise ValueError(f"Unsupported YAML node: {node!r}")
        include_file = str(loader.construct_scalar(node))
        if PurePosixPath(include_file).is_absolute():
            raise ValueError("YAML include file path must be a relative path")
        return [
            yaml.load(path.read_bytes(), Loader=type(loader))
            for path in conf_path.parent.glob(include_file)
        ]

    _Loader.add_constructor("!include", _include)

    with conf_path.open("rb") as f:
        try:
            flattened_result = lflatten(filter(None, yaml.load_all(f, Loader=_Loader)))
        except yaml.parser.ParserError as e:
            raise InvalidConfigFileError(conf_path, quiet) from e

    merged_options = defaultdict(list)
    for option in (
        "_exclude",
        "_jinja_extensions",
        "_secret_questions",
        "_skip_if_exists",
    ):
        for result in flattened_result:
            if option in result:
                merged_options[option].extend(result[option])

    return dict(ChainMap(dict(merged_options), *reversed(flattened_result)))

verify_copier_version(version_str)

Raise an error if the current Copier version is less than the given version.

Parameters:

Name Type Description Default
version_str str

Minimal copier version for the template.

required
Source code in copier/template.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def verify_copier_version(version_str: str) -> None:
    """Raise an error if the current Copier version is less than the given version.

    Args:
        version_str:
            Minimal copier version for the template.
    """
    installed_version = copier_version()

    # Disable check when running copier as editable installation
    if installed_version == Version("0.0.0"):
        warn(
            "Cannot check Copier version constraint.",
            UnknownCopierVersionWarning,
        )
        return
    parsed_min = Version(version_str)
    if installed_version < parsed_min:
        raise UnsupportedVersionError(
            f"This template requires Copier version >= {version_str}, "
            f"while your version of Copier is {installed_version}."
        )
    if installed_version.major > parsed_min.major:
        warn(
            f"This template was designed for Copier {version_str}, "
            f"but your version of Copier is {installed_version}. "
            f"You could find some incompatibilities.",
            OldTemplateWarning,
        )