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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """Dotflow __init__ module."""
2
2
 
3
- __version__ = "0.15.0.dev2"
3
+ __version__ = "0.15.0.dev3"
4
4
  __description__ = "🎲 Dotflow turns an idea into flow!"
5
5
 
6
6
  from .core.action import Action as action
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
@@ -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
- if not str(filepath).startswith(str(output)):
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}",
@@ -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
- with contextlib.suppress(Exception):
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 filepath in sorted(project_dir.rglob("*")):
77
- if not filepath.is_file():
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, TaskError
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, task=task, **contexts),
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, task=task),
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, task=task, **contexts),
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, task=task),
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, task=None, **kwargs):
140
- current_delay = self.retry_delay
141
-
142
- is_async = asyncio.iscoroutinefunction(self.func)
143
-
144
- for attempt in range(1, self.retry + 1):
145
- try:
146
- if self.timeout:
147
- executor = ThreadPoolExecutor(max_workers=1)
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
- try:
199
- loop = asyncio.get_running_loop()
200
- except RuntimeError:
201
- loop = None
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
- return self.func(*args, **kwargs)
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 isinstance(value, int):
72
- self._task_id = value
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
- self._workflow_id = value
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