dotflow 1.0.0.dev4__tar.gz → 1.0.1__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-1.0.0.dev4 → dotflow-1.0.1}/PKG-INFO +2 -3
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/__init__.py +1 -1
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/flow.py +2 -0
- dotflow-1.0.1/dotflow/abc/storage.py +77 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/login.py +2 -10
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/start.py +3 -3
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/setup.py +0 -11
- dotflow-1.0.1/dotflow/cloud/aws/services/s3.py +148 -0
- dotflow-1.0.1/dotflow/cloud/gcp/services/gcs.py +110 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/dotflow.py +35 -9
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/engine.py +2 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/exception.py +18 -0
- dotflow-1.0.1/dotflow/core/fingerprint.py +45 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/serializers/task.py +7 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/serializers/transport.py +2 -0
- dotflow-1.0.1/dotflow/core/serializers/workflow.py +28 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/task.py +11 -4
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/types/__init__.py +3 -0
- dotflow-1.0.1/dotflow/core/types/input_change.py +31 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/workflow.py +64 -3
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/server_default.py +6 -9
- dotflow-1.0.1/dotflow/providers/storage_default.py +118 -0
- dotflow-1.0.1/dotflow/providers/storage_file.py +206 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/storage_gcs.py +72 -3
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/storage_s3.py +68 -3
- dotflow-1.0.1/dotflow/testing/__init__.py +5 -0
- dotflow-1.0.1/dotflow/testing/storage_contract.py +135 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/utils/__init__.py +2 -1
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/utils/tools.py +9 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/pyproject.toml +4 -6
- dotflow-1.0.0.dev4/dotflow/abc/storage.py +0 -25
- dotflow-1.0.0.dev4/dotflow/cloud/aws/services/s3.py +0 -47
- dotflow-1.0.0.dev4/dotflow/cloud/gcp/services/gcs.py +0 -45
- dotflow-1.0.0.dev4/dotflow/core/serializers/workflow.py +0 -14
- dotflow-1.0.0.dev4/dotflow/providers/storage_default.py +0 -22
- dotflow-1.0.0.dev4/dotflow/providers/storage_file.py +0 -72
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/LICENSE +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/README.md +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/file.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/http.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/log.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/metrics.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/notify.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/scheduler.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/server.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/tcp.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/abc/tracer.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/command.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/cloud.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/deploy.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/init.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/log.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/logout.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/schedule.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/commands/storage.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/validators/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cli/validators/start.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/constants.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/fc.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/fc_scheduled.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/services/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/alibaba/services/cr.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/constants.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/base_lambda.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/ecs.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/ecs_scheduled.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_api.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_s3.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_sqs.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/schedule.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/services/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/services/cloudwatch.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/services/ecr.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/aws/services/iam.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/core.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/constants.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/deployers/cloudrun.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/services/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/services/apis.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/gcp/services/artifact_registry.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/github/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/github/constants.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/github/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/cloud/github/deployers/actions.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/action.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/config.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/config_file.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/context.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/decorators/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/decorators/time.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/module.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/serializers/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/types/execution.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/types/overlap.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/types/status.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/types/storage.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/core/types/workflow.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/logging.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/main.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/__init__.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/log_default.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/log_opentelemetry.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/log_sentry.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/metrics_default.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/metrics_opentelemetry.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/notify_default.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/notify_discord.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/notify_telegram.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/scheduler_cron.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/scheduler_default.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/tracer_default.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/tracer_opentelemetry.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/providers/tracer_sentry.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/settings.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/types.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/utils/basic_functions.py +0 -0
- {dotflow-1.0.0.dev4 → dotflow-1.0.1}/dotflow/utils/error_handler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotflow
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
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: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -196,7 +196,7 @@ License-File: LICENSE
|
|
|
196
196
|
Keywords: pipeline,workflow,etl,task-runner,orchestration,automation,parallel,async,retry,data-pipeline,scheduler,cron,cron-job
|
|
197
197
|
Author: Fernando Celmer
|
|
198
198
|
Author-email: email@fernandocelmer.com
|
|
199
|
-
Requires-Python: >=3.
|
|
199
|
+
Requires-Python: >=3.10
|
|
200
200
|
Classifier: Development Status :: 4 - Beta
|
|
201
201
|
Classifier: Operating System :: OS Independent
|
|
202
202
|
Classifier: Intended Audience :: Developers
|
|
@@ -205,7 +205,6 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
205
205
|
Classifier: Topic :: Software Development :: Libraries
|
|
206
206
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
207
207
|
Classifier: Topic :: System :: Distributed Computing
|
|
208
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
209
208
|
Classifier: Programming Language :: Python :: 3.10
|
|
210
209
|
Classifier: Programming Language :: Python :: 3.11
|
|
211
210
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Storage ABC"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dotflow.core.context import Context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Storage(ABC):
|
|
13
|
+
"""Storage"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, **kwargs):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def post(
|
|
20
|
+
self,
|
|
21
|
+
key: str,
|
|
22
|
+
context: Context,
|
|
23
|
+
ttl: int | None = None,
|
|
24
|
+
fingerprint: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Persist context under key."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get(self, key: str) -> Context:
|
|
30
|
+
"""Return stored context or empty Context()."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def key(self, task: Callable) -> str:
|
|
34
|
+
"""Storage key for task."""
|
|
35
|
+
|
|
36
|
+
def clear(self, workflow_id: str) -> None:
|
|
37
|
+
"""Remove every entry under workflow_id.
|
|
38
|
+
|
|
39
|
+
Default implementation delegates to ``delete_prefix``. External
|
|
40
|
+
subclasses may override directly.
|
|
41
|
+
"""
|
|
42
|
+
self.delete_prefix(f"{workflow_id}-")
|
|
43
|
+
|
|
44
|
+
def delete(self, key: str) -> bool:
|
|
45
|
+
"""Remove key. Returns True when present.
|
|
46
|
+
|
|
47
|
+
Optional in 1.x. Becomes abstract in 2.0.
|
|
48
|
+
"""
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def delete_prefix(self, prefix: str) -> int:
|
|
52
|
+
"""Remove keys starting with prefix. Returns count.
|
|
53
|
+
|
|
54
|
+
Optional in 1.x. Becomes abstract in 2.0.
|
|
55
|
+
"""
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
def list_keys(self, prefix: str) -> Iterable[str]:
|
|
59
|
+
"""Iterate keys starting with prefix.
|
|
60
|
+
|
|
61
|
+
Optional in 1.x. Becomes abstract in 2.0.
|
|
62
|
+
"""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def atomic_swap(
|
|
66
|
+
self,
|
|
67
|
+
key: str,
|
|
68
|
+
expected: Any,
|
|
69
|
+
new: Any,
|
|
70
|
+
ttl: int | None = None,
|
|
71
|
+
fingerprint: str | None = None,
|
|
72
|
+
) -> bool:
|
|
73
|
+
"""Replace value when current equals expected.
|
|
74
|
+
|
|
75
|
+
Optional in 1.x. Becomes abstract in 2.0.
|
|
76
|
+
"""
|
|
77
|
+
raise NotImplementedError
|
|
@@ -23,14 +23,8 @@ MAX_POLL_INTERVAL = 60
|
|
|
23
23
|
|
|
24
24
|
class LoginCommand(Command):
|
|
25
25
|
def setup(self):
|
|
26
|
-
token = getattr(self.params, "token", None)
|
|
27
26
|
base_url = self._resolve_base_url()
|
|
28
27
|
|
|
29
|
-
if token:
|
|
30
|
-
save_cloud_config(token=token, base_url=base_url)
|
|
31
|
-
print(settings.INFO_ALERT, "Token saved.")
|
|
32
|
-
return
|
|
33
|
-
|
|
34
28
|
handshake = self._start_device(base_url)
|
|
35
29
|
if handshake is None:
|
|
36
30
|
return
|
|
@@ -49,10 +43,8 @@ class LoginCommand(Command):
|
|
|
49
43
|
print(settings.INFO_ALERT, "Authenticated.")
|
|
50
44
|
|
|
51
45
|
def _resolve_base_url(self) -> str:
|
|
52
|
-
"""
|
|
53
|
-
explicit =
|
|
54
|
-
"SERVER_BASE_URL"
|
|
55
|
-
)
|
|
46
|
+
"""``SERVER_BASE_URL`` env var wins over the default."""
|
|
47
|
+
explicit = os.environ.get("SERVER_BASE_URL")
|
|
56
48
|
return explicit.rstrip("/") if explicit else DEFAULT_BASE_URL
|
|
57
49
|
|
|
58
50
|
def _start_device(self, base_url: str) -> dict | None:
|
|
@@ -51,12 +51,12 @@ class StartCommand(Command):
|
|
|
51
51
|
if not callable(factory):
|
|
52
52
|
raise InvalidWorkflowFactory(factory=self.params.workflow)
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
workflow = factory()
|
|
55
55
|
|
|
56
|
-
if not isinstance(
|
|
56
|
+
if not isinstance(workflow, DotFlow):
|
|
57
57
|
raise InvalidWorkflowFactory(factory=self.params.workflow)
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
workflow.start(mode=self.params.mode, resume=self.params.resume)
|
|
60
60
|
|
|
61
61
|
def _new_workflow(self):
|
|
62
62
|
storage = self._build_storage()
|
|
@@ -56,17 +56,6 @@ class Command:
|
|
|
56
56
|
cmd = self.subparsers.add_parser(
|
|
57
57
|
"login", help="Authenticate the CLI via browser"
|
|
58
58
|
)
|
|
59
|
-
cmd_group = cmd.add_argument_group("Usage: dotflow login [OPTIONS]")
|
|
60
|
-
cmd_group.add_argument(
|
|
61
|
-
"--base-url",
|
|
62
|
-
default=None,
|
|
63
|
-
help="Override the Dotflow Cloud API base URL",
|
|
64
|
-
)
|
|
65
|
-
cmd_group.add_argument(
|
|
66
|
-
"--token",
|
|
67
|
-
default=None,
|
|
68
|
-
help="Skip the browser flow and save a pre-obtained API token",
|
|
69
|
-
)
|
|
70
59
|
cmd.set_defaults(exec=LoginCommand)
|
|
71
60
|
|
|
72
61
|
def setup_logout(self):
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""S3 operations — read and write objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from json import dumps, loads
|
|
6
|
+
|
|
7
|
+
from dotflow.cloud.core import ObjectStorage
|
|
8
|
+
from dotflow.core.exception import ModuleNotFound
|
|
9
|
+
|
|
10
|
+
_PRECONDITION_CODES = {
|
|
11
|
+
"PreconditionFailed",
|
|
12
|
+
"ConditionalRequestConflict",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class S3(ObjectStorage):
|
|
17
|
+
"""Amazon S3 object storage."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, bucket: str, prefix: str = "", region: str = None):
|
|
20
|
+
try:
|
|
21
|
+
import boto3
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ModuleNotFound(
|
|
24
|
+
module="boto3", library="dotflow[aws]"
|
|
25
|
+
) from None
|
|
26
|
+
|
|
27
|
+
self._s3 = boto3.client("s3", region_name=region)
|
|
28
|
+
self.bucket = bucket
|
|
29
|
+
self.prefix = prefix
|
|
30
|
+
|
|
31
|
+
self._s3.head_bucket(Bucket=self.bucket)
|
|
32
|
+
|
|
33
|
+
def read(self, key: str) -> list:
|
|
34
|
+
"""Read a JSON list from S3. Returns empty list if key not found."""
|
|
35
|
+
try:
|
|
36
|
+
response = self._s3.get_object(
|
|
37
|
+
Bucket=self.bucket,
|
|
38
|
+
Key=f"{self.prefix}{key}",
|
|
39
|
+
)
|
|
40
|
+
data = response["Body"].read().decode("utf-8")
|
|
41
|
+
return loads(data)
|
|
42
|
+
except self._s3.exceptions.NoSuchKey:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
def write(self, key: str, data: list) -> None:
|
|
46
|
+
"""Write a JSON list to S3."""
|
|
47
|
+
self._s3.put_object(
|
|
48
|
+
Bucket=self.bucket,
|
|
49
|
+
Key=f"{self.prefix}{key}",
|
|
50
|
+
Body=dumps(data),
|
|
51
|
+
ContentType="application/json",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def delete(self, key: str) -> bool:
|
|
55
|
+
"""Delete a single object."""
|
|
56
|
+
from botocore.exceptions import ClientError
|
|
57
|
+
|
|
58
|
+
full_key = f"{self.prefix}{key}"
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
self._s3.head_object(Bucket=self.bucket, Key=full_key)
|
|
62
|
+
except ClientError as error:
|
|
63
|
+
code = error.response.get("Error", {}).get("Code")
|
|
64
|
+
|
|
65
|
+
if code in ("404", "NoSuchKey", "NotFound"):
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
self._s3.delete_object(Bucket=self.bucket, Key=full_key)
|
|
71
|
+
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def read_with_etag(self, key: str) -> tuple[list, str | None]:
|
|
75
|
+
"""Return (data, etag). Etag is None when key is missing."""
|
|
76
|
+
try:
|
|
77
|
+
response = self._s3.get_object(
|
|
78
|
+
Bucket=self.bucket,
|
|
79
|
+
Key=f"{self.prefix}{key}",
|
|
80
|
+
)
|
|
81
|
+
data = response["Body"].read().decode("utf-8")
|
|
82
|
+
|
|
83
|
+
return loads(data), response.get("ETag")
|
|
84
|
+
except self._s3.exceptions.NoSuchKey:
|
|
85
|
+
return [], None
|
|
86
|
+
|
|
87
|
+
def write_if_match(self, key: str, data: list, etag: str | None) -> bool:
|
|
88
|
+
"""Conditional PutObject. Returns False on precondition failure."""
|
|
89
|
+
from botocore.exceptions import ClientError
|
|
90
|
+
|
|
91
|
+
kwargs = {
|
|
92
|
+
"Bucket": self.bucket,
|
|
93
|
+
"Key": f"{self.prefix}{key}",
|
|
94
|
+
"Body": dumps(data),
|
|
95
|
+
"ContentType": "application/json",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if etag is None:
|
|
99
|
+
kwargs["IfNoneMatch"] = "*"
|
|
100
|
+
else:
|
|
101
|
+
kwargs["IfMatch"] = etag
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
self._s3.put_object(**kwargs)
|
|
105
|
+
return True
|
|
106
|
+
except ClientError as error:
|
|
107
|
+
code = error.response.get("Error", {}).get("Code")
|
|
108
|
+
|
|
109
|
+
if code in _PRECONDITION_CODES:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
def list_keys(self, sub_prefix: str) -> list[str]:
|
|
115
|
+
"""Return keys starting with sub_prefix."""
|
|
116
|
+
full_prefix = f"{self.prefix}{sub_prefix}"
|
|
117
|
+
paginator = self._s3.get_paginator("list_objects_v2")
|
|
118
|
+
names = []
|
|
119
|
+
offset = len(self.prefix)
|
|
120
|
+
|
|
121
|
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
|
|
122
|
+
for item in page.get("Contents", []):
|
|
123
|
+
names.append(item["Key"][offset:])
|
|
124
|
+
|
|
125
|
+
return names
|
|
126
|
+
|
|
127
|
+
def delete_prefix(self, sub_prefix: str) -> None:
|
|
128
|
+
"""Delete every object whose key starts with prefix + sub_prefix.
|
|
129
|
+
|
|
130
|
+
Empty ``sub_prefix`` is rejected to avoid accidentally wiping
|
|
131
|
+
the entire bucket prefix.
|
|
132
|
+
"""
|
|
133
|
+
if not sub_prefix:
|
|
134
|
+
raise ValueError("delete_prefix requires a non-empty sub_prefix")
|
|
135
|
+
|
|
136
|
+
full_prefix = f"{self.prefix}{sub_prefix}"
|
|
137
|
+
paginator = self._s3.get_paginator("list_objects_v2")
|
|
138
|
+
|
|
139
|
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
|
|
140
|
+
objects = [
|
|
141
|
+
{"Key": item["Key"]} for item in page.get("Contents", [])
|
|
142
|
+
]
|
|
143
|
+
if not objects:
|
|
144
|
+
continue
|
|
145
|
+
self._s3.delete_objects(
|
|
146
|
+
Bucket=self.bucket,
|
|
147
|
+
Delete={"Objects": objects},
|
|
148
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""GCS operations — read and write objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from json import dumps, loads
|
|
6
|
+
|
|
7
|
+
from dotflow.cloud.core import ObjectStorage
|
|
8
|
+
from dotflow.core.exception import ModuleNotFound
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GCS(ObjectStorage):
|
|
12
|
+
"""Google Cloud Storage object storage."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, bucket: str, prefix: str = "", project: str = None):
|
|
15
|
+
try:
|
|
16
|
+
from google.api_core.exceptions import NotFound
|
|
17
|
+
from google.cloud import storage as gcs
|
|
18
|
+
except ImportError:
|
|
19
|
+
raise ModuleNotFound(
|
|
20
|
+
module="google-cloud-storage",
|
|
21
|
+
library="dotflow[gcp]",
|
|
22
|
+
) from None
|
|
23
|
+
|
|
24
|
+
self._not_found = NotFound
|
|
25
|
+
self._client = gcs.Client(project=project)
|
|
26
|
+
self._bucket = self._client.bucket(bucket)
|
|
27
|
+
self._bucket.reload()
|
|
28
|
+
self.prefix = prefix
|
|
29
|
+
|
|
30
|
+
def read(self, key: str) -> list:
|
|
31
|
+
"""Read a JSON list from GCS. Returns empty list if key not found."""
|
|
32
|
+
blob = self._bucket.blob(f"{self.prefix}{key}")
|
|
33
|
+
try:
|
|
34
|
+
data = blob.download_as_text()
|
|
35
|
+
return loads(data)
|
|
36
|
+
except self._not_found:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
def write(self, key: str, data: list) -> None:
|
|
40
|
+
"""Write a JSON list to GCS."""
|
|
41
|
+
blob = self._bucket.blob(f"{self.prefix}{key}")
|
|
42
|
+
blob.upload_from_string(
|
|
43
|
+
dumps(data),
|
|
44
|
+
content_type="application/json",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def delete(self, key: str) -> bool:
|
|
48
|
+
"""Delete a single blob."""
|
|
49
|
+
blob = self._bucket.blob(f"{self.prefix}{key}")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
blob.delete()
|
|
53
|
+
return True
|
|
54
|
+
except self._not_found:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def read_with_generation(self, key: str) -> tuple[list, int | None]:
|
|
58
|
+
"""Return (data, generation). Generation is None when missing."""
|
|
59
|
+
blob = self._bucket.blob(f"{self.prefix}{key}")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
data = blob.download_as_text()
|
|
63
|
+
return loads(data), blob.generation
|
|
64
|
+
except self._not_found:
|
|
65
|
+
return [], None
|
|
66
|
+
|
|
67
|
+
def write_if_generation_match(
|
|
68
|
+
self, key: str, data: list, generation: int | None
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""Conditional upload. Returns False on precondition failure."""
|
|
71
|
+
from google.api_core.exceptions import PreconditionFailed
|
|
72
|
+
|
|
73
|
+
blob = self._bucket.blob(f"{self.prefix}{key}")
|
|
74
|
+
precondition = generation if generation is not None else 0
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
blob.upload_from_string(
|
|
78
|
+
dumps(data),
|
|
79
|
+
content_type="application/json",
|
|
80
|
+
if_generation_match=precondition,
|
|
81
|
+
)
|
|
82
|
+
return True
|
|
83
|
+
except PreconditionFailed:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def list_keys(self, sub_prefix: str) -> list[str]:
|
|
87
|
+
"""Return blob names starting with sub_prefix."""
|
|
88
|
+
full_prefix = f"{self.prefix}{sub_prefix}"
|
|
89
|
+
offset = len(self.prefix)
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
blob.name[offset:]
|
|
93
|
+
for blob in self._client.list_blobs(
|
|
94
|
+
self._bucket, prefix=full_prefix
|
|
95
|
+
)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def delete_prefix(self, sub_prefix: str) -> None:
|
|
99
|
+
"""Delete every blob whose name starts with prefix + sub_prefix.
|
|
100
|
+
|
|
101
|
+
Empty ``sub_prefix`` is rejected to avoid accidentally wiping
|
|
102
|
+
the entire bucket prefix.
|
|
103
|
+
"""
|
|
104
|
+
if not sub_prefix:
|
|
105
|
+
raise ValueError("delete_prefix requires a non-empty sub_prefix")
|
|
106
|
+
|
|
107
|
+
full_prefix = f"{self.prefix}{sub_prefix}"
|
|
108
|
+
|
|
109
|
+
for blob in self._client.list_blobs(self._bucket, prefix=full_prefix):
|
|
110
|
+
blob.delete()
|
|
@@ -9,6 +9,8 @@ from uuid import uuid4
|
|
|
9
9
|
from dotflow.core.config import Config
|
|
10
10
|
from dotflow.core.task import TaskBuilder
|
|
11
11
|
from dotflow.core.workflow import Manager
|
|
12
|
+
from dotflow.logging import logger
|
|
13
|
+
from dotflow.utils import hostname
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class DotFlow:
|
|
@@ -34,9 +36,16 @@ class DotFlow:
|
|
|
34
36
|
workflow_id (Optional[str]): Fixed workflow identifier for checkpoint
|
|
35
37
|
resume. If not provided, a random UUID is generated.
|
|
36
38
|
|
|
39
|
+
name (Optional[str]): Human-readable label sent to the managed
|
|
40
|
+
server on registration. Defaults to the machine hostname so
|
|
41
|
+
runs from different hosts stay distinguishable in the
|
|
42
|
+
dashboard.
|
|
43
|
+
|
|
37
44
|
Attributes:
|
|
38
45
|
workflow_id (UUID):
|
|
39
46
|
|
|
47
|
+
name (str):
|
|
48
|
+
|
|
40
49
|
task (List[Task]):
|
|
41
50
|
|
|
42
51
|
start (Manager):
|
|
@@ -48,31 +57,48 @@ class DotFlow:
|
|
|
48
57
|
self,
|
|
49
58
|
config: Config | None = None,
|
|
50
59
|
workflow_id: str | None = None,
|
|
60
|
+
name: str | None = None,
|
|
51
61
|
) -> None:
|
|
52
62
|
workflow_id = workflow_id or os.getenv("WORKFLOW_ID")
|
|
53
63
|
self._externally_provided_id = workflow_id is not None
|
|
54
64
|
self.workflow_id = workflow_id or uuid4()
|
|
65
|
+
self.name = name or hostname()
|
|
55
66
|
self._config = config if config else Config()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
self._config.server.create_workflow(workflow=self.workflow_id)
|
|
67
|
+
self._manager: Manager | None = None
|
|
68
|
+
self._last_run_signature: tuple = ()
|
|
59
69
|
|
|
60
70
|
self.task = TaskBuilder(
|
|
61
71
|
config=self._config,
|
|
62
72
|
workflow_id=self.workflow_id,
|
|
73
|
+
workflow_name=self.name,
|
|
63
74
|
)
|
|
64
75
|
|
|
65
|
-
self.
|
|
66
|
-
|
|
67
|
-
tasks=self.task.queue,
|
|
68
|
-
workflow_id=self.workflow_id,
|
|
69
|
-
config=self._config,
|
|
70
|
-
)
|
|
76
|
+
if not self._externally_provided_id:
|
|
77
|
+
self._config.server.create_workflow(workflow=self)
|
|
71
78
|
|
|
72
79
|
self.schedule = partial(
|
|
73
80
|
self._config.scheduler.start, workflow=self.start
|
|
74
81
|
)
|
|
75
82
|
|
|
83
|
+
def start(self, **kwargs) -> Manager:
|
|
84
|
+
"""Run the workflow once; duplicate calls return the original Manager."""
|
|
85
|
+
signature = tuple(task.task_id for task in self.task.queue)
|
|
86
|
+
if self._manager is not None and signature == self._last_run_signature:
|
|
87
|
+
logger.warning(
|
|
88
|
+
"DotFlow.start() already ran for %s; ignoring duplicate call",
|
|
89
|
+
self.workflow_id,
|
|
90
|
+
)
|
|
91
|
+
return self._manager
|
|
92
|
+
|
|
93
|
+
self._last_run_signature = signature
|
|
94
|
+
self._manager = Manager(
|
|
95
|
+
tasks=self.task.queue,
|
|
96
|
+
workflow_id=self.workflow_id,
|
|
97
|
+
config=self._config,
|
|
98
|
+
**kwargs,
|
|
99
|
+
)
|
|
100
|
+
return self._manager
|
|
101
|
+
|
|
76
102
|
def result_task(self):
|
|
77
103
|
"""
|
|
78
104
|
Returns:
|
|
@@ -21,6 +21,14 @@ MESSAGE_INVALID_WORKFLOW_FACTORY = (
|
|
|
21
21
|
MESSAGE_WORKFLOW_FLAG_CONFLICT = (
|
|
22
22
|
"{flag} is only valid with --step and cannot be used with --workflow."
|
|
23
23
|
)
|
|
24
|
+
MESSAGE_INPUT_CHANGED = (
|
|
25
|
+
"Workflow '{workflow_id}' was previously executed with a different "
|
|
26
|
+
"initial_context. Pass on_input_change='reset' to discard prior "
|
|
27
|
+
"checkpoints, or 'reuse' to ignore the new input."
|
|
28
|
+
)
|
|
29
|
+
MESSAGE_INVALID_ON_INPUT_CHANGE = (
|
|
30
|
+
"on_input_change must be one of 'reuse', 'reset', 'raise'; got '{value}'."
|
|
31
|
+
)
|
|
24
32
|
|
|
25
33
|
|
|
26
34
|
class MissingActionDecorator(Exception):
|
|
@@ -72,6 +80,16 @@ class ExecutionWithClassError(Exception):
|
|
|
72
80
|
super().__init__("Unknown")
|
|
73
81
|
|
|
74
82
|
|
|
83
|
+
class InputChangedError(Exception):
|
|
84
|
+
def __init__(self, workflow_id: str):
|
|
85
|
+
super().__init__(MESSAGE_INPUT_CHANGED.format(workflow_id=workflow_id))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class InvalidOnInputChange(Exception):
|
|
89
|
+
def __init__(self, value: str):
|
|
90
|
+
super().__init__(MESSAGE_INVALID_ON_INPUT_CHANGE.format(value=value))
|
|
91
|
+
|
|
92
|
+
|
|
75
93
|
class TaskError:
|
|
76
94
|
def __init__(self, error: Exception = None, attempt: int = None) -> None:
|
|
77
95
|
self.attempt = attempt
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Input fingerprint helpers for resume + on_input_change policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
from json import dumps
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dotflow.core.context import Context
|
|
10
|
+
|
|
11
|
+
FP_KEY_SUFFIX = "-_input_fp"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fingerprint_of(values: list[Any]) -> str:
|
|
15
|
+
"""SHA256 of JSON-serialized list of initial_context payloads.
|
|
16
|
+
|
|
17
|
+
Inputs must be JSON-serializable (primitives, lists, dicts).
|
|
18
|
+
Non-serializable objects fall back to ``repr``, which is **not**
|
|
19
|
+
guaranteed to be stable across processes — objects without a custom
|
|
20
|
+
``__repr__`` will produce different fingerprints on every run because
|
|
21
|
+
the default repr embeds the memory address.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
encoded = dumps(values, sort_keys=True)
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
encoded = repr(values)
|
|
27
|
+
|
|
28
|
+
return sha256(encoded.encode("utf-8")).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fp_key(workflow_id: str) -> str:
|
|
32
|
+
return f"{workflow_id}{FP_KEY_SUFFIX}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_fingerprint(storage, workflow_id: str) -> str | None:
|
|
36
|
+
context = storage.get(key=fp_key(workflow_id))
|
|
37
|
+
|
|
38
|
+
if isinstance(context, Context) and isinstance(context.storage, str):
|
|
39
|
+
return context.storage
|
|
40
|
+
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_fingerprint(storage, workflow_id: str, value: str) -> None:
|
|
45
|
+
storage.post(key=fp_key(workflow_id), context=Context(storage=value))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Task serializer module"""
|
|
2
2
|
|
|
3
|
+
# mypy: disable-error-code="misc"
|
|
4
|
+
|
|
3
5
|
from __future__ import annotations
|
|
4
6
|
|
|
5
7
|
import json
|
|
@@ -49,6 +51,11 @@ class SerializerTask(BaseModel):
|
|
|
49
51
|
exclude=True,
|
|
50
52
|
)
|
|
51
53
|
|
|
54
|
+
@computed_field
|
|
55
|
+
@property
|
|
56
|
+
def id(self) -> Optional[str]:
|
|
57
|
+
return self.task_id
|
|
58
|
+
|
|
52
59
|
@computed_field
|
|
53
60
|
@property
|
|
54
61
|
def error(self) -> Optional[SerializerTaskError]:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Workflow serializer module"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# mypy: disable-error-code="misc"
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
9
|
+
|
|
10
|
+
from dotflow.core.serializers.task import SerializerTask
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SerializerWorkflow(BaseModel):
|
|
14
|
+
model_config = ConfigDict(title="workflow")
|
|
15
|
+
|
|
16
|
+
workflow_id: UUID = Field(default=None)
|
|
17
|
+
workflow_name: str | None = Field(default=None)
|
|
18
|
+
tasks: list[SerializerTask] = Field(default=[])
|
|
19
|
+
|
|
20
|
+
@computed_field
|
|
21
|
+
@property
|
|
22
|
+
def id(self) -> UUID | None:
|
|
23
|
+
return self.workflow_id
|
|
24
|
+
|
|
25
|
+
@computed_field
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str | None:
|
|
28
|
+
return self.workflow_name
|