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.
Files changed (27) hide show
  1. {cook_build-0.6.2/src/cook_build.egg-info → cook_build-0.6.4}/PKG-INFO +1 -1
  2. {cook_build-0.6.2 → cook_build-0.6.4}/pyproject.toml +1 -1
  3. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/__main__.py +9 -1
  4. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/contexts.py +18 -4
  5. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/controller.py +83 -43
  6. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/manager.py +10 -4
  7. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/task.py +1 -1
  8. {cook_build-0.6.2 → cook_build-0.6.4/src/cook_build.egg-info}/PKG-INFO +1 -1
  9. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_actions.py +10 -10
  10. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_contexts.py +26 -7
  11. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_controller.py +1 -1
  12. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_main.py +78 -7
  13. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_manager.py +1 -1
  14. {cook_build-0.6.2 → cook_build-0.6.4}/LICENSE +0 -0
  15. {cook_build-0.6.2 → cook_build-0.6.4}/README.md +0 -0
  16. {cook_build-0.6.2 → cook_build-0.6.4}/README.rst +0 -0
  17. {cook_build-0.6.2 → cook_build-0.6.4}/setup.cfg +0 -0
  18. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/__init__.py +0 -0
  19. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/actions.py +0 -0
  20. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook/util.py +0 -0
  21. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/SOURCES.txt +0 -0
  22. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/dependency_links.txt +0 -0
  23. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/entry_points.txt +0 -0
  24. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/requires.txt +0 -0
  25. {cook_build-0.6.2 → cook_build-0.6.4}/src/cook_build.egg-info/top_level.txt +0 -0
  26. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_examples.py +0 -0
  27. {cook_build-0.6.2 → cook_build-0.6.4}/tests/test_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cook-build
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: A task-centric build system with simple declarative recipes specified in Python
5
5
  Author: Till Hoffmann
6
6
  License: BSD-3-Clause
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cook-build"
3
- version = "0.6.2"
3
+ version = "0.6.4"
4
4
  description = "A task-centric build system with simple declarative recipes specified in Python"
5
5
  readme = "README.md"
6
6
  license = {text = "BSD-3-Clause"}
@@ -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) -> Context:
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
- task_dependencies.append(dependency) # pyright: ignore[reportArgumentType]
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
- task.dependencies = [Path(x) for x in dependencies] # pyright: ignore[reportAttributeAccessIssue]
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, tasks: "Task | list[Task]", num_concurrent: int = 1, interval: float = 1
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
- params = {
286
- "name": event.task.name,
287
- "last_failed": event.timestamp,
288
- }
289
- self.connection.execute(QUERIES["upsert_task_failed"], params)
290
- self.connection.commit()
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
- params = {
296
- "name": event.task.name,
297
- "digest": event.digest,
298
- "last_completed": event.timestamp,
299
- }
300
- self.connection.execute(QUERIES["upsert_task_completed"], params)
301
- self.connection.commit()
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
- params = {
304
- "name": event.task.name,
305
- "last_started": event.timestamp,
306
- }
307
- self.connection.execute(QUERIES["upsert_task_started"], params)
308
- self.connection.commit()
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, stop: util.StopEvent, input_queue: Queue, output_queue: Queue
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
- LOGGER.log(
364
- logging.DEBUG if task.name.startswith("_") else logging.INFO,
365
- "executing %s ...",
366
- task,
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
- # Check that all targets were created.
380
- for target in task.targets:
381
- if not target.is_file():
382
- raise FileNotFoundError(
383
- f"task {task} did not create target {target}"
384
- )
385
- LOGGER.debug("%s created `%s`", task, target)
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
- delta = util.format_timedelta(datetime.now() - start)
398
- LOGGER.log(
399
- logging.DEBUG if task.name.startswith("_") else logging.INFO,
400
- "completed %s in %s",
401
- task,
402
- delta,
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 path in task.dependencies:
100
- path = Path(path).resolve()
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cook-build
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: A task-centric build system with simple declarative recipes specified in Python
5
5
  Author: Till Hoffmann
6
6
  License: BSD-3-Clause
@@ -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("task1", dependencies=[g])
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
- task = m.create_task("task3", task_dependencies=["g"])
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 pytest.shared.strip_colors(out)
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 pytest.shared.strip_colors(stdout)
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 pytest.shared.strip_colors(stdout)
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 pytest.shared.strip_colors(stdout)
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 pytest.shared.strip_colors(stdout)
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 pytest.shared.strip_colors(stdout)
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 = pytest.shared.strip_colors(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