ddeutil-workflow 0.0.34__py3-none-any.whl → 0.0.36__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/audit.py CHANGED
@@ -20,7 +20,7 @@ from typing_extensions import Self
20
20
 
21
21
  from .__types import DictData, TupleStr
22
22
  from .conf import config
23
- from .result import TraceLog
23
+ from .logs import TraceLog, get_trace
24
24
 
25
25
  __all__: TupleStr = (
26
26
  "get_audit",
@@ -112,7 +112,8 @@ class FileAudit(BaseAudit):
112
112
  :param release: A release datetime that want to search log.
113
113
 
114
114
  :raise FileNotFoundError:
115
- :raise NotImplementedError:
115
+ :raise NotImplementedError: If an input release does not pass to this
116
+ method. Because this method does not implement latest log.
116
117
 
117
118
  :rtype: Self
118
119
  """
@@ -174,14 +175,16 @@ class FileAudit(BaseAudit):
174
175
 
175
176
  :rtype: Self
176
177
  """
177
- trace: TraceLog = TraceLog(self.run_id, self.parent_run_id)
178
+ trace: TraceLog = get_trace(self.run_id, self.parent_run_id)
178
179
 
179
180
  # NOTE: Check environ variable was set for real writing.
180
181
  if not config.enable_write_audit:
181
182
  trace.debug("[LOG]: Skip writing log cause config was set")
182
183
  return self
183
184
 
184
- log_file: Path = self.pointer() / f"{self.run_id}.log"
185
+ log_file: Path = (
186
+ self.pointer() / f"{self.parent_run_id or self.run_id}.log"
187
+ )
185
188
  log_file.write_text(
186
189
  json.dumps(
187
190
  self.model_dump(exclude=excluded),
@@ -196,7 +199,7 @@ class FileAudit(BaseAudit):
196
199
  class SQLiteAudit(BaseAudit): # pragma: no cov
197
200
  """SQLite Audit Pydantic Model."""
198
201
 
199
- table_name: ClassVar[str] = "workflow_log"
202
+ table_name: ClassVar[str] = "audits"
200
203
  schemas: ClassVar[
201
204
  str
202
205
  ] = """
@@ -214,7 +217,7 @@ class SQLiteAudit(BaseAudit): # pragma: no cov
214
217
  """Save logging data that receive a context data from a workflow
215
218
  execution result.
216
219
  """
217
- trace: TraceLog = TraceLog(self.run_id, self.parent_run_id)
220
+ trace: TraceLog = get_trace(self.run_id, self.parent_run_id)
218
221
 
219
222
  # NOTE: Check environ variable was set for real writing.
220
223
  if not config.enable_write_audit:
@@ -60,7 +60,7 @@ def tag(
60
60
 
61
61
  @wraps(func)
62
62
  def wrapped(*args: P.args, **kwargs: P.kwargs) -> TagFunc:
63
- # NOTE: Able to do anything before calling call function.
63
+ # NOTE: Able to do anything before calling the call function.
64
64
  return func(*args, **kwargs)
65
65
 
66
66
  return wrapped
@@ -150,7 +150,7 @@ def extract_call(call: str) -> Callable[[], TagFunc]:
150
150
  """
151
151
  if not (found := Re.RE_TASK_FMT.search(call)):
152
152
  raise ValueError(
153
- f"Call {call!r} does not match with call format regex."
153
+ f"Call {call!r} does not match with the call regex format."
154
154
  )
155
155
 
156
156
  # NOTE: Pass the searching call string to `path`, `func`, and `tag`.
@@ -160,13 +160,13 @@ def extract_call(call: str) -> Callable[[], TagFunc]:
160
160
  rgt: dict[str, Registry] = make_registry(f"{call.path}")
161
161
  if call.func not in rgt:
162
162
  raise NotImplementedError(
163
- f"``REGISTER-MODULES.{call.path}.registries`` does not "
163
+ f"`REGISTER-MODULES.{call.path}.registries` does not "
164
164
  f"implement registry: {call.func!r}."
165
165
  )
166
166
 
167
167
  if call.tag not in rgt[call.func]:
168
168
  raise NotImplementedError(
169
169
  f"tag: {call.tag!r} does not found on registry func: "
170
- f"``REGISTER-MODULES.{call.path}.registries.{call.func}``"
170
+ f"`REGISTER-MODULES.{call.path}.registries.{call.func}`"
171
171
  )
172
172
  return rgt[call.func][call.tag]
ddeutil/workflow/job.py CHANGED
@@ -5,7 +5,7 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  """Job Model that use for keeping stages and node that running its stages.
7
7
  The job handle the lineage of stages and location of execution of stages that
8
- mean the job model able to define ``runs-on`` key that allow you to run this
8
+ mean the job model able to define `runs-on` key that allow you to run this
9
9
  job.
10
10
 
11
11
  This module include Strategy Model that use on the job strategy field.
@@ -24,15 +24,15 @@ from enum import Enum
24
24
  from functools import lru_cache
25
25
  from textwrap import dedent
26
26
  from threading import Event
27
- from typing import Any, Optional, Union
27
+ from typing import Annotated, Any, Literal, Optional, Union
28
28
 
29
29
  from ddeutil.core import freeze_args
30
- from pydantic import BaseModel, Field
30
+ from pydantic import BaseModel, ConfigDict, Field
31
31
  from pydantic.functional_validators import field_validator, model_validator
32
32
  from typing_extensions import Self
33
33
 
34
34
  from .__types import DictData, DictStr, Matrix, TupleStr
35
- from .conf import config, get_logger
35
+ from .conf import config
36
36
  from .exceptions import (
37
37
  JobException,
38
38
  StageException,
@@ -48,7 +48,6 @@ from .utils import (
48
48
  gen_id,
49
49
  )
50
50
 
51
- logger = get_logger("ddeutil.workflow")
52
51
  MatrixFilter = list[dict[str, Union[str, int]]]
53
52
 
54
53
 
@@ -56,6 +55,10 @@ __all__: TupleStr = (
56
55
  "Strategy",
57
56
  "Job",
58
57
  "TriggerRules",
58
+ "RunsOn",
59
+ "RunsOnLocal",
60
+ "RunsOnSelfHosted",
61
+ "RunsOnK8s",
59
62
  "make",
60
63
  )
61
64
 
@@ -216,13 +219,52 @@ class TriggerRules(str, Enum):
216
219
  none_skipped: str = "none_skipped"
217
220
 
218
221
 
219
- class RunsOn(str, Enum):
222
+ class RunsOnType(str, Enum):
220
223
  """Runs-On enum object."""
221
224
 
222
- local: str = "local"
223
- docker: str = "docker"
224
- self_hosted: str = "self_hosted"
225
- k8s: str = "k8s"
225
+ LOCAL: str = "local"
226
+ SELF_HOSTED: str = "self_hosted"
227
+ K8S: str = "k8s"
228
+
229
+
230
+ class BaseRunsOn(BaseModel): # pragma: no cov
231
+ model_config = ConfigDict(use_enum_values=True)
232
+
233
+ type: Literal[RunsOnType.LOCAL]
234
+ args: DictData = Field(
235
+ default_factory=dict,
236
+ alias="with",
237
+ )
238
+
239
+
240
+ class RunsOnLocal(BaseRunsOn): # pragma: no cov
241
+ """Runs-on local."""
242
+
243
+ type: Literal[RunsOnType.LOCAL] = Field(default=RunsOnType.LOCAL)
244
+
245
+
246
+ class RunsOnSelfHosted(BaseRunsOn): # pragma: no cov
247
+ """Runs-on self-hosted."""
248
+
249
+ type: Literal[RunsOnType.SELF_HOSTED] = Field(
250
+ default=RunsOnType.SELF_HOSTED
251
+ )
252
+
253
+
254
+ class RunsOnK8s(BaseRunsOn): # pragma: no cov
255
+ """Runs-on Kubernetes."""
256
+
257
+ type: Literal[RunsOnType.K8S] = Field(default=RunsOnType.K8S)
258
+
259
+
260
+ RunsOn = Annotated[
261
+ Union[
262
+ RunsOnLocal,
263
+ RunsOnSelfHosted,
264
+ RunsOnK8s,
265
+ ],
266
+ Field(discriminator="type"),
267
+ ]
226
268
 
227
269
 
228
270
  class Job(BaseModel):
@@ -234,7 +276,7 @@ class Job(BaseModel):
234
276
 
235
277
  Data Validate:
236
278
  >>> job = {
237
- ... "runs-on": None,
279
+ ... "runs-on": {"type": "local"},
238
280
  ... "strategy": {
239
281
  ... "max-parallel": 1,
240
282
  ... "matrix": {
@@ -263,9 +305,9 @@ class Job(BaseModel):
263
305
  default=None,
264
306
  description="A job description that can be string of markdown content.",
265
307
  )
266
- runs_on: Optional[str] = Field(
267
- default=None,
268
- description="A target executor node for this job use to execution.",
308
+ runs_on: RunsOn = Field(
309
+ default_factory=RunsOnLocal,
310
+ description="A target node for this job to use for execution.",
269
311
  serialization_alias="runs-on",
270
312
  )
271
313
  stages: list[Stage] = Field(
@@ -359,7 +401,7 @@ class Job(BaseModel):
359
401
 
360
402
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
361
403
  """Set an outputs from execution process to the received context. The
362
- result from execution will pass to value of ``strategies`` key.
404
+ result from execution will pass to value of `strategies` key.
363
405
 
364
406
  For example of setting output method, If you receive execute output
365
407
  and want to set on the `to` like;
@@ -424,14 +466,14 @@ class Job(BaseModel):
424
466
  workflow execution to strategy matrix.
425
467
 
426
468
  This execution is the minimum level of execution of this job model.
427
- It different with ``self.execute`` because this method run only one
469
+ It different with `self.execute` because this method run only one
428
470
  strategy and return with context of this strategy data.
429
471
 
430
472
  The result of this execution will return result with strategy ID
431
473
  that generated from the `gen_id` function with an input strategy value.
432
474
 
433
- :raise JobException: If it has any error from ``StageException`` or
434
- ``UtilException``.
475
+ :raise JobException: If it has any error from `StageException` or
476
+ `UtilException`.
435
477
 
436
478
  :param strategy: A strategy metrix value that use on this execution.
437
479
  This value will pass to the `matrix` key for templating.
@@ -510,7 +552,7 @@ class Job(BaseModel):
510
552
  #
511
553
  # ... params |= stage.execute(params=params)
512
554
  #
513
- # This step will add the stage result to ``stages`` key in
555
+ # This step will add the stage result to `stages` key in
514
556
  # that stage id. It will have structure like;
515
557
  #
516
558
  # {
@@ -581,7 +623,7 @@ class Job(BaseModel):
581
623
  ) -> Result:
582
624
  """Job execution with passing dynamic parameters from the workflow
583
625
  execution. It will generate matrix values at the first step and run
584
- multithread on this metrics to the ``stages`` field of this job.
626
+ multithread on this metrics to the `stages` field of this job.
585
627
 
586
628
  :param params: An input parameters that use on job execution.
587
629
  :param run_id: A job running ID for this execution.
@@ -591,15 +633,12 @@ class Job(BaseModel):
591
633
 
592
634
  :rtype: Result
593
635
  """
594
-
595
- # NOTE: I use this condition because this method allow passing empty
596
- # params and I do not want to create new dict object.
597
636
  if result is None: # pragma: no cov
598
637
  result: Result = Result(
599
638
  run_id=(run_id or gen_id(self.id or "", unique=True)),
600
639
  parent_run_id=parent_run_id,
601
640
  )
602
- elif parent_run_id:
641
+ elif parent_run_id: # pragma: no cov
603
642
  result.set_parent_run_id(parent_run_id)
604
643
 
605
644
  # NOTE: Normal Job execution without parallel strategy matrix. It uses
@@ -0,0 +1,326 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2022 Korawich Anuttra. All rights reserved.
3
+ # Licensed under the MIT License. See LICENSE in the project root for
4
+ # license information.
5
+ # ------------------------------------------------------------------------------
6
+ """A Logs module contain a TraceLog dataclass.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import Iterator
14
+ from datetime import datetime
15
+ from inspect import Traceback, currentframe, getframeinfo
16
+ from pathlib import Path
17
+ from threading import get_ident
18
+ from typing import ClassVar, Literal, Optional, Union
19
+
20
+ from pydantic import BaseModel, Field
21
+ from pydantic.dataclasses import dataclass
22
+ from typing_extensions import Self
23
+
24
+ from .__types import DictStr, TupleStr
25
+ from .conf import config, get_logger
26
+ from .utils import cut_id, get_dt_now
27
+
28
+ logger = get_logger("ddeutil.workflow")
29
+
30
+ __all__: TupleStr = (
31
+ "FileTraceLog",
32
+ "SQLiteTraceLog",
33
+ "TraceData",
34
+ "TraceMeda",
35
+ "TraceLog",
36
+ "get_dt_tznow",
37
+ "get_trace",
38
+ "get_trace_obj",
39
+ )
40
+
41
+
42
+ def get_dt_tznow() -> datetime: # pragma: no cov
43
+ """Return the current datetime object that passing the config timezone.
44
+
45
+ :rtype: datetime
46
+ """
47
+ return get_dt_now(tz=config.tz)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class BaseTraceLog(ABC): # pragma: no cov
52
+ """Base Trace Log dataclass object."""
53
+
54
+ run_id: str
55
+ parent_run_id: Optional[str] = None
56
+
57
+ @abstractmethod
58
+ def writer(self, message: str, is_err: bool = False) -> None:
59
+ """Write a trace message after making to target pointer object. The
60
+ target can be anything be inherited this class and overwrite this method
61
+ such as file, console, or database.
62
+
63
+ :param message: A message after making.
64
+ :param is_err: A flag for writing with an error trace or not.
65
+ """
66
+ raise NotImplementedError(
67
+ "Create writer logic for this trace object before using."
68
+ )
69
+
70
+ @abstractmethod
71
+ def make_message(self, message: str) -> str:
72
+ """Prepare and Make a message before write and log processes.
73
+
74
+ :param message: A message that want to prepare and make before.
75
+
76
+ :rtype: str
77
+ """
78
+ raise NotImplementedError(
79
+ "Adjust make message method for this trace object before using."
80
+ )
81
+
82
+ def debug(self, message: str):
83
+ """Write trace log with append mode and logging this message with the
84
+ DEBUG level.
85
+
86
+ :param message: (str) A message that want to log.
87
+ """
88
+ msg: str = self.make_message(message)
89
+
90
+ # NOTE: Write file if debug mode was enabled.
91
+ if config.debug:
92
+ self.writer(msg)
93
+
94
+ logger.debug(msg, stacklevel=2)
95
+
96
+ def info(self, message: str) -> None:
97
+ """Write trace log with append mode and logging this message with the
98
+ INFO level.
99
+
100
+ :param message: (str) A message that want to log.
101
+ """
102
+ msg: str = self.make_message(message)
103
+ self.writer(msg)
104
+ logger.info(msg, stacklevel=2)
105
+
106
+ def warning(self, message: str) -> None:
107
+ """Write trace log with append mode and logging this message with the
108
+ WARNING level.
109
+
110
+ :param message: (str) A message that want to log.
111
+ """
112
+ msg: str = self.make_message(message)
113
+ self.writer(msg)
114
+ logger.warning(msg, stacklevel=2)
115
+
116
+ def error(self, message: str) -> None:
117
+ """Write trace log with append mode and logging this message with the
118
+ ERROR level.
119
+
120
+ :param message: (str) A message that want to log.
121
+ """
122
+ msg: str = self.make_message(message)
123
+ self.writer(msg, is_err=True)
124
+ logger.error(msg, stacklevel=2)
125
+
126
+ def exception(self, message: str) -> None:
127
+ """Write trace log with append mode and logging this message with the
128
+ EXCEPTION level.
129
+
130
+ :param message: (str) A message that want to log.
131
+ """
132
+ msg: str = self.make_message(message)
133
+ self.writer(msg, is_err=True)
134
+ logger.exception(msg, stacklevel=2)
135
+
136
+
137
+ class TraceMeda(BaseModel): # pragma: no cov
138
+ mode: Literal["stdout", "stderr"]
139
+ datetime: str
140
+ process: int
141
+ thread: int
142
+ message: str
143
+ filename: str
144
+ lineno: int
145
+
146
+
147
+ class TraceData(BaseModel): # pragma: no cov
148
+ stdout: str = Field(description="A standard output trace data.")
149
+ stderr: str = Field(description="A standard error trace data.")
150
+ meta: list[TraceMeda] = Field(
151
+ default_factory=list,
152
+ description=(
153
+ "A metadata mapping of this output and error before making it to "
154
+ "standard value."
155
+ ),
156
+ )
157
+
158
+ @classmethod
159
+ def from_path(cls, file: Path) -> Self:
160
+ data: DictStr = {"stdout": "", "stderr": "", "meta": []}
161
+
162
+ if (file / "stdout.txt").exists():
163
+ data["stdout"] = (file / "stdout.txt").read_text(encoding="utf-8")
164
+
165
+ if (file / "stderr.txt").exists():
166
+ data["stderr"] = (file / "stderr.txt").read_text(encoding="utf-8")
167
+
168
+ if (file / "metadata.json").exists():
169
+ data["meta"] = [
170
+ json.loads(line)
171
+ for line in (
172
+ (file / "metadata.json")
173
+ .read_text(encoding="utf-8")
174
+ .splitlines()
175
+ )
176
+ ]
177
+
178
+ return cls.model_validate(data)
179
+
180
+
181
+ class FileTraceLog(BaseTraceLog): # pragma: no cov
182
+ """Trace Log object that write file to the local storage."""
183
+
184
+ @classmethod
185
+ def find_logs(cls) -> Iterator[TraceData]: # pragma: no cov
186
+ for file in sorted(
187
+ config.log_path.glob("./run_id=*"),
188
+ key=lambda f: f.lstat().st_mtime,
189
+ ):
190
+ yield TraceData.from_path(file)
191
+
192
+ @classmethod
193
+ def find_log_with_id(
194
+ cls, run_id: str, force_raise: bool = True
195
+ ) -> TraceData:
196
+ file: Path = config.log_path / f"run_id={run_id}"
197
+ if file.exists():
198
+ return TraceData.from_path(file)
199
+ elif force_raise:
200
+ raise FileNotFoundError(
201
+ f"Trace log on path 'run_id={run_id}' does not found."
202
+ )
203
+ return {}
204
+
205
+ @property
206
+ def pointer(self) -> Path:
207
+ log_file: Path = (
208
+ config.log_path / f"run_id={self.parent_run_id or self.run_id}"
209
+ )
210
+ if not log_file.exists():
211
+ log_file.mkdir(parents=True)
212
+ return log_file
213
+
214
+ @property
215
+ def cut_id(self) -> str:
216
+ """Combine cutting ID of parent running ID if it set.
217
+
218
+ :rtype: str
219
+ """
220
+ cut_run_id: str = cut_id(self.run_id)
221
+ if not self.parent_run_id:
222
+ return f"{cut_run_id} -> {' ' * 6}"
223
+
224
+ cut_parent_run_id: str = cut_id(self.parent_run_id)
225
+ return f"{cut_parent_run_id} -> {cut_run_id}"
226
+
227
+ def make_message(self, message: str) -> str:
228
+ """Prepare and Make a message before write and log processes.
229
+
230
+ :param message: A message that want to prepare and make before.
231
+
232
+ :rtype: str
233
+ """
234
+ return f"({self.cut_id}) {message}"
235
+
236
+ def writer(self, message: str, is_err: bool = False) -> None:
237
+ """ "Write a trace message after making to target file and write metadata
238
+ in the same path of standard files.
239
+
240
+ The path of logging data will store by format:
241
+
242
+ ... ./logs/run_id=<run-id>/metadata.json
243
+ ... ./logs/run_id=<run-id>/stdout.txt
244
+ ... ./logs/run_id=<run-id>/stderr.txt
245
+
246
+ :param message: A message after making.
247
+ :param is_err: A flag for writing with an error trace or not.
248
+ """
249
+ if not config.enable_write_log:
250
+ return
251
+
252
+ frame_info: Traceback = getframeinfo(currentframe().f_back.f_back)
253
+ filename: str = frame_info.filename.split(os.path.sep)[-1]
254
+ lineno: int = frame_info.lineno
255
+
256
+ # NOTE: set process and thread IDs.
257
+ process: int = os.getpid()
258
+ thread: int = get_ident()
259
+
260
+ write_file: str = "stderr.txt" if is_err else "stdout.txt"
261
+ write_data: dict[str, Union[str, int]] = {
262
+ "datetime": get_dt_tznow().strftime(config.log_datetime_format),
263
+ "process": process,
264
+ "thread": thread,
265
+ "message": message,
266
+ "filename": filename,
267
+ "lineno": lineno,
268
+ }
269
+
270
+ with (self.pointer / write_file).open(mode="at", encoding="utf-8") as f:
271
+ msg_fmt: str = f"{config.log_format_file}\n"
272
+ f.write(msg_fmt.format(**write_data))
273
+
274
+ with (self.pointer / "metadata.json").open(
275
+ mode="at", encoding="utf-8"
276
+ ) as f:
277
+ f.write(
278
+ json.dumps({"mode": write_file.split(".")[0]} | write_data)
279
+ + "\n"
280
+ )
281
+
282
+
283
+ class SQLiteTraceLog(BaseTraceLog): # pragma: no cov
284
+ """Trace Log object that write trace log to the SQLite database file."""
285
+
286
+ table_name: ClassVar[str] = "audits"
287
+ schemas: ClassVar[
288
+ str
289
+ ] = """
290
+ run_id int,
291
+ stdout str,
292
+ stderr str,
293
+ update datetime
294
+ primary key ( run_id )
295
+ """
296
+
297
+ @classmethod
298
+ def find_logs(cls) -> Iterator[DictStr]: ...
299
+
300
+ @classmethod
301
+ def find_log_with_id(cls, run_id: str) -> DictStr: ...
302
+
303
+ def make_message(self, message: str) -> str: ...
304
+
305
+ def writer(self, message: str, is_err: bool = False) -> None: ...
306
+
307
+
308
+ TraceLog = Union[
309
+ FileTraceLog,
310
+ SQLiteTraceLog,
311
+ ]
312
+
313
+
314
+ def get_trace(
315
+ run_id: str, parent_run_id: str | None = None
316
+ ) -> TraceLog: # pragma: no cov
317
+ """Get dynamic TraceLog object from the setting config."""
318
+ if config.log_path.is_file():
319
+ return SQLiteTraceLog(run_id, parent_run_id=parent_run_id)
320
+ return FileTraceLog(run_id, parent_run_id=parent_run_id)
321
+
322
+
323
+ def get_trace_obj() -> type[TraceLog]: # pragma: no cov
324
+ if config.log_path.is_file():
325
+ return SQLiteTraceLog
326
+ return FileTraceLog