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.
- {cook-build-0.2.0/cook_build.egg-info → cook-build-0.2.1}/PKG-INFO +30 -20
- {cook-build-0.2.0 → cook-build-0.2.1}/README.rst +24 -19
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/__main__.py +4 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/controller.py +24 -3
- {cook-build-0.2.0 → cook-build-0.2.1/cook_build.egg-info}/PKG-INFO +30 -20
- {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/SOURCES.txt +8 -1
- {cook-build-0.2.0 → cook-build-0.2.1}/setup.py +6 -3
- cook-build-0.2.1/tests/test_actions.py +51 -0
- cook-build-0.2.1/tests/test_contexts.py +89 -0
- cook-build-0.2.1/tests/test_controller.py +154 -0
- cook-build-0.2.1/tests/test_examples.py +11 -0
- cook-build-0.2.1/tests/test_main.py +68 -0
- cook-build-0.2.1/tests/test_manager.py +60 -0
- cook-build-0.2.1/tests/test_util.py +9 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/LICENSE +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/__init__.py +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/actions.py +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/contexts.py +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/manager.py +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/task.py +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook/util.py +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/dependency_links.txt +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/entry_points.txt +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/requires.txt +0 -0
- {cook-build-0.2.0 → cook-build-0.2.1}/cook_build.egg-info/top_level.txt +0 -0
- {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.
|
|
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::
|
|
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:
|
|
39
|
-
<task `hello` @ /.../recipe.py:
|
|
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::
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
DEBUG: completed <task `cc` @ ...> in ... seconds
|
|
54
|
-
DEBUG: `<task `cc` @ ...>` created `hello`
|
|
49
|
+
:cwd: examples/getting_started
|
|
50
|
+
:stderr:
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
DEBUG:
|
|
58
|
-
DEBUG:
|
|
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::
|
|
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
|
-
..
|
|
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:
|
|
33
|
-
<task `hello` @ /.../recipe.py:
|
|
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
|
-
..
|
|
38
|
+
.. shtest::
|
|
39
|
+
:cwd: examples/getting_started
|
|
40
|
+
:stderr:
|
|
38
41
|
|
|
39
42
|
$ cook --log-level=debug exec hello
|
|
40
|
-
DEBUG: <task `src` @
|
|
41
|
-
DEBUG: started <task `src` @
|
|
42
|
-
DEBUG: completed <task `src` @
|
|
43
|
-
DEBUG:
|
|
44
|
-
|
|
45
|
-
DEBUG: <task `cc` @
|
|
46
|
-
DEBUG:
|
|
47
|
-
DEBUG:
|
|
48
|
-
DEBUG:
|
|
49
|
-
|
|
50
|
-
DEBUG: <task `hello` @
|
|
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
|
-
..
|
|
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.
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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::
|
|
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:
|
|
39
|
-
<task `hello` @ /.../recipe.py:
|
|
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::
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
DEBUG: completed <task `cc` @ ...> in ... seconds
|
|
54
|
-
DEBUG: `<task `cc` @ ...>` created `hello`
|
|
49
|
+
:cwd: examples/getting_started
|
|
50
|
+
:stderr:
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
DEBUG:
|
|
58
|
-
DEBUG:
|
|
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::
|
|
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.
|
|
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
|
|
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
|