dotflow 0.14.0.dev1__tar.gz → 0.14.1.dev2__tar.gz

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.
Files changed (69) hide show
  1. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/PKG-INFO +6 -6
  2. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/README.md +5 -3
  3. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/__init__.py +1 -1
  4. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/notify.py +2 -2
  5. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/action.py +23 -4
  6. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/config.py +12 -10
  7. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/context.py +7 -3
  8. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/execution.py +7 -2
  9. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/module.py +1 -1
  10. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/task.py +1 -1
  11. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/workflow.py +34 -12
  12. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/__init__.py +2 -0
  13. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/notify_default.py +1 -1
  14. dotflow-0.14.1.dev2/dotflow/providers/notify_discord.py +112 -0
  15. dotflow-0.14.1.dev2/dotflow/providers/notify_telegram.py +109 -0
  16. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/scheduler_cron.py +41 -7
  17. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/pyproject.toml +4 -4
  18. dotflow-0.14.0.dev1/dotflow/providers/notify_telegram.py +0 -59
  19. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/LICENSE +0 -0
  20. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/__init__.py +0 -0
  21. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/api.py +0 -0
  22. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/file.py +0 -0
  23. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/flow.py +0 -0
  24. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/http.py +0 -0
  25. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/log.py +0 -0
  26. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/scheduler.py +0 -0
  27. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/storage.py +0 -0
  28. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/tcp.py +0 -0
  29. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/__init__.py +0 -0
  30. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/command.py +0 -0
  31. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/__init__.py +0 -0
  32. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/init.py +0 -0
  33. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/log.py +0 -0
  34. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/schedule.py +0 -0
  35. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/start.py +0 -0
  36. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/setup.py +0 -0
  37. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/validators/__init__.py +0 -0
  38. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/validators/start.py +0 -0
  39. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/__init__.py +0 -0
  40. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/decorators/__init__.py +0 -0
  41. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/decorators/time.py +0 -0
  42. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/dotflow.py +0 -0
  43. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/exception.py +0 -0
  44. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/__init__.py +0 -0
  45. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/task.py +0 -0
  46. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/transport.py +0 -0
  47. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/workflow.py +0 -0
  48. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/__init__.py +0 -0
  49. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/execution.py +0 -0
  50. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/overlap.py +0 -0
  51. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/status.py +0 -0
  52. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/storage.py +0 -0
  53. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/workflow.py +0 -0
  54. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/logging.py +0 -0
  55. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/main.py +0 -0
  56. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/api_default.py +0 -0
  57. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/log_default.py +0 -0
  58. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/scheduler_default.py +0 -0
  59. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_default.py +0 -0
  60. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_file.py +0 -0
  61. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_gcs.py +0 -0
  62. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_s3.py +0 -0
  63. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/settings.py +0 -0
  64. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/storage.py +0 -0
  65. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/types.py +0 -0
  66. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/__init__.py +0 -0
  67. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/basic_functions.py +0 -0
  68. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/error_handler.py +0 -0
  69. {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotflow
3
- Version: 0.14.0.dev1
3
+ Version: 0.14.1.dev2
4
4
  Summary: 🎲 Dotflow turns an idea into flow! Lightweight Python library for execution pipelines with retry, parallel, cron and async support.
5
5
  License: MIT License
6
6
 
@@ -43,12 +43,10 @@ Classifier: Programming Language :: Python :: 3.12
43
43
  Classifier: Programming Language :: Python :: 3.13
44
44
  Provides-Extra: aws
45
45
  Provides-Extra: gcp
46
- Provides-Extra: mongodb
47
46
  Provides-Extra: scheduler
48
47
  Requires-Dist: boto3 ; extra == "aws"
49
48
  Requires-Dist: cookiecutter (>=2.0)
50
49
  Requires-Dist: croniter ; extra == "scheduler"
51
- Requires-Dist: dotflow-mongodb ; extra == "mongodb"
52
50
  Requires-Dist: google-cloud-storage ; extra == "gcp"
53
51
  Requires-Dist: pydantic
54
52
  Requires-Dist: requests
@@ -172,7 +170,7 @@ workflow.start()
172
170
 
173
171
  ### Execution Modes
174
172
 
175
- > [Process Mode docs](https://dotflow-io.github.io/dotflow/nav/learn/process-mode-sequential/)
173
+ > [Process Mode docs](https://dotflow-io.github.io/dotflow/nav/concepts/process-mode-sequential/)
176
174
 
177
175
  Dotflow supports 4 execution strategies out of the box:
178
176
 
@@ -531,6 +529,8 @@ workflow.start()
531
529
 
532
530
  ### Scheduler / Cron
533
531
 
532
+ > [Cron scheduler docs](https://dotflow-io.github.io/dotflow/nav/tutorial/scheduler-cron/) | [Default scheduler](https://dotflow-io.github.io/dotflow/nav/tutorial/scheduler-default/) | [Cron overlap (concepts)](https://dotflow-io.github.io/dotflow/nav/concepts/concept-cron-overlap/)
533
+
534
534
  Schedule workflows to run automatically using cron expressions.
535
535
 
536
536
  ```bash
@@ -578,7 +578,7 @@ The scheduler handles graceful shutdown via `SIGINT`/`SIGTERM` signals automatic
578
578
 
579
579
  ### CLI
580
580
 
581
- > [CLI docs](https://dotflow-io.github.io/dotflow/nav/learn/cli/simple-start/)
581
+ > [CLI docs](https://dotflow-io.github.io/dotflow/nav/how-to/cli/simple-start/)
582
582
 
583
583
  Run workflows directly from the command line:
584
584
 
@@ -648,7 +648,7 @@ Extend Dotflow by implementing the abstract base classes:
648
648
  | ABC | Methods | Purpose |
649
649
  |-----|---------|---------|
650
650
  | `Storage` | `post`, `get`, `key` | Custom storage backends |
651
- | `Notify` | `send` | Custom notification channels |
651
+ | `Notify` | `hook_status_task` | Custom notification channels |
652
652
  | `Log` | `info`, `error` | Custom logging |
653
653
  | `Scheduler` | `start`, `stop` | Custom scheduling strategies |
654
654
 
@@ -109,7 +109,7 @@ workflow.start()
109
109
 
110
110
  ### Execution Modes
111
111
 
112
- > [Process Mode docs](https://dotflow-io.github.io/dotflow/nav/learn/process-mode-sequential/)
112
+ > [Process Mode docs](https://dotflow-io.github.io/dotflow/nav/concepts/process-mode-sequential/)
113
113
 
114
114
  Dotflow supports 4 execution strategies out of the box:
115
115
 
@@ -468,6 +468,8 @@ workflow.start()
468
468
 
469
469
  ### Scheduler / Cron
470
470
 
471
+ > [Cron scheduler docs](https://dotflow-io.github.io/dotflow/nav/tutorial/scheduler-cron/) | [Default scheduler](https://dotflow-io.github.io/dotflow/nav/tutorial/scheduler-default/) | [Cron overlap (concepts)](https://dotflow-io.github.io/dotflow/nav/concepts/concept-cron-overlap/)
472
+
471
473
  Schedule workflows to run automatically using cron expressions.
472
474
 
473
475
  ```bash
@@ -515,7 +517,7 @@ The scheduler handles graceful shutdown via `SIGINT`/`SIGTERM` signals automatic
515
517
 
516
518
  ### CLI
517
519
 
518
- > [CLI docs](https://dotflow-io.github.io/dotflow/nav/learn/cli/simple-start/)
520
+ > [CLI docs](https://dotflow-io.github.io/dotflow/nav/how-to/cli/simple-start/)
519
521
 
520
522
  Run workflows directly from the command line:
521
523
 
@@ -585,7 +587,7 @@ Extend Dotflow by implementing the abstract base classes:
585
587
  | ABC | Methods | Purpose |
586
588
  |-----|---------|---------|
587
589
  | `Storage` | `post`, `get`, `key` | Custom storage backends |
588
- | `Notify` | `send` | Custom notification channels |
590
+ | `Notify` | `hook_status_task` | Custom notification channels |
589
591
  | `Log` | `info`, `error` | Custom logging |
590
592
  | `Scheduler` | `start`, `stop` | Custom scheduling strategies |
591
593
 
@@ -1,6 +1,6 @@
1
1
  """Dotflow __init__ module."""
2
2
 
3
- __version__ = "0.14.0.dev1"
3
+ __version__ = "0.14.1.dev2"
4
4
  __description__ = "🎲 Dotflow turns an idea into flow!"
5
5
 
6
6
  from .core.action import Action as action
@@ -8,5 +8,5 @@ class Notify(ABC):
8
8
  """Notify"""
9
9
 
10
10
  @abstractmethod
11
- def send(self, task: Any) -> None:
12
- """Send"""
11
+ def hook_status_task(self, task: Any) -> None:
12
+ """Hook called when a task status changes."""
@@ -144,7 +144,8 @@ class Action:
144
144
  for attempt in range(1, self.retry + 1):
145
145
  try:
146
146
  if self.timeout:
147
- with ThreadPoolExecutor(max_workers=1) as executor:
147
+ executor = ThreadPoolExecutor(max_workers=1)
148
+ try:
148
149
  future = executor.submit(
149
150
  self._call_func,
150
151
  is_async,
@@ -152,11 +153,23 @@ class Action:
152
153
  **kwargs,
153
154
  )
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)
155
165
  else:
156
166
  result = self._call_func(is_async, *args, **kwargs)
157
167
 
158
168
  return result
159
169
 
170
+ except TimeoutError:
171
+ raise
172
+
160
173
  except Exception as error:
161
174
  last_exception = error
162
175
 
@@ -166,7 +179,7 @@ class Action:
166
179
  raise ExecutionWithClassError() from None
167
180
 
168
181
  if attempt == self.retry:
169
- raise last_exception from last_exception
182
+ raise last_exception from None
170
183
 
171
184
  if task is not None:
172
185
  task.retry_count += 1
@@ -199,14 +212,20 @@ class Action:
199
212
 
200
213
  def _set_params(self):
201
214
  if isinstance(self.func, FunctionType):
202
- self.params = list(self.func.__code__.co_varnames)
215
+ code = self.func.__code__
216
+ self.params = list(
217
+ code.co_varnames[: code.co_argcount + code.co_kwonlyargcount]
218
+ )
203
219
 
204
220
  if (
205
221
  type(self.func) is type
206
222
  and hasattr(self.func, "__init__")
207
223
  and hasattr(self.func.__init__, "__code__")
208
224
  ):
209
- self.params = list(self.func.__init__.__code__.co_varnames)
225
+ code = self.func.__init__.__code__
226
+ self.params = list(
227
+ code.co_varnames[: code.co_argcount + code.co_kwonlyargcount]
228
+ )
210
229
 
211
230
  def _get_context(self, kwargs: dict):
212
231
  context = {}
@@ -61,17 +61,19 @@ class Config:
61
61
 
62
62
  def __init__(
63
63
  self,
64
- storage: Storage | None = StorageDefault(),
65
- notify: Notify | None = NotifyDefault(),
66
- log: Log | None = LogDefault(),
67
- api: Api | None = ApiDefault(),
68
- scheduler: Scheduler | None = SchedulerDefault(),
64
+ storage: Storage | None = None,
65
+ notify: Notify | None = None,
66
+ log: Log | None = None,
67
+ api: Api | None = None,
68
+ scheduler: Scheduler | None = None,
69
69
  ) -> None:
70
- self.storage = storage
71
- self.notify = notify
72
- self.log = log
73
- self.api = api
74
- self.scheduler = scheduler
70
+ self.storage = storage if storage is not None else StorageDefault()
71
+ self.notify = notify if notify is not None else NotifyDefault()
72
+ self.log = log if log is not None else LogDefault()
73
+ self.api = api if api is not None else ApiDefault()
74
+ self.scheduler = (
75
+ scheduler if scheduler is not None else SchedulerDefault()
76
+ )
75
77
 
76
78
  self._validate()
77
79
 
@@ -71,15 +71,19 @@ class Context(ContextInstance):
71
71
  if isinstance(value, int):
72
72
  self._task_id = value
73
73
 
74
- if not self.task_id:
75
- self._task_id = value
76
-
77
74
  @property
78
75
  def workflow_id(self):
79
76
  return self._workflow_id
80
77
 
81
78
  @workflow_id.setter
82
79
  def workflow_id(self, value: UUID):
80
+ if isinstance(value, str):
81
+ try:
82
+ value = UUID(value)
83
+ except ValueError as err:
84
+ raise ValueError(
85
+ f"Invalid workflow_id: '{value}' is not a valid UUID format."
86
+ ) from err
83
87
  if isinstance(value, UUID):
84
88
  self._workflow_id = value
85
89
 
@@ -1,5 +1,6 @@
1
1
  """Execution module"""
2
2
 
3
+ import re
3
4
  from collections.abc import Callable
4
5
  from datetime import datetime
5
6
  from inspect import getsourcelines
@@ -59,7 +60,7 @@ class Execution:
59
60
  return (
60
61
  callable(getattr(class_instance, func))
61
62
  and getattr(class_instance, func).__module__
62
- is Action.__module__
63
+ == Action.__module__
63
64
  and not func.startswith("__")
64
65
  )
65
66
  except AttributeError:
@@ -74,9 +75,13 @@ class Execution:
74
75
  inside_code = getsourcelines(class_instance.__class__)[0]
75
76
 
76
77
  for callable_name in callable_list:
78
+ pattern = re.compile(
79
+ rf"\bdef\s+{re.escape(callable_name)}\s*\("
80
+ )
77
81
  for index, code in enumerate(inside_code):
78
- if code.find(f"def {callable_name}") != -1:
82
+ if pattern.search(code):
79
83
  ordered_list.append((index, callable_name))
84
+ break
80
85
 
81
86
  ordered_list.sort()
82
87
  return ordered_list
@@ -19,7 +19,7 @@ class Module:
19
19
  spec = file_location(value, cls._get_path(value))
20
20
  module = module_from_spec(spec)
21
21
 
22
- sys.modules[module] = module
22
+ sys.modules[module.__name__] = module
23
23
  spec.loader.exec_module(module)
24
24
 
25
25
  if hasattr(module, cls._get_name(value)):
@@ -248,7 +248,7 @@ class Task(TaskInstance):
248
248
  def status(self, value: TypeStatus) -> None:
249
249
  self._status = value
250
250
 
251
- self.config.notify.send(task=self)
251
+ self.config.notify.hook_status_task(task=self)
252
252
  self.config.log.info(task=self)
253
253
 
254
254
  @property
@@ -16,7 +16,18 @@ from dotflow.core.task import Task, TaskError
16
16
  from dotflow.core.types import TypeExecution, TypeStatus
17
17
  from dotflow.utils import basic_callback
18
18
 
19
- _mp = get_context("fork") if sys.platform != "win32" else get_context("spawn")
19
+ if sys.platform == "win32":
20
+ _mp = get_context("spawn")
21
+ else:
22
+ import multiprocessing
23
+
24
+ if sys.platform == "darwin" and hasattr(
25
+ multiprocessing, "set_forkserver_preload"
26
+ ):
27
+ import os
28
+
29
+ os.environ.setdefault("OBJC_DISABLE_INITIALIZE_FORK_SAFETY", "YES")
30
+ _mp = get_context("fork")
20
31
 
21
32
 
22
33
  def grouper(tasks: list[Task]) -> dict[str, list[Task]]:
@@ -107,20 +118,27 @@ class Manager:
107
118
  execution = None
108
119
  groups = grouper(tasks=tasks)
109
120
 
110
- try:
111
- execution = getattr(self, mode)
112
- except AttributeError as err:
113
- raise ExecutionModeNotExist() from err
121
+ VALID_MODES = {
122
+ "sequential",
123
+ "sequential_group",
124
+ "background",
125
+ "parallel",
126
+ }
127
+ if mode not in VALID_MODES:
128
+ raise ExecutionModeNotExist()
129
+
130
+ execution = getattr(self, mode)
114
131
 
115
132
  self.tasks = execution(
116
133
  tasks=tasks,
117
- workflow_id=workflow_id,
134
+ workflow_id=self.workflow_id,
118
135
  ignore=keep_going,
119
136
  groups=groups,
120
137
  resume=resume,
121
138
  )
122
139
 
123
- self._callback_workflow(tasks=self.tasks)
140
+ if mode != TypeExecution.BACKGROUND:
141
+ self._callback_workflow(tasks=self.tasks)
124
142
 
125
143
  def _callback_workflow(self, tasks: list[Task]):
126
144
  final_status = [task.status for task in tasks]
@@ -144,6 +162,7 @@ class Manager:
144
162
 
145
163
  def background(self, **kwargs) -> list[Task]:
146
164
  process = Background(**kwargs)
165
+ self.thread = process.thread
147
166
  return process.get_tasks()
148
167
 
149
168
  def parallel(self, **kwargs) -> list[Task]:
@@ -302,12 +321,14 @@ class Background(Flow):
302
321
 
303
322
  def setup_queue(self) -> None:
304
323
  self.queue = []
324
+ self._lock = threading.Lock()
305
325
 
306
326
  def get_tasks(self) -> list[Task]:
307
- return self.queue
327
+ return self.tasks
308
328
 
309
329
  def _flow_callback(self, task: Task) -> None:
310
- self.queue.append(task)
330
+ with self._lock:
331
+ self.queue.append(task)
311
332
 
312
333
  def _has_checkpoint(self, task: Task) -> bool:
313
334
  if not self.resume:
@@ -348,9 +369,10 @@ class Background(Flow):
348
369
  break
349
370
 
350
371
  def run(self) -> None:
351
- thread = threading.Thread(target=self._run_sequential)
352
- thread.start()
353
- thread.join()
372
+ self.thread = threading.Thread(
373
+ target=self._run_sequential, daemon=True
374
+ )
375
+ self.thread.start()
354
376
 
355
377
 
356
378
  class Parallel(Flow):
@@ -2,6 +2,7 @@
2
2
 
3
3
  from dotflow.providers.log_default import LogDefault
4
4
  from dotflow.providers.notify_default import NotifyDefault
5
+ from dotflow.providers.notify_discord import NotifyDiscord
5
6
  from dotflow.providers.notify_telegram import NotifyTelegram
6
7
  from dotflow.providers.scheduler_default import SchedulerDefault
7
8
  from dotflow.providers.storage_default import StorageDefault
@@ -10,6 +11,7 @@ from dotflow.providers.storage_file import StorageFile
10
11
  __all__ = [
11
12
  "LogDefault",
12
13
  "NotifyDefault",
14
+ "NotifyDiscord",
13
15
  "NotifyTelegram",
14
16
  "SchedulerCron",
15
17
  "SchedulerDefault",
@@ -6,5 +6,5 @@ from dotflow.abc.notify import Notify
6
6
 
7
7
 
8
8
  class NotifyDefault(Notify):
9
- def send(self, task: Any) -> None:
9
+ def hook_status_task(self, task: Any) -> None:
10
10
  pass
@@ -0,0 +1,112 @@
1
+ """Notify Discord"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from json import dumps
6
+ from typing import Any
7
+
8
+ from requests import post
9
+
10
+ from dotflow.abc.notify import Notify
11
+ from dotflow.core.types.status import TypeStatus
12
+ from dotflow.logging import logger
13
+
14
+
15
+ class NotifyDiscord(Notify):
16
+ """
17
+ Import:
18
+ You can import the **NotifyDiscord** class with:
19
+
20
+ from dotflow.providers import NotifyDiscord
21
+
22
+ Example:
23
+ `class` dotflow.providers.notify_discord.NotifyDiscord
24
+
25
+ from dotflow import Config, DotFlow
26
+ from dotflow.providers import NotifyDiscord
27
+ from dotflow.core.types.status import TypeStatus
28
+
29
+ config = Config(
30
+ notify=NotifyDiscord(
31
+ webhook_url="https://discord.com/api/webhooks/...",
32
+ notification_type=TypeStatus.FAILED,
33
+ )
34
+ )
35
+
36
+ workflow = DotFlow(config=config)
37
+
38
+ Args:
39
+ webhook_url (str): Discord webhook URL.
40
+
41
+ notification_type (Optional[TypeStatus]): Filter notifications
42
+ by task status. If None, all statuses are notified.
43
+
44
+ show_result (bool): Include task result in the notification.
45
+ Defaults to False.
46
+
47
+ timeout (float): Request timeout in seconds.
48
+ """
49
+
50
+ COLORS = {
51
+ TypeStatus.COMPLETED: 0x4CAF50,
52
+ TypeStatus.FAILED: 0xF44336,
53
+ TypeStatus.RETRY: 0xFF9800,
54
+ TypeStatus.IN_PROGRESS: 0x2196F3,
55
+ TypeStatus.NOT_STARTED: 0x9E9E9E,
56
+ TypeStatus.PAUSED: 0x607D8B,
57
+ }
58
+
59
+ def __init__(
60
+ self,
61
+ webhook_url: str,
62
+ notification_type: TypeStatus | None = None,
63
+ show_result: bool = False,
64
+ timeout: float = 1.5,
65
+ ):
66
+ self.webhook_url = webhook_url
67
+ self.notification_type = notification_type
68
+ self.show_result = show_result
69
+ self.timeout = timeout
70
+
71
+ def hook_status_task(self, task: Any) -> None:
72
+ if self.notification_type and self.notification_type != task.status:
73
+ return
74
+
75
+ try:
76
+ response = post(
77
+ url=self.webhook_url,
78
+ headers={"Content-Type": "application/json"},
79
+ data=dumps({"embeds": [self._build_embed(task)]}),
80
+ timeout=self.timeout,
81
+ )
82
+ response.raise_for_status()
83
+ except Exception as error:
84
+ logger.error(
85
+ "Internal problem sending notification on Discord: %s",
86
+ str(error),
87
+ )
88
+
89
+ def _build_embed(self, task: Any) -> dict:
90
+ embed = {
91
+ "title": f"{TypeStatus.get_symbol(task.status)} {task.status}",
92
+ "color": self.COLORS.get(task.status, 0x9E9E9E),
93
+ "description": f"`{task.workflow_id}` — Task {task.task_id}",
94
+ "fields": [],
95
+ }
96
+
97
+ if self.show_result:
98
+ embed["fields"].append(
99
+ {
100
+ "name": "Result",
101
+ "value": f"```json\n{task.result(max=1024)}```",
102
+ }
103
+ )
104
+
105
+ if task.status == TypeStatus.FAILED and task.errors:
106
+ last_error = task.errors[-1]
107
+ error_value = f"`{last_error.exception}`: {last_error.message}"
108
+ embed["fields"].append(
109
+ {"name": "Error", "value": error_value[:1024]}
110
+ )
111
+
112
+ return embed
@@ -0,0 +1,109 @@
1
+ """Notify Telegram"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from json import dumps
6
+ from typing import Any
7
+
8
+ from requests import post
9
+
10
+ from dotflow.abc.notify import Notify
11
+ from dotflow.core.types.status import TypeStatus
12
+ from dotflow.logging import logger
13
+
14
+
15
+ class NotifyTelegram(Notify):
16
+ """
17
+ Import:
18
+ You can import the **NotifyTelegram** class with:
19
+
20
+ from dotflow.providers import NotifyTelegram
21
+
22
+ Example:
23
+ `class` dotflow.providers.notify_telegram.NotifyTelegram
24
+
25
+ from dotflow import Config, DotFlow
26
+ from dotflow.providers import NotifyTelegram
27
+ from dotflow.core.types.status import TypeStatus
28
+
29
+ config = Config(
30
+ notify=NotifyTelegram(
31
+ token="YOUR_BOT_TOKEN",
32
+ chat_id=123456789,
33
+ notification_type=TypeStatus.FAILED,
34
+ )
35
+ )
36
+
37
+ workflow = DotFlow(config=config)
38
+
39
+ Args:
40
+ token (str): Telegram bot token from BotFather.
41
+
42
+ chat_id (int): Telegram chat ID to send messages to.
43
+
44
+ notification_type (Optional[TypeStatus]): Filter notifications
45
+ by task status. If None, all statuses are notified.
46
+
47
+ show_result (bool): Include task result in the notification.
48
+ Defaults to False.
49
+
50
+ timeout (float): Request timeout in seconds.
51
+ """
52
+
53
+ API_URL = "https://api.telegram.org/bot{token}/sendMessage"
54
+
55
+ def __init__(
56
+ self,
57
+ token: str,
58
+ chat_id: int,
59
+ notification_type: TypeStatus | None = None,
60
+ show_result: bool = False,
61
+ timeout: float = 1.5,
62
+ ):
63
+ self.token = token
64
+ self.chat_id = chat_id
65
+ self.notification_type = notification_type
66
+ self.show_result = show_result
67
+ self.timeout = timeout
68
+
69
+ def hook_status_task(self, task: Any) -> None:
70
+ if self.notification_type and self.notification_type != task.status:
71
+ return
72
+
73
+ try:
74
+ response = post(
75
+ url=self.API_URL.format(token=self.token),
76
+ headers={"Content-Type": "application/json"},
77
+ data=dumps(
78
+ {
79
+ "chat_id": self.chat_id,
80
+ "text": self._build_message(task),
81
+ "parse_mode": "markdown",
82
+ }
83
+ ),
84
+ timeout=self.timeout,
85
+ )
86
+ response.raise_for_status()
87
+ except Exception as error:
88
+ logger.error(
89
+ "Internal problem sending notification on Telegram: %s",
90
+ str(error),
91
+ )
92
+
93
+ def _build_message(self, task: Any) -> str:
94
+ symbol = TypeStatus.get_symbol(task.status)
95
+ header = f"{symbol} {task.status}"
96
+ footer = f"`{task.workflow_id}` — Task {task.task_id}"
97
+
98
+ parts = [header]
99
+
100
+ if self.show_result:
101
+ parts.append(f"```json\n{task.result(max=4000)}```")
102
+
103
+ if task.status == TypeStatus.FAILED and task.errors:
104
+ last_error = task.errors[-1]
105
+ parts.append(f"`{last_error.exception}`: {last_error.message}")
106
+
107
+ parts.append(footer)
108
+
109
+ return "\n".join(parts)
@@ -92,6 +92,9 @@ class SchedulerCron(Scheduler):
92
92
  self._lock = threading.Lock()
93
93
  self._queue_count = 0
94
94
  self._parallel_semaphore = threading.Semaphore(10)
95
+ self._threads: list[threading.Thread] = []
96
+ self._prev_sigint = None
97
+ self._prev_sigterm = None
95
98
 
96
99
  def start(self, workflow: Callable, **kwargs) -> None:
97
100
  """Start the scheduler loop. Blocks the main thread.
@@ -123,9 +126,18 @@ class SchedulerCron(Scheduler):
123
126
 
124
127
  self._dispatch(workflow=workflow, **kwargs)
125
128
 
126
- def stop(self) -> None:
127
- """Stop the scheduler loop gracefully."""
129
+ def stop(self, timeout: float | None = None) -> None:
130
+ """Stop the scheduler loop and wait for in-flight threads.
131
+
132
+ Args:
133
+ timeout: Max seconds to wait for each thread. None = wait forever.
134
+ """
128
135
  self.running = False
136
+ self._restore_signals()
137
+ with self._lock:
138
+ threads, self._threads = self._threads, []
139
+ for thread in threads:
140
+ thread.join(timeout=timeout)
129
141
 
130
142
  def _dispatch(self, workflow: Callable, **kwargs) -> None:
131
143
  if self.overlap == TypeOverlap.SKIP:
@@ -140,6 +152,11 @@ class SchedulerCron(Scheduler):
140
152
  self.overlap,
141
153
  )
142
154
 
155
+ def _track_thread(self, thread: threading.Thread) -> None:
156
+ with self._lock:
157
+ self._threads = [t for t in self._threads if t.is_alive()]
158
+ self._threads.append(thread)
159
+
143
160
  def _dispatch_skip(self, workflow: Callable, **kwargs) -> None:
144
161
  with self._lock:
145
162
  if self._executing:
@@ -151,6 +168,7 @@ class SchedulerCron(Scheduler):
151
168
  args=(workflow,),
152
169
  kwargs=kwargs,
153
170
  )
171
+ self._track_thread(thread)
154
172
  thread.start()
155
173
 
156
174
  def _dispatch_queue(self, workflow: Callable, **kwargs) -> None:
@@ -166,6 +184,7 @@ class SchedulerCron(Scheduler):
166
184
  args=(workflow,),
167
185
  kwargs=kwargs,
168
186
  )
187
+ self._track_thread(thread)
169
188
  thread.start()
170
189
 
171
190
  def _dispatch_parallel(self, workflow: Callable, **kwargs) -> None:
@@ -177,6 +196,7 @@ class SchedulerCron(Scheduler):
177
196
  args=(workflow,),
178
197
  kwargs=kwargs,
179
198
  )
199
+ self._track_thread(thread)
180
200
  thread.start()
181
201
 
182
202
  def _execute_parallel(self, workflow: Callable, **kwargs) -> None:
@@ -204,21 +224,35 @@ class SchedulerCron(Scheduler):
204
224
  finally:
205
225
  with self._lock:
206
226
  if self._queue_count > 0:
207
- self._queue_count -= 1
208
- thread = threading.Thread(
227
+ self._queue_count = 0
228
+ next_thread = threading.Thread(
209
229
  target=self._execute_queued,
210
230
  args=(workflow,),
211
231
  kwargs=kwargs,
212
232
  )
213
- thread.start()
214
233
  else:
215
234
  self._executing = False
235
+ next_thread = None
236
+
237
+ if next_thread is not None:
238
+ self._track_thread(next_thread)
239
+ next_thread.start()
216
240
 
217
241
  def _register_signals(self) -> None:
218
242
  if threading.current_thread() is not threading.main_thread():
219
243
  return
220
- signal.signal(signal.SIGINT, self._handle_signal)
221
- signal.signal(signal.SIGTERM, self._handle_signal)
244
+ self._prev_sigint = signal.signal(signal.SIGINT, self._handle_signal)
245
+ self._prev_sigterm = signal.signal(signal.SIGTERM, self._handle_signal)
246
+
247
+ def _restore_signals(self) -> None:
248
+ if threading.current_thread() is not threading.main_thread():
249
+ return
250
+ if self._prev_sigint is not None:
251
+ signal.signal(signal.SIGINT, self._prev_sigint)
252
+ self._prev_sigint = None
253
+ if self._prev_sigterm is not None:
254
+ signal.signal(signal.SIGTERM, self._prev_sigterm)
255
+ self._prev_sigterm = None
222
256
 
223
257
  def _handle_signal(self, signum, frame) -> None:
224
258
  self.stop()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotflow"
3
- version = "0.14.0.dev1"
3
+ version = "0.14.1.dev2"
4
4
  authors = [
5
5
  { name="Fernando Celmer", email="email@fernandocelmer.com" },
6
6
  ]
@@ -54,14 +54,13 @@ Documentation = "https://dotflow-io.github.io/dotflow/"
54
54
  Changelog = "https://dotflow-io.github.io/dotflow/nav/development/release-notes/"
55
55
 
56
56
  [project.optional-dependencies]
57
- mongodb = ["dotflow-mongodb"]
58
57
  aws = ["boto3"]
59
58
  gcp = ["google-cloud-storage"]
60
59
  scheduler = ["croniter"]
61
60
 
62
61
  [tool.poetry]
63
62
  name = "dotflow"
64
- version = "0.14.0.dev1"
63
+ version = "0.14.1.dev2"
65
64
  description = "🎲 Dotflow turns an idea into flow! Lightweight Python library for execution pipelines with retry, parallel, cron and async support."
66
65
  authors = ["Fernando Celmer <email@fernandocelmer.com>"]
67
66
  readme = "README.md"
@@ -116,7 +115,7 @@ requests = "^2.32.4"
116
115
  build = "^1.2.2.post1"
117
116
  pytest = "^8.3.4"
118
117
  flake8 = "^7.1.1"
119
- tox = "^4.23.2"
118
+ tox = "^4.26.0"
120
119
  pytest-cov = "^6.0.0"
121
120
  pylint = "^3.3.4"
122
121
  pyzmq = "^26.2.1"
@@ -138,6 +137,7 @@ mkdocs-simple-blog = "0.2.0"
138
137
  mkdocs-material = "^9.6.13"
139
138
  markdown-include-variants = "^0.0.4"
140
139
  mkdocs-static-i18n = "^1.3.0"
140
+ mkdocs-redirects = "^1.2.2"
141
141
  mdx-include = "^1.4.2"
142
142
  griffe = "<1.14"
143
143
 
@@ -1,59 +0,0 @@
1
- """Notify Default"""
2
-
3
- from __future__ import annotations
4
-
5
- from json import dumps
6
- from typing import Any
7
-
8
- from requests import post
9
-
10
- from dotflow.abc.notify import Notify
11
- from dotflow.core.types.status import TypeStatus
12
- from dotflow.logging import logger
13
-
14
-
15
- class NotifyTelegram(Notify):
16
- MESSAGE = "{symbol} {status}\n```json\n{task}```\n{workflow_id}-{task_id}"
17
- API_TELEGRAM = "https://api.telegram.org/bot{token}/sendMessage"
18
-
19
- def __init__(
20
- self,
21
- token: str,
22
- chat_id: int,
23
- notification_type: TypeStatus | None = None,
24
- timeout: int = 1.5,
25
- ):
26
- self.token = token
27
- self.chat_id = chat_id
28
- self.notification_type = notification_type
29
- self.timeout = timeout
30
-
31
- def send(self, task: Any) -> None:
32
- if not self.notification_type or self.notification_type == task.status:
33
- data = {
34
- "chat_id": self.chat_id,
35
- "text": self._get_text(task=task),
36
- "parse_mode": "markdown",
37
- }
38
- try:
39
- response = post(
40
- url=self.API_TELEGRAM.format(token=self.token),
41
- headers={"Content-Type": "application/json"},
42
- data=dumps(data),
43
- timeout=self.timeout,
44
- )
45
- response.raise_for_status()
46
- except Exception as error:
47
- logger.error(
48
- "Internal problem sending notification on Telegram: %s",
49
- str(error),
50
- )
51
-
52
- def _get_text(self, task: Any) -> str:
53
- return self.MESSAGE.format(
54
- symbol=TypeStatus.get_symbol(task.status),
55
- status=task.status,
56
- workflow_id=task.workflow_id,
57
- task_id=task.task_id,
58
- task=task.result(max=4000),
59
- )
File without changes