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