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/__init__.py +11 -0
- cook/__main__.py +392 -0
- cook/actions.py +215 -0
- cook/contexts.py +307 -0
- cook/controller.py +473 -0
- cook/manager.py +168 -0
- cook/task.py +50 -0
- cook/util.py +110 -0
- cook_build-0.6.4.dist-info/METADATA +110 -0
- cook_build-0.6.4.dist-info/RECORD +14 -0
- cook_build-0.6.4.dist-info/WHEEL +5 -0
- cook_build-0.6.4.dist-info/entry_points.txt +2 -0
- cook_build-0.6.4.dist-info/licenses/LICENSE +28 -0
- cook_build-0.6.4.dist-info/top_level.txt +1 -0
cook/__init__.py
ADDED
cook/__main__.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import colorama
|
|
3
|
+
from contextlib import closing
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import fnmatch
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
import textwrap
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
from .contexts import (
|
|
17
|
+
create_target_directories,
|
|
18
|
+
normalize_action,
|
|
19
|
+
normalize_dependencies,
|
|
20
|
+
)
|
|
21
|
+
from .controller import Controller, QUERIES
|
|
22
|
+
from .manager import Manager
|
|
23
|
+
from .task import Task
|
|
24
|
+
from .util import FailedTaskError, format_datetime, format_timedelta
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LOGGER = logging.getLogger("cook")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NoMatchingTaskError(ValueError):
|
|
31
|
+
def __init__(
|
|
32
|
+
self, patterns: Iterable[re.Pattern | str], hidden_tasks_available: bool
|
|
33
|
+
) -> None:
|
|
34
|
+
self.hidden_tasks_available = hidden_tasks_available
|
|
35
|
+
formatted_patterns = [f"`{pattern}`" for pattern in patterns]
|
|
36
|
+
if len(formatted_patterns) == 1:
|
|
37
|
+
message = f"found no tasks matching pattern {formatted_patterns[0]}"
|
|
38
|
+
else:
|
|
39
|
+
*formatted_patterns, last = formatted_patterns
|
|
40
|
+
message = (
|
|
41
|
+
"found no tasks matching patterns "
|
|
42
|
+
+ ", ".join(formatted_patterns)
|
|
43
|
+
+ (", or " if len(formatted_patterns) > 1 else " or ")
|
|
44
|
+
+ last
|
|
45
|
+
)
|
|
46
|
+
if hidden_tasks_available:
|
|
47
|
+
message = message + "; use --all or -a to include hidden tasks"
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Args:
|
|
52
|
+
tasks: Iterable[re.Pattern | str]
|
|
53
|
+
re: bool
|
|
54
|
+
all: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Command:
|
|
58
|
+
"""
|
|
59
|
+
Abstract base class for commands.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
NAME: str | None = None
|
|
63
|
+
ALLOW_EMPTY_PATTERN: bool = False
|
|
64
|
+
|
|
65
|
+
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--re",
|
|
68
|
+
"-r",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="use regular expressions for pattern matching instead of glob",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--all",
|
|
74
|
+
"-a",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="include tasks starting with `_` prefix",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"tasks",
|
|
80
|
+
nargs="*" if self.ALLOW_EMPTY_PATTERN else "+",
|
|
81
|
+
help="task or tasks to execute as regular expressions or glob patterns",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def execute(self, controller: Controller, args: argparse.Namespace) -> None:
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
|
|
87
|
+
def discover_tasks(self, controller: Controller, args: Args) -> list[Task]:
|
|
88
|
+
task: Task
|
|
89
|
+
tasks: list[Task] = []
|
|
90
|
+
|
|
91
|
+
# Get tasks based on the pattern matching.
|
|
92
|
+
if not args.tasks:
|
|
93
|
+
tasks = list(controller.dependencies)
|
|
94
|
+
else:
|
|
95
|
+
for task in controller.dependencies:
|
|
96
|
+
if args.re:
|
|
97
|
+
match = any(re.match(pattern, task.name) for pattern in args.tasks)
|
|
98
|
+
else:
|
|
99
|
+
match = any(
|
|
100
|
+
fnmatch.fnmatch(task.name, pattern) # pyright: ignore[reportArgumentType]
|
|
101
|
+
for pattern in args.tasks
|
|
102
|
+
)
|
|
103
|
+
if match:
|
|
104
|
+
tasks.append(task)
|
|
105
|
+
|
|
106
|
+
# Store whether any of the candidates are hidden by default.
|
|
107
|
+
has_hidden_task = any(task.name.startswith("_") for task in tasks)
|
|
108
|
+
|
|
109
|
+
# Filter out hidden tasks if desired unless the name is an exact match to a specified
|
|
110
|
+
# pattern.
|
|
111
|
+
if not args.all:
|
|
112
|
+
tasks = [
|
|
113
|
+
task
|
|
114
|
+
for task in tasks
|
|
115
|
+
if not task.name.startswith("_") or task.name in args.tasks
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if not tasks:
|
|
119
|
+
raise NoMatchingTaskError(args.tasks, not args.all and has_hidden_task)
|
|
120
|
+
return tasks
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ExecArgs(Args):
|
|
124
|
+
jobs: int
|
|
125
|
+
dry_run: bool
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ExecCommand(Command):
|
|
129
|
+
"""
|
|
130
|
+
Execute one or more tasks.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
NAME = "exec"
|
|
134
|
+
|
|
135
|
+
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--jobs", "-j", help="number of concurrent jobs", type=int, default=1
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--dry-run",
|
|
141
|
+
"-n",
|
|
142
|
+
help="show what \033[1mwould\033[0m be executed without running tasks",
|
|
143
|
+
action="store_true",
|
|
144
|
+
dest="dry_run",
|
|
145
|
+
)
|
|
146
|
+
super().configure_parser(parser)
|
|
147
|
+
|
|
148
|
+
def execute(self, controller: Controller, args: ExecArgs) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
149
|
+
tasks = self.discover_tasks(controller, args)
|
|
150
|
+
controller.execute(tasks, num_concurrent=args.jobs, dry_run=args.dry_run)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class LsArgs(Args):
|
|
154
|
+
stale: bool
|
|
155
|
+
current: bool
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class LsCommand(Command):
|
|
159
|
+
"""
|
|
160
|
+
List tasks.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
NAME = "ls"
|
|
164
|
+
ALLOW_EMPTY_PATTERN = True
|
|
165
|
+
|
|
166
|
+
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--stale", "-s", action="store_true", help="only show stale tasks"
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"--current", "-c", action="store_true", help="only show current tasks"
|
|
172
|
+
)
|
|
173
|
+
super().configure_parser(parser)
|
|
174
|
+
|
|
175
|
+
def execute(self, controller: Controller, args: LsArgs) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
176
|
+
tasks = self.discover_tasks(controller, args)
|
|
177
|
+
tasks = [
|
|
178
|
+
task.format(colorama.Fore.YELLOW if is_stale else colorama.Fore.GREEN)
|
|
179
|
+
for is_stale, task in zip(controller.is_stale(tasks), tasks)
|
|
180
|
+
]
|
|
181
|
+
print("\n".join(tasks))
|
|
182
|
+
|
|
183
|
+
def discover_tasks(self, controller: Controller, args: LsArgs) -> list[Task]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
184
|
+
if args.current and args.stale:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"only one of `--stale` and `--current` may be given at the same time"
|
|
187
|
+
)
|
|
188
|
+
tasks = super().discover_tasks(controller, args)
|
|
189
|
+
if args.stale:
|
|
190
|
+
return [
|
|
191
|
+
task for stale, task in zip(controller.is_stale(tasks), tasks) if stale
|
|
192
|
+
]
|
|
193
|
+
elif args.current:
|
|
194
|
+
return [
|
|
195
|
+
task
|
|
196
|
+
for stale, task in zip(controller.is_stale(tasks), tasks)
|
|
197
|
+
if not stale
|
|
198
|
+
]
|
|
199
|
+
return tasks
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class InfoCommand(LsCommand):
|
|
203
|
+
"""
|
|
204
|
+
Display information about one or more tasks.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
NAME = "info"
|
|
208
|
+
|
|
209
|
+
def execute(self, controller: Controller, args: LsArgs) -> None:
|
|
210
|
+
tasks = self.discover_tasks(controller, args)
|
|
211
|
+
stales = controller.is_stale(tasks)
|
|
212
|
+
|
|
213
|
+
stale_string = f"{colorama.Fore.YELLOW}stale{colorama.Fore.RESET}"
|
|
214
|
+
current_string = f"{colorama.Fore.GREEN}current{colorama.Fore.RESET}"
|
|
215
|
+
indent = " "
|
|
216
|
+
|
|
217
|
+
task: Task
|
|
218
|
+
for stale, task in zip(stales, tasks):
|
|
219
|
+
# Show the status.
|
|
220
|
+
parts = [
|
|
221
|
+
f"status: {stale_string if stale else current_string}",
|
|
222
|
+
]
|
|
223
|
+
# Show when the task last completed and failed.
|
|
224
|
+
last = controller.connection.execute(
|
|
225
|
+
"SELECT last_started, last_completed, last_failed FROM tasks WHERE name = :name",
|
|
226
|
+
{"name": task.name},
|
|
227
|
+
).fetchone() or (None, None, None)
|
|
228
|
+
for key, value in zip(["started", "completed", "failed"], last):
|
|
229
|
+
if value is None:
|
|
230
|
+
parts.append(f"last {key}: -")
|
|
231
|
+
continue
|
|
232
|
+
parts.append(
|
|
233
|
+
f"last {key}: {format_timedelta(datetime.now() - value)} ago "
|
|
234
|
+
f"({format_datetime(value)})"
|
|
235
|
+
)
|
|
236
|
+
# Show dependencies and targets.
|
|
237
|
+
task_dependencies = list(
|
|
238
|
+
sorted(controller.dependencies.successors(task), key=lambda t: t.name)
|
|
239
|
+
)
|
|
240
|
+
task_dependencies = [
|
|
241
|
+
dep.format(colorama.Fore.YELLOW if is_stale else colorama.Fore.GREEN)
|
|
242
|
+
for is_stale, dep in zip(
|
|
243
|
+
controller.is_stale(task_dependencies), task_dependencies
|
|
244
|
+
)
|
|
245
|
+
]
|
|
246
|
+
items = [
|
|
247
|
+
("dependencies", task.dependencies),
|
|
248
|
+
("targets", task.targets),
|
|
249
|
+
("task_dependencies", task_dependencies),
|
|
250
|
+
]
|
|
251
|
+
for key, value in items:
|
|
252
|
+
value = "\n".join(map(str, value))
|
|
253
|
+
if value:
|
|
254
|
+
parts.append(f"{key}:\n{textwrap.indent(value, indent)}")
|
|
255
|
+
else:
|
|
256
|
+
parts.append(f"{key}: -")
|
|
257
|
+
parts.append(f"action: {task.action if task.action else '-'}")
|
|
258
|
+
|
|
259
|
+
parts = textwrap.indent("\n".join(parts), indent)
|
|
260
|
+
print(f"{task}\n{parts}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class ResetArgs(Args):
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ResetCommand(Command):
|
|
268
|
+
"""
|
|
269
|
+
Reset the status of one or more tasks.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
NAME = "reset"
|
|
273
|
+
|
|
274
|
+
def execute(self, controller: Controller, args: ResetArgs) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
275
|
+
tasks = self.discover_tasks(controller, args)
|
|
276
|
+
controller.reset(*tasks)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Formatter(logging.Formatter):
|
|
280
|
+
COLOR_BY_LEVEL = {
|
|
281
|
+
"DEBUG": colorama.Fore.MAGENTA,
|
|
282
|
+
"INFO": colorama.Fore.BLUE,
|
|
283
|
+
"WARNING": colorama.Fore.YELLOW,
|
|
284
|
+
"ERROR": colorama.Fore.RED,
|
|
285
|
+
"CRITICAL": colorama.Fore.WHITE + colorama.Back.RED,
|
|
286
|
+
}
|
|
287
|
+
RESET = colorama.Fore.RESET + colorama.Back.RESET
|
|
288
|
+
|
|
289
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
290
|
+
color = self.COLOR_BY_LEVEL[record.levelname]
|
|
291
|
+
formatted = f"{color}{record.levelname}{self.RESET}: {record.getMessage()}"
|
|
292
|
+
if record.exc_info:
|
|
293
|
+
formatted = f"{formatted}\n{self.formatException(record.exc_info)}"
|
|
294
|
+
return formatted
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def __main__(cli_args: list[str] | None = None) -> None:
|
|
298
|
+
parser = argparse.ArgumentParser("cook")
|
|
299
|
+
parser.add_argument(
|
|
300
|
+
"--recipe",
|
|
301
|
+
help="file containing declarative recipe for tasks",
|
|
302
|
+
default="recipe.py",
|
|
303
|
+
type=Path,
|
|
304
|
+
)
|
|
305
|
+
parser.add_argument(
|
|
306
|
+
"--module", "-m", help="module containing declarative recipe for tasks"
|
|
307
|
+
)
|
|
308
|
+
parser.add_argument(
|
|
309
|
+
"--db", help="database for keeping track of assets", default=".cook"
|
|
310
|
+
)
|
|
311
|
+
parser.add_argument(
|
|
312
|
+
"--log-level",
|
|
313
|
+
help="log level",
|
|
314
|
+
default="info",
|
|
315
|
+
choices={"error", "warning", "info", "debug"},
|
|
316
|
+
)
|
|
317
|
+
subparsers = parser.add_subparsers()
|
|
318
|
+
subparsers.required = True
|
|
319
|
+
|
|
320
|
+
for command_cls in [ExecCommand, LsCommand, InfoCommand, ResetCommand]:
|
|
321
|
+
subparser = subparsers.add_parser(command_cls.NAME, help=command_cls.__doc__)
|
|
322
|
+
command = command_cls()
|
|
323
|
+
command.configure_parser(subparser)
|
|
324
|
+
subparser.set_defaults(command=command)
|
|
325
|
+
|
|
326
|
+
args = parser.parse_args(cli_args)
|
|
327
|
+
|
|
328
|
+
handler = logging.StreamHandler()
|
|
329
|
+
handler.setFormatter(Formatter())
|
|
330
|
+
logging.basicConfig(level=args.log_level.upper(), handlers=[handler])
|
|
331
|
+
|
|
332
|
+
with Manager() as manager:
|
|
333
|
+
try:
|
|
334
|
+
manager.contexts.extend(
|
|
335
|
+
[
|
|
336
|
+
create_target_directories(),
|
|
337
|
+
normalize_action(),
|
|
338
|
+
normalize_dependencies(),
|
|
339
|
+
]
|
|
340
|
+
)
|
|
341
|
+
if args.module:
|
|
342
|
+
# Temporarily add the current working directory to the path.
|
|
343
|
+
try:
|
|
344
|
+
sys.path.append(os.getcwd())
|
|
345
|
+
importlib.import_module(args.module)
|
|
346
|
+
finally:
|
|
347
|
+
sys.path.pop()
|
|
348
|
+
elif args.recipe.is_file():
|
|
349
|
+
# Parse the recipe.
|
|
350
|
+
spec = importlib.util.spec_from_file_location("recipe", args.recipe)
|
|
351
|
+
assert spec, f"Could not load spec for '{args.recipe}'."
|
|
352
|
+
recipe = importlib.util.module_from_spec(spec)
|
|
353
|
+
assert spec.loader, f"Could not load recipe '{args.recipe}'."
|
|
354
|
+
spec.loader.exec_module(recipe)
|
|
355
|
+
else: # pragma: no cover
|
|
356
|
+
raise ValueError(
|
|
357
|
+
"recipe file or module must be specified; default recipe.py not "
|
|
358
|
+
"found"
|
|
359
|
+
)
|
|
360
|
+
except: # noqa: E722
|
|
361
|
+
LOGGER.fatal("failed to load recipe", exc_info=sys.exc_info())
|
|
362
|
+
sys.exit(1)
|
|
363
|
+
|
|
364
|
+
with closing(
|
|
365
|
+
sqlite3.connect(args.db, detect_types=sqlite3.PARSE_DECLTYPES)
|
|
366
|
+
) as connection:
|
|
367
|
+
_setup_schema(connection)
|
|
368
|
+
controller = Controller(manager.resolve_dependencies(), connection)
|
|
369
|
+
command: Command = args.command
|
|
370
|
+
try:
|
|
371
|
+
command.execute(controller, args)
|
|
372
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
373
|
+
LOGGER.warning("interrupted by user")
|
|
374
|
+
except NoMatchingTaskError as ex:
|
|
375
|
+
LOGGER.warning(ex)
|
|
376
|
+
sys.exit(1)
|
|
377
|
+
except FailedTaskError:
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _setup_schema(connection: sqlite3.Connection) -> None:
|
|
382
|
+
connection.executescript(QUERIES["schema"])
|
|
383
|
+
# Attempt to add the column which may not be present for cook versions <0.6.
|
|
384
|
+
try:
|
|
385
|
+
connection.execute("ALTER TABLE tasks ADD COLUMN last_started TIMESTAMP")
|
|
386
|
+
except sqlite3.OperationalError as ex:
|
|
387
|
+
if "duplicate column name" not in ex.args[0]:
|
|
388
|
+
raise # pragma: no cover
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == "__main__":
|
|
392
|
+
__main__()
|
cook/actions.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Actions
|
|
3
|
+
-------
|
|
4
|
+
|
|
5
|
+
Actions are performed when tasks are executed. Builtin actions include calling python functions
|
|
6
|
+
using :class:`.FunctionAction`, running subprocesses using :class:`.SubprocessAction`, composing
|
|
7
|
+
multiple actions using :class:`.CompositeAction`, and executing modules as scripts using
|
|
8
|
+
:class:`.ModuleAction`.
|
|
9
|
+
|
|
10
|
+
Custom actions can be implemented by inheriting from :class:`.Action` and implementing the
|
|
11
|
+
:meth:`~.Action.execute` method which receives a :class:`~.task.Task`. The method should execute the
|
|
12
|
+
action; its return value is ignored. For example, the following action waits for a specified time.
|
|
13
|
+
|
|
14
|
+
.. doctest::
|
|
15
|
+
|
|
16
|
+
>>> from cook.actions import Action
|
|
17
|
+
>>> from cook.task import Task
|
|
18
|
+
>>> from time import sleep, time
|
|
19
|
+
|
|
20
|
+
>>> class SleepAction(Action):
|
|
21
|
+
... def __init__(self, delay: float) -> None:
|
|
22
|
+
... self.delay = delay
|
|
23
|
+
...
|
|
24
|
+
... def execute(self, task: Task) -> None:
|
|
25
|
+
... start = time()
|
|
26
|
+
... sleep(self.delay)
|
|
27
|
+
... print(f"time: {time() - start:.3f}")
|
|
28
|
+
|
|
29
|
+
>>> action = SleepAction(0.1)
|
|
30
|
+
>>> action.execute(None)
|
|
31
|
+
time: 0.1...
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import os
|
|
36
|
+
import shlex
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from types import ModuleType
|
|
40
|
+
from typing import Callable, TYPE_CHECKING
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from .task import Task
|
|
45
|
+
from .util import StopEvent
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Action:
|
|
49
|
+
"""
|
|
50
|
+
Action to perform when a task is executed in its own thread.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def execute(self, task: "Task", stop: "StopEvent | None" = None) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Execute the action.
|
|
56
|
+
"""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def hexdigest(self) -> str | None:
|
|
61
|
+
"""
|
|
62
|
+
Optional digest to check if an action changed.
|
|
63
|
+
"""
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FunctionAction(Action):
|
|
68
|
+
"""
|
|
69
|
+
Action wrapping a python callable.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
func: Function to call which must accept a :class:`~.task.Task` as its first argument.
|
|
73
|
+
*args: Additional positional arguments.
|
|
74
|
+
**kwargs: Keyword arguments.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, func: Callable, *args, **kwargs) -> None:
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.func = func
|
|
80
|
+
self.args = args
|
|
81
|
+
self.kwargs = kwargs
|
|
82
|
+
|
|
83
|
+
def execute(self, task: "Task", stop: "StopEvent | None" = None) -> None:
|
|
84
|
+
self.func(task, *self.args, **self.kwargs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SubprocessAction(Action):
|
|
88
|
+
"""
|
|
89
|
+
Run a subprocess.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
*args: Positional arguments for :class:`subprocess.Popen`.
|
|
93
|
+
**kwargs: Keyword arguments for :class:`subprocess.Popen`.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
|
|
97
|
+
.. doctest::
|
|
98
|
+
|
|
99
|
+
>>> from cook.actions import SubprocessAction
|
|
100
|
+
>>> from pathlib import Path
|
|
101
|
+
|
|
102
|
+
>>> action = SubprocessAction(["touch", "hello.txt"])
|
|
103
|
+
>>> action.execute(None)
|
|
104
|
+
>>> Path("hello.txt").is_file()
|
|
105
|
+
True
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
109
|
+
self.args = args
|
|
110
|
+
self.kwargs = kwargs
|
|
111
|
+
|
|
112
|
+
def execute(self, task: "Task", stop: "StopEvent | None" = None) -> None:
|
|
113
|
+
# Repeatedly wait for the process to complete, checking the stop event after each poll.
|
|
114
|
+
interval = stop.interval if stop else None
|
|
115
|
+
process = subprocess.Popen(*self.args, **self.kwargs)
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
returncode = process.wait(interval)
|
|
119
|
+
if returncode:
|
|
120
|
+
raise subprocess.CalledProcessError(returncode, process.args)
|
|
121
|
+
return
|
|
122
|
+
except subprocess.TimeoutExpired:
|
|
123
|
+
if stop and stop.is_set():
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# Clean up the process by trying to terminate it and then killing it.
|
|
127
|
+
for method in [process.terminate, process.kill]:
|
|
128
|
+
method()
|
|
129
|
+
try:
|
|
130
|
+
returncode = process.wait(max(interval, 3) if interval else None)
|
|
131
|
+
if returncode:
|
|
132
|
+
raise subprocess.CalledProcessError(returncode, process.args)
|
|
133
|
+
# The process managed to exit gracefully after the main loop. This is unlikely.
|
|
134
|
+
return # pragma: no cover
|
|
135
|
+
except subprocess.TimeoutExpired: # pragma: no cover
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# We couldn't kill the process. Also very unlikely.
|
|
139
|
+
raise subprocess.SubprocessError(
|
|
140
|
+
f"failed to shut down {process}"
|
|
141
|
+
) # pragma: no cover
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def hexdigest(self) -> str:
|
|
145
|
+
hasher = hashlib.sha1()
|
|
146
|
+
(args,) = self.args
|
|
147
|
+
if isinstance(args, str):
|
|
148
|
+
hasher.update(args.encode())
|
|
149
|
+
else:
|
|
150
|
+
for arg in args:
|
|
151
|
+
hasher.update(arg.encode())
|
|
152
|
+
return hasher.hexdigest()
|
|
153
|
+
|
|
154
|
+
def __repr__(self) -> str:
|
|
155
|
+
args, *_ = self.args
|
|
156
|
+
if not isinstance(args, str):
|
|
157
|
+
args = " ".join(map(shlex.quote, args))
|
|
158
|
+
return f"{self.__class__.__name__}({repr(args)})"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CompositeAction(Action):
|
|
162
|
+
"""
|
|
163
|
+
Execute multiple actions sequentially.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
*actions: Actions to execute.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, *actions: Action) -> None:
|
|
170
|
+
self.actions = actions
|
|
171
|
+
|
|
172
|
+
def execute(self, task: "Task", stop: "StopEvent | None" = None) -> None:
|
|
173
|
+
for action in self.actions:
|
|
174
|
+
action.execute(task, stop)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def hexdigest(self) -> str | None:
|
|
178
|
+
parts = []
|
|
179
|
+
for action in self.actions:
|
|
180
|
+
hexdigest = action.hexdigest
|
|
181
|
+
if hexdigest is None:
|
|
182
|
+
return None
|
|
183
|
+
parts.append(hexdigest)
|
|
184
|
+
return "".join(parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ModuleAction(SubprocessAction):
|
|
188
|
+
"""
|
|
189
|
+
Execute a module as a script.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
args: List comprising the module to execute as the first element and arguments for the
|
|
193
|
+
module as subsequent elements.
|
|
194
|
+
debug: Run the module using `pdb` (defaults to the :code:`COOK_DEBUG` environment variable
|
|
195
|
+
being set).
|
|
196
|
+
**kwargs: Keyword arguments for :class:`subprocess.Popen`.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, args: list, debug: bool | None = None, **kwargs) -> None:
|
|
200
|
+
if kwargs.get("shell"):
|
|
201
|
+
raise ValueError("shell execution is not supported by `ModuleAction`")
|
|
202
|
+
if not args:
|
|
203
|
+
raise ValueError("`args` must not be empty")
|
|
204
|
+
module: ModuleType
|
|
205
|
+
module, *args = args
|
|
206
|
+
if not isinstance(module, ModuleType):
|
|
207
|
+
raise TypeError("first element of `args` must be a module")
|
|
208
|
+
|
|
209
|
+
# Assemble the arguments.
|
|
210
|
+
args_ = [sys.executable, "-m"]
|
|
211
|
+
debug = "COOK_DEBUG" in os.environ if debug is None else debug
|
|
212
|
+
if debug:
|
|
213
|
+
args_.extend(["pdb", "-m"])
|
|
214
|
+
args_.extend([module.__name__, *map(str, args)])
|
|
215
|
+
super().__init__(args_, **kwargs)
|