6
6
import subprocess
7
7
import sys
8
8
from contextlib import suppress
9
+ from contextvars import ContextVar
9
10
from dataclasses import asdict , field , replace
10
11
from filecmp import dircmp
11
- from functools import cached_property , partial
12
+ from functools import cached_property , partial , wraps
12
13
from itertools import chain
13
14
from pathlib import Path
14
15
from shutil import rmtree
60
61
MISSING ,
61
62
AnyByStrDict ,
62
63
JSONSerializable ,
64
+ Operation ,
65
+ ParamSpec ,
63
66
RelativePath ,
64
67
StrOrPath ,
65
68
)
66
69
from .user_data import DEFAULT_DATA , AnswersMap , Question
67
70
from .vcs import get_git
68
71
69
72
_T = TypeVar ("_T" )
73
+ _P = ParamSpec ("_P" )
74
+
75
+ _operation : ContextVar [Operation ] = ContextVar ("_operation" )
76
+
77
+
78
+ def as_operation (value : Operation ) -> Callable [[Callable [_P , _T ]], Callable [_P , _T ]]:
79
+ """Decorator to set the current operation context, if not defined already.
80
+
81
+ This value is used to template specific configuration options.
82
+ """
83
+
84
+ def _decorator (func : Callable [_P , _T ]) -> Callable [_P , _T ]:
85
+ @wraps (func )
86
+ def _wrapper (* args : _P .args , ** kwargs : _P .kwargs ) -> _T :
87
+ token = _operation .set (_operation .get (value ))
88
+ try :
89
+ return func (* args , ** kwargs )
90
+ finally :
91
+ _operation .reset (token )
92
+
93
+ return _wrapper
94
+
95
+ return _decorator
70
96
71
97
72
98
@dataclass (config = ConfigDict (extra = "forbid" ))
@@ -243,7 +269,7 @@ def _cleanup(self) -> None:
243
269
for method in self ._cleanup_hooks :
244
270
method ()
245
271
246
- def _check_unsafe (self , mode : Literal [ "copy" , "update" ] ) -> None :
272
+ def _check_unsafe (self , mode : Operation ) -> None :
247
273
"""Check whether a template uses unsafe features."""
248
274
if self .unsafe :
249
275
return
@@ -296,8 +322,10 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
296
322
Arguments:
297
323
tasks: The list of tasks to run.
298
324
"""
325
+ operation = _operation .get ()
299
326
for i , task in enumerate (tasks ):
300
327
extra_context = {f"_{ k } " : v for k , v in task .extra_vars .items ()}
328
+ extra_context ["_operation" ] = operation
301
329
302
330
if not cast_to_bool (self ._render_value (task .condition , extra_context )):
303
331
continue
@@ -327,7 +355,7 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
327
355
/ Path (self ._render_string (str (task .working_directory ), extra_context ))
328
356
).absolute ()
329
357
330
- extra_env = {k .upper (): str (v ) for k , v in task . extra_vars .items ()}
358
+ extra_env = {k [ 1 :] .upper (): str (v ) for k , v in extra_context .items ()}
331
359
with local .cwd (working_directory ), local .env (** extra_env ):
332
360
subprocess .run (task_cmd , shell = use_shell , check = True , env = local .env )
333
361
@@ -588,7 +616,14 @@ def _pathjoin(
588
616
@cached_property
589
617
def match_exclude (self ) -> Callable [[Path ], bool ]:
590
618
"""Get a callable to match paths against all exclusions."""
591
- return self ._path_matcher (self .all_exclusions )
619
+ # Include the current operation in the rendering context.
620
+ # Note: This method is a cached property, it needs to be regenerated
621
+ # when reusing an instance in different contexts.
622
+ extra_context = {"_operation" : _operation .get ()}
623
+ return self ._path_matcher (
624
+ self ._render_string (exclusion , extra_context = extra_context )
625
+ for exclusion in self .all_exclusions
626
+ )
592
627
593
628
@cached_property
594
629
def match_skip (self ) -> Callable [[Path ], bool ]:
@@ -818,6 +853,7 @@ def template_copy_root(self) -> Path:
818
853
return self .template .local_abspath / subdir
819
854
820
855
# Main operations
856
+ @as_operation ("copy" )
821
857
def run_copy (self ) -> None :
822
858
"""Generate a subproject from zero, ignoring what was in the folder.
823
859
@@ -828,6 +864,11 @@ def run_copy(self) -> None:
828
864
829
865
See [generating a project][generating-a-project].
830
866
"""
867
+ with suppress (AttributeError ):
868
+ # We might have switched operation context, ensure the cached property
869
+ # is regenerated to re-render templates.
870
+ del self .match_exclude
871
+
831
872
self ._check_unsafe ("copy" )
832
873
self ._print_message (self .template .message_before_copy )
833
874
self ._ask ()
@@ -854,6 +895,7 @@ def run_copy(self) -> None:
854
895
# TODO Unify printing tools
855
896
print ("" ) # padding space
856
897
898
+ @as_operation ("copy" )
857
899
def run_recopy (self ) -> None :
858
900
"""Update a subproject, keeping answers but discarding evolution."""
859
901
if self .subproject .template is None :
@@ -864,6 +906,7 @@ def run_recopy(self) -> None:
864
906
with replace (self , src_path = self .subproject .template .url ) as new_worker :
865
907
new_worker .run_copy ()
866
908
909
+ @as_operation ("update" )
867
910
def run_update (self ) -> None :
868
911
"""Update a subproject that was already generated.
869
912
@@ -911,6 +954,11 @@ def run_update(self) -> None:
911
954
print (
912
955
f"Updating to template version { self .template .version } " , file = sys .stderr
913
956
)
957
+ with suppress (AttributeError ):
958
+ # We might have switched operation context, ensure the cached property
959
+ # is regenerated to re-render templates.
960
+ del self .match_exclude
961
+
914
962
self ._apply_update ()
915
963
self ._print_message (self .template .message_after_update )
916
964
0 commit comments