ddeutil-workflow 0.0.5__py3-none-any.whl → 0.0.6__py3-none-any.whl

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.
@@ -8,13 +8,9 @@ from __future__ import annotations
8
8
  import copy
9
9
  from collections.abc import Iterator
10
10
  from dataclasses import dataclass, field
11
- from datetime import datetime, timedelta, timezone
11
+ from datetime import datetime, timedelta
12
12
  from functools import partial, total_ordering
13
- from typing import (
14
- Callable,
15
- Optional,
16
- Union,
17
- )
13
+ from typing import Callable, Optional, Union
18
14
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
19
15
 
20
16
  from ddeutil.core import (
@@ -117,7 +113,7 @@ CRON_UNITS: tuple[Unit, ...] = (
117
113
  ),
118
114
  )
119
115
 
120
- CRON_UNITS_AWS: tuple[Unit, ...] = CRON_UNITS + (
116
+ CRON_UNITS_YEAR: tuple[Unit, ...] = CRON_UNITS + (
121
117
  Unit(
122
118
  name="year",
123
119
  range=partial(range, 1990, 2101),
@@ -152,6 +148,7 @@ class CronPart:
152
148
  values: list[int] = self.replace_weekday(values)
153
149
  else:
154
150
  raise TypeError(f"Invalid type of value in cron part: {values}.")
151
+
155
152
  self.values: list[int] = self.out_of_range(
156
153
  sorted(dict.fromkeys(values))
157
154
  )
@@ -190,10 +187,16 @@ class CronPart:
190
187
  )
191
188
 
192
189
  def __lt__(self, other) -> bool:
193
- return self.values < other.values
190
+ if isinstance(other, CronPart):
191
+ return self.values < other.values
192
+ elif isinstance(other, list):
193
+ return self.values < other
194
194
 
195
195
  def __eq__(self, other) -> bool:
196
- return self.values == other.values
196
+ if isinstance(other, CronPart):
197
+ return self.values == other.values
198
+ elif isinstance(other, list):
199
+ return self.values == other
197
200
 
198
201
  @property
199
202
  def min(self) -> int:
@@ -225,11 +228,11 @@ class CronPart:
225
228
  """Parses a string as a range of positive integers. The string should
226
229
  include only `-` and `,` special strings.
227
230
 
228
- :param value: A string value
231
+ :param value: A string value that want to parse
229
232
  :type value: str
230
233
 
231
234
  TODO: support for `L`, `W`, and `#`
232
- TODO: if you didn't care what day of the week the 7th was, you
235
+ TODO: if you didn't care what day of the week the 7th was, you
233
236
  could enter ? in the Day-of-week field.
234
237
  TODO: L : the Day-of-month or Day-of-week fields specifies the last day
235
238
  of the month or week.
@@ -239,7 +242,7 @@ class CronPart:
239
242
  TODO: # : 3#2 would be the second Tuesday of the month,
240
243
  the 3 refers to Tuesday because it is the third day of each week.
241
244
 
242
- Examples:
245
+ Noted:
243
246
  - 0 10 * * ? *
244
247
  Run at 10:00 am (UTC) every day
245
248
 
@@ -302,9 +305,14 @@ class CronPart:
302
305
  return value
303
306
 
304
307
  def replace_weekday(self, values: list[int] | Iterator[int]) -> list[int]:
305
- """Replaces all 7 with 0 as Sunday can be represented by both."""
308
+ """Replaces all 7 with 0 as Sunday can be represented by both.
309
+
310
+ :param values: list or iter of int that want to mode by 7
311
+ :rtype: list[int]
312
+ """
306
313
  if self.unit.name == "weekday":
307
- return [0 if value == 7 else value for value in values]
314
+ # NOTE: change weekday value in range 0-6 (div-mod by 7).
315
+ return [value % 7 for value in values]
308
316
  return list(values)
309
317
 
310
318
  def out_of_range(self, values: list[int]) -> list[int]:
@@ -531,27 +539,27 @@ class CronJob:
531
539
  return reversed(self.parts[:3] + [self.parts[4], self.parts[3]])
532
540
 
533
541
  @property
534
- def minute(self):
542
+ def minute(self) -> CronPart:
535
543
  """Return part of minute."""
536
544
  return self.parts[0]
537
545
 
538
546
  @property
539
- def hour(self):
547
+ def hour(self) -> CronPart:
540
548
  """Return part of hour."""
541
549
  return self.parts[1]
542
550
 
543
551
  @property
544
- def day(self):
552
+ def day(self) -> CronPart:
545
553
  """Return part of day."""
546
554
  return self.parts[2]
547
555
 
548
556
  @property
549
- def month(self):
557
+ def month(self) -> CronPart:
550
558
  """Return part of month."""
551
559
  return self.parts[3]
552
560
 
553
561
  @property
554
- def dow(self):
562
+ def dow(self) -> CronPart:
555
563
  """Return part of day of month."""
556
564
  return self.parts[4]
557
565
 
@@ -560,15 +568,29 @@ class CronJob:
560
568
  return [part.values for part in self.parts]
561
569
 
562
570
  def schedule(
563
- self, date: Optional[datetime] = None, _tz: Optional[str] = None
571
+ self,
572
+ date: datetime | None = None,
573
+ *,
574
+ tz: str | None = None,
564
575
  ) -> CronRunner:
565
- """Returns the time the schedule would run next."""
566
- return CronRunner(self, date, tz_str=_tz)
576
+ """Returns the schedule datetime runner with this cronjob. It would run
577
+ ``next``, ``prev``, or ``reset`` to generate running date that you want.
578
+
579
+ :param date: An initial date that want to mark as the start point.
580
+ :param tz: A string timezone that want to change on runner.
581
+ :rtype: CronRunner
582
+ """
583
+ return CronRunner(self, date, tz=tz)
567
584
 
568
585
 
569
- class CronJobAWS(CronJob):
586
+ class CronJobYear(CronJob):
570
587
  cron_length = 6
571
- cron_units = CRON_UNITS_AWS
588
+ cron_units = CRON_UNITS_YEAR
589
+
590
+ @property
591
+ def year(self) -> CronPart:
592
+ """Return part of year."""
593
+ return self.parts[5]
572
594
 
573
595
 
574
596
  class CronRunner:
@@ -586,33 +608,37 @@ class CronRunner:
586
608
 
587
609
  def __init__(
588
610
  self,
589
- cron: CronJob,
590
- date: Optional[datetime] = None,
611
+ cron: CronJob | CronJobYear,
612
+ date: datetime | None = None,
591
613
  *,
592
- tz_str: Optional[str] = None,
614
+ tz: str | None = None,
593
615
  ) -> None:
594
- # NOTE: Prepare date and tz_info
595
- self.tz = timezone.utc
596
- if tz_str:
616
+ # NOTE: Prepare timezone if this value does not set, it will use UTC.
617
+ self.tz: ZoneInfo = ZoneInfo("UTC")
618
+ if tz:
597
619
  try:
598
- self.tz = ZoneInfo(tz_str)
620
+ self.tz = ZoneInfo(tz)
599
621
  except ZoneInfoNotFoundError as err:
600
- raise ValueError(f"Invalid timezone: {tz_str}") from err
622
+ raise ValueError(f"Invalid timezone: {tz}") from err
623
+
624
+ # NOTE: Prepare date
601
625
  if date:
602
626
  if not isinstance(date, datetime):
603
627
  raise ValueError(
604
628
  "Input schedule start time is not a valid datetime object."
605
629
  )
606
- self.tz = date.tzinfo
607
- self.date: datetime = date
630
+ if tz is None:
631
+ self.tz = date.tzinfo
632
+ self.date: datetime = date.astimezone(self.tz)
608
633
  else:
609
634
  self.date: datetime = datetime.now(tz=self.tz)
610
635
 
636
+ # NOTE: Add one minute if the second value more than 0.
611
637
  if self.date.second > 0:
612
- self.date: datetime = self.date + timedelta(minutes=+1)
638
+ self.date: datetime = self.date + timedelta(minutes=1)
613
639
 
614
640
  self.__start_date: datetime = self.date
615
- self.cron: CronJob = cron
641
+ self.cron: CronJob | CronJobYear = cron
616
642
  self.reset_flag: bool = True
617
643
 
618
644
  def reset(self) -> None:
@@ -637,7 +663,11 @@ class CronRunner:
637
663
  return self.find_date(reverse=True)
638
664
 
639
665
  def find_date(self, reverse: bool = False) -> datetime:
640
- """Returns the time the schedule would run by `next` or `prev`."""
666
+ """Returns the time the schedule would run by `next` or `prev`.
667
+
668
+ :param reverse: A reverse flag.
669
+ """
670
+ # NOTE: Set reset flag to false if start any action.
641
671
  self.reset_flag: bool = False
642
672
  for _ in range(25):
643
673
  if all(
@@ -656,7 +686,7 @@ class CronRunner:
656
686
  "minute": "hour",
657
687
  }
658
688
  current_value: int = getattr(self.date, switch[mode])
659
- _addition: Callable[[], bool] = (
689
+ _addition_condition: Callable[[], bool] = (
660
690
  (
661
691
  lambda: WEEKDAYS.get(self.date.strftime("%a"))
662
692
  not in self.cron.dow.values
@@ -664,15 +694,12 @@ class CronRunner:
664
694
  if mode == "day"
665
695
  else lambda: False
666
696
  )
697
+ # NOTE: Start while-loop for checking this date include in this cronjob.
667
698
  while (
668
699
  getattr(self.date, mode) not in getattr(self.cron, mode).values
669
- ) or _addition():
670
- self.date: datetime = next_date(
671
- self.date, mode=mode, reverse=reverse
672
- )
673
- self.date: datetime = replace_date(
674
- self.date, mode=mode, reverse=reverse
675
- )
700
+ ) or _addition_condition():
701
+ self.date: datetime = next_date(self.date, mode, reverse=reverse)
702
+ self.date: datetime = replace_date(self.date, mode, reverse=reverse)
676
703
  if current_value != getattr(self.date, switch[mode]):
677
704
  return mode != "month"
678
705
  return False
@@ -680,6 +707,7 @@ class CronRunner:
680
707
 
681
708
  __all__ = (
682
709
  "CronJob",
710
+ "CronJobYear",
683
711
  "CronRunner",
684
712
  "WEEKDAYS",
685
713
  )
@@ -0,0 +1,402 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2022 Korawich Anuttra. All rights reserved.
3
+ # Licensed under the MIT License. See LICENSE in the project root for
4
+ # license information.
5
+ # ------------------------------------------------------------------------------
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import inspect
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import uuid
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import Iterator
17
+ from dataclasses import dataclass
18
+ from inspect import Parameter
19
+ from pathlib import Path
20
+ from subprocess import CompletedProcess
21
+ from typing import Callable, Optional, Union
22
+
23
+ from ddeutil.core import str2bool
24
+ from pydantic import BaseModel, Field
25
+
26
+ from .__types import DictData, DictStr, Re, TupleStr
27
+ from .exceptions import StageException
28
+ from .utils import (
29
+ Registry,
30
+ Result,
31
+ TagFunc,
32
+ gen_id,
33
+ make_exec,
34
+ make_registry,
35
+ param2template,
36
+ )
37
+
38
+
39
+ class BaseStage(BaseModel, ABC):
40
+ """Base Stage Model that keep only id and name fields for the stage
41
+ metadata. If you want to implement any custom stage, you can use this class
42
+ to parent and implement ``self.execute()`` method only.
43
+ """
44
+
45
+ id: Optional[str] = Field(
46
+ default=None,
47
+ description=(
48
+ "A stage ID that use to keep execution output or getting by job "
49
+ "owner."
50
+ ),
51
+ )
52
+ name: str = Field(
53
+ description="A stage name that want to logging when start execution."
54
+ )
55
+ condition: Optional[str] = Field(
56
+ default=None,
57
+ alias="if",
58
+ )
59
+
60
+ @abstractmethod
61
+ def execute(self, params: DictData) -> Result:
62
+ """Execute abstraction method that action something by sub-model class.
63
+ This is important method that make this class is able to be the stage.
64
+
65
+ :param params: A parameter data that want to use in this execution.
66
+ :rtype: Result
67
+ """
68
+ raise NotImplementedError("Stage should implement ``execute`` method.")
69
+
70
+ def set_outputs(self, output: DictData, params: DictData) -> DictData:
71
+ """Set an outputs from execution process to an input params.
72
+
73
+ :param output: A output data that want to extract to an output key.
74
+ :param params: A context data that want to add output result.
75
+ :rtype: DictData
76
+ """
77
+ if self.id:
78
+ _id: str = param2template(self.id, params)
79
+ elif str2bool(os.getenv("WORKFLOW_CORE_DEFAULT_STAGE_ID", "false")):
80
+ _id: str = gen_id(param2template(self.name, params))
81
+ else:
82
+ return params
83
+
84
+ # NOTE: Create stages key to receive an output from the stage execution.
85
+ if "stages" not in params:
86
+ params["stages"] = {}
87
+
88
+ params["stages"][_id] = {"outputs": output}
89
+ return params
90
+
91
+ def is_skip(self, params: DictData | None = None) -> bool:
92
+ """Return true if condition of this stage do not correct.
93
+
94
+ :param params: A parameters that want to pass to condition template.
95
+ """
96
+ params: DictData = params or {}
97
+ if self.condition is None:
98
+ return False
99
+
100
+ _g: DictData = globals() | params
101
+ try:
102
+ rs: bool = eval(
103
+ param2template(self.condition, params, repr_flag=True), _g, {}
104
+ )
105
+ if not isinstance(rs, bool):
106
+ raise TypeError("Return type of condition does not be boolean")
107
+ return not rs
108
+ except Exception as err:
109
+ logging.error(str(err))
110
+ raise StageException(str(err)) from err
111
+
112
+
113
+ class EmptyStage(BaseStage):
114
+ """Empty stage that do nothing (context equal empty stage) and logging the
115
+ name of stage only to stdout.
116
+ """
117
+
118
+ echo: Optional[str] = Field(
119
+ default=None,
120
+ description="A string statement that want to logging",
121
+ )
122
+
123
+ def execute(self, params: DictData) -> Result:
124
+ """Execution method for the Empty stage that do only logging out to
125
+ stdout.
126
+
127
+ :param params: A context data that want to add output result. But this
128
+ stage does not pass any output.
129
+ """
130
+ logging.info(f"[STAGE]: Empty-Execute: {self.name!r}")
131
+ return Result(status=0, context={})
132
+
133
+
134
+ class BashStage(BaseStage):
135
+ """Bash execution stage that execute bash script on the current OS.
136
+ That mean if your current OS is Windows, it will running bash in the WSL.
137
+
138
+ I get some limitation when I run shell statement with the built-in
139
+ supprocess package. It does not good enough to use multiline statement.
140
+ Thus, I add writing ``.sh`` file before execution process for fix this
141
+ issue.
142
+
143
+ Data Validate:
144
+ >>> stage = {
145
+ ... "name": "Shell stage execution",
146
+ ... "bash": 'echo "Hello $FOO"',
147
+ ... "env": {
148
+ ... "FOO": "BAR",
149
+ ... },
150
+ ... }
151
+ """
152
+
153
+ bash: str = Field(description="A bash statement that want to execute.")
154
+ env: DictStr = Field(
155
+ default_factory=dict,
156
+ description=(
157
+ "An environment variable mapping that want to set before execute "
158
+ "this shell statement."
159
+ ),
160
+ )
161
+
162
+ @contextlib.contextmanager
163
+ def __prepare_bash(self, bash: str, env: DictStr) -> Iterator[TupleStr]:
164
+ """Return context of prepared bash statement that want to execute. This
165
+ step will write the `.sh` file before giving this file name to context.
166
+ After that, it will auto delete this file automatic.
167
+ """
168
+ f_name: str = f"{uuid.uuid4()}.sh"
169
+ f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
170
+ with open(f"./{f_name}", mode="w", newline="\n") as f:
171
+ # NOTE: write header of `.sh` file
172
+ f.write(f"#!/bin/{f_shebang}\n")
173
+
174
+ # NOTE: add setting environment variable before bash skip statement.
175
+ f.writelines([f"{k}='{env[k]}';\n" for k in env])
176
+
177
+ # NOTE: make sure that shell script file does not have `\r` char.
178
+ f.write(bash.replace("\r\n", "\n"))
179
+
180
+ make_exec(f"./{f_name}")
181
+
182
+ yield [f_shebang, f_name]
183
+
184
+ Path(f"./{f_name}").unlink()
185
+
186
+ def execute(self, params: DictData) -> Result:
187
+ """Execute the Bash statement with the Python build-in ``subprocess``
188
+ package.
189
+
190
+ :param params: A parameter data that want to use in this execution.
191
+ :rtype: Result
192
+ """
193
+ bash: str = param2template(self.bash, params)
194
+ with self.__prepare_bash(
195
+ bash=bash, env=param2template(self.env, params)
196
+ ) as sh:
197
+ logging.info(f"[STAGE]: Shell-Execute: {sh}")
198
+ rs: CompletedProcess = subprocess.run(
199
+ sh,
200
+ shell=False,
201
+ capture_output=True,
202
+ text=True,
203
+ )
204
+ if rs.returncode > 0:
205
+ err: str = (
206
+ rs.stderr.encode("utf-8").decode("utf-16")
207
+ if "\\x00" in rs.stderr
208
+ else rs.stderr
209
+ )
210
+ logging.error(f"{err}\nRunning Statement:\n---\n{bash}")
211
+ raise StageException(f"{err}\nRunning Statement:\n---\n{bash}")
212
+ return Result(
213
+ status=0,
214
+ context={
215
+ "return_code": rs.returncode,
216
+ "stdout": rs.stdout.rstrip("\n"),
217
+ "stderr": rs.stderr.rstrip("\n"),
218
+ },
219
+ )
220
+
221
+
222
+ class PyStage(BaseStage):
223
+ """Python executor stage that running the Python statement that receive
224
+ globals nad additional variables.
225
+ """
226
+
227
+ run: str = Field(
228
+ description="A Python string statement that want to run with exec.",
229
+ )
230
+ vars: DictData = Field(
231
+ default_factory=dict,
232
+ description=(
233
+ "A mapping to variable that want to pass to globals in exec."
234
+ ),
235
+ )
236
+
237
+ def set_outputs(self, output: DictData, params: DictData) -> DictData:
238
+ """Set an outputs from the Python execution process to an input params.
239
+
240
+ :param output: A output data that want to extract to an output key.
241
+ :param params: A context data that want to add output result.
242
+ :rtype: DictData
243
+ """
244
+ # NOTE: The output will fileter unnecessary keys from locals.
245
+ _locals: DictData = output["locals"]
246
+ super().set_outputs(
247
+ {k: _locals[k] for k in _locals if k != "__annotations__"},
248
+ params=params,
249
+ )
250
+
251
+ # NOTE:
252
+ # Override value that changing from the globals that pass via exec.
253
+ _globals: DictData = output["globals"]
254
+ params.update({k: _globals[k] for k in params if k in _globals})
255
+ return params
256
+
257
+ def execute(self, params: DictData) -> Result:
258
+ """Execute the Python statement that pass all globals and input params
259
+ to globals argument on ``exec`` build-in function.
260
+
261
+ :param params: A parameter that want to pass before run any statement.
262
+ :rtype: Result
263
+ """
264
+ # NOTE: create custom globals value that will pass to exec function.
265
+ _globals: DictData = (
266
+ globals() | params | param2template(self.vars, params)
267
+ )
268
+ _locals: DictData = {}
269
+ try:
270
+ logging.info(f"[STAGE]: Py-Execute: {uuid.uuid4()}")
271
+ exec(param2template(self.run, params), _globals, _locals)
272
+ except Exception as err:
273
+ raise StageException(
274
+ f"{err.__class__.__name__}: {err}\nRunning Statement:\n---\n"
275
+ f"{self.run}"
276
+ ) from None
277
+ return Result(
278
+ status=0,
279
+ context={"locals": _locals, "globals": _globals},
280
+ )
281
+
282
+
283
+ @dataclass
284
+ class HookSearch:
285
+ """Hook Search dataclass."""
286
+
287
+ path: str
288
+ func: str
289
+ tag: str
290
+
291
+
292
+ class HookStage(BaseStage):
293
+ """Hook executor that hook the Python function from registry with tag
294
+ decorator function in ``utils`` module and run it with input arguments.
295
+
296
+ This stage is different with PyStage because the PyStage is just calling
297
+ a Python statement with the ``eval`` and pass that locale before eval that
298
+ statement. So, you can create your function complexly that you can for your
299
+ propose to invoked by this stage object.
300
+
301
+ Data Validate:
302
+ >>> stage = {
303
+ ... "name": "Task stage execution",
304
+ ... "task": "tasks/function-name@tag-name",
305
+ ... "args": {
306
+ ... "FOO": "BAR",
307
+ ... },
308
+ ... }
309
+ """
310
+
311
+ uses: str = Field(
312
+ description="A pointer that want to load function from registry",
313
+ )
314
+ args: DictData = Field(alias="with")
315
+
316
+ @staticmethod
317
+ def extract_hook(hook: str) -> Callable[[], TagFunc]:
318
+ """Extract Hook string value to hook function.
319
+
320
+ :param hook: A hook value that able to match with Task regex.
321
+ """
322
+ if not (found := Re.RE_TASK_FMT.search(hook)):
323
+ raise ValueError("Task does not match with task format regex.")
324
+
325
+ # NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
326
+ hook: HookSearch = HookSearch(**found.groupdict())
327
+
328
+ # NOTE: Registry object should implement on this package only.
329
+ rgt: dict[str, Registry] = make_registry(f"{hook.path}")
330
+ if hook.func not in rgt:
331
+ raise NotImplementedError(
332
+ f"``REGISTER-MODULES.{hook.path}.registries`` does not "
333
+ f"implement registry: {hook.func!r}."
334
+ )
335
+
336
+ if hook.tag not in rgt[hook.func]:
337
+ raise NotImplementedError(
338
+ f"tag: {hook.tag!r} does not found on registry func: "
339
+ f"``REGISTER-MODULES.{hook.path}.registries.{hook.func}``"
340
+ )
341
+ return rgt[hook.func][hook.tag]
342
+
343
+ def execute(self, params: DictData) -> Result:
344
+ """Execute the Task function that already mark registry.
345
+
346
+ :param params: A parameter that want to pass before run any statement.
347
+ :type params: DictData
348
+ :rtype: Result
349
+ """
350
+ t_func: TagFunc = self.extract_hook(param2template(self.uses, params))()
351
+ if not callable(t_func):
352
+ raise ImportError("Hook caller function does not callable.")
353
+
354
+ args: DictData = param2template(self.args, params)
355
+ # VALIDATE: check input task caller parameters that exists before
356
+ # calling.
357
+ ips = inspect.signature(t_func)
358
+ if any(
359
+ k not in args
360
+ for k in ips.parameters
361
+ if ips.parameters[k].default == Parameter.empty
362
+ ):
363
+ raise ValueError(
364
+ f"Necessary params, ({', '.join(ips.parameters.keys())}), "
365
+ f"does not set to args"
366
+ )
367
+
368
+ try:
369
+ logging.info(f"[STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}")
370
+ rs: DictData = t_func(**param2template(args, params))
371
+ except Exception as err:
372
+ raise StageException(f"{err.__class__.__name__}: {err}") from err
373
+ return Result(status=0, context=rs)
374
+
375
+
376
+ class TriggerStage(BaseStage):
377
+ """Trigger Pipeline execution stage that execute another pipeline object."""
378
+
379
+ trigger: str = Field(description="A trigger pipeline name.")
380
+ params: DictData = Field(default_factory=dict)
381
+
382
+ def execute(self, params: DictData) -> Result:
383
+ """Trigger execution.
384
+
385
+ :param params: A parameter data that want to use in this execution.
386
+ :rtype: Result
387
+ """
388
+ from .pipeline import Pipeline
389
+
390
+ pipe: Pipeline = Pipeline.from_loader(name=self.trigger, externals={})
391
+ rs = pipe.execute(params=self.params)
392
+ return Result(status=0, context=rs)
393
+
394
+
395
+ # NOTE: Order of parsing stage data
396
+ Stage = Union[
397
+ PyStage,
398
+ BashStage,
399
+ HookStage,
400
+ TriggerStage,
401
+ EmptyStage,
402
+ ]