cook-build 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cook/contexts.py ADDED
@@ -0,0 +1,307 @@
1
+ r"""
2
+ Contexts
3
+ --------
4
+
5
+ :class:`.Context`\ s can modify the creation of :class:`~.task.Task`\ s and are activated using the
6
+ :code:`with` keyword. Builtin contexts handle the majority of Cook's task creation logic, such as
7
+ :class:`.normalize_dependencies` and :class:`.create_group`. The innermost context is applied first.
8
+
9
+ Custom contexts can be implemented by inheriting from :class:`.Context` and implementing the
10
+ :meth:`~.Context.apply` method which receives a :class:`~.task.Task` and must return a
11
+ :class:`~.task.Task`. For example, the following context converts all task names to uppercase.
12
+
13
+ .. doctest::
14
+
15
+ >>> from cook import create_task
16
+ >>> from cook.contexts import Context
17
+
18
+ >>> class UpperCaseContext(Context):
19
+ ... def apply(self, task):
20
+ ... task.name = task.name.upper()
21
+ ... return task
22
+
23
+ >>> # Using the contexts yields uppercase task names.
24
+ >>> with UpperCaseContext():
25
+ ... create_task("foo")
26
+ <task `FOO` @ ...>
27
+
28
+ >>> # create_task behaves as usual after the context is exited.
29
+ >>> create_task("bar")
30
+ <task `bar` @ ...>
31
+ """
32
+
33
+ from __future__ import annotations
34
+ from pathlib import Path
35
+ from types import ModuleType
36
+ from typing import Callable, TYPE_CHECKING, TypeVar
37
+ import warnings
38
+ from . import actions
39
+ from . import manager as manager_
40
+ from . import task as task_
41
+ from . import util
42
+
43
+
44
+ if TYPE_CHECKING:
45
+ from .manager import Manager
46
+ from .task import Task
47
+
48
+ ContextT = TypeVar("ContextT", bound="Context")
49
+
50
+
51
+ class Context:
52
+ """
53
+ Context that is applied to tasks when they are created.
54
+
55
+ Args:
56
+ manager: Manager to which the context is added.
57
+ """
58
+
59
+ def __init__(self, manager: "Manager | None" = None) -> None:
60
+ self.manager = manager or manager_.Manager.get_instance()
61
+
62
+ def __enter__(self: ContextT) -> ContextT:
63
+ self.manager.contexts.append(self)
64
+ return self
65
+
66
+ def __exit__(self, ex_type, ex_value, ex_traceback) -> None:
67
+ if not self.manager.contexts:
68
+ raise RuntimeError("exiting failed: no active contexts")
69
+ if self.manager.contexts[-1] is not self:
70
+ raise RuntimeError("exiting failed: unexpected context")
71
+ self.manager.contexts.pop()
72
+
73
+ def apply(self, task: "Task") -> "Task":
74
+ """
75
+ Apply the context to a task.
76
+
77
+ Args:
78
+ task: Task to modify.
79
+
80
+ Returns:
81
+ Modified task.
82
+ """
83
+ raise NotImplementedError
84
+
85
+
86
+ class FunctionContext(Context):
87
+ """
88
+ Context wrapping a function to modify or replace a task.
89
+
90
+ Args:
91
+ func: Function to call which must accept a :class:`~.task.Task` as its first argument and
92
+ return a :class:`~.task.Task`.
93
+ *args: Additional positional arguments.
94
+ **kwargs: Keyword arguments.
95
+
96
+ .. note::
97
+
98
+ :code:`manager` is a reserved keyword for the constructor of all contexts and cannot be used
99
+ as a keyword argument for the wrapped function.
100
+
101
+ Example:
102
+
103
+ .. doctest::
104
+
105
+ >>> from cook import create_task
106
+ >>> from cook.contexts import FunctionContext
107
+ >>> from cook.task import Task
108
+
109
+ >>> def repeat(task: Task, n: int) -> Task:
110
+ ... task.name = task.name * n
111
+ ... return task
112
+
113
+ >>> with FunctionContext(repeat, 3):
114
+ ... create_task("baz")
115
+ <task `bazbazbaz` @ ...>
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ func: Callable[["Task"], Task],
121
+ *args,
122
+ manager: "Manager | None" = None,
123
+ **kwargs,
124
+ ) -> None:
125
+ super().__init__(manager)
126
+ self.func = func
127
+ self.args = args or ()
128
+ self.kwargs = kwargs or {}
129
+
130
+ def apply(self, task: "Task") -> "Task":
131
+ return self.func(task, *self.args, **self.kwargs)
132
+
133
+
134
+ class create_target_directories(Context):
135
+ """
136
+ Create parent directories for all targets before the task is executed.
137
+
138
+ .. note::
139
+
140
+ This context is active by default.
141
+ """
142
+
143
+ def apply(self, task: "Task") -> Task:
144
+ for target in task.targets:
145
+ name = f"_create_target_directories:{target.parent}"
146
+ # No need to create a task if the parent already exists.
147
+ if target.parent.is_dir():
148
+ continue
149
+ # Create a task if necessary.
150
+ create = self.manager.tasks.get(name)
151
+ if create is None:
152
+ create = self.manager.create_task(
153
+ name,
154
+ action=actions.FunctionAction(
155
+ lambda _: target.parent.mkdir(parents=True, exist_ok=True)
156
+ ),
157
+ )
158
+ task.task_dependencies.append(create)
159
+ return task
160
+
161
+
162
+ class normalize_action(Context):
163
+ """
164
+ Normalize actions of tasks.
165
+
166
+ - If the action is a callable, it will be wrapped in a :class:`~.actions.FunctionAction`.
167
+ - If the action is a string, it will be executed using a :class:`~.actions.SubprocessAction`
168
+ with :code:`shell = True`.
169
+ - If the action is a list of actions, a :class:`~.actions.CompositeAction` will be created.
170
+ - If the action is a list and the first element is a module, a :class:`~.actions.ModuleAction`
171
+ will be created. A subsequent elements ared passed to the module as strings on the command
172
+ line.
173
+ - If the action is any other list, it will be executed using a
174
+ :class:`.actions.SubprocessAction` after converting elements to strings.
175
+
176
+ .. note::
177
+
178
+ This context is active by default.
179
+ """
180
+
181
+ def apply(self, task: "Task") -> "Task":
182
+ if isinstance(task.action, Callable):
183
+ task.action = actions.FunctionAction(task.action)
184
+ elif isinstance(task.action, str):
185
+ task.action = actions.SubprocessAction(task.action, shell=True)
186
+ elif isinstance(task.action, list):
187
+ if not task.action:
188
+ raise ValueError("action must not be an empty list")
189
+ if all(isinstance(x, actions.Action) for x in task.action):
190
+ task.action = actions.CompositeAction(*task.action)
191
+ elif isinstance(task.action[0], ModuleType):
192
+ task.action = actions.ModuleAction(task.action)
193
+ else:
194
+ task.action = actions.SubprocessAction(list(map(str, task.action)))
195
+ return task
196
+
197
+
198
+ class normalize_dependencies(Context):
199
+ """
200
+ Normalize dependencies of tasks.
201
+
202
+ - If a dependency is a string, it will be converted to a :class:`~pathlib.Path`.
203
+ - If a dependency is a :class:`~.task.Task` or :class:`.create_group`, it will be removed and
204
+ added to the :code:`task_dependencies` of the task.
205
+ - If a task dependency is a string, the corresponding task will be looked up by name.
206
+ - If a task dependency is a group, it will be replaced by the corresponding meta task.
207
+
208
+ .. note::
209
+
210
+ This context is active by default.
211
+ """
212
+
213
+ def apply(self, task: "Task") -> "Task":
214
+ # Move task and group dependencies to the task_dependencies if they appear in regular
215
+ # dependencies.
216
+ dependencies = []
217
+ task_dependencies = task.task_dependencies
218
+ for dependency in task.dependencies:
219
+ if isinstance(dependency, (task_.Task, create_group)):
220
+ warnings.warn(
221
+ "Passing Task objects to 'dependencies' is deprecated. Use "
222
+ "'task_dependencies' instead.",
223
+ DeprecationWarning,
224
+ stacklevel=4,
225
+ )
226
+ task_dependencies.append(dependency)
227
+ else:
228
+ dependencies.append(dependency)
229
+ # Convert all remaining dependencies (strings) to Path objects.
230
+ # After normalization, dependencies list contains only Path objects, but the
231
+ # Task.dependencies attribute is typed as list[PathOrStr | Task] to accept broader
232
+ # input before normalization. The isinstance checks in controller.py and manager.py
233
+ # validate this assumption at runtime.
234
+ task.dependencies = [Path(x) for x in dependencies] # type: ignore[assignment]
235
+
236
+ # Unpack group dependencies and look up tasks by name.
237
+ task_dependencies = []
238
+ for other in task.task_dependencies:
239
+ if isinstance(other, create_group):
240
+ other = other.task
241
+ elif isinstance(other, str):
242
+ other = self.manager.tasks[other]
243
+ task_dependencies.append(other)
244
+ task.task_dependencies = task_dependencies
245
+
246
+ return task
247
+
248
+
249
+ class create_group(Context):
250
+ """
251
+ Context for grouping tasks. A task with the same name will be created.
252
+
253
+ Args:
254
+ name: Name of the group.
255
+ manager: Manager to which the context is added.
256
+ location: Location at which the group was created as a tuple :code:`(filename, lineno)`
257
+ (defaults to :func:`~.util.get_location`).
258
+
259
+ Example:
260
+
261
+ .. doctest::
262
+
263
+ >>> from cook import create_task
264
+ >>> from cook.contexts import create_group
265
+
266
+ >>> with create_group("my_group") as my_group:
267
+ ... create_task("task1")
268
+ ... create_task("task2")
269
+ <task `task1` @ ...>
270
+ <task `task2` @ ...>
271
+
272
+ >>> my_group
273
+ <group `my_group` @ ... with 2 tasks>
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ name: str,
279
+ manager: "Manager | None" = None,
280
+ location: tuple[str, int] | None = None,
281
+ ) -> None:
282
+ super().__init__(manager)
283
+ self.name = name
284
+ self.task: task_.Task | None = None
285
+ self.location = location or util.get_location()
286
+
287
+ def apply(self, task: "Task") -> "Task":
288
+ # Skip if we're creating the task for the group itself to avoid infinite recursion.
289
+ if task.name == self.name:
290
+ return task
291
+ if self.task is None:
292
+ self.task = self.manager.create_task(self.name)
293
+ self.task.task_dependencies.append(task)
294
+ return task
295
+
296
+ def __exit__(self, ex_type, ex_value, ex_traceback) -> None:
297
+ super().__exit__(ex_type, ex_value, ex_traceback)
298
+ # Raise an error if the group was successfully created but no tasks were added.
299
+ has_dependents = self.task and self.task.task_dependencies
300
+ if not has_dependents and not ex_value:
301
+ raise RuntimeError(f"group `{self.name}` has no tasks")
302
+
303
+ def __repr__(self) -> str:
304
+ filename, lineno = self.location
305
+ num_tasks = len(self.task.task_dependencies) if self.task else 0
306
+ desc = "task" if num_tasks == 1 else "tasks"
307
+ return f"<group `{self.name}` @ {filename}:{lineno} with {num_tasks} {desc}>"