ddeutil-workflow 0.0.40__py3-none-any.whl → 0.0.41__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/logs.py CHANGED
@@ -3,7 +3,7 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
- """A Logs module contain a TraceLog dataclass.
6
+ """A Logs module contain TraceLog dataclass and AuditLog model.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
@@ -19,9 +19,10 @@ from typing import ClassVar, Literal, Optional, Union
19
19
 
20
20
  from pydantic import BaseModel, Field
21
21
  from pydantic.dataclasses import dataclass
22
+ from pydantic.functional_validators import model_validator
22
23
  from typing_extensions import Self
23
24
 
24
- from .__types import DictStr, TupleStr
25
+ from .__types import DictData, DictStr, TupleStr
25
26
  from .conf import config, get_logger
26
27
  from .utils import cut_id, get_dt_now
27
28
 
@@ -36,6 +37,10 @@ __all__: TupleStr = (
36
37
  "get_dt_tznow",
37
38
  "get_trace",
38
39
  "get_trace_obj",
40
+ "get_audit",
41
+ "FileAudit",
42
+ "SQLiteAudit",
43
+ "Audit",
39
44
  )
40
45
 
41
46
 
@@ -47,6 +52,66 @@ def get_dt_tznow() -> datetime: # pragma: no cov
47
52
  return get_dt_now(tz=config.tz)
48
53
 
49
54
 
55
+ class TraceMeda(BaseModel): # pragma: no cov
56
+ mode: Literal["stdout", "stderr"]
57
+ datetime: str
58
+ process: int
59
+ thread: int
60
+ message: str
61
+ filename: str
62
+ lineno: int
63
+
64
+ @classmethod
65
+ def make(cls, mode: Literal["stdout", "stderr"], message: str) -> Self:
66
+ """Make a TraceMeda instance."""
67
+ frame_info: Traceback = getframeinfo(
68
+ currentframe().f_back.f_back.f_back
69
+ )
70
+ return cls(
71
+ mode=mode,
72
+ datetime=get_dt_tznow().strftime(config.log_datetime_format),
73
+ process=os.getpid(),
74
+ thread=get_ident(),
75
+ message=message,
76
+ filename=frame_info.filename.split(os.path.sep)[-1],
77
+ lineno=frame_info.lineno,
78
+ )
79
+
80
+
81
+ class TraceData(BaseModel): # pragma: no cov
82
+ stdout: str = Field(description="A standard output trace data.")
83
+ stderr: str = Field(description="A standard error trace data.")
84
+ meta: list[TraceMeda] = Field(
85
+ default_factory=list,
86
+ description=(
87
+ "A metadata mapping of this output and error before making it to "
88
+ "standard value."
89
+ ),
90
+ )
91
+
92
+ @classmethod
93
+ def from_path(cls, file: Path) -> Self:
94
+ data: DictStr = {"stdout": "", "stderr": "", "meta": []}
95
+
96
+ if (file / "stdout.txt").exists():
97
+ data["stdout"] = (file / "stdout.txt").read_text(encoding="utf-8")
98
+
99
+ if (file / "stderr.txt").exists():
100
+ data["stderr"] = (file / "stderr.txt").read_text(encoding="utf-8")
101
+
102
+ if (file / "metadata.json").exists():
103
+ data["meta"] = [
104
+ json.loads(line)
105
+ for line in (
106
+ (file / "metadata.json")
107
+ .read_text(encoding="utf-8")
108
+ .splitlines()
109
+ )
110
+ ]
111
+
112
+ return cls.model_validate(data)
113
+
114
+
50
115
  @dataclass(frozen=True)
51
116
  class BaseTraceLog(ABC): # pragma: no cov
52
117
  """Base Trace Log dataclass object."""
@@ -67,6 +132,14 @@ class BaseTraceLog(ABC): # pragma: no cov
67
132
  "Create writer logic for this trace object before using."
68
133
  )
69
134
 
135
+ @abstractmethod
136
+ async def awriter(self, message: str, is_err: bool = False) -> None:
137
+ """Async Write a trace message after making to target pointer object.
138
+
139
+ :param message:
140
+ :param is_err:
141
+ """
142
+
70
143
  @abstractmethod
71
144
  def make_message(self, message: str) -> str:
72
145
  """Prepare and Make a message before write and log processes.
@@ -134,50 +207,6 @@ class BaseTraceLog(ABC): # pragma: no cov
134
207
  logger.exception(msg, stacklevel=2)
135
208
 
136
209
 
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
210
  class FileTraceLog(BaseTraceLog): # pragma: no cov
182
211
  """Trace Log object that write file to the local storage."""
183
212
 
@@ -227,7 +256,7 @@ class FileTraceLog(BaseTraceLog): # pragma: no cov
227
256
  def make_message(self, message: str) -> str:
228
257
  """Prepare and Make a message before write and log processes.
229
258
 
230
- :param message: A message that want to prepare and make before.
259
+ :param message: (str) A message that want to prepare and make before.
231
260
 
232
261
  :rtype: str
233
262
  """
@@ -249,40 +278,44 @@ class FileTraceLog(BaseTraceLog): # pragma: no cov
249
278
  if not config.enable_write_log:
250
279
  return
251
280
 
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
- }
281
+ write_file: str = "stderr" if is_err else "stdout"
282
+ trace_meta: TraceMeda = TraceMeda.make(mode=write_file, message=message)
269
283
 
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(
284
+ with (self.pointer / f"{write_file}.txt").open(
275
285
  mode="at", encoding="utf-8"
276
286
  ) as f:
277
287
  f.write(
278
- json.dumps({"mode": write_file.split(".")[0]} | write_data)
279
- + "\n"
288
+ f"{config.log_format_file}\n".format(**trace_meta.model_dump())
280
289
  )
281
290
 
291
+ with (self.pointer / "metadata.json").open(
292
+ mode="at", encoding="utf-8"
293
+ ) as f:
294
+ f.write(trace_meta.model_dump_json() + "\n")
295
+
282
296
  async def awriter(
283
297
  self, message: str, is_err: bool = False
284
- ): # pragma: no cov
298
+ ) -> None: # pragma: no cov
285
299
  """TODO: Use `aiofiles` for make writer method support async."""
300
+ if not config.enable_write_log:
301
+ return
302
+
303
+ import aiofiles
304
+
305
+ write_file: str = "stderr" if is_err else "stdout"
306
+ trace_meta: TraceMeda = TraceMeda.make(mode=write_file, message=message)
307
+
308
+ async with aiofiles.open(
309
+ self.pointer / f"{write_file}.txt", mode="at", encoding="utf-8"
310
+ ) as f:
311
+ await f.write(
312
+ f"{config.log_format_file}\n".format(**trace_meta.model_dump())
313
+ )
314
+
315
+ async with aiofiles.open(
316
+ self.pointer / "metadata.json", mode="at", encoding="utf-8"
317
+ ) as f:
318
+ await f.write(trace_meta.model_dump_json() + "\n")
286
319
 
287
320
 
288
321
  class SQLiteTraceLog(BaseTraceLog): # pragma: no cov
@@ -309,6 +342,8 @@ class SQLiteTraceLog(BaseTraceLog): # pragma: no cov
309
342
 
310
343
  def writer(self, message: str, is_err: bool = False) -> None: ...
311
344
 
345
+ def awriter(self, message: str, is_err: bool = False) -> None: ...
346
+
312
347
 
313
348
  TraceLog = Union[
314
349
  FileTraceLog,
@@ -329,3 +364,230 @@ def get_trace_obj() -> type[TraceLog]: # pragma: no cov
329
364
  if config.log_path.is_file():
330
365
  return SQLiteTraceLog
331
366
  return FileTraceLog
367
+
368
+
369
+ class BaseAudit(BaseModel, ABC):
370
+ """Base Audit Pydantic Model with abstraction class property that implement
371
+ only model fields. This model should to use with inherit to logging
372
+ subclass like file, sqlite, etc.
373
+ """
374
+
375
+ name: str = Field(description="A workflow name.")
376
+ release: datetime = Field(description="A release datetime.")
377
+ type: str = Field(description="A running type before logging.")
378
+ context: DictData = Field(
379
+ default_factory=dict,
380
+ description="A context that receive from a workflow execution result.",
381
+ )
382
+ parent_run_id: Optional[str] = Field(
383
+ default=None, description="A parent running ID."
384
+ )
385
+ run_id: str = Field(description="A running ID")
386
+ update: datetime = Field(default_factory=get_dt_tznow)
387
+ execution_time: float = Field(default=0, description="An execution time.")
388
+
389
+ @model_validator(mode="after")
390
+ def __model_action(self) -> Self:
391
+ """Do before the Audit action with WORKFLOW_AUDIT_ENABLE_WRITE env variable.
392
+
393
+ :rtype: Self
394
+ """
395
+ if config.enable_write_audit:
396
+ self.do_before()
397
+ return self
398
+
399
+ def do_before(self) -> None: # pragma: no cov
400
+ """To something before end up of initial log model."""
401
+
402
+ @abstractmethod
403
+ def save(self, excluded: list[str] | None) -> None: # pragma: no cov
404
+ """Save this model logging to target logging store."""
405
+ raise NotImplementedError("Audit should implement ``save`` method.")
406
+
407
+
408
+ class FileAudit(BaseAudit):
409
+ """File Audit Pydantic Model that use to saving log data from result of
410
+ workflow execution. It inherits from BaseAudit model that implement the
411
+ ``self.save`` method for file.
412
+ """
413
+
414
+ filename_fmt: ClassVar[str] = (
415
+ "workflow={name}/release={release:%Y%m%d%H%M%S}"
416
+ )
417
+
418
+ def do_before(self) -> None:
419
+ """Create directory of release before saving log file."""
420
+ self.pointer().mkdir(parents=True, exist_ok=True)
421
+
422
+ @classmethod
423
+ def find_audits(cls, name: str) -> Iterator[Self]:
424
+ """Generate the audit data that found from logs path with specific a
425
+ workflow name.
426
+
427
+ :param name: A workflow name that want to search release logging data.
428
+
429
+ :rtype: Iterator[Self]
430
+ """
431
+ pointer: Path = config.audit_path / f"workflow={name}"
432
+ if not pointer.exists():
433
+ raise FileNotFoundError(f"Pointer: {pointer.absolute()}.")
434
+
435
+ for file in pointer.glob("./release=*/*.log"):
436
+ with file.open(mode="r", encoding="utf-8") as f:
437
+ yield cls.model_validate(obj=json.load(f))
438
+
439
+ @classmethod
440
+ def find_audit_with_release(
441
+ cls,
442
+ name: str,
443
+ release: datetime | None = None,
444
+ ) -> Self:
445
+ """Return the audit data that found from logs path with specific
446
+ workflow name and release values. If a release does not pass to an input
447
+ argument, it will return the latest release from the current log path.
448
+
449
+ :param name: A workflow name that want to search log.
450
+ :param release: A release datetime that want to search log.
451
+
452
+ :raise FileNotFoundError:
453
+ :raise NotImplementedError: If an input release does not pass to this
454
+ method. Because this method does not implement latest log.
455
+
456
+ :rtype: Self
457
+ """
458
+ if release is None:
459
+ raise NotImplementedError("Find latest log does not implement yet.")
460
+
461
+ pointer: Path = (
462
+ config.audit_path
463
+ / f"workflow={name}/release={release:%Y%m%d%H%M%S}"
464
+ )
465
+ if not pointer.exists():
466
+ raise FileNotFoundError(
467
+ f"Pointer: ./logs/workflow={name}/"
468
+ f"release={release:%Y%m%d%H%M%S} does not found."
469
+ )
470
+
471
+ with max(pointer.glob("./*.log"), key=os.path.getctime).open(
472
+ mode="r", encoding="utf-8"
473
+ ) as f:
474
+ return cls.model_validate(obj=json.load(f))
475
+
476
+ @classmethod
477
+ def is_pointed(cls, name: str, release: datetime) -> bool:
478
+ """Check the release log already pointed or created at the destination
479
+ log path.
480
+
481
+ :param name: A workflow name.
482
+ :param release: A release datetime.
483
+
484
+ :rtype: bool
485
+ :return: Return False if the release log was not pointed or created.
486
+ """
487
+ # NOTE: Return False if enable writing log flag does not set.
488
+ if not config.enable_write_audit:
489
+ return False
490
+
491
+ # NOTE: create pointer path that use the same logic of pointer method.
492
+ pointer: Path = config.audit_path / cls.filename_fmt.format(
493
+ name=name, release=release
494
+ )
495
+
496
+ return pointer.exists()
497
+
498
+ def pointer(self) -> Path:
499
+ """Return release directory path that was generated from model data.
500
+
501
+ :rtype: Path
502
+ """
503
+ return config.audit_path / self.filename_fmt.format(
504
+ name=self.name, release=self.release
505
+ )
506
+
507
+ def save(self, excluded: list[str] | None) -> Self:
508
+ """Save logging data that receive a context data from a workflow
509
+ execution result.
510
+
511
+ :param excluded: An excluded list of key name that want to pass in the
512
+ model_dump method.
513
+
514
+ :rtype: Self
515
+ """
516
+ trace: TraceLog = get_trace(self.run_id, self.parent_run_id)
517
+
518
+ # NOTE: Check environ variable was set for real writing.
519
+ if not config.enable_write_audit:
520
+ trace.debug("[LOG]: Skip writing log cause config was set")
521
+ return self
522
+
523
+ log_file: Path = (
524
+ self.pointer() / f"{self.parent_run_id or self.run_id}.log"
525
+ )
526
+ log_file.write_text(
527
+ json.dumps(
528
+ self.model_dump(exclude=excluded),
529
+ default=str,
530
+ indent=2,
531
+ ),
532
+ encoding="utf-8",
533
+ )
534
+ return self
535
+
536
+
537
+ class SQLiteAudit(BaseAudit): # pragma: no cov
538
+ """SQLite Audit Pydantic Model."""
539
+
540
+ table_name: ClassVar[str] = "audits"
541
+ schemas: ClassVar[
542
+ str
543
+ ] = """
544
+ workflow str,
545
+ release int,
546
+ type str,
547
+ context json,
548
+ parent_run_id int,
549
+ run_id int,
550
+ update datetime
551
+ primary key ( run_id )
552
+ """
553
+
554
+ def save(self, excluded: list[str] | None) -> SQLiteAudit:
555
+ """Save logging data that receive a context data from a workflow
556
+ execution result.
557
+ """
558
+ trace: TraceLog = get_trace(self.run_id, self.parent_run_id)
559
+
560
+ # NOTE: Check environ variable was set for real writing.
561
+ if not config.enable_write_audit:
562
+ trace.debug("[LOG]: Skip writing log cause config was set")
563
+ return self
564
+
565
+ raise NotImplementedError("SQLiteAudit does not implement yet.")
566
+
567
+
568
+ class RemoteFileAudit(FileAudit): # pragma: no cov
569
+ """Remote File Audit Pydantic Model."""
570
+
571
+ def save(self, excluded: list[str] | None) -> RemoteFileAudit: ...
572
+
573
+
574
+ class RedisAudit(BaseAudit): # pragma: no cov
575
+ """Redis Audit Pydantic Model."""
576
+
577
+ def save(self, excluded: list[str] | None) -> RedisAudit: ...
578
+
579
+
580
+ Audit = Union[
581
+ FileAudit,
582
+ SQLiteAudit,
583
+ ]
584
+
585
+
586
+ def get_audit() -> type[Audit]: # pragma: no cov
587
+ """Get an audit class that dynamic base on the config audit path value.
588
+
589
+ :rtype: type[Audit]
590
+ """
591
+ if config.audit_path.is_file():
592
+ return SQLiteAudit
593
+ return FileAudit
@@ -3,6 +3,7 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
+ # [ ] Use config
6
7
  """This module include all Param Pydantic Models that use for parsing an
7
8
  incoming parameters that was passed to the Workflow and Schedule objects before
8
9
  execution or release methods.
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
  from dataclasses import field
12
12
  from datetime import datetime
13
13
  from enum import IntEnum
14
- from threading import Event
15
14
  from typing import Optional
16
15
 
17
16
  from pydantic import ConfigDict
@@ -21,30 +20,21 @@ from typing_extensions import Self
21
20
 
22
21
  from .__types import DictData, TupleStr
23
22
  from .logs import TraceLog, get_dt_tznow, get_trace
24
- from .utils import gen_id
23
+ from .utils import default_gen_id, gen_id
25
24
 
26
25
  __all__: TupleStr = (
27
26
  "Result",
28
27
  "Status",
29
- "default_gen_id",
30
28
  )
31
29
 
32
30
 
33
- def default_gen_id() -> str:
34
- """Return running ID which use for making default ID for the Result model if
35
- a run_id field initializes at the first time.
36
-
37
- :rtype: str
38
- """
39
- return gen_id("manual", unique=True)
40
-
41
-
42
31
  class Status(IntEnum):
43
32
  """Status Int Enum object."""
44
33
 
45
34
  SUCCESS: int = 0
46
35
  FAILED: int = 1
47
36
  WAIT: int = 2
37
+ SKIP: int = 3
48
38
 
49
39
 
50
40
  @dataclass(
@@ -65,7 +55,6 @@ class Result:
65
55
  parent_run_id: Optional[str] = field(default=None, compare=False)
66
56
  ts: datetime = field(default_factory=get_dt_tznow, compare=False)
67
57
 
68
- event: Event = field(default_factory=Event, compare=False, repr=False)
69
58
  trace: Optional[TraceLog] = field(default=None, compare=False, repr=False)
70
59
 
71
60
  @classmethod
@@ -90,10 +79,12 @@ class Result:
90
79
 
91
80
  @model_validator(mode="after")
92
81
  def __prepare_trace(self) -> Self:
93
- """Prepare trace field that want to pass after its initialize step."""
82
+ """Prepare trace field that want to pass after its initialize step.
83
+
84
+ :rtype: Self
85
+ """
94
86
  if self.trace is None: # pragma: no cov
95
87
  self.trace: TraceLog = get_trace(self.run_id, self.parent_run_id)
96
-
97
88
  return self
98
89
 
99
90
  def set_parent_run_id(self, running_id: str) -> Self: