cook-build 0.7.1__tar.gz → 0.7.3__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.1/src/cook_build.egg-info → cook_build-0.7.3}/PKG-INFO +1 -1
- {cook_build-0.7.1 → cook_build-0.7.3}/pyproject.toml +1 -1
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/__main__.py +4 -6
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/actions.py +13 -9
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/contexts.py +1 -1
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/controller.py +22 -26
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/manager.py +2 -2
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/util.py +1 -0
- {cook_build-0.7.1 → cook_build-0.7.3/src/cook_build.egg-info}/PKG-INFO +1 -1
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_actions.py +12 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_contexts.py +24 -3
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_controller.py +30 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/LICENSE +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/README.md +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/README.rst +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/setup.cfg +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/__init__.py +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook/task.py +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook_build.egg-info/SOURCES.txt +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook_build.egg-info/dependency_links.txt +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook_build.egg-info/entry_points.txt +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook_build.egg-info/requires.txt +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/src/cook_build.egg-info/top_level.txt +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_examples.py +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_main.py +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_manager.py +0 -0
- {cook_build-0.7.1 → cook_build-0.7.3}/tests/test_util.py +0 -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)
|
|
@@ -36,6 +36,7 @@ executor with a deprecation warning.
|
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
import asyncio
|
|
39
|
+
import functools
|
|
39
40
|
import hashlib
|
|
40
41
|
import logging
|
|
41
42
|
import os
|
|
@@ -94,7 +95,9 @@ class FunctionAction(Action):
|
|
|
94
95
|
else:
|
|
95
96
|
# Run sync function in executor
|
|
96
97
|
loop = asyncio.get_running_loop()
|
|
97
|
-
await loop.run_in_executor(
|
|
98
|
+
await loop.run_in_executor(
|
|
99
|
+
None, functools.partial(self.func, task, *self.args, **self.kwargs)
|
|
100
|
+
)
|
|
98
101
|
|
|
99
102
|
|
|
100
103
|
class SubprocessAction(Action):
|
|
@@ -119,21 +122,22 @@ class SubprocessAction(Action):
|
|
|
119
122
|
True
|
|
120
123
|
"""
|
|
121
124
|
|
|
122
|
-
def __init__(self,
|
|
125
|
+
def __init__(self, args: str | list[str], **kwargs) -> None:
|
|
123
126
|
# Validate shell argument early
|
|
124
|
-
if kwargs.get("shell", False) and
|
|
127
|
+
if kwargs.get("shell", False) and not isinstance(args, str):
|
|
125
128
|
raise ValueError("shell=True requires string args")
|
|
126
129
|
self.args = args
|
|
127
130
|
self.kwargs = kwargs
|
|
128
131
|
|
|
129
132
|
async def execute(self, task: "Task") -> None:
|
|
130
133
|
# Get the command arguments
|
|
131
|
-
|
|
134
|
+
args = self.args
|
|
132
135
|
shell = self.kwargs.get("shell", False)
|
|
133
136
|
other_kwargs = {k: v for k, v in self.kwargs.items() if k != "shell"}
|
|
134
137
|
|
|
135
138
|
# Create the subprocess
|
|
136
139
|
if shell:
|
|
140
|
+
assert isinstance(args, str)
|
|
137
141
|
process = await asyncio.create_subprocess_shell(args, **other_kwargs)
|
|
138
142
|
else:
|
|
139
143
|
# Exec mode: args can be a string (single command) or list
|
|
@@ -164,7 +168,7 @@ class SubprocessAction(Action):
|
|
|
164
168
|
@property
|
|
165
169
|
def hexdigest(self) -> str:
|
|
166
170
|
hasher = hashlib.sha1()
|
|
167
|
-
|
|
171
|
+
args = self.args
|
|
168
172
|
if isinstance(args, str):
|
|
169
173
|
hasher.update(args.encode())
|
|
170
174
|
else:
|
|
@@ -173,7 +177,7 @@ class SubprocessAction(Action):
|
|
|
173
177
|
return hasher.hexdigest()
|
|
174
178
|
|
|
175
179
|
def __repr__(self) -> str:
|
|
176
|
-
args
|
|
180
|
+
args = self.args
|
|
177
181
|
if not isinstance(args, str):
|
|
178
182
|
args = " ".join(map(shlex.quote, args))
|
|
179
183
|
return f"{self.__class__.__name__}({repr(args)})"
|
|
@@ -196,13 +200,13 @@ class CompositeAction(Action):
|
|
|
196
200
|
|
|
197
201
|
@property
|
|
198
202
|
def hexdigest(self) -> str | None:
|
|
199
|
-
|
|
203
|
+
hasher = hashlib.sha1()
|
|
200
204
|
for action in self.actions:
|
|
201
205
|
hexdigest = action.hexdigest
|
|
202
206
|
if hexdigest is None:
|
|
203
207
|
return None
|
|
204
|
-
|
|
205
|
-
return
|
|
208
|
+
hasher.update(bytearray.fromhex(hexdigest))
|
|
209
|
+
return hasher.hexdigest()
|
|
206
210
|
|
|
207
211
|
|
|
208
212
|
class ModuleAction(SubprocessAction):
|
|
@@ -152,7 +152,7 @@ class create_target_directories(Context):
|
|
|
152
152
|
create = self.manager.create_task(
|
|
153
153
|
name,
|
|
154
154
|
action=actions.FunctionAction(
|
|
155
|
-
lambda _
|
|
155
|
+
lambda _, p=target.parent: p.mkdir(parents=True, exist_ok=True)
|
|
156
156
|
),
|
|
157
157
|
)
|
|
158
158
|
task.task_dependencies.append(create)
|
|
@@ -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,8 +285,10 @@ class Controller:
|
|
|
290
285
|
dep_futures = [task_futures[dep] for dep in dep_tasks]
|
|
291
286
|
await asyncio.gather(*dep_futures)
|
|
292
287
|
|
|
293
|
-
|
|
294
|
-
|
|
288
|
+
digest: str | None = None
|
|
289
|
+
if not dry_run:
|
|
290
|
+
digest = self._evaluate_task_hexdigest(task)
|
|
291
|
+
start: datetime | None = None
|
|
295
292
|
|
|
296
293
|
try:
|
|
297
294
|
# Log what we're doing
|
|
@@ -307,22 +304,19 @@ class Controller:
|
|
|
307
304
|
" action: %s",
|
|
308
305
|
task.action,
|
|
309
306
|
)
|
|
310
|
-
else:
|
|
311
|
-
LOGGER.log(
|
|
312
|
-
logging.DEBUG if task.name.startswith("_") else logging.INFO,
|
|
313
|
-
"executing %s ...",
|
|
314
|
-
task,
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
# Update DB for start
|
|
318
|
-
if not dry_run:
|
|
319
|
-
params = {"name": task.name, "last_started": start}
|
|
320
|
-
self.connection.execute(QUERIES["upsert_task_started"], params)
|
|
321
|
-
self.connection.commit()
|
|
322
307
|
|
|
323
308
|
# Execute the task
|
|
324
309
|
if not dry_run:
|
|
325
310
|
async with semaphore:
|
|
311
|
+
start = datetime.now()
|
|
312
|
+
LOGGER.log(
|
|
313
|
+
logging.DEBUG if task.name.startswith("_") else logging.INFO,
|
|
314
|
+
"executing %s ...",
|
|
315
|
+
task,
|
|
316
|
+
)
|
|
317
|
+
params = {"name": task.name, "last_started": start}
|
|
318
|
+
self.connection.execute(QUERIES["upsert_task_started"], params)
|
|
319
|
+
self.connection.commit()
|
|
326
320
|
await task.execute()
|
|
327
321
|
|
|
328
322
|
# Check that all targets were created
|
|
@@ -335,6 +329,7 @@ class Controller:
|
|
|
335
329
|
|
|
336
330
|
# Update DB for completion
|
|
337
331
|
if not dry_run:
|
|
332
|
+
assert digest is not None
|
|
338
333
|
params = {
|
|
339
334
|
"name": task.name,
|
|
340
335
|
"digest": digest,
|
|
@@ -344,6 +339,7 @@ class Controller:
|
|
|
344
339
|
self.connection.commit()
|
|
345
340
|
|
|
346
341
|
# Log completion
|
|
342
|
+
assert start is not None
|
|
347
343
|
delta = util.format_timedelta(datetime.now() - start)
|
|
348
344
|
LOGGER.log(
|
|
349
345
|
logging.DEBUG if task.name.startswith("_") else logging.INFO,
|
|
@@ -362,7 +358,7 @@ class Controller:
|
|
|
362
358
|
self.connection.execute(QUERIES["upsert_task_failed"], params)
|
|
363
359
|
self.connection.commit()
|
|
364
360
|
|
|
365
|
-
delta = util.format_timedelta(datetime.now() - start)
|
|
361
|
+
delta = util.format_timedelta(datetime.now() - start) if start else "?"
|
|
366
362
|
LOGGER.exception("failed to execute %s after %s", task, delta)
|
|
367
363
|
raise util.FailedTaskError(ex, task=task) from ex
|
|
368
364
|
|
|
@@ -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
|
|
@@ -150,7 +150,7 @@ def create_task(
|
|
|
150
150
|
|
|
151
151
|
Args:
|
|
152
152
|
name: Name of the new task. Defaults to the string representation of the first
|
|
153
|
-
|
|
153
|
+
target if not provided.
|
|
154
154
|
action: Action to execute or a string for shell commands.
|
|
155
155
|
targets: Paths for files to be generated.
|
|
156
156
|
dependencies: Paths to files on which this task depends.
|
|
@@ -158,6 +158,18 @@ async def test_module_action_debug() -> None:
|
|
|
158
158
|
create_subprocess.assert_called_with(sys.executable, "-m", "pytest")
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
@pytest.mark.asyncio
|
|
162
|
+
async def test_sync_function_action_with_kwargs() -> None:
|
|
163
|
+
calls = []
|
|
164
|
+
|
|
165
|
+
def func(task, *, key):
|
|
166
|
+
calls.append((task, key))
|
|
167
|
+
|
|
168
|
+
action = FunctionAction(func, key="value")
|
|
169
|
+
await action.execute("my_task") # type: ignore[arg-type]
|
|
170
|
+
assert calls == [("my_task", "value")]
|
|
171
|
+
|
|
172
|
+
|
|
161
173
|
def test_composite_digest() -> None:
|
|
162
174
|
actions = [
|
|
163
175
|
SubprocessAction("hello"),
|
|
@@ -84,19 +84,40 @@ async def test_create_target_directories_with_multiple_targets(
|
|
|
84
84
|
assert filename.parent.is_dir()
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_create_target_directories_different_parents(
|
|
89
|
+
m: Manager, tmp_wd: Path, conn: sqlite3.Connection
|
|
90
|
+
) -> None:
|
|
91
|
+
# Targets in two different directories: the closure in create_target_directories
|
|
92
|
+
# must create both parents, not just the last one from the loop.
|
|
93
|
+
targets = [tmp_wd / "dir_a" / "file.txt", tmp_wd / "dir_b" / "file.txt"]
|
|
94
|
+
with normalize_action(), create_target_directories():
|
|
95
|
+
task = m.create_task(
|
|
96
|
+
"multi_dir",
|
|
97
|
+
targets=targets,
|
|
98
|
+
action=FunctionAction(
|
|
99
|
+
lambda t: [target.write_text("ok") for target in t.targets]
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
controller = Controller(m.resolve_dependencies(), conn)
|
|
103
|
+
await controller.execute(task)
|
|
104
|
+
for target in targets:
|
|
105
|
+
assert target.is_file(), f"{target} was not created"
|
|
106
|
+
|
|
107
|
+
|
|
87
108
|
def test_normalize_action(m: Manager) -> None:
|
|
88
109
|
with normalize_action():
|
|
89
110
|
task = m.create_task("foo", action="bar")
|
|
90
111
|
assert (
|
|
91
112
|
isinstance(task.action, SubprocessAction)
|
|
92
|
-
and task.action.args
|
|
113
|
+
and task.action.args == "bar"
|
|
93
114
|
and task.action.kwargs["shell"]
|
|
94
115
|
)
|
|
95
116
|
|
|
96
117
|
task = m.create_task("bar", action=["baz"])
|
|
97
118
|
assert (
|
|
98
119
|
isinstance(task.action, SubprocessAction)
|
|
99
|
-
and task.action.args
|
|
120
|
+
and task.action.args == ["baz"]
|
|
100
121
|
and not task.action.kwargs.get("shell")
|
|
101
122
|
)
|
|
102
123
|
|
|
@@ -110,7 +131,7 @@ def test_normalize_action(m: Manager) -> None:
|
|
|
110
131
|
assert isinstance(task.action, FunctionAction)
|
|
111
132
|
|
|
112
133
|
task = m.create_task("fizz", action=[pytest, "foo", "bar"])
|
|
113
|
-
assert isinstance(task.action, ModuleAction) and task.action.args
|
|
134
|
+
assert isinstance(task.action, ModuleAction) and task.action.args == [
|
|
114
135
|
sys.executable,
|
|
115
136
|
"-m",
|
|
116
137
|
"pytest",
|
|
@@ -420,6 +420,36 @@ async def test_last_started_completed(m: Manager, conn: Connection) -> None:
|
|
|
420
420
|
assert delta.total_seconds() > 1
|
|
421
421
|
|
|
422
422
|
|
|
423
|
+
@pytest.mark.asyncio
|
|
424
|
+
async def test_last_started_reflects_actual_start(m: Manager, conn: Connection) -> None:
|
|
425
|
+
delay = 0.5
|
|
426
|
+
task1 = m.create_task(
|
|
427
|
+
"task1",
|
|
428
|
+
action=SubprocessAction(f"sleep {delay} && touch task1.txt", shell=True),
|
|
429
|
+
targets=["task1.txt"],
|
|
430
|
+
)
|
|
431
|
+
task2 = m.create_task(
|
|
432
|
+
"task2",
|
|
433
|
+
action=SubprocessAction("touch task2.txt", shell=True),
|
|
434
|
+
targets=["task2.txt"],
|
|
435
|
+
)
|
|
436
|
+
result = m.create_task("result", dependencies=[task1.targets[0], task2.targets[0]])
|
|
437
|
+
|
|
438
|
+
# With num_concurrent=1, task2 must wait for task1 to finish. Its last_started
|
|
439
|
+
# should reflect when it actually began executing, not when it was scheduled.
|
|
440
|
+
await Controller(m.resolve_dependencies(), conn).execute(result, num_concurrent=1)
|
|
441
|
+
rows = dict(
|
|
442
|
+
conn.execute(
|
|
443
|
+
"SELECT name, last_started FROM tasks WHERE name IN ('task1', 'task2')"
|
|
444
|
+
).fetchall()
|
|
445
|
+
)
|
|
446
|
+
delta = (rows["task2"] - rows["task1"]).total_seconds()
|
|
447
|
+
assert delta >= delay, (
|
|
448
|
+
f"task2 started only {delta:.3f}s after task1, expected >= {delay}s; "
|
|
449
|
+
"last_started is recorded at scheduling time, not actual execution time"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
423
453
|
@pytest.mark.asyncio
|
|
424
454
|
async def test_sync_action_backwards_compat(
|
|
425
455
|
m: Manager, conn: Connection, caplog: pytest.LogCaptureFixture
|
|
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
|