Coverage for /home/runner/work/coxbuild/coxbuild/src/coxbuild/pipelines.py: 78%
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 inspect
2import logging
3import sys
4import traceback
5from dataclasses import asdict, dataclass, field
6from datetime import datetime, timedelta
7from enum import Enum
8from graphlib import TopologicalSorter
9from queue import Queue
10from typing import TYPE_CHECKING, Any, Awaitable, Callable
12from coxbuild.configurations import Configuration
13from coxbuild.hooks import Hook
14from coxbuild.runtime import ExecutionState
16from .exceptions import CoxbuildRuntimeException, CoxbuildSchemaException
17from .runners import Runner
18from .tasks import Task, TaskContext, TaskHook, TaskResult
20if TYPE_CHECKING:
21 from .extensions import Extension
23logger = logging.getLogger("pipelines")
26class PipelineRuntimeException(CoxbuildRuntimeException):
27 """Exception for pipeline running."""
28 pass
31@dataclass
32class PipelineContext:
33 """Execution context for pipeline."""
34 pipeline: "Pipeline"
35 """pipeline"""
36 tasks: list[TaskResult]
37 """tasks in the pipeline"""
38 unmatchedNames: list[str] = field(default_factory=list)
39 """not found task names"""
40 config: Configuration | None = None
41 """configuration"""
44@dataclass
45class PipelineResult:
46 """Execution result for pipeline."""
47 duration: timedelta
48 """execution duration"""
49 tasks: list[TaskResult]
50 """tasks in the pipeline"""
51 exception: PipelineRuntimeException | None = None
52 """exception when running"""
54 def __bool__(self):
55 return self.exception is None and all((t for t in self.tasks if not t.task.continueOnError))
57 @property
58 def description(self) -> str:
59 """Return result's description string."""
60 return "🟢 SUCCESS" if self else "🔴 FAILING"
63@dataclass
64class PipelineHook(Hook):
65 """Hooks for a pipeline."""
66 extension: "Extension | None" = None
69@dataclass
70class PipelineBeforeTaskHook(PipelineHook):
71 """Before task starting"""
72 hook: Callable[[TaskContext], Awaitable[bool] | bool | None] | None = None
75@dataclass
76class PipelineBeforeHook(PipelineHook):
77 """Before pipeline starting"""
78 hook: Callable[[PipelineContext],
79 Awaitable[bool] | bool | None] | None = None
82@dataclass
83class PipelineAfterHook(PipelineHook):
84 """After pipeline finished"""
85 hook: Callable[[PipelineContext, PipelineResult],
86 Awaitable | None] | None = None
89@dataclass
90class PipelineAfterTaskHook(PipelineHook):
91 """After task finished"""
92 hook: Callable[[TaskContext, TaskResult], Awaitable | None] | None = None
95def beforePipeline(hook: Callable[[PipelineContext], Awaitable[bool] | bool | None] | PipelineBeforeHook) -> PipelineBeforeHook:
96 """
97 Decorator to configure before hook of a pipeline.
98 Run before pipeline.
100 hook: hook function
101 """
102 return hook if isinstance(hook, PipelineBeforeHook) else PipelineBeforeHook(hook)
105def afterPipeline(hook: Callable[[PipelineContext, PipelineResult], Awaitable | None] | PipelineAfterHook) -> PipelineAfterHook:
106 """
107 Decorator to configure after hook of a pipeline.
108 Run after pipeline.
110 hook: hook function
111 """
112 return hook if isinstance(hook, PipelineAfterHook) else PipelineAfterHook(hook)
115def beforeTask(hook: Callable[[TaskContext], Awaitable[bool] | bool | None] | PipelineBeforeTaskHook) -> PipelineBeforeTaskHook:
116 """
117 Decorator to configure before task hook of a task.
118 Run before task.
120 hook: hook function
121 """
122 return hook if isinstance(hook, PipelineBeforeTaskHook) else PipelineBeforeTaskHook(hook)
125def afterTask(hook: Callable[[TaskContext, TaskResult], Awaitable | None] | PipelineAfterTaskHook) -> PipelineAfterTaskHook:
126 """
127 Decorator to configure after task hook of a task.
128 Run after task.
130 hook: hook function
131 """
132 return hook if isinstance(hook, PipelineAfterTaskHook) else PipelineAfterTaskHook(hook)
135class PipelineRunner(Runner):
136 """Runner for pipeline."""
138 def __init__(self, pipeline: "Pipeline", tasks: list[Task], hooks: list[PipelineHook], unmatchedNames: list[str]) -> None:
139 """
140 Create runner.
142 pipeline: pipeline
143 tasks: tasks in the pipeline
144 hooks: hooks for pipeline
145 unmatchedNames: not found task names
146 """
147 self.pipeline = pipeline
148 self.tasks = tasks
149 self.beforeTask = [beforeTask for beforeTask in hooks if isinstance(
150 beforeTask, PipelineBeforeTaskHook)]
151 self.afterTask = [afterTask for afterTask in hooks if isinstance(
152 afterTask, PipelineAfterTaskHook)]
153 self.before = [before for before in hooks if isinstance(
154 before, PipelineBeforeHook)]
155 self.after = [after for after in hooks if isinstance(
156 after, PipelineAfterHook)]
157 self.context = PipelineContext(
158 self.pipeline, self.tasks, unmatchedNames)
159 self.result = None
161 super().__init__(self._run)
163 async def _before(self):
164 logger.debug(f"Run pipeline before hook")
165 try:
166 for hook in self.before:
167 pre = hook.hook(self.context)
168 if inspect.isawaitable(pre):
169 pre: bool = await pre
171 if pre == False:
172 return False
173 except Exception as ex:
174 logger.error(f"Run pipeline before hook failed.", exc_info=ex)
175 print(f"Run pipeline before hook failed: {ex}")
176 return False
178 async def _beforeTask(self, context: TaskContext):
179 logger.debug(f"Run pipeline before task hook for {context.task.name}")
180 for hook in self.beforeTask:
181 pre = hook.hook(context)
182 if inspect.isawaitable(pre):
183 pre: bool = await pre
184 if pre == False:
185 return False
187 async def _afterTask(self, context: TaskContext, result: TaskResult):
188 logger.debug(f"Run pipeline after task hook for {context.task.name}")
189 for hook in self.afterTask:
190 res = hook.hook(context, result)
191 if inspect.isawaitable(res):
192 await res
194 async def _after(self):
195 logger.debug(f"Pipeline after hook.")
196 try:
197 for hook in self.after:
198 res = hook.hook(self.context, self.result)
199 if inspect.isawaitable(res):
200 await res
201 except Exception as ex:
202 logger.error(f"Run pipeline after hook failed.", exc_info=ex)
203 print(f"Run pipeline after hook failed: {ex}")
205 async def _run(self):
206 n = len(self.tasks)
208 for i, task in enumerate(self.tasks):
209 logger.debug(f"Run task {i+1}({task.name}) of {n} tasks")
210 print(f"{'-'*15} ({i+1}/{n}) 📜 Task {task.name} {'-'*15}")
211 print("")
213 self._executionState.task = task
215 runner = task()
216 runner.context.config = self.context.config
218 pre = await self._beforeTask(runner.context)
219 if pre == False:
220 message = f"Stop task {task.name} running by pipeline before hook"
221 logger.info(message)
222 print(message)
223 continue
225 res = await runner
227 self._results.append(res)
229 await self._afterTask(runner.context, res)
231 self._executionState.task = None
233 if not res:
234 if task.continueOnError:
235 message = f"Task {task.name} failed, but continue on error."
236 logger.error(message)
237 print(message)
238 print("")
239 else:
240 print("")
241 break
243 async def __aenter__(self) -> Callable[[], Awaitable | None]:
244 logger.debug(f"Running pipeline: {self.tasks}")
245 print(f"{'-'*20} ⌛ Running 🕰️ {datetime.now()} {'-'*20}")
247 self.result = None
248 self._results: list[TaskResult] = []
250 self.context.config = self.context.config or Configuration()
251 self._executionState = ExecutionState(self.context.config)
252 self._executionState.matchedTasks = self.tasks
253 self._executionState.unmatchedTasks = self.context.unmatchedNames
255 res = await super().__aenter__()
257 pre = await self._before()
258 if pre == False:
259 message = "Stop pipeline running by pipeline setup hook"
260 logger.info(message)
261 print(message)
262 res = None
264 print("")
266 return res or (lambda: None)
268 async def __aexit__(self, exc_type, exc_value, exc_tb) -> bool:
269 await super().__aexit__(exc_type, exc_value, exc_tb)
271 exception = None if self.exc_value is None else PipelineRuntimeException(
272 f"Failed to run pipeline", cause=self.exc_value)
273 self.result = PipelineResult(
274 duration=self.duration, tasks=self._results, exception=exception)
276 if self.exc_value is not None:
277 logger.error("Task execute exception.", exc_info=self.exc_value)
278 traceback.print_exception(
279 self.exc_type, self.exc_value, self.exc_tb, file=sys.stdout)
281 await self._after()
283 logger.info(f"Finish pipeline: {self.result}")
285 print(
286 f"{'-'*20} 📋 Done {self.result.description} (⏱️ {self.result.duration}) {'-'*20}")
288 cnt = len(self.result.tasks)
290 for i, tr in enumerate(self.result.tasks):
291 print(
292 f"({i+1}/{cnt})\t{tr.description}\t⏱️ {tr.duration}\t📜 {tr.task.name}")
294 del self._results
295 del self._executionState
297 return True
299 def __await__(self):
300 yield from super().__await__()
301 return self.result
304@dataclass
305class Pipeline:
306 """Pipeline to run tasks."""
308 tasks: dict[str, Task] = field(default_factory=dict)
309 """tasks in the pipeline"""
310 hooks: list[PipelineHook] = field(default_factory=list)
311 """hook for pipeline"""
313 def copy(self) -> "Pipeline":
314 """Copy pipeline."""
315 return Pipeline(tasks=self.tasks.copy(), hooks=self.hooks.copy())
317 def register(self, task: Task) -> None:
318 """
319 Register a task into pipeline.
321 task: task to register
322 """
323 if task.name in self.tasks:
324 logger.warning(
325 f"Replace registered task: {self.tasks[task.name]}.")
326 self.tasks[task.name] = task
327 logger.debug(f"Register task {task}")
329 def hook(self, hook: PipelineHook) -> None:
330 """
331 Hook pipeline.
333 thook: the hook
334 """
336 self.hooks.append(hook)
338 def beforeTask(self, body: Callable[[TaskContext], Awaitable[bool] | bool | None] | PipelineBeforeTaskHook) -> PipelineBeforeTaskHook:
339 """Add before task hook."""
340 hook = beforeTask(body)
341 self.hook(hook)
342 return hook
344 def afterTask(self, body: Callable[[TaskContext, TaskResult], Awaitable[bool] | bool | None] | PipelineAfterTaskHook) -> PipelineAfterTaskHook:
345 """Add after task hook."""
346 hook = afterTask(body)
347 self.hook(hook)
348 return hook
350 def before(self, body: Callable[[PipelineContext], Awaitable[bool] | bool | None] | PipelineBeforeHook) -> PipelineBeforeHook:
351 """Add before hook."""
352 hook = beforePipeline(body)
353 self.hook(hook)
354 return hook
356 def after(self, body: Callable[[PipelineContext, PipelineResult], Awaitable | None] | PipelineAfterHook) -> PipelineAfterHook:
357 """Add after hook."""
358 hook = afterPipeline(body)
359 self.hook(hook)
360 return hook
362 def __call__(self, *args: str | Task, **kwds: Any):
363 """
364 Build runner of pipeline.
366 args: list of tasks or task names
367 """
369 logger.debug(f"Build pipeline by {args}.")
371 unmatchedNames = []
373 tks: set[Task] = set()
374 q: Queue[Task] = Queue()
375 for task in args:
376 if isinstance(task, str):
377 if task in self.tasks:
378 task = self.tasks[task]
379 else:
380 unmatchedNames.append(task)
381 continue
382 if task not in tks:
383 tks.add(task)
384 q.put(task)
386 if len(unmatchedNames) > 0:
387 message = f"Not found task names: {unmatchedNames}"
388 logger.warning(message)
389 print(message)
391 while not q.empty():
392 t = q.get()
393 for d in t.deps:
394 if d not in tks:
395 tks.add(d)
396 q.put(d)
398 logger.debug(f"Build pipeline for tasks: {tks}.")
400 graph: dict[Task, set[Task]] = {}
402 for key in tks:
403 graph[key] = {d for d in key.deps}
405 tasks = list((task
406 for task in TopologicalSorter(graph).static_order()))
408 logger.debug(f"Tasks to run: {', '.join((t.name for t in tasks))}")
410 return PipelineRunner(self, tasks, self.hooks, unmatchedNames)