dotflow 0.15.0.dev2__py3-none-any.whl → 0.15.0.dev3__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.
- dotflow/__init__.py +1 -1
- dotflow/abc/flow.py +19 -0
- dotflow/cli/commands/cloud.py +8 -1
- dotflow/cli/commands/init.py +1 -3
- dotflow/cli/commands/storage.py +55 -0
- dotflow/cloud/gcp/services/apis.py +5 -3
- dotflow/cloud/github/actions_deployer.py +14 -11
- dotflow/core/action.py +40 -79
- dotflow/core/context.py +17 -4
- dotflow/core/engine.py +277 -0
- dotflow/core/module.py +11 -17
- dotflow/core/serializers/task.py +7 -9
- dotflow/core/workflow.py +46 -62
- dotflow/providers/storage_default.py +8 -6
- dotflow/providers/storage_file.py +11 -8
- dotflow/settings.py +1 -1
- dotflow/utils/error_handler.py +3 -12
- dotflow/utils/tools.py +0 -10
- {dotflow-0.15.0.dev2.dist-info → dotflow-0.15.0.dev3.dist-info}/METADATA +185 -208
- {dotflow-0.15.0.dev2.dist-info → dotflow-0.15.0.dev3.dist-info}/RECORD +23 -23
- dotflow/core/execution.py +0 -176
- dotflow/storage.py +0 -22
- {dotflow-0.15.0.dev2.dist-info → dotflow-0.15.0.dev3.dist-info}/WHEEL +0 -0
- {dotflow-0.15.0.dev2.dist-info → dotflow-0.15.0.dev3.dist-info}/entry_points.txt +0 -0
- {dotflow-0.15.0.dev2.dist-info → dotflow-0.15.0.dev3.dist-info}/licenses/LICENSE +0 -0
dotflow/__init__.py
CHANGED
dotflow/abc/flow.py
CHANGED
|
@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
|
|
|
4
4
|
from uuid import UUID
|
|
5
5
|
|
|
6
6
|
from dotflow.core.task import Task
|
|
7
|
+
from dotflow.core.types import TypeStatus
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Flow(ABC):
|
|
@@ -40,3 +41,21 @@ class Flow(ABC):
|
|
|
40
41
|
@abstractmethod
|
|
41
42
|
def run(self) -> None:
|
|
42
43
|
return None
|
|
44
|
+
|
|
45
|
+
def _try_restore_checkpoint(self, task: Task):
|
|
46
|
+
"""Attempts to restore a checkpoint. Returns the Context if found, None otherwise."""
|
|
47
|
+
if not self.resume:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
context = task.config.storage.get(
|
|
51
|
+
key=task.config.storage.key(task=task)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if context.storage is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
task.status = TypeStatus.COMPLETED
|
|
58
|
+
task.current_context = context
|
|
59
|
+
self._flow_callback(task=task)
|
|
60
|
+
|
|
61
|
+
return context
|
dotflow/cli/commands/cloud.py
CHANGED
|
@@ -20,6 +20,11 @@ def _get_template_dir() -> Path | None:
|
|
|
20
20
|
cloud_dir = target / settings.TEMPLATE_CLOUD_DIR
|
|
21
21
|
|
|
22
22
|
if cloud_dir.exists():
|
|
23
|
+
run(
|
|
24
|
+
["git", "-C", str(target), "pull", "--ff-only"],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
)
|
|
23
28
|
return cloud_dir
|
|
24
29
|
|
|
25
30
|
try:
|
|
@@ -133,7 +138,9 @@ class CloudGenerateCommand(Command):
|
|
|
133
138
|
|
|
134
139
|
for filename in files:
|
|
135
140
|
filepath = (output / filename).resolve()
|
|
136
|
-
|
|
141
|
+
try:
|
|
142
|
+
filepath.relative_to(output)
|
|
143
|
+
except ValueError:
|
|
137
144
|
print(
|
|
138
145
|
settings.ERROR_ALERT,
|
|
139
146
|
f" Skipped (unsafe filename): {filename}",
|
dotflow/cli/commands/init.py
CHANGED
|
@@ -6,12 +6,10 @@ from rich import print # type: ignore
|
|
|
6
6
|
from dotflow.cli.command import Command
|
|
7
7
|
from dotflow.settings import Settings as settings
|
|
8
8
|
|
|
9
|
-
TEMPLATE_REPO = "https://github.com/dotflow-io/template"
|
|
10
|
-
|
|
11
9
|
|
|
12
10
|
class InitCommand(Command):
|
|
13
11
|
def setup(self):
|
|
14
|
-
cookiecutter(TEMPLATE_REPO)
|
|
12
|
+
cookiecutter(settings.TEMPLATE_REPO)
|
|
15
13
|
|
|
16
14
|
print(
|
|
17
15
|
settings.INFO_ALERT,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Storage resolver for CLI commands"""
|
|
2
|
+
|
|
3
|
+
from dotflow.providers import (
|
|
4
|
+
StorageDefault,
|
|
5
|
+
StorageFile,
|
|
6
|
+
StorageGCS,
|
|
7
|
+
StorageS3,
|
|
8
|
+
)
|
|
9
|
+
from dotflow.settings import Settings as settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StorageResolver:
|
|
13
|
+
"""Resolves a storage provider instance from CLI params."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, params):
|
|
16
|
+
self.params = params
|
|
17
|
+
|
|
18
|
+
def resolve(self):
|
|
19
|
+
"""Returns a storage instance or None if no --storage was specified."""
|
|
20
|
+
if not self.params.storage:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
resolvers = {
|
|
24
|
+
"default": self._resolve_local,
|
|
25
|
+
"file": self._resolve_local,
|
|
26
|
+
"s3": self._resolve_s3,
|
|
27
|
+
"gcs": self._resolve_gcs,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
resolver = resolvers.get(self.params.storage)
|
|
31
|
+
if resolver is None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
return resolver()
|
|
35
|
+
|
|
36
|
+
def _resolve_local(self):
|
|
37
|
+
storage_cls = {"default": StorageDefault, "file": StorageFile}
|
|
38
|
+
return storage_cls[self.params.storage](path=self.params.path)
|
|
39
|
+
|
|
40
|
+
def _resolve_s3(self):
|
|
41
|
+
self._require("bucket")
|
|
42
|
+
return StorageS3(bucket=self.params.bucket, region=self.params.region)
|
|
43
|
+
|
|
44
|
+
def _resolve_gcs(self):
|
|
45
|
+
self._require("bucket")
|
|
46
|
+
return StorageGCS(
|
|
47
|
+
bucket=self.params.bucket, project=self.params.gcp_project
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _require(self, arg):
|
|
51
|
+
if not getattr(self.params, arg, None):
|
|
52
|
+
raise SystemExit(
|
|
53
|
+
f"{settings.ERROR_ALERT} --storage {self.params.storage}"
|
|
54
|
+
f" requires --{arg}"
|
|
55
|
+
)
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import contextlib
|
|
6
|
-
|
|
7
5
|
from dotflow.core.exception import ModuleNotFound
|
|
8
6
|
|
|
9
7
|
|
|
@@ -33,5 +31,9 @@ class APIs:
|
|
|
33
31
|
print(" Enabling APIs...")
|
|
34
32
|
for api in self.REQUIRED:
|
|
35
33
|
name = f"projects/{self._project}/services/{api}"
|
|
36
|
-
|
|
34
|
+
try:
|
|
37
35
|
self._client.enable_service(name=name).result()
|
|
36
|
+
except Exception as err:
|
|
37
|
+
if "already enabled" in str(err).lower():
|
|
38
|
+
continue
|
|
39
|
+
print(f" Warning: Failed to enable {api}: {err}")
|
|
@@ -71,19 +71,11 @@ class ActionsDeployer(Deployer):
|
|
|
71
71
|
print(" Pushing files...")
|
|
72
72
|
|
|
73
73
|
project_dir = Path.cwd()
|
|
74
|
+
tracked = self._get_tracked_files(project_dir)
|
|
74
75
|
count = 0
|
|
75
76
|
|
|
76
|
-
for
|
|
77
|
-
|
|
78
|
-
continue
|
|
79
|
-
|
|
80
|
-
relative = str(filepath.relative_to(project_dir))
|
|
81
|
-
|
|
82
|
-
if relative.startswith(".") and not relative.startswith(".github"):
|
|
83
|
-
continue
|
|
84
|
-
|
|
85
|
-
if "__pycache__" in relative or ".egg-info" in relative:
|
|
86
|
-
continue
|
|
77
|
+
for relative in tracked:
|
|
78
|
+
filepath = project_dir / relative
|
|
87
79
|
|
|
88
80
|
try:
|
|
89
81
|
content = filepath.read_text()
|
|
@@ -95,6 +87,17 @@ class ActionsDeployer(Deployer):
|
|
|
95
87
|
|
|
96
88
|
print(f" Pushed {count} files")
|
|
97
89
|
|
|
90
|
+
def _get_tracked_files(self, project_dir: Path) -> list[str]:
|
|
91
|
+
"""Get files that git would track, respecting .gitignore."""
|
|
92
|
+
from git import Repo
|
|
93
|
+
|
|
94
|
+
repo = Repo(project_dir)
|
|
95
|
+
tracked = set(repo.git.ls_files("--cached").splitlines())
|
|
96
|
+
untracked = set(
|
|
97
|
+
repo.git.ls_files("--others", "--exclude-standard").splitlines()
|
|
98
|
+
)
|
|
99
|
+
return sorted(tracked | untracked)
|
|
100
|
+
|
|
98
101
|
def _create_or_update_file(self, repo, path: str, content: str):
|
|
99
102
|
"""Create or update a single file in the repo."""
|
|
100
103
|
try:
|
dotflow/core/action.py
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
"""Action module"""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import inspect
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
-
from time import sleep
|
|
7
7
|
from types import FunctionType
|
|
8
8
|
|
|
9
9
|
from dotflow.core.context import Context
|
|
10
|
-
from dotflow.core.exception import ExecutionWithClassError
|
|
11
|
-
from dotflow.core.types.status import TypeStatus
|
|
10
|
+
from dotflow.core.exception import ExecutionWithClassError
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
def is_execution_with_class_internal_error(error: Exception) -> bool:
|
|
@@ -22,7 +21,11 @@ def is_execution_with_class_internal_error(error: Exception) -> bool:
|
|
|
22
21
|
|
|
23
22
|
|
|
24
23
|
class Action:
|
|
25
|
-
"""
|
|
24
|
+
"""Decorator for creating task steps.
|
|
25
|
+
|
|
26
|
+
Accepts configuration for retry, timeout, retry_delay, and backoff,
|
|
27
|
+
which are stored as attributes and consumed by TaskEngine during execution.
|
|
28
|
+
|
|
26
29
|
Import:
|
|
27
30
|
You can import the **action** decorator directly from dotflow:
|
|
28
31
|
|
|
@@ -43,7 +46,6 @@ class Action:
|
|
|
43
46
|
def my_task():
|
|
44
47
|
print("task")
|
|
45
48
|
|
|
46
|
-
|
|
47
49
|
With Timeout
|
|
48
50
|
|
|
49
51
|
@action(timeout=60)
|
|
@@ -62,6 +64,10 @@ class Action:
|
|
|
62
64
|
def my_task():
|
|
63
65
|
print("task")
|
|
64
66
|
|
|
67
|
+
Note:
|
|
68
|
+
Retry, timeout, and backoff are enforced by TaskEngine.execute_with_retry(),
|
|
69
|
+
not by Action itself. Action stores the configuration as attributes.
|
|
70
|
+
|
|
65
71
|
Args:
|
|
66
72
|
func (Callable):
|
|
67
73
|
|
|
@@ -103,13 +109,13 @@ class Action:
|
|
|
103
109
|
|
|
104
110
|
if contexts:
|
|
105
111
|
return Context(
|
|
106
|
-
storage=self._run_action(*args,
|
|
112
|
+
storage=self._run_action(*args, **contexts),
|
|
107
113
|
task_id=task.task_id,
|
|
108
114
|
workflow_id=task.workflow_id,
|
|
109
115
|
)
|
|
110
116
|
|
|
111
117
|
return Context(
|
|
112
|
-
storage=self._run_action(*args
|
|
118
|
+
storage=self._run_action(*args),
|
|
113
119
|
task_id=task.task_id,
|
|
114
120
|
workflow_id=task.workflow_id,
|
|
115
121
|
)
|
|
@@ -123,92 +129,47 @@ class Action:
|
|
|
123
129
|
|
|
124
130
|
if contexts:
|
|
125
131
|
return Context(
|
|
126
|
-
storage=self._run_action(*_args,
|
|
132
|
+
storage=self._run_action(*_args, **contexts),
|
|
127
133
|
task_id=task.task_id,
|
|
128
134
|
workflow_id=task.workflow_id,
|
|
129
135
|
)
|
|
130
136
|
|
|
131
137
|
return Context(
|
|
132
|
-
storage=self._run_action(*_args
|
|
138
|
+
storage=self._run_action(*_args),
|
|
133
139
|
task_id=task.task_id,
|
|
134
140
|
workflow_id=task.workflow_id,
|
|
135
141
|
)
|
|
136
142
|
|
|
143
|
+
action.retry = self.retry
|
|
144
|
+
action.timeout = self.timeout
|
|
145
|
+
action.retry_delay = self.retry_delay
|
|
146
|
+
action.backoff = self.backoff
|
|
147
|
+
|
|
137
148
|
return action
|
|
138
149
|
|
|
139
|
-
def _run_action(self, *args,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
try:
|
|
149
|
-
future = executor.submit(
|
|
150
|
-
self._call_func,
|
|
151
|
-
is_async,
|
|
152
|
-
*args,
|
|
153
|
-
**kwargs,
|
|
154
|
-
)
|
|
155
|
-
result = future.result(timeout=self.timeout)
|
|
156
|
-
except TimeoutError:
|
|
157
|
-
future.cancel()
|
|
158
|
-
executor.shutdown(wait=False, cancel_futures=True)
|
|
159
|
-
raise
|
|
160
|
-
except Exception:
|
|
161
|
-
executor.shutdown(wait=False)
|
|
162
|
-
raise
|
|
163
|
-
else:
|
|
164
|
-
executor.shutdown(wait=False)
|
|
165
|
-
else:
|
|
166
|
-
result = self._call_func(is_async, *args, **kwargs)
|
|
167
|
-
|
|
168
|
-
return result
|
|
169
|
-
|
|
170
|
-
except TimeoutError:
|
|
171
|
-
raise
|
|
172
|
-
|
|
173
|
-
except Exception as error:
|
|
174
|
-
last_exception = error
|
|
175
|
-
|
|
176
|
-
if is_execution_with_class_internal_error(
|
|
177
|
-
error=last_exception
|
|
178
|
-
):
|
|
179
|
-
raise ExecutionWithClassError() from None
|
|
180
|
-
|
|
181
|
-
if attempt == self.retry:
|
|
182
|
-
raise last_exception from None
|
|
183
|
-
|
|
184
|
-
if task is not None:
|
|
185
|
-
task.retry_count += 1
|
|
186
|
-
task.errors = TaskError(
|
|
187
|
-
error=error,
|
|
188
|
-
attempt=attempt,
|
|
189
|
-
)
|
|
190
|
-
task.status = TypeStatus.RETRY
|
|
191
|
-
|
|
192
|
-
sleep(current_delay)
|
|
193
|
-
if self.backoff:
|
|
194
|
-
current_delay *= 2
|
|
150
|
+
def _run_action(self, *args, **kwargs):
|
|
151
|
+
is_async = inspect.iscoroutinefunction(self.func)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
return self._call_func(is_async, *args, **kwargs)
|
|
155
|
+
except Exception as error:
|
|
156
|
+
if is_execution_with_class_internal_error(error=error):
|
|
157
|
+
raise ExecutionWithClassError() from None
|
|
158
|
+
raise
|
|
195
159
|
|
|
196
160
|
def _call_func(self, is_async, *args, **kwargs):
|
|
197
|
-
if is_async:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if loop and loop.is_running():
|
|
204
|
-
import concurrent.futures
|
|
205
|
-
|
|
206
|
-
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
207
|
-
return pool.submit(
|
|
208
|
-
asyncio.run, self.func(*args, **kwargs)
|
|
209
|
-
).result()
|
|
161
|
+
if not is_async:
|
|
162
|
+
return self.func(*args, **kwargs)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
asyncio.get_running_loop()
|
|
166
|
+
except RuntimeError:
|
|
210
167
|
return asyncio.run(self.func(*args, **kwargs))
|
|
211
|
-
|
|
168
|
+
|
|
169
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
170
|
+
return pool.submit(
|
|
171
|
+
asyncio.run, self.func(*args, **kwargs)
|
|
172
|
+
).result()
|
|
212
173
|
|
|
213
174
|
def _set_params(self):
|
|
214
175
|
if isinstance(self.func, FunctionType):
|
dotflow/core/context.py
CHANGED
|
@@ -68,8 +68,14 @@ class Context(ContextInstance):
|
|
|
68
68
|
|
|
69
69
|
@task_id.setter
|
|
70
70
|
def task_id(self, value: int):
|
|
71
|
-
if
|
|
72
|
-
self._task_id =
|
|
71
|
+
if value is None:
|
|
72
|
+
self._task_id = None
|
|
73
|
+
return
|
|
74
|
+
if not isinstance(value, int):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"task_id must be an int, got {type(value).__name__}: {value!r}"
|
|
77
|
+
)
|
|
78
|
+
self._task_id = value
|
|
73
79
|
|
|
74
80
|
@property
|
|
75
81
|
def workflow_id(self):
|
|
@@ -77,6 +83,9 @@ class Context(ContextInstance):
|
|
|
77
83
|
|
|
78
84
|
@workflow_id.setter
|
|
79
85
|
def workflow_id(self, value: UUID):
|
|
86
|
+
if value is None:
|
|
87
|
+
self._workflow_id = None
|
|
88
|
+
return
|
|
80
89
|
if isinstance(value, str):
|
|
81
90
|
try:
|
|
82
91
|
value = UUID(value)
|
|
@@ -84,8 +93,12 @@ class Context(ContextInstance):
|
|
|
84
93
|
raise ValueError(
|
|
85
94
|
f"Invalid workflow_id: '{value}' is not a valid UUID format."
|
|
86
95
|
) from err
|
|
87
|
-
if isinstance(value, UUID):
|
|
88
|
-
|
|
96
|
+
if not isinstance(value, UUID):
|
|
97
|
+
raise TypeError(
|
|
98
|
+
f"workflow_id must be a UUID or UUID string, "
|
|
99
|
+
f"got {type(value).__name__}: {value!r}"
|
|
100
|
+
)
|
|
101
|
+
self._workflow_id = value
|
|
89
102
|
|
|
90
103
|
@property
|
|
91
104
|
def storage(self):
|
dotflow/core/engine.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""TaskEngine module"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from inspect import getsourcelines
|
|
9
|
+
from time import sleep
|
|
10
|
+
from types import FunctionType
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from types import NoneType
|
|
15
|
+
except ImportError:
|
|
16
|
+
NoneType = type(None)
|
|
17
|
+
|
|
18
|
+
from dotflow.core.action import Action
|
|
19
|
+
from dotflow.core.context import Context
|
|
20
|
+
from dotflow.core.exception import ExecutionWithClassError, TaskError
|
|
21
|
+
from dotflow.core.task import Task
|
|
22
|
+
from dotflow.core.types import TypeStatus
|
|
23
|
+
from dotflow.logging import logger
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskEngine:
|
|
27
|
+
"""Manages the execution lifecycle of a single task.
|
|
28
|
+
|
|
29
|
+
Separates the task lifecycle (status, duration, error handling)
|
|
30
|
+
from how tasks are executed (sequential, parallel, background).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
VALID_OBJECTS = [
|
|
34
|
+
str,
|
|
35
|
+
int,
|
|
36
|
+
float,
|
|
37
|
+
complex,
|
|
38
|
+
dict,
|
|
39
|
+
list,
|
|
40
|
+
tuple,
|
|
41
|
+
set,
|
|
42
|
+
frozenset,
|
|
43
|
+
range,
|
|
44
|
+
bool,
|
|
45
|
+
FunctionType,
|
|
46
|
+
NoneType,
|
|
47
|
+
bytes,
|
|
48
|
+
bytearray,
|
|
49
|
+
memoryview,
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
task: Task,
|
|
55
|
+
workflow_id: UUID,
|
|
56
|
+
previous_context: Context = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.task = task
|
|
59
|
+
self.workflow_id = workflow_id
|
|
60
|
+
self.previous_context = previous_context
|
|
61
|
+
self._start_time = None
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def start(self):
|
|
65
|
+
"""Prepares the task for execution and manages its lifecycle."""
|
|
66
|
+
self.task.workflow_id = self.workflow_id
|
|
67
|
+
self.task.previous_context = self.previous_context
|
|
68
|
+
self.task.config.tracer.start_task(task=self.task)
|
|
69
|
+
self.task.status = TypeStatus.IN_PROGRESS
|
|
70
|
+
self._start_time = datetime.now()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
yield self
|
|
74
|
+
except AssertionError as err:
|
|
75
|
+
raise err
|
|
76
|
+
except Exception as err:
|
|
77
|
+
self.task.errors = err
|
|
78
|
+
self.task.current_context = None
|
|
79
|
+
self.task.status = TypeStatus.FAILED
|
|
80
|
+
else:
|
|
81
|
+
if self.task.status in (
|
|
82
|
+
TypeStatus.IN_PROGRESS,
|
|
83
|
+
TypeStatus.RETRY,
|
|
84
|
+
):
|
|
85
|
+
self.task.status = TypeStatus.COMPLETED
|
|
86
|
+
finally:
|
|
87
|
+
self.task.duration = (
|
|
88
|
+
datetime.now() - self._start_time
|
|
89
|
+
).total_seconds()
|
|
90
|
+
self.task.config.tracer.end_task(task=self.task)
|
|
91
|
+
|
|
92
|
+
def execute(self):
|
|
93
|
+
"""Executes the task function and returns the context."""
|
|
94
|
+
current_context = self.task.step(
|
|
95
|
+
initial_context=self.task.initial_context,
|
|
96
|
+
previous_context=self.task.previous_context,
|
|
97
|
+
task=self.task,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if type(current_context.storage) not in self.VALID_OBJECTS:
|
|
101
|
+
current_context = self._execution_with_class(
|
|
102
|
+
class_instance=current_context.storage
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.task.current_context = current_context
|
|
106
|
+
return current_context
|
|
107
|
+
|
|
108
|
+
def execute_with_retry(self):
|
|
109
|
+
"""Executes the task with retry, timeout, and backoff managed by the engine.
|
|
110
|
+
|
|
111
|
+
Reads retry, timeout, retry_delay, and backoff from the task's step
|
|
112
|
+
(the @action decorator) and manages the full retry loop.
|
|
113
|
+
"""
|
|
114
|
+
step = self.task.step
|
|
115
|
+
max_attempts = max(1, step.retry)
|
|
116
|
+
timeout = step.timeout
|
|
117
|
+
retry_delay = step.retry_delay
|
|
118
|
+
backoff = step.backoff
|
|
119
|
+
current_delay = retry_delay
|
|
120
|
+
|
|
121
|
+
for attempt in range(1, max_attempts + 1):
|
|
122
|
+
try:
|
|
123
|
+
if timeout:
|
|
124
|
+
result = self._execute_with_timeout(timeout)
|
|
125
|
+
else:
|
|
126
|
+
result = self._execute_single()
|
|
127
|
+
|
|
128
|
+
self.task.current_context = result
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
except TimeoutError:
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
except Exception as error:
|
|
135
|
+
if self._is_class_internal_error(error):
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
if attempt == max_attempts:
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
self.task.retry_count += 1
|
|
142
|
+
self.task.errors = TaskError(
|
|
143
|
+
error=error,
|
|
144
|
+
attempt=attempt,
|
|
145
|
+
)
|
|
146
|
+
self.task.status = TypeStatus.RETRY
|
|
147
|
+
|
|
148
|
+
sleep(current_delay)
|
|
149
|
+
if backoff:
|
|
150
|
+
current_delay *= 2
|
|
151
|
+
|
|
152
|
+
def _execute_single(self):
|
|
153
|
+
"""Executes the task function once, handling class-based steps."""
|
|
154
|
+
current_context = self.task.step(
|
|
155
|
+
initial_context=self.task.initial_context,
|
|
156
|
+
previous_context=self.task.previous_context,
|
|
157
|
+
task=self.task,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if type(current_context.storage) not in self.VALID_OBJECTS:
|
|
161
|
+
current_context = self._execution_with_class(
|
|
162
|
+
class_instance=current_context.storage
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return current_context
|
|
166
|
+
|
|
167
|
+
def _execute_with_timeout(self, seconds: int):
|
|
168
|
+
"""Executes the task function with a real timeout using ThreadPoolExecutor."""
|
|
169
|
+
executor = ThreadPoolExecutor(max_workers=1)
|
|
170
|
+
try:
|
|
171
|
+
future = executor.submit(self._execute_single)
|
|
172
|
+
return future.result(timeout=seconds)
|
|
173
|
+
except TimeoutError:
|
|
174
|
+
future.cancel()
|
|
175
|
+
raise
|
|
176
|
+
finally:
|
|
177
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _is_class_internal_error(error: Exception) -> bool:
|
|
181
|
+
"""Checks if an error is an internal class execution error."""
|
|
182
|
+
if isinstance(error, ExecutionWithClassError):
|
|
183
|
+
return True
|
|
184
|
+
message = str(error)
|
|
185
|
+
patterns = [
|
|
186
|
+
"initial_context",
|
|
187
|
+
"previous_context",
|
|
188
|
+
"missing 1 required positional argument: 'self'",
|
|
189
|
+
]
|
|
190
|
+
return any(pattern in message for pattern in patterns)
|
|
191
|
+
|
|
192
|
+
def _is_action(self, class_instance: Callable, func: Callable):
|
|
193
|
+
try:
|
|
194
|
+
return (
|
|
195
|
+
callable(getattr(class_instance, func))
|
|
196
|
+
and getattr(class_instance, func).__module__
|
|
197
|
+
== Action.__module__
|
|
198
|
+
and not func.startswith("__")
|
|
199
|
+
)
|
|
200
|
+
except AttributeError:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def _execution_orderer(
|
|
204
|
+
self, callable_list: list[str], class_instance: Callable
|
|
205
|
+
) -> tuple[int, Callable]:
|
|
206
|
+
ordered_list = []
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
inside_code = getsourcelines(class_instance.__class__)[0]
|
|
210
|
+
|
|
211
|
+
for callable_name in callable_list:
|
|
212
|
+
pattern = re.compile(
|
|
213
|
+
rf"\bdef\s+{re.escape(callable_name)}\s*\("
|
|
214
|
+
)
|
|
215
|
+
for index, code in enumerate(inside_code):
|
|
216
|
+
if pattern.search(code):
|
|
217
|
+
ordered_list.append((index, callable_name))
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
ordered_list.sort()
|
|
221
|
+
return ordered_list
|
|
222
|
+
|
|
223
|
+
except TypeError as err:
|
|
224
|
+
logger.error(
|
|
225
|
+
"Internal problem with ordering the class functions, "
|
|
226
|
+
"but don't worry, it was executed.: %s",
|
|
227
|
+
str(err),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
for index, callable_name in enumerate(callable_list):
|
|
231
|
+
ordered_list.append((index, callable_name))
|
|
232
|
+
|
|
233
|
+
return ordered_list
|
|
234
|
+
|
|
235
|
+
def _execution_with_class(self, class_instance: Callable):
|
|
236
|
+
new_context = Context(storage=[])
|
|
237
|
+
previous_context = self.task.previous_context
|
|
238
|
+
callable_list = [
|
|
239
|
+
func
|
|
240
|
+
for func in dir(class_instance)
|
|
241
|
+
if self._is_action(class_instance, func)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
ordered_list = self._execution_orderer(
|
|
245
|
+
callable_list=callable_list, class_instance=class_instance
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
for index, new in enumerate(ordered_list):
|
|
249
|
+
new_object = getattr(class_instance, new[1])
|
|
250
|
+
try:
|
|
251
|
+
subcontext = new_object(
|
|
252
|
+
initial_context=self.task.initial_context,
|
|
253
|
+
previous_context=previous_context,
|
|
254
|
+
task=self.task,
|
|
255
|
+
)
|
|
256
|
+
subcontext.task_id = index
|
|
257
|
+
new_context.storage.append(subcontext)
|
|
258
|
+
previous_context = subcontext
|
|
259
|
+
|
|
260
|
+
except Exception as error:
|
|
261
|
+
if not isinstance(error, ExecutionWithClassError):
|
|
262
|
+
raise error
|
|
263
|
+
|
|
264
|
+
subcontext = new_object(
|
|
265
|
+
class_instance,
|
|
266
|
+
initial_context=self.task.initial_context,
|
|
267
|
+
previous_context=previous_context,
|
|
268
|
+
task=self.task,
|
|
269
|
+
)
|
|
270
|
+
subcontext.task_id = index
|
|
271
|
+
new_context.storage.append(subcontext)
|
|
272
|
+
previous_context = subcontext
|
|
273
|
+
|
|
274
|
+
if not new_context.storage:
|
|
275
|
+
return Context(storage=class_instance)
|
|
276
|
+
|
|
277
|
+
return new_context
|