ddeutil-workflow 0.0.55__py3-none-any.whl → 0.0.57__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.55"
1
+ __version__: str = "0.0.57"
@@ -502,10 +502,10 @@ class CronPart:
502
502
  except IndexError:
503
503
  next_value: int = -1
504
504
  if value != (next_value - 1):
505
- # NOTE: ``next_value`` is not the subsequent number
505
+ # NOTE: `next_value` is not the subsequent number
506
506
  if start_number is None:
507
507
  # NOTE:
508
- # The last number of the list ``self.values`` is not in a
508
+ # The last number of the list `self.values` is not in a
509
509
  # range.
510
510
  multi_dim_values.append(value)
511
511
  else:
@@ -703,11 +703,14 @@ class CronJob:
703
703
  *,
704
704
  tz: str | None = None,
705
705
  ) -> CronRunner:
706
- """Returns the schedule datetime runner with this cronjob. It would run
707
- ``next``, ``prev``, or ``reset`` to generate running date that you want.
706
+ """Returns CronRunner instance that be datetime runner with this
707
+ cronjob. It can use `next`, `prev`, or `reset` methods to generate
708
+ running date.
708
709
 
709
- :param date: An initial date that want to mark as the start point.
710
- :param tz: A string timezone that want to change on runner.
710
+ :param date: (datetime) An initial date that want to mark as the start
711
+ point. (Default is use the current datetime)
712
+ :param tz: (str) A string timezone that want to change on runner.
713
+ (Default is None)
711
714
 
712
715
  :rtype: CronRunner
713
716
  """
@@ -743,6 +746,10 @@ class CronJobYear(CronJob):
743
746
  class CronRunner:
744
747
  """Create an instance of Date Runner object for datetime generate with
745
748
  cron schedule object value.
749
+
750
+ :param cron: (CronJob | CronJobYear)
751
+ :param date: (datetime)
752
+ :param tz: (str)
746
753
  """
747
754
 
748
755
  shift_limit: ClassVar[int] = 25
@@ -761,11 +768,17 @@ class CronRunner:
761
768
  cron: CronJob | CronJobYear,
762
769
  date: datetime | None = None,
763
770
  *,
764
- tz: str | None = None,
771
+ tz: str | ZoneInfo | None = None,
765
772
  ) -> None:
766
- # NOTE: Prepare timezone if this value does not set, it will use UTC.
767
- self.tz: ZoneInfo = ZoneInfo("UTC")
773
+ self.tz: ZoneInfo | None = None
768
774
  if tz:
775
+ if isinstance(tz, ZoneInfo):
776
+ self.tz = tz
777
+ elif not isinstance(tz, str):
778
+ raise TypeError(
779
+ "Invalid type of `tz` parameter, it should be str or "
780
+ "ZoneInfo instance."
781
+ )
769
782
  try:
770
783
  self.tz = ZoneInfo(tz)
771
784
  except ZoneInfoNotFoundError as err:
@@ -777,9 +790,10 @@ class CronRunner:
777
790
  raise ValueError(
778
791
  "Input schedule start time is not a valid datetime object."
779
792
  )
780
- if tz is None:
781
- self.tz = date.tzinfo
782
- self.date: datetime = date.astimezone(self.tz)
793
+ if tz is not None:
794
+ self.date: datetime = date.astimezone(self.tz)
795
+ else:
796
+ self.date: datetime = date
783
797
  else:
784
798
  self.date: datetime = datetime.now(tz=self.tz)
785
799
 
@@ -7,16 +7,18 @@ from .__cron import CronJob, CronRunner
7
7
  from .__types import DictData, DictStr, Matrix, Re, TupleStr
8
8
  from .conf import (
9
9
  Config,
10
- Loader,
10
+ FileLoad,
11
11
  config,
12
12
  env,
13
13
  )
14
- from .cron import *
14
+ from .event import *
15
15
  from .exceptions import *
16
16
  from .job import *
17
17
  from .logs import (
18
18
  Audit,
19
19
  AuditModel,
20
+ FileAudit,
21
+ FileTrace,
20
22
  Trace,
21
23
  TraceData,
22
24
  TraceMeta,
@@ -0,0 +1,30 @@
1
+ import typer
2
+
3
+ app = typer.Typer()
4
+
5
+
6
+ @app.callback()
7
+ def callback():
8
+ """
9
+ Awesome Portal Gun
10
+ """
11
+
12
+
13
+ @app.command()
14
+ def provision():
15
+ """
16
+ Shoot the portal gun
17
+ """
18
+ typer.echo("Shooting portal gun")
19
+
20
+
21
+ @app.command()
22
+ def job():
23
+ """
24
+ Load the portal gun
25
+ """
26
+ typer.echo("Loading portal gun")
27
+
28
+
29
+ if __name__ == "__main__":
30
+ app()
@@ -20,6 +20,7 @@ from typing import Any, Optional, TypedDict, Union
20
20
 
21
21
  from typing_extensions import Self
22
22
 
23
+ StrOrInt = Union[str, int]
23
24
  TupleStr = tuple[str, ...]
24
25
  DictData = dict[str, Any]
25
26
  DictStr = dict[str, str]
ddeutil/workflow/conf.py CHANGED
@@ -7,24 +7,26 @@ from __future__ import annotations
7
7
 
8
8
  import json
9
9
  import os
10
+ from abc import ABC, abstractmethod
10
11
  from collections.abc import Iterator
11
12
  from datetime import timedelta
12
13
  from functools import cached_property
14
+ from inspect import isclass
13
15
  from pathlib import Path
14
- from typing import Final, Optional, TypeVar
16
+ from typing import Final, Optional, Protocol, TypeVar, Union
15
17
  from zoneinfo import ZoneInfo
16
18
 
17
19
  from ddeutil.core import str2bool
18
20
  from ddeutil.io import YamlFlResolve
19
21
  from ddeutil.io.paths import glob_files, is_ignored, read_ignore
20
22
 
21
- from .__types import DictData, TupleStr
23
+ from .__types import DictData
22
24
 
23
25
  T = TypeVar("T")
24
26
  PREFIX: Final[str] = "WORKFLOW"
25
27
 
26
28
 
27
- def env(var: str, default: str | None = None) -> str | None: # pragma: no cov
29
+ def env(var: str, default: str | None = None) -> str | None:
28
30
  """Get environment variable with uppercase and adding prefix string.
29
31
 
30
32
  :param var: (str) A env variable name.
@@ -35,17 +37,6 @@ def env(var: str, default: str | None = None) -> str | None: # pragma: no cov
35
37
  return os.getenv(f"{PREFIX}_{var.upper().replace(' ', '_')}", default)
36
38
 
37
39
 
38
- __all__: TupleStr = (
39
- "api_config",
40
- "env",
41
- "Config",
42
- "SimLoad",
43
- "Loader",
44
- "config",
45
- "dynamic",
46
- )
47
-
48
-
49
40
  class Config: # pragma: no cov
50
41
  """Config object for keeping core configurations on the current session
51
42
  without changing when if the application still running.
@@ -188,7 +179,7 @@ class Config: # pragma: no cov
188
179
  return timedelta(**json.loads(stop_boundary_delta_str))
189
180
  except Exception as err:
190
181
  raise ValueError(
191
- "Config ``WORKFLOW_APP_STOP_BOUNDARY_DELTA`` can not parsing to"
182
+ "Config `WORKFLOW_APP_STOP_BOUNDARY_DELTA` can not parsing to"
192
183
  f"timedelta with {stop_boundary_delta_str}."
193
184
  ) from err
194
185
 
@@ -209,110 +200,202 @@ class APIConfig:
209
200
  return str2bool(env("API_ENABLE_ROUTE_SCHEDULE", "true"))
210
201
 
211
202
 
212
- class SimLoad:
213
- """Simple Load Object that will search config data by given some identity
214
- value like name of workflow or on.
203
+ class BaseLoad(ABC): # pragma: no cov
204
+ """Base Load object is the abstraction object for any Load object that
205
+ should to inherit from this base class.
206
+ """
207
+
208
+ @classmethod
209
+ @abstractmethod
210
+ def find(cls, name: str, *args, **kwargs) -> DictData: ...
211
+
212
+ @classmethod
213
+ @abstractmethod
214
+ def finds(
215
+ cls, obj: object, *args, **kwargs
216
+ ) -> Iterator[tuple[str, DictData]]: ...
215
217
 
216
- :param name: A name of config data that will read by Yaml Loader object.
217
- :param conf_path: A config path object.
218
- :param externals: An external parameters
218
+
219
+ class FileLoad(BaseLoad):
220
+ """Base Load object that use to search config data by given some identity
221
+ value like name of `Workflow` or `On` templates.
222
+
223
+ :param name: (str) A name of key of config data that read with YAML
224
+ Environment object.
225
+ :param path: (Path) A config path object.
226
+ :param externals: (DictData) An external config data that want to add to
227
+ loaded config data.
228
+ :param extras: (DictDdata) An extra parameters that use to override core
229
+ config values.
230
+
231
+ :raise ValueError: If the data does not find on the config path with the
232
+ name parameter.
219
233
 
220
234
  Noted:
221
- The config data should have ``type`` key for modeling validation that
235
+ The config data should have `type` key for modeling validation that
222
236
  make this loader know what is config should to do pass to.
223
237
 
224
238
  ... <identity-key>:
225
239
  ... type: <importable-object>
226
240
  ... <key-data-1>: <value-data-1>
227
241
  ... <key-data-2>: <value-data-2>
242
+
243
+ This object support multiple config paths if you pass the `conf_paths`
244
+ key to the `extras` parameter.
228
245
  """
229
246
 
230
247
  def __init__(
231
248
  self,
232
249
  name: str,
233
- conf_path: Path,
250
+ *,
251
+ path: Optional[Union[str, Path]] = None,
234
252
  externals: DictData | None = None,
253
+ extras: DictData | None = None,
235
254
  ) -> None:
236
- self.conf_path: Path = conf_path
255
+ self.path: Path = Path(dynamic("conf_path", f=path, extras=extras))
237
256
  self.externals: DictData = externals or {}
238
-
239
- self.data: DictData = {}
240
- for file in glob_files(conf_path):
241
-
242
- if self.is_ignore(file, conf_path):
243
- continue
244
-
245
- if data := self.filter_yaml(file, name=name):
246
- self.data = data
257
+ self.extras: DictData = extras or {}
258
+ self.data: DictData = self.find(
259
+ name,
260
+ path=path,
261
+ paths=self.extras.get("conf_paths"),
262
+ extras=extras,
263
+ )
247
264
 
248
265
  # VALIDATE: check the data that reading should not empty.
249
266
  if not self.data:
250
267
  raise ValueError(
251
- f"Config {name!r} does not found on conf path: "
252
- f"{self.conf_path}."
268
+ f"Config {name!r} does not found on the conf path: {self.path}."
253
269
  )
254
270
 
255
271
  self.data.update(self.externals)
256
272
 
273
+ @classmethod
274
+ def find(
275
+ cls,
276
+ name: str,
277
+ *,
278
+ path: Optional[Path] = None,
279
+ paths: Optional[list[Path]] = None,
280
+ extras: Optional[DictData] = None,
281
+ ) -> DictData:
282
+ """Find data with specific key and return the latest modify date data if
283
+ this key exists multiple files.
284
+
285
+ :param name: (str) A name of data that want to find.
286
+ :param path: (Path) A config path object.
287
+ :param paths: (list[Path]) A list of config path object.
288
+ :param extras: (DictData) An extra parameter that use to override core
289
+ config values.
290
+
291
+ :rtype: DictData
292
+ """
293
+ path: Path = dynamic("conf_path", f=path, extras=extras)
294
+ if not paths:
295
+ paths: list[Path] = [path]
296
+ elif not isinstance(paths, list):
297
+ raise TypeError(
298
+ f"Multi-config paths does not support for type: {type(paths)}"
299
+ )
300
+ else:
301
+ paths.append(path)
302
+
303
+ all_data: list[tuple[float, DictData]] = []
304
+ for path in paths:
305
+ for file in glob_files(path):
306
+
307
+ if cls.is_ignore(file, path):
308
+ continue
309
+
310
+ if data := cls.filter_yaml(file, name=name):
311
+ all_data.append((file.lstat().st_mtime, data))
312
+
313
+ return {} if not all_data else max(all_data, key=lambda x: x[0])[1]
314
+
257
315
  @classmethod
258
316
  def finds(
259
317
  cls,
260
318
  obj: object,
261
- conf_path: Path,
262
319
  *,
263
- included: list[str] | None = None,
320
+ path: Optional[Path] = None,
321
+ paths: Optional[list[Path]] = None,
264
322
  excluded: list[str] | None = None,
323
+ extras: Optional[DictData] = None,
265
324
  ) -> Iterator[tuple[str, DictData]]:
266
325
  """Find all data that match with object type in config path. This class
267
326
  method can use include and exclude list of identity name for filter and
268
327
  adds-on.
269
328
 
270
329
  :param obj: An object that want to validate matching before return.
271
- :param conf_path: A config object.
272
- :param included: An excluded list of data key that want to reject this
273
- data if any key exist.
330
+ :param path: A config path object.
331
+ :param paths: (list[Path]) A list of config path object.
274
332
  :param excluded: An included list of data key that want to filter from
275
333
  data.
334
+ :param extras: (DictData) An extra parameter that use to override core
335
+ config values.
276
336
 
277
337
  :rtype: Iterator[tuple[str, DictData]]
278
338
  """
279
- exclude: list[str] = excluded or []
280
- for file in glob_files(conf_path):
281
-
282
- if cls.is_ignore(file, conf_path):
283
- continue
339
+ excluded: list[str] = excluded or []
340
+ path: Path = dynamic("conf_path", f=path, extras=extras)
341
+ paths: Optional[list[Path]] = paths or (extras or {}).get("conf_paths")
342
+ if not paths:
343
+ paths: list[Path] = [path]
344
+ elif not isinstance(paths, list):
345
+ raise TypeError(
346
+ f"Multi-config paths does not support for type: {type(paths)}"
347
+ )
348
+ else:
349
+ paths.append(path)
284
350
 
285
- for key, data in cls.filter_yaml(file).items():
351
+ all_data: dict[str, list[tuple[float, DictData]]] = {}
352
+ for path in paths:
353
+ for file in glob_files(path):
286
354
 
287
- if key in exclude:
355
+ if cls.is_ignore(file, path):
288
356
  continue
289
357
 
290
- if data.get("type", "") == obj.__name__:
291
- yield key, (
292
- {k: data[k] for k in data if k in included}
293
- if included
294
- else data
295
- )
358
+ for key, data in cls.filter_yaml(file).items():
359
+
360
+ if key in excluded:
361
+ continue
362
+
363
+ if (
364
+ data.get("type", "")
365
+ == (obj if isclass(obj) else obj.__class__).__name__
366
+ ):
367
+ marking: tuple[float, DictData] = (
368
+ file.lstat().st_mtime,
369
+ data,
370
+ )
371
+ if key in all_data:
372
+ all_data[key].append(marking)
373
+ else:
374
+ all_data[key] = [marking]
375
+
376
+ for key in all_data:
377
+ yield key, max(all_data[key], key=lambda x: x[0])[1]
296
378
 
297
379
  @classmethod
298
380
  def is_ignore(
299
381
  cls,
300
382
  file: Path,
301
- conf_path: Path,
383
+ path: Path,
302
384
  *,
303
385
  ignore_filename: Optional[str] = None,
304
386
  ) -> bool:
305
387
  """Check this file was ignored.
306
388
 
307
389
  :param file: (Path) A file path that want to check.
308
- :param conf_path: (Path) A config path that want to read the config
390
+ :param path: (Path) A config path that want to read the config
309
391
  ignore file.
310
- :param ignore_filename: (str) An ignore filename.
392
+ :param ignore_filename: (str) An ignore filename. Default is
393
+ `.confignore` filename.
311
394
 
312
395
  :rtype: bool
313
396
  """
314
397
  ignore_filename: str = ignore_filename or ".confignore"
315
- return is_ignored(file, read_ignore(conf_path / ignore_filename))
398
+ return is_ignored(file, read_ignore(path / ignore_filename))
316
399
 
317
400
  @classmethod
318
401
  def filter_yaml(cls, file: Path, name: str | None = None) -> DictData:
@@ -356,57 +439,36 @@ def dynamic(
356
439
  """Dynamic get config if extra value was passed at run-time.
357
440
 
358
441
  :param key: (str) A config key that get from Config object.
359
- :param f: An inner config function scope.
442
+ :param f: (T) An inner config function scope.
360
443
  :param extras: An extra values that pass at run-time.
444
+
445
+ :rtype: T
361
446
  """
362
- rsx: Optional[T] = extras[key] if extras and key in extras else None
363
- rs: Optional[T] = getattr(config, key, None) if f is None else f
364
- if rsx is not None and not isinstance(rsx, type(rs)):
447
+ extra: Optional[T] = (extras or {}).get(key, None)
448
+ conf: Optional[T] = getattr(config, key, None) if f is None else f
449
+ if extra is None:
450
+ return conf
451
+ if not isinstance(extra, type(conf)):
365
452
  raise TypeError(
366
- f"Type of config {key!r} from extras: {rsx!r} does not valid "
367
- f"as config {type(rs)}."
453
+ f"Type of config {key!r} from extras: {extra!r} does not valid "
454
+ f"as config {type(conf)}."
368
455
  )
369
- return rsx if rsx is not None else rs
456
+ return extra
370
457
 
371
458
 
372
- class Loader(SimLoad):
373
- """Loader Object that get the config `yaml` file from current path.
459
+ class Loader(Protocol): # pragma: no cov
460
+ type: str
461
+ path: Path
462
+ data: DictData
463
+ extras: DictData
464
+ externals: DictData
374
465
 
375
- :param name: (str) A name of config data that will read by Yaml Loader object.
376
- :param externals: (DictData) An external parameters
377
- """
466
+ def __init__(self, *args, **kwargs) -> None: ...
378
467
 
379
468
  @classmethod
380
- def finds(
381
- cls,
382
- obj: object,
383
- *,
384
- path: Path | None = None,
385
- included: list[str] | None = None,
386
- excluded: list[str] | None = None,
387
- **kwargs,
388
- ) -> Iterator[tuple[str, DictData]]:
389
- """Override the find class method from the Simple Loader object.
390
-
391
- :param obj: An object that want to validate matching before return.
392
- :param path: (Path) A override config path.
393
- :param included: An excluded list of data key that want to reject this
394
- data if any key exist.
395
- :param excluded: An included list of data key that want to filter from
396
- data.
397
-
398
- :rtype: Iterator[tuple[str, DictData]]
399
- """
400
- return super().finds(
401
- obj=obj,
402
- conf_path=(path or config.conf_path),
403
- included=included,
404
- excluded=excluded,
405
- )
469
+ def find(cls, name: str, *args, **kwargs) -> DictData: ...
406
470
 
407
- def __init__(self, name: str, externals: DictData) -> None:
408
- super().__init__(
409
- name,
410
- conf_path=dynamic("conf_path", extras=externals),
411
- externals=externals,
412
- )
471
+ @classmethod
472
+ def finds(
473
+ cls, obj: object, *args, **kwargs
474
+ ) -> Iterator[tuple[str, DictData]]: ...
@@ -3,11 +3,14 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
+ """Event module that store all event object. Now, it has only `On` and `OnYear`
7
+ model these are schedule with crontab event.
8
+ """
6
9
  from __future__ import annotations
7
10
 
8
11
  from dataclasses import fields
9
12
  from datetime import datetime
10
- from typing import Annotated, Literal, Union
13
+ from typing import Annotated, Any, Literal, Union
11
14
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
12
15
 
13
16
  from pydantic import BaseModel, ConfigDict, Field, ValidationInfo
@@ -17,11 +20,13 @@ from typing_extensions import Self
17
20
 
18
21
  from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner, Options
19
22
  from .__types import DictData, DictStr
20
- from .conf import Loader
23
+ from .conf import FileLoad
24
+
25
+ Interval = Literal["daily", "weekly", "monthly"]
21
26
 
22
27
 
23
28
  def interval2crontab(
24
- interval: Literal["daily", "weekly", "monthly"],
29
+ interval: Interval,
25
30
  *,
26
31
  day: str | None = None,
27
32
  time: str = "00:00",
@@ -59,10 +64,11 @@ def interval2crontab(
59
64
 
60
65
 
61
66
  class On(BaseModel):
62
- """On Pydantic model (Warped crontab object by model).
67
+ """On model (Warped crontab object by Pydantic model) to keep crontab value
68
+ and generate CronRunner object from this crontab value.
63
69
 
64
- See Also:
65
- * `generate()` is the main use-case of this schedule object.
70
+ Methods:
71
+ - generate: is the main use-case of this schedule object.
66
72
  """
67
73
 
68
74
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -90,40 +96,48 @@ class On(BaseModel):
90
96
  description="A timezone string value",
91
97
  alias="timezone",
92
98
  ),
93
- ] = "Etc/UTC"
99
+ ] = "UTC"
94
100
 
95
101
  @classmethod
96
102
  def from_value(cls, value: DictStr, extras: DictData) -> Self:
97
103
  """Constructor from values that will generate crontab by function.
98
104
 
99
- :param value: A mapping value that will generate crontab before create
100
- schedule model.
101
- :param extras: An extras parameter that will keep in extras.
105
+ :param value: (DictStr) A mapping value that will generate crontab
106
+ before create schedule model.
107
+ :param extras: (DictData) An extra parameter that use to override core
108
+ config value.
102
109
  """
103
110
  passing: DictStr = {}
111
+
104
112
  if "timezone" in value:
105
113
  passing["tz"] = value.pop("timezone")
114
+ elif "tz" in value:
115
+ passing["tz"] = value.pop("tz")
116
+
106
117
  passing["cronjob"] = interval2crontab(
107
118
  **{v: value[v] for v in value if v in ("interval", "day", "time")}
108
119
  )
120
+ print(passing)
109
121
  return cls(extras=extras | passing.pop("extras", {}), **passing)
110
122
 
111
123
  @classmethod
112
124
  def from_conf(
113
125
  cls,
114
126
  name: str,
127
+ *,
115
128
  extras: DictData | None = None,
116
129
  ) -> Self:
117
- """Constructor from the name of config that will use loader object for
118
- getting the data.
130
+ """Constructor from the name of config loader that will use loader
131
+ object for getting the `On` data.
119
132
 
120
- :param name: A name of config that will get from loader.
121
- :param extras: An extra parameter that will keep in extras.
133
+ :param name: (str) A name of config that will get from loader.
134
+ :param extras: (DictData) An extra parameter that use to override core
135
+ config values.
122
136
 
123
137
  :rtype: Self
124
138
  """
125
139
  extras: DictData = extras or {}
126
- loader: Loader = Loader(name, externals=extras)
140
+ loader: FileLoad = FileLoad(name, extras=extras)
127
141
 
128
142
  # NOTE: Validate the config type match with current connection model
129
143
  if loader.type != cls.__name__:
@@ -155,17 +169,17 @@ class On(BaseModel):
155
169
  )
156
170
 
157
171
  @model_validator(mode="before")
158
- def __prepare_values(cls, values: DictData) -> DictData:
172
+ def __prepare_values(cls, data: Any) -> Any:
159
173
  """Extract tz key from value and change name to timezone key.
160
174
 
161
- :param values: (DictData) A data that want to pass for create an On
175
+ :param data: (DictData) A data that want to pass for create an On
162
176
  model.
163
177
 
164
178
  :rtype: DictData
165
179
  """
166
- if tz := values.pop("tz", None):
167
- values["timezone"] = tz
168
- return values
180
+ if isinstance(data, dict) and (tz := data.pop("tz", None)):
181
+ data["timezone"] = tz
182
+ return data
169
183
 
170
184
  @field_validator("tz")
171
185
  def __validate_tz(cls, value: str) -> str:
@@ -238,6 +252,9 @@ class On(BaseModel):
238
252
  """Return a next datetime from Cron runner object that start with any
239
253
  date that given from input.
240
254
 
255
+ :param start: (str | datetime) A start datetime that use to generate
256
+ the CronRunner object.
257
+
241
258
  :rtype: CronRunner
242
259
  """
243
260
  runner: CronRunner = self.generate(start=start)