ddeutil-workflow 0.0.77__py3-none-any.whl → 0.0.78__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.
@@ -1 +1 @@
1
- __version__: str = "0.0.77"
1
+ __version__: str = "0.0.78"
ddeutil/workflow/cli.py CHANGED
@@ -57,7 +57,7 @@ def init() -> None:
57
57
  wf-example:
58
58
  type: Workflow
59
59
  desc: |
60
- An example workflow template.
60
+ An example workflow template that provide the demo of workflow.
61
61
  params:
62
62
  name:
63
63
  type: str
@@ -65,6 +65,10 @@ def init() -> None:
65
65
  jobs:
66
66
  first-job:
67
67
  stages:
68
+
69
+ - name: "Hello Stage"
70
+ echo: "Start say hi to the console"
71
+
68
72
  - name: "Call tasks"
69
73
  uses: tasks/say-hello-func@example
70
74
  with:
@@ -232,7 +236,7 @@ def workflow_json_schema(
232
236
  json_schema = TypeAdapter(template).json_schema(by_alias=True)
233
237
  template_schema: dict[str, str] = {
234
238
  "$schema": "http://json-schema.org/draft-07/schema#",
235
- "title": "Workflow Configuration Schema",
239
+ "title": "Workflow Configuration JSON Schema",
236
240
  "version": __version__,
237
241
  }
238
242
  with open(output, mode="w", encoding="utf-8") as f:
ddeutil/workflow/conf.py CHANGED
@@ -307,8 +307,6 @@ class YamlParser:
307
307
  all_data.append((file_stat.st_mtime, data))
308
308
  elif (t := data.get("type")) and t == obj_type:
309
309
  all_data.append((file_stat.st_mtime, data))
310
- else:
311
- continue
312
310
 
313
311
  return {} if not all_data else max(all_data, key=lambda x: x[0])[1]
314
312
 
@@ -322,7 +320,7 @@ class YamlParser:
322
320
  excluded: Optional[list[str]] = None,
323
321
  extras: Optional[DictData] = None,
324
322
  ignore_filename: Optional[str] = None,
325
- tags: Optional[list[str]] = None,
323
+ tags: Optional[list[Union[str, int]]] = None,
326
324
  ) -> Iterator[tuple[str, DictData]]:
327
325
  """Find all data that match with object type in config path. This class
328
326
  method can use include and exclude list of identity name for filter and
@@ -373,13 +371,15 @@ class YamlParser:
373
371
 
374
372
  if (
375
373
  tags
376
- and (ts := data[key].get("tags"))
377
- and isinstance(ts, list)
378
- and all(t not in tags for t in ts)
379
- ): # pragma: no cov
374
+ and isinstance((ts := data.get("tags", [])), list)
375
+ and any(t not in ts for t in tags)
376
+ ):
380
377
  continue
381
378
 
382
379
  if (t := data.get("type")) and t == obj_type:
380
+ file_stat: os.stat_result = file.lstat()
381
+ data["created_at"] = file_stat.st_ctime
382
+ data["updated_at"] = file_stat.st_mtime
383
383
  marking: tuple[float, DictData] = (
384
384
  file.lstat().st_mtime,
385
385
  data,
@@ -56,13 +56,13 @@ def to_dict(exception: Exception, **kwargs) -> ErrorData: # pragma: no cov
56
56
  ErrorData: Dictionary containing exception name and message
57
57
 
58
58
  Example:
59
- ```python
60
- try:
61
- raise ValueError("Something went wrong")
62
- except Exception as e:
63
- error_data = to_dict(e, context="workflow_execution")
64
- # Returns: {"name": "ValueError", "message": "Something went wrong", "context": "workflow_execution"}
65
- ```
59
+ >>> try:
60
+ >>> raise ValueError("Something went wrong")
61
+ >>> except Exception as e:
62
+ >>> error_data = to_dict(e, context="workflow_execution")
63
+ >>> # Returns: {
64
+ >>> # "name": "ValueError", "message": "Something went wrong", "context": "workflow_execution"
65
+ >>> # }
66
66
  """
67
67
  return {
68
68
  "name": exception.__class__.__name__,
@@ -85,14 +85,12 @@ class BaseError(Exception):
85
85
  params: Parameter data that was being processed when error occurred
86
86
 
87
87
  Example:
88
- ```python
89
- try:
90
- # Some workflow operation
91
- pass
92
- except BaseError as e:
93
- error_dict = e.to_dict(with_refs=True)
94
- print(f"Error in {e.refs}: {error_dict}")
95
- ```
88
+ >>> try:
89
+ >>> # NOTE: Some workflow operation
90
+ >>> pass
91
+ >>> except BaseError as e:
92
+ >>> error_dict = e.to_dict(with_refs=True)
93
+ >>> print(f\"Error in {e.refs}: {error_dict}\")
96
94
  """
97
95
 
98
96
  def __init__(
ddeutil/workflow/event.py CHANGED
@@ -39,7 +39,6 @@ from __future__ import annotations
39
39
  from dataclasses import fields
40
40
  from datetime import datetime
41
41
  from typing import Annotated, Any, Literal, Optional, Union
42
- from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
43
42
 
44
43
  from pydantic import BaseModel, ConfigDict, Field, ValidationInfo
45
44
  from pydantic.functional_serializers import field_serializer
@@ -107,7 +106,7 @@ class BaseCrontab(BaseModel):
107
106
  )
108
107
  tz: TimeZoneName = Field(
109
108
  default="UTC",
110
- description="A timezone string value.",
109
+ description="A timezone string value that will pass to ZoneInfo.",
111
110
  alias="timezone",
112
111
  )
113
112
 
@@ -125,38 +124,25 @@ class BaseCrontab(BaseModel):
125
124
  data["timezone"] = tz
126
125
  return data
127
126
 
128
- @field_validator("tz")
129
- def __validate_tz(cls, value: str) -> str:
130
- """Validate timezone value.
131
-
132
- Args:
133
- value: Timezone string to validate.
134
-
135
- Returns:
136
- Validated timezone string.
137
-
138
- Raises:
139
- ValueError: If timezone is invalid.
140
- """
141
- try:
142
- _ = ZoneInfo(value)
143
- return value
144
- except ZoneInfoNotFoundError as e:
145
- raise ValueError(f"Invalid timezone: {value}") from e
146
-
147
127
 
148
128
  class CrontabValue(BaseCrontab):
149
129
  """Crontab model using interval-based specification.
150
130
 
151
131
  Attributes:
152
- interval: Scheduling interval ('daily', 'weekly', 'monthly').
153
- day: Day specification for weekly/monthly schedules.
132
+ interval: (Interval)
133
+ A scheduling interval string ('daily', 'weekly', 'monthly').
134
+ day: (str, default None)
135
+ Day specification for weekly/monthly schedules.
154
136
  time: Time of day in 'HH:MM' format.
155
137
  """
156
138
 
157
- interval: Interval
139
+ interval: Interval = Field(description="A scheduling interval string.")
158
140
  day: Optional[str] = Field(default=None)
159
- time: str = Field(default="00:00")
141
+ time: str = Field(
142
+ default="00:00",
143
+ pattern=r"\d{2}:\d{2}",
144
+ description="A time of day that pass with format 'HH:MM'.",
145
+ )
160
146
 
161
147
  @property
162
148
  def cronjob(self) -> CronJob:
@@ -182,10 +168,13 @@ class CrontabValue(BaseCrontab):
182
168
  TypeError: If start parameter is neither string nor datetime.
183
169
  """
184
170
  if isinstance(start, str):
185
- start: datetime = datetime.fromisoformat(start)
186
- elif not isinstance(start, datetime):
187
- raise TypeError("start value should be str or datetime type.")
188
- return self.cronjob.schedule(date=start, tz=self.tz)
171
+ return self.cronjob.schedule(
172
+ date=datetime.fromisoformat(start), tz=self.tz
173
+ )
174
+
175
+ if isinstance(start, datetime):
176
+ return self.cronjob.schedule(date=start, tz=self.tz)
177
+ raise TypeError("start value should be str or datetime type.")
189
178
 
190
179
  def next(self, start: Union[str, datetime]) -> CronRunner:
191
180
  """Get next scheduled datetime after given start time.
@@ -222,11 +211,6 @@ class Crontab(BaseCrontab):
222
211
  "A Cronjob object that use for validate and generate datetime."
223
212
  ),
224
213
  )
225
- tz: TimeZoneName = Field(
226
- default="UTC",
227
- description="A timezone string value.",
228
- alias="timezone",
229
- )
230
214
 
231
215
  @model_validator(mode="before")
232
216
  def __prepare_values(cls, data: Any) -> Any:
@@ -376,7 +360,10 @@ Cron = Annotated[
376
360
  ],
377
361
  Field(
378
362
  union_mode="smart",
379
- description="Event model type supporting year-based, standard, and interval-based cron scheduling.",
363
+ description=(
364
+ "Event model type supporting year-based, standard, and "
365
+ "interval-based cron scheduling."
366
+ ),
380
367
  ),
381
368
  ] # pragma: no cov
382
369
 
ddeutil/workflow/job.py CHANGED
@@ -48,10 +48,11 @@ from enum import Enum
48
48
  from functools import lru_cache
49
49
  from textwrap import dedent
50
50
  from threading import Event
51
- from typing import Annotated, Any, Literal, Optional, Union
51
+ from typing import Annotated, Any, Optional, Union
52
52
 
53
53
  from ddeutil.core import freeze_args
54
54
  from pydantic import BaseModel, Discriminator, Field, SecretStr, Tag
55
+ from pydantic.functional_serializers import field_serializer
55
56
  from pydantic.functional_validators import field_validator, model_validator
56
57
  from typing_extensions import Self
57
58
 
@@ -263,24 +264,20 @@ class BaseRunsOn(BaseModel): # pragma: no cov
263
264
  object and override execute method.
264
265
  """
265
266
 
266
- type: RunsOn = Field(description="A runs-on type.")
267
+ type: RunsOn = LOCAL
267
268
  args: DictData = Field(
268
269
  default_factory=dict,
269
- alias="with",
270
270
  description=(
271
271
  "An argument that pass to the runs-on execution function. This "
272
272
  "args will override by this child-model with specific args model."
273
273
  ),
274
+ alias="with",
274
275
  )
275
276
 
276
277
 
277
278
  class OnLocal(BaseRunsOn): # pragma: no cov
278
279
  """Runs-on local."""
279
280
 
280
- type: Literal[RunsOn.LOCAL] = Field(
281
- default=RunsOn.LOCAL, validate_default=True
282
- )
283
-
284
281
 
285
282
  class SelfHostedArgs(BaseModel):
286
283
  """Self-Hosted arguments."""
@@ -292,9 +289,7 @@ class SelfHostedArgs(BaseModel):
292
289
  class OnSelfHosted(BaseRunsOn): # pragma: no cov
293
290
  """Runs-on self-hosted."""
294
291
 
295
- type: Literal[RunsOn.SELF_HOSTED] = Field(
296
- default=RunsOn.SELF_HOSTED, validate_default=True
297
- )
292
+ type: RunsOn = SELF_HOSTED
298
293
  args: SelfHostedArgs = Field(alias="with")
299
294
 
300
295
 
@@ -310,9 +305,7 @@ class AzBatchArgs(BaseModel):
310
305
 
311
306
  class OnAzBatch(BaseRunsOn): # pragma: no cov
312
307
 
313
- type: Literal[RunsOn.AZ_BATCH] = Field(
314
- default=RunsOn.AZ_BATCH, validate_default=True
315
- )
308
+ type: RunsOn = AZ_BATCH
316
309
  args: AzBatchArgs = Field(alias="with")
317
310
 
318
311
 
@@ -331,23 +324,21 @@ class DockerArgs(BaseModel):
331
324
  class OnDocker(BaseRunsOn): # pragma: no cov
332
325
  """Runs-on Docker container."""
333
326
 
334
- type: Literal[RunsOn.DOCKER] = Field(
335
- default=RunsOn.DOCKER, validate_default=True
336
- )
337
- args: DockerArgs = Field(alias="with", default_factory=DockerArgs)
327
+ type: RunsOn = DOCKER
328
+ args: DockerArgs = Field(default_factory=DockerArgs, alias="with")
338
329
 
339
330
 
340
331
  def get_discriminator_runs_on(model: dict[str, Any]) -> RunsOn:
341
332
  """Get discriminator of the RunsOn models."""
342
333
  t: str = model.get("type")
343
- return RunsOn(t) if t else RunsOn.LOCAL
334
+ return RunsOn(t) if t else LOCAL
344
335
 
345
336
 
346
337
  RunsOnModel = Annotated[
347
338
  Union[
348
- Annotated[OnSelfHosted, Tag(RunsOn.SELF_HOSTED)],
349
- Annotated[OnDocker, Tag(RunsOn.DOCKER)],
350
- Annotated[OnLocal, Tag(RunsOn.LOCAL)],
339
+ Annotated[OnSelfHosted, Tag(SELF_HOSTED)],
340
+ Annotated[OnDocker, Tag(DOCKER)],
341
+ Annotated[OnLocal, Tag(LOCAL)],
351
342
  ],
352
343
  Discriminator(get_discriminator_runs_on),
353
344
  ]
@@ -490,6 +481,10 @@ class Job(BaseModel):
490
481
 
491
482
  return self
492
483
 
484
+ @field_serializer("runs_on")
485
+ def __serialize_runs_on(self, value: RunsOnModel):
486
+ return value.model_dump(by_alias=True)
487
+
493
488
  def stage(self, stage_id: str) -> Stage:
494
489
  """Return stage instance that exists in this job via passing an input
495
490
  stage ID.
@@ -20,7 +20,6 @@ Functions:
20
20
  from __future__ import annotations
21
21
 
22
22
  from dataclasses import field
23
- from datetime import datetime
24
23
  from enum import Enum
25
24
  from typing import Optional, Union
26
25
 
@@ -42,7 +41,7 @@ from . import (
42
41
  from .__types import DictData
43
42
  from .audits import Trace, get_trace
44
43
  from .errors import ResultError
45
- from .utils import default_gen_id, get_dt_now
44
+ from .utils import default_gen_id
46
45
 
47
46
 
48
47
  class Status(str, Enum):
@@ -105,8 +104,9 @@ def validate_statuses(statuses: list[Status]) -> Status:
105
104
  """Determine final status from multiple status values.
106
105
 
107
106
  Applies workflow logic to determine the overall status based on a collection
108
- of individual status values. Follows priority order: CANCEL > FAILED > WAIT >
109
- individual status consistency.
107
+ of individual status values. Follows priority order:
108
+
109
+ CANCEL > FAILED > WAIT > individual status consistency.
110
110
 
111
111
  Args:
112
112
  statuses: List of status values to evaluate
@@ -132,7 +132,7 @@ def validate_statuses(statuses: list[Status]) -> Status:
132
132
  for status in (SUCCESS, SKIP):
133
133
  if all(s == status for s in statuses):
134
134
  return status
135
- return FAILED if FAILED in statuses else SUCCESS
135
+ return SUCCESS
136
136
 
137
137
 
138
138
  def get_status_from_error(
@@ -166,10 +166,6 @@ def get_status_from_error(
166
166
  return FAILED
167
167
 
168
168
 
169
- def default_context() -> DictData:
170
- return {"status": WAIT}
171
-
172
-
173
169
  @dataclass(
174
170
  config=ConfigDict(arbitrary_types_allowed=True, use_enum_values=True),
175
171
  )
@@ -186,14 +182,13 @@ class Result:
186
182
  field that keep dict value change its ID when update new value to it.
187
183
  """
188
184
 
185
+ extras: DictData = field(default_factory=dict, compare=False, repr=False)
189
186
  status: Status = field(default=WAIT)
190
- context: DictData = field(default_factory=default_context)
187
+ context: Optional[DictData] = field(default=None)
191
188
  info: DictData = field(default_factory=dict)
192
- run_id: Optional[str] = field(default_factory=default_gen_id)
189
+ run_id: str = field(default_factory=default_gen_id)
193
190
  parent_run_id: Optional[str] = field(default=None)
194
- ts: datetime = field(default_factory=get_dt_now, compare=False)
195
191
  trace: Optional[Trace] = field(default=None, compare=False, repr=False)
196
- extras: DictData = field(default_factory=dict, compare=False, repr=False)
197
192
 
198
193
  @model_validator(mode="after")
199
194
  def __prepare_trace(self) -> Self:
@@ -207,20 +202,18 @@ class Result:
207
202
  parent_run_id=self.parent_run_id,
208
203
  extras=self.extras,
209
204
  )
210
- return self
211
-
212
- def set_parent_run_id(self, running_id: str) -> Self:
213
- """Set a parent running ID.
214
205
 
215
- :param running_id: (str) A running ID that want to update on this model.
206
+ return self
216
207
 
217
- :rtype: Self
218
- """
219
- self.parent_run_id: str = running_id
220
- self.trace: Trace = get_trace(
221
- self.run_id, parent_run_id=running_id, extras=self.extras
208
+ @classmethod
209
+ def from_trace(cls, trace: Trace):
210
+ """Construct the result model from trace for clean code objective."""
211
+ return cls(
212
+ run_id=trace.run_id,
213
+ parent_run_id=trace.parent_run_id,
214
+ extras=trace.extras,
215
+ trace=trace,
222
216
  )
223
- return self
224
217
 
225
218
  def catch(
226
219
  self,
@@ -237,7 +230,11 @@ class Result:
237
230
 
238
231
  :rtype: Self
239
232
  """
240
- self.__dict__["context"].update(context or {})
233
+ if self.__dict__["context"] is None:
234
+ self.__dict__["context"] = context
235
+ else:
236
+ self.__dict__["context"].update(context or {})
237
+
241
238
  self.__dict__["status"] = (
242
239
  Status(status) if isinstance(status, int) else status
243
240
  )
@@ -262,13 +259,6 @@ class Result:
262
259
  self.__dict__["info"].update(data)
263
260
  return self
264
261
 
265
- def alive_time(self) -> float: # pragma: no cov
266
- """Return total seconds that this object use since it was created.
267
-
268
- :rtype: float
269
- """
270
- return (get_dt_now() - self.ts).total_seconds()
271
-
272
262
 
273
263
  def catch(
274
264
  context: DictData,
@@ -276,7 +266,13 @@ def catch(
276
266
  updated: DictData | None = None,
277
267
  **kwargs,
278
268
  ) -> DictData:
279
- """Catch updated context to the current context."""
269
+ """Catch updated context to the current context.
270
+
271
+ Args:
272
+ context: A context data that want to be the current context.
273
+ status: A status enum object.
274
+ updated: A updated data that will update to the current context.
275
+ """
280
276
  context.update(updated or {})
281
277
  context["status"] = Status(status) if isinstance(status, int) else status
282
278
 
@@ -289,7 +285,7 @@ def catch(
289
285
  context[k].update(kwargs[k])
290
286
  # NOTE: Exclude the `info` key for update information data.
291
287
  elif k == "info":
292
- context["info"].update(kwargs["info"])
288
+ context.update({"info": kwargs["info"]})
293
289
  else:
294
290
  raise ResultError(f"The key {k!r} does not exists on context data.")
295
291
  return context
@@ -599,7 +599,7 @@ def make_registry(
599
599
  if not (
600
600
  hasattr(func, "tag")
601
601
  and hasattr(func, "name")
602
- and str(getattr(func, "mark", "NOT SET")) == "tag"
602
+ and str(getattr(func, "mark", "NOTSET")) == "tag"
603
603
  ): # pragma: no cov
604
604
  continue
605
605
 
@@ -617,6 +617,7 @@ def make_registry(
617
617
  f"{module}.{submodule}, you should change this tag name or "
618
618
  f"change it func name."
619
619
  )
620
+
620
621
  rs[func.name][func.tag] = lazy(f"{module}.{submodule}.{fstr}")
621
622
 
622
623
  return rs
@@ -77,7 +77,15 @@ from pathlib import Path
77
77
  from subprocess import CompletedProcess
78
78
  from textwrap import dedent
79
79
  from threading import Event
80
- from typing import Annotated, Any, Optional, TypeVar, Union, get_type_hints
80
+ from typing import (
81
+ Annotated,
82
+ Any,
83
+ Callable,
84
+ Optional,
85
+ TypeVar,
86
+ Union,
87
+ get_type_hints,
88
+ )
81
89
 
82
90
  from ddeutil.core import str2list
83
91
  from pydantic import BaseModel, Field, ValidationError
@@ -177,7 +185,7 @@ class BaseStage(BaseModel, ABC):
177
185
  "A stage description that use to logging when start execution."
178
186
  ),
179
187
  )
180
- condition: StrOrNone = Field(
188
+ condition: Optional[Union[str, bool]] = Field(
181
189
  default=None,
182
190
  description=(
183
191
  "A stage condition statement to allow stage executable. This field "
@@ -513,6 +521,9 @@ class BaseStage(BaseModel, ABC):
513
521
  if not self.condition:
514
522
  return False
515
523
 
524
+ if isinstance(self.condition, bool):
525
+ return self.condition
526
+
516
527
  try:
517
528
  # WARNING: The eval build-in function is very dangerous. So, it
518
529
  # should use the `re` module to validate eval-string before
@@ -1580,15 +1591,23 @@ class CallStage(BaseRetryStage):
1580
1591
 
1581
1592
  :rtype: Any
1582
1593
  """
1583
- if isinstance(value, dict):
1584
- if any(k in value for k in ("result", "extras")):
1585
- raise ValueError(
1586
- "The argument on workflow template for the caller stage "
1587
- "should not pass `result` and `extras`. They are special "
1588
- "arguments."
1589
- )
1594
+ if isinstance(value, dict) and any(
1595
+ k in value for k in ("result", "extras")
1596
+ ):
1597
+ raise ValueError(
1598
+ "The argument on workflow template for the caller stage "
1599
+ "should not pass `result` and `extras`. They are special "
1600
+ "arguments."
1601
+ )
1590
1602
  return value
1591
1603
 
1604
+ def get_caller(self, params: DictData) -> Callable[[], TagFunc]:
1605
+ """Get the lazy TagFuc object from registry."""
1606
+ return extract_call(
1607
+ param2template(self.uses, params, extras=self.extras),
1608
+ registries=self.extras.get("registry_caller"),
1609
+ )
1610
+
1592
1611
  def process(
1593
1612
  self,
1594
1613
  params: DictData,
@@ -1615,11 +1634,7 @@ class CallStage(BaseRetryStage):
1615
1634
  trace: Trace = get_trace(
1616
1635
  run_id, parent_run_id=parent_run_id, extras=self.extras
1617
1636
  )
1618
- call_func: TagFunc = extract_call(
1619
- param2template(self.uses, params, extras=self.extras),
1620
- registries=self.extras.get("registry_caller"),
1621
- )()
1622
-
1637
+ call_func: TagFunc = self.get_caller(params=params)()
1623
1638
  trace.info(f"[STAGE]: Caller Func: '{call_func.name}@{call_func.tag}'")
1624
1639
 
1625
1640
  # VALIDATE: check input task caller parameters that exists before
@@ -1677,7 +1692,7 @@ class CallStage(BaseRetryStage):
1677
1692
  )
1678
1693
 
1679
1694
  args: DictData = self.validate_model_args(
1680
- call_func, args, run_id, parent_run_id
1695
+ call_func, args, run_id, parent_run_id, extras=self.extras
1681
1696
  )
1682
1697
  if inspect.iscoroutinefunction(call_func):
1683
1698
  loop = asyncio.get_event_loop()
@@ -1738,11 +1753,7 @@ class CallStage(BaseRetryStage):
1738
1753
  trace: Trace = get_trace(
1739
1754
  run_id, parent_run_id=parent_run_id, extras=self.extras
1740
1755
  )
1741
- call_func: TagFunc = extract_call(
1742
- param2template(self.uses, params, extras=self.extras),
1743
- registries=self.extras.get("registry_caller"),
1744
- )()
1745
-
1756
+ call_func: TagFunc = self.get_caller(params=params)()
1746
1757
  await trace.ainfo(
1747
1758
  f"[STAGE]: Caller Func: '{call_func.name}@{call_func.tag}'"
1748
1759
  )
@@ -1795,8 +1806,13 @@ class CallStage(BaseRetryStage):
1795
1806
  if "extras" not in sig.parameters and not has_keyword:
1796
1807
  args.pop("extras")
1797
1808
 
1809
+ if event and event.is_set():
1810
+ raise StageCancelError(
1811
+ "Execution was canceled from the event before start parallel."
1812
+ )
1813
+
1798
1814
  args: DictData = self.validate_model_args(
1799
- call_func, args, run_id, parent_run_id
1815
+ call_func, args, run_id, parent_run_id, extras=self.extras
1800
1816
  )
1801
1817
  if inspect.iscoroutinefunction(call_func):
1802
1818
  rs: DictOrModel = await call_func(
@@ -1829,12 +1845,13 @@ class CallStage(BaseRetryStage):
1829
1845
  extras=self.extras,
1830
1846
  )
1831
1847
 
1848
+ @staticmethod
1832
1849
  def validate_model_args(
1833
- self,
1834
1850
  func: TagFunc,
1835
1851
  args: DictData,
1836
1852
  run_id: str,
1837
1853
  parent_run_id: Optional[str] = None,
1854
+ extras: Optional[DictData] = None,
1838
1855
  ) -> DictData:
1839
1856
  """Validate an input arguments before passing to the caller function.
1840
1857
 
@@ -1846,18 +1863,18 @@ class CallStage(BaseRetryStage):
1846
1863
  :rtype: DictData
1847
1864
  """
1848
1865
  try:
1849
- model_instance: BaseModel = create_model_from_caller(
1850
- func
1851
- ).model_validate(args)
1852
- override: DictData = dict(model_instance)
1866
+ override: DictData = dict(
1867
+ create_model_from_caller(func).model_validate(args)
1868
+ )
1853
1869
  args.update(override)
1870
+
1854
1871
  type_hints: dict[str, Any] = get_type_hints(func)
1855
1872
  for arg in type_hints:
1856
1873
 
1857
1874
  if arg == "return":
1858
1875
  continue
1859
1876
 
1860
- if arg.removeprefix("_") in args:
1877
+ if arg.startswith("_") and arg.removeprefix("_") in args:
1861
1878
  args[arg] = args.pop(arg.removeprefix("_"))
1862
1879
  continue
1863
1880
 
@@ -1868,7 +1885,7 @@ class CallStage(BaseRetryStage):
1868
1885
  ) from e
1869
1886
  except TypeError as e:
1870
1887
  trace: Trace = get_trace(
1871
- run_id, parent_run_id=parent_run_id, extras=self.extras
1888
+ run_id, parent_run_id=parent_run_id, extras=extras
1872
1889
  )
1873
1890
  trace.warning(
1874
1891
  f"[STAGE]: Get type hint raise TypeError: {e}, so, it skip "
@@ -2295,7 +2312,13 @@ class ForEachStage(BaseNestedStage):
2295
2312
  ... }
2296
2313
  """
2297
2314
 
2298
- foreach: Union[list[str], list[int], str] = Field(
2315
+ foreach: Union[
2316
+ list[str],
2317
+ list[int],
2318
+ str,
2319
+ dict[str, Any],
2320
+ dict[int, Any],
2321
+ ] = Field(
2299
2322
  description=(
2300
2323
  "A items for passing to stages via ${{ item }} template parameter."
2301
2324
  ),
@@ -2501,7 +2524,7 @@ class ForEachStage(BaseNestedStage):
2501
2524
  run_id, parent_run_id=parent_run_id, extras=self.extras
2502
2525
  )
2503
2526
  event: Event = event or Event()
2504
- foreach: Union[list[str], list[int]] = pass_env(
2527
+ foreach: Union[list[str], list[int], str] = pass_env(
2505
2528
  param2template(self.foreach, params, extras=self.extras)
2506
2529
  )
2507
2530
 
@@ -2516,9 +2539,10 @@ class ForEachStage(BaseNestedStage):
2516
2539
  ) from e
2517
2540
 
2518
2541
  # [VALIDATE]: Type of the foreach should be `list` type.
2519
- elif not isinstance(foreach, list):
2542
+ elif isinstance(foreach, dict):
2520
2543
  raise TypeError(
2521
- f"Does not support foreach: {foreach!r} ({type(foreach)})"
2544
+ f"Does not support dict foreach: {foreach!r} ({type(foreach)}) "
2545
+ f"yet."
2522
2546
  )
2523
2547
  # [Validate]: Value in the foreach item should not be duplicate when the
2524
2548
  # `use_index_as_key` field did not set.
@@ -32,6 +32,7 @@ Example:
32
32
  >>> trace = get_trace("running-id-101", parent_run_id="workflow-001")
33
33
  >>> trace.info("Workflow execution started")
34
34
  >>> trace.debug("Processing stage 1")
35
+
35
36
  """
36
37
  from __future__ import annotations
37
38
 
@@ -848,7 +849,7 @@ def get_trace(
848
849
  :rtype: Trace
849
850
  """
850
851
  # NOTE: Allow you to override trace model by the extra parameter.
851
- map_trace_models: dict[str, type[Trace]] = extras.get(
852
+ map_trace_models: dict[str, type[Trace]] = (extras or {}).get(
852
853
  "trace_model_mapping", {}
853
854
  )
854
855
  url: ParseResult
ddeutil/workflow/utils.py CHANGED
@@ -247,7 +247,7 @@ def default_gen_id() -> str:
247
247
 
248
248
  :rtype: str
249
249
  """
250
- return gen_id("manual", unique=True)
250
+ return gen_id("MOCK", unique=True)
251
251
 
252
252
 
253
253
  def make_exec(path: Union[Path, str]) -> None:
@@ -352,7 +352,7 @@ def dump_all(
352
352
 
353
353
  def obj_name(obj: Optional[Union[str, object]] = None) -> Optional[str]:
354
354
  if not obj:
355
- obj_type: Optional[str] = None
355
+ return None
356
356
  elif isinstance(obj, str):
357
357
  obj_type: str = obj
358
358
  elif isclass(obj):
@@ -145,7 +145,7 @@ class Workflow(BaseModel):
145
145
  description="A parameters that need to use on this workflow.",
146
146
  )
147
147
  on: Event = Field(
148
- default_factory=list,
148
+ default_factory=Event,
149
149
  description="An events for this workflow.",
150
150
  )
151
151
  jobs: dict[str, Job] = Field(
@@ -211,11 +211,6 @@ class Workflow(BaseModel):
211
211
  ```
212
212
  """
213
213
  load: YamlParser = YamlParser(name, path=path, extras=extras, obj=cls)
214
-
215
- # NOTE: Validate the config type match with current connection model
216
- if load.type != cls.__name__:
217
- raise ValueError(f"Type {load.type} does not match with {cls}")
218
-
219
214
  data: DictData = copy.deepcopy(load.data)
220
215
  data["name"] = name
221
216
 
@@ -289,6 +284,50 @@ class Workflow(BaseModel):
289
284
 
290
285
  return self
291
286
 
287
+ def detail(self) -> DictData: # pragma: no cov
288
+ """Return the detail of this workflow for generate markdown."""
289
+ return self.model_dump(by_alias=True)
290
+
291
+ def md(self, author: Optional[str] = None) -> str: # pragma: no cov
292
+ """Generate the markdown template."""
293
+
294
+ def align_newline(value: str) -> str:
295
+ return value.rstrip("\n").replace("\n", "\n ")
296
+
297
+ info: str = (
298
+ f"| Author: {author or 'nobody'} "
299
+ f"| created_at: `{self.created_at:%Y-%m-%d %H:%M:%S}` "
300
+ f"| updated_at: `{self.updated_dt:%Y-%m-%d %H:%M:%S}` |\n"
301
+ f"| --- | --- | --- |"
302
+ )
303
+ jobs: str = ""
304
+ for job in self.jobs:
305
+ job_model: Job = self.jobs[job]
306
+ jobs += f"### {job}\n{job_model.desc or ''}\n"
307
+ stags: str = ""
308
+ for stage_model in job_model.stages:
309
+ stags += (
310
+ f"#### {stage_model.name}\n\n"
311
+ f"Stage ID: {stage_model.id or ''}\n"
312
+ f"Stage Model: {stage_model.__class__.__name__}\n\n"
313
+ )
314
+ jobs += f"{stags}\n"
315
+ return dedent(
316
+ f"""
317
+ # Workflow: {self.name}\n
318
+ {align_newline(info)}\n
319
+ {align_newline(self.desc)}\n
320
+ ## Parameters\n
321
+ | name | type | default | description |
322
+ | --- | --- | --- | : --- : |
323
+
324
+ ## Jobs\n
325
+ {align_newline(jobs)}
326
+ """.lstrip(
327
+ "\n"
328
+ )
329
+ )
330
+
292
331
  def job(self, name: str) -> Job:
293
332
  """Return the workflow's Job model that getting by an input job's name
294
333
  or job's ID. This method will pass an extra parameter from this model
@@ -377,7 +416,7 @@ class Workflow(BaseModel):
377
416
  dt = dt.replace(tzinfo=UTC)
378
417
 
379
418
  release: datetime = replace_sec(dt.astimezone(UTC))
380
- if not self.on:
419
+ if not self.on.schedule:
381
420
  return release
382
421
 
383
422
  for on in self.on.schedule:
@@ -871,7 +910,7 @@ class Workflow(BaseModel):
871
910
  event: Optional[ThreadEvent] = None,
872
911
  timeout: float = 3600,
873
912
  max_job_parallel: int = 2,
874
- ) -> Result:
913
+ ) -> Result: # pragma: no cov
875
914
  """Re-Execute workflow with passing the error context data.
876
915
 
877
916
  :param context: A context result that get the failed status.
@@ -900,12 +939,9 @@ class Workflow(BaseModel):
900
939
  "[WORKFLOW]: Does not rerun because it already executed with "
901
940
  "success status."
902
941
  )
903
- return Result(
904
- run_id=run_id,
905
- parent_run_id=parent_run_id,
942
+ return Result.from_trace(trace).catch(
906
943
  status=SUCCESS,
907
944
  context=catch(context=context, status=SUCCESS),
908
- extras=self.extras,
909
945
  )
910
946
 
911
947
  err: dict[str, str] = context.get("errors", {})
@@ -921,12 +957,9 @@ class Workflow(BaseModel):
921
957
  )
922
958
  if not self.jobs:
923
959
  trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
924
- return Result(
925
- run_id=run_id,
926
- parent_run_id=parent_run_id,
960
+ return Result.from_trace(trace).catch(
927
961
  status=SUCCESS,
928
962
  context=catch(context=context, status=SUCCESS),
929
- extras=self.extras,
930
963
  )
931
964
 
932
965
  # NOTE: Prepare the new context variable for rerun process.
@@ -951,12 +984,9 @@ class Workflow(BaseModel):
951
984
  "[WORKFLOW]: It does not have job to rerun. it will change "
952
985
  "status to skip."
953
986
  )
954
- return Result(
955
- run_id=run_id,
956
- parent_run_id=parent_run_id,
987
+ return Result.from_trace(trace).catch(
957
988
  status=SKIP,
958
989
  context=catch(context=context, status=SKIP),
959
- extras=self.extras,
960
990
  )
961
991
 
962
992
  not_timeout_flag: bool = True
@@ -969,9 +999,7 @@ class Workflow(BaseModel):
969
999
 
970
1000
  catch(context, status=WAIT)
971
1001
  if event and event.is_set():
972
- return Result(
973
- run_id=run_id,
974
- parent_run_id=parent_run_id,
1002
+ return Result.from_trace(trace).catch(
975
1003
  status=CANCEL,
976
1004
  context=catch(
977
1005
  context,
@@ -983,7 +1011,6 @@ class Workflow(BaseModel):
983
1011
  ).to_dict(),
984
1012
  },
985
1013
  ),
986
- extras=self.extras,
987
1014
  )
988
1015
 
989
1016
  with ThreadPoolExecutor(max_job_parallel, "wf") as executor:
@@ -1011,9 +1038,7 @@ class Workflow(BaseModel):
1011
1038
  backoff_sleep = 0.01
1012
1039
 
1013
1040
  if check == FAILED: # pragma: no cov
1014
- return Result(
1015
- run_id=run_id,
1016
- parent_run_id=parent_run_id,
1041
+ return Result.from_trace(trace).catch(
1017
1042
  status=FAILED,
1018
1043
  context=catch(
1019
1044
  context,
@@ -1026,7 +1051,6 @@ class Workflow(BaseModel):
1026
1051
  ).to_dict(),
1027
1052
  },
1028
1053
  ),
1029
- extras=self.extras,
1030
1054
  )
1031
1055
  elif check == SKIP: # pragma: no cov
1032
1056
  trace.info(
@@ -1102,12 +1126,9 @@ class Workflow(BaseModel):
1102
1126
  statuses[total + 1 + skip_count + i] = s
1103
1127
 
1104
1128
  st: Status = validate_statuses(statuses)
1105
- return Result(
1106
- run_id=run_id,
1107
- parent_run_id=parent_run_id,
1129
+ return Result.from_trace(trace).catch(
1108
1130
  status=st,
1109
1131
  context=catch(context, status=st),
1110
- extras=self.extras,
1111
1132
  )
1112
1133
 
1113
1134
  event.set()
@@ -1121,9 +1142,7 @@ class Workflow(BaseModel):
1121
1142
 
1122
1143
  time.sleep(0.0025)
1123
1144
 
1124
- return Result(
1125
- run_id=run_id,
1126
- parent_run_id=parent_run_id,
1145
+ return Result.from_trace(trace).catch(
1127
1146
  status=FAILED,
1128
1147
  context=catch(
1129
1148
  context,
@@ -1135,5 +1154,4 @@ class Workflow(BaseModel):
1135
1154
  ).to_dict(),
1136
1155
  },
1137
1156
  ),
1138
- extras=self.extras,
1139
1157
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.77
3
+ Version: 0.0.78
4
4
  Summary: Lightweight workflow orchestration with YAML template
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -147,7 +147,6 @@ For comprehensive API documentation, examples, and best practices:
147
147
  - **[Full Documentation](https://ddeutils.github.io/ddeutil-workflow/)** - Complete user guide and API reference
148
148
  - **[Getting Started](https://ddeutils.github.io/ddeutil-workflow/getting-started/)** - Quick start guide
149
149
  - **[API Reference](https://ddeutils.github.io/ddeutil-workflow/api/workflow/)** - Detailed API documentation
150
- - **[Examples](https://ddeutils.github.io/ddeutil-workflow/examples/)** - Real-world usage examples
151
150
 
152
151
  ## 🎯 Usage
153
152
 
@@ -0,0 +1,30 @@
1
+ ddeutil/workflow/__about__.py,sha256=9g_DJubkaHxt4W_0-r1PmUT5x3bCo_72c-f60EDtlq8,28
2
+ ddeutil/workflow/__cron.py,sha256=avOagaHl9xXOmizeRWm13cOrty9Tw0vRjFq-xoEgpAY,29167
3
+ ddeutil/workflow/__init__.py,sha256=_8sP-CTPOfwsFFhmdwQ2Gp7yY7qJemP7TYsIWgd5jc0,3300
4
+ ddeutil/workflow/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
5
+ ddeutil/workflow/__types.py,sha256=tA2vsr6mzTSzbWB1sb62c5GgxODlfVRz6FvgLNJtQao,4788
6
+ ddeutil/workflow/audits.py,sha256=wANG0jEQ7slUSgVZG4JbjlR5PtmF8mHpM9RH-zpYM_g,12679
7
+ ddeutil/workflow/cli.py,sha256=WnUkxqs2hCc5JVuTvWuEGKp8_EcS_wmhVvSXDhj0eEM,7544
8
+ ddeutil/workflow/conf.py,sha256=7yMSBVh2W9KcDOZxQjeabJZizB_3ydCLICo4JI0syWU,16892
9
+ ddeutil/workflow/errors.py,sha256=UpUIqoyqkvzqjuxtUQ9535l1HeAsyh-plEG0PgDVR2w,5541
10
+ ddeutil/workflow/event.py,sha256=6d5_UvnPI8xRLcX_5wptmvWoXUIGs_JZbjq7khz5oYE,13355
11
+ ddeutil/workflow/job.py,sha256=4_IxtoIwl1qVuSaNngelYNkqQl9ZOa-PHdKCnXvXu0M,43943
12
+ ddeutil/workflow/params.py,sha256=Cyz142OcvENIZrM7Efc2xuGPmmFBhROifP5ojoaCezg,13658
13
+ ddeutil/workflow/result.py,sha256=Xi07E3WuMHS1jLcJg7p4DPuaMFGp0yEDaWCJRotOH6g,8921
14
+ ddeutil/workflow/reusables.py,sha256=pbCHsEl2V3jGWDRcGyxDvGN5rP5kaRxZNgv9x6-pINQ,23338
15
+ ddeutil/workflow/stages.py,sha256=ocsk64duS4BHEOLT3Agkx-fbY6iYmygxCaQhwp6YyyM,122482
16
+ ddeutil/workflow/traces.py,sha256=h7oDlb4Q8LJUp0pste2dWJYOqEaN64KsLTNMfeRUqx8,28475
17
+ ddeutil/workflow/utils.py,sha256=vSGdpaFgQ5vUPxWvVfbNC2__tu5q16B9mhx1BRbEuJo,10968
18
+ ddeutil/workflow/workflow.py,sha256=0KaRCTAipPvvmi9SOC7T0V0CAusgebCM6qju0ethGF0,42409
19
+ ddeutil/workflow/api/__init__.py,sha256=5DzYL3ngceoRshh5HYCSVWChqNJSiP01E1bEd8XxPi0,4799
20
+ ddeutil/workflow/api/log_conf.py,sha256=WfS3udDLSyrP-C80lWOvxxmhd_XWKvQPkwDqKblcH3E,1834
21
+ ddeutil/workflow/api/routes/__init__.py,sha256=JRaJZB0D6mgR17MbZo8yLtdYDtD62AA8MdKlFqhG84M,420
22
+ ddeutil/workflow/api/routes/job.py,sha256=-lbZ_hS9pEdSy6zeke5qrXEgdNxtQ2w9in7cHuM2Jzs,2536
23
+ ddeutil/workflow/api/routes/logs.py,sha256=RiZ62eQVMWArPHE3lpan955U4DdLLkethlvSMlwF7Mg,5312
24
+ ddeutil/workflow/api/routes/workflows.py,sha256=1Mqx4Hft4uJglgJI-Wcw-JzkhomFYZrtP0DnQDBkAFQ,4410
25
+ ddeutil_workflow-0.0.78.dist-info/licenses/LICENSE,sha256=nGFZ1QEhhhWeMHf9n99_fdt4vQaXS29xWKxt-OcLywk,1085
26
+ ddeutil_workflow-0.0.78.dist-info/METADATA,sha256=2phJ3JRH9o0Sbr22iOo1YeQBr8nyYx6areM2rwNp_Eg,15681
27
+ ddeutil_workflow-0.0.78.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
+ ddeutil_workflow-0.0.78.dist-info/entry_points.txt,sha256=qDTpPSauL0ciO6T4iSVt8bJeYrVEkkoEEw_RlGx6Kgk,63
29
+ ddeutil_workflow-0.0.78.dist-info/top_level.txt,sha256=m9M6XeSWDwt_yMsmH6gcOjHZVK5O0-vgtNBuncHjzW4,8
30
+ ddeutil_workflow-0.0.78.dist-info/RECORD,,
@@ -1,30 +0,0 @@
1
- ddeutil/workflow/__about__.py,sha256=aU8Cs3gP8PwasjEnV2TWve1NsqO6d4yk5KpYOnhSHz0,28
2
- ddeutil/workflow/__cron.py,sha256=avOagaHl9xXOmizeRWm13cOrty9Tw0vRjFq-xoEgpAY,29167
3
- ddeutil/workflow/__init__.py,sha256=_8sP-CTPOfwsFFhmdwQ2Gp7yY7qJemP7TYsIWgd5jc0,3300
4
- ddeutil/workflow/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
5
- ddeutil/workflow/__types.py,sha256=tA2vsr6mzTSzbWB1sb62c5GgxODlfVRz6FvgLNJtQao,4788
6
- ddeutil/workflow/audits.py,sha256=wANG0jEQ7slUSgVZG4JbjlR5PtmF8mHpM9RH-zpYM_g,12679
7
- ddeutil/workflow/cli.py,sha256=eAwRZMSEJu-NONc_un0D_1swFlENMjl3C-iXYnyTTPY,7411
8
- ddeutil/workflow/conf.py,sha256=CL_LFJyrocNCSGN9NwErVAtN_JdetcBqeYIJ9x7e2nE,16796
9
- ddeutil/workflow/errors.py,sha256=tHS6ekxBmZ6sIeLaxHHSaMfhVvlWnndfb2-Aq-bL2So,5509
10
- ddeutil/workflow/event.py,sha256=siChcBhsu4ejzW1fK0tjHPXQVaSUCSxPYDgDrh6duwo,13676
11
- ddeutil/workflow/job.py,sha256=_NOPWPs2FuiMvNE-L6c9mpXEChXmgQ8zmD33ZzqVi0A,44146
12
- ddeutil/workflow/params.py,sha256=Cyz142OcvENIZrM7Efc2xuGPmmFBhROifP5ojoaCezg,13658
13
- ddeutil/workflow/result.py,sha256=Fz6y6apivLW-94gAxcT42z-mGqWMk6-O3RJ2GGSNUHM,9146
14
- ddeutil/workflow/reusables.py,sha256=3_TV3lpwzqW2lnBJbgt9MkPXk8lFvp2NhYSCdjyOQI8,23338
15
- ddeutil/workflow/stages.py,sha256=KchpcPSgrkvPHhpF1YYNOclk1nhdpLL-AG1M71G6QV8,121972
16
- ddeutil/workflow/traces.py,sha256=0n6Mytp6oeNjOV8lIsFitzZ6TrtuSNVFkUmodBiE_vA,28466
17
- ddeutil/workflow/utils.py,sha256=N8dVsBYOBVXdFOlUETo7zPFefqp3w0XK1940s7k7iOE,10989
18
- ddeutil/workflow/workflow.py,sha256=iQ9z6eOoj-66w6p8wGu28doQGGnL569BcjI3dTK561o,41616
19
- ddeutil/workflow/api/__init__.py,sha256=5DzYL3ngceoRshh5HYCSVWChqNJSiP01E1bEd8XxPi0,4799
20
- ddeutil/workflow/api/log_conf.py,sha256=WfS3udDLSyrP-C80lWOvxxmhd_XWKvQPkwDqKblcH3E,1834
21
- ddeutil/workflow/api/routes/__init__.py,sha256=JRaJZB0D6mgR17MbZo8yLtdYDtD62AA8MdKlFqhG84M,420
22
- ddeutil/workflow/api/routes/job.py,sha256=-lbZ_hS9pEdSy6zeke5qrXEgdNxtQ2w9in7cHuM2Jzs,2536
23
- ddeutil/workflow/api/routes/logs.py,sha256=RiZ62eQVMWArPHE3lpan955U4DdLLkethlvSMlwF7Mg,5312
24
- ddeutil/workflow/api/routes/workflows.py,sha256=1Mqx4Hft4uJglgJI-Wcw-JzkhomFYZrtP0DnQDBkAFQ,4410
25
- ddeutil_workflow-0.0.77.dist-info/licenses/LICENSE,sha256=nGFZ1QEhhhWeMHf9n99_fdt4vQaXS29xWKxt-OcLywk,1085
26
- ddeutil_workflow-0.0.77.dist-info/METADATA,sha256=hplZltt-c1l8T6x2nJidJ-2U79x-6MhS5c5hXws1oGo,15781
27
- ddeutil_workflow-0.0.77.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
- ddeutil_workflow-0.0.77.dist-info/entry_points.txt,sha256=qDTpPSauL0ciO6T4iSVt8bJeYrVEkkoEEw_RlGx6Kgk,63
29
- ddeutil_workflow-0.0.77.dist-info/top_level.txt,sha256=m9M6XeSWDwt_yMsmH6gcOjHZVK5O0-vgtNBuncHjzW4,8
30
- ddeutil_workflow-0.0.77.dist-info/RECORD,,