ddeutil-workflow 0.0.73__py3-none-any.whl → 0.0.75__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/event.py CHANGED
@@ -3,10 +3,36 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
- """An Event module keep all triggerable object to the Workflow model. The simple
7
- event trigger that use to run workflow is `Crontab` model.
8
- Now, it has only `Crontab` and `CrontabYear` event models in this module because
9
- I think it is the core event for workflow orchestration.
6
+ """Event Scheduling Module for Workflow Orchestration.
7
+
8
+ This module provides event-driven scheduling capabilities for workflows, with
9
+ a primary focus on cron-based scheduling. It includes models for defining
10
+ when workflows should be triggered and executed.
11
+
12
+ The core event trigger is the Crontab model, which wraps cron functionality
13
+ in a Pydantic model for validation and easy integration with the workflow system.
14
+
15
+ Attributes:
16
+ Interval: Type alias for scheduling intervals ('daily', 'weekly', 'monthly')
17
+
18
+ Classes:
19
+ Crontab: Main cron-based event scheduler.
20
+ CrontabYear: Enhanced cron scheduler with year constraints.
21
+ ReleaseEvent: Release-based event triggers.
22
+ SensorEvent: Sensor-based event monitoring.
23
+
24
+ Example:
25
+ >>> from ddeutil.workflow.event import Crontab
26
+ >>> # NOTE: Create daily schedule at 9 AM
27
+ >>> schedule = Crontab.model_validate(
28
+ ... {
29
+ ... "cronjob": "0 9 * * *",
30
+ ... "timezone": "America/New_York",
31
+ ... }
32
+ ... )
33
+ >>> # NOTE: Generate next run times
34
+ >>> runner = schedule.generate(datetime.now())
35
+ >>> next_run = runner.next
10
36
  """
11
37
  from __future__ import annotations
12
38
 
@@ -19,11 +45,9 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo
19
45
  from pydantic.functional_serializers import field_serializer
20
46
  from pydantic.functional_validators import field_validator, model_validator
21
47
  from pydantic_extra_types.timezone_name import TimeZoneName
22
- from typing_extensions import Self
23
48
 
24
49
  from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner, Options
25
- from .__types import DictData, DictStr
26
- from .conf import YamlParser
50
+ from .__types import DictData
27
51
 
28
52
  Interval = Literal["daily", "weekly", "monthly"]
29
53
 
@@ -34,13 +58,16 @@ def interval2crontab(
34
58
  day: Optional[str] = None,
35
59
  time: str = "00:00",
36
60
  ) -> str:
37
- """Return the crontab string that was generated from specific values.
61
+ """Convert interval specification to cron expression.
38
62
 
39
- :param interval: An interval value that is one of 'daily', 'weekly', or
40
- 'monthly'.
41
- :param day: A day value that will be day of week. The default value is
42
- monday if it is weekly interval.
43
- :param time: A time value that passing with format '%H:%M'.
63
+ Args:
64
+ interval: Scheduling interval ('daily', 'weekly', or 'monthly').
65
+ day: Day of week for weekly intervals or monthly schedules. Defaults to
66
+ Monday for weekly intervals.
67
+ time: Time of day in 'HH:MM' format. Defaults to '00:00'.
68
+
69
+ Returns:
70
+ Generated crontab expression string.
44
71
 
45
72
  Examples:
46
73
  >>> interval2crontab(interval='daily', time='01:30')
@@ -51,8 +78,6 @@ def interval2crontab(
51
78
  '0 0 1 * *'
52
79
  >>> interval2crontab(interval='monthly', day='tuesday', time='12:00')
53
80
  '12 0 1 * 2'
54
-
55
- :rtype: str
56
81
  """
57
82
  d: str = "*"
58
83
  if interval == "weekly":
@@ -66,119 +91,35 @@ def interval2crontab(
66
91
  return f"{h} {m} {'1' if interval == 'monthly' else '*'} * {d}"
67
92
 
68
93
 
69
- class Crontab(BaseModel):
70
- """Cron event model (Warped the CronJob object by Pydantic model) to keep
71
- crontab value and generate CronRunner object from this crontab value.
94
+ class BaseCrontab(BaseModel):
95
+ """Base class for crontab-based scheduling models.
72
96
 
73
- Methods:
74
- - generate: is the main use-case of this schedule object.
97
+ Attributes:
98
+ extras: Additional parameters to pass to the CronJob field.
99
+ tz: Timezone string value (alias: timezone).
75
100
  """
76
101
 
77
- model_config = ConfigDict(arbitrary_types_allowed=True)
78
-
79
- extras: Annotated[
80
- DictData,
81
- Field(
82
- default_factory=dict,
83
- description=(
84
- "An extras parameters that want to pass to the CronJob field."
85
- ),
86
- ),
87
- ]
88
- cronjob: Annotated[
89
- CronJob,
90
- Field(
91
- description=(
92
- "A Cronjob object that use for validate and generate datetime."
93
- ),
94
- ),
95
- ]
96
- tz: Annotated[
97
- TimeZoneName,
98
- Field(
99
- description="A timezone string value.",
100
- alias="timezone",
102
+ extras: DictData = Field(
103
+ default_factory=dict,
104
+ description=(
105
+ "An extras parameters that want to pass to the CronJob field."
101
106
  ),
102
- ] = "UTC"
103
-
104
- @classmethod
105
- def from_value(cls, value: DictStr, extras: DictData) -> Self:
106
- """Constructor from values that will generate crontab by function.
107
-
108
- :param value: (DictStr) A mapping value that will generate crontab
109
- before create schedule model.
110
- :param extras: (DictData) An extra parameter that use to override core
111
- config value.
112
- """
113
- passing: DictStr = {}
114
-
115
- if "timezone" in value:
116
- passing["tz"] = value.pop("timezone")
117
- elif "tz" in value:
118
- passing["tz"] = value.pop("tz")
119
-
120
- passing["cronjob"] = interval2crontab(
121
- **{v: value[v] for v in value if v in ("interval", "day", "time")}
122
- )
123
- return cls(extras=extras | passing.pop("extras", {}), **passing)
124
-
125
- @classmethod
126
- def from_conf(
127
- cls,
128
- name: str,
129
- *,
130
- extras: DictData | None = None,
131
- ) -> Self:
132
- """Constructor from the name of config loader that will use loader
133
- object for getting the `Crontab` data.
134
-
135
- :param name: (str) A name of config that will get from loader.
136
- :param extras: (DictData) An extra parameter that use to override core
137
- config values.
138
-
139
- :rtype: Self
140
- """
141
- extras: DictData = extras or {}
142
- loader: YamlParser = YamlParser(name, extras=extras, obj=cls)
143
-
144
- # NOTE: Validate the config type match with current connection model
145
- if loader.type != cls.__name__:
146
- raise ValueError(f"Type {loader.type} does not match with {cls}")
147
-
148
- loader_data: DictData = loader.data
149
- if "interval" in loader_data:
150
- return cls.model_validate(
151
- obj=dict(
152
- cronjob=interval2crontab(
153
- **{
154
- v: loader_data[v]
155
- for v in loader_data
156
- if v in ("interval", "day", "time")
157
- }
158
- ),
159
- extras=extras | loader_data.pop("extras", {}),
160
- **loader_data,
161
- )
162
- )
163
- if "cronjob" not in loader_data:
164
- raise ValueError("Config does not set `cronjob` or `interval` keys")
165
- return cls.model_validate(
166
- obj=dict(
167
- cronjob=loader_data.pop("cronjob"),
168
- extras=extras | loader_data.pop("extras", {}),
169
- **loader_data,
170
- )
171
- )
107
+ )
108
+ tz: TimeZoneName = Field(
109
+ default="UTC",
110
+ description="A timezone string value.",
111
+ alias="timezone",
112
+ )
172
113
 
173
114
  @model_validator(mode="before")
174
115
  def __prepare_values(cls, data: Any) -> Any:
175
- """Extract a `tz` key from data and change the key name from `tz` to
176
- `timezone`.
116
+ """Extract and rename timezone key in input data.
177
117
 
178
- :param data: (DictData) A data that want to pass for create a Crontab
179
- model.
118
+ Args:
119
+ data: Input data dictionary for creating Crontab model.
180
120
 
181
- :rtype: DictData
121
+ Returns:
122
+ Modified data dictionary with standardized timezone key.
182
123
  """
183
124
  if isinstance(data, dict) and (tz := data.pop("tz", None)):
184
125
  data["timezone"] = tz
@@ -186,10 +127,16 @@ class Crontab(BaseModel):
186
127
 
187
128
  @field_validator("tz")
188
129
  def __validate_tz(cls, value: str) -> str:
189
- """Validate timezone value that able to initialize with ZoneInfo after
190
- it passing to this model in before mode.
130
+ """Validate timezone value.
191
131
 
192
- :rtype: str
132
+ Args:
133
+ value: Timezone string to validate.
134
+
135
+ Returns:
136
+ Validated timezone string.
137
+
138
+ Raises:
139
+ ValueError: If timezone is invalid.
193
140
  """
194
141
  try:
195
142
  _ = ZoneInfo(value)
@@ -197,21 +144,118 @@ class Crontab(BaseModel):
197
144
  except ZoneInfoNotFoundError as e:
198
145
  raise ValueError(f"Invalid timezone: {value}") from e
199
146
 
147
+
148
+ class CrontabValue(BaseCrontab):
149
+ """Crontab model using interval-based specification.
150
+
151
+ Attributes:
152
+ interval: Scheduling interval ('daily', 'weekly', 'monthly').
153
+ day: Day specification for weekly/monthly schedules.
154
+ time: Time of day in 'HH:MM' format.
155
+ """
156
+
157
+ interval: Interval
158
+ day: Optional[str] = Field(default=None)
159
+ time: str = Field(default="00:00")
160
+
161
+ @property
162
+ def cronjob(self) -> CronJob:
163
+ """Get CronJob object built from interval format.
164
+
165
+ Returns:
166
+ CronJob instance configured with interval-based schedule.
167
+ """
168
+ return CronJob(
169
+ value=interval2crontab(self.interval, day=self.day, time=self.time)
170
+ )
171
+
172
+ def generate(self, start: Union[str, datetime]) -> CronRunner:
173
+ """Generate CronRunner from initial datetime.
174
+
175
+ Args:
176
+ start: Starting datetime (string or datetime object).
177
+
178
+ Returns:
179
+ CronRunner instance for schedule generation.
180
+
181
+ Raises:
182
+ TypeError: If start parameter is neither string nor datetime.
183
+ """
184
+ 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)
189
+
190
+ def next(self, start: Union[str, datetime]) -> CronRunner:
191
+ """Get next scheduled datetime after given start time.
192
+
193
+ Args:
194
+ start: Starting datetime for schedule generation.
195
+
196
+ Returns:
197
+ CronRunner instance positioned at next scheduled time.
198
+ """
199
+ runner: CronRunner = self.generate(start=start)
200
+
201
+ # NOTE: ship the next date of runner object that create from start.
202
+ _ = runner.next
203
+
204
+ return runner
205
+
206
+
207
+ class Crontab(BaseCrontab):
208
+ """Cron event model wrapping CronJob functionality.
209
+
210
+ A Pydantic model that encapsulates crontab scheduling functionality with
211
+ validation and datetime generation capabilities.
212
+
213
+ Attributes:
214
+ cronjob: CronJob instance for schedule validation and datetime generation.
215
+ tz: Timezone string value (alias: timezone).
216
+ """
217
+
218
+ model_config = ConfigDict(arbitrary_types_allowed=True)
219
+
220
+ cronjob: CronJob = Field(
221
+ description=(
222
+ "A Cronjob object that use for validate and generate datetime."
223
+ ),
224
+ )
225
+ tz: TimeZoneName = Field(
226
+ default="UTC",
227
+ description="A timezone string value.",
228
+ alias="timezone",
229
+ )
230
+
231
+ @model_validator(mode="before")
232
+ def __prepare_values(cls, data: Any) -> Any:
233
+ """Prepare input data by standardizing timezone key.
234
+
235
+ Args:
236
+ data: Input dictionary for model creation.
237
+
238
+ Returns:
239
+ Modified dictionary with standardized timezone key.
240
+ """
241
+ if isinstance(data, dict) and (tz := data.pop("tz", None)):
242
+ data["timezone"] = tz
243
+ return data
244
+
200
245
  @field_validator(
201
246
  "cronjob", mode="before", json_schema_input_type=Union[CronJob, str]
202
247
  )
203
248
  def __prepare_cronjob(
204
249
  cls, value: Union[str, CronJob], info: ValidationInfo
205
250
  ) -> CronJob:
206
- """Prepare crontab value that able to receive with string type.
207
- This step will get options kwargs from extras field and pass to the
208
- CronJob object.
251
+ """Prepare and validate cronjob input.
209
252
 
210
- :param value: (str | CronJobYear) A cronjob value that want to create.
211
- :param info: (ValidationInfo) A validation info object that use to get
212
- the extra parameters for create cronjob.
253
+ Args:
254
+ value: Raw cronjob value (string or CronJob instance).
255
+ info: Validation context containing extra parameters.
213
256
 
214
- :rtype: CronJob
257
+ Returns:
258
+ Configured CronJob instance.
215
259
  """
216
260
  extras: DictData = info.data.get("extras", {})
217
261
  return (
@@ -229,21 +273,27 @@ class Crontab(BaseModel):
229
273
 
230
274
  @field_serializer("cronjob")
231
275
  def __serialize_cronjob(self, value: CronJob) -> str:
232
- """Serialize the cronjob field that store with CronJob object.
276
+ """Serialize CronJob instance to string representation.
233
277
 
234
- :param value: (CronJob) The CronJob field.
278
+ Args:
279
+ value: CronJob instance to serialize.
235
280
 
236
- :rtype: str
281
+ Returns:
282
+ String representation of the CronJob.
237
283
  """
238
284
  return str(value)
239
285
 
240
286
  def generate(self, start: Union[str, datetime]) -> CronRunner:
241
- """Return CronRunner object from an initial datetime.
287
+ """Generate schedule runner from start time.
288
+
289
+ Args:
290
+ start: Starting datetime (string or datetime object).
242
291
 
243
- :param start: (str | datetime) A string or datetime for generate the
244
- CronRunner object.
292
+ Returns:
293
+ CronRunner instance for schedule generation.
245
294
 
246
- :rtype: CronRunner
295
+ Raises:
296
+ TypeError: If start parameter is neither string nor datetime.
247
297
  """
248
298
  if isinstance(start, str):
249
299
  start: datetime = datetime.fromisoformat(start)
@@ -252,13 +302,13 @@ class Crontab(BaseModel):
252
302
  return self.cronjob.schedule(date=start, tz=self.tz)
253
303
 
254
304
  def next(self, start: Union[str, datetime]) -> CronRunner:
255
- """Return a next datetime from Cron runner object that start with any
256
- date that given from input.
305
+ """Get runner positioned at next scheduled time.
257
306
 
258
- :param start: (str | datetime) A start datetime that use to generate
259
- the CronRunner object.
307
+ Args:
308
+ start: Starting datetime for schedule generation.
260
309
 
261
- :rtype: CronRunner
310
+ Returns:
311
+ CronRunner instance positioned at next scheduled time.
262
312
  """
263
313
  runner: CronRunner = self.generate(start=start)
264
314
 
@@ -269,21 +319,22 @@ class Crontab(BaseModel):
269
319
 
270
320
 
271
321
  class CrontabYear(Crontab):
272
- """Cron event with enhance Year Pydantic model for limit year matrix that
273
- use by some data schedule tools like AWS Glue.
274
- """
322
+ """Cron event model with enhanced year-based scheduling.
275
323
 
276
- model_config = ConfigDict(arbitrary_types_allowed=True)
324
+ Extends the base Crontab model to support year-specific scheduling,
325
+ particularly useful for tools like AWS Glue.
277
326
 
278
- # NOTE: This is fields of the base schedule.
279
- cronjob: Annotated[
280
- CronJobYear,
327
+ Attributes:
328
+ cronjob: CronJobYear instance for year-aware schedule validation and generation.
329
+ """
330
+
331
+ cronjob: CronJobYear = (
281
332
  Field(
282
333
  description=(
283
334
  "A Cronjob object that use for validate and generate datetime."
284
335
  ),
285
336
  ),
286
- ]
337
+ )
287
338
 
288
339
  @field_validator(
289
340
  "cronjob",
@@ -293,15 +344,14 @@ class CrontabYear(Crontab):
293
344
  def __prepare_cronjob(
294
345
  cls, value: Union[CronJobYear, str], info: ValidationInfo
295
346
  ) -> CronJobYear:
296
- """Prepare crontab value that able to receive with string type.
297
- This step will get options kwargs from extras field and pass to the
298
- CronJobYear object.
347
+ """Prepare and validate year-aware cronjob input.
299
348
 
300
- :param value: (str | CronJobYear) A cronjob value that want to create.
301
- :param info: (ValidationInfo) A validation info object that use to get
302
- the extra parameters for create cronjob.
349
+ Args:
350
+ value: Raw cronjob value (string or CronJobYear instance).
351
+ info: Validation context containing extra parameters.
303
352
 
304
- :rtype: CronJobYear
353
+ Returns:
354
+ Configured CronJobYear instance with applied options.
305
355
  """
306
356
  extras: DictData = info.data.get("extras", {})
307
357
  return (
@@ -318,24 +368,73 @@ class CrontabYear(Crontab):
318
368
  )
319
369
 
320
370
 
321
- class ReleaseEvent(BaseModel): # pragma: no cov
322
- """Release trigger event."""
371
+ Cron = Annotated[
372
+ Union[
373
+ CrontabYear,
374
+ Crontab,
375
+ CrontabValue,
376
+ ],
377
+ Field(
378
+ union_mode="smart",
379
+ description="Event model type supporting year-based, standard, and interval-based cron scheduling.",
380
+ ),
381
+ ] # pragma: no cov
382
+
383
+
384
+ class Event(BaseModel):
385
+ """Event model."""
323
386
 
387
+ schedule: list[Cron] = Field(
388
+ default_factory=list,
389
+ description="A list of Cron schedule.",
390
+ )
324
391
  release: list[str] = Field(
392
+ default_factory=list,
325
393
  description=(
326
394
  "A list of workflow name that want to receive event from release"
327
395
  "trigger."
328
- )
396
+ ),
329
397
  )
330
398
 
399
+ @field_validator("schedule", mode="after")
400
+ def __on_no_dup_and_reach_limit__(
401
+ cls,
402
+ value: list[Crontab],
403
+ ) -> list[Crontab]:
404
+ """Validate the on fields should not contain duplicate values and if it
405
+ contains the every minute value more than one value, it will remove to
406
+ only one value.
407
+
408
+ Args:
409
+ value: A list of on object.
331
410
 
332
- Event = Annotated[
333
- Union[
334
- CronJobYear,
335
- CronJob,
336
- ],
337
- Field(
338
- union_mode="smart",
339
- description="An event models.",
340
- ),
341
- ] # pragma: no cov
411
+ Returns:
412
+ list[CronJobYear | Crontab]: The validated list of Crontab objects.
413
+
414
+ Raises:
415
+ ValueError: If it has some duplicate value.
416
+ """
417
+ set_ons: set[str] = {str(on.cronjob) for on in value}
418
+ if len(set_ons) != len(value):
419
+ raise ValueError(
420
+ "The on fields should not contain duplicate on value."
421
+ )
422
+
423
+ # WARNING:
424
+ # if '* * * * *' in set_ons and len(set_ons) > 1:
425
+ # raise ValueError(
426
+ # "If it has every minute cronjob on value, it should have "
427
+ # "only one value in the on field."
428
+ # )
429
+ set_tz: set[str] = {on.tz for on in value}
430
+ if len(set_tz) > 1:
431
+ raise ValueError(
432
+ f"The on fields should not contain multiple timezone, "
433
+ f"{list(set_tz)}."
434
+ )
435
+
436
+ if len(set_ons) > 10:
437
+ raise ValueError(
438
+ "The number of the on should not more than 10 crontabs."
439
+ )
440
+ return value