Skip to content

Commit b3f5747

Browse files
committed
fix(extensions): ensure trusted/safe templates extensions are seen during update or copy with --vcs-ref
1 parent ba15e57 commit b3f5747

4 files changed

Lines changed: 91 additions & 6 deletions

File tree

copier/_main.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,8 +657,14 @@ def jinja_env(self) -> SandboxedEnvironment:
657657
658658
Respects template settings.
659659
"""
660-
paths = [str(self.template.local_abspath)]
661-
loader = FileSystemLoader(paths)
660+
template_path = str(self.template.local_abspath)
661+
loader = FileSystemLoader([template_path])
662+
# Add template directory to sys.path so that template-local Python packages
663+
# are importable as or by Jinja extensions and their transitive imports.
664+
if template_path not in sys.path and (
665+
self.unsafe or is_trusted_repository(self.settings.trust, self.template.url)
666+
):
667+
sys.path.insert(0, template_path)
662668
default_extensions = [
663669
"jinja2_ansible_filters.AnsibleCoreFiltersExtension",
664670
YieldExtension,

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,9 @@ def settings_path(config_path: Path) -> Path:
119119
config_path.mkdir()
120120
settings_path = config_path / "settings.yml"
121121
return settings_path
122+
123+
124+
@pytest.fixture(autouse=True)
125+
def sys_path_cleanup(monkeypatch: pytest.MonkeyPatch) -> None:
126+
# Ensure that `sys.path` is restored after each test
127+
monkeypatch.setattr("sys.path", sys.path)

tests/test_context.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
import sys
32
from pathlib import Path
43
from uuid import uuid4
54

@@ -12,7 +11,7 @@
1211

1312

1413
def test_no_path_variables(
15-
tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
14+
tmp_path_factory: pytest.TempPathFactory,
1615
) -> None:
1716
"""Test that there are no context variables of type `pathlib.Path`."""
1817
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
@@ -60,7 +59,6 @@ def _assert(self, ctx: Context) -> None:
6059
src / "test.txt.jinja": "{{ __assert() | default('', true) }}",
6160
}
6261
)
63-
monkeypatch.setattr("sys.path", [str(src), *sys.path])
6462
copier.run_copy(str(src), dst, unsafe=True)
6563
assert (dst / "test.txt").read_text("utf-8") == ""
6664

tests/test_jinja2_extensions.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
import pytest
66
from jinja2 import Environment
77
from jinja2.ext import Extension
8+
from plumbum import local
89

910
import copier
1011

11-
from .helpers import PROJECT_TEMPLATE, build_file_tree
12+
from .helpers import PROJECT_TEMPLATE, build_file_tree, git_save
1213

1314

1415
class FilterExtension(Extension):
@@ -36,6 +37,39 @@ def super_func(argument: Any) -> str:
3637
environment.globals.update(super_var="super var!")
3738

3839

40+
@pytest.fixture
41+
def template_with_extension(tmp_path_factory: pytest.TempPathFactory) -> Path:
42+
src = tmp_path_factory.mktemp("src")
43+
build_file_tree(
44+
{
45+
src / "extensions" / "__init__.py": "",
46+
src / "extensions" / "data.py": """\
47+
DATA_VALUE = "hello from data module"
48+
""",
49+
src / "extensions" / "filters.py": """\
50+
from jinja2 import Environment
51+
from jinja2.ext import Extension
52+
53+
from extensions import data
54+
55+
56+
class DataFilter(Extension):
57+
def __init__(self, environment: Environment) -> None:
58+
super().__init__(environment)
59+
environment.filters["data_value"] = lambda _: data.DATA_VALUE
60+
""",
61+
src / "copier.yml": """\
62+
_jinja_extensions:
63+
- extensions.filters.DataFilter
64+
""",
65+
src
66+
/ "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
67+
src / "result.txt.jinja": "{{ '' | data_value }}",
68+
}
69+
)
70+
return src
71+
72+
3973
def test_default_jinja2_extensions(tmp_path: Path) -> None:
4074
copier.run_copy(str(PROJECT_TEMPLATE) + "_extensions_default", tmp_path)
4175
super_file = tmp_path / "super_file.md"
@@ -64,3 +98,44 @@ def test_to_json_filter_with_conf(tmp_path_factory: pytest.TempPathFactory) -> N
6498
assert conf_file.exists()
6599
# must not raise an error
66100
assert json.loads(conf_file.read_text())
101+
102+
103+
def test_extension_from_copy_with_vcs_ref(
104+
template_with_extension: Path, tmp_path_factory: pytest.TempPathFactory
105+
) -> None:
106+
dst = tmp_path_factory.mktemp("dst")
107+
with local.cwd(template_with_extension):
108+
git_save(tag="1.0.0")
109+
copier.run_copy(str(template_with_extension), dst, unsafe=True, vcs_ref="HEAD")
110+
result = dst / "result.txt"
111+
assert result.exists()
112+
assert result.read_text() == "hello from data module"
113+
114+
115+
def test_extension_on_update(
116+
template_with_extension: Path, tmp_path_factory: pytest.TempPathFactory
117+
) -> None:
118+
dst = tmp_path_factory.mktemp("dst")
119+
with local.cwd(template_with_extension):
120+
git_save(tag="1.0.0")
121+
# Initial copy
122+
copier.run_copy(
123+
str(template_with_extension),
124+
dst,
125+
unsafe=True,
126+
vcs_ref="1.0.0",
127+
defaults=True,
128+
overwrite=True,
129+
)
130+
assert (dst / "result.txt").read_text() == "hello from data module"
131+
with local.cwd(dst):
132+
git_save()
133+
# Add a new file in v2 (extension stays the same)
134+
build_file_tree({template_with_extension / "v2.txt": "new in v2"})
135+
with local.cwd(template_with_extension):
136+
git_save(tag="2.0.0")
137+
# Run update — extension should work because template is trusted
138+
copier.run_update(dst, unsafe=True, defaults=True, overwrite=True)
139+
# Extension still works during update rendering
140+
assert (dst / "result.txt").read_text() == "hello from data module"
141+
assert (dst / "v2.txt").read_text() == "new in v2"

0 commit comments

Comments
 (0)