cook-build 0.6.2__tar.gz → 0.6.4__tar.gz
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_build-0.6.2/src/cook_build.egg-info → cook_build-0.6.4}/PKG-INFO +1 -1
- {cook_build-0.6.2 → cook_build-0.6.4}/pyproject.toml +1 -1
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/__main__.py +9 -1
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/contexts.py +18 -4
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/controller.py +83 -43
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/manager.py +10 -4
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/task.py +1 -1
- {cook_build-0.6.2 → cook_build-0.6.4/src/cook_build.egg-info}/PKG-INFO +1 -1
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_actions.py +10 -10
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_contexts.py +26 -7
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_controller.py +1 -1
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_main.py +78 -7
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_manager.py +1 -1
- {cook_build-0.6.2 → cook_build-0.6.4}/LICENSE +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/README.md +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/README.rst +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/setup.cfg +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/__init__.py +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/actions.py +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/util.py +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/SOURCES.txt +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/dependency_links.txt +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/entry_points.txt +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/requires.txt +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/top_level.txt +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_examples.py +0 -0
- {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_util.py +0 -0
|
@@ -122,6 +122,7 @@ class Command:
|
|
|
122
122
|
|
|
123
123
|
class ExecArgs(Args):
|
|
124
124
|
jobs: int
|
|
125
|
+
dry_run: bool
|
|
125
126
|
|
|
126
127
|
|
|
127
128
|
class ExecCommand(Command):
|
|
@@ -135,11 +136,18 @@ class ExecCommand(Command):
|
|
|
135
136
|
parser.add_argument(
|
|
136
137
|
"--jobs", "-j", help="number of concurrent jobs", type=int, default=1
|
|
137
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
|
+
)
|
|
138
146
|
super().configure_parser(parser)
|
|
139
147
|
|
|
140
148
|
def execute(self, controller: Controller, args: ExecArgs) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
141
149
|
tasks = self.discover_tasks(controller, args)
|
|
142
|
-
controller.execute(tasks, num_concurrent=args.jobs)
|
|
150
|
+
controller.execute(tasks, num_concurrent=args.jobs, dry_run=args.dry_run)
|
|
143
151
|
|
|
144
152
|
|
|
145
153
|
class LsArgs(Args):
|
|
@@ -33,7 +33,8 @@ Custom contexts can be implemented by inheriting from :class:`.Context` and impl
|
|
|
33
33
|
from __future__ import annotations
|
|
34
34
|
from pathlib import Path
|
|
35
35
|
from types import ModuleType
|
|
36
|
-
from typing import Callable, TYPE_CHECKING
|
|
36
|
+
from typing import Callable, TYPE_CHECKING, TypeVar
|
|
37
|
+
import warnings
|
|
37
38
|
from . import actions
|
|
38
39
|
from . import manager as manager_
|
|
39
40
|
from . import task as task_
|
|
@@ -44,6 +45,8 @@ if TYPE_CHECKING:
|
|
|
44
45
|
from .manager import Manager
|
|
45
46
|
from .task import Task
|
|
46
47
|
|
|
48
|
+
ContextT = TypeVar("ContextT", bound="Context")
|
|
49
|
+
|
|
47
50
|
|
|
48
51
|
class Context:
|
|
49
52
|
"""
|
|
@@ -56,7 +59,7 @@ class Context:
|
|
|
56
59
|
def __init__(self, manager: "Manager | None" = None) -> None:
|
|
57
60
|
self.manager = manager or manager_.Manager.get_instance()
|
|
58
61
|
|
|
59
|
-
def __enter__(self) ->
|
|
62
|
+
def __enter__(self: ContextT) -> ContextT:
|
|
60
63
|
self.manager.contexts.append(self)
|
|
61
64
|
return self
|
|
62
65
|
|
|
@@ -214,10 +217,21 @@ class normalize_dependencies(Context):
|
|
|
214
217
|
task_dependencies = task.task_dependencies
|
|
215
218
|
for dependency in task.dependencies:
|
|
216
219
|
if isinstance(dependency, (task_.Task, create_group)):
|
|
217
|
-
|
|
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)
|
|
218
227
|
else:
|
|
219
228
|
dependencies.append(dependency)
|
|
220
|
-
|
|
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]
|
|
221
235
|
|
|
222
236
|
# Unpack group dependencies and look up tasks by name.
|
|
223
237
|
task_dependencies = []
|
|
@@ -109,6 +109,12 @@ class Controller:
|
|
|
109
109
|
"""
|
|
110
110
|
dependencies = []
|
|
111
111
|
for dependency in task.dependencies:
|
|
112
|
+
# Dependencies should be Path or str after normalize_dependencies runs.
|
|
113
|
+
# Tasks should have been moved to task_dependencies.
|
|
114
|
+
assert isinstance(dependency, (Path, str)), (
|
|
115
|
+
f"Unexpected dependency type '{type(dependency)}'. Dependencies "
|
|
116
|
+
"should be 'Path' or 'str' after 'normalize_dependencies'."
|
|
117
|
+
)
|
|
112
118
|
dependency = Path(dependency).resolve()
|
|
113
119
|
if not dependency.is_file():
|
|
114
120
|
LOGGER.debug("dependency %s of %s is missing", dependency, task)
|
|
@@ -227,7 +233,11 @@ class Controller:
|
|
|
227
233
|
return False
|
|
228
234
|
|
|
229
235
|
def execute(
|
|
230
|
-
self,
|
|
236
|
+
self,
|
|
237
|
+
tasks: "Task | list[Task]",
|
|
238
|
+
num_concurrent: int = 1,
|
|
239
|
+
interval: float = 1,
|
|
240
|
+
dry_run: bool = False,
|
|
231
241
|
) -> None:
|
|
232
242
|
"""
|
|
233
243
|
Execute one or more tasks.
|
|
@@ -235,6 +245,8 @@ class Controller:
|
|
|
235
245
|
Args:
|
|
236
246
|
tasks: Tasks to execute.
|
|
237
247
|
num_concurrent: Number of concurrent threads to run.
|
|
248
|
+
interval: Interval for checking stop events.
|
|
249
|
+
dry_run: If True, show what would execute without running tasks.
|
|
238
250
|
"""
|
|
239
251
|
if not isinstance(tasks, Sequence):
|
|
240
252
|
tasks = [tasks]
|
|
@@ -250,7 +262,7 @@ class Controller:
|
|
|
250
262
|
thread = threading.Thread(
|
|
251
263
|
target=self._target,
|
|
252
264
|
name=f"cook-thread-{i}",
|
|
253
|
-
args=(stop, input_queue, output_queue),
|
|
265
|
+
args=(stop, input_queue, output_queue, dry_run),
|
|
254
266
|
daemon=True,
|
|
255
267
|
)
|
|
256
268
|
thread.start()
|
|
@@ -282,30 +294,35 @@ class Controller:
|
|
|
282
294
|
# Unpack the results.
|
|
283
295
|
if event.kind == "fail":
|
|
284
296
|
# Update the status in the database.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
297
|
+
if not dry_run:
|
|
298
|
+
params = {
|
|
299
|
+
"name": event.task.name,
|
|
300
|
+
"last_failed": event.timestamp,
|
|
301
|
+
}
|
|
302
|
+
self.connection.execute(QUERIES["upsert_task_failed"], params)
|
|
303
|
+
self.connection.commit()
|
|
291
304
|
ex = event.exc_info[1]
|
|
292
305
|
raise util.FailedTaskError(ex, task=event.task) from ex
|
|
293
306
|
elif event.kind == "complete":
|
|
294
307
|
# Update the status in the database.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
308
|
+
if not dry_run:
|
|
309
|
+
params = {
|
|
310
|
+
"name": event.task.name,
|
|
311
|
+
"digest": event.digest,
|
|
312
|
+
"last_completed": event.timestamp,
|
|
313
|
+
}
|
|
314
|
+
self.connection.execute(
|
|
315
|
+
QUERIES["upsert_task_completed"], params
|
|
316
|
+
)
|
|
317
|
+
self.connection.commit()
|
|
302
318
|
elif event.kind == "start":
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
319
|
+
if not dry_run:
|
|
320
|
+
params = {
|
|
321
|
+
"name": event.task.name,
|
|
322
|
+
"last_started": event.timestamp,
|
|
323
|
+
}
|
|
324
|
+
self.connection.execute(QUERIES["upsert_task_started"], params)
|
|
325
|
+
self.connection.commit()
|
|
309
326
|
continue
|
|
310
327
|
else:
|
|
311
328
|
raise ValueError(event) # pragma: no cover
|
|
@@ -339,7 +356,11 @@ class Controller:
|
|
|
339
356
|
raise RuntimeError(f"thread {thread} failed to join")
|
|
340
357
|
|
|
341
358
|
def _target(
|
|
342
|
-
self,
|
|
359
|
+
self,
|
|
360
|
+
stop: util.StopEvent,
|
|
361
|
+
input_queue: Queue,
|
|
362
|
+
output_queue: Queue,
|
|
363
|
+
dry_run: bool = False,
|
|
343
364
|
) -> None:
|
|
344
365
|
LOGGER.debug(f"started thread `{threading.current_thread().name}`")
|
|
345
366
|
while not stop.is_set():
|
|
@@ -359,12 +380,28 @@ class Controller:
|
|
|
359
380
|
|
|
360
381
|
start = datetime.now()
|
|
361
382
|
try:
|
|
362
|
-
# Execute the task.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
383
|
+
# Execute or simulate the task.
|
|
384
|
+
if dry_run:
|
|
385
|
+
LOGGER.log(
|
|
386
|
+
logging.DEBUG if task.name.startswith("_") else logging.INFO,
|
|
387
|
+
"would execute %s",
|
|
388
|
+
task,
|
|
389
|
+
)
|
|
390
|
+
if task.action:
|
|
391
|
+
LOGGER.log(
|
|
392
|
+
logging.DEBUG
|
|
393
|
+
if task.name.startswith("_")
|
|
394
|
+
else logging.INFO,
|
|
395
|
+
" action: %s",
|
|
396
|
+
task.action,
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
LOGGER.log(
|
|
400
|
+
logging.DEBUG if task.name.startswith("_") else logging.INFO,
|
|
401
|
+
"executing %s ...",
|
|
402
|
+
task,
|
|
403
|
+
)
|
|
404
|
+
|
|
368
405
|
output_queue.put(
|
|
369
406
|
Event(
|
|
370
407
|
kind="start",
|
|
@@ -374,15 +411,17 @@ class Controller:
|
|
|
374
411
|
exc_info=(None, None, None),
|
|
375
412
|
)
|
|
376
413
|
)
|
|
377
|
-
task.execute(stop)
|
|
378
414
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
)
|
|
385
|
-
|
|
415
|
+
if not dry_run:
|
|
416
|
+
task.execute(stop)
|
|
417
|
+
|
|
418
|
+
# Check that all targets were created.
|
|
419
|
+
for target in task.targets:
|
|
420
|
+
if not target.is_file():
|
|
421
|
+
raise FileNotFoundError(
|
|
422
|
+
f"task {task} did not create target {target}"
|
|
423
|
+
)
|
|
424
|
+
LOGGER.debug("%s created `%s`", task, target)
|
|
386
425
|
|
|
387
426
|
# Add the result to the output queue and report success.
|
|
388
427
|
output_queue.put(
|
|
@@ -394,13 +433,14 @@ class Controller:
|
|
|
394
433
|
exc_info=(None, None, None),
|
|
395
434
|
)
|
|
396
435
|
)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
436
|
+
if not dry_run:
|
|
437
|
+
delta = util.format_timedelta(datetime.now() - start)
|
|
438
|
+
LOGGER.log(
|
|
439
|
+
logging.DEBUG if task.name.startswith("_") else logging.INFO,
|
|
440
|
+
"completed %s in %s",
|
|
441
|
+
task,
|
|
442
|
+
delta,
|
|
443
|
+
)
|
|
404
444
|
except: # noqa: E722
|
|
405
445
|
exc_info = sys.exc_info()
|
|
406
446
|
delta = util.format_timedelta(datetime.now() - start)
|
|
@@ -96,8 +96,14 @@ class Manager:
|
|
|
96
96
|
f"tasks {task} and {other} both have target {path}"
|
|
97
97
|
)
|
|
98
98
|
task_by_target[path] = task
|
|
99
|
-
for
|
|
100
|
-
|
|
99
|
+
for dependency in task.dependencies:
|
|
100
|
+
# Dependencies should be Path or str after normalize_dependencies runs.
|
|
101
|
+
# Tasks should have been moved to task_dependencies.
|
|
102
|
+
assert isinstance(dependency, (Path, str)), (
|
|
103
|
+
f"Unexpected dependency type '{type(dependency)}'. Dependencies "
|
|
104
|
+
"should be 'Path' or 'str' after 'normalize_dependencies'."
|
|
105
|
+
)
|
|
106
|
+
path = Path(dependency).resolve()
|
|
101
107
|
tasks_by_file_dependency.setdefault(path, set()).add(task)
|
|
102
108
|
|
|
103
109
|
# Build a directed graph of dependencies based on files produced and consumed by tasks.
|
|
@@ -132,8 +138,8 @@ def create_task(
|
|
|
132
138
|
name: str,
|
|
133
139
|
*,
|
|
134
140
|
action: "Action | str | None" = None,
|
|
135
|
-
targets: list["Path"] | None = None,
|
|
136
|
-
dependencies: list["Path"] | None = None,
|
|
141
|
+
targets: list["Path | str"] | None = None,
|
|
142
|
+
dependencies: list["Path | str | Task"] | None = None,
|
|
137
143
|
task_dependencies: list["Task"] | None = None,
|
|
138
144
|
location: tuple[str, int] | None = None,
|
|
139
145
|
) -> "Task":
|
|
@@ -19,7 +19,7 @@ class Task:
|
|
|
19
19
|
self,
|
|
20
20
|
name: str,
|
|
21
21
|
*,
|
|
22
|
-
dependencies: list["PathOrStr"] | None = None,
|
|
22
|
+
dependencies: list["PathOrStr | Task"] | None = None,
|
|
23
23
|
targets: list["PathOrStr"] | None = None,
|
|
24
24
|
action: Action | None = None,
|
|
25
25
|
task_dependencies: list[Task] | None = None,
|
|
@@ -11,7 +11,7 @@ from unittest import mock
|
|
|
11
11
|
|
|
12
12
|
def test_shell_action(tmp_wd: Path) -> None:
|
|
13
13
|
action = SubprocessAction("echo hello > world.txt", shell=True)
|
|
14
|
-
action.execute(None)
|
|
14
|
+
action.execute(None) # type: ignore[arg-type]
|
|
15
15
|
assert (tmp_wd / "world.txt").read_text().strip() == "hello"
|
|
16
16
|
|
|
17
17
|
|
|
@@ -27,7 +27,7 @@ def test_shell_action_timeout() -> None:
|
|
|
27
27
|
|
|
28
28
|
action = SubprocessAction(["sleep", "2"])
|
|
29
29
|
with Timer() as timer, pytest.raises(SubprocessError, match="SIGTERM"):
|
|
30
|
-
action.execute(None, stop)
|
|
30
|
+
action.execute(None, stop) # type: ignore[arg-type]
|
|
31
31
|
|
|
32
32
|
assert 1 < timer.duration < 2
|
|
33
33
|
assert not thread.is_alive()
|
|
@@ -35,14 +35,14 @@ def test_shell_action_timeout() -> None:
|
|
|
35
35
|
|
|
36
36
|
def test_subprocess_action(tmp_wd: Path) -> None:
|
|
37
37
|
action = SubprocessAction(["touch", "foo"])
|
|
38
|
-
action.execute(None)
|
|
38
|
+
action.execute(None) # type: ignore[arg-type]
|
|
39
39
|
assert (tmp_wd / "foo").is_file()
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def test_bad_subprocess_action() -> None:
|
|
43
43
|
action = SubprocessAction("false")
|
|
44
44
|
with pytest.raises(SubprocessError):
|
|
45
|
-
action.execute(None)
|
|
45
|
+
action.execute(None) # type: ignore[arg-type]
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def test_subprocess_action_repr() -> None:
|
|
@@ -54,7 +54,7 @@ def test_function_action() -> None:
|
|
|
54
54
|
args = []
|
|
55
55
|
|
|
56
56
|
action = FunctionAction(args.append)
|
|
57
|
-
action.execute(42)
|
|
57
|
+
action.execute(42) # type: ignore[arg-type]
|
|
58
58
|
|
|
59
59
|
assert args == [42]
|
|
60
60
|
|
|
@@ -63,13 +63,13 @@ def test_composite_action() -> None:
|
|
|
63
63
|
args = []
|
|
64
64
|
|
|
65
65
|
action = CompositeAction(FunctionAction(args.append), FunctionAction(args.append))
|
|
66
|
-
action.execute("hello")
|
|
66
|
+
action.execute("hello") # type: ignore[arg-type]
|
|
67
67
|
assert args == ["hello", "hello"]
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
def test_module_action() -> None:
|
|
71
71
|
action = ModuleAction([pytest, "-h"])
|
|
72
|
-
action.execute(None)
|
|
72
|
+
action.execute(None) # type: ignore[arg-type]
|
|
73
73
|
|
|
74
74
|
with pytest.raises(ValueError, match="shell execution"):
|
|
75
75
|
ModuleAction([pytest], shell=True)
|
|
@@ -84,12 +84,12 @@ def test_module_action() -> None:
|
|
|
84
84
|
def test_module_action_debug() -> None:
|
|
85
85
|
with mock.patch("subprocess.Popen") as Popen:
|
|
86
86
|
Popen().wait = mock.MagicMock(return_value=0)
|
|
87
|
-
ModuleAction([pytest], debug=True).execute(None)
|
|
87
|
+
ModuleAction([pytest], debug=True).execute(None) # type: ignore[arg-type]
|
|
88
88
|
Popen.assert_called_with([sys.executable, "-m", "pdb", "-m", "pytest"])
|
|
89
89
|
|
|
90
90
|
with mock.patch("subprocess.Popen") as Popen:
|
|
91
91
|
Popen().wait = mock.MagicMock(return_value=0)
|
|
92
|
-
ModuleAction([pytest], debug=False).execute(None)
|
|
92
|
+
ModuleAction([pytest], debug=False).execute(None) # type: ignore[arg-type]
|
|
93
93
|
Popen.assert_called_with([sys.executable, "-m", "pytest"])
|
|
94
94
|
|
|
95
95
|
|
|
@@ -100,5 +100,5 @@ def test_composite_digest() -> None:
|
|
|
100
100
|
]
|
|
101
101
|
assert CompositeAction(*actions).hexdigest
|
|
102
102
|
|
|
103
|
-
actions.append(FunctionAction(print))
|
|
103
|
+
actions.append(FunctionAction(print)) # type: ignore[arg-type]
|
|
104
104
|
assert CompositeAction(*actions).hexdigest is None
|
|
@@ -33,7 +33,7 @@ def test_function_context(m: Manager) -> None:
|
|
|
33
33
|
def test_missing_task_context(m: Manager) -> None:
|
|
34
34
|
with (
|
|
35
35
|
pytest.raises(ValueError, match="did not return a task"),
|
|
36
|
-
FunctionContext(lambda _: None),
|
|
36
|
+
FunctionContext(lambda _: None), # type: ignore[arg-type]
|
|
37
37
|
):
|
|
38
38
|
m.create_task("my-task")
|
|
39
39
|
|
|
@@ -42,7 +42,7 @@ def test_context_management(m: Manager) -> None:
|
|
|
42
42
|
with pytest.raises(RuntimeError, match="no active contexts"), Context():
|
|
43
43
|
m.contexts = []
|
|
44
44
|
with pytest.raises(RuntimeError, match="unexpected context"), Context():
|
|
45
|
-
m.contexts.append("something else")
|
|
45
|
+
m.contexts.append("something else") # type: ignore[arg-type]
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def test_create_target_directories(
|
|
@@ -59,17 +59,20 @@ def test_create_target_directories(
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def test_create_target_directories_with_multiple_targets(
|
|
62
|
-
m: Manager, tmp_wd: Path, conn: sqlite3
|
|
62
|
+
m: Manager, tmp_wd: Path, conn: sqlite3.Connection
|
|
63
63
|
) -> None:
|
|
64
64
|
filenames = [
|
|
65
65
|
tmp_wd / "this/is/a/hierarchy.txt",
|
|
66
66
|
tmp_wd / "this/is/a/hierarchy2.txt",
|
|
67
67
|
]
|
|
68
|
+
filename: Path | None = None
|
|
69
|
+
task: Task | None = None
|
|
68
70
|
with normalize_action(), create_target_directories():
|
|
69
71
|
for filename in filenames:
|
|
70
72
|
task = m.create_task(
|
|
71
73
|
filename.name, targets=[filename], action=["touch", filename]
|
|
72
74
|
)
|
|
75
|
+
assert filename is not None and task is not None
|
|
73
76
|
assert not filename.parent.is_dir()
|
|
74
77
|
|
|
75
78
|
controller = Controller(m.resolve_dependencies(), conn)
|
|
@@ -131,11 +134,27 @@ def test_normalize_dependencies(m: Manager) -> None:
|
|
|
131
134
|
with create_group("g") as g:
|
|
132
135
|
base = m.create_task("base")
|
|
133
136
|
with normalize_dependencies():
|
|
134
|
-
task = m.create_task("
|
|
137
|
+
task = m.create_task("task3", task_dependencies=["g"])
|
|
135
138
|
assert task.task_dependencies == [g.task]
|
|
136
139
|
|
|
137
|
-
task = m.create_task("task2", dependencies=[base])
|
|
138
|
-
assert task.task_dependencies == [base]
|
|
139
140
|
|
|
140
|
-
|
|
141
|
+
def test_normalize_dependencies_deprecated_syntax(m: Manager) -> None:
|
|
142
|
+
"""Test that passing Tasks to dependencies parameter emits DeprecationWarning."""
|
|
143
|
+
with create_group("g") as g:
|
|
144
|
+
base = m.create_task("base")
|
|
145
|
+
with normalize_dependencies():
|
|
146
|
+
# Test with group
|
|
147
|
+
with pytest.warns(
|
|
148
|
+
DeprecationWarning,
|
|
149
|
+
match="Passing Task objects to 'dependencies' is deprecated",
|
|
150
|
+
):
|
|
151
|
+
task = m.create_task("task1", dependencies=[g])
|
|
141
152
|
assert task.task_dependencies == [g.task]
|
|
153
|
+
|
|
154
|
+
# Test with regular task
|
|
155
|
+
with pytest.warns(
|
|
156
|
+
DeprecationWarning,
|
|
157
|
+
match="Passing Task objects to 'dependencies' is deprecated",
|
|
158
|
+
):
|
|
159
|
+
task = m.create_task("task2", dependencies=[base])
|
|
160
|
+
assert task.task_dependencies == [base]
|
|
@@ -188,7 +188,7 @@ def test_digest_cache(m: Manager, conn: Connection, tmp_wd: Path) -> None:
|
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def test_skip_if_no_stale_tasks(m: Manager, conn: Connection, tmp_wd: Path) -> None:
|
|
191
|
-
c = Controller(m, conn)
|
|
191
|
+
c = Controller(m.resolve_dependencies(), conn)
|
|
192
192
|
c.execute([])
|
|
193
193
|
|
|
194
194
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import colorama
|
|
1
2
|
from cook.__main__ import __main__, Formatter
|
|
2
3
|
import logging
|
|
3
4
|
from pathlib import Path
|
|
@@ -9,6 +10,13 @@ import sys
|
|
|
9
10
|
RECIPES = Path(__file__).parent / "recipes"
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def strip_colors(text: str) -> str:
|
|
14
|
+
for ansi_codes in [colorama.Fore, colorama.Back]:
|
|
15
|
+
for code in vars(ansi_codes).values():
|
|
16
|
+
text = text.replace(code, "")
|
|
17
|
+
return text
|
|
18
|
+
|
|
19
|
+
|
|
12
20
|
def test_blah_recipe_run(tmp_wd: Path) -> None:
|
|
13
21
|
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "run"])
|
|
14
22
|
|
|
@@ -28,35 +36,35 @@ def test_blah_recipe_ls(
|
|
|
28
36
|
__main__(["--recipe", str(RECIPES / "blah.py"), "ls", *patterns])
|
|
29
37
|
out, _ = capsys.readouterr()
|
|
30
38
|
for task in expected:
|
|
31
|
-
assert f"<task `{task}` @ " in
|
|
39
|
+
assert f"<task `{task}` @ " in strip_colors(out)
|
|
32
40
|
|
|
33
41
|
|
|
34
42
|
def test_blah_recipe_info(tmp_wd: Path, capsys: pytest.CaptureFixture) -> None:
|
|
35
43
|
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "link"])
|
|
36
44
|
stdout, _ = capsys.readouterr()
|
|
37
|
-
assert "status: stale" in
|
|
45
|
+
assert "status: stale" in strip_colors(stdout)
|
|
38
46
|
|
|
39
47
|
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "link"])
|
|
40
48
|
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "link"])
|
|
41
49
|
stdout, _ = capsys.readouterr()
|
|
42
|
-
assert "status: current" in
|
|
50
|
+
assert "status: current" in strip_colors(stdout)
|
|
43
51
|
|
|
44
52
|
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "run"])
|
|
45
53
|
stdout, _ = capsys.readouterr()
|
|
46
|
-
assert "targets: -" in
|
|
54
|
+
assert "targets: -" in strip_colors(stdout)
|
|
47
55
|
|
|
48
56
|
# Check filtering based on stale/current status.
|
|
49
57
|
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "--stale"])
|
|
50
58
|
stdout, _ = capsys.readouterr()
|
|
51
|
-
assert "status: current" not in
|
|
59
|
+
assert "status: current" not in strip_colors(stdout)
|
|
52
60
|
|
|
53
61
|
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "--current"])
|
|
54
62
|
stdout, _ = capsys.readouterr()
|
|
55
|
-
assert "status: stale" not in
|
|
63
|
+
assert "status: stale" not in strip_colors(stdout)
|
|
56
64
|
|
|
57
65
|
__main__(["--recipe", str(RECIPES / "blah.py"), "info"])
|
|
58
66
|
stdout, _ = capsys.readouterr()
|
|
59
|
-
stdout =
|
|
67
|
+
stdout = strip_colors(stdout)
|
|
60
68
|
assert "status: stale" in stdout and "status: current" in stdout
|
|
61
69
|
|
|
62
70
|
# Check only one can be given.
|
|
@@ -119,3 +127,66 @@ def test_terrible_recipe(caplog: pytest.LogCaptureFixture) -> None:
|
|
|
119
127
|
with pytest.raises(SystemExit), caplog.at_level("CRITICAL"):
|
|
120
128
|
__main__(["--recipe", str(RECIPES / "terrible.not-py"), "ls"])
|
|
121
129
|
assert "failed to load recipe" in caplog.text
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_dry_run_shows_tasks(tmp_wd: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
133
|
+
"""Dry-run should show what would execute without running tasks."""
|
|
134
|
+
with caplog.at_level(logging.INFO):
|
|
135
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "--dry-run", "run"])
|
|
136
|
+
|
|
137
|
+
# Should show "would execute" messages
|
|
138
|
+
assert "would execute <task `create_source`" in caplog.text
|
|
139
|
+
assert "would execute <task `compile`" in caplog.text
|
|
140
|
+
assert "would execute <task `link`" in caplog.text
|
|
141
|
+
assert "would execute <task `run`" in caplog.text
|
|
142
|
+
|
|
143
|
+
# Should show actions
|
|
144
|
+
assert "action:" in caplog.text
|
|
145
|
+
|
|
146
|
+
# Should NOT show "executing" or "completed" messages
|
|
147
|
+
assert "executing <task" not in caplog.text
|
|
148
|
+
assert "completed <task" not in caplog.text
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_dry_run_does_not_create_files(tmp_wd: Path) -> None:
|
|
152
|
+
"""Dry-run should not create target files."""
|
|
153
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "--dry-run", "link"])
|
|
154
|
+
|
|
155
|
+
# Files should NOT be created
|
|
156
|
+
assert not (tmp_wd / "blah.c").exists()
|
|
157
|
+
assert not (tmp_wd / "blah.o").exists()
|
|
158
|
+
assert not (tmp_wd / "blah").exists()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_dry_run_does_not_update_database(tmp_wd: Path) -> None:
|
|
162
|
+
"""Dry-run should not update the database."""
|
|
163
|
+
# First, run normally to create database entry
|
|
164
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "link"])
|
|
165
|
+
|
|
166
|
+
# Check the database was updated
|
|
167
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "link"])
|
|
168
|
+
|
|
169
|
+
# Reset the task
|
|
170
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "reset", "link"])
|
|
171
|
+
|
|
172
|
+
# Now dry-run should not update last_completed
|
|
173
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "--dry-run", "link"])
|
|
174
|
+
|
|
175
|
+
# Task should still be stale (database not updated)
|
|
176
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "info", "--stale", "link"])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_dry_run_with_no_stale_tasks(
|
|
180
|
+
tmp_wd: Path, caplog: pytest.LogCaptureFixture
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Dry-run with no stale tasks should be silent."""
|
|
183
|
+
# First, run normally
|
|
184
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "link"])
|
|
185
|
+
|
|
186
|
+
# Now dry-run with nothing stale
|
|
187
|
+
caplog.clear()
|
|
188
|
+
with caplog.at_level(logging.INFO):
|
|
189
|
+
__main__(["--recipe", str(RECIPES / "blah.py"), "exec", "--dry-run", "link"])
|
|
190
|
+
|
|
191
|
+
# Should be silent (no "would execute" messages)
|
|
192
|
+
assert "would execute" not in caplog.text
|
|
@@ -85,7 +85,7 @@ def test_get_manager_instance() -> None:
|
|
|
85
85
|
with Manager() as m:
|
|
86
86
|
assert Manager.get_instance() is m
|
|
87
87
|
with pytest.raises(RuntimeError, match="unexpected manager"), Manager():
|
|
88
|
-
Manager._INSTANCE = "asdf"
|
|
88
|
+
Manager._INSTANCE = "asdf" # pyright: ignore[reportAttributeAccessIssue]
|
|
89
89
|
with pytest.raises(ValueError, match="already active"), Manager(), Manager():
|
|
90
90
|
pass
|
|
91
91
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|