ddeutil-workflow 0.0.36__py3-none-any.whl → 0.0.38__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.36"
1
+ __version__: str = "0.0.38"
@@ -39,6 +39,8 @@ from .job import (
39
39
  Job,
40
40
  RunsOn,
41
41
  Strategy,
42
+ local_execute,
43
+ local_execute_strategy,
42
44
  )
43
45
  from .logs import (
44
46
  TraceData,
@@ -69,6 +71,8 @@ from .stages import (
69
71
  BashStage,
70
72
  CallStage,
71
73
  EmptyStage,
74
+ ForEachStage,
75
+ ParallelStage,
72
76
  PyStage,
73
77
  Stage,
74
78
  TriggerStage,
@@ -89,7 +93,6 @@ from .templates import (
89
93
  from .utils import (
90
94
  batch,
91
95
  cross_product,
92
- dash2underscore,
93
96
  delay,
94
97
  filter_func,
95
98
  gen_id,
@@ -27,7 +27,7 @@ from .repeat import repeat_at
27
27
  from .routes import job, log
28
28
 
29
29
  load_dotenv()
30
- logger = get_logger("ddeutil.workflow")
30
+ logger = get_logger("uvicorn.error")
31
31
 
32
32
 
33
33
  class State(TypedDict):
@@ -151,6 +151,7 @@ if config.enable_route_schedule:
151
151
  async def validation_exception_handler(
152
152
  request: Request, exc: RequestValidationError
153
153
  ):
154
+ _ = request
154
155
  return UJSONResponse(
155
156
  status_code=st.HTTP_422_UNPROCESSABLE_ENTITY,
156
157
  content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
@@ -164,4 +165,5 @@ if __name__ == "__main__":
164
165
  app,
165
166
  host="0.0.0.0",
166
167
  port=80,
168
+ log_level="DEBUG",
167
169
  )
@@ -0,0 +1,59 @@
1
+ from ..conf import config
2
+
3
+ LOGGING_CONFIG = { # pragma: no cov
4
+ "version": 1,
5
+ "disable_existing_loggers": False,
6
+ "formatters": {
7
+ "standard": {
8
+ "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
9
+ },
10
+ "custom_formatter": {
11
+ "format": config.log_format,
12
+ "datefmt": config.log_datetime_format,
13
+ },
14
+ },
15
+ "root": {
16
+ "level": "DEBUG" if config.debug else "INFO",
17
+ },
18
+ "handlers": {
19
+ "default": {
20
+ "formatter": "standard",
21
+ "class": "logging.StreamHandler",
22
+ "stream": "ext://sys.stderr",
23
+ },
24
+ "stream_handler": {
25
+ "formatter": "custom_formatter",
26
+ "class": "logging.StreamHandler",
27
+ "stream": "ext://sys.stdout",
28
+ },
29
+ "file_handler": {
30
+ "formatter": "custom_formatter",
31
+ "class": "logging.handlers.RotatingFileHandler",
32
+ "filename": "logs/app.log",
33
+ "maxBytes": 1024 * 1024 * 1,
34
+ "backupCount": 3,
35
+ },
36
+ },
37
+ "loggers": {
38
+ "uvicorn": {
39
+ "handlers": ["default", "file_handler"],
40
+ "level": "DEBUG" if config.debug else "INFO",
41
+ "propagate": False,
42
+ },
43
+ "uvicorn.access": {
44
+ "handlers": ["stream_handler", "file_handler"],
45
+ "level": "DEBUG" if config.debug else "INFO",
46
+ "propagate": False,
47
+ },
48
+ "uvicorn.error": {
49
+ "handlers": ["stream_handler", "file_handler"],
50
+ "level": "DEBUG" if config.debug else "INFO",
51
+ "propagate": False,
52
+ },
53
+ # "uvicorn.asgi": {
54
+ # "handlers": ["stream_handler", "file_handler"],
55
+ # "level": "TRACE",
56
+ # "propagate": False,
57
+ # },
58
+ },
59
+ }
@@ -15,7 +15,7 @@ from starlette.concurrency import run_in_threadpool
15
15
  from ..__cron import CronJob
16
16
  from ..conf import config, get_logger
17
17
 
18
- logger = get_logger("ddeutil.workflow")
18
+ logger = get_logger("uvicorn.error")
19
19
 
20
20
 
21
21
  def get_cronjob_delta(cron: str) -> float:
@@ -17,7 +17,7 @@ from ...exceptions import JobException
17
17
  from ...job import Job
18
18
  from ...result import Result
19
19
 
20
- logger = get_logger("ddeutil.workflow")
20
+ logger = get_logger("uvicorn.error")
21
21
 
22
22
 
23
23
  job_route = APIRouter(
@@ -45,6 +45,7 @@ async def job_execute(
45
45
  run_id=result.run_id,
46
46
  parent_run_id=result.parent_run_id,
47
47
  )
48
+ context: DictData = {}
48
49
  try:
49
50
  job.set_outputs(
50
51
  job.execute(
@@ -52,7 +53,7 @@ async def job_execute(
52
53
  run_id=rs.run_id,
53
54
  parent_run_id=rs.parent_run_id,
54
55
  ).context,
55
- to=params,
56
+ to=context,
56
57
  )
57
58
  except JobException as err:
58
59
  rs.trace.error(f"[WORKFLOW]: {err.__class__.__name__}: {err}")
@@ -70,4 +71,5 @@ async def job_execute(
70
71
  exclude_defaults=True,
71
72
  ),
72
73
  "params": params,
74
+ "context": context,
73
75
  }
@@ -6,7 +6,8 @@
6
6
  """This route include audit and trace log paths."""
7
7
  from __future__ import annotations
8
8
 
9
- from fastapi import APIRouter
9
+ from fastapi import APIRouter, Path, Query
10
+ from fastapi import status as st
10
11
  from fastapi.responses import UJSONResponse
11
12
 
12
13
  from ...audit import get_audit
@@ -14,47 +15,124 @@ from ...logs import get_trace_obj
14
15
 
15
16
  log_route = APIRouter(
16
17
  prefix="/logs",
17
- tags=["logs", "trace", "audit"],
18
+ tags=["logs"],
18
19
  default_response_class=UJSONResponse,
19
20
  )
20
21
 
21
22
 
22
- @log_route.get(path="/trace/")
23
- async def get_traces():
24
- """Get all trace logs."""
23
+ @log_route.get(
24
+ path="/traces/",
25
+ response_class=UJSONResponse,
26
+ status_code=st.HTTP_200_OK,
27
+ summary="Read all trace logs.",
28
+ tags=["trace"],
29
+ )
30
+ async def get_traces(
31
+ offset: int = Query(default=0, gt=0),
32
+ limit: int = Query(default=100, gt=0),
33
+ ):
34
+ """Return all trace logs from the current trace log path that config with
35
+ `WORKFLOW_LOG_PATH` environment variable name.
36
+ """
25
37
  return {
26
- "message": "Getting trace logs",
27
- "traces": list(get_trace_obj().find_logs()),
38
+ "message": (
39
+ f"Getting trace logs with offset: {offset} and limit: {limit}"
40
+ ),
41
+ "traces": [
42
+ trace.model_dump(
43
+ by_alias=True,
44
+ exclude_none=True,
45
+ exclude_unset=True,
46
+ exclude_defaults=True,
47
+ )
48
+ for trace in get_trace_obj().find_logs()
49
+ ],
28
50
  }
29
51
 
30
52
 
31
- @log_route.get(path="/trace/{run_id}")
53
+ @log_route.get(
54
+ path="/traces/{run_id}",
55
+ response_class=UJSONResponse,
56
+ status_code=st.HTTP_200_OK,
57
+ summary="Read trace log with specific running ID.",
58
+ tags=["trace"],
59
+ )
32
60
  async def get_trace_with_id(run_id: str):
33
- """Get trace log with specific running ID."""
34
- return get_trace_obj().find_log_with_id(run_id)
61
+ """Return trace log with specific running ID from the current trace log path
62
+ that config with `WORKFLOW_LOG_PATH` environment variable name.
63
+
64
+ - **run_id**: A running ID that want to search a trace log from the log
65
+ path.
66
+ """
67
+ return {
68
+ "message": f"Getting trace log with specific running ID: {run_id}",
69
+ "trace": (
70
+ get_trace_obj()
71
+ .find_log_with_id(run_id)
72
+ .model_dump(
73
+ by_alias=True,
74
+ exclude_none=True,
75
+ exclude_unset=True,
76
+ exclude_defaults=True,
77
+ )
78
+ ),
79
+ }
35
80
 
36
81
 
37
- @log_route.get(path="/audit/")
82
+ @log_route.get(
83
+ path="/audits/",
84
+ response_class=UJSONResponse,
85
+ status_code=st.HTTP_200_OK,
86
+ summary="Read all audit logs.",
87
+ tags=["audit"],
88
+ )
38
89
  async def get_audits():
39
- """Get all audit logs."""
90
+ """Return all audit logs from the current audit log path that config with
91
+ `WORKFLOW_AUDIT_PATH` environment variable name.
92
+ """
40
93
  return {
41
94
  "message": "Getting audit logs",
42
95
  "audits": list(get_audit().find_audits(name="demo")),
43
96
  }
44
97
 
45
98
 
46
- @log_route.get(path="/audit/{workflow}/")
99
+ @log_route.get(
100
+ path="/audits/{workflow}/",
101
+ response_class=UJSONResponse,
102
+ status_code=st.HTTP_200_OK,
103
+ summary="Read all audit logs with specific workflow name.",
104
+ tags=["audit"],
105
+ )
47
106
  async def get_audit_with_workflow(workflow: str):
48
- """Get all audit logs."""
107
+ """Return all audit logs with specific workflow name from the current audit
108
+ log path that config with `WORKFLOW_AUDIT_PATH` environment variable name.
109
+
110
+ - **workflow**: A specific workflow name that want to find audit logs.
111
+ """
49
112
  return {
50
113
  "message": f"Getting audit logs with workflow name {workflow}",
51
114
  "audits": list(get_audit().find_audits(name="demo")),
52
115
  }
53
116
 
54
117
 
55
- @log_route.get(path="/audit/{workflow}/{release}")
56
- async def get_audit_with_workflow_release(workflow: str, release: str):
57
- """Get all audit logs."""
118
+ @log_route.get(
119
+ path="/audits/{workflow}/{release}",
120
+ response_class=UJSONResponse,
121
+ status_code=st.HTTP_200_OK,
122
+ summary="Read all audit logs with specific workflow name and release date.",
123
+ tags=["audit"],
124
+ )
125
+ async def get_audit_with_workflow_release(
126
+ workflow: str = Path(...),
127
+ release: str = Path(...),
128
+ ):
129
+ """Return all audit logs with specific workflow name and release date from
130
+ the current audit log path that config with `WORKFLOW_AUDIT_PATH`
131
+ environment variable name.
132
+
133
+ - **workflow**: A specific workflow name that want to find audit logs.
134
+ - **release**: A release date with a string format `%Y%m%d%H%M%S`.
135
+ """
58
136
  return {
59
137
  "message": (
60
138
  f"Getting audit logs with workflow name {workflow} and release "
@@ -62,3 +140,34 @@ async def get_audit_with_workflow_release(workflow: str, release: str):
62
140
  ),
63
141
  "audits": list(get_audit().find_audits(name="demo")),
64
142
  }
143
+
144
+
145
+ @log_route.get(
146
+ path="/audits/{workflow}/{release}/{run_id}",
147
+ response_class=UJSONResponse,
148
+ status_code=st.HTTP_200_OK,
149
+ summary=(
150
+ "Read all audit logs with specific workflow name, release date "
151
+ "and running ID."
152
+ ),
153
+ tags=["audit"],
154
+ )
155
+ async def get_audit_with_workflow_release_run_id(
156
+ workflow: str, release: str, run_id: str
157
+ ):
158
+ """Return all audit logs with specific workflow name and release date from
159
+ the current audit log path that config with `WORKFLOW_AUDIT_PATH`
160
+ environment variable name.
161
+
162
+ - **workflow**: A specific workflow name that want to find audit logs.
163
+ - **release**: A release date with a string format `%Y%m%d%H%M%S`.
164
+ - **run_id**: A running ID that want to search audit log from this release
165
+ date.
166
+ """
167
+ return {
168
+ "message": (
169
+ f"Getting audit logs with workflow name {workflow}, release "
170
+ f"{release}, and running ID {run_id}"
171
+ ),
172
+ "audits": list(get_audit().find_audits(name="demo")),
173
+ }
@@ -15,7 +15,7 @@ from fastapi.responses import UJSONResponse
15
15
  from ...conf import config, get_logger
16
16
  from ...scheduler import Schedule
17
17
 
18
- logger = get_logger("ddeutil.workflow")
18
+ logger = get_logger("uvicorn.error")
19
19
 
20
20
  schedule_route = APIRouter(
21
21
  prefix="/schedules",
@@ -24,7 +24,7 @@ schedule_route = APIRouter(
24
24
  )
25
25
 
26
26
 
27
- @schedule_route.get(path="/{name}")
27
+ @schedule_route.get(path="/{name}", status_code=st.HTTP_200_OK)
28
28
  async def get_schedules(name: str):
29
29
  """Get schedule object."""
30
30
  try:
@@ -42,13 +42,13 @@ async def get_schedules(name: str):
42
42
  )
43
43
 
44
44
 
45
- @schedule_route.get(path="/deploy/")
45
+ @schedule_route.get(path="/deploy/", status_code=st.HTTP_200_OK)
46
46
  async def get_deploy_schedulers(request: Request):
47
47
  snapshot = copy.deepcopy(request.state.scheduler)
48
48
  return {"schedule": snapshot}
49
49
 
50
50
 
51
- @schedule_route.get(path="/deploy/{name}")
51
+ @schedule_route.get(path="/deploy/{name}", status_code=st.HTTP_200_OK)
52
52
  async def get_deploy_scheduler(request: Request, name: str):
53
53
  if name in request.state.scheduler:
54
54
  schedule = Schedule.from_loader(name)
@@ -76,7 +76,7 @@ async def get_deploy_scheduler(request: Request, name: str):
76
76
  )
77
77
 
78
78
 
79
- @schedule_route.post(path="/deploy/{name}")
79
+ @schedule_route.post(path="/deploy/{name}", status_code=st.HTTP_202_ACCEPTED)
80
80
  async def add_deploy_scheduler(request: Request, name: str):
81
81
  """Adding schedule name to application state store."""
82
82
  if name in request.state.scheduler:
@@ -116,7 +116,7 @@ async def add_deploy_scheduler(request: Request, name: str):
116
116
  }
117
117
 
118
118
 
119
- @schedule_route.delete(path="/deploy/{name}")
119
+ @schedule_route.delete(path="/deploy/{name}", status_code=st.HTTP_202_ACCEPTED)
120
120
  async def del_deploy_scheduler(request: Request, name: str):
121
121
  """Delete workflow task on the schedule listener."""
122
122
  if name in request.state.scheduler:
@@ -20,7 +20,7 @@ from ...conf import Loader, get_logger
20
20
  from ...result import Result
21
21
  from ...workflow import Workflow
22
22
 
23
- logger = get_logger("ddeutil.workflow")
23
+ logger = get_logger("uvicorn.error")
24
24
 
25
25
  workflow_route = APIRouter(
26
26
  prefix="/workflows",
@@ -29,7 +29,7 @@ workflow_route = APIRouter(
29
29
  )
30
30
 
31
31
 
32
- @workflow_route.get(path="/")
32
+ @workflow_route.get(path="/", status_code=st.HTTP_200_OK)
33
33
  async def get_workflows() -> DictData:
34
34
  """Return all workflow workflows that exists in config path."""
35
35
  workflows: DictData = dict(Loader.finds(Workflow))
@@ -40,7 +40,7 @@ async def get_workflows() -> DictData:
40
40
  }
41
41
 
42
42
 
43
- @workflow_route.get(path="/{name}")
43
+ @workflow_route.get(path="/{name}", status_code=st.HTTP_200_OK)
44
44
  async def get_workflow_by_name(name: str) -> DictData:
45
45
  """Return model of workflow that passing an input workflow name."""
46
46
  try:
@@ -66,7 +66,7 @@ class ExecutePayload(BaseModel):
66
66
 
67
67
 
68
68
  @workflow_route.post(path="/{name}/execute", status_code=st.HTTP_202_ACCEPTED)
69
- async def execute_workflow(name: str, payload: ExecutePayload) -> DictData:
69
+ async def workflow_execute(name: str, payload: ExecutePayload) -> DictData:
70
70
  """Return model of workflow that passing an input workflow name."""
71
71
  try:
72
72
  workflow: Workflow = Workflow.from_loader(name=name, externals={})
@@ -90,7 +90,7 @@ async def execute_workflow(name: str, payload: ExecutePayload) -> DictData:
90
90
  return asdict(result)
91
91
 
92
92
 
93
- @workflow_route.get(path="/{name}/audits")
93
+ @workflow_route.get(path="/{name}/audits", status_code=st.HTTP_200_OK)
94
94
  async def get_workflow_audits(name: str):
95
95
  try:
96
96
  return {
@@ -112,11 +112,13 @@ async def get_workflow_audits(name: str):
112
112
  ) from None
113
113
 
114
114
 
115
- @workflow_route.get(path="/{name}/audits/{release}")
115
+ @workflow_route.get(path="/{name}/audits/{release}", status_code=st.HTTP_200_OK)
116
116
  async def get_workflow_release_audit(name: str, release: str):
117
+ """Get Workflow audit log with an input release value."""
117
118
  try:
118
119
  audit: Audit = get_audit().find_audit_with_release(
119
- name=name, release=datetime.strptime(release, "%Y%m%d%H%M%S")
120
+ name=name,
121
+ release=datetime.strptime(release, "%Y%m%d%H%M%S"),
120
122
  )
121
123
  except FileNotFoundError:
122
124
  raise HTTPException(
@@ -26,6 +26,7 @@ T = TypeVar("T")
26
26
  P = ParamSpec("P")
27
27
 
28
28
  logger = logging.getLogger("ddeutil.workflow")
29
+ logging.getLogger("asyncio").setLevel(logging.INFO)
29
30
 
30
31
 
31
32
  class TagFunc(Protocol):
@@ -60,10 +61,13 @@ def tag(
60
61
 
61
62
  @wraps(func)
62
63
  def wrapped(*args: P.args, **kwargs: P.kwargs) -> TagFunc:
63
- # NOTE: Able to do anything before calling the call function.
64
64
  return func(*args, **kwargs)
65
65
 
66
- return wrapped
66
+ @wraps(func)
67
+ async def awrapped(*args: P.args, **kwargs: P.kwargs) -> TagFunc:
68
+ return await func(*args, **kwargs)
69
+
70
+ return awrapped if inspect.iscoroutinefunction(func) else wrapped
67
71
 
68
72
  return func_internal
69
73
 
@@ -91,7 +95,9 @@ def make_registry(submodule: str) -> dict[str, Registry]:
91
95
  for fstr, func in inspect.getmembers(importer, inspect.isfunction):
92
96
  # NOTE: check function attribute that already set tag by
93
97
  # ``utils.tag`` decorator.
94
- if not (hasattr(func, "tag") and hasattr(func, "name")):
98
+ if not (
99
+ hasattr(func, "tag") and hasattr(func, "name")
100
+ ): # pragma: no cov
95
101
  continue
96
102
 
97
103
  # NOTE: Define type of the func value.
ddeutil/workflow/conf.py CHANGED
@@ -31,7 +31,6 @@ def glob_files(path: Path) -> Iterator[Path]: # pragma: no cov
31
31
 
32
32
 
33
33
  __all__: TupleStr = (
34
- "LOGGING_CONFIG",
35
34
  "env",
36
35
  "get_logger",
37
36
  "Config",
@@ -422,62 +421,3 @@ def get_logger(name: str):
422
421
 
423
422
  logger.setLevel(logging.DEBUG if config.debug else logging.INFO)
424
423
  return logger
425
-
426
-
427
- LOGGING_CONFIG = { # pragma: no cov
428
- "version": 1,
429
- "disable_existing_loggers": False,
430
- "formatters": {
431
- "standard": {
432
- "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
433
- },
434
- "custom_formatter": {
435
- "format": config.log_format,
436
- "datefmt": config.log_datetime_format,
437
- },
438
- },
439
- "root": {
440
- "level": "DEBUG" if config.debug else "INFO",
441
- },
442
- "handlers": {
443
- "default": {
444
- "formatter": "standard",
445
- "class": "logging.StreamHandler",
446
- "stream": "ext://sys.stderr",
447
- },
448
- "stream_handler": {
449
- "formatter": "custom_formatter",
450
- "class": "logging.StreamHandler",
451
- "stream": "ext://sys.stdout",
452
- },
453
- "file_handler": {
454
- "formatter": "custom_formatter",
455
- "class": "logging.handlers.RotatingFileHandler",
456
- "filename": "logs/app.log",
457
- "maxBytes": 1024 * 1024 * 1,
458
- "backupCount": 3,
459
- },
460
- },
461
- "loggers": {
462
- "uvicorn": {
463
- "handlers": ["default", "file_handler"],
464
- "level": "DEBUG" if config.debug else "INFO",
465
- "propagate": False,
466
- },
467
- "uvicorn.access": {
468
- "handlers": ["stream_handler", "file_handler"],
469
- "level": "DEBUG" if config.debug else "INFO",
470
- "propagate": False,
471
- },
472
- "uvicorn.error": {
473
- "handlers": ["stream_handler", "file_handler"],
474
- "level": "DEBUG" if config.debug else "INFO",
475
- "propagate": False,
476
- },
477
- # "uvicorn.asgi": {
478
- # "handlers": ["stream_handler", "file_handler"],
479
- # "level": "TRACE",
480
- # "propagate": False,
481
- # },
482
- },
483
- }
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Union
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from .__types import DictData
8
+
9
+
10
+ class ErrorContext(BaseModel): # pragma: no cov
11
+ model_config = ConfigDict(arbitrary_types_allowed=True)
12
+
13
+ obj: Exception = Field(alias="class")
14
+ name: str = Field(description="A name of exception class.")
15
+ message: str = Field(description="A exception message.")
16
+
17
+
18
+ class OutputContext(BaseModel): # pragma: no cov
19
+ outputs: DictData = Field(default_factory=dict)
20
+ errors: Optional[ErrorContext] = Field(default=None)
21
+
22
+ def is_exception(self) -> bool:
23
+ return self.errors is not None
24
+
25
+
26
+ class StageContext(BaseModel): # pragma: no cov
27
+ stages: dict[str, OutputContext]
28
+ errors: Optional[ErrorContext] = Field(default=None)
29
+
30
+ def is_exception(self) -> bool:
31
+ return self.errors is not None
32
+
33
+
34
+ class MatrixContext(StageContext): # pragma: no cov
35
+ matrix: DictData = Field(default_factory=dict)
36
+
37
+
38
+ MatrixStageContext = dict[
39
+ str, Union[MatrixContext, StageContext]
40
+ ] # pragma: no cov
41
+
42
+
43
+ class StrategyContext(BaseModel): # pragma: no cov
44
+ strategies: MatrixStageContext
45
+ errors: Optional[ErrorContext] = Field(default=None)
46
+
47
+ def is_exception(self) -> bool:
48
+ return self.errors is not None
49
+
50
+
51
+ StrategyMatrixContext = Union[
52
+ StrategyContext, MatrixStageContext
53
+ ] # pragma: no cov
54
+
55
+
56
+ class JobContext(BaseModel): # pragma: no cov
57
+ params: DictData = Field(description="A parameterize value")
58
+ jobs: dict[str, StrategyMatrixContext]
59
+ errors: Optional[ErrorContext] = Field(default=None)
@@ -9,8 +9,21 @@ annotate for handle error only.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ from typing import Any
12
13
 
13
- class BaseWorkflowException(Exception): ...
14
+
15
+ def to_dict(exception: Exception) -> dict[str, Any]: # pragma: no cov
16
+ return {
17
+ "class": exception,
18
+ "name": exception.__class__.__name__,
19
+ "message": str(exception),
20
+ }
21
+
22
+
23
+ class BaseWorkflowException(Exception):
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ return to_dict(self)
14
27
 
15
28
 
16
29
  class UtilException(BaseWorkflowException): ...