Coverage for /home/runner/work/coxbuild/coxbuild/src/coxbuild/tasks.py: 73%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import asyncio
2import inspect
3import logging
4import sys
5import traceback
6from dataclasses import dataclass, field
7from datetime import datetime, timedelta
8from typing import TYPE_CHECKING, Any, Awaitable, Callable
10from coxbuild.configurations import Configuration
11from coxbuild.hooks import Hook
13from .exceptions import CoxbuildRuntimeException
14from .runners import Runner
16if TYPE_CHECKING:
17 from .extensions import Extension
19logger = logging.getLogger("tasks")
22class TaskRuntimeException(CoxbuildRuntimeException):
23 """
24 Exception to indicate task is running.
25 """
27 def __init__(self, task: "Task", error: str = "", cause: Exception | None = None):
28 super().__init__(
29 f"Failed to run task {task.name} due to {error}.", cause=cause)
32class TaskPostConditionFailed(TaskRuntimeException):
33 """
34 Exception to indicate postcondition of a task is failed.
35 """
37 def __init__(self, task: "Task"):
38 super().__init__(task, error="postcondition failed")
41@dataclass
42class TaskContext:
43 """Execution context for task."""
44 task: "Task"
45 """running task"""
46 args: list[Any] = field(default_factory=list)
47 """arguments"""
48 kwds: dict[str, Any] = field(default_factory=dict)
49 """keyword arguments"""
50 config: Configuration | None = None
51 """configuration"""
54@dataclass
55class TaskResult:
56 """Execution result for task."""
57 task: "Task"
58 """task"""
59 duration: timedelta
60 """execution duration"""
61 exception: TaskRuntimeException | None
62 """exception when running"""
64 def __bool__(self):
65 return self.exception is None
67 @property
68 def description(self) -> str:
69 """Return result's description string."""
70 return "🟢 SUCCESS" if self else "🔴 FAILING"
72 def ensure(self) -> None:
73 """Ensure the result is success, otherwise re-raise exception."""
74 if not self:
75 raise self.exception
78@dataclass
79class TaskHook(Hook):
80 """Hooks for a task."""
81 pass
84@dataclass
85class TaskBeforeHook(TaskHook):
86 """Before task starting"""
87 hook: Callable[[TaskContext], Awaitable[bool] | bool | None] | None = None
90@dataclass
91class TaskSetupHook(TaskHook):
92 """Setup before task running"""
93 hook: Callable[..., Awaitable | None] | None = None
96@dataclass
97class TaskPreconditionHook(TaskHook):
98 """Condition to decide whether to run the task"""
99 hook: Callable[..., Awaitable[bool] | bool] | None = None
102@dataclass
103class TaskPostconditionHook(TaskHook):
104 """Condition to decide whether to run the task"""
105 hook: Callable[..., Awaitable[bool] | bool] | None = None
108@dataclass
109class TaskTeardownHook(TaskHook):
110 """Teardown after task running"""
111 hook: Callable[..., Awaitable | None] | None = None
114@dataclass
115class TaskAfterHook(TaskHook):
116 """After task finished"""
117 hook: Callable[[TaskContext, TaskResult], Awaitable | None] | None = None
120def asprecond(predicate: Callable[..., bool] | TaskPreconditionHook):
121 """
122 Decorator to define precondition of a task.
123 Decide whether to run a task.
125 predicate: condition tester
126 """
127 return predicate if isinstance(predicate, TaskPreconditionHook) else TaskPreconditionHook(predicate)
130def aspostcond(predicate: Callable[..., bool] | TaskPostconditionHook):
131 """
132 Decorator to define postcondition of the task.
133 Check the task works well.
135 predicate: condition tester
136 """
137 return predicate if isinstance(predicate, TaskPostconditionHook) else TaskPostconditionHook(predicate)
140def asbefore(hook: Callable[[TaskContext], Awaitable[bool] | bool | None] | TaskBeforeHook):
141 """
142 Decorator to define before hook of a task.
143 Run before task body.
145 hook: hook function
146 """
147 return hook if isinstance(hook, TaskBeforeHook) else TaskBeforeHook(hook)
150def asafter(hook: Callable[[TaskContext, TaskResult], Awaitable | None] | TaskAfterHook):
151 """
152 Decorator to define after hook of a task.
153 Run after task body.
155 hook: hook function
156 """
157 return hook if isinstance(hook, TaskAfterHook) else TaskAfterHook(hook)
160def assetup(hook: Callable[..., Awaitable | None] | TaskSetupHook):
161 """
162 Decorator to define setup hook of a task.
163 Run before task body.
165 hook: hook function
166 """
167 return hook if isinstance(hook, TaskSetupHook) else TaskSetupHook(hook)
170def asteardown(hook: Callable[..., Awaitable | None] | TaskTeardownHook):
171 """
172 Decorator to define teardown hook of a task.
173 Run after task body.
175 hook: hook function
176 """
177 return hook if isinstance(hook, TaskTeardownHook) else TaskTeardownHook(hook)
180@dataclass
181class Task:
182 """Task."""
183 name: str = "default"
184 """task name"""
185 body: Callable[..., Awaitable | None] | None = None
186 """task body"""
187 description: str = ""
188 """task document"""
189 deps: list["Task"] = field(default_factory=list)
190 """dependency task names"""
191 hooks: list[TaskHook] = field(default_factory=list)
192 """task hooks"""
193 continueOnError: bool = False
194 """continue execution on error"""
195 extension: "Extension | None" = None
197 def copy(self) -> "Task":
198 """Clone task."""
199 return Task(self.name, self.body, self.description, self.deps.copy(), self.hooks.copy(), self.continueOnError, self.extension)
201 def __call__(self, *args: Any, **kwds: Any) -> "TaskRunner":
202 """
203 Build runner of task.
205 args: task arguments
206 kwds: task keyword arguments
207 setup: setup hooks
208 teardown: teardown hooks
209 """
210 return TaskRunner(self, list(args), kwds)
212 def __hash__(self) -> int:
213 return id(self)
215 def depend(self, task: "Task") -> "Task":
216 """Add dependency task."""
217 self.deps.append(task)
218 return task
220 def hook(self, hook: TaskHook) -> "TaskHook":
221 """Add hook."""
222 self.hooks.append(hook)
223 return hook
225 def precond(self, body: Callable[..., Awaitable[bool] | bool] | TaskPreconditionHook) -> TaskPreconditionHook:
226 """Add precondition hook."""
227 return self.hook(asprecond(body))
229 def postcond(self, body: Callable[..., Awaitable[bool] | bool] | TaskPostconditionHook) -> TaskPostconditionHook:
230 """Add postcondition hook."""
231 return self.hook(aspostcond(body))
233 def before(self, body: Callable[[TaskContext], Awaitable[bool] | bool | None] | TaskBeforeHook) -> TaskBeforeHook:
234 """Add before hook."""
235 return self.hook(asbefore(body))
237 def after(self, body: Callable[[TaskContext, TaskResult], Awaitable | None] | TaskAfterHook) -> TaskAfterHook:
238 """Add after hook."""
239 return self.hook(asafter(body))
241 def setup(self, body: Callable[..., Awaitable | None] | TaskSetupHook) -> TaskSetupHook:
242 """Add setup hook."""
243 return self.hook(assetup(body))
245 def teardown(self, body: Callable[..., Awaitable | None] | TaskTeardownHook) -> TaskTeardownHook:
246 """Add teardown hook."""
247 return self.hook(asteardown(body))
250class TaskRunner(Runner):
251 """Runner for task."""
253 def __init__(self, task: Task, args: list[Any], kwds: dict[str, Any]) -> None:
254 self.context = TaskContext(task, args, kwds)
255 self.setup = [setup for setup in task.hooks if isinstance(
256 setup, TaskSetupHook)]
257 self.teardown = [teardown for teardown in task.hooks if isinstance(
258 teardown, TaskTeardownHook)]
259 self.before = [before for before in task.hooks if isinstance(
260 before, TaskBeforeHook)]
261 self.after = [after for after in task.hooks if isinstance(
262 after, TaskAfterHook)]
263 self.precond = [precond for precond in task.hooks if isinstance(
264 precond, TaskPreconditionHook)]
265 self.postcond = [postcond for postcond in task.hooks if isinstance(
266 postcond, TaskPostconditionHook)]
268 super().__init__(self._run)
270 async def _before(self):
271 logger.debug(f"Run task before hook for {self.context.task.name}")
272 try:
273 for hook in self.before:
274 pre = hook.hook(self.context)
275 if inspect.isawaitable(pre):
276 pre: bool = await pre
277 if pre == False:
278 return False
279 except Exception as ex:
280 logger.error(
281 f"Task before hook failed for {self.context.task.name}", exc_info=ex)
282 print(
283 f"Task before hook failed for {self.context.task.name}: {ex}")
284 return False
286 async def _after(self):
287 logger.debug(f"Run task after hook for {self.context.task.name}")
288 try:
289 for hook in self.after:
290 res = hook.hook(self.context, self.result)
291 if inspect.isawaitable(res):
292 await res
293 except Exception as ex:
294 logger.error(
295 f"Task after hook failed for {self.context.task.name}", exc_info=ex)
296 print(f"Task after hook failed for {self.context.task.name}: {ex}")
298 async def _setup(self):
299 logger.debug(f"Task {self.context.task.name} setup hook.")
300 for hook in self.setup:
301 af = hook(*self.context.args, **self.context.kwds)
302 if inspect.isawaitable(af):
303 af = await af
305 async def _teardown(self):
306 logger.debug(f"Task {self.context.task.name} teardown hook.")
307 for hook in self.teardown:
308 af = hook(*self.context.args, **self.context.kwds)
309 if inspect.isawaitable(af):
310 af = await af
312 async def _precond(self):
313 logger.debug(f"Task {self.context.task.name} check precondition.")
314 for hook in self.precond:
315 pre = hook.hook(*self.context.args, **self.context.kwds)
316 if inspect.isawaitable(pre):
317 pre: bool = await pre
319 if not pre:
320 return pre
321 return True
323 async def _postcond(self):
324 logger.debug(f"Task {self.context.task.name} check postcondition.")
325 for hook in self.postcond:
326 post = hook.hook(*self.context.args, **self.context.kwds)
327 if inspect.isawaitable(post):
328 post: bool = await post
330 if not post:
331 return post
332 return True
334 async def _run(self):
335 pre = await self._precond()
336 if not pre:
337 message = f"Task {self.context.task.name} ignored: precondition filtered"
338 logger.info(message)
339 print(message)
340 return
342 await self._setup()
344 try:
345 if self.context.task.body is not None:
346 logger.debug(f"Task {self.context.task.name} body execute.")
347 af = self.context.task.body(
348 *self.context.args, **self.context.kwds)
349 if inspect.isawaitable(af):
350 af = await af
351 finally:
352 await self._teardown()
354 post = await self._postcond()
355 if not post:
356 raise TaskPostConditionFailed(self.context.task)
358 async def __aenter__(self) -> Callable[[], Awaitable | None]:
359 logger.debug(f"Start task {self.context.task.name}.")
360 print(f"{'-'*3} 🔻 {self.context.task.name} 🕰️ {datetime.now()} {'-'*3}")
362 self.result = None
364 res = await super().__aenter__()
366 pre = await self._before()
368 if pre == False:
369 message = f"Stop task {self.context.task.name} running by task before hook"
370 logger.info(message)
371 print(message)
372 res = None
374 return res or (lambda: None)
376 async def __aexit__(self, exc_type, exc_value, exc_tb) -> bool:
377 await super().__aexit__(exc_type, exc_value, exc_tb)
379 exception = None if self.exc_value is None else TaskRuntimeException(
380 self.context.task, cause=self.exc_value)
381 self.result = TaskResult(
382 self.context.task, duration=self.duration, exception=exception)
384 if self.exc_value is not None:
385 logger.error("Task execute exception.", exc_info=self.exc_value)
386 traceback.print_exception(
387 self.exc_type, self.exc_value, self.exc_tb, file=sys.stdout)
389 await self._after()
391 logger.debug(f"Finish task {self.context.task.name}: {self.result}.")
393 print(
394 f"{'-'*3} 🔺 {self.context.task.name} {self.result.description} ⏱️ {self.result.duration} {'-'*3}")
396 return True
398 def __await__(self):
399 yield from super().__await__()
400 return self.result
403TaskFuncDecorator = Callable[[Callable[..., None]], Task]
406def task(body: Callable[..., None]) -> Task:
407 """
408 Decorator to define a task.
409 """
410 tk = Task(body.__name__, body, body.__doc__ or "")
411 return tk
414def named(name: str):
415 """
416 Decorator to set task name.
417 """
418 def decorator(inner: Task) -> Task:
419 inner.name = name
420 return inner
421 return decorator
424def continueOnError(inner: Task) -> Task:
425 """
426 Decorator to continue task execution on error.
427 """
428 inner.continueOnError = True
429 return inner
432def group(*names: str):
433 """
434 Decorator to set task group.
435 """
436 def decorator(inner: Task) -> Task:
437 inner.name = f"{':'.join(names)}:{inner.name}"
438 return inner
439 return decorator
442def depend(*tasks: Task):
443 """
444 Decorator to define dependencies of a task.
446 names: task names or task instances
447 """
448 def decorator(inner: Task) -> Task:
449 for task in tasks:
450 inner.depend(task)
451 return inner
452 return decorator
455def precond(hook: Callable[..., Awaitable[bool] | bool] | TaskPreconditionHook):
456 """
457 Decorator to configure precondition hook of the task.
458 """
459 def decorator(inner: Task) -> Task:
460 inner.precond(hook)
461 return inner
462 return decorator
465def postcond(hook: Callable[..., Awaitable[bool] | bool] | TaskPostconditionHook):
466 """
467 Decorator to configure postcondition hook of the task.
468 """
469 def decorator(inner: Task) -> Task:
470 inner.postcond(hook)
471 return inner
472 return decorator
475def before(hook: Callable[[TaskContext], Awaitable[bool] | bool] | TaskBeforeHook):
476 """
477 Decorator to configure before hook of the task.
478 """
479 def decorator(inner: Task) -> Task:
480 inner.before(hook)
481 return inner
482 return decorator
485def after(hook: Callable[[TaskContext, TaskResult], Awaitable | None] | TaskAfterHook):
486 """
487 Decorator to configure after hook of the task.
488 """
489 def decorator(inner: Task) -> Task:
490 inner.after(hook)
491 return inner
492 return decorator
495def setup(hook: Callable[..., Awaitable | None] | TaskSetupHook):
496 """
497 Decorator to configure setup hook of the task.
498 """
499 def decorator(inner: Task) -> Task:
500 inner.setup(hook)
501 return inner
502 return decorator
505def teardown(hook: Callable[..., Awaitable | None] | TaskTeardownHook):
506 """
507 Decorator to configure teardown hook of the task.
508 """
509 def decorator(inner: Task) -> Task:
510 inner.teardown(hook)
511 return inner
512 return decorator
515def taskconfigs(*args: Callable[[Task], Task]) -> Callable[[Task], Task]:
516 """
517 Combine multiple task configs.
519 taskconfig(a, b, c) is equivalent to c(b(a(task)))
520 """
521 def wrapper(task: Task):
522 for config in args:
523 task = config(task)
524 return task
525 return wrapper