ddeutil-workflow 0.0.15__py3-none-any.whl → 0.0.17__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/utils.py CHANGED
@@ -7,7 +7,6 @@ from __future__ import annotations
7
7
 
8
8
  import inspect
9
9
  import logging
10
- import os
11
10
  import stat
12
11
  import time
13
12
  from abc import ABC, abstractmethod
@@ -15,7 +14,7 @@ from ast import Call, Constant, Expr, Module, Name, parse
15
14
  from collections.abc import Iterator
16
15
  from dataclasses import field
17
16
  from datetime import date, datetime
18
- from functools import cached_property, wraps
17
+ from functools import wraps
19
18
  from hashlib import md5
20
19
  from importlib import import_module
21
20
  from inspect import isfunction
@@ -30,12 +29,10 @@ try:
30
29
  except ImportError:
31
30
  from typing_extensions import ParamSpec
32
31
 
33
- from ddeutil.core import getdot, hasdot, hash_str, import_string, lazy, str2bool
34
- from ddeutil.io import PathData, PathSearch, YamlFlResolve, search_env_replace
35
- from ddeutil.io.models.lineage import dt_now
36
- from pydantic import BaseModel, ConfigDict, Field
32
+ from ddeutil.core import getdot, hasdot, hash_str, import_string, lazy
33
+ from ddeutil.io import search_env_replace
34
+ from pydantic import BaseModel, Field
37
35
  from pydantic.dataclasses import dataclass
38
- from pydantic.functional_serializers import field_serializer
39
36
  from pydantic.functional_validators import model_validator
40
37
  from typing_extensions import Self
41
38
 
@@ -43,6 +40,7 @@ from .__types import DictData, Matrix, Re
43
40
  from .conf import config
44
41
  from .exceptions import ParamValueException, UtilException
45
42
 
43
+ T = TypeVar("T")
46
44
  P = ParamSpec("P")
47
45
  AnyModel = TypeVar("AnyModel", bound=BaseModel)
48
46
  AnyModelType = type[AnyModel]
@@ -50,16 +48,30 @@ AnyModelType = type[AnyModel]
50
48
  logger = logging.getLogger("ddeutil.workflow")
51
49
 
52
50
 
53
- def get_diff_sec(dt: datetime, tz: ZoneInfo | None = None) -> int:
51
+ def get_dt_now(tz: ZoneInfo | None = None) -> datetime: # pragma: no cov
52
+ """Return the current datetime object.
53
+
54
+ :param tz:
55
+ :return: The current datetime object that use an input timezone or UTC.
56
+ """
57
+ return datetime.now(tz=(tz or ZoneInfo("UTC")))
58
+
59
+
60
+ def get_diff_sec(
61
+ dt: datetime, tz: ZoneInfo | None = None
62
+ ) -> int: # pragma: no cov
54
63
  """Return second value that come from diff of an input datetime and the
55
64
  current datetime with specific timezone.
65
+
66
+ :param dt:
67
+ :param tz:
56
68
  """
57
69
  return round(
58
70
  (dt - datetime.now(tz=(tz or ZoneInfo("UTC")))).total_seconds()
59
71
  )
60
72
 
61
73
 
62
- def delay(second: float = 0) -> None:
74
+ def delay(second: float = 0) -> None: # pragma: no cov
63
75
  """Delay time that use time.sleep with random second value between
64
76
  0.00 - 0.99 seconds.
65
77
 
@@ -68,203 +80,6 @@ def delay(second: float = 0) -> None:
68
80
  time.sleep(second + randrange(0, 99, step=10) / 100)
69
81
 
70
82
 
71
- class Engine(BaseModel):
72
- """Engine Model"""
73
-
74
- paths: PathData = Field(default_factory=PathData)
75
- registry: list[str] = Field(
76
- default_factory=lambda: ["ddeutil.workflow"], # pragma: no cover
77
- )
78
- registry_filter: list[str] = Field(
79
- default_factory=lambda: ["ddeutil.workflow.utils"], # pragma: no cover
80
- )
81
-
82
- @model_validator(mode="before")
83
- def __prepare_registry(cls, values: DictData) -> DictData:
84
- """Prepare registry value that passing with string type. It convert the
85
- string type to list of string.
86
- """
87
- if (_regis := values.get("registry")) and isinstance(_regis, str):
88
- values["registry"] = [_regis]
89
- if (_regis_filter := values.get("registry_filter")) and isinstance(
90
- _regis, str
91
- ):
92
- values["registry_filter"] = [_regis_filter]
93
- return values
94
-
95
-
96
- class CoreConf(BaseModel):
97
- """Core Config Model"""
98
-
99
- model_config = ConfigDict(arbitrary_types_allowed=True)
100
-
101
- tz: ZoneInfo = Field(default_factory=lambda: ZoneInfo("UTC"))
102
-
103
-
104
- class ConfParams(BaseModel):
105
- """Params Model"""
106
-
107
- engine: Engine = Field(
108
- default_factory=Engine,
109
- description="A engine mapping values.",
110
- )
111
- core: CoreConf = Field(
112
- default_factory=CoreConf,
113
- description="A core config value",
114
- )
115
-
116
-
117
- def load_config() -> ConfParams:
118
- """Load Config data from ``workflows-conf.yaml`` file.
119
-
120
- Configuration Docs:
121
- ---
122
- :var engine.registry:
123
- :var engine.registry_filter:
124
- :var paths.root:
125
- :var paths.conf:
126
- """
127
- root_path: str = os.getenv("WORKFLOW_ROOT_PATH", ".")
128
-
129
- regis: list[str] = ["ddeutil.workflow"]
130
- if regis_env := os.getenv("WORKFLOW_CORE_REGISTRY"):
131
- regis = [r.strip() for r in regis_env.split(",")]
132
-
133
- regis_filter: list[str] = ["ddeutil.workflow.utils"]
134
- if regis_filter_env := os.getenv("WORKFLOW_CORE_REGISTRY_FILTER"):
135
- regis_filter = [r.strip() for r in regis_filter_env.split(",")]
136
-
137
- conf_path: str = (
138
- f"{root_path}/{conf_env}"
139
- if (conf_env := os.getenv("WORKFLOW_CORE_PATH_CONF"))
140
- else None
141
- )
142
- return ConfParams.model_validate(
143
- obj={
144
- "engine": {
145
- "registry": regis,
146
- "registry_filter": regis_filter,
147
- "paths": {
148
- "root": root_path,
149
- "conf": conf_path,
150
- },
151
- },
152
- }
153
- )
154
-
155
-
156
- class SimLoad:
157
- """Simple Load Object that will search config data by given some identity
158
- value like name of workflow or on.
159
-
160
- :param name: A name of config data that will read by Yaml Loader object.
161
- :param params: A Params model object.
162
- :param externals: An external parameters
163
-
164
- Noted:
165
-
166
- The config data should have ``type`` key for modeling validation that
167
- make this loader know what is config should to do pass to.
168
-
169
- ... <identity-key>:
170
- ... type: <importable-object>
171
- ... <key-data>: <value-data>
172
- ... ...
173
-
174
- """
175
-
176
- def __init__(
177
- self,
178
- name: str,
179
- params: ConfParams,
180
- externals: DictData | None = None,
181
- ) -> None:
182
- self.data: DictData = {}
183
- for file in PathSearch(params.engine.paths.conf).files:
184
- if any(file.suffix.endswith(s) for s in (".yml", ".yaml")) and (
185
- data := YamlFlResolve(file).read().get(name, {})
186
- ):
187
- self.data = data
188
-
189
- # VALIDATE: check the data that reading should not empty.
190
- if not self.data:
191
- raise ValueError(f"Config {name!r} does not found on conf path")
192
-
193
- self.conf_params: ConfParams = params
194
- self.externals: DictData = externals or {}
195
- self.data.update(self.externals)
196
-
197
- @classmethod
198
- def finds(
199
- cls,
200
- obj: object,
201
- params: ConfParams,
202
- *,
203
- include: list[str] | None = None,
204
- exclude: list[str] | None = None,
205
- ) -> Iterator[tuple[str, DictData]]:
206
- """Find all data that match with object type in config path. This class
207
- method can use include and exclude list of identity name for filter and
208
- adds-on.
209
-
210
- :param obj:
211
- :param params:
212
- :param include:
213
- :param exclude:
214
- :rtype: Iterator[tuple[str, DictData]]
215
- """
216
- exclude: list[str] = exclude or []
217
- for file in PathSearch(params.engine.paths.conf).files:
218
- if any(file.suffix.endswith(s) for s in (".yml", ".yaml")) and (
219
- values := YamlFlResolve(file).read()
220
- ):
221
- for key, data in values.items():
222
- if key in exclude:
223
- continue
224
- if issubclass(get_type(data["type"], params), obj) and (
225
- include is None or all(i in data for i in include)
226
- ):
227
- yield key, data
228
-
229
- @cached_property
230
- def type(self) -> AnyModelType:
231
- """Return object of string type which implement on any registry. The
232
- object type.
233
-
234
- :rtype: AnyModelType
235
- """
236
- if not (_typ := self.data.get("type")):
237
- raise ValueError(
238
- f"the 'type' value: {_typ} does not exists in config data."
239
- )
240
- return get_type(_typ, self.conf_params)
241
-
242
-
243
- class Loader(SimLoad):
244
- """Loader Object that get the config `yaml` file from current path.
245
-
246
- :param name: A name of config data that will read by Yaml Loader object.
247
- :param externals: An external parameters
248
- """
249
-
250
- @classmethod
251
- def finds(
252
- cls,
253
- obj: object,
254
- *,
255
- include: list[str] | None = None,
256
- exclude: list[str] | None = None,
257
- **kwargs,
258
- ) -> DictData:
259
- """Override the find class method from the Simple Loader object."""
260
- return super().finds(
261
- obj=obj, params=load_config(), include=include, exclude=exclude
262
- )
263
-
264
- def __init__(self, name: str, externals: DictData) -> None:
265
- super().__init__(name, load_config(), externals)
266
-
267
-
268
83
  def gen_id(
269
84
  value: Any,
270
85
  *,
@@ -272,9 +87,9 @@ def gen_id(
272
87
  unique: bool = False,
273
88
  ) -> str:
274
89
  """Generate running ID for able to tracking. This generate process use `md5`
275
- algorithm function if ``WORKFLOW_CORE_PIPELINE_ID_SIMPLE`` set to false.
276
- But it will cut this hashing value length to 10 it the setting value set to
277
- true.
90
+ algorithm function if ``WORKFLOW_CORE_WORKFLOW_ID_SIMPLE_MODE`` set to
91
+ false. But it will cut this hashing value length to 10 it the setting value
92
+ set to true.
278
93
 
279
94
  :param value: A value that want to add to prefix before hashing with md5.
280
95
  :param sensitive: A flag that convert the value to lower case before hashing
@@ -285,7 +100,7 @@ def gen_id(
285
100
  if not isinstance(value, str):
286
101
  value: str = str(value)
287
102
 
288
- if str2bool(os.getenv("WORKFLOW_CORE_PIPELINE_ID_SIMPLE", "true")):
103
+ if config.workflow_id_simple_mode:
289
104
  return hash_str(f"{(value if sensitive else value.lower())}", n=10) + (
290
105
  f"{datetime.now(tz=config.tz):%Y%m%d%H%M%S%f}" if unique else ""
291
106
  )
@@ -297,40 +112,22 @@ def gen_id(
297
112
  ).hexdigest()
298
113
 
299
114
 
300
- def get_type(t: str, params: ConfParams) -> AnyModelType:
301
- """Return import type from string importable value in the type key.
302
-
303
- :param t: A importable type string.
304
- :param params: A config parameters that use registry to search this
305
- type.
306
- :rtype: AnyModelType
307
- """
308
- try:
309
- # NOTE: Auto adding module prefix if it does not set
310
- return import_string(f"ddeutil.workflow.{t}")
311
- except ModuleNotFoundError:
312
- for registry in params.engine.registry:
313
- try:
314
- return import_string(f"{registry}.{t}")
315
- except ModuleNotFoundError:
316
- continue
317
- return import_string(f"{t}")
318
-
319
-
320
115
  class TagFunc(Protocol):
321
116
  """Tag Function Protocol"""
322
117
 
323
118
  name: str
324
119
  tag: str
325
120
 
326
- def __call__(self, *args, **kwargs): ... # pragma: no cove
121
+ def __call__(self, *args, **kwargs): ... # pragma: no cov
327
122
 
328
123
 
329
124
  ReturnTagFunc = Callable[P, TagFunc]
330
125
  DecoratorTagFunc = Callable[[Callable[[...], Any]], ReturnTagFunc]
331
126
 
332
127
 
333
- def tag(name: str, alias: str | None = None) -> DecoratorTagFunc:
128
+ def tag(
129
+ name: str, alias: str | None = None
130
+ ) -> DecoratorTagFunc: # pragma: no cov
334
131
  """Tag decorator function that set function attributes, ``tag`` and ``name``
335
132
  for making registries variable.
336
133
 
@@ -364,7 +161,7 @@ def make_registry(submodule: str) -> dict[str, Registry]:
364
161
  :rtype: dict[str, Registry]
365
162
  """
366
163
  rs: dict[str, Registry] = {}
367
- for module in load_config().engine.registry:
164
+ for module in config.regis_hook:
368
165
  # NOTE: try to sequential import task functions
369
166
  try:
370
167
  importer = import_module(f"{module}.{submodule}")
@@ -396,9 +193,14 @@ def make_registry(submodule: str) -> dict[str, Registry]:
396
193
  class BaseParam(BaseModel, ABC):
397
194
  """Base Parameter that use to make Params Model."""
398
195
 
399
- desc: Optional[str] = None
400
- required: bool = True
401
- type: str
196
+ desc: Optional[str] = Field(
197
+ default=None, description="A description of parameter providing."
198
+ )
199
+ required: bool = Field(
200
+ default=True,
201
+ description="A require flag that force to pass this parameter value.",
202
+ )
203
+ type: str = Field(description="A type of parameter.")
402
204
 
403
205
  @abstractmethod
404
206
  def receive(self, value: Optional[Any] = None) -> Any:
@@ -406,19 +208,20 @@ class BaseParam(BaseModel, ABC):
406
208
  "Receive value and validate typing before return valid value."
407
209
  )
408
210
 
409
- @field_serializer("type")
410
- def __serializer_type(self, value: str) -> str:
411
- """Serialize the value of the type field.
412
-
413
- :rtype: str
414
- """
415
- return value
416
-
417
211
 
418
212
  class DefaultParam(BaseParam):
419
- """Default Parameter that will check default if it required"""
213
+ """Default Parameter that will check default if it required. This model do
214
+ not implement the receive method.
215
+ """
420
216
 
421
- default: Optional[str] = None
217
+ required: bool = Field(
218
+ default=False,
219
+ description="A require flag for the default-able parameter value.",
220
+ )
221
+ default: Optional[str] = Field(
222
+ default=None,
223
+ description="A default value if parameter does not pass.",
224
+ )
422
225
 
423
226
  @abstractmethod
424
227
  def receive(self, value: Optional[Any] = None) -> Any:
@@ -426,22 +229,12 @@ class DefaultParam(BaseParam):
426
229
  "Receive value and validate typing before return valid value."
427
230
  )
428
231
 
429
- @model_validator(mode="after")
430
- def __check_default(self) -> Self:
431
- """Check default value should pass when it set required."""
432
- if not self.required and self.default is None:
433
- raise ParamValueException(
434
- "Default should set when this parameter does not required."
435
- )
436
- return self
437
-
438
232
 
439
233
  class DatetimeParam(DefaultParam):
440
234
  """Datetime parameter."""
441
235
 
442
236
  type: Literal["datetime"] = "datetime"
443
- required: bool = False
444
- default: datetime = Field(default_factory=dt_now)
237
+ default: datetime = Field(default_factory=get_dt_now)
445
238
 
446
239
  def receive(self, value: str | datetime | date | None = None) -> datetime:
447
240
  """Receive value that match with datetime. If a input value pass with
@@ -463,7 +256,12 @@ class DatetimeParam(DefaultParam):
463
256
  f"Value that want to convert to datetime does not support for "
464
257
  f"type: {type(value)}"
465
258
  )
466
- return datetime.fromisoformat(value)
259
+ try:
260
+ return datetime.fromisoformat(value)
261
+ except ValueError:
262
+ raise ParamValueException(
263
+ f"Invalid isoformat string: {value!r}"
264
+ ) from None
467
265
 
468
266
 
469
267
  class StrParam(DefaultParam):
@@ -471,7 +269,7 @@ class StrParam(DefaultParam):
471
269
 
472
270
  type: Literal["str"] = "str"
473
271
 
474
- def receive(self, value: Optional[str] = None) -> str | None:
272
+ def receive(self, value: str | None = None) -> str | None:
475
273
  """Receive value that match with str.
476
274
 
477
275
  :param value: A value that want to validate with string parameter type.
@@ -486,8 +284,12 @@ class IntParam(DefaultParam):
486
284
  """Integer parameter."""
487
285
 
488
286
  type: Literal["int"] = "int"
287
+ default: Optional[int] = Field(
288
+ default=None,
289
+ description="A default value if parameter does not pass.",
290
+ )
489
291
 
490
- def receive(self, value: Optional[int] = None) -> int | None:
292
+ def receive(self, value: int | None = None) -> int | None:
491
293
  """Receive value that match with int.
492
294
 
493
295
  :param value: A value that want to validate with integer parameter type.
@@ -498,10 +300,9 @@ class IntParam(DefaultParam):
498
300
  if not isinstance(value, int):
499
301
  try:
500
302
  return int(str(value))
501
- except TypeError as err:
303
+ except ValueError as err:
502
304
  raise ParamValueException(
503
- f"Value that want to convert to integer does not support "
504
- f"for type: {type(value)}"
305
+ f"Value can not convert to int, {value}, with base 10"
505
306
  ) from err
506
307
  return value
507
308
 
@@ -510,15 +311,19 @@ class ChoiceParam(BaseParam):
510
311
  """Choice parameter."""
511
312
 
512
313
  type: Literal["choice"] = "choice"
513
- options: list[str]
314
+ options: list[str] = Field(description="A list of choice parameters.")
315
+
316
+ def receive(self, value: str | None = None) -> str:
317
+ """Receive value that match with options.
514
318
 
515
- def receive(self, value: Optional[str] = None) -> str:
516
- """Receive value that match with options."""
319
+ :param value: A value that want to select from the options field.
320
+ :rtype: str
321
+ """
517
322
  # NOTE:
518
323
  # Return the first value in options if does not pass any input value
519
324
  if value is None:
520
325
  return self.options[0]
521
- if any(value not in self.options):
326
+ if value not in self.options:
522
327
  raise ParamValueException(
523
328
  f"{value!r} does not match any value in choice options."
524
329
  )
@@ -545,7 +350,7 @@ class Result:
545
350
 
546
351
  status: int = field(default=2)
547
352
  context: DictData = field(default_factory=dict)
548
- start_at: datetime = field(default_factory=dt_now, compare=False)
353
+ start_at: datetime = field(default_factory=get_dt_now, compare=False)
549
354
  end_at: Optional[datetime] = field(default=None, compare=False)
550
355
 
551
356
  # NOTE: Ignore this field to compare another result model with __eq__.
@@ -577,15 +382,15 @@ class Result:
577
382
  :param running_id: A running ID that want to update on this model.
578
383
  :rtype: Self
579
384
  """
580
- self._parent_run_id = running_id
385
+ self._parent_run_id: str = running_id
581
386
  return self
582
387
 
583
388
  @property
584
- def parent_run_id(self):
389
+ def parent_run_id(self) -> str:
585
390
  return self._parent_run_id
586
391
 
587
392
  @property
588
- def run_id(self):
393
+ def run_id(self) -> str:
589
394
  return self._run_id
590
395
 
591
396
  def catch(self, status: int, context: DictData) -> Self:
@@ -622,8 +427,8 @@ class Result:
622
427
  self.__dict__["context"]["jobs"].update(result.context)
623
428
 
624
429
  # NOTE: Update running ID from an incoming result.
625
- self._parent_run_id = result.parent_run_id
626
- self._run_id = result.run_id
430
+ self._parent_run_id: str = result.parent_run_id
431
+ self._run_id: str = result.run_id
627
432
  return self
628
433
 
629
434
 
@@ -684,7 +489,7 @@ def make_filter_registry() -> dict[str, FilterRegistry]:
684
489
  :rtype: dict[str, Registry]
685
490
  """
686
491
  rs: dict[str, Registry] = {}
687
- for module in load_config().engine.registry_filter:
492
+ for module in config.regis_filter:
688
493
  # NOTE: try to sequential import task functions
689
494
  try:
690
495
  importer = import_module(module)
@@ -716,11 +521,11 @@ def get_args_const(
716
521
  raise UtilException(
717
522
  f"Post-filter: {expr} does not valid because it raise syntax error."
718
523
  ) from None
719
- body: list[Expr] = mod.body
720
524
 
525
+ body: list[Expr] = mod.body
721
526
  if len(body) > 1:
722
527
  raise UtilException(
723
- "Post-filter function should be only one calling per wf"
528
+ "Post-filter function should be only one calling per workflow."
724
529
  )
725
530
 
726
531
  caller: Union[Name, Call]
@@ -736,12 +541,15 @@ def get_args_const(
736
541
  keywords: dict[str, Constant] = {k.arg: k.value for k in caller.keywords}
737
542
 
738
543
  if any(not isinstance(i, Constant) for i in args):
739
- raise UtilException("Argument should be constant.")
544
+ raise UtilException(f"Argument of {expr} should be constant.")
545
+
546
+ if any(not isinstance(i, Constant) for i in keywords.values()):
547
+ raise UtilException(f"Keyword argument of {expr} should be constant.")
740
548
 
741
549
  return name.id, args, keywords
742
550
 
743
551
 
744
- @custom_filter("fmt")
552
+ @custom_filter("fmt") # pragma: no cov
745
553
  def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
746
554
  """Format datetime object to string with the format."""
747
555
  if isinstance(value, datetime):
@@ -752,16 +560,18 @@ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
752
560
 
753
561
 
754
562
  def map_post_filter(
755
- value: Any,
563
+ value: T,
756
564
  post_filter: list[str],
757
565
  filters: dict[str, FilterRegistry],
758
- ) -> Any:
566
+ ) -> T:
759
567
  """Mapping post-filter to value with sequence list of filter function name
760
568
  that will get from the filter registry.
761
569
 
762
570
  :param value: A string value that want to mapped with filter function.
763
571
  :param post_filter: A list of post-filter function name.
764
572
  :param filters: A filter registry.
573
+
574
+ :rtype: T
765
575
  """
766
576
  for _filter in post_filter:
767
577
  func_name, _args, _kwargs = get_args_const(_filter)
@@ -784,6 +594,8 @@ def map_post_filter(
784
594
  value: Any = func(value)
785
595
  else:
786
596
  value: Any = f_func(value, *args, **kwargs)
597
+ except UtilException:
598
+ raise
787
599
  except Exception as err:
788
600
  logger.warning(str(err))
789
601
  raise UtilException(
@@ -796,8 +608,8 @@ def map_post_filter(
796
608
  def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
797
609
  """Check value should not pass template with not_in value prefix.
798
610
 
799
- :param value:
800
- :param not_in:
611
+ :param value: A value that want to find parameter template prefix.
612
+ :param not_in: The not in string that use in the `.startswith` function.
801
613
  :rtype: bool
802
614
  """
803
615
  if isinstance(value, dict):
@@ -815,7 +627,7 @@ def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
815
627
  def has_template(value: Any) -> bool:
816
628
  """Check value include templating string.
817
629
 
818
- :param value:
630
+ :param value: A value that want to find parameter template.
819
631
  :rtype: bool
820
632
  """
821
633
  if isinstance(value, dict):
@@ -971,6 +783,7 @@ def batch(iterable: Iterator[Any], n: int) -> Iterator[Any]:
971
783
  """
972
784
  if n < 1:
973
785
  raise ValueError("n must be at least one")
786
+
974
787
  it: Iterator[Any] = iter(iterable)
975
788
  while True:
976
789
  chunk_it = islice(it, n)