ddeutil-workflow 0.0.81__py3-none-any.whl → 0.0.83__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.
@@ -29,7 +29,16 @@ from inspect import Traceback, currentframe, getframeinfo
29
29
  from pathlib import Path
30
30
  from threading import Lock, get_ident
31
31
  from types import FrameType
32
- from typing import Annotated, Any, ClassVar, Final, Literal, Optional, Union
32
+ from typing import (
33
+ Annotated,
34
+ Any,
35
+ ClassVar,
36
+ Final,
37
+ Literal,
38
+ Optional,
39
+ TypeVar,
40
+ Union,
41
+ )
33
42
  from zoneinfo import ZoneInfo
34
43
 
35
44
  from pydantic import BaseModel, Field, PrivateAttr
@@ -42,6 +51,8 @@ from .utils import cut_id, get_dt_now, prepare_newline
42
51
 
43
52
  logger = logging.getLogger("ddeutil.workflow")
44
53
  Level = Literal["debug", "info", "warning", "error", "exception"]
54
+ EMJ_ALERT: str = "🚨"
55
+ EMJ_SKIP: str = "⏭️"
45
56
 
46
57
 
47
58
  @lru_cache
@@ -86,9 +97,10 @@ PREFIX_LOGS: Final[dict[str, dict]] = {
86
97
  "emoji": "⚙️",
87
98
  "desc": "logs from any usage from custom caller function.",
88
99
  },
100
+ "NESTED": {"emoji": "⛓️", "desc": "logs from stages module."},
89
101
  "STAGE": {"emoji": "🔗", "desc": "logs from stages module."},
90
- "JOB": {"emoji": "⛓️", "desc": "logs from job module."},
91
- "WORKFLOW": {"emoji": "🏃", "desc": "logs from workflow module."},
102
+ "JOB": {"emoji": "🏗", "desc": "logs from job module."},
103
+ "WORKFLOW": {"emoji": "👟", "desc": "logs from workflow module."},
92
104
  "RELEASE": {"emoji": "📅", "desc": "logs from release workflow method."},
93
105
  "POKING": {"emoji": "⏰", "desc": "logs from poke workflow method."},
94
106
  "AUDIT": {"emoji": "📌", "desc": "logs from audit model."},
@@ -229,7 +241,7 @@ class Metadata(BaseModel): # pragma: no cov
229
241
  default=None, description="Environment (dev, staging, prod)."
230
242
  )
231
243
 
232
- # System context
244
+ # NOTE: System context
233
245
  hostname: Optional[str] = Field(
234
246
  default=None, description="Hostname where workflow is running."
235
247
  )
@@ -243,7 +255,7 @@ class Metadata(BaseModel): # pragma: no cov
243
255
  default=None, description="Workflow package version."
244
256
  )
245
257
 
246
- # Custom metadata
258
+ # NOTE: Custom metadata
247
259
  tags: Optional[list[str]] = Field(
248
260
  default_factory=list, description="Custom tags for categorization."
249
261
  )
@@ -310,6 +322,8 @@ class Metadata(BaseModel): # pragma: no cov
310
322
  import socket
311
323
  import sys
312
324
 
325
+ from .__about__ import __version__
326
+
313
327
  frame: Optional[FrameType] = currentframe()
314
328
  if frame is None:
315
329
  raise ValueError("Cannot get current frame")
@@ -374,7 +388,7 @@ class Metadata(BaseModel): # pragma: no cov
374
388
  hostname=hostname,
375
389
  ip_address=ip_address,
376
390
  python_version=python_version,
377
- package_version=extras_data.get("package_version"),
391
+ package_version=__version__,
378
392
  # NOTE: Custom metadata
379
393
  tags=extras_data.get("tags", []),
380
394
  metadata=extras_data.get("metadata", {}),
@@ -427,6 +441,9 @@ class BaseHandler(BaseModel, ABC):
427
441
  self, metadata: list[Metadata], *, extra: Optional[DictData] = None
428
442
  ) -> None: ...
429
443
 
444
+ def pre(self) -> None:
445
+ """Pre-process of handler that will execute when start create trance."""
446
+
430
447
 
431
448
  class ConsoleHandler(BaseHandler):
432
449
  """Console Handler model."""
@@ -460,14 +477,20 @@ class FileHandler(BaseHandler):
460
477
  metadata_filename: ClassVar[str] = "metadata.txt"
461
478
 
462
479
  type: Literal["file"] = "file"
463
- path: str = Field(description="A file path.")
480
+ path: str = Field(
481
+ description=(
482
+ "A file path that use to save all trace log files that include "
483
+ "stdout, stderr, and metadata."
484
+ )
485
+ )
464
486
  format: str = Field(
465
487
  default=(
466
488
  "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} "
467
489
  "({filename}:{lineno})"
468
- )
490
+ ),
491
+ description="A trace log format that write on stdout and stderr files.",
469
492
  )
470
- buffer_size: int = 8192
493
+ buffer_size: int = Field(default=8192)
471
494
 
472
495
  # NOTE: Private attrs for the internal process.
473
496
  _lock: Lock = PrivateAttr(default_factory=Lock)
@@ -488,7 +511,9 @@ class FileHandler(BaseHandler):
488
511
  log_file.mkdir(parents=True)
489
512
  return log_file
490
513
 
491
- def pre(self) -> None: ...
514
+ def pre(self) -> None: # pragma: no cov
515
+ if not (p := Path(self.path)).exists():
516
+ p.mkdir(parents=True)
492
517
 
493
518
  def emit(
494
519
  self,
@@ -496,6 +521,7 @@ class FileHandler(BaseHandler):
496
521
  *,
497
522
  extra: Optional[DictData] = None,
498
523
  ) -> None:
524
+ """Emit trace log."""
499
525
  pointer: Path = self.pointer(metadata.pointer_id)
500
526
  std_file = "stderr" if metadata.error_flag else "stdout"
501
527
  with self._lock:
@@ -518,7 +544,9 @@ class FileHandler(BaseHandler):
518
544
  try:
519
545
  import aiofiles
520
546
  except ImportError as e:
521
- raise ImportError("Async mode need aiofiles package") from e
547
+ raise ImportError(
548
+ "Async mode need to install `aiofiles` package first"
549
+ ) from e
522
550
 
523
551
  with self._lock:
524
552
  pointer: Path = self.pointer(metadata.pointer_id)
@@ -538,6 +566,7 @@ class FileHandler(BaseHandler):
538
566
  def flush(
539
567
  self, metadata: list[Metadata], *, extra: Optional[DictData] = None
540
568
  ) -> None:
569
+ """Flush logs."""
541
570
  with self._lock:
542
571
  pointer: Path = self.pointer(metadata[0].pointer_id)
543
572
  stdout_file = open(
@@ -613,7 +642,7 @@ class FileHandler(BaseHandler):
613
642
  """Find trace logs.
614
643
 
615
644
  Args:
616
- path: A trace path that want to find.
645
+ path (Path | None, default None): A trace path that want to find.
617
646
  """
618
647
  for file in sorted(
619
648
  (path or Path(self.path)).glob("./run_id=*"),
@@ -634,6 +663,9 @@ class FileHandler(BaseHandler):
634
663
  run_id: A running ID of trace log.
635
664
  force_raise: Whether to raise an exception if not found.
636
665
  path: Optional path override.
666
+
667
+ Returns:
668
+ TraceData: A TranceData instance that already passed searching data.
637
669
  """
638
670
  base_path: Path = path or self.path
639
671
  file: Path = base_path / f"run_id={run_id}"
@@ -757,7 +789,8 @@ class SQLiteHandler(BaseHandler): # pragma: no cov
757
789
  metadata: Metadata,
758
790
  *,
759
791
  extra: Optional[DictData] = None,
760
- ) -> None: ...
792
+ ) -> None:
793
+ raise NotImplementedError("Does not implement async emit yet.")
761
794
 
762
795
  def flush(
763
796
  self, metadata: list[Metadata], *, extra: Optional[DictData] = None
@@ -1506,7 +1539,6 @@ class ElasticHandler(BaseHandler): # pragma: no cov
1506
1539
  try:
1507
1540
  from elasticsearch import Elasticsearch
1508
1541
 
1509
- # Create client
1510
1542
  client = Elasticsearch(
1511
1543
  hosts=es_hosts if isinstance(es_hosts, list) else [es_hosts],
1512
1544
  basic_auth=(
@@ -1653,8 +1685,6 @@ class ElasticHandler(BaseHandler): # pragma: no cov
1653
1685
 
1654
1686
  for hit in response["hits"]["hits"]:
1655
1687
  source = hit["_source"]
1656
-
1657
- # Convert to TraceMeta
1658
1688
  trace_meta = Metadata(
1659
1689
  run_id=source["run_id"],
1660
1690
  parent_run_id=source["parent_run_id"],
@@ -1724,6 +1754,7 @@ class ElasticHandler(BaseHandler): # pragma: no cov
1724
1754
  return TraceData(stdout="", stderr="")
1725
1755
 
1726
1756
 
1757
+ Handler = TypeVar("Handler", bound=BaseHandler)
1727
1758
  TraceHandler = Annotated[
1728
1759
  Union[
1729
1760
  ConsoleHandler,
@@ -1866,7 +1897,7 @@ class BaseAsyncEmit(ABC):
1866
1897
  await self.amit(msg, level="exception")
1867
1898
 
1868
1899
 
1869
- class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
1900
+ class Trace(BaseModel, BaseEmit, BaseAsyncEmit):
1870
1901
  """Trace Manager model that keep all trance handler and emit log to its
1871
1902
  handler.
1872
1903
  """
@@ -1955,7 +1986,7 @@ class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
1955
1986
  any logging level.
1956
1987
 
1957
1988
  Args:
1958
- msg: A message that want to log.
1989
+ msg (str): A message that want to log.
1959
1990
  level (Level): A logging mode.
1960
1991
  """
1961
1992
  _msg: str = self.make_message(msg)
@@ -2005,10 +2036,12 @@ class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
2005
2036
  def get_trace(
2006
2037
  run_id: str,
2007
2038
  *,
2039
+ handlers: list[Union[DictData, Handler]] = None,
2008
2040
  parent_run_id: Optional[str] = None,
2009
2041
  extras: Optional[DictData] = None,
2010
- ) -> TraceManager:
2011
- """Get dynamic TraceManager instance from the core config.
2042
+ auto_pre_process: bool = False,
2043
+ ) -> Trace:
2044
+ """Get dynamic Trace instance from the core config.
2012
2045
 
2013
2046
  This factory function returns the appropriate trace implementation based on
2014
2047
  configuration. It can be overridden by extras argument and accepts running ID
@@ -2017,16 +2050,27 @@ def get_trace(
2017
2050
  Args:
2018
2051
  run_id (str): A running ID.
2019
2052
  parent_run_id (str | None, default None): A parent running ID.
2053
+ handlers (list):
2020
2054
  extras: An extra parameter that want to override the core
2021
2055
  config values.
2056
+ auto_pre_process (bool, default False)
2022
2057
 
2023
2058
  Returns:
2024
- TraceManager: The appropriate trace instance.
2059
+ Trace: The appropriate trace instance.
2025
2060
  """
2026
- handlers = dynamic("trace_handlers", extras=extras)
2027
- return TraceManager(
2028
- run_id=run_id,
2029
- parent_run_id=parent_run_id,
2030
- handlers=handlers,
2031
- extras=extras or {},
2061
+ handlers: list[DictData] = dynamic(
2062
+ "trace_handlers", f=handlers, extras=extras
2063
+ )
2064
+ trace: Trace = Trace.model_validate(
2065
+ {
2066
+ "run_id": run_id,
2067
+ "parent_run_id": parent_run_id,
2068
+ "handlers": handlers,
2069
+ "extras": extras or {},
2070
+ }
2032
2071
  )
2072
+ # NOTE: Start pre-process when start create trace.
2073
+ if auto_pre_process:
2074
+ for handler in trace.handlers:
2075
+ handler.pre()
2076
+ return trace
ddeutil/workflow/utils.py CHANGED
@@ -8,26 +8,6 @@
8
8
  This module provides essential utility functions used throughout the workflow
9
9
  system for ID generation, datetime handling, string processing, template
10
10
  operations, and other common tasks.
11
-
12
- Functions:
13
- to_train: Convert camel case strings to train case format
14
- prepare_newline: Format messages with multiple newlines
15
- replace_sec: Replace seconds and microseconds in datetime objects
16
- clear_tz: Clear timezone info from datetime objects
17
- get_dt_now: Get current datetime with timezone
18
- get_d_now: Get current date
19
- get_diff_sec: Calculate time difference in seconds
20
- reach_next_minute: Check if datetime reaches next minute
21
- wait_until_next_minute: Wait until next minute
22
- delay: Add random delay to execution
23
- gen_id: Generate unique identifiers for workflow components
24
- default_gen_id: Generate default running ID
25
- make_exec: Make files executable
26
- filter_func: Filter function objects from data structures
27
- cross_product: Generate cross product of matrix values
28
- cut_id: Cut running ID to specified length
29
- dump_all: Serialize nested BaseModel objects to dictionaries
30
- obj_name: Get object name or class name
31
11
  """
32
12
  from __future__ import annotations
33
13
 
@@ -218,7 +198,10 @@ def gen_id(
218
198
  hashing value length to 10 if simple mode is enabled.
219
199
 
220
200
  Simple Mode Format:
221
- YYYYMMDDHHMMSSffffffTxxxxxxxxxx
201
+
202
+ The format of ID include full datetime and hashing identity.
203
+
204
+ YYYY MM DD HH MM SS ffffff T **********
222
205
  year month day hour minute second microsecond sep simple-id
223
206
 
224
207
  Args:
@@ -250,6 +233,33 @@ def gen_id(
250
233
  ).hexdigest()
251
234
 
252
235
 
236
+ def extract_id(
237
+ name: str,
238
+ run_id: Optional[str] = None,
239
+ extras: Optional[DictData] = None,
240
+ ) -> tuple[str, str]:
241
+ """Extract the parent ID and running ID. If the `run_id` parameter was
242
+ passed, it will replace the parent_run_id with this value and re-generate
243
+ new running ID for it instead.
244
+
245
+ Args:
246
+ name (str): A name for generate hashing value for the `gen_id` function.
247
+ run_id (str | None, default None):
248
+ extras:
249
+
250
+ Returns:
251
+ tuple[str, str]: A pair of parent running ID and running ID.
252
+ """
253
+ generated = gen_id(name, unique=True, extras=extras)
254
+ if run_id:
255
+ parent_run_id: str = run_id
256
+ run_id: str = generated
257
+ else:
258
+ run_id: str = generated
259
+ parent_run_id: str = run_id
260
+ return parent_run_id, run_id
261
+
262
+
253
263
  def default_gen_id() -> str:
254
264
  """Return running ID for making default ID for the Result model.
255
265
 
@@ -318,12 +328,14 @@ def cross_product(matrix: Matrix) -> Iterator[DictData]:
318
328
  )
319
329
 
320
330
 
321
- def cut_id(run_id: str, *, num: int = 6) -> str:
331
+ def cut_id(run_id: str, *, num: int = 8) -> str:
322
332
  """Cut running ID to specified length.
323
333
 
324
334
  Example:
325
335
  >>> cut_id(run_id='20240101081330000000T1354680202')
326
336
  '202401010813680202'
337
+ >>> cut_id(run_id='20240101081330000000T1354680202')
338
+ '54680202'
327
339
 
328
340
  Args:
329
341
  run_id: A running ID to cut.
@@ -334,8 +346,8 @@ def cut_id(run_id: str, *, num: int = 6) -> str:
334
346
  """
335
347
  if "T" in run_id:
336
348
  dt, simple = run_id.split("T", maxsplit=1)
337
- return dt[:12] + simple[-num:]
338
- return run_id[:12] + run_id[-num:]
349
+ return dt[10:20] + simple[-num:]
350
+ return run_id[-num:]
339
351
 
340
352
 
341
353
  @overload
@@ -391,3 +403,8 @@ def obj_name(obj: Optional[Union[str, object]] = None) -> Optional[str]:
391
403
  else:
392
404
  obj_type: str = obj.__class__.__name__
393
405
  return obj_type
406
+
407
+
408
+ def remove_sys_extras(extras: DictData) -> DictData:
409
+ """Remove key that starts with `__sys_` from the extra dict parameter."""
410
+ return {k: extras[k] for k in extras if not k.startswith("__sys_")}