cook-build 0.7.2__tar.gz → 0.7.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cook_build-0.7.2/src/cook_build.egg-info → cook_build-0.7.4}/PKG-INFO +1 -1
- {cook_build-0.7.2 → cook_build-0.7.4}/pyproject.toml +2 -1
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/__init__.py +2 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/__main__.py +4 -6
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/actions.py +10 -8
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/controller.py +10 -12
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/manager.py +1 -1
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/util.py +45 -0
- {cook_build-0.7.2 → cook_build-0.7.4/src/cook_build.egg-info}/PKG-INFO +1 -1
- {cook_build-0.7.2 → cook_build-0.7.4}/tests/test_contexts.py +3 -3
- cook_build-0.7.4/tests/test_util.py +69 -0
- cook_build-0.7.2/tests/test_util.py +0 -25
- {cook_build-0.7.2 → cook_build-0.7.4}/LICENSE +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/README.md +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/README.rst +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/setup.cfg +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/contexts.py +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook/task.py +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook_build.egg-info/SOURCES.txt +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook_build.egg-info/dependency_links.txt +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook_build.egg-info/entry_points.txt +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook_build.egg-info/requires.txt +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/src/cook_build.egg-info/top_level.txt +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/tests/test_actions.py +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/tests/test_controller.py +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/tests/test_examples.py +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/tests/test_main.py +0 -0
- {cook_build-0.7.2 → cook_build-0.7.4}/tests/test_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cook-build"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.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"}
|
|
@@ -37,6 +37,7 @@ Issues = "https://github.com/tillahoffmann/cook-build/issues"
|
|
|
37
37
|
dev = [
|
|
38
38
|
"build>=1.3.0",
|
|
39
39
|
"furo>=2025.9.25",
|
|
40
|
+
"pre-commit>=4.5.1",
|
|
40
41
|
"pyright>=1.1.406",
|
|
41
42
|
"pytest>=8.4.2",
|
|
42
43
|
"pytest-asyncio>=0.24.0",
|
|
@@ -342,12 +342,10 @@ def __main__(cli_args: list[str] | None = None) -> None:
|
|
|
342
342
|
]
|
|
343
343
|
)
|
|
344
344
|
if args.module:
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
finally:
|
|
350
|
-
sys.path.pop()
|
|
345
|
+
# Add the current working directory to the path so local modules can be
|
|
346
|
+
# imported.
|
|
347
|
+
sys.path.append(os.getcwd())
|
|
348
|
+
importlib.import_module(args.module)
|
|
351
349
|
elif args.recipe.is_file():
|
|
352
350
|
# Parse the recipe.
|
|
353
351
|
spec = importlib.util.spec_from_file_location("recipe", args.recipe)
|
|
@@ -122,21 +122,22 @@ class SubprocessAction(Action):
|
|
|
122
122
|
True
|
|
123
123
|
"""
|
|
124
124
|
|
|
125
|
-
def __init__(self,
|
|
125
|
+
def __init__(self, args: str | list[str], **kwargs) -> None:
|
|
126
126
|
# Validate shell argument early
|
|
127
|
-
if kwargs.get("shell", False) and
|
|
127
|
+
if kwargs.get("shell", False) and not isinstance(args, str):
|
|
128
128
|
raise ValueError("shell=True requires string args")
|
|
129
129
|
self.args = args
|
|
130
130
|
self.kwargs = kwargs
|
|
131
131
|
|
|
132
132
|
async def execute(self, task: "Task") -> None:
|
|
133
133
|
# Get the command arguments
|
|
134
|
-
|
|
134
|
+
args = self.args
|
|
135
135
|
shell = self.kwargs.get("shell", False)
|
|
136
136
|
other_kwargs = {k: v for k, v in self.kwargs.items() if k != "shell"}
|
|
137
137
|
|
|
138
138
|
# Create the subprocess
|
|
139
139
|
if shell:
|
|
140
|
+
assert isinstance(args, str)
|
|
140
141
|
process = await asyncio.create_subprocess_shell(args, **other_kwargs)
|
|
141
142
|
else:
|
|
142
143
|
# Exec mode: args can be a string (single command) or list
|
|
@@ -167,16 +168,17 @@ class SubprocessAction(Action):
|
|
|
167
168
|
@property
|
|
168
169
|
def hexdigest(self) -> str:
|
|
169
170
|
hasher = hashlib.sha1()
|
|
170
|
-
|
|
171
|
+
args = self.args
|
|
171
172
|
if isinstance(args, str):
|
|
172
173
|
hasher.update(args.encode())
|
|
173
174
|
else:
|
|
174
175
|
for arg in args:
|
|
175
176
|
hasher.update(arg.encode())
|
|
177
|
+
hasher.update(b"\0")
|
|
176
178
|
return hasher.hexdigest()
|
|
177
179
|
|
|
178
180
|
def __repr__(self) -> str:
|
|
179
|
-
args
|
|
181
|
+
args = self.args
|
|
180
182
|
if not isinstance(args, str):
|
|
181
183
|
args = " ".join(map(shlex.quote, args))
|
|
182
184
|
return f"{self.__class__.__name__}({repr(args)})"
|
|
@@ -199,13 +201,13 @@ class CompositeAction(Action):
|
|
|
199
201
|
|
|
200
202
|
@property
|
|
201
203
|
def hexdigest(self) -> str | None:
|
|
202
|
-
|
|
204
|
+
hasher = hashlib.sha1()
|
|
203
205
|
for action in self.actions:
|
|
204
206
|
hexdigest = action.hexdigest
|
|
205
207
|
if hexdigest is None:
|
|
206
208
|
return None
|
|
207
|
-
|
|
208
|
-
return
|
|
209
|
+
hasher.update(bytearray.fromhex(hexdigest))
|
|
210
|
+
return hasher.hexdigest()
|
|
209
211
|
|
|
210
212
|
|
|
211
213
|
class ModuleAction(SubprocessAction):
|
|
@@ -39,11 +39,6 @@ QUERIES = {
|
|
|
39
39
|
"last_digested" TIMESTAMP NOT NULL
|
|
40
40
|
);
|
|
41
41
|
""",
|
|
42
|
-
"select_task": """
|
|
43
|
-
SELECT "digest"
|
|
44
|
-
FROM "files"
|
|
45
|
-
WHERE "name" = :name
|
|
46
|
-
""",
|
|
47
42
|
"upsert_task_completed": """
|
|
48
43
|
INSERT INTO "tasks" ("name", "digest", "last_completed")
|
|
49
44
|
VALUES (:name, :digest, :last_completed)
|
|
@@ -80,7 +75,6 @@ class Controller:
|
|
|
80
75
|
def __init__(self, dependencies: nx.DiGraph, connection: Connection) -> None:
|
|
81
76
|
self.dependencies = dependencies
|
|
82
77
|
self.connection = connection
|
|
83
|
-
self._digest_cache: dict[Path, tuple[float, bytes]] = {}
|
|
84
78
|
|
|
85
79
|
def resolve_stale_tasks(self, tasks: list["Task"] | None = None) -> set["Task"]:
|
|
86
80
|
self.is_stale(tasks or list(self.dependencies))
|
|
@@ -88,7 +82,7 @@ class Controller:
|
|
|
88
82
|
node for node, data in self.dependencies.nodes(True) if data.get("is_stale")
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
def _evaluate_task_hexdigest(self, task: "Task") -> str
|
|
85
|
+
def _evaluate_task_hexdigest(self, task: "Task") -> str:
|
|
92
86
|
"""
|
|
93
87
|
Evaluate the digest of a task by combining the digest of all its dependencies.
|
|
94
88
|
"""
|
|
@@ -102,8 +96,7 @@ class Controller:
|
|
|
102
96
|
)
|
|
103
97
|
dependency = Path(dependency).resolve()
|
|
104
98
|
if not dependency.is_file():
|
|
105
|
-
|
|
106
|
-
return None
|
|
99
|
+
raise FileNotFoundError(f"dependency {dependency} of {task} is missing")
|
|
107
100
|
dependencies.append(dependency)
|
|
108
101
|
|
|
109
102
|
hasher = hashlib.sha1()
|
|
@@ -198,9 +191,11 @@ class Controller:
|
|
|
198
191
|
return True
|
|
199
192
|
|
|
200
193
|
# If one of the dependencies is missing, the task is stale.
|
|
201
|
-
|
|
202
|
-
|
|
194
|
+
try:
|
|
195
|
+
current_digest = self._evaluate_task_hexdigest(task)
|
|
196
|
+
except FileNotFoundError:
|
|
203
197
|
LOGGER.debug("%s is stale because one of its dependencies is missing", task)
|
|
198
|
+
return True
|
|
204
199
|
|
|
205
200
|
# If the digest has changed, the task is stale.
|
|
206
201
|
(cached_digest,) = cached_digest
|
|
@@ -290,7 +285,9 @@ class Controller:
|
|
|
290
285
|
dep_futures = [task_futures[dep] for dep in dep_tasks]
|
|
291
286
|
await asyncio.gather(*dep_futures)
|
|
292
287
|
|
|
293
|
-
digest =
|
|
288
|
+
digest: str | None = None
|
|
289
|
+
if not dry_run:
|
|
290
|
+
digest = self._evaluate_task_hexdigest(task)
|
|
294
291
|
start: datetime | None = None
|
|
295
292
|
|
|
296
293
|
try:
|
|
@@ -332,6 +329,7 @@ class Controller:
|
|
|
332
329
|
|
|
333
330
|
# Update DB for completion
|
|
334
331
|
if not dry_run:
|
|
332
|
+
assert digest is not None
|
|
335
333
|
params = {
|
|
336
334
|
"name": task.name,
|
|
337
335
|
"digest": digest,
|
|
@@ -63,7 +63,7 @@ class Manager:
|
|
|
63
63
|
raise ValueError(f"{context} did not return a task")
|
|
64
64
|
self.tasks[task.name] = task
|
|
65
65
|
return task
|
|
66
|
-
except:
|
|
66
|
+
except Exception:
|
|
67
67
|
filename, lineno = util.get_location()
|
|
68
68
|
LOGGER.exception(
|
|
69
69
|
"failed to create task with name '%s' at %s:%d", name, filename, lineno
|
|
@@ -32,6 +32,7 @@ def evaluate_hexdigest(path: PathOrStr, size=2**16, hasher: str = "sha1") -> str
|
|
|
32
32
|
class Timer:
|
|
33
33
|
def __init__(self):
|
|
34
34
|
self.start = None
|
|
35
|
+
self.end = None
|
|
35
36
|
|
|
36
37
|
def __enter__(self) -> Timer:
|
|
37
38
|
self.start = time()
|
|
@@ -97,3 +98,47 @@ def format_datetime(dt: datetime) -> str:
|
|
|
97
98
|
Format a date-time.
|
|
98
99
|
"""
|
|
99
100
|
return str(dt).rsplit(".", 2)[0]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def dict2args(*positional, flags: dict | None = None, **kwargs) -> list[str]:
|
|
104
|
+
"""
|
|
105
|
+
Convert arguments to command-line argument strings.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
*positional: Positional arguments appended after flags.
|
|
109
|
+
flags: Dictionary for flag names that aren't valid Python identifiers
|
|
110
|
+
(e.g., names containing dashes).
|
|
111
|
+
**kwargs: Keyword arguments converted to flags.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of command-line argument strings.
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
|
|
118
|
+
.. doctest::
|
|
119
|
+
|
|
120
|
+
>>> from cook.util import dict2args
|
|
121
|
+
>>> dict2args(output="file.txt", verbose=True, count=3)
|
|
122
|
+
['--output=file.txt', '--verbose', '--count=3']
|
|
123
|
+
>>> dict2args("input.txt", "output.txt", verbose=True)
|
|
124
|
+
['--verbose', 'input.txt', 'output.txt']
|
|
125
|
+
>>> dict2args(flags={"dry-run": True, "num-workers": 4})
|
|
126
|
+
['--dry-run', '--num-workers=4']
|
|
127
|
+
>>> dict2args(values=[1, 2, 3])
|
|
128
|
+
['--values=1,2,3']
|
|
129
|
+
"""
|
|
130
|
+
if flags is not None:
|
|
131
|
+
kwargs = flags | kwargs
|
|
132
|
+
args = []
|
|
133
|
+
for key, value in kwargs.items():
|
|
134
|
+
if isinstance(value, bool):
|
|
135
|
+
if value:
|
|
136
|
+
args.append(f"--{key}")
|
|
137
|
+
elif isinstance(value, (list, tuple)):
|
|
138
|
+
if not value:
|
|
139
|
+
raise ValueError(f"empty sequence for argument {key!r}")
|
|
140
|
+
args.append(f"--{key}={','.join(map(str, value))}")
|
|
141
|
+
else:
|
|
142
|
+
args.append(f"--{key}={value}")
|
|
143
|
+
args.extend(str(arg) for arg in positional)
|
|
144
|
+
return args
|
|
@@ -110,14 +110,14 @@ def test_normalize_action(m: Manager) -> None:
|
|
|
110
110
|
task = m.create_task("foo", action="bar")
|
|
111
111
|
assert (
|
|
112
112
|
isinstance(task.action, SubprocessAction)
|
|
113
|
-
and task.action.args
|
|
113
|
+
and task.action.args == "bar"
|
|
114
114
|
and task.action.kwargs["shell"]
|
|
115
115
|
)
|
|
116
116
|
|
|
117
117
|
task = m.create_task("bar", action=["baz"])
|
|
118
118
|
assert (
|
|
119
119
|
isinstance(task.action, SubprocessAction)
|
|
120
|
-
and task.action.args
|
|
120
|
+
and task.action.args == ["baz"]
|
|
121
121
|
and not task.action.kwargs.get("shell")
|
|
122
122
|
)
|
|
123
123
|
|
|
@@ -131,7 +131,7 @@ def test_normalize_action(m: Manager) -> None:
|
|
|
131
131
|
assert isinstance(task.action, FunctionAction)
|
|
132
132
|
|
|
133
133
|
task = m.create_task("fizz", action=[pytest, "foo", "bar"])
|
|
134
|
-
assert isinstance(task.action, ModuleAction) and task.action.args
|
|
134
|
+
assert isinstance(task.action, ModuleAction) and task.action.args == [
|
|
135
135
|
sys.executable,
|
|
136
136
|
"-m",
|
|
137
137
|
"pytest",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from cook import util
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_evaluate_digest(tmp_wd: Path) -> None:
|
|
11
|
+
fn = tmp_wd / "foo.txt"
|
|
12
|
+
fn.write_text("bar")
|
|
13
|
+
assert util.evaluate_hexdigest(fn) == hashlib.sha1(b"bar").hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_format_timedelta() -> None:
|
|
17
|
+
assert (
|
|
18
|
+
util.format_timedelta(timedelta(1, 13, 17, 28, 40, 3, 8)) == "57 days, 3:40:13"
|
|
19
|
+
)
|
|
20
|
+
assert util.format_timedelta(timedelta(microseconds=999)) == "0:00:00.000999"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_format_datetime() -> None:
|
|
24
|
+
assert (
|
|
25
|
+
util.format_datetime(datetime(2023, 7, 25, 13, 7, 9, 777))
|
|
26
|
+
== "2023-07-25 13:07:09"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_dict2args() -> None:
|
|
31
|
+
# Basic string value
|
|
32
|
+
assert util.dict2args(output="file.txt") == ["--output=file.txt"]
|
|
33
|
+
|
|
34
|
+
# Boolean True adds flag, False omits it
|
|
35
|
+
assert util.dict2args(verbose=True) == ["--verbose"]
|
|
36
|
+
assert util.dict2args(verbose=False) == []
|
|
37
|
+
|
|
38
|
+
# Numeric values
|
|
39
|
+
assert util.dict2args(count=3) == ["--count=3"]
|
|
40
|
+
|
|
41
|
+
# Lists/tuples joined with commas
|
|
42
|
+
assert util.dict2args(values=[1, 2, 3]) == ["--values=1,2,3"]
|
|
43
|
+
assert util.dict2args(items=("a", "b")) == ["--items=a,b"]
|
|
44
|
+
|
|
45
|
+
# Multiple arguments preserve order (Python 3.7+)
|
|
46
|
+
result = util.dict2args(output="out.txt", verbose=True, count=5)
|
|
47
|
+
assert result == ["--output=out.txt", "--verbose", "--count=5"]
|
|
48
|
+
|
|
49
|
+
# Positional arguments come after flags
|
|
50
|
+
result = util.dict2args("input.txt", "output.txt", verbose=True)
|
|
51
|
+
assert result == ["--verbose", "input.txt", "output.txt"]
|
|
52
|
+
|
|
53
|
+
# flags parameter for names with dashes or other non-identifier chars
|
|
54
|
+
assert util.dict2args(flags={"dry-run": True}) == ["--dry-run"]
|
|
55
|
+
assert util.dict2args(flags={"num-workers": 4}) == ["--num-workers=4"]
|
|
56
|
+
|
|
57
|
+
# flags and kwargs combined (kwargs take precedence)
|
|
58
|
+
result = util.dict2args(flags={"output": "old.txt"}, output="new.txt")
|
|
59
|
+
assert result == ["--output=new.txt"]
|
|
60
|
+
|
|
61
|
+
# All together: flags, kwargs, and positional
|
|
62
|
+
result = util.dict2args("file.txt", flags={"dry-run": True}, verbose=True)
|
|
63
|
+
assert result == ["--dry-run", "--verbose", "file.txt"]
|
|
64
|
+
|
|
65
|
+
# Empty sequences raise ValueError
|
|
66
|
+
with pytest.raises(ValueError, match="empty sequence"):
|
|
67
|
+
util.dict2args(values=[])
|
|
68
|
+
with pytest.raises(ValueError, match="empty sequence"):
|
|
69
|
+
util.dict2args(items=())
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import hashlib
|
|
2
|
-
from datetime import datetime, timedelta
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from cook import util
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_evaluate_digest(tmp_wd: Path) -> None:
|
|
9
|
-
fn = tmp_wd / "foo.txt"
|
|
10
|
-
fn.write_text("bar")
|
|
11
|
-
assert util.evaluate_hexdigest(fn) == hashlib.sha1(b"bar").hexdigest()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_format_timedelta() -> None:
|
|
15
|
-
assert (
|
|
16
|
-
util.format_timedelta(timedelta(1, 13, 17, 28, 40, 3, 8)) == "57 days, 3:40:13"
|
|
17
|
-
)
|
|
18
|
-
assert util.format_timedelta(timedelta(microseconds=999)) == "0:00:00.000999"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_format_datetime() -> None:
|
|
22
|
-
assert (
|
|
23
|
-
util.format_datetime(datetime(2023, 7, 25, 13, 7, 9, 777))
|
|
24
|
-
== "2023-07-25 13:07:09"
|
|
25
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|