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

285 statements  

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 

9 

10from coxbuild.configurations import Configuration 

11from coxbuild.hooks import Hook 

12 

13from .exceptions import CoxbuildRuntimeException 

14from .runners import Runner 

15 

16if TYPE_CHECKING: 

17 from .extensions import Extension 

18 

19logger = logging.getLogger("tasks") 

20 

21 

22class TaskRuntimeException(CoxbuildRuntimeException): 

23 """ 

24 Exception to indicate task is running. 

25 """ 

26 

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) 

30 

31 

32class TaskPostConditionFailed(TaskRuntimeException): 

33 """ 

34 Exception to indicate postcondition of a task is failed. 

35 """ 

36 

37 def __init__(self, task: "Task"): 

38 super().__init__(task, error="postcondition failed") 

39 

40 

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""" 

52 

53 

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""" 

63 

64 def __bool__(self): 

65 return self.exception is None 

66 

67 @property 

68 def description(self) -> str: 

69 """Return result's description string.""" 

70 return "🟢 SUCCESS" if self else "🔴 FAILING" 

71 

72 def ensure(self) -> None: 

73 """Ensure the result is success, otherwise re-raise exception.""" 

74 if not self: 

75 raise self.exception 

76 

77 

78@dataclass 

79class TaskHook(Hook): 

80 """Hooks for a task.""" 

81 pass 

82 

83 

84@dataclass 

85class TaskBeforeHook(TaskHook): 

86 """Before task starting""" 

87 hook: Callable[[TaskContext], Awaitable[bool] | bool | None] | None = None 

88 

89 

90@dataclass 

91class TaskSetupHook(TaskHook): 

92 """Setup before task running""" 

93 hook: Callable[..., Awaitable | None] | None = None 

94 

95 

96@dataclass 

97class TaskPreconditionHook(TaskHook): 

98 """Condition to decide whether to run the task""" 

99 hook: Callable[..., Awaitable[bool] | bool] | None = None 

100 

101 

102@dataclass 

103class TaskPostconditionHook(TaskHook): 

104 """Condition to decide whether to run the task""" 

105 hook: Callable[..., Awaitable[bool] | bool] | None = None 

106 

107 

108@dataclass 

109class TaskTeardownHook(TaskHook): 

110 """Teardown after task running""" 

111 hook: Callable[..., Awaitable | None] | None = None 

112 

113 

114@dataclass 

115class TaskAfterHook(TaskHook): 

116 """After task finished""" 

117 hook: Callable[[TaskContext, TaskResult], Awaitable | None] | None = None 

118 

119 

120def asprecond(predicate: Callable[..., bool] | TaskPreconditionHook): 

121 """ 

122 Decorator to define precondition of a task. 

123 Decide whether to run a task. 

124 

125 predicate: condition tester 

126 """ 

127 return predicate if isinstance(predicate, TaskPreconditionHook) else TaskPreconditionHook(predicate) 

128 

129 

130def aspostcond(predicate: Callable[..., bool] | TaskPostconditionHook): 

131 """ 

132 Decorator to define postcondition of the task. 

133 Check the task works well. 

134 

135 predicate: condition tester 

136 """ 

137 return predicate if isinstance(predicate, TaskPostconditionHook) else TaskPostconditionHook(predicate) 

138 

139 

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. 

144 

145 hook: hook function 

146 """ 

147 return hook if isinstance(hook, TaskBeforeHook) else TaskBeforeHook(hook) 

148 

149 

150def asafter(hook: Callable[[TaskContext, TaskResult], Awaitable | None] | TaskAfterHook): 

151 """ 

152 Decorator to define after hook of a task. 

153 Run after task body. 

154 

155 hook: hook function 

156 """ 

157 return hook if isinstance(hook, TaskAfterHook) else TaskAfterHook(hook) 

158 

159 

160def assetup(hook: Callable[..., Awaitable | None] | TaskSetupHook): 

161 """ 

162 Decorator to define setup hook of a task. 

163 Run before task body. 

164 

165 hook: hook function 

166 """ 

167 return hook if isinstance(hook, TaskSetupHook) else TaskSetupHook(hook) 

168 

169 

170def asteardown(hook: Callable[..., Awaitable | None] | TaskTeardownHook): 

171 """ 

172 Decorator to define teardown hook of a task. 

173 Run after task body. 

174 

175 hook: hook function 

176 """ 

177 return hook if isinstance(hook, TaskTeardownHook) else TaskTeardownHook(hook) 

178 

179 

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 

196 

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) 

200 

201 def __call__(self, *args: Any, **kwds: Any) -> "TaskRunner": 

202 """ 

203 Build runner of task. 

204 

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) 

211 

212 def __hash__(self) -> int: 

213 return id(self) 

214 

215 def depend(self, task: "Task") -> "Task": 

216 """Add dependency task.""" 

217 self.deps.append(task) 

218 return task 

219 

220 def hook(self, hook: TaskHook) -> "TaskHook": 

221 """Add hook.""" 

222 self.hooks.append(hook) 

223 return hook 

224 

225 def precond(self, body: Callable[..., Awaitable[bool] | bool] | TaskPreconditionHook) -> TaskPreconditionHook: 

226 """Add precondition hook.""" 

227 return self.hook(asprecond(body)) 

228 

229 def postcond(self, body: Callable[..., Awaitable[bool] | bool] | TaskPostconditionHook) -> TaskPostconditionHook: 

230 """Add postcondition hook.""" 

231 return self.hook(aspostcond(body)) 

232 

233 def before(self, body: Callable[[TaskContext], Awaitable[bool] | bool | None] | TaskBeforeHook) -> TaskBeforeHook: 

234 """Add before hook.""" 

235 return self.hook(asbefore(body)) 

236 

237 def after(self, body: Callable[[TaskContext, TaskResult], Awaitable | None] | TaskAfterHook) -> TaskAfterHook: 

238 """Add after hook.""" 

239 return self.hook(asafter(body)) 

240 

241 def setup(self, body: Callable[..., Awaitable | None] | TaskSetupHook) -> TaskSetupHook: 

242 """Add setup hook.""" 

243 return self.hook(assetup(body)) 

244 

245 def teardown(self, body: Callable[..., Awaitable | None] | TaskTeardownHook) -> TaskTeardownHook: 

246 """Add teardown hook.""" 

247 return self.hook(asteardown(body)) 

248 

249 

250class TaskRunner(Runner): 

251 """Runner for task.""" 

252 

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)] 

267 

268 super().__init__(self._run) 

269 

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 

285 

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}") 

297 

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 

304 

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 

311 

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 

318 

319 if not pre: 

320 return pre 

321 return True 

322 

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 

329 

330 if not post: 

331 return post 

332 return True 

333 

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 

341 

342 await self._setup() 

343 

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() 

353 

354 post = await self._postcond() 

355 if not post: 

356 raise TaskPostConditionFailed(self.context.task) 

357 

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}") 

361 

362 self.result = None 

363 

364 res = await super().__aenter__() 

365 

366 pre = await self._before() 

367 

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 

373 

374 return res or (lambda: None) 

375 

376 async def __aexit__(self, exc_type, exc_value, exc_tb) -> bool: 

377 await super().__aexit__(exc_type, exc_value, exc_tb) 

378 

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) 

383 

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) 

388 

389 await self._after() 

390 

391 logger.debug(f"Finish task {self.context.task.name}: {self.result}.") 

392 

393 print( 

394 f"{'-'*3} 🔺 {self.context.task.name} {self.result.description} ⏱️ {self.result.duration} {'-'*3}") 

395 

396 return True 

397 

398 def __await__(self): 

399 yield from super().__await__() 

400 return self.result 

401 

402 

403TaskFuncDecorator = Callable[[Callable[..., None]], Task] 

404 

405 

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 

412 

413 

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 

422 

423 

424def continueOnError(inner: Task) -> Task: 

425 """ 

426 Decorator to continue task execution on error. 

427 """ 

428 inner.continueOnError = True 

429 return inner 

430 

431 

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 

440 

441 

442def depend(*tasks: Task): 

443 """ 

444 Decorator to define dependencies of a task. 

445 

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 

453 

454 

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 

463 

464 

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 

473 

474 

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 

483 

484 

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 

493 

494 

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 

503 

504 

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 

513 

514 

515def taskconfigs(*args: Callable[[Task], Task]) -> Callable[[Task], Task]: 

516 """ 

517 Combine multiple task configs. 

518 

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