ddeutil-workflow 0.0.8__py3-none-any.whl → 0.0.9__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.
ddeutil/workflow/stage.py CHANGED
@@ -31,11 +31,18 @@ from functools import wraps
31
31
  from inspect import Parameter
32
32
  from pathlib import Path
33
33
  from subprocess import CompletedProcess
34
+ from textwrap import dedent
34
35
  from typing import Callable, Optional, Union
35
36
 
37
+ try:
38
+ from typing import ParamSpec
39
+ except ImportError:
40
+ from typing_extensions import ParamSpec
41
+
36
42
  from ddeutil.core import str2bool
37
43
  from pydantic import BaseModel, Field
38
44
  from pydantic.functional_validators import model_validator
45
+ from typing_extensions import Self
39
46
 
40
47
  from .__types import DictData, DictStr, Re, TupleStr
41
48
  from .exceptions import StageException
@@ -46,33 +53,63 @@ from .utils import (
46
53
  gen_id,
47
54
  make_exec,
48
55
  make_registry,
56
+ not_in_template,
49
57
  param2template,
50
58
  )
51
59
 
60
+ P = ParamSpec("P")
61
+ __all__: TupleStr = (
62
+ "Stage",
63
+ "EmptyStage",
64
+ "BashStage",
65
+ "PyStage",
66
+ "HookStage",
67
+ "TriggerStage",
68
+ "handler_result",
69
+ )
70
+
52
71
 
53
- def handler_result(message: str | None = None):
54
- """Decorator function for handler result from the stage execution."""
72
+ def handler_result(message: str | None = None) -> Callable[P, Result]:
73
+ """Decorator function for handler result from the stage execution. This
74
+ function should to use with execution method only.
75
+
76
+ :param message: A message that want to add at prefix of exception statement.
77
+ """
55
78
  message: str = message or ""
56
79
 
57
- def decorator(func):
80
+ def decorator(func: Callable[P, Result]) -> Callable[P, Result]:
58
81
 
59
82
  @wraps(func)
60
- def wrapped(self: BaseStage, *args, **kwargs):
83
+ def wrapped(self: Stage, *args, **kwargs):
61
84
  try:
62
- rs: DictData = func(self, *args, **kwargs)
63
- return Result(status=0, context=rs)
85
+ # NOTE: Start calling origin function with a passing args.
86
+ return func(self, *args, **kwargs).set_run_id(self.run_id)
64
87
  except Exception as err:
88
+ # NOTE: Start catching error from the stage execution.
65
89
  logging.error(
66
90
  f"({self.run_id}) [STAGE]: {err.__class__.__name__}: {err}"
67
91
  )
68
- if isinstance(err, StageException):
92
+ if str2bool(
93
+ os.getenv("WORKFLOW_CORE_STAGE_RAISE_ERROR", "true")
94
+ ):
95
+ # NOTE: If error that raise from stage execution course by
96
+ # itself, it will return that error with previous
97
+ # dependency.
98
+ if isinstance(err, StageException):
99
+ raise StageException(
100
+ f"{self.__class__.__name__}: {message}\n\t{err}"
101
+ ) from err
69
102
  raise StageException(
70
- f"{self.__class__.__name__}: {message}\n---\n\t{err}"
71
- ) from err
72
- raise StageException(
73
- f"{self.__class__.__name__}: {message}\n---\n\t"
74
- f"{err.__class__.__name__}: {err}"
75
- ) from None
103
+ f"{self.__class__.__name__}: {message}\n\t"
104
+ f"{err.__class__.__name__}: {err}"
105
+ ) from None
106
+ rs: Result = Result(
107
+ status=1,
108
+ context={
109
+ "error_message": f"{err.__class__.__name__}: {err}",
110
+ },
111
+ )
112
+ return rs.set_run_id(self.run_id)
76
113
 
77
114
  return wrapped
78
115
 
@@ -93,10 +130,11 @@ class BaseStage(BaseModel, ABC):
93
130
  ),
94
131
  )
95
132
  name: str = Field(
96
- description="A stage name that want to logging when start execution."
133
+ description="A stage name that want to logging when start execution.",
97
134
  )
98
135
  condition: Optional[str] = Field(
99
136
  default=None,
137
+ description="A stage condition statement to allow stage executable.",
100
138
  alias="if",
101
139
  )
102
140
  run_id: Optional[str] = Field(
@@ -107,10 +145,31 @@ class BaseStage(BaseModel, ABC):
107
145
 
108
146
  @model_validator(mode="after")
109
147
  def __prepare_running_id(self):
148
+ """Prepare stage running ID that use default value of field and this
149
+ method will validate name and id fields should not contain any template
150
+ parameter (exclude matrix template).
151
+ """
110
152
  if self.run_id is None:
111
153
  self.run_id = gen_id(self.name + (self.id or ""), unique=True)
154
+
155
+ # VALIDATE: Validate stage id and name should not dynamic with params
156
+ # template. (allow only matrix)
157
+ if not_in_template(self.id) or not_in_template(self.name):
158
+ raise ValueError(
159
+ "Stage name and ID should only template with matrix."
160
+ )
161
+
112
162
  return self
113
163
 
164
+ def get_running_id(self, run_id: str) -> Self:
165
+ """Return Stage model object that changing stage running ID with an
166
+ input running ID.
167
+
168
+ :param run_id: A replace stage running ID.
169
+ :rtype: Self
170
+ """
171
+ return self.model_copy(update={"run_id": run_id})
172
+
114
173
  @abstractmethod
115
174
  def execute(self, params: DictData) -> Result:
116
175
  """Execute abstraction method that action something by sub-model class.
@@ -121,41 +180,39 @@ class BaseStage(BaseModel, ABC):
121
180
  """
122
181
  raise NotImplementedError("Stage should implement ``execute`` method.")
123
182
 
124
- def set_outputs(self, output: DictData, params: DictData) -> DictData:
183
+ def set_outputs(self, output: DictData, to: DictData) -> DictData:
125
184
  """Set an outputs from execution process to an input params.
126
185
 
127
186
  :param output: A output data that want to extract to an output key.
128
- :param params: A context data that want to add output result.
187
+ :param to: A context data that want to add output result.
129
188
  :rtype: DictData
130
189
  """
131
190
  if not (
132
191
  self.id
133
- or str2bool(os.getenv("WORKFLOW_CORE_DEFAULT_STAGE_ID", "false"))
192
+ or str2bool(os.getenv("WORKFLOW_CORE_STAGE_DEFAULT_ID", "false"))
134
193
  ):
135
194
  logging.debug(
136
195
  f"({self.run_id}) [STAGE]: Output does not set because this "
137
196
  f"stage does not set ID or default stage ID config flag not be "
138
197
  f"True."
139
198
  )
140
- return params
199
+ return to
141
200
 
142
201
  # NOTE: Create stages key to receive an output from the stage execution.
143
- if "stages" not in params:
144
- params["stages"] = {}
202
+ if "stages" not in to:
203
+ to["stages"] = {}
145
204
 
146
- # TODO: Validate stage id and name should not dynamic with params
147
- # template. (allow only matrix)
148
205
  if self.id:
149
- _id: str = param2template(self.id, params=params)
206
+ _id: str = param2template(self.id, params=to)
150
207
  else:
151
- _id: str = gen_id(param2template(self.name, params=params))
208
+ _id: str = gen_id(param2template(self.name, params=to))
152
209
 
153
210
  # NOTE: Set the output to that stage generated ID.
154
- params["stages"][_id] = {"outputs": output}
155
211
  logging.debug(
156
212
  f"({self.run_id}) [STAGE]: Set output complete with stage ID: {_id}"
157
213
  )
158
- return params
214
+ to["stages"][_id] = {"outputs": output}
215
+ return to
159
216
 
160
217
  def is_skipped(self, params: DictData | None = None) -> bool:
161
218
  """Return true if condition of this stage do not correct.
@@ -175,7 +232,7 @@ class BaseStage(BaseModel, ABC):
175
232
  return not rs
176
233
  except Exception as err:
177
234
  logging.error(f"({self.run_id}) [STAGE]: {err}")
178
- raise StageException(str(err)) from err
235
+ raise StageException(f"{err.__class__.__name__}: {err}") from err
179
236
 
180
237
 
181
238
  class EmptyStage(BaseStage):
@@ -246,15 +303,17 @@ class BashStage(BaseStage):
246
303
  f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
247
304
  with open(f"./{f_name}", mode="w", newline="\n") as f:
248
305
  # NOTE: write header of `.sh` file
249
- f.write(f"#!/bin/{f_shebang}\n")
306
+ f.write(f"#!/bin/{f_shebang}\n\n")
250
307
 
251
308
  # NOTE: add setting environment variable before bash skip statement.
252
309
  f.writelines([f"{k}='{env[k]}';\n" for k in env])
253
310
 
254
311
  # NOTE: make sure that shell script file does not have `\r` char.
255
- f.write(bash.replace("\r\n", "\n"))
312
+ f.write("\n" + bash.replace("\r\n", "\n"))
256
313
 
314
+ # NOTE: Make this .sh file able to executable.
257
315
  make_exec(f"./{f_name}")
316
+
258
317
  logging.debug(
259
318
  f"({self.run_id}) [STAGE]: Start create `.sh` file and running a "
260
319
  f"bash statement."
@@ -262,17 +321,18 @@ class BashStage(BaseStage):
262
321
 
263
322
  yield [f_shebang, f_name]
264
323
 
324
+ # Note: Remove .sh file that use to run bash.
265
325
  Path(f"./{f_name}").unlink()
266
326
 
267
327
  @handler_result()
268
- def execute(self, params: DictData) -> DictData:
328
+ def execute(self, params: DictData) -> Result:
269
329
  """Execute the Bash statement with the Python build-in ``subprocess``
270
330
  package.
271
331
 
272
332
  :param params: A parameter data that want to use in this execution.
273
333
  :rtype: Result
274
334
  """
275
- bash: str = param2template(self.bash, params)
335
+ bash: str = param2template(dedent(self.bash), params)
276
336
  with self.__prepare_bash(
277
337
  bash=bash, env=param2template(self.env, params)
278
338
  ) as sh:
@@ -288,19 +348,19 @@ class BashStage(BaseStage):
288
348
  rs.stderr.encode("utf-8").decode("utf-16")
289
349
  if "\\x00" in rs.stderr
290
350
  else rs.stderr
291
- )
292
- logging.error(
293
- f"({self.run_id}) [STAGE]: {err}\n\n```bash\n{bash}```"
294
- )
351
+ ).removesuffix("\n")
295
352
  raise StageException(
296
- f"{err.__class__.__name__}: {err}\nRunning Statement:"
297
- f"\n---\n```bash\n{bash}\n```"
353
+ f"Subprocess: {err}\nRunning Statement:\n---\n"
354
+ f"```bash\n{bash}\n```"
298
355
  )
299
- return {
300
- "return_code": rs.returncode,
301
- "stdout": rs.stdout.rstrip("\n"),
302
- "stderr": rs.stderr.rstrip("\n"),
303
- }
356
+ return Result(
357
+ status=0,
358
+ context={
359
+ "return_code": rs.returncode,
360
+ "stdout": rs.stdout.rstrip("\n"),
361
+ "stderr": rs.stderr.rstrip("\n"),
362
+ },
363
+ )
304
364
 
305
365
 
306
366
  class PyStage(BaseStage):
@@ -327,48 +387,56 @@ class PyStage(BaseStage):
327
387
  ),
328
388
  )
329
389
 
330
- def set_outputs(self, output: DictData, params: DictData) -> DictData:
390
+ def set_outputs(self, output: DictData, to: DictData) -> DictData:
331
391
  """Set an outputs from the Python execution process to an input params.
332
392
 
333
393
  :param output: A output data that want to extract to an output key.
334
- :param params: A context data that want to add output result.
394
+ :param to: A context data that want to add output result.
335
395
  :rtype: DictData
336
396
  """
337
397
  # NOTE: The output will fileter unnecessary keys from locals.
338
398
  _locals: DictData = output["locals"]
339
399
  super().set_outputs(
340
- {k: _locals[k] for k in _locals if k != "__annotations__"},
341
- params=params,
400
+ {k: _locals[k] for k in _locals if k != "__annotations__"}, to=to
342
401
  )
343
402
 
344
403
  # NOTE:
345
404
  # Override value that changing from the globals that pass via exec.
346
405
  _globals: DictData = output["globals"]
347
- params.update({k: _globals[k] for k in params if k in _globals})
348
- return params
406
+ to.update({k: _globals[k] for k in to if k in _globals})
407
+ return to
349
408
 
350
409
  @handler_result()
351
- def execute(self, params: DictData) -> DictData:
410
+ def execute(self, params: DictData) -> Result:
352
411
  """Execute the Python statement that pass all globals and input params
353
412
  to globals argument on ``exec`` build-in function.
354
413
 
355
414
  :param params: A parameter that want to pass before run any statement.
356
415
  :rtype: Result
357
416
  """
417
+ # NOTE: Replace the run statement that has templating value.
418
+ run: str = param2template(dedent(self.run), params)
419
+
358
420
  # NOTE: create custom globals value that will pass to exec function.
359
421
  _globals: DictData = (
360
422
  globals() | params | param2template(self.vars, params)
361
423
  )
362
424
  _locals: DictData = {}
363
- run: str = param2template(self.run, params)
364
- logging.info(f"({self.run_id}) [STAGE]: Py-Execute: {uuid.uuid4()}")
425
+
426
+ # NOTE: Start exec the run statement.
427
+ logging.info(f"({self.run_id}) [STAGE]: Py-Execute: {self.name}")
365
428
  exec(run, _globals, _locals)
366
- return {"locals": _locals, "globals": _globals}
429
+
430
+ return Result(
431
+ status=0, context={"locals": _locals, "globals": _globals}
432
+ )
367
433
 
368
434
 
369
435
  @dataclass
370
436
  class HookSearch:
371
- """Hook Search dataclass."""
437
+ """Hook Search dataclass that use for receive regular expression grouping
438
+ dict from searching hook string value.
439
+ """
372
440
 
373
441
  path: str
374
442
  func: str
@@ -376,13 +444,16 @@ class HookSearch:
376
444
 
377
445
 
378
446
  def extract_hook(hook: str) -> Callable[[], TagFunc]:
379
- """Extract Hook string value to hook function.
447
+ """Extract Hook function from string value to hook partial function that
448
+ does run it at runtime.
380
449
 
381
450
  :param hook: A hook value that able to match with Task regex.
382
451
  :rtype: Callable[[], TagFunc]
383
452
  """
384
453
  if not (found := Re.RE_TASK_FMT.search(hook)):
385
- raise ValueError("Task does not match with task format regex.")
454
+ raise ValueError(
455
+ f"Hook {hook!r} does not match with hook format regex."
456
+ )
386
457
 
387
458
  # NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
388
459
  hook: HookSearch = HookSearch(**found.groupdict())
@@ -415,7 +486,7 @@ class HookStage(BaseStage):
415
486
  Data Validate:
416
487
  >>> stage = {
417
488
  ... "name": "Task stage execution",
418
- ... "task": "tasks/function-name@tag-name",
489
+ ... "uses": "tasks/function-name@tag-name",
419
490
  ... "args": {
420
491
  ... "FOO": "BAR",
421
492
  ... },
@@ -426,12 +497,13 @@ class HookStage(BaseStage):
426
497
  description="A pointer that want to load function from registry.",
427
498
  )
428
499
  args: DictData = Field(
500
+ default_factory=dict,
429
501
  description="An arguments that want to pass to the hook function.",
430
502
  alias="with",
431
503
  )
432
504
 
433
505
  @handler_result()
434
- def execute(self, params: DictData) -> DictData:
506
+ def execute(self, params: DictData) -> Result:
435
507
  """Execute the Hook function that already in the hook registry.
436
508
 
437
509
  :param params: A parameter that want to pass before run any statement.
@@ -440,10 +512,7 @@ class HookStage(BaseStage):
440
512
  """
441
513
  t_func_hook: str = param2template(self.uses, params)
442
514
  t_func: TagFunc = extract_hook(t_func_hook)()
443
- if not callable(t_func):
444
- raise ImportError(
445
- f"Hook caller {t_func_hook!r} function does not callable."
446
- )
515
+
447
516
  # VALIDATE: check input task caller parameters that exists before
448
517
  # calling.
449
518
  args: DictData = param2template(self.args, params)
@@ -454,7 +523,7 @@ class HookStage(BaseStage):
454
523
  if ips.parameters[k].default == Parameter.empty
455
524
  ):
456
525
  raise ValueError(
457
- f"Necessary params, ({', '.join(ips.parameters.keys())}), "
526
+ f"Necessary params, ({', '.join(ips.parameters.keys())}, ), "
458
527
  f"does not set to args"
459
528
  )
460
529
  # NOTE: add '_' prefix if it want to use.
@@ -463,8 +532,7 @@ class HookStage(BaseStage):
463
532
  args[k] = args.pop(k.removeprefix("_"))
464
533
 
465
534
  logging.info(
466
- f"({self.run_id}) [STAGE]: Hook-Execute: "
467
- f"{t_func.name}@{t_func.tag}"
535
+ f"({self.run_id}) [STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}"
468
536
  )
469
537
  rs: DictData = t_func(**param2template(args, params))
470
538
 
@@ -472,11 +540,10 @@ class HookStage(BaseStage):
472
540
  # Check the result type from hook function, it should be dict.
473
541
  if not isinstance(rs, dict):
474
542
  raise TypeError(
475
- f"Return of hook function: {t_func.name}@{t_func.tag} does "
476
- f"not serialize to result model, you should fix it to "
477
- f"`dict` type."
543
+ f"Return type: '{t_func.name}@{t_func.tag}' does not serialize "
544
+ f"to result model, you change return type to `dict`."
478
545
  )
479
- return rs
546
+ return Result(status=0, context=rs)
480
547
 
481
548
 
482
549
  class TriggerStage(BaseStage):
@@ -499,8 +566,8 @@ class TriggerStage(BaseStage):
499
566
  description="A parameter that want to pass to pipeline execution.",
500
567
  )
501
568
 
502
- @handler_result("Raise from trigger pipeline")
503
- def execute(self, params: DictData) -> DictData:
569
+ @handler_result("Raise from TriggerStage")
570
+ def execute(self, params: DictData) -> Result:
504
571
  """Trigger pipeline execution.
505
572
 
506
573
  :param params: A parameter data that want to use in this execution.
@@ -510,9 +577,14 @@ class TriggerStage(BaseStage):
510
577
 
511
578
  # NOTE: Loading pipeline object from trigger name.
512
579
  _trigger: str = param2template(self.trigger, params=params)
513
- pipe: Pipeline = Pipeline.from_loader(name=_trigger, externals={})
514
- rs: Result = pipe.execute(params=param2template(self.params, params))
515
- return rs.context
580
+
581
+ # NOTE: Set running pipeline ID from running stage ID to external
582
+ # params on Loader object.
583
+ pipe: Pipeline = Pipeline.from_loader(
584
+ name=_trigger, externals={"run_id": self.run_id}
585
+ )
586
+ logging.info(f"({self.run_id}) [STAGE]: Trigger-Execute: {_trigger!r}")
587
+ return pipe.execute(params=param2template(self.params, params))
516
588
 
517
589
 
518
590
  # NOTE: Order of parsing stage data