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.
@@ -1 +1 @@
1
- __version__: str = "0.0.34"
1
+ __version__: str = "0.0.36"
@@ -9,7 +9,7 @@ from .audit import (
9
9
  Audit,
10
10
  get_audit,
11
11
  )
12
- from .call import (
12
+ from .caller import (
13
13
  ReturnTagFunc,
14
14
  TagFunc,
15
15
  extract_call,
@@ -37,8 +37,15 @@ from .exceptions import (
37
37
  )
38
38
  from .job import (
39
39
  Job,
40
+ RunsOn,
40
41
  Strategy,
41
42
  )
43
+ from .logs import (
44
+ TraceData,
45
+ TraceLog,
46
+ get_dt_tznow,
47
+ get_trace,
48
+ )
42
49
  from .params import (
43
50
  ChoiceParam,
44
51
  DatetimeParam,
@@ -49,9 +56,7 @@ from .params import (
49
56
  from .result import (
50
57
  Result,
51
58
  Status,
52
- TraceLog,
53
59
  default_gen_id,
54
- get_dt_tznow,
55
60
  )
56
61
  from .scheduler import (
57
62
  Schedule,
@@ -11,7 +11,11 @@ from datetime import datetime, timedelta
11
11
  from typing import TypedDict
12
12
 
13
13
  from dotenv import load_dotenv
14
- from fastapi import FastAPI
14
+ from fastapi import FastAPI, Request
15
+ from fastapi import status as st
16
+ from fastapi.encoders import jsonable_encoder
17
+ from fastapi.exceptions import RequestValidationError
18
+ from fastapi.middleware.cors import CORSMiddleware
15
19
  from fastapi.middleware.gzip import GZipMiddleware
16
20
  from fastapi.responses import UJSONResponse
17
21
 
@@ -20,6 +24,7 @@ from ..conf import config, get_logger
20
24
  from ..scheduler import ReleaseThread, ReleaseThreads
21
25
  from ..workflow import ReleaseQueue, WorkflowTask
22
26
  from .repeat import repeat_at
27
+ from .routes import job, log
23
28
 
24
29
  load_dotenv()
25
30
  logger = get_logger("ddeutil.workflow")
@@ -60,39 +65,57 @@ async def lifespan(a: FastAPI) -> AsyncIterator[State]:
60
65
 
61
66
 
62
67
  app = FastAPI(
63
- titile="Workflow API",
68
+ titile="Workflow",
64
69
  description=(
65
- "This is workflow FastAPI web application that use to manage manual "
66
- "execute or schedule workflow via RestAPI."
70
+ "This is a workflow FastAPI application that use to manage manual "
71
+ "execute, logging, and schedule workflow via RestAPI."
67
72
  ),
68
73
  version=__version__,
69
74
  lifespan=lifespan,
70
75
  default_response_class=UJSONResponse,
71
76
  )
72
77
  app.add_middleware(GZipMiddleware, minimum_size=1000)
78
+ origins: list[str] = [
79
+ "http://localhost",
80
+ "http://localhost:88",
81
+ "http://localhost:80",
82
+ ]
83
+ app.add_middleware(
84
+ CORSMiddleware,
85
+ allow_origins=origins,
86
+ allow_credentials=True,
87
+ allow_methods=["*"],
88
+ allow_headers=["*"],
89
+ )
73
90
 
74
91
 
75
92
  @app.get("/")
76
93
  async def health():
77
- return {"message": "Workflow API already start up"}
94
+ """Index view that not return any template without json status."""
95
+ return {"message": "Workflow already start up with healthy status."}
96
+
78
97
 
98
+ # NOTE Add the jobs and logs routes by default.
99
+ app.include_router(job, prefix=config.prefix_path)
100
+ app.include_router(log, prefix=config.prefix_path)
79
101
 
80
- # NOTE: Enable the workflow route.
102
+
103
+ # NOTE: Enable the workflows route.
81
104
  if config.enable_route_workflow:
82
- from .route import workflow_route
105
+ from .routes import workflow
83
106
 
84
- app.include_router(workflow_route, prefix=config.prefix_path)
107
+ app.include_router(workflow, prefix=config.prefix_path)
85
108
 
86
109
 
87
- # NOTE: Enable the schedule route.
110
+ # NOTE: Enable the schedules route.
88
111
  if config.enable_route_schedule:
89
112
  from ..audit import get_audit
90
113
  from ..scheduler import schedule_task
91
- from .route import schedule_route
114
+ from .routes import schedule
92
115
 
93
- app.include_router(schedule_route, prefix=config.prefix_path)
116
+ app.include_router(schedule, prefix=config.prefix_path)
94
117
 
95
- @schedule_route.on_event("startup")
118
+ @schedule.on_event("startup")
96
119
  @repeat_at(cron="* * * * *", delay=2)
97
120
  def scheduler_listener():
98
121
  """Schedule broker every minute at 02 second."""
@@ -106,12 +129,13 @@ if config.enable_route_schedule:
106
129
  stop=datetime.now(config.tz) + timedelta(minutes=1),
107
130
  queue=app.state.workflow_queue,
108
131
  threads=app.state.workflow_threads,
109
- log=get_audit(),
132
+ audit=get_audit(),
110
133
  )
111
134
 
112
- @schedule_route.on_event("startup")
135
+ @schedule.on_event("startup")
113
136
  @repeat_at(cron="*/5 * * * *", delay=10)
114
137
  def monitoring():
138
+ """Monitoring workflow thread that running in the background."""
115
139
  logger.debug("[MONITOR]: Start monitoring threading.")
116
140
  snapshot_threads: list[str] = list(app.state.workflow_threads.keys())
117
141
  for t_name in snapshot_threads:
@@ -121,3 +145,23 @@ if config.enable_route_schedule:
121
145
  # NOTE: remove the thread that running success.
122
146
  if not thread_release["thread"].is_alive():
123
147
  app.state.workflow_threads.pop(t_name)
148
+
149
+
150
+ @app.exception_handler(RequestValidationError)
151
+ async def validation_exception_handler(
152
+ request: Request, exc: RequestValidationError
153
+ ):
154
+ return UJSONResponse(
155
+ status_code=st.HTTP_422_UNPROCESSABLE_ENTITY,
156
+ content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
157
+ )
158
+
159
+
160
+ if __name__ == "__main__":
161
+ import uvicorn
162
+
163
+ uvicorn.run(
164
+ app,
165
+ host="0.0.0.0",
166
+ port=80,
167
+ )
@@ -21,17 +21,26 @@ logger = get_logger("ddeutil.workflow")
21
21
  def get_cronjob_delta(cron: str) -> float:
22
22
  """This function returns the time delta between now and the next cron
23
23
  execution time.
24
+
25
+ :rtype: float
24
26
  """
25
27
  now: datetime = datetime.now(tz=config.tz)
26
28
  cron = CronJob(cron)
27
29
  return (cron.schedule(now).next - now).total_seconds()
28
30
 
29
31
 
30
- def cron_valid(cron: str):
32
+ def cron_valid(cron: str, raise_error: bool = True) -> bool:
33
+ """Check this crontab string value is valid with its cron syntax.
34
+
35
+ :rtype: bool
36
+ """
31
37
  try:
32
38
  CronJob(cron)
39
+ return True
33
40
  except Exception as err:
34
- raise ValueError(f"Crontab value does not valid, {cron}") from err
41
+ if raise_error:
42
+ raise ValueError(f"Crontab value does not valid, {cron}") from err
43
+ return False
35
44
 
36
45
 
37
46
  async def run_func(
@@ -41,6 +50,7 @@ async def run_func(
41
50
  raise_exceptions: bool = False,
42
51
  **kwargs,
43
52
  ):
53
+ """Run function inside the repeat decorator functions."""
44
54
  try:
45
55
  if is_coroutine:
46
56
  await func(*args, **kwargs)
@@ -62,11 +72,11 @@ def repeat_at(
62
72
  """This function returns a decorator that makes a function execute
63
73
  periodically as per the cron expression provided.
64
74
 
65
- :param cron: str
66
- Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
67
- :param delay:
68
- :param raise_exceptions: bool (default False)
69
- Whether to raise exceptions or log them
75
+ :param cron: (str) A Cron-style string for periodic execution, e.g.
76
+ '0 0 * * *' every midnight
77
+ :param delay: (float) A delay seconds value.
78
+ :param raise_exceptions: (bool) A raise exception flag. Whether to raise
79
+ exceptions or log them if raise was set be false.
70
80
  :param max_repetitions: int (default None)
71
81
  Maximum number of times to repeat the function. If None, repeat
72
82
  indefinitely.
@@ -81,12 +91,12 @@ def repeat_at(
81
91
 
82
92
  @wraps(func)
83
93
  def wrapper(*_args, **_kwargs):
84
- repititions: int = 0
94
+ repetitions: int = 0
85
95
  cron_valid(cron)
86
96
 
87
97
  async def loop(*args, **kwargs):
88
- nonlocal repititions
89
- while max_repetitions is None or repititions < max_repetitions:
98
+ nonlocal repetitions
99
+ while max_repetitions is None or repetitions < max_repetitions:
90
100
  sleep_time = get_cronjob_delta(cron) + delay
91
101
  await asyncio.sleep(sleep_time)
92
102
  await run_func(
@@ -96,7 +106,7 @@ def repeat_at(
96
106
  raise_exceptions=raise_exceptions,
97
107
  **kwargs,
98
108
  )
99
- repititions += 1
109
+ repetitions += 1
100
110
 
101
111
  ensure_future(loop(*_args, **_kwargs))
102
112
 
@@ -0,0 +1,9 @@
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
+ from .job import job_route as job
7
+ from .logs import log_route as log
8
+ from .schedules import schedule_route as schedule
9
+ from .workflows import workflow_route as workflow
@@ -0,0 +1,73 @@
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
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from fastapi import APIRouter
11
+ from fastapi.responses import UJSONResponse
12
+ from pydantic import BaseModel
13
+
14
+ from ...__types import DictData
15
+ from ...conf import get_logger
16
+ from ...exceptions import JobException
17
+ from ...job import Job
18
+ from ...result import Result
19
+
20
+ logger = get_logger("ddeutil.workflow")
21
+
22
+
23
+ job_route = APIRouter(
24
+ prefix="/job",
25
+ tags=["job"],
26
+ default_response_class=UJSONResponse,
27
+ )
28
+
29
+
30
+ class ResultPost(BaseModel):
31
+ context: DictData
32
+ run_id: str
33
+ parent_run_id: Optional[str] = None
34
+
35
+
36
+ @job_route.post(path="/execute/")
37
+ async def job_execute(
38
+ result: ResultPost,
39
+ job: Job,
40
+ params: dict[str, Any],
41
+ ):
42
+ """Execute job via API."""
43
+ rs: Result = Result(
44
+ context=result.context,
45
+ run_id=result.run_id,
46
+ parent_run_id=result.parent_run_id,
47
+ )
48
+ try:
49
+ job.set_outputs(
50
+ job.execute(
51
+ params=params,
52
+ run_id=rs.run_id,
53
+ parent_run_id=rs.parent_run_id,
54
+ ).context,
55
+ to=params,
56
+ )
57
+ except JobException as err:
58
+ rs.trace.error(f"[WORKFLOW]: {err.__class__.__name__}: {err}")
59
+
60
+ return {
61
+ "message": "Start execute job via API.",
62
+ "result": {
63
+ "run_id": rs.run_id,
64
+ "parent_run_id": rs.parent_run_id,
65
+ },
66
+ "job": job.model_dump(
67
+ by_alias=True,
68
+ exclude_none=True,
69
+ exclude_unset=True,
70
+ exclude_defaults=True,
71
+ ),
72
+ "params": params,
73
+ }
@@ -0,0 +1,64 @@
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
+ """This route include audit and trace log paths."""
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter
10
+ from fastapi.responses import UJSONResponse
11
+
12
+ from ...audit import get_audit
13
+ from ...logs import get_trace_obj
14
+
15
+ log_route = APIRouter(
16
+ prefix="/logs",
17
+ tags=["logs", "trace", "audit"],
18
+ default_response_class=UJSONResponse,
19
+ )
20
+
21
+
22
+ @log_route.get(path="/trace/")
23
+ async def get_traces():
24
+ """Get all trace logs."""
25
+ return {
26
+ "message": "Getting trace logs",
27
+ "traces": list(get_trace_obj().find_logs()),
28
+ }
29
+
30
+
31
+ @log_route.get(path="/trace/{run_id}")
32
+ 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)
35
+
36
+
37
+ @log_route.get(path="/audit/")
38
+ async def get_audits():
39
+ """Get all audit logs."""
40
+ return {
41
+ "message": "Getting audit logs",
42
+ "audits": list(get_audit().find_audits(name="demo")),
43
+ }
44
+
45
+
46
+ @log_route.get(path="/audit/{workflow}/")
47
+ async def get_audit_with_workflow(workflow: str):
48
+ """Get all audit logs."""
49
+ return {
50
+ "message": f"Getting audit logs with workflow name {workflow}",
51
+ "audits": list(get_audit().find_audits(name="demo")),
52
+ }
53
+
54
+
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."""
58
+ return {
59
+ "message": (
60
+ f"Getting audit logs with workflow name {workflow} and release "
61
+ f"{release}"
62
+ ),
63
+ "audits": list(get_audit().find_audits(name="demo")),
64
+ }
@@ -6,30 +6,17 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import copy
9
- from dataclasses import asdict
10
9
  from datetime import datetime, timedelta
11
- from typing import Any
12
10
 
13
11
  from fastapi import APIRouter, HTTPException, Request
14
12
  from fastapi import status as st
15
13
  from fastapi.responses import UJSONResponse
16
- from pydantic import BaseModel
17
14
 
18
- from ..__types import DictData
19
- from ..audit import Audit, get_audit
20
- from ..conf import Loader, config, get_logger
21
- from ..result import Result
22
- from ..scheduler import Schedule
23
- from ..workflow import Workflow
15
+ from ...conf import config, get_logger
16
+ from ...scheduler import Schedule
24
17
 
25
18
  logger = get_logger("ddeutil.workflow")
26
19
 
27
- workflow_route = APIRouter(
28
- prefix="/workflows",
29
- tags=["workflows"],
30
- default_response_class=UJSONResponse,
31
- )
32
-
33
20
  schedule_route = APIRouter(
34
21
  prefix="/schedules",
35
22
  tags=["schedules"],
@@ -37,124 +24,9 @@ schedule_route = APIRouter(
37
24
  )
38
25
 
39
26
 
40
- @workflow_route.get(path="/")
41
- async def get_workflows() -> DictData:
42
- """Return all workflow workflows that exists in config path."""
43
- workflows: DictData = dict(Loader.finds(Workflow))
44
- return {
45
- "message": f"Getting all workflows: {len(workflows)}",
46
- "count": len(workflows),
47
- "workflows": workflows,
48
- }
49
-
50
-
51
- @workflow_route.get(path="/{name}")
52
- async def get_workflow_by_name(name: str) -> DictData:
53
- """Return model of workflow that passing an input workflow name."""
54
- try:
55
- workflow: Workflow = Workflow.from_loader(name=name, externals={})
56
- except ValueError as err:
57
- logger.exception(err)
58
- raise HTTPException(
59
- status_code=st.HTTP_404_NOT_FOUND,
60
- detail=(
61
- f"Workflow workflow name: {name!r} does not found in /conf path"
62
- ),
63
- ) from None
64
- return workflow.model_dump(
65
- by_alias=True,
66
- exclude_none=True,
67
- exclude_unset=True,
68
- exclude_defaults=True,
69
- )
70
-
71
-
72
- class ExecutePayload(BaseModel):
73
- params: dict[str, Any]
74
-
75
-
76
- @workflow_route.post(path="/{name}/execute", status_code=st.HTTP_202_ACCEPTED)
77
- async def execute_workflow(name: str, payload: ExecutePayload) -> DictData:
78
- """Return model of workflow that passing an input workflow name."""
79
- try:
80
- workflow: Workflow = Workflow.from_loader(name=name, externals={})
81
- except ValueError:
82
- raise HTTPException(
83
- status_code=st.HTTP_404_NOT_FOUND,
84
- detail=(
85
- f"Workflow workflow name: {name!r} does not found in /conf path"
86
- ),
87
- ) from None
88
-
89
- # NOTE: Start execute manually
90
- try:
91
- result: Result = workflow.execute(params=payload.params)
92
- except Exception as err:
93
- raise HTTPException(
94
- status_code=st.HTTP_500_INTERNAL_SERVER_ERROR,
95
- detail=f"{type(err)}: {err}",
96
- ) from None
97
-
98
- return asdict(result)
99
-
100
-
101
- @workflow_route.get(path="/{name}/logs")
102
- async def get_workflow_logs(name: str):
103
- try:
104
- return {
105
- "message": f"Getting workflow {name!r} logs",
106
- "logs": [
107
- log.model_dump(
108
- by_alias=True,
109
- exclude_none=True,
110
- exclude_unset=True,
111
- exclude_defaults=True,
112
- )
113
- for log in get_audit().find_audits(name=name)
114
- ],
115
- }
116
- except FileNotFoundError:
117
- raise HTTPException(
118
- status_code=st.HTTP_404_NOT_FOUND,
119
- detail=f"Does not found log for workflow {name!r}",
120
- ) from None
121
-
122
-
123
- @workflow_route.get(path="/{name}/logs/{release}")
124
- async def get_workflow_release_log(name: str, release: str):
125
- try:
126
- log: Audit = get_audit().find_audit_with_release(
127
- name=name, release=datetime.strptime(release, "%Y%m%d%H%M%S")
128
- )
129
- except FileNotFoundError:
130
- raise HTTPException(
131
- status_code=st.HTTP_404_NOT_FOUND,
132
- detail=(
133
- f"Does not found log for workflow {name!r} "
134
- f"with release {release!r}"
135
- ),
136
- ) from None
137
- return {
138
- "message": f"Getting workflow {name!r} log in release {release}",
139
- "log": log.model_dump(
140
- by_alias=True,
141
- exclude_none=True,
142
- exclude_unset=True,
143
- exclude_defaults=True,
144
- ),
145
- }
146
-
147
-
148
- @workflow_route.delete(
149
- path="/{name}/logs/{release}",
150
- status_code=st.HTTP_204_NO_CONTENT,
151
- )
152
- async def del_workflow_release_log(name: str, release: str):
153
- return {"message": f"Deleted workflow {name!r} log in release {release}"}
154
-
155
-
156
27
  @schedule_route.get(path="/{name}")
157
28
  async def get_schedules(name: str):
29
+ """Get schedule object."""
158
30
  try:
159
31
  schedule: Schedule = Schedule.from_loader(name=name, externals={})
160
32
  except ValueError:
@@ -0,0 +1,137 @@
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
+ from __future__ import annotations
7
+
8
+ from dataclasses import asdict
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from fastapi import status as st
14
+ from fastapi.responses import UJSONResponse
15
+ from pydantic import BaseModel
16
+
17
+ from ...__types import DictData
18
+ from ...audit import Audit, get_audit
19
+ from ...conf import Loader, get_logger
20
+ from ...result import Result
21
+ from ...workflow import Workflow
22
+
23
+ logger = get_logger("ddeutil.workflow")
24
+
25
+ workflow_route = APIRouter(
26
+ prefix="/workflows",
27
+ tags=["workflows"],
28
+ default_response_class=UJSONResponse,
29
+ )
30
+
31
+
32
+ @workflow_route.get(path="/")
33
+ async def get_workflows() -> DictData:
34
+ """Return all workflow workflows that exists in config path."""
35
+ workflows: DictData = dict(Loader.finds(Workflow))
36
+ return {
37
+ "message": f"Getting all workflows: {len(workflows)}",
38
+ "count": len(workflows),
39
+ "workflows": workflows,
40
+ }
41
+
42
+
43
+ @workflow_route.get(path="/{name}")
44
+ async def get_workflow_by_name(name: str) -> DictData:
45
+ """Return model of workflow that passing an input workflow name."""
46
+ try:
47
+ workflow: Workflow = Workflow.from_loader(name=name, externals={})
48
+ except ValueError as err:
49
+ logger.exception(err)
50
+ raise HTTPException(
51
+ status_code=st.HTTP_404_NOT_FOUND,
52
+ detail=(
53
+ f"Workflow workflow name: {name!r} does not found in /conf path"
54
+ ),
55
+ ) from None
56
+ return workflow.model_dump(
57
+ by_alias=True,
58
+ exclude_none=True,
59
+ exclude_unset=True,
60
+ exclude_defaults=True,
61
+ )
62
+
63
+
64
+ class ExecutePayload(BaseModel):
65
+ params: dict[str, Any]
66
+
67
+
68
+ @workflow_route.post(path="/{name}/execute", status_code=st.HTTP_202_ACCEPTED)
69
+ async def execute_workflow(name: str, payload: ExecutePayload) -> DictData:
70
+ """Return model of workflow that passing an input workflow name."""
71
+ try:
72
+ workflow: Workflow = Workflow.from_loader(name=name, externals={})
73
+ except ValueError:
74
+ raise HTTPException(
75
+ status_code=st.HTTP_404_NOT_FOUND,
76
+ detail=(
77
+ f"Workflow workflow name: {name!r} does not found in /conf path"
78
+ ),
79
+ ) from None
80
+
81
+ # NOTE: Start execute manually
82
+ try:
83
+ result: Result = workflow.execute(params=payload.params)
84
+ except Exception as err:
85
+ raise HTTPException(
86
+ status_code=st.HTTP_500_INTERNAL_SERVER_ERROR,
87
+ detail=f"{type(err)}: {err}",
88
+ ) from None
89
+
90
+ return asdict(result)
91
+
92
+
93
+ @workflow_route.get(path="/{name}/audits")
94
+ async def get_workflow_audits(name: str):
95
+ try:
96
+ return {
97
+ "message": f"Getting workflow {name!r} audits",
98
+ "audits": [
99
+ audit.model_dump(
100
+ by_alias=True,
101
+ exclude_none=True,
102
+ exclude_unset=True,
103
+ exclude_defaults=True,
104
+ )
105
+ for audit in get_audit().find_audits(name=name)
106
+ ],
107
+ }
108
+ except FileNotFoundError:
109
+ raise HTTPException(
110
+ status_code=st.HTTP_404_NOT_FOUND,
111
+ detail=f"Does not found audit for workflow {name!r}",
112
+ ) from None
113
+
114
+
115
+ @workflow_route.get(path="/{name}/audits/{release}")
116
+ async def get_workflow_release_audit(name: str, release: str):
117
+ try:
118
+ audit: Audit = get_audit().find_audit_with_release(
119
+ name=name, release=datetime.strptime(release, "%Y%m%d%H%M%S")
120
+ )
121
+ except FileNotFoundError:
122
+ raise HTTPException(
123
+ status_code=st.HTTP_404_NOT_FOUND,
124
+ detail=(
125
+ f"Does not found audit for workflow {name!r} "
126
+ f"with release {release!r}"
127
+ ),
128
+ ) from None
129
+ return {
130
+ "message": f"Getting workflow {name!r} audit in release {release}",
131
+ "audit": audit.model_dump(
132
+ by_alias=True,
133
+ exclude_none=True,
134
+ exclude_unset=True,
135
+ exclude_defaults=True,
136
+ ),
137
+ }