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 ADDED
@@ -0,0 +1,11 @@
1
+ from .controller import Controller
2
+ from .manager import create_task, Manager
3
+ from .task import Task
4
+
5
+
6
+ __all__ = [
7
+ "Controller",
8
+ "create_task",
9
+ "Manager",
10
+ "Task",
11
+ ]
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)