ddeutil-workflow 0.0.81__py3-none-any.whl → 0.0.82__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,2 @@
1
- __version__: str = "0.0.81"
1
+ __version__: str = "0.0.82"
2
+ __python_version__: str = "3.9"
@@ -50,11 +50,25 @@ Note:
50
50
  from .__cron import CronRunner
51
51
  from .__types import DictData, DictStr, Matrix, Re, TupleStr
52
52
  from .audits import (
53
+ EVENT,
54
+ FORCE,
55
+ NORMAL,
56
+ RERUN,
53
57
  Audit,
54
58
  FileAudit,
55
59
  get_audit,
56
60
  )
57
- from .conf import *
61
+ from .conf import (
62
+ PREFIX,
63
+ CallerSecret,
64
+ Config,
65
+ YamlParser,
66
+ api_config,
67
+ config,
68
+ dynamic,
69
+ env,
70
+ pass_env,
71
+ )
58
72
  from .errors import (
59
73
  BaseError,
60
74
  JobCancelError,
@@ -63,6 +77,9 @@ from .errors import (
63
77
  ResultError,
64
78
  StageCancelError,
65
79
  StageError,
80
+ StageNestedCancelError,
81
+ StageNestedError,
82
+ StageNestedSkipError,
66
83
  StageSkipError,
67
84
  UtilError,
68
85
  WorkflowCancelError,
@@ -132,15 +149,11 @@ from .stages import (
132
149
  VirtualPyStage,
133
150
  )
134
151
  from .traces import (
135
- TraceManager,
152
+ Trace,
136
153
  get_trace,
137
154
  )
138
155
  from .utils import *
139
156
  from .workflow import (
140
- EVENT,
141
- FORCE,
142
- NORMAL,
143
- RERUN,
144
157
  ReleaseType,
145
158
  Workflow,
146
159
  )
@@ -1,4 +1,283 @@
1
- from .cli import app
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
+ import json
9
+ from pathlib import Path
10
+ from platform import python_version
11
+ from textwrap import dedent
12
+ from typing import Annotated, Any, Literal, Optional, Union
13
+
14
+ import typer
15
+ from pydantic import Field, TypeAdapter
16
+
17
+ from .__about__ import __version__
18
+ from .__types import DictData
19
+ from .conf import config
20
+ from .errors import JobError
21
+ from .job import Job
22
+ from .params import Param
23
+ from .workflow import Workflow
24
+
25
+ app = typer.Typer(pretty_exceptions_enable=True)
26
+
27
+
28
+ @app.callback()
29
+ def callback() -> None:
30
+ """Manage Workflow Orchestration CLI.
31
+
32
+ Use it with the interface workflow engine.
33
+ """
34
+
35
+
36
+ @app.command()
37
+ def version() -> None:
38
+ """Get the ddeutil-workflow package version."""
39
+ typer.echo(f"ddeutil-workflow=={__version__}")
40
+ typer.echo(f"python-version=={python_version()}")
41
+
42
+
43
+ @app.command()
44
+ def init() -> None:
45
+ """Initialize a Workflow structure on the current context."""
46
+ config.conf_path.mkdir(exist_ok=True)
47
+ (config.conf_path / ".confignore").touch()
48
+
49
+ conf_example_path: Path = config.conf_path / "examples"
50
+ conf_example_path.mkdir(exist_ok=True)
51
+
52
+ example_template: Path = conf_example_path / "wf_examples.yml"
53
+ example_template.write_text(
54
+ dedent(
55
+ """
56
+ # Example workflow template.
57
+ name: wf-example:
58
+ type: Workflow
59
+ desc: |
60
+ An example workflow template that provide the demo of workflow.
61
+ params:
62
+ name:
63
+ type: str
64
+ default: "World"
65
+ jobs:
66
+ first-job:
67
+ stages:
68
+
69
+ - name: "Hello Stage"
70
+ echo: "Start say hi to the console"
71
+
72
+ - name: "Call tasks"
73
+ uses: tasks/say-hello-func@example
74
+ with:
75
+ name: ${{ params.name }}
76
+
77
+ second-job:
78
+
79
+ - name: "Hello Env"
80
+ echo: "Start say hi with ${ WORKFLOW_DEMO_HELLO }"
81
+ """
82
+ ).lstrip("\n")
83
+ )
84
+
85
+ if "." in config.registry_caller:
86
+ task_path = Path("./tasks")
87
+ task_path.mkdir(exist_ok=True)
88
+
89
+ dummy_tasks_path = task_path / "example.py"
90
+ dummy_tasks_path.write_text(
91
+ dedent(
92
+ """
93
+ from typing import Any, Optional
94
+
95
+ from ddeutil.workflow import Result, tag
96
+
97
+ @tag(name="example", alias="say-hello-func")
98
+ def hello_world_task(name: str, rs: Result, extras: Optional[dict[str, Any]] = None) -> dict[str, str]:
99
+ \"\"\"Logging hello task function\"\"\"
100
+ _extras = extras or {}
101
+ # NOTE: I will use custom newline logging if you pass `||`.
102
+ rs.trace.info(
103
+ f"Hello, {name}||"
104
+ f"> running ID: {rs.run_id}"
105
+ f"> extras: {_extras}"
106
+ )
107
+ return {"name": name}
108
+ """
109
+ ).lstrip("\n")
110
+ )
111
+
112
+ init_path = task_path / "__init__.py"
113
+ init_path.write_text("from .example import hello_world_task\n")
114
+
115
+ dotenv_file = Path(".env")
116
+ mode: str = "a" if dotenv_file.exists() else "w"
117
+ with dotenv_file.open(mode=mode) as f:
118
+ f.write("\n# Workflow Environment Variables\n")
119
+ f.write(
120
+ "WORKFLOW_DEMO_HELLO=foo\n"
121
+ "WORKFLOW_CORE_DEBUG_MODE=true\n"
122
+ "WORKFLOW_LOG_TIMEZONE=Asia/Bangkok\n"
123
+ 'WORKFLOW_LOG_TRACE_HANDLERS=\'[{"type": "console"}]\'\n'
124
+ 'WORKFLOW_LOG_AUDIT_CONF=\'{"type": "file", "path": "./audits"}\''
125
+ "WORKFLOW_LOG_AUDIT_ENABLE_WRITE=true\n"
126
+ )
127
+
128
+ typer.echo("Starter command:")
129
+ typer.echo(
130
+ ">>> `source .env && workflow-cli workflows execute --name=wf-example`"
131
+ )
132
+
133
+
134
+ @app.command(name="job")
135
+ def execute_job(
136
+ params: Annotated[str, typer.Option(help="A job execute parameters")],
137
+ job: Annotated[str, typer.Option(help="A job model")],
138
+ run_id: Annotated[str, typer.Option(help="A running ID")],
139
+ ) -> None:
140
+ """Job execution on the local.
141
+
142
+ Example:
143
+ ... workflow-cli job --params \"{\\\"test\\\": 1}\"
144
+ """
145
+ try:
146
+ params_dict: dict[str, Any] = json.loads(params)
147
+ except json.JSONDecodeError as e:
148
+ raise ValueError(f"Params does not support format: {params!r}.") from e
149
+
150
+ try:
151
+ job_dict: dict[str, Any] = json.loads(job)
152
+ _job: Job = Job.model_validate(obj=job_dict)
153
+ except json.JSONDecodeError as e:
154
+ raise ValueError(f"Jobs does not support format: {job!r}.") from e
155
+
156
+ typer.echo(f"Job params: {params_dict}")
157
+ context: DictData = {}
158
+ try:
159
+ _job.set_outputs(
160
+ _job.execute(params=params_dict, run_id=run_id).context,
161
+ to=context,
162
+ )
163
+ typer.echo("[JOB]: Context result:")
164
+ typer.echo(json.dumps(context, default=str, indent=0))
165
+ except JobError as err:
166
+ typer.echo(f"[JOB]: {err.__class__.__name__}: {err}")
167
+
168
+
169
+ @app.command()
170
+ def api(
171
+ host: Annotated[str, typer.Option(help="A host url.")] = "0.0.0.0",
172
+ port: Annotated[int, typer.Option(help="A port url.")] = 80,
173
+ debug: Annotated[bool, typer.Option(help="A debug mode flag")] = True,
174
+ workers: Annotated[int, typer.Option(help="A worker number")] = None,
175
+ reload: Annotated[bool, typer.Option(help="A reload flag")] = False,
176
+ ) -> None:
177
+ """
178
+ Provision API application from the FastAPI.
179
+ """
180
+ import uvicorn
181
+
182
+ from .api import app as fastapp
183
+ from .api.log_conf import LOGGING_CONFIG
184
+
185
+ # LOGGING_CONFIG = {}
186
+
187
+ uvicorn.run(
188
+ fastapp,
189
+ host=host,
190
+ port=port,
191
+ log_config=uvicorn.config.LOGGING_CONFIG | LOGGING_CONFIG,
192
+ # NOTE: Logging level of uvicorn should be lowered case.
193
+ log_level=("debug" if debug else "info"),
194
+ workers=workers,
195
+ reload=reload,
196
+ )
197
+
198
+
199
+ @app.command()
200
+ def make(
201
+ name: Annotated[Path, typer.Argument()],
202
+ ) -> None:
203
+ """
204
+ Create Workflow YAML template.
205
+
206
+ :param name:
207
+ """
208
+ typer.echo(f"Start create YAML template filename: {name.resolve()}")
209
+
210
+
211
+ workflow_app = typer.Typer()
212
+ app.add_typer(workflow_app, name="workflows", help="An Only Workflow CLI.")
213
+
214
+
215
+ @workflow_app.callback()
216
+ def workflow_callback():
217
+ """Manage Only Workflow CLI."""
218
+
219
+
220
+ @workflow_app.command(name="execute")
221
+ def workflow_execute(
222
+ name: Annotated[
223
+ str,
224
+ typer.Option(help="A name of workflow template."),
225
+ ],
226
+ params: Annotated[
227
+ str,
228
+ typer.Option(help="A workflow execute parameters"),
229
+ ] = "{}",
230
+ ):
231
+ """Execute workflow by passing a workflow template name."""
232
+ try:
233
+ params_dict: dict[str, Any] = json.loads(params)
234
+ except json.JSONDecodeError as e:
235
+ raise ValueError(f"Params does not support format: {params!r}.") from e
236
+
237
+ typer.echo(f"Start execute workflow template: {name}")
238
+ typer.echo(f"... with params: {params_dict}")
239
+
240
+
241
+ class WorkflowSchema(Workflow):
242
+ """Override workflow model fields for generate JSON schema file."""
243
+
244
+ type: Literal["Workflow"] = Field(
245
+ description="A type of workflow template that should be `Workflow`."
246
+ )
247
+ name: Optional[str] = Field(default=None, description="A workflow name.")
248
+ params: dict[str, Union[Param, str]] = Field(
249
+ default_factory=dict,
250
+ description="A parameters that need to use on this workflow.",
251
+ )
252
+
253
+
254
+ @workflow_app.command(name="json-schema")
255
+ def workflow_json_schema(
256
+ output: Annotated[
257
+ Path,
258
+ typer.Option(help="An output file to export the JSON schema."),
259
+ ] = Path("./json-schema.json"),
260
+ ) -> None:
261
+ """Generate JSON schema file from the Workflow model."""
262
+ template = dict[str, WorkflowSchema]
263
+ json_schema = TypeAdapter(template).json_schema(by_alias=True)
264
+ template_schema: dict[str, str] = {
265
+ "$schema": "http://json-schema.org/draft-07/schema#",
266
+ "title": "Workflow Configuration JSON Schema",
267
+ "version": __version__,
268
+ }
269
+ with open(output, mode="w", encoding="utf-8") as f:
270
+ json.dump(template_schema | json_schema, f, indent=2)
271
+
272
+
273
+ log_app = typer.Typer()
274
+ app.add_typer(log_app, name="logs", help="An Only Log CLI.")
275
+
276
+
277
+ @log_app.callback()
278
+ def log_callback():
279
+ """Manage Only Log CLI."""
280
+
2
281
 
3
282
  if __name__ == "__main__":
4
283
  app()
@@ -15,7 +15,7 @@ from fastapi.responses import UJSONResponse
15
15
  from ...__types import DictData
16
16
  from ...errors import JobError
17
17
  from ...job import Job
18
- from ...traces import TraceManager, get_trace
18
+ from ...traces import Trace, get_trace
19
19
  from ...utils import gen_id
20
20
 
21
21
  logger = logging.getLogger("uvicorn.error")
@@ -41,7 +41,7 @@ async def job_execute(
41
41
  if extras:
42
42
  job.extras = extras
43
43
 
44
- trace: TraceManager = get_trace(
44
+ trace: Trace = get_trace(
45
45
  run_id, parent_run_id=parent_run_id, extras=job.extras
46
46
  )
47
47
 
@@ -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
- """This route include audit and trace log paths."""
6
+ """This route include audit log path."""
7
7
  from __future__ import annotations
8
8
 
9
9
  from fastapi import APIRouter, Path, Query
@@ -11,7 +11,6 @@ from fastapi import status as st
11
11
  from fastapi.responses import UJSONResponse
12
12
 
13
13
  from ...audits import get_audit
14
- from ...result import Result
15
14
 
16
15
  router = APIRouter(
17
16
  prefix="/logs",
@@ -20,63 +19,6 @@ router = APIRouter(
20
19
  )
21
20
 
22
21
 
23
- @router.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
- """
37
- result = Result()
38
- return {
39
- "message": (
40
- f"Getting trace logs with offset: {offset} and limit: {limit}"
41
- ),
42
- "traces": [
43
- trace.model_dump(
44
- by_alias=True,
45
- exclude_none=True,
46
- exclude_unset=True,
47
- )
48
- for trace in result.trace.find_traces()
49
- ],
50
- }
51
-
52
-
53
- @router.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
- )
60
- async def get_trace_with_id(run_id: str):
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
- result = Result()
68
- return {
69
- "message": f"Getting trace log with specific running ID: {run_id}",
70
- "trace": (
71
- result.trace.find_trace_with_id(run_id).model_dump(
72
- by_alias=True,
73
- exclude_none=True,
74
- exclude_unset=True,
75
- )
76
- ),
77
- }
78
-
79
-
80
22
  @router.get(
81
23
  path="/audits/",
82
24
  response_class=UJSONResponse,
@@ -84,12 +26,17 @@ async def get_trace_with_id(run_id: str):
84
26
  summary="Read all audit logs.",
85
27
  tags=["audit"],
86
28
  )
87
- async def get_audits():
29
+ async def get_audits(
30
+ offset: int = Query(default=0, gt=0),
31
+ limit: int = Query(default=100, gt=0),
32
+ ):
88
33
  """Return all audit logs from the current audit log path that config with
89
34
  `WORKFLOW_AUDIT_URL` environment variable name.
90
35
  """
91
36
  return {
92
- "message": "Getting audit logs",
37
+ "message": (
38
+ f"Getting audit logs with offset: {offset} and limit: {limit}",
39
+ ),
93
40
  "audits": list(get_audit().find_audits(name="demo")),
94
41
  }
95
42
 
@@ -49,33 +49,65 @@ import zlib
49
49
  from abc import ABC, abstractmethod
50
50
  from collections.abc import Iterator
51
51
  from datetime import datetime, timedelta
52
+ from enum import Enum
52
53
  from pathlib import Path
53
54
  from typing import Annotated, Any, ClassVar, Literal, Optional, Union
54
55
  from urllib.parse import ParseResult, urlparse
55
56
 
56
- from pydantic import BaseModel, Field, TypeAdapter
57
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
57
58
  from pydantic.functional_validators import field_validator, model_validator
58
59
  from typing_extensions import Self
59
60
 
60
61
  from .__types import DictData
61
62
  from .conf import dynamic
62
- from .traces import TraceManager, get_trace, set_logging
63
+ from .traces import Trace, get_trace, set_logging
63
64
 
64
65
  logger = logging.getLogger("ddeutil.workflow")
65
66
 
66
67
 
68
+ class ReleaseType(str, Enum):
69
+ """Release type enumeration for workflow execution modes.
70
+
71
+ This enum defines the different types of workflow releases that can be
72
+ triggered, each with specific behavior and use cases.
73
+
74
+ Attributes:
75
+ NORMAL: Standard workflow release execution
76
+ RERUN: Re-execution of previously failed workflow
77
+ EVENT: Event-triggered workflow execution
78
+ FORCE: Forced execution bypassing normal conditions
79
+ """
80
+
81
+ NORMAL = "normal"
82
+ RERUN = "rerun"
83
+ EVENT = "event"
84
+ FORCE = "force"
85
+
86
+
87
+ NORMAL = ReleaseType.NORMAL
88
+ RERUN = ReleaseType.RERUN
89
+ EVENT = ReleaseType.EVENT
90
+ FORCE = ReleaseType.FORCE
91
+
92
+
67
93
  class AuditData(BaseModel):
94
+ """Audit Data model."""
95
+
96
+ model_config = ConfigDict(use_enum_values=True)
97
+
68
98
  name: str = Field(description="A workflow name.")
69
99
  release: datetime = Field(description="A release datetime.")
70
- type: str = Field(description="A running type before logging.")
100
+ type: ReleaseType = Field(
101
+ default=NORMAL, description="A running type before logging."
102
+ )
71
103
  context: DictData = Field(
72
104
  default_factory=dict,
73
105
  description="A context that receive from a workflow execution result.",
74
106
  )
107
+ run_id: str = Field(description="A running ID")
75
108
  parent_run_id: Optional[str] = Field(
76
109
  default=None, description="A parent running ID."
77
110
  )
78
- run_id: str = Field(description="A running ID")
79
111
  runs_metadata: DictData = Field(
80
112
  default_factory=dict,
81
113
  description="A runs metadata that will use to tracking this audit log.",
@@ -122,7 +154,7 @@ class BaseAudit(BaseModel, ABC):
122
154
  @abstractmethod
123
155
  def is_pointed(
124
156
  self,
125
- data: AuditData,
157
+ data: Any,
126
158
  *,
127
159
  extras: Optional[DictData] = None,
128
160
  ) -> bool:
@@ -328,21 +360,21 @@ class FileAudit(BaseAudit):
328
360
  return AuditData.model_validate(obj=json.load(f))
329
361
 
330
362
  def is_pointed(
331
- self, data: AuditData, *, extras: Optional[DictData] = None
363
+ self,
364
+ data: Any,
365
+ *,
366
+ extras: Optional[DictData] = None,
332
367
  ) -> bool:
333
368
  """Check if the release log already exists at the destination log path.
334
369
 
335
370
  Args:
336
- data: The workflow name.
371
+ data (str):
337
372
  extras: Optional extra parameters to override core config.
338
373
 
339
374
  Returns:
340
375
  bool: True if the release log exists, False otherwise.
341
376
  """
342
- # NOTE: Return False if enable writing log flag does not set.
343
- if not dynamic("enable_write_audit", extras=extras):
344
- return False
345
- return self.pointer(data).exists()
377
+ return self.pointer(AuditData.model_validate(data)).exists()
346
378
 
347
379
  def pointer(self, data: AuditData) -> Path:
348
380
  """Return release directory path generated from model data.
@@ -365,7 +397,7 @@ class FileAudit(BaseAudit):
365
397
  Self: The audit instance after saving.
366
398
  """
367
399
  audit = AuditData.model_validate(data)
368
- trace: TraceManager = get_trace(
400
+ trace: Trace = get_trace(
369
401
  audit.run_id,
370
402
  parent_run_id=audit.parent_run_id,
371
403
  extras=self.extras,
@@ -655,7 +687,7 @@ class SQLiteAudit(BaseAudit): # pragma: no cov
655
687
  ValueError: If SQLite database is not properly configured.
656
688
  """
657
689
  audit = AuditData.model_validate(data)
658
- trace: TraceManager = get_trace(
690
+ trace: Trace = get_trace(
659
691
  audit.run_id,
660
692
  parent_run_id=audit.parent_run_id,
661
693
  extras=self.extras,
@@ -748,10 +780,7 @@ Audit = Annotated[
748
780
  ]
749
781
 
750
782
 
751
- def get_audit(
752
- *,
753
- extras: Optional[DictData] = None,
754
- ) -> Audit: # pragma: no cov
783
+ def get_audit(extras: Optional[DictData] = None) -> Audit: # pragma: no cov
755
784
  """Get an audit model dynamically based on the config audit path value.
756
785
 
757
786
  Args: