Skip to content

Commit 7341731

Browse files
authored
Merge branch 'master' into floating-tag-fix
2 parents b7cb24c + cd45f37 commit 7341731

28 files changed

Lines changed: 1468 additions & 906 deletions

.github/workflows/autofix.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
autofix:
1616
runs-on: ubuntu-latest
1717
steps:
18-
- uses: actions/checkout@v5
18+
- uses: actions/checkout@v6
1919

2020
# Install Devbox
2121
- name: Install devbox

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ env:
1919
PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }}
2020
PYTHONIOENCODING: "UTF-8"
2121
# renovate: datasource=devbox depName=uv
22-
UV_VERSION: "0.8.19"
22+
UV_VERSION: "0.9.26"
2323
# renovate: datasource=github-tags depName=devbox packageName=jetify-com/devbox
2424
DEVBOX_VERSION: "0.16.0"
2525

@@ -32,7 +32,7 @@ jobs:
3232
- macos-latest
3333
- ubuntu-latest
3434
- windows-latest
35-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
35+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
3636
include:
3737
# HACK: Limit the number of pytest workers on macOS to 3 to avoid a
3838
# timeout for a few test cases. For unknown reasons, the number of
@@ -43,7 +43,7 @@ jobs:
4343
pytest-xdist-maxprocesses: 3
4444
runs-on: ${{ matrix.os }}
4545
steps:
46-
- uses: actions/checkout@v5
46+
- uses: actions/checkout@v6
4747
with:
4848
fetch-depth: 0 # Needs all tags to compute dynamic version
4949
- name: Install uv
@@ -98,7 +98,7 @@ jobs:
9898
permissions:
9999
contents: write # Allow pushing back pre-commit changes
100100
steps:
101-
- uses: actions/checkout@v5
101+
- uses: actions/checkout@v6
102102
- name: Install devbox
103103
uses: jetify-com/devbox-install-action@v0.14.0
104104
with:

.github/workflows/release.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ on:
77

88
env:
99
# renovate: datasource=devbox depName=uv
10-
UV_VERSION: "0.8.19"
10+
UV_VERSION: "0.9.26"
1111

1212
jobs:
1313
build:
1414
name: Build project for distribution
1515
runs-on: ubuntu-latest
1616

1717
steps:
18-
- uses: actions/checkout@v5
18+
- uses: actions/checkout@v6
1919
with:
2020
fetch-depth: 0
2121

@@ -39,7 +39,7 @@ jobs:
3939
run: uv build
4040

4141
- name: Upload artifact containing distribution files
42-
uses: actions/upload-artifact@v5
42+
uses: actions/upload-artifact@v6
4343
with:
4444
name: dist
4545
path: dist/
@@ -58,7 +58,7 @@ jobs:
5858

5959
steps:
6060
- name: Download artifact containing distribution files
61-
uses: actions/download-artifact@v6
61+
uses: actions/download-artifact@v7
6262
with:
6363
name: dist
6464
path: dist/
@@ -82,7 +82,7 @@ jobs:
8282

8383
steps:
8484
- name: Download artifact containing distribution files
85-
uses: actions/download-artifact@v6
85+
uses: actions/download-artifact@v7
8686
with:
8787
name: dist
8888
path: dist/

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.9
1+
3.10

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file. This projec
44
adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) versioning schema, and
55
the changelog itself conforms to [Keep A Changelog](https://keepachangelog.com/).
66

7+
## v9.11.3 (2026-01-23)
8+
9+
### Fix
10+
11+
- **updating**: include non-question answers when generating fresh copy of new
12+
template
13+
- **updating**: ignore Git hooks on internal checkout before 3-way merging file with
14+
conflicts (#2432)
15+
- avoid pattern deprecation warning for `pathspec` v1.0.0+
16+
17+
## v9.11.2 (2026-01-20)
18+
19+
### Fix
20+
21+
- **updating**: restore support for preserved symlinks pointing outside subproject
22+
(#2427)
23+
24+
### Security
25+
26+
- disallow symlink-based includes outside template root
27+
- disallow symlink-following write operations outside destination directory (#2427)
28+
29+
## v9.11.1 (2026-01-10)
30+
31+
### Fix
32+
33+
- **updating**: avoid circular reference when rendering JSON-serialized `_copier_conf`
34+
variable
35+
36+
## v9.11.0 (2025-11-20)
37+
38+
### Feat
39+
40+
- **updating**: allow updating a dirty Git repository when the subproject directory is
41+
clean (#2369)
42+
- add support for custom question icons (#2381)
43+
- add support for conditionally unsetting a question's default value
44+
45+
### Fix
46+
47+
- raise warning instead of error when `chmod` is not allowed
48+
- fix using default answers from settings for required questions (#2374)
49+
50+
### Refactor
51+
52+
- drop support for Python 3.9
53+
754
## v9.10.3 (2025-10-17)
855

956
### Fix

README.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,12 @@ A library and CLI app for rendering project templates.
2323

2424
## Installation
2525

26-
1. Install Python 3.9 or newer.
26+
1. Install Python 3.10 or newer.
2727
1. Install Git 2.27 or newer.
2828
1. To use as a CLI app: [`pipx install copier`](https://github.com/pypa/pipx) or
2929
[`uv tool install copier`](https://docs.astral.sh/uv/#tool-management)
3030
1. To use as a library: `pip install copier` or `conda install -c conda-forge copier`
3131

32-
### Nix flake
33-
34-
To install the latest Copier release with 100% reproducibility:
35-
36-
```shell
37-
nix profile install 'https://flakehub.com/f/copier-org/copier/*.tar.gz'
38-
```
39-
4032
### Homebrew formula
4133

4234
To install the latest Copier release using
@@ -59,6 +51,8 @@ To create a template:
5951
└── 📄 {{_copier_conf.answers_file}}.jinja # answers are recorded here
6052
```
6153

54+
Content of the `copier.yml` file:
55+
6256
```yaml title="copier.yml"
6357
# questions
6458
project_name:
@@ -70,10 +64,14 @@ module_name:
7064
help: What is your Python module name?
7165
```
7266
67+
Content of the `{{project_name}}/{{module_name}}.py.jinja` file:
68+
7369
```python+jinja title="{{project_name}}/{{module_name}}.py.jinja"
7470
print("Hello from {{module_name}}!")
7571
```
7672

73+
Content of the `{{_copier_conf.answers_file}}.jinja` file:
74+
7775
```yaml+jinja title="{{_copier_conf.answers_file}}.jinja"
7876
# Changes here will be overwritten by Copier
7977
{{ _copier_answers|to_nice_yaml -}}

copier/_cli.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@
4949
"""
5050

5151
import sys
52-
from collections.abc import Iterable
52+
from collections.abc import Callable, Iterable
5353
from os import PathLike
5454
from pathlib import Path
5555
from textwrap import dedent
56-
from typing import Any, Callable, Optional
56+
from typing import Any
5757

5858
import yaml
5959
from plumbum import cli, colors
@@ -168,7 +168,7 @@ def __init__(self, executable: PathLike[str]) -> None:
168168
help="Always use SHA commit hash instead of git tags/branches for updates",
169169
)
170170

171-
@cli.switch( # type: ignore[misc]
171+
@cli.switch( # type: ignore[untyped-decorator]
172172
["-d", "--data"],
173173
str,
174174
"VARIABLE=VALUE",
@@ -186,7 +186,7 @@ def data_switch(self, values: Iterable[str]) -> None:
186186
key, value = arg.split("=", 1)
187187
self.data[key] = value
188188

189-
@cli.switch( # type: ignore[misc]
189+
@cli.switch( # type: ignore[untyped-decorator]
190190
["--data-file"],
191191
cli.ExistingFile,
192192
help="Load data from a YAML file",
@@ -207,7 +207,7 @@ def data_file_switch(self, path: cli.ExistingFile) -> None:
207207

208208
def _worker(
209209
self,
210-
src_path: Optional[str] = None,
210+
src_path: str | None = None,
211211
dst_path: str = ".",
212212
**kwargs: Any, # noqa: FA100
213213
) -> Worker:

copier/_jinja_ext.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Iterable
6-
from typing import Any, Callable
5+
from collections.abc import Callable, Iterable
6+
from typing import Any
77

88
from jinja2 import nodes
99
from jinja2.exceptions import UndefinedError

copier/_main.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import subprocess
1010
import sys
1111
import warnings
12-
from collections.abc import Iterable, Mapping, Sequence
12+
from collections.abc import Callable, Iterable, Mapping, Sequence
1313
from contextlib import suppress
1414
from contextvars import ContextVar
1515
from dataclasses import field, replace
@@ -22,8 +22,9 @@
2222
from types import TracebackType
2323
from typing import (
2424
Any,
25-
Callable,
25+
Final,
2626
Literal,
27+
ParamSpec,
2728
TypeVar,
2829
get_args,
2930
overload,
@@ -32,7 +33,8 @@
3233

3334
from jinja2.loaders import FileSystemLoader
3435
from packaging.version import InvalidVersion, parse as parse_version
35-
from pathspec import PathSpec
36+
from packaging.version import Version
37+
from pathspec import PathSpec, __version__ as pathspec_version
3638
from plumbum import ProcessExecutionError, colors
3739
from plumbum.machines import local
3840
from pydantic import ConfigDict, PositiveInt
@@ -60,7 +62,6 @@
6062
JSONSerializable,
6163
LazyDict,
6264
Operation,
63-
ParamSpec,
6465
Phase,
6566
RelativePath,
6667
StrOrPath,
@@ -84,6 +85,9 @@
8485
_P = ParamSpec("_P")
8586

8687
_operation: ContextVar[Operation] = ContextVar("_operation")
88+
_pathspec_pattern: Final = (
89+
"gitignore" if Version(pathspec_version) >= Version("1.0.0") else "gitwildmatch"
90+
)
8791

8892

8993
def as_operation(value: Operation) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
@@ -574,7 +578,7 @@ def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]:
574578
"""Produce a function that matches against specified patterns."""
575579
# TODO Is normalization really needed?
576580
normalized_patterns = (normalize("NFD", pattern) for pattern in patterns)
577-
spec = PathSpec.from_lines("gitwildmatch", normalized_patterns)
581+
spec = PathSpec.from_lines(_pathspec_pattern, normalized_patterns)
578582
return spec.match_file
579583

580584
def _solve_render_conflict(self, dst_relpath: Path) -> bool:
@@ -850,15 +854,39 @@ def match_skip(self) -> Callable[[Path], bool]:
850854
def _render_template(self) -> None:
851855
"""Render the template in the subproject root."""
852856
follow_symlinks = not self.template.preserve_symlinks
853-
cwd = Path.cwd()
857+
dst_root = self.dst_path.resolve()
854858
for src in scantree(str(self.template_copy_root), follow_symlinks):
855859
src_abspath = Path(src.path)
860+
# If the source is a symlink, we are not preserving symlinks, and the
861+
# symlink target is outside the template root, this means that we are
862+
# copying a file/directory from outside the template, which is
863+
# forbidden, so raise an error.
864+
if (
865+
src_abspath.is_symlink()
866+
and not self.template.preserve_symlinks
867+
and not (src_abspath.resolve()).is_relative_to(
868+
self.template.local_abspath
869+
)
870+
):
871+
raise ForbiddenPathError(
872+
path=src_abspath.relative_to(self.template_copy_root)
873+
)
856874
src_relpath = Path(src_abspath).relative_to(self.template.local_abspath)
857875
dst_relpaths_ctxs = self._render_path(
858876
Path(src_abspath).relative_to(self.template_copy_root)
859877
)
860878
for dst_relpath, ctx in dst_relpaths_ctxs:
861-
if not cwd.joinpath(dst_relpath).resolve().is_relative_to(cwd):
879+
dst_abspath = dst_root / dst_relpath
880+
if dst_abspath.is_symlink() and self.template.preserve_symlinks:
881+
# If destination path is a symlink, it can safely point outside the
882+
# subproject dir, while still itself existing within the subproject.
883+
# (So long as nothing is templated into it (if it is a directory),
884+
# which would be caught by that path's own check.)
885+
# Therefore avoid resolving the symlink itself:
886+
dst_realpath = dst_abspath.parent.resolve() / dst_abspath.name
887+
else:
888+
dst_realpath = dst_abspath.resolve()
889+
if not dst_realpath.is_relative_to(dst_root):
862890
raise ForbiddenPathError(path=dst_relpath)
863891
if self.match_exclude(dst_relpath):
864892
continue
@@ -1384,7 +1412,10 @@ def _apply_update(self) -> None: # noqa: C901
13841412
data={
13851413
k: v
13861414
for k, v in self.answers.combined.items()
1387-
if k not in self.answers.hidden
1415+
if not k.startswith("_")
1416+
and k not in self.answers.hidden
1417+
and isinstance(k, JSONSerializable)
1418+
and isinstance(v, JSONSerializable)
13881419
},
13891420
defaults=True,
13901421
quiet=True,
@@ -1474,7 +1505,15 @@ def _apply_update(self) -> None: # noqa: C901
14741505
# Remove ".rej" suffix
14751506
fname = fname[:-4]
14761507
# Undo possible non-rejected chunks
1477-
git("checkout", "--", fname)
1508+
git(
1509+
# Ignore hooks to avoid errors from them or
1510+
# issues when .pre-commit-config.yaml is changed
1511+
"-c",
1512+
f"core.hooksPath={os.devnull}",
1513+
"checkout",
1514+
"--",
1515+
fname,
1516+
)
14781517
# 3-way-merge the file directly
14791518
git(
14801519
"merge-file",

copier/_subproject.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
from __future__ import annotations
77

8+
from collections.abc import Callable
89
from dataclasses import field
910
from functools import cached_property
1011
from pathlib import Path
11-
from typing import Callable
1212

1313
from plumbum.machines import local
1414
from pydantic.dataclasses import dataclass
@@ -43,7 +43,9 @@ def is_dirty(self) -> bool:
4343
"""
4444
if self.vcs == "git":
4545
with local.cwd(self.local_abspath):
46-
return bool(get_git()("status", "--porcelain").strip())
46+
return bool(
47+
get_git()("status", self.local_abspath, "--porcelain").strip()
48+
)
4749
return False
4850

4951
def _cleanup(self) -> None:

0 commit comments

Comments
 (0)