Skip to content

Commit df13975

Browse files
authored
fix: clean up tmp dir
Until now, Copier always left a lot of garbage in the tmp dir. When running in batch, this could become a big problem, as templates would fill up memory (`/tmp/` is usually mounted as tmpfs). @moduon MT-3411 Co-authored-by: Sigurd Spieckermann <[email protected]>
1 parent 9a2fd04 commit df13975

File tree

4 files changed

+157
-23
lines changed

4 files changed

+157
-23
lines changed

copier/cli.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,14 @@ def main(self, template_src: str, destination_path: str) -> int:
234234
destination_path:
235235
Where to generate the new subproject. It must not exist or be empty.
236236
"""
237-
self._worker(
237+
with self._worker(
238238
template_src,
239239
destination_path,
240240
cleanup_on_error=self.cleanup_on_error,
241241
defaults=self.force or self.defaults,
242242
overwrite=self.force or self.overwrite,
243-
).run_copy()
243+
) as worker:
244+
worker.run_copy()
244245
return 0
245246

246247

@@ -295,11 +296,12 @@ def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
295296
The subproject must exist. If not specified, the currently
296297
working directory is used.
297298
"""
298-
self._worker(
299+
with self._worker(
299300
dst_path=destination_path,
300301
defaults=self.force or self.defaults,
301302
overwrite=self.force or self.overwrite,
302-
).run_recopy()
303+
) as worker:
304+
worker.run_recopy()
303305
return 0
304306

305307

@@ -360,13 +362,14 @@ def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
360362
The subproject must exist. If not specified, the currently
361363
working directory is used.
362364
"""
363-
self._worker(
365+
with self._worker(
364366
dst_path=destination_path,
365367
conflict=self.conflict,
366368
context_lines=self.context_lines,
367369
defaults=self.defaults,
368370
overwrite=True,
369-
).run_update()
371+
) as worker:
372+
worker.run_update()
370373
return 0
371374

372375

copier/main.py

+26-15
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import (
1616
Callable,
1717
Iterable,
18+
List,
1819
Literal,
1920
Mapping,
2021
Optional,
@@ -187,6 +188,7 @@ class Worker:
187188
unsafe: bool = False
188189

189190
answers: AnswersMap = field(default_factory=AnswersMap, init=False)
191+
_cleanup_hooks: List[Callable] = field(default_factory=list, init=False)
190192

191193
def __enter__(self):
192194
"""Allow using worker as a context manager."""
@@ -204,7 +206,9 @@ def __exit__(self, type, value, traceback):
204206
self._cleanup()
205207

206208
def _cleanup(self):
207-
self.template._cleanup()
209+
"""Execute all stored cleanup methods."""
210+
for method in self._cleanup_hooks:
211+
method()
208212

209213
def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
210214
"""Check whether a template uses unsafe features."""
@@ -283,6 +287,7 @@ def _render_context(self) -> Mapping:
283287
# Backwards compatibility
284288
# FIXME Remove it?
285289
conf = asdict(self)
290+
conf.pop("_cleanup_hooks")
286291
conf.update(
287292
{
288293
"answers_file": self.answers_relpath,
@@ -682,10 +687,12 @@ def _render_string(self, string: str) -> str:
682687
@cached_property
683688
def subproject(self) -> Subproject:
684689
"""Get related subproject."""
685-
return Subproject(
690+
result = Subproject(
686691
local_abspath=self.dst_path.absolute(),
687692
answers_relpath=self.answers_file or Path(".copier-answers.yml"),
688693
)
694+
self._cleanup_hooks.append(result._cleanup)
695+
return result
689696

690697
@cached_property
691698
def template(self) -> Template:
@@ -695,7 +702,11 @@ def template(self) -> Template:
695702
if self.subproject.template is None:
696703
raise TypeError("Template not found")
697704
url = str(self.subproject.template.url)
698-
return Template(url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases)
705+
result = Template(
706+
url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases
707+
)
708+
self._cleanup_hooks.append(result._cleanup)
709+
return result
699710

700711
@cached_property
701712
def template_copy_root(self) -> Path:
@@ -750,8 +761,8 @@ def run_recopy(self) -> None:
750761
"Cannot recopy because cannot obtain old template references "
751762
f"from `{self.subproject.answers_relpath}`."
752763
)
753-
new_worker = replace(self, src_path=self.subproject.template.url)
754-
return new_worker.run_copy()
764+
with replace(self, src_path=self.subproject.template.url) as new_worker:
765+
return new_worker.run_copy()
755766

756767
def run_update(self) -> None:
757768
"""Update a subproject that was already generated.
@@ -816,16 +827,16 @@ def _apply_update(self):
816827
prefix=f"{__name__}.old_copy."
817828
) as old_copy, TemporaryDirectory(prefix=f"{__name__}.new_copy.") as new_copy:
818829
# Copy old template into a temporary destination
819-
old_worker = replace(
830+
with replace(
820831
self,
821832
dst_path=old_copy / subproject_subdir,
822833
data=self.subproject.last_answers,
823834
defaults=True,
824835
quiet=True,
825836
src_path=self.subproject.template.url,
826837
vcs_ref=self.subproject.template.commit,
827-
)
828-
old_worker.run_copy()
838+
) as old_worker:
839+
old_worker.run_copy()
829840
# Extract diff between temporary destination and real destination
830841
with local.cwd(old_copy):
831842
self._git_initialize_repo()
@@ -853,26 +864,26 @@ def _apply_update(self):
853864
with suppress(AttributeError):
854865
del self.subproject.last_answers
855866
# Do a normal update in final destination
856-
current_worker = replace(
867+
with replace(
857868
self,
858869
# Files can change due to the historical diff, and those
859870
# changes are not detected in this process, so it's better to
860871
# say nothing than lie.
861872
# TODO
862873
quiet=True,
863-
)
864-
current_worker.run_copy()
865-
self.answers = current_worker.answers
874+
) as current_worker:
875+
current_worker.run_copy()
876+
self.answers = current_worker.answers
866877
# Render with the same answers in an empty dir to avoid pollution
867-
new_worker = replace(
878+
with replace(
868879
self,
869880
dst_path=new_copy / subproject_subdir,
870881
data=self.answers.combined,
871882
defaults=True,
872883
quiet=True,
873884
src_path=self.subproject.template.url,
874-
)
875-
new_worker.run_copy()
885+
) as new_worker:
886+
new_worker.run_copy()
876887
compared = dircmp(old_copy, new_copy)
877888
# Try to apply cached diff into final destination
878889
with local.cwd(subproject_top):

copier/subproject.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
A *subproject* is a project that gets rendered and/or updated with Copier.
44
"""
55

6+
from dataclasses import field
67
from functools import cached_property
78
from pathlib import Path
8-
from typing import Optional
9+
from typing import Callable, List, Optional
910

1011
import yaml
1112
from plumbum.cmd import git
@@ -32,6 +33,8 @@ class Subproject:
3233
local_abspath: AbsolutePath
3334
answers_relpath: Path = Path(".copier-answers.yml")
3435

36+
_cleanup_hooks: List[Callable] = field(default_factory=list, init=False)
37+
3538
def is_dirty(self) -> bool:
3639
"""Indicate if the local template root is dirty.
3740
@@ -42,6 +45,11 @@ def is_dirty(self) -> bool:
4245
return bool(git("status", "--porcelain").strip())
4346
return False
4447

48+
def _cleanup(self):
49+
"""Remove temporary files and folders created by the subproject."""
50+
for method in self._cleanup_hooks:
51+
method()
52+
4553
@property
4654
def _raw_answers(self) -> AnyByStrDict:
4755
"""Get last answers, loaded raw as yaml."""
@@ -67,7 +75,9 @@ def template(self) -> Optional[Template]:
6775
last_url = self.last_answers.get("_src_path")
6876
last_ref = self.last_answers.get("_commit")
6977
if last_url:
70-
return Template(url=last_url, ref=last_ref)
78+
result = Template(url=last_url, ref=last_ref)
79+
self._cleanup_hooks.append(result._cleanup)
80+
return result
7181

7282
@cached_property
7383
def vcs(self) -> Optional[VCSTypes]:

tests/test_tmpdir.py

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from plumbum.cmd import git
5+
6+
from copier.cli import CopierApp
7+
from copier.main import run_copy, run_recopy, run_update
8+
9+
from .helpers import build_file_tree
10+
11+
12+
@pytest.fixture(scope="module")
13+
def template_path(tmp_path_factory: pytest.TempPathFactory) -> str:
14+
# V1 of the template
15+
root = tmp_path_factory.mktemp("template")
16+
build_file_tree(
17+
{
18+
root / "copier.yml": "favorite_app: Copier",
19+
root / "fav.txt.jinja": "{{ favorite_app }}",
20+
root
21+
/ "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
22+
}
23+
)
24+
_git = git["-C", root]
25+
_git("init")
26+
_git("add", "-A")
27+
_git("commit", "-m", "Initial commit")
28+
_git("tag", "v1")
29+
# V2 of the template
30+
build_file_tree({root / "v2": "true"})
31+
_git("add", "-A")
32+
_git("commit", "-m", "Second commit")
33+
_git("tag", "v2")
34+
return str(root)
35+
36+
37+
def empty_dir(dir: Path):
38+
assert dir.is_dir()
39+
assert dir.exists()
40+
assert len(list(dir.iterdir())) == 0
41+
42+
43+
def test_api(
44+
template_path: str,
45+
tmp_path_factory: pytest.TempPathFactory,
46+
monkeypatch: pytest.MonkeyPatch,
47+
):
48+
tmp, dst = map(tmp_path_factory.mktemp, ["tmp", "dst"])
49+
_git = git["-C", dst]
50+
# Mock tmp dir to assert it ends up clean
51+
monkeypatch.setattr("tempfile.tempdir", str(tmp))
52+
# Copy
53+
run_copy(template_path, dst, vcs_ref="v1", quiet=True, defaults=True)
54+
assert (dst / "fav.txt").read_text() == "Copier"
55+
assert not (dst / "v2").exists()
56+
_git("init")
57+
_git("add", "-A")
58+
_git("commit", "-m", "Initial commit")
59+
empty_dir(tmp)
60+
# Recopy
61+
run_recopy(dst, vcs_ref="v1", quiet=True, defaults=True)
62+
assert (dst / "fav.txt").read_text() == "Copier"
63+
assert not (dst / "v2").exists()
64+
empty_dir(tmp)
65+
# Update
66+
run_update(dst, quiet=True, defaults=True, overwrite=True)
67+
assert (dst / "fav.txt").read_text() == "Copier"
68+
assert (dst / "v2").read_text() == "true"
69+
empty_dir(tmp)
70+
71+
72+
def test_cli(
73+
template_path: str,
74+
tmp_path_factory: pytest.TempPathFactory,
75+
monkeypatch: pytest.MonkeyPatch,
76+
):
77+
tmp, dst = map(tmp_path_factory.mktemp, ["tmp", "dst"])
78+
_git = git["-C", dst]
79+
# Mock tmp dir to assert it ends up clean
80+
monkeypatch.setattr("tempfile.tempdir", str(tmp))
81+
# Copy
82+
run_result = CopierApp.run(
83+
[
84+
"copier",
85+
"copy",
86+
"-fqrv1",
87+
template_path,
88+
str(dst),
89+
],
90+
exit=False,
91+
)
92+
assert run_result[1] == 0
93+
assert (dst / "fav.txt").read_text() == "Copier"
94+
assert not (dst / "v2").exists()
95+
empty_dir(tmp)
96+
_git("init")
97+
_git("add", "-A")
98+
_git("commit", "-m", "Initial commit")
99+
# Recopy
100+
run_result = CopierApp.run(["copier", "recopy", "-fqrv1", str(dst)], exit=False)
101+
assert run_result[1] == 0
102+
assert (dst / "fav.txt").read_text() == "Copier"
103+
assert not (dst / "v2").exists()
104+
empty_dir(tmp)
105+
# Update
106+
run_result = CopierApp.run(["copier", "update", "-fq", str(dst)], exit=False)
107+
assert run_result[1] == 0
108+
assert (dst / "fav.txt").read_text() == "Copier"
109+
assert (dst / "v2").read_text() == "true"
110+
empty_dir(tmp)

0 commit comments

Comments
 (0)