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.
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/PKG-INFO +6 -6
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/README.md +5 -3
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/__init__.py +1 -1
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/notify.py +2 -2
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/action.py +23 -4
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/config.py +12 -10
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/context.py +7 -3
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/execution.py +7 -2
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/module.py +1 -1
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/task.py +1 -1
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/workflow.py +34 -12
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/__init__.py +2 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/notify_default.py +1 -1
- dotflow-0.14.1.dev2/dotflow/providers/notify_discord.py +112 -0
- dotflow-0.14.1.dev2/dotflow/providers/notify_telegram.py +109 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/scheduler_cron.py +41 -7
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/pyproject.toml +4 -4
- dotflow-0.14.0.dev1/dotflow/providers/notify_telegram.py +0 -59
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/LICENSE +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/api.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/file.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/flow.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/http.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/log.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/scheduler.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/storage.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/abc/tcp.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/command.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/init.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/log.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/schedule.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/commands/start.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/setup.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/validators/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/cli/validators/start.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/decorators/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/decorators/time.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/dotflow.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/exception.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/task.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/transport.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/serializers/workflow.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/execution.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/overlap.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/status.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/storage.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/core/types/workflow.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/logging.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/main.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/api_default.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/log_default.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/scheduler_default.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_default.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_file.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_gcs.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/providers/storage_s3.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/settings.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/storage.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/types.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/__init__.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/basic_functions.py +0 -0
- {dotflow-0.14.0.dev1 → dotflow-0.14.1.dev2}/dotflow/utils/error_handler.py +0 -0
- {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.
|
|
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/
|
|
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/
|
|
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` | `
|
|
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/
|
|
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/
|
|
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` | `
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
65
|
-
notify: Notify | None =
|
|
66
|
-
log: Log | None =
|
|
67
|
-
api: Api | None =
|
|
68
|
-
scheduler: Scheduler | None =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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)):
|
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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.
|
|
327
|
+
return self.tasks
|
|
308
328
|
|
|
309
329
|
def _flow_callback(self, task: Task) -> None:
|
|
310
|
-
self.
|
|
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(
|
|
352
|
-
|
|
353
|
-
|
|
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",
|
|
@@ -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
|
|
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
|
|
208
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|