ddeutil-workflow 0.0.82__tar.gz → 0.0.84__tar.gz

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.
Files changed (64) hide show
  1. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/PKG-INFO +1 -1
  2. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/__about__.py +1 -1
  3. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/__cron.py +1 -1
  4. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/__init__.py +3 -2
  5. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/__types.py +10 -1
  6. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/audits.py +64 -41
  7. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/errors.py +3 -0
  8. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/event.py +34 -11
  9. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/job.py +5 -15
  10. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/result.py +41 -12
  11. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/stages.py +825 -333
  12. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/traces.py +9 -5
  13. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/utils.py +45 -20
  14. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/workflow.py +71 -80
  15. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil_workflow.egg-info/PKG-INFO +1 -1
  16. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_audits.py +18 -15
  17. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_job.py +7 -1
  18. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_job_exec.py +1 -1
  19. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_result.py +1 -1
  20. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_workflow_exec.py +24 -5
  21. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_workflow_release.py +58 -17
  22. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/LICENSE +0 -0
  23. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/README.md +0 -0
  24. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/pyproject.toml +0 -0
  25. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/setup.cfg +0 -0
  26. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/__main__.py +0 -0
  27. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/api/__init__.py +0 -0
  28. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/api/log_conf.py +0 -0
  29. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/api/routes/__init__.py +0 -0
  30. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/api/routes/job.py +0 -0
  31. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/api/routes/logs.py +0 -0
  32. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/api/routes/workflows.py +0 -0
  33. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/conf.py +0 -0
  34. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/params.py +0 -0
  35. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/plugins/__init__.py +0 -0
  36. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/plugins/providers/__init__.py +0 -0
  37. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/plugins/providers/aws.py +0 -0
  38. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/plugins/providers/az.py +0 -0
  39. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/plugins/providers/container.py +0 -0
  40. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/plugins/providers/gcs.py +0 -0
  41. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil/workflow/reusables.py +0 -0
  42. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil_workflow.egg-info/SOURCES.txt +0 -0
  43. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil_workflow.egg-info/dependency_links.txt +0 -0
  44. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil_workflow.egg-info/entry_points.txt +0 -0
  45. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil_workflow.egg-info/requires.txt +0 -0
  46. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/src/ddeutil_workflow.egg-info/top_level.txt +0 -0
  47. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test__cron.py +0 -0
  48. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test__regex.py +0 -0
  49. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_cli.py +0 -0
  50. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_conf.py +0 -0
  51. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_errors.py +0 -0
  52. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_event.py +0 -0
  53. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_job_exec_strategy.py +0 -0
  54. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_params.py +0 -0
  55. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_reusables_call_tag.py +0 -0
  56. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_reusables_func_model.py +0 -0
  57. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_reusables_template.py +0 -0
  58. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_reusables_template_filter.py +0 -0
  59. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_strategy.py +0 -0
  60. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_traces.py +0 -0
  61. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_utils.py +0 -0
  62. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_workflow.py +0 -0
  63. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_workflow_exec_job.py +0 -0
  64. {ddeutil_workflow-0.0.82 → ddeutil_workflow-0.0.84}/tests/test_workflow_rerun.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.82
3
+ Version: 0.0.84
4
4
  Summary: Lightweight workflow orchestration with YAML template
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
- __version__: str = "0.0.82"
1
+ __version__: str = "0.0.84"
2
2
  __python_version__: str = "3.9"
@@ -715,7 +715,7 @@ class CronJob:
715
715
  self,
716
716
  date: Optional[datetime] = None,
717
717
  *,
718
- tz: Optional[str] = None,
718
+ tz: Optional[Union[str, ZoneInfo]] = None,
719
719
  ) -> CronRunner:
720
720
  """Returns CronRunner instance that be datetime runner with this
721
721
  cronjob. It can use `next`, `prev`, or `reset` methods to generate
@@ -50,12 +50,12 @@ Note:
50
50
  from .__cron import CronRunner
51
51
  from .__types import DictData, DictStr, Matrix, Re, TupleStr
52
52
  from .audits import (
53
- EVENT,
53
+ DRYRUN,
54
54
  FORCE,
55
55
  NORMAL,
56
56
  RERUN,
57
57
  Audit,
58
- FileAudit,
58
+ LocalFileAudit,
59
59
  get_audit,
60
60
  )
61
61
  from .conf import (
@@ -71,6 +71,7 @@ from .conf import (
71
71
  )
72
72
  from .errors import (
73
73
  BaseError,
74
+ EventError,
74
75
  JobCancelError,
75
76
  JobError,
76
77
  JobSkipError,
@@ -16,17 +16,26 @@ from re import (
16
16
  Match,
17
17
  Pattern,
18
18
  )
19
- from typing import Any, Optional, TypedDict, Union
19
+ from typing import Any, Optional, TypedDict, Union, cast
20
20
 
21
21
  from typing_extensions import Self
22
22
 
23
23
  StrOrNone = Optional[str]
24
24
  StrOrInt = Union[str, int]
25
25
  TupleStr = tuple[str, ...]
26
+ ListStr = list[str]
27
+ ListInt = list[int]
26
28
  DictData = dict[str, Any]
29
+ DictRange = dict[int, Any]
27
30
  DictStr = dict[str, str]
28
31
  Matrix = dict[str, Union[list[str], list[int]]]
29
32
 
33
+
34
+ def cast_dict(value: TypedDict[...]) -> DictData:
35
+ """Cast any TypedDict object to DictData type."""
36
+ return cast(DictData, value)
37
+
38
+
30
39
  # Pre-compile regex patterns for better performance
31
40
  _RE_CALLER_PATTERN = r"""
32
41
  \$ # start with $
@@ -74,31 +74,38 @@ class ReleaseType(str, Enum):
74
74
  Attributes:
75
75
  NORMAL: Standard workflow release execution
76
76
  RERUN: Re-execution of previously failed workflow
77
- EVENT: Event-triggered workflow execution
77
+ DRYRUN: Dry-execution workflow
78
78
  FORCE: Forced execution bypassing normal conditions
79
79
  """
80
80
 
81
81
  NORMAL = "normal"
82
82
  RERUN = "rerun"
83
- EVENT = "event"
84
83
  FORCE = "force"
84
+ DRYRUN = "dryrun"
85
85
 
86
86
 
87
87
  NORMAL = ReleaseType.NORMAL
88
88
  RERUN = ReleaseType.RERUN
89
- EVENT = ReleaseType.EVENT
89
+ DRYRUN = ReleaseType.DRYRUN
90
90
  FORCE = ReleaseType.FORCE
91
91
 
92
92
 
93
93
  class AuditData(BaseModel):
94
- """Audit Data model."""
94
+ """Audit Data model that use to be the core data for any Audit model manage
95
+ logging at the target pointer system or service like file-system, sqlite
96
+ database, etc.
97
+ """
95
98
 
96
99
  model_config = ConfigDict(use_enum_values=True)
97
100
 
98
101
  name: str = Field(description="A workflow name.")
99
102
  release: datetime = Field(description="A release datetime.")
100
103
  type: ReleaseType = Field(
101
- default=NORMAL, description="A running type before logging."
104
+ default=NORMAL,
105
+ description=(
106
+ "An execution type that should be value in ('normal', 'rerun', "
107
+ "'force', 'dryrun')."
108
+ ),
102
109
  )
103
110
  context: DictData = Field(
104
111
  default_factory=dict,
@@ -121,18 +128,17 @@ class BaseAudit(BaseModel, ABC):
121
128
  for logging subclasses like file, sqlite, etc.
122
129
  """
123
130
 
124
- type: str
131
+ type: Literal["base"] = "base"
132
+ logging_name: str = "ddeutil.workflow"
125
133
  extras: DictData = Field(
126
134
  default_factory=dict,
127
135
  description="An extras parameter that want to override core config",
128
136
  )
129
137
 
130
138
  @field_validator("extras", mode="before")
131
- def validate_extras(cls, v: Any) -> DictData:
139
+ def __prepare_extras(cls, v: Any) -> Any:
132
140
  """Validate extras field to ensure it's a dictionary."""
133
- if v is None:
134
- return {}
135
- return v
141
+ return {} if v is None else v
136
142
 
137
143
  @model_validator(mode="after")
138
144
  def __model_action(self) -> Self:
@@ -148,7 +154,7 @@ class BaseAudit(BaseModel, ABC):
148
154
  self.do_before()
149
155
 
150
156
  # NOTE: Start setting log config in this line with cache.
151
- set_logging("ddeutil.workflow")
157
+ set_logging(self.logging_name)
152
158
  return self
153
159
 
154
160
  @abstractmethod
@@ -248,7 +254,7 @@ class BaseAudit(BaseModel, ABC):
248
254
  raise NotImplementedError("Audit should implement `save` method.")
249
255
 
250
256
 
251
- class FileAudit(BaseAudit):
257
+ class LocalFileAudit(BaseAudit):
252
258
  """File Audit Pydantic Model for saving log data from workflow execution.
253
259
 
254
260
  This class inherits from BaseAudit and implements file-based storage
@@ -256,19 +262,25 @@ class FileAudit(BaseAudit):
256
262
  in a structured directory hierarchy.
257
263
 
258
264
  Attributes:
259
- filename_fmt: Class variable defining the filename format for audit files.
265
+ file_fmt: Class variable defining the filename format for audit log.
266
+ file_release_fmt: Class variable defining the filename format for audit
267
+ release log.
260
268
  """
261
269
 
262
- filename_fmt: ClassVar[str] = (
263
- "workflow={name}/release={release:%Y%m%d%H%M%S}"
264
- )
270
+ file_fmt: ClassVar[str] = "workflow={name}"
271
+ file_release_fmt: ClassVar[str] = "release={release:%Y%m%d%H%M%S}"
265
272
 
266
273
  type: Literal["file"] = "file"
267
- path: str = Field(
268
- default="./audits",
274
+ path: Path = Field(
275
+ default=Path("./audits"),
269
276
  description="A file path that use to manage audit logs.",
270
277
  )
271
278
 
279
+ @field_validator("path", mode="before", json_schema_input_type=str)
280
+ def __prepare_path(cls, data: Any) -> Any:
281
+ """Prepare path that passing with string to Path instance."""
282
+ return Path(data) if isinstance(data, str) else data
283
+
272
284
  def do_before(self) -> None:
273
285
  """Create directory of release before saving log file.
274
286
 
@@ -278,7 +290,10 @@ class FileAudit(BaseAudit):
278
290
  Path(self.path).mkdir(parents=True, exist_ok=True)
279
291
 
280
292
  def find_audits(
281
- self, name: str, *, extras: Optional[DictData] = None
293
+ self,
294
+ name: str,
295
+ *,
296
+ extras: Optional[DictData] = None,
282
297
  ) -> Iterator[AuditData]:
283
298
  """Generate audit data found from logs path for a specific workflow name.
284
299
 
@@ -292,7 +307,7 @@ class FileAudit(BaseAudit):
292
307
  Raises:
293
308
  FileNotFoundError: If the workflow directory does not exist.
294
309
  """
295
- pointer: Path = Path(self.path) / f"workflow={name}"
310
+ pointer: Path = self.path / self.file_fmt.format(name=name)
296
311
  if not pointer.exists():
297
312
  raise FileNotFoundError(f"Pointer: {pointer.absolute()}.")
298
313
 
@@ -325,7 +340,7 @@ class FileAudit(BaseAudit):
325
340
  ValueError: If no releases found when release is None.
326
341
  """
327
342
  if release is None:
328
- pointer: Path = Path(self.path) / f"workflow={name}"
343
+ pointer: Path = self.path / self.file_fmt.format(name=name)
329
344
  if not pointer.exists():
330
345
  raise FileNotFoundError(f"Pointer: {pointer.absolute()}.")
331
346
 
@@ -382,8 +397,10 @@ class FileAudit(BaseAudit):
382
397
  Returns:
383
398
  Path: The directory path for the current workflow and release.
384
399
  """
385
- return Path(self.path) / self.filename_fmt.format(
386
- name=data.name, release=data.release
400
+ return (
401
+ self.path
402
+ / self.file_fmt.format(**data.model_dump(by_alias=True))
403
+ / self.file_release_fmt.format(**data.model_dump(by_alias=True))
387
404
  )
388
405
 
389
406
  def save(self, data: Any, excluded: Optional[list[str]] = None) -> Self:
@@ -459,7 +476,7 @@ class FileAudit(BaseAudit):
459
476
  return cleaned_count
460
477
 
461
478
 
462
- class SQLiteAudit(BaseAudit): # pragma: no cov
479
+ class LocalSQLiteAudit(BaseAudit): # pragma: no cov
463
480
  """SQLite Audit model for database-based audit storage.
464
481
 
465
482
  This class inherits from BaseAudit and implements SQLite database storage
@@ -467,11 +484,11 @@ class SQLiteAudit(BaseAudit): # pragma: no cov
467
484
 
468
485
  Attributes:
469
486
  table_name: Class variable defining the database table name.
470
- schemas: Class variable defining the database schema.
487
+ ddl: Class variable defining the database schema.
471
488
  """
472
489
 
473
490
  table_name: ClassVar[str] = "audits"
474
- schemas: ClassVar[
491
+ ddl: ClassVar[
475
492
  str
476
493
  ] = """
477
494
  CREATE TABLE IF NOT EXISTS audits (
@@ -489,22 +506,21 @@ class SQLiteAudit(BaseAudit): # pragma: no cov
489
506
  """
490
507
 
491
508
  type: Literal["sqlite"] = "sqlite"
492
- path: str
509
+ path: Path = Field(
510
+ default=Path("./audits.db"),
511
+ description="A SQLite filepath.",
512
+ )
493
513
 
494
- def _ensure_table_exists(self) -> None:
514
+ def do_before(self) -> None:
495
515
  """Ensure the audit table exists in the database."""
496
- audit_url = dynamic("audit_url", extras=self.extras)
497
- if audit_url is None or not audit_url.path:
516
+ if self.path.is_dir():
498
517
  raise ValueError(
499
- "SQLite audit_url must specify a database file path"
518
+ "SQLite path must specify a database file path not dir."
500
519
  )
501
520
 
502
- audit_url_parse: ParseResult = urlparse(audit_url)
503
- db_path = Path(audit_url_parse.path)
504
- db_path.parent.mkdir(parents=True, exist_ok=True)
505
-
506
- with sqlite3.connect(db_path) as conn:
507
- conn.execute(self.schemas)
521
+ self.path.parent.mkdir(parents=True, exist_ok=True)
522
+ with sqlite3.connect(self.path) as conn:
523
+ conn.execute(self.ddl)
508
524
  conn.commit()
509
525
 
510
526
  def is_pointed(
@@ -771,24 +787,31 @@ class SQLiteAudit(BaseAudit): # pragma: no cov
771
787
  return cursor.rowcount
772
788
 
773
789
 
790
+ class PostgresAudit(BaseAudit, ABC): ... # pragma: no cov
791
+
792
+
774
793
  Audit = Annotated[
775
794
  Union[
776
- FileAudit,
777
- SQLiteAudit,
795
+ LocalFileAudit,
796
+ LocalSQLiteAudit,
778
797
  ],
779
798
  Field(discriminator="type"),
780
799
  ]
781
800
 
782
801
 
783
- def get_audit(extras: Optional[DictData] = None) -> Audit: # pragma: no cov
802
+ def get_audit(
803
+ audit_conf: Optional[DictData] = None,
804
+ extras: Optional[DictData] = None,
805
+ ) -> Audit: # pragma: no cov
784
806
  """Get an audit model dynamically based on the config audit path value.
785
807
 
786
808
  Args:
809
+ audit_conf (DictData):
787
810
  extras: Optional extra parameters to override the core config.
788
811
 
789
812
  Returns:
790
813
  Audit: The appropriate audit model class based on configuration.
791
814
  """
792
- audit_conf = dynamic("audit_conf", extras=extras)
815
+ audit_conf = dynamic("audit_conf", f=audit_conf, extras=extras)
793
816
  model = TypeAdapter(Audit).validate_python(audit_conf | {"extras": extras})
794
817
  return model
@@ -184,6 +184,9 @@ class JobCancelError(JobError): ...
184
184
  class JobSkipError(JobError): ...
185
185
 
186
186
 
187
+ class EventError(BaseError): ...
188
+
189
+
187
190
  class WorkflowError(BaseError): ...
188
191
 
189
192
 
@@ -16,13 +16,9 @@ Attributes:
16
16
  Interval: Type alias for scheduling intervals ('daily', 'weekly', 'monthly')
17
17
 
18
18
  Classes:
19
+ CrontabValue:
19
20
  Crontab: Main cron-based event scheduler.
20
21
  CrontabYear: Enhanced cron scheduler with year constraints.
21
- ReleaseEvent: Release-based event triggers.
22
- FileEvent: File system monitoring triggers.
23
- WebhookEvent: API/webhook-based triggers.
24
- DatabaseEvent: Database change monitoring triggers.
25
- SensorEvent: Sensor-based event monitoring.
26
22
 
27
23
  Example:
28
24
  >>> from ddeutil.workflow.event import Crontab
@@ -50,6 +46,8 @@ from pydantic_extra_types.timezone_name import TimeZoneName
50
46
 
51
47
  from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner, Options
52
48
  from .__types import DictData
49
+ from .errors import EventError
50
+ from .utils import UTC, replace_sec
53
51
 
54
52
  Interval = Literal["daily", "weekly", "monthly"]
55
53
 
@@ -393,19 +391,16 @@ class Event(BaseModel):
393
391
  )
394
392
 
395
393
  @field_validator("schedule", mode="after")
396
- def __on_no_dup_and_reach_limit__(
397
- cls,
398
- value: list[Crontab],
399
- ) -> list[Crontab]:
394
+ def __prepare_schedule__(cls, value: list[Crontab]) -> list[Crontab]:
400
395
  """Validate the on fields should not contain duplicate values and if it
401
396
  contains the every minute value more than one value, it will remove to
402
397
  only one value.
403
398
 
404
399
  Args:
405
- value: A list of on object.
400
+ value (list[Crontab]): A list of on object.
406
401
 
407
402
  Returns:
408
- list[CronJobYear | Crontab]: The validated list of Crontab objects.
403
+ list[Crontab]: The validated list of Crontab objects.
409
404
 
410
405
  Raises:
411
406
  ValueError: If it has some duplicate value.
@@ -434,3 +429,31 @@ class Event(BaseModel):
434
429
  "The number of the on should not more than 10 crontabs."
435
430
  )
436
431
  return value
432
+
433
+ def validate_dt(self, dt: datetime) -> datetime:
434
+ """Validate the release datetime that should was replaced second and
435
+ millisecond to 0 and replaced timezone to None before checking it match
436
+ with the set `on` field.
437
+
438
+ Args:
439
+ dt (datetime): A datetime object that want to validate.
440
+
441
+ Returns:
442
+ datetime: The validated release datetime.
443
+ """
444
+ if dt.tzinfo is None:
445
+ dt = dt.replace(tzinfo=UTC)
446
+
447
+ release: datetime = replace_sec(dt.astimezone(UTC))
448
+
449
+ # NOTE: Return itself if schedule event does not set.
450
+ if not self.schedule:
451
+ return release
452
+
453
+ for on in self.schedule:
454
+ if release == on.cronjob.schedule(release, tz=UTC).next:
455
+ return release
456
+ raise EventError(
457
+ f"This datetime, {datetime}, does not support for this event "
458
+ f"schedule."
459
+ )
@@ -73,7 +73,7 @@ from .result import (
73
73
  from .reusables import has_template, param2template
74
74
  from .stages import Stage
75
75
  from .traces import Trace, get_trace
76
- from .utils import cross_product, filter_func, gen_id
76
+ from .utils import cross_product, extract_id, filter_func, gen_id
77
77
 
78
78
  MatrixFilter = list[dict[str, Union[str, int]]]
79
79
 
@@ -890,8 +890,9 @@ class Job(BaseModel):
890
890
  Result: Return Result object that create from execution context.
891
891
  """
892
892
  ts: float = time.monotonic()
893
- parent_run_id: str = run_id
894
- run_id: str = gen_id((self.id or "EMPTY"), unique=True)
893
+ parent_run_id, run_id = extract_id(
894
+ (self.id or "EMPTY"), run_id=run_id, extras=self.extras
895
+ )
895
896
  trace: Trace = get_trace(
896
897
  run_id, parent_run_id=parent_run_id, extras=self.extras
897
898
  )
@@ -1272,7 +1273,6 @@ def local_execute(
1272
1273
 
1273
1274
  errors: DictData = {}
1274
1275
  statuses: list[Status] = [WAIT] * len_strategy
1275
- fail_fast: bool = False
1276
1276
 
1277
1277
  if not job.strategy.fail_fast:
1278
1278
  done: Iterator[Future] = as_completed(futures)
@@ -1297,7 +1297,6 @@ def local_execute(
1297
1297
  )
1298
1298
  trace.debug(f"[JOB]: ... Job was set Fail-Fast{nd}")
1299
1299
  done: Iterator[Future] = as_completed(futures)
1300
- fail_fast: bool = True
1301
1300
 
1302
1301
  for i, future in enumerate(done, start=0):
1303
1302
  try:
@@ -1312,19 +1311,10 @@ def local_execute(
1312
1311
  pass
1313
1312
 
1314
1313
  status: Status = validate_statuses(statuses)
1315
-
1316
- # NOTE: Prepare status because it does not cancel from parent event but
1317
- # cancel from failed item execution.
1318
- if fail_fast and status == CANCEL:
1319
- status = FAILED
1320
-
1321
- return Result(
1322
- run_id=run_id,
1323
- parent_run_id=parent_run_id,
1314
+ return Result.from_trace(trace).catch(
1324
1315
  status=status,
1325
1316
  context=catch(context, status=status, updated=errors),
1326
1317
  info={"execution_time": time.monotonic() - ts},
1327
- extras=job.extras,
1328
1318
  )
1329
1319
 
1330
1320
 
@@ -8,20 +8,12 @@
8
8
  This module provides the core result and status management functionality for
9
9
  workflow execution tracking. It includes the Status enumeration for execution
10
10
  states and the Result dataclass for context transfer between workflow components.
11
-
12
- Classes:
13
- Status: Enumeration for execution status tracking
14
- Result: Dataclass for execution context and result management
15
-
16
- Functions:
17
- validate_statuses: Determine final status from multiple status values
18
- get_status_from_error: Convert exception types to appropriate status
19
11
  """
20
12
  from __future__ import annotations
21
13
 
22
14
  from dataclasses import field
23
15
  from enum import Enum
24
- from typing import Optional, TypedDict, Union
16
+ from typing import Any, Optional, TypedDict, Union
25
17
 
26
18
  from pydantic import ConfigDict
27
19
  from pydantic.dataclasses import dataclass
@@ -126,10 +118,10 @@ def validate_statuses(statuses: list[Status]) -> Status:
126
118
  >>> validate_statuses([SUCCESS, SUCCESS, SUCCESS])
127
119
  >>> # Returns: SUCCESS
128
120
  """
129
- if any(s == CANCEL for s in statuses):
130
- return CANCEL
131
- elif any(s == FAILED for s in statuses):
121
+ if any(s == FAILED for s in statuses):
132
122
  return FAILED
123
+ elif any(s == CANCEL for s in statuses):
124
+ return CANCEL
133
125
  elif any(s == WAIT for s in statuses):
134
126
  return WAIT
135
127
  for status in (SUCCESS, SKIP):
@@ -313,3 +305,40 @@ class Context(TypedDict):
313
305
  context: NotRequired[DictData]
314
306
  errors: NotRequired[Union[list[ErrorData], ErrorData]]
315
307
  info: NotRequired[DictData]
308
+
309
+
310
+ class Layer(str, Enum):
311
+ WORKFLOW = "workflow"
312
+ JOB = "job"
313
+ STRATEGY = "strategy"
314
+ STAGE = "stage"
315
+
316
+
317
+ def get_context_by_layer(
318
+ context: DictData,
319
+ key: str,
320
+ layer: Layer,
321
+ context_key: str,
322
+ *,
323
+ default: Optional[Any] = None,
324
+ ) -> Any: # pragma: no cov
325
+ if layer == Layer.WORKFLOW:
326
+ return context.get("jobs", {}).get(key, {}).get(context_key, default)
327
+ elif layer == Layer.JOB:
328
+ return context.get("stages", {}).get(key, {}).get(context_key, default)
329
+ elif layer == Layer.STRATEGY:
330
+ return (
331
+ context.get("strategies", {}).get(key, {}).get(context_key, default)
332
+ )
333
+ return context.get(key, {}).get(context_key, default)
334
+
335
+
336
+ def get_status(
337
+ context: DictData,
338
+ key: str,
339
+ layer: Layer,
340
+ ) -> Status: # pragma: no cov
341
+ """Get status from context by a specific key and context layer."""
342
+ return get_context_by_layer(
343
+ context, key, layer, context_key="status", default=WAIT
344
+ )