cook-build 0.2.0__tar.gz → 0.2.1__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 (26) hide show
  1. {cook-build-0.2.0/cook_build.egg-info → cook-build-0.2.1}/PKG-INFO +30 -20
  2. {cook-build-0.2.0 → cook-build-0.2.1}/README.rst +24 -19
  3. {cook-build-0.2.0 → cook-build-0.2.1}/cook/__main__.py +4 -0
  4. {cook-build-0.2.0 → cook-build-0.2.1}/cook/controller.py +24 -3
  5. {cook-build-0.2.0 → cook-build-0.2.1/cook_build.egg-info}/PKG-INFO +30 -20
  6. {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/SOURCES.txt +8 -1
  7. {cook-build-0.2.0 → cook-build-0.2.1}/setup.py +6 -3
  8. cook-build-0.2.1/tests/test_actions.py +51 -0
  9. cook-build-0.2.1/tests/test_contexts.py +89 -0
  10. cook-build-0.2.1/tests/test_controller.py +154 -0
  11. cook-build-0.2.1/tests/test_examples.py +11 -0
  12. cook-build-0.2.1/tests/test_main.py +68 -0
  13. cook-build-0.2.1/tests/test_manager.py +60 -0
  14. cook-build-0.2.1/tests/test_util.py +9 -0
  15. {cook-build-0.2.0 → cook-build-0.2.1}/LICENSE +0 -0
  16. {cook-build-0.2.0 → cook-build-0.2.1}/cook/__init__.py +0 -0
  17. {cook-build-0.2.0 → cook-build-0.2.1}/cook/actions.py +0 -0
  18. {cook-build-0.2.0 → cook-build-0.2.1}/cook/contexts.py +0 -0
  19. {cook-build-0.2.0 → cook-build-0.2.1}/cook/manager.py +0 -0
  20. {cook-build-0.2.0 → cook-build-0.2.1}/cook/task.py +0 -0
  21. {cook-build-0.2.0 → cook-build-0.2.1}/cook/util.py +0 -0
  22. {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/dependency_links.txt +0 -0
  23. {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/entry_points.txt +0 -0
  24. {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/requires.txt +0 -0
  25. {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/top_level.txt +0 -0
  26. {cook-build-0.2.0 → cook-build-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cook-build
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
+ Requires-Python: >=3.8
4
5
  Description-Content-Type: text/x-rst
5
6
  License-File: LICENSE
6
7
 
@@ -21,6 +22,7 @@ Tasks are declared in a :code:`recipe.py` file using the :code:`~cook.manager.cr
21
22
 
22
23
  .. code-block::
23
24
 
25
+
24
26
  >>> from cook import create_task
25
27
 
26
28
  >>> create_task("src", targets=["hello.c"],
@@ -31,40 +33,48 @@ Tasks are declared in a :code:`recipe.py` file using the :code:`~cook.manager.cr
31
33
 
32
34
  Running :code:`cook ls` from the command line lists all known tasks, e.g.,
33
35
 
34
- .. code-block:: bash
36
+ .. code-block::
37
+
38
+ :cwd: examples/getting_started
35
39
 
36
40
  $ cook ls
37
41
  <task `src` @ /.../recipe.py:3>
38
- <task `cc` @ /.../recipe.py:5>
39
- <task `hello` @ /.../recipe.py:7>
42
+ <task `cc` @ /.../recipe.py:4>
43
+ <task `hello` @ /.../recipe.py:5>
40
44
 
41
45
  Running :code:`cook exec hello` creates the source file, compile it, and executes the binary. We use :code:`--log-level=debug` to provide additional information here.
42
46
 
43
- .. code-block:: bash
44
-
45
- $ cook --log-level=debug exec hello
46
- DEBUG: <task `src` @ ...> is stale because one of its targets is missing
47
- DEBUG: started <task `src` @ ...>
48
- DEBUG: completed <task `src` @ ...> in ... seconds
49
- DEBUG: `<task `src` @ ...>` created `hello.c`
47
+ .. code-block::
50
48
 
51
- DEBUG: <task `cc` @ ...> is stale because its dependency `hello.c` does not have a hash entry
52
- DEBUG: started <task `cc` @ ...>
53
- DEBUG: completed <task `cc` @ ...> in ... seconds
54
- DEBUG: `<task `cc` @ ...>` created `hello`
49
+ :cwd: examples/getting_started
50
+ :stderr:
55
51
 
56
- DEBUG: <task `hello` @ ...> is "stale" because it has no targets
57
- DEBUG: started <task `hello` @ ...>
58
- DEBUG: completed <task `hello` @ ...> in ... seconds
52
+ $ cook --log-level=debug exec hello
53
+ DEBUG: <task `src` @ .../recipe.py:3> is stale because one of its targets is missing
54
+ DEBUG: started <task `src` @ .../recipe.py:3>
55
+ DEBUG: completed <task `src` @ .../recipe.py:3> in ... seconds
56
+ DEBUG: <task `src` @ .../recipe.py:3> created `hello.c`
57
+ DEBUG: <task `cc` @ .../recipe.py:4> is stale because one of its targets is missing
58
+ DEBUG: started <task `cc` @ .../recipe.py:4>
59
+ DEBUG: completed <task `cc` @ .../recipe.py:4> in ... seconds
60
+ DEBUG: <task `cc` @ .../recipe.py:4> created `hello`
61
+ DEBUG: <task `hello` @ .../recipe.py:5> is "stale" because it has no targets
62
+ DEBUG: started <task `hello` @ .../recipe.py:5>
63
+ DEBUG: completed <task `hello` @ .../recipe.py:5> in ... seconds
59
64
 
60
65
  To rerun a task, tell Cook to reset it.
61
66
 
62
- .. code-block:: bash
67
+ .. code-block::
68
+
69
+ :cwd: examples/getting_started
70
+ :stderr:
63
71
 
64
72
  $ cook reset cc
65
73
  INFO: reset 1 task
66
74
 
67
- The full set of available commands can be explored using :code:`cook --help`.
75
+ The full set of available commands can be explored using :code:`cook --help` as shown below.
76
+
77
+ .. cook --help
68
78
 
69
79
  Tasks Are Dumb; Contexts Are Smart
70
80
  ----------------------------------
@@ -25,40 +25,45 @@ Tasks are declared in a :code:`recipe.py` file using the :func:`~cook.manager.cr
25
25
 
26
26
  Running :code:`cook ls` from the command line lists all known tasks, e.g.,
27
27
 
28
- .. code-block:: bash
28
+ .. shtest::
29
+ :cwd: examples/getting_started
29
30
 
30
31
  $ cook ls
31
32
  <task `src` @ /.../recipe.py:3>
32
- <task `cc` @ /.../recipe.py:5>
33
- <task `hello` @ /.../recipe.py:7>
33
+ <task `cc` @ /.../recipe.py:4>
34
+ <task `hello` @ /.../recipe.py:5>
34
35
 
35
36
  Running :code:`cook exec hello` creates the source file, compile it, and executes the binary. We use :code:`--log-level=debug` to provide additional information here.
36
37
 
37
- .. code-block:: bash
38
+ .. shtest::
39
+ :cwd: examples/getting_started
40
+ :stderr:
38
41
 
39
42
  $ cook --log-level=debug exec hello
40
- DEBUG: <task `src` @ ...> is stale because one of its targets is missing
41
- DEBUG: started <task `src` @ ...>
42
- DEBUG: completed <task `src` @ ...> in ... seconds
43
- DEBUG: `<task `src` @ ...>` created `hello.c`
44
-
45
- DEBUG: <task `cc` @ ...> is stale because its dependency `hello.c` does not have a hash entry
46
- DEBUG: started <task `cc` @ ...>
47
- DEBUG: completed <task `cc` @ ...> in ... seconds
48
- DEBUG: `<task `cc` @ ...>` created `hello`
49
-
50
- DEBUG: <task `hello` @ ...> is "stale" because it has no targets
51
- DEBUG: started <task `hello` @ ...>
52
- DEBUG: completed <task `hello` @ ...> in ... seconds
43
+ DEBUG: <task `src` @ .../recipe.py:3> is stale because one of its targets is missing
44
+ DEBUG: started <task `src` @ .../recipe.py:3>
45
+ DEBUG: completed <task `src` @ .../recipe.py:3> in ... seconds
46
+ DEBUG: <task `src` @ .../recipe.py:3> created `hello.c`
47
+ DEBUG: <task `cc` @ .../recipe.py:4> is stale because one of its targets is missing
48
+ DEBUG: started <task `cc` @ .../recipe.py:4>
49
+ DEBUG: completed <task `cc` @ .../recipe.py:4> in ... seconds
50
+ DEBUG: <task `cc` @ .../recipe.py:4> created `hello`
51
+ DEBUG: <task `hello` @ .../recipe.py:5> is "stale" because it has no targets
52
+ DEBUG: started <task `hello` @ .../recipe.py:5>
53
+ DEBUG: completed <task `hello` @ .../recipe.py:5> in ... seconds
53
54
 
54
55
  To rerun a task, tell Cook to reset it.
55
56
 
56
- .. code-block:: bash
57
+ .. shtest::
58
+ :cwd: examples/getting_started
59
+ :stderr:
57
60
 
58
61
  $ cook reset cc
59
62
  INFO: reset 1 task
60
63
 
61
- The full set of available commands can be explored using :code:`cook --help`.
64
+ The full set of available commands can be explored using :code:`cook --help` as shown below.
65
+
66
+ .. sh:: cook --help
62
67
 
63
68
  Tasks Are Dumb; Contexts Are Smart
64
69
  ----------------------------------
@@ -63,6 +63,7 @@ class Command:
63
63
  class ExecArgs(argparse.Namespace):
64
64
  tasks: Iterable[re.Pattern]
65
65
  re: bool
66
+ jobs: int
66
67
 
67
68
 
68
69
  class ExecCommand(Command):
@@ -74,10 +75,13 @@ class ExecCommand(Command):
74
75
  def configure_parser(self, parser: argparse.ArgumentParser) -> None:
75
76
  parser.add_argument("--re", "-r", action="store_true",
76
77
  help="use regular expressions for pattern matching instead of glob")
78
+ parser.add_argument("--jobs", "-j", help="number of concurrent jobs", type=int, default=1)
77
79
  parser.add_argument("tasks", nargs="+",
78
80
  help="task or tasks to execute as regular expressions")
79
81
 
80
82
  def execute(self, controller: Controller, args: ExecArgs) -> None:
83
+ # Monkeypatch the controller semaphore.
84
+ controller.num_concurrent = args.jobs
81
85
  tasks = discover_tasks(controller.manager, args.tasks, args.re)
82
86
  controller.execute_sync(*tasks)
83
87
 
@@ -3,6 +3,7 @@ import logging
3
3
  import os
4
4
  from pathlib import Path
5
5
  from sqlite3 import Connection
6
+ import sys
6
7
  from typing import Dict, Iterable, Optional, Set, Tuple, TYPE_CHECKING, Union
7
8
  from . import util
8
9
 
@@ -41,7 +42,17 @@ class Controller:
41
42
  self.status: Dict[Task, bool] = {}
42
43
  self.futures: Dict[Task, asyncio.Future] = {}
43
44
  self.size_digest: Dict[str, Tuple[int, str]] = {}
44
- self.semaphore = asyncio.Semaphore(num_concurrent)
45
+ self.num_concurrent = num_concurrent
46
+ self._semaphore = None
47
+ self.cancelled = False
48
+
49
+ @property
50
+ def semaphore(self) -> asyncio.Semaphore:
51
+ # Create the semaphore upon first use for python 3.9 and below (see
52
+ # https://stackoverflow.com/a/55918049/1150961 for details).
53
+ if self._semaphore is None:
54
+ self._semaphore = asyncio.Semaphore(self.num_concurrent)
55
+ return self._semaphore
45
56
 
46
57
  def resolve_stale_tasks(self, tasks: Optional[Iterable["Task"]] = None) -> Set["Task"]:
47
58
  tasks = tasks or self.manager.tasks.values()
@@ -115,6 +126,9 @@ class Controller:
115
126
  return self.status.setdefault(task, self._is_self_stale(task))
116
127
 
117
128
  async def execute(self, task: "Task") -> None:
129
+ if self.cancelled: # pragma: no cover
130
+ return
131
+
118
132
  # If there already is a future, just wait for it and return.
119
133
  if future := self.futures.get(task):
120
134
  await future
@@ -140,7 +154,7 @@ class Controller:
140
154
  for target in task.targets:
141
155
  if not target.is_file():
142
156
  raise FileNotFoundError(f"`{task}` did not create `{target}`")
143
- LOGGER.debug("`%s` created `%s`", task, target)
157
+ LOGGER.debug("%s created `%s`", task, target)
144
158
 
145
159
  # Update the state and write to the database.
146
160
  self.status[task] = False
@@ -159,10 +173,17 @@ class Controller:
159
173
  error = util.FailedTaskError(message, task=task)
160
174
  error.__cause__ = ex
161
175
  future.set_exception(error)
176
+ # Cancel all other futures.
177
+ self.cancelled = True
178
+ for future in self.futures.values():
179
+ future.cancel(message) if sys.version_info[:2] > (3, 8) else future.cancel()
162
180
  else:
163
181
  future.set_result(None)
164
182
 
165
- await future
183
+ try:
184
+ await future
185
+ except asyncio.CancelledError: # pragma: no cover
186
+ pass
166
187
 
167
188
  def execute_sync(self, *tasks: "Task") -> None:
168
189
  util.run_until_complete(*(self.execute(task) for task in tasks))
@@ -1,6 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cook-build
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
+ Requires-Python: >=3.8
4
5
  Description-Content-Type: text/x-rst
5
6
  License-File: LICENSE
6
7
 
@@ -21,6 +22,7 @@ Tasks are declared in a :code:`recipe.py` file using the :code:`~cook.manager.cr
21
22
 
22
23
  .. code-block::
23
24
 
25
+
24
26
  >>> from cook import create_task
25
27
 
26
28
  >>> create_task("src", targets=["hello.c"],
@@ -31,40 +33,48 @@ Tasks are declared in a :code:`recipe.py` file using the :code:`~cook.manager.cr
31
33
 
32
34
  Running :code:`cook ls` from the command line lists all known tasks, e.g.,
33
35
 
34
- .. code-block:: bash
36
+ .. code-block::
37
+
38
+ :cwd: examples/getting_started
35
39
 
36
40
  $ cook ls
37
41
  <task `src` @ /.../recipe.py:3>
38
- <task `cc` @ /.../recipe.py:5>
39
- <task `hello` @ /.../recipe.py:7>
42
+ <task `cc` @ /.../recipe.py:4>
43
+ <task `hello` @ /.../recipe.py:5>
40
44
 
41
45
  Running :code:`cook exec hello` creates the source file, compile it, and executes the binary. We use :code:`--log-level=debug` to provide additional information here.
42
46
 
43
- .. code-block:: bash
44
-
45
- $ cook --log-level=debug exec hello
46
- DEBUG: <task `src` @ ...> is stale because one of its targets is missing
47
- DEBUG: started <task `src` @ ...>
48
- DEBUG: completed <task `src` @ ...> in ... seconds
49
- DEBUG: `<task `src` @ ...>` created `hello.c`
47
+ .. code-block::
50
48
 
51
- DEBUG: <task `cc` @ ...> is stale because its dependency `hello.c` does not have a hash entry
52
- DEBUG: started <task `cc` @ ...>
53
- DEBUG: completed <task `cc` @ ...> in ... seconds
54
- DEBUG: `<task `cc` @ ...>` created `hello`
49
+ :cwd: examples/getting_started
50
+ :stderr:
55
51
 
56
- DEBUG: <task `hello` @ ...> is "stale" because it has no targets
57
- DEBUG: started <task `hello` @ ...>
58
- DEBUG: completed <task `hello` @ ...> in ... seconds
52
+ $ cook --log-level=debug exec hello
53
+ DEBUG: <task `src` @ .../recipe.py:3> is stale because one of its targets is missing
54
+ DEBUG: started <task `src` @ .../recipe.py:3>
55
+ DEBUG: completed <task `src` @ .../recipe.py:3> in ... seconds
56
+ DEBUG: <task `src` @ .../recipe.py:3> created `hello.c`
57
+ DEBUG: <task `cc` @ .../recipe.py:4> is stale because one of its targets is missing
58
+ DEBUG: started <task `cc` @ .../recipe.py:4>
59
+ DEBUG: completed <task `cc` @ .../recipe.py:4> in ... seconds
60
+ DEBUG: <task `cc` @ .../recipe.py:4> created `hello`
61
+ DEBUG: <task `hello` @ .../recipe.py:5> is "stale" because it has no targets
62
+ DEBUG: started <task `hello` @ .../recipe.py:5>
63
+ DEBUG: completed <task `hello` @ .../recipe.py:5> in ... seconds
59
64
 
60
65
  To rerun a task, tell Cook to reset it.
61
66
 
62
- .. code-block:: bash
67
+ .. code-block::
68
+
69
+ :cwd: examples/getting_started
70
+ :stderr:
63
71
 
64
72
  $ cook reset cc
65
73
  INFO: reset 1 task
66
74
 
67
- The full set of available commands can be explored using :code:`cook --help`.
75
+ The full set of available commands can be explored using :code:`cook --help` as shown below.
76
+
77
+ .. cook --help
68
78
 
69
79
  Tasks Are Dumb; Contexts Are Smart
70
80
  ----------------------------------
@@ -15,4 +15,11 @@ cook_build.egg-info/SOURCES.txt
15
15
  cook_build.egg-info/dependency_links.txt
16
16
  cook_build.egg-info/entry_points.txt
17
17
  cook_build.egg-info/requires.txt
18
- cook_build.egg-info/top_level.txt
18
+ cook_build.egg-info/top_level.txt
19
+ tests/test_actions.py
20
+ tests/test_contexts.py
21
+ tests/test_controller.py
22
+ tests/test_examples.py
23
+ tests/test_main.py
24
+ tests/test_manager.py
25
+ tests/test_util.py
@@ -5,16 +5,19 @@ with open("README.rst") as fp:
5
5
  long_description = fp.read()
6
6
  long_description = long_description \
7
7
  .replace(":func:", ":code:") \
8
- .replace(".. doctest::", ".. code-block::") \
8
+ .replace(".. doctest::", ".. code-block::\n") \
9
+ .replace(".. shtest::", ".. code-block::\n") \
9
10
  .replace(":class:", ":code:") \
10
- .replace(".. toctree::", "..")
11
+ .replace(".. toctree::", "..") \
12
+ .replace(".. sh::", "..")
11
13
 
12
14
 
13
15
  setup(
14
16
  name="cook-build",
15
- version="0.2.0",
17
+ version="0.2.1",
16
18
  long_description=long_description,
17
19
  long_description_content_type="text/x-rst",
20
+ python_requires=">=3.8",
18
21
  install_requires=[
19
22
  "colorama",
20
23
  ],
@@ -0,0 +1,51 @@
1
+ from cook.actions import CompositeAction, FunctionAction, ShellAction, SubprocessAction
2
+ from pathlib import Path
3
+ import pytest
4
+ from subprocess import SubprocessError
5
+
6
+
7
+ def test_shell_action(tmp_wd: Path) -> None:
8
+ action = ShellAction("echo hello > world.txt")
9
+ action.execute_sync(None)
10
+ assert (tmp_wd / "world.txt").read_text().strip() == "hello"
11
+
12
+
13
+ def test_subprocess_action(tmp_wd: Path) -> None:
14
+ action = SubprocessAction("touch", "foo")
15
+ action.execute_sync(None)
16
+ assert (tmp_wd / "foo").is_file()
17
+
18
+
19
+ def test_bad_subprocess_action() -> None:
20
+ action = SubprocessAction("false")
21
+ with pytest.raises(SubprocessError):
22
+ action.execute_sync(None)
23
+
24
+
25
+ def test_function_action() -> None:
26
+ args = []
27
+
28
+ action = FunctionAction(args.append)
29
+ action.execute_sync(42)
30
+
31
+ assert args == [42]
32
+
33
+
34
+ def test_async_function_action() -> None:
35
+ args = []
36
+
37
+ async def func(*x) -> None:
38
+ args.append(*x)
39
+
40
+ action = FunctionAction(func)
41
+ action.execute_sync(17)
42
+
43
+ assert args == [17]
44
+
45
+
46
+ def test_composite_action() -> None:
47
+ args = []
48
+
49
+ action = CompositeAction(FunctionAction(args.append), FunctionAction(args.append))
50
+ action.execute_sync("hello")
51
+ assert args == ["hello", "hello"]
@@ -0,0 +1,89 @@
1
+ from cook import Manager, Task
2
+ from cook.actions import CompositeAction, FunctionAction, ShellAction, SubprocessAction
3
+ from cook.contexts import Context, create_target_directories, FunctionContext, create_group, \
4
+ normalize_action, normalize_dependencies
5
+ from cook.controller import Controller
6
+ from pathlib import Path
7
+ import pytest
8
+ import sqlite3
9
+ from typing import List
10
+
11
+
12
+ def test_function_context(m: Manager) -> None:
13
+ tasks: List[Task] = []
14
+
15
+ def func(t: Task) -> Task:
16
+ tasks.append(t)
17
+ return t
18
+
19
+ with FunctionContext(func):
20
+ m.create_task("my-task")
21
+ m.create_task("my-other-task")
22
+
23
+ task, = tasks
24
+ assert task.name == "my-task"
25
+
26
+
27
+ def test_missing_task_context(m: Manager) -> None:
28
+ with pytest.raises(ValueError, match="did not return a task"), FunctionContext(lambda _: None):
29
+ m.create_task("my-task")
30
+
31
+
32
+ def test_context_management(m: Manager) -> None:
33
+ with pytest.raises(RuntimeError, match="no active contexts"), Context():
34
+ m.contexts = []
35
+ with pytest.raises(RuntimeError, match="unexpected context"), Context():
36
+ m.contexts.append("something else")
37
+
38
+
39
+ def test_create_target_directories(m: Manager, tmp_wd: Path, conn: sqlite3.Connection) -> None:
40
+ filename = tmp_wd / "this/is/a/hierarchy.txt"
41
+ with normalize_action(), create_target_directories():
42
+ task = m.create_task("foo", targets=[filename], action=["touch", filename])
43
+ assert not filename.parent.is_dir()
44
+
45
+ controller = Controller(m, conn)
46
+ controller.execute_sync(task)
47
+ assert filename.parent.is_dir()
48
+
49
+
50
+ def test_normalize_action(m: Manager) -> None:
51
+ with normalize_action():
52
+ task = m.create_task("foo", action="bar")
53
+ assert isinstance(task.action, ShellAction) and task.action.cmd == "bar"
54
+
55
+ task = m.create_task("bar", action=["baz"])
56
+ assert isinstance(task.action, SubprocessAction) and task.action.program == "baz"
57
+
58
+ actions = [ShellAction("hello"), SubprocessAction("world")]
59
+ task = m.create_task("baz", action=actions)
60
+ assert isinstance(task.action, CompositeAction) and task.action.actions == tuple(actions)
61
+
62
+ task = m.create_task("xyz", action=lambda x: None)
63
+ assert isinstance(task.action, FunctionAction)
64
+
65
+
66
+ def test_group_no_tasks(m: Manager) -> None:
67
+ with pytest.raises(RuntimeError, match="no tasks"), create_group("g"):
68
+ pass
69
+
70
+
71
+ def test_group(m: Manager) -> None:
72
+ with create_group("g"):
73
+ t1 = m.create_task("t1")
74
+ t2 = m.create_task("t2")
75
+ assert m.tasks["g"].task_dependencies == [t1, t2]
76
+
77
+
78
+ def test_normalize_dependencies(m: Manager) -> None:
79
+ with create_group("g") as g:
80
+ base = m.create_task("base")
81
+ with normalize_dependencies():
82
+ task = m.create_task("task1", dependencies=[g])
83
+ assert task.task_dependencies == [g.task]
84
+
85
+ task = m.create_task("task2", dependencies=[base])
86
+ assert task.task_dependencies == [base]
87
+
88
+ task = m.create_task("task3", task_dependencies=["g"])
89
+ assert task.task_dependencies == [g.task]
@@ -0,0 +1,154 @@
1
+ from cook import Controller, Manager, Task
2
+ from cook.actions import FunctionAction, ShellAction
3
+ from cook.contexts import normalize_dependencies
4
+ from cook.controller import QUERIES
5
+ from cook.util import FailedTaskError, Timer
6
+ from pathlib import Path
7
+ import pytest
8
+ import shutil
9
+ from sqlite3 import Connection
10
+ from unittest.mock import patch
11
+
12
+
13
+ def touch(task: Task) -> None:
14
+ for target in task.targets:
15
+ target.write_text(target.name)
16
+
17
+
18
+ @pytest.fixture
19
+ def patched_hexdigest() -> None:
20
+ with patch("cook.util.evaluate_hexdigest", lambda path: path.name):
21
+ yield
22
+
23
+
24
+ def test_controller_empty_task(m: Manager, conn: Connection) -> None:
25
+ task = m.create_task("foo")
26
+ c = Controller(m, conn)
27
+ assert c.is_stale(task)
28
+ assert c.resolve_stale_tasks() == {task}
29
+
30
+
31
+ def test_controller_missing_target(m: Manager, conn: Connection) -> None:
32
+ task = m.create_task("foo", targets=["bar"])
33
+ c = Controller(m, conn)
34
+ assert c.is_stale(task)
35
+ assert c.resolve_stale_tasks() == {task}
36
+
37
+
38
+ def test_controller_simple_file_deps(m: Manager, conn: Connection, patched_hexdigest: None) -> None:
39
+ for path in ["input.txt", "output.txt"]:
40
+ Path(path).write_text(path)
41
+ with normalize_dependencies():
42
+ task = m.create_task("foo", dependencies=["input.txt"], targets=["output.txt"])
43
+ c = Controller(m, conn)
44
+
45
+ # No entry in the database.
46
+ assert c.is_stale(task)
47
+ assert c.resolve_stale_tasks() == {task}
48
+
49
+ # Up to date entry in the database.
50
+ conn.execute(QUERIES["upsert"], {"name": "input.txt", "digest": "input.txt", "size": 9})
51
+ c = Controller(m, conn)
52
+ assert not c.is_stale(task)
53
+
54
+ # Wrong file size in the database.
55
+ conn.execute(QUERIES["upsert"], {"name": "input.txt", "digest": "input.txt", "size": 7})
56
+ c = Controller(m, conn)
57
+ assert c.is_stale(task)
58
+
59
+ # Wrong digest in the database.
60
+ conn.execute(QUERIES["upsert"], {"name": "input.txt", "digest": "-", "size": 9})
61
+ c = Controller(m, conn)
62
+ assert c.is_stale(task)
63
+
64
+
65
+ def test_controller(m: Manager, conn: Connection, patched_hexdigest: None) -> None:
66
+ for filename in ["input1.txt", "input2.txt", "intermediate.txt", "output1.txt"]:
67
+ Path(filename).write_text(filename)
68
+
69
+ with normalize_dependencies():
70
+ intermediate = m.create_task("intermediate", dependencies=["input1.txt", "input2.txt"],
71
+ targets=["intermediate.txt"], action=FunctionAction(touch))
72
+ output1 = m.create_task("output1", dependencies=["intermediate.txt"],
73
+ targets=["output1.txt"], action=FunctionAction(touch))
74
+ output2 = m.create_task("output2", targets=["output2.txt"], action=FunctionAction(touch),
75
+ dependencies=["intermediate.txt", "input2.txt", "output1.txt"])
76
+ special = m.create_task("special", dependencies=["intermediate.txt"])
77
+
78
+ # Make sure that the first output is not itself stale.
79
+ conn.executemany(QUERIES["upsert"], [
80
+ {"name": "intermediate.txt", "digest": "intermediate.txt", "size": 16},
81
+ ])
82
+ c = Controller(m, conn)
83
+ assert not c.is_stale(output1, recursive=False)
84
+
85
+ # We should get back all tasks anyway because the intermediate task is out of date (its inputs
86
+ # are not in the database).
87
+ c = Controller(m, conn)
88
+ assert c.resolve_stale_tasks() == {intermediate, output1, output2, special}
89
+
90
+ # Make sure we don't get any tasks that are upstream from what we request.
91
+ c = Controller(m, conn)
92
+ assert c.resolve_stale_tasks([output1]) == {intermediate, output1}
93
+
94
+ # Execute tasks and check that they are no longer stale.
95
+ c = Controller(m, conn)
96
+ c.execute_sync(output1)
97
+ assert not c.resolve_stale_tasks([output1])
98
+
99
+ # But the other ones are still stale.
100
+ c = Controller(m, conn)
101
+ assert c.resolve_stale_tasks() == {output2, special}
102
+
103
+ # Execute the second output. The special task without outputs never disappears.
104
+ c = Controller(m, conn)
105
+ c.execute_sync(output2)
106
+ assert c.resolve_stale_tasks() == {special}
107
+
108
+
109
+ def test_target_not_created(m: Manager, conn: Connection) -> None:
110
+ task = m.create_task("nothing", targets=["missing"])
111
+ c = Controller(m, conn)
112
+ with pytest.raises(FailedTaskError, match="did not create"):
113
+ c.execute_sync(task)
114
+
115
+
116
+ def test_failing_task(m: Manager, conn: Connection) -> None:
117
+ def raise_exception(_) -> None:
118
+ raise RuntimeError
119
+
120
+ task = m.create_task("nothing", action=FunctionAction(raise_exception))
121
+ c = Controller(m, conn)
122
+ with pytest.raises(FailedTaskError):
123
+ c.execute_sync(task)
124
+
125
+
126
+ def test_concurrency(m: Manager, conn: Connection) -> None:
127
+ delay = 0.2
128
+ num_tasks = 4
129
+
130
+ tasks = [m.create_task(str(i), action=ShellAction(f"sleep {delay} && touch {i}.txt"),
131
+ targets=[f"{i}.txt"]) for i in range(num_tasks)]
132
+ task = m.create_task("result", dependencies=[task.targets[0] for task in tasks])
133
+
134
+ c = Controller(m, conn)
135
+ with Timer() as timer:
136
+ c.execute_sync(task)
137
+ assert timer.duration > num_tasks * delay
138
+
139
+ c = Controller(m, conn, num_concurrent=num_tasks)
140
+ with Timer() as timer:
141
+ c.execute_sync(task)
142
+ assert timer.duration < 2 * delay
143
+
144
+
145
+ def test_hexdigest_cache(m: Manager, conn: Connection, tmp_wd: Path) -> None:
146
+ c = Controller(m, conn)
147
+ shutil.copy(__file__, tmp_wd / "foo")
148
+ with patch("cook.util.evaluate_hexdigest") as evaluate_hexdigest:
149
+ c.evaluate_size_digest("foo")
150
+ c.evaluate_size_digest("foo")
151
+ evaluate_hexdigest.assert_called_once()
152
+
153
+
154
+ # TODO: add tests to verify what happens when tasks are cancelled, e.g., by `KeyboardInterrupt`.
@@ -0,0 +1,11 @@
1
+ from cook.__main__ import __main__
2
+ from cook.util import working_directory
3
+ import pytest
4
+
5
+
6
+ @pytest.mark.parametrize("name, task", [
7
+ ("hellomake", "say-hello"),
8
+ ])
9
+ def test_example(name: str, task: str) -> None:
10
+ with working_directory(f"examples/{name}"):
11
+ __main__(["exec", task])
@@ -0,0 +1,68 @@
1
+ from cook.__main__ import __main__, Formatter
2
+ import logging
3
+ from pathlib import Path
4
+ import pytest
5
+ import shutil
6
+ from typing import List
7
+
8
+
9
+ RECIPES = Path(__file__).parent / "recipes"
10
+
11
+
12
+ def test_blah_recipe_run(tmp_wd: Path) -> None:
13
+ __main__(["--recipe", str(RECIPES / "blah.py"), "exec", "run"])
14
+
15
+
16
+ @pytest.mark.parametrize("patterns, expected", [
17
+ ([], ["create_source", "compile", "link", "run"]),
18
+ (["c*"], ["create_source", "compile"]),
19
+ (["--re", r"^\w{3}\w?$"], ["link", "run"]),
20
+ (["run"], ["run"]),
21
+ ])
22
+ def test_blah_recipe_ls(patterns: str, expected: List[str], capsys: pytest.CaptureFixture) -> None:
23
+ __main__(["--recipe", str(RECIPES / "blah.py"), "ls", *patterns])
24
+ out, _ = capsys.readouterr()
25
+ for task in expected:
26
+ assert f"<task `{task}` @ " in out
27
+
28
+
29
+ def test_blah_recipe_info() -> None:
30
+ __main__(["--recipe", str(RECIPES / "blah.py"), "info", "link"])
31
+
32
+
33
+ def test_blah_recipe_reset() -> None:
34
+ __main__(["--recipe", str(RECIPES / "blah.py"), "reset", "link"])
35
+
36
+
37
+ def test_simple_dag_run(tmp_wd: Path) -> None:
38
+ __main__(["--recipe", str(RECIPES / "simple_dag.py"), "exec", "3-1"])
39
+
40
+
41
+ @pytest.mark.parametrize("patterns", [
42
+ ["foo"],
43
+ ["foo", "bar"],
44
+ ["foo", "bar", "baz"],
45
+ ])
46
+ def test_simple_dag_no_matching_tasks(caplog: pytest.LogCaptureFixture, patterns: List[str]) \
47
+ -> None:
48
+ with pytest.raises(SystemExit), caplog.at_level("WARNING"):
49
+ __main__(["--recipe", str(RECIPES / "simple_dag.py"), "ls", *patterns])
50
+ assert "found no tasks matching" in caplog.text
51
+
52
+
53
+ def test_module_import(tmp_wd: Path) -> None:
54
+ recipe = tmp_wd / "my_recipe.py"
55
+ shutil.copy(RECIPES / "simple_dag.py", recipe)
56
+ __main__(["-m", "my_recipe", "ls"])
57
+
58
+
59
+ def test_bad_recipe(caplog: pytest.LogCaptureFixture) -> None:
60
+ with pytest.raises(SystemExit), caplog.at_level("ERROR"):
61
+ __main__(["--recipe", str(RECIPES / "bad.py"), "exec", "false"])
62
+ assert "failed to execute" in caplog.text
63
+
64
+
65
+ def test_custom_formatter() -> None:
66
+ formatter = Formatter()
67
+ record = logging.LogRecord("a", logging.ERROR, "b", 2, "foo", None, None)
68
+ assert isinstance(formatter.format(record), str)
@@ -0,0 +1,60 @@
1
+ from cook import Manager
2
+ from cook.contexts import create_group, normalize_dependencies
3
+ from pathlib import Path
4
+ import pytest
5
+
6
+
7
+ def test_resolve_dependencies(m: Manager) -> None:
8
+ Path("input1.txt").write_text("input1.txt")
9
+ Path("input2.txt").write_text("input2.txt")
10
+
11
+ with normalize_dependencies():
12
+ intermediate = m.create_task("intermediate", dependencies=["input1.txt", "input2.txt"],
13
+ targets=["intermediate.txt"])
14
+ with create_group("outputs") as outputs:
15
+ output1 = m.create_task("output1", dependencies=["intermediate.txt"],
16
+ targets=["output1.txt"])
17
+ output2 = m.create_task("output2", targets=["output2.txt"],
18
+ dependencies=["intermediate.txt", "input2.txt", "output1.txt"])
19
+ special = m.create_task("special", dependencies=["intermediate.txt"])
20
+ dependent = m.create_task("dependent", task_dependencies=[output1])
21
+ dependencies = m.resolve_dependencies()
22
+
23
+ assert dependencies == {
24
+ output1: {intermediate},
25
+ output2: {intermediate, output1},
26
+ special: {intermediate},
27
+ dependent: {output1},
28
+ outputs.task: {output1, output2},
29
+ }
30
+
31
+
32
+ def test_missing_file(m: Manager) -> None:
33
+ with normalize_dependencies():
34
+ m.create_task("has_missing_file_dependency", dependencies=["missing-file.txt"])
35
+ with pytest.raises(FileNotFoundError, match="does not exist nor is"):
36
+ m.resolve_dependencies()
37
+
38
+
39
+ def test_conflicting_targets(m: Manager) -> None:
40
+ m.create_task("foo", targets=["bar"])
41
+ m.create_task("baz", targets=["bar"])
42
+ with pytest.raises(ValueError, match="both have target"):
43
+ m.resolve_dependencies()
44
+
45
+
46
+ def test_same_name(m: Manager) -> None:
47
+ m.create_task("foo")
48
+ with pytest.raises(ValueError, match="task with name foo already exists"):
49
+ m.create_task("foo")
50
+
51
+
52
+ def test_get_manager_instance() -> None:
53
+ with pytest.raises(ValueError, match="no manager"):
54
+ Manager.get_instance()
55
+ with Manager() as m:
56
+ assert Manager.get_instance() is m
57
+ with pytest.raises(RuntimeError, match="unexpected manager"), Manager():
58
+ Manager._INSTANCE = "asdf"
59
+ with pytest.raises(ValueError, match="already active"), Manager(), Manager():
60
+ pass
@@ -0,0 +1,9 @@
1
+ from cook import util
2
+ import hashlib
3
+ from pathlib import Path
4
+
5
+
6
+ def test_evaluate_digest(tmp_wd: Path) -> None:
7
+ fn = tmp_wd / "foo.txt"
8
+ fn.write_text("bar")
9
+ assert util.evaluate_hexdigest(fn) == hashlib.sha1(b"bar").hexdigest()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes