dotflow 0.16.0.dev3__tar.gz → 1.0.0__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.16.0.dev3 → dotflow-1.0.0}/PKG-INFO +2 -1
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/__init__.py +1 -1
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/flow.py +2 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/storage.py +8 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/__init__.py +4 -0
- dotflow-1.0.0/dotflow/cli/commands/login.py +115 -0
- dotflow-1.0.0/dotflow/cli/commands/logout.py +15 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/start.py +7 -32
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/setup.py +22 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/services/s3.py +23 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/services/gcs.py +14 -0
- dotflow-1.0.0/dotflow/core/config_file.py +95 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/dotflow.py +37 -9
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/engine.py +2 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/exception.py +18 -0
- dotflow-1.0.0/dotflow/core/fingerprint.py +45 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/module.py +2 -1
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/serializers/task.py +7 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/serializers/transport.py +2 -0
- dotflow-1.0.0/dotflow/core/serializers/workflow.py +28 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/task.py +11 -4
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/types/__init__.py +3 -0
- dotflow-1.0.0/dotflow/core/types/input_change.py +31 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/workflow.py +63 -2
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/__init__.py +0 -2
- dotflow-1.0.0/dotflow/providers/server_default.py +106 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/storage_default.py +7 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/storage_file.py +7 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/storage_gcs.py +3 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/storage_s3.py +3 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/utils/__init__.py +2 -1
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/utils/tools.py +9 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/pyproject.toml +5 -3
- dotflow-0.16.0.dev3/dotflow/core/serializers/workflow.py +0 -14
- dotflow-0.16.0.dev3/dotflow/providers/server_api.py +0 -89
- dotflow-0.16.0.dev3/dotflow/providers/server_default.py +0 -23
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/LICENSE +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/README.md +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/file.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/http.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/log.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/metrics.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/notify.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/scheduler.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/server.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/tcp.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/abc/tracer.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/command.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/cloud.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/deploy.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/init.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/log.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/schedule.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/commands/storage.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/validators/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cli/validators/start.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/constants.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/deployers/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/deployers/fc.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/deployers/fc_scheduled.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/services/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/alibaba/services/cr.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/constants.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/base_lambda.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/ecs.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/ecs_scheduled.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_api.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_s3.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_sqs.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/schedule.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/services/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/services/cloudwatch.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/services/ecr.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/aws/services/iam.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/core.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/constants.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/deployers/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/deployers/cloudrun.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/services/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/services/apis.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/gcp/services/artifact_registry.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/github/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/github/constants.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/github/deployers/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/cloud/github/deployers/actions.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/action.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/config.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/context.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/decorators/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/decorators/time.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/serializers/__init__.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/types/execution.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/types/overlap.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/types/status.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/types/storage.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/core/types/workflow.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/logging.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/main.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/log_default.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/log_opentelemetry.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/log_sentry.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/metrics_default.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/metrics_opentelemetry.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/notify_default.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/notify_discord.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/notify_telegram.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/scheduler_cron.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/scheduler_default.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/tracer_default.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/tracer_opentelemetry.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/providers/tracer_sentry.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/settings.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/types.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/utils/basic_functions.py +0 -0
- {dotflow-0.16.0.dev3 → dotflow-1.0.0}/dotflow/utils/error_handler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
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
|
|
@@ -227,6 +227,7 @@ Requires-Dist: boto3 ; extra == "aws"
|
|
|
227
227
|
Requires-Dist: boto3 ; extra == "deploy-aws"
|
|
228
228
|
Requires-Dist: cookiecutter (>=2.0)
|
|
229
229
|
Requires-Dist: croniter ; extra == "scheduler"
|
|
230
|
+
Requires-Dist: eval-type-backport ; python_version < "3.10"
|
|
230
231
|
Requires-Dist: google-auth ; extra == "deploy-gcp"
|
|
231
232
|
Requires-Dist: google-cloud-artifact-registry ; extra == "deploy-gcp"
|
|
232
233
|
Requires-Dist: google-cloud-build ; extra == "deploy-gcp"
|
|
@@ -23,3 +23,11 @@ class Storage(ABC):
|
|
|
23
23
|
@abstractmethod
|
|
24
24
|
def key(self, task: Callable):
|
|
25
25
|
"""Function that returns a key to get and post storage"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def clear(self, workflow_id: str) -> None:
|
|
29
|
+
"""Remove every persisted entry under ``workflow_id``.
|
|
30
|
+
|
|
31
|
+
Used by the input-fingerprint reset path when
|
|
32
|
+
``on_input_change='reset'``.
|
|
33
|
+
"""
|
|
@@ -4,6 +4,8 @@ from dotflow.cli.commands.cloud import CloudGenerateCommand, CloudListCommand
|
|
|
4
4
|
from dotflow.cli.commands.deploy import DeployCommand
|
|
5
5
|
from dotflow.cli.commands.init import InitCommand
|
|
6
6
|
from dotflow.cli.commands.log import LogCommand
|
|
7
|
+
from dotflow.cli.commands.login import LoginCommand
|
|
8
|
+
from dotflow.cli.commands.logout import LogoutCommand
|
|
7
9
|
from dotflow.cli.commands.schedule import ScheduleCommand
|
|
8
10
|
from dotflow.cli.commands.start import StartCommand
|
|
9
11
|
|
|
@@ -13,6 +15,8 @@ __all__ = [
|
|
|
13
15
|
"DeployCommand",
|
|
14
16
|
"InitCommand",
|
|
15
17
|
"LogCommand",
|
|
18
|
+
"LoginCommand",
|
|
19
|
+
"LogoutCommand",
|
|
16
20
|
"ScheduleCommand",
|
|
17
21
|
"StartCommand",
|
|
18
22
|
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command login module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
from requests import RequestException, post
|
|
10
|
+
from rich import print # type: ignore
|
|
11
|
+
|
|
12
|
+
from dotflow.cli.command import Command
|
|
13
|
+
from dotflow.core.config_file import save_cloud_config
|
|
14
|
+
from dotflow.settings import Settings as settings
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://www.host.dotflow.io/api/v1"
|
|
17
|
+
DEVICE_ENDPOINT = "/cli/device"
|
|
18
|
+
TOKEN_ENDPOINT = "/cli/token"
|
|
19
|
+
TIMEOUT = 15
|
|
20
|
+
DEFAULT_INTERVAL = 5
|
|
21
|
+
MAX_POLL_INTERVAL = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoginCommand(Command):
|
|
25
|
+
def setup(self):
|
|
26
|
+
base_url = self._resolve_base_url()
|
|
27
|
+
|
|
28
|
+
handshake = self._start_device(base_url)
|
|
29
|
+
if handshake is None:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
print(settings.INFO_ALERT, f"Opening {handshake['verification_uri']}")
|
|
33
|
+
print(settings.INFO_ALERT, f"Code: {handshake['user_code']}")
|
|
34
|
+
|
|
35
|
+
webbrowser.open(handshake["verification_uri"])
|
|
36
|
+
|
|
37
|
+
token = self._poll_token(base_url, handshake)
|
|
38
|
+
|
|
39
|
+
if not token:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
save_cloud_config(token=token, base_url=base_url)
|
|
43
|
+
print(settings.INFO_ALERT, "Authenticated.")
|
|
44
|
+
|
|
45
|
+
def _resolve_base_url(self) -> str:
|
|
46
|
+
"""``SERVER_BASE_URL`` env var wins over the default."""
|
|
47
|
+
explicit = os.environ.get("SERVER_BASE_URL")
|
|
48
|
+
return explicit.rstrip("/") if explicit else DEFAULT_BASE_URL
|
|
49
|
+
|
|
50
|
+
def _start_device(self, base_url: str) -> dict | None:
|
|
51
|
+
try:
|
|
52
|
+
response = post(
|
|
53
|
+
f"{base_url}{DEVICE_ENDPOINT}",
|
|
54
|
+
timeout=TIMEOUT,
|
|
55
|
+
)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
return response.json()
|
|
58
|
+
except RequestException as error:
|
|
59
|
+
print(
|
|
60
|
+
settings.ERROR_ALERT,
|
|
61
|
+
f"Could not reach Dotflow Cloud: {error}",
|
|
62
|
+
)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def _poll_token(self, base_url: str, handshake: dict) -> str | None:
|
|
66
|
+
interval = handshake.get("interval") or DEFAULT_INTERVAL
|
|
67
|
+
deadline = time.monotonic() + (handshake.get("expires_in") or 300)
|
|
68
|
+
|
|
69
|
+
while time.monotonic() < deadline:
|
|
70
|
+
try:
|
|
71
|
+
response = post(
|
|
72
|
+
f"{base_url}{TOKEN_ENDPOINT}",
|
|
73
|
+
json={"device_code": handshake["device_code"]},
|
|
74
|
+
timeout=TIMEOUT,
|
|
75
|
+
)
|
|
76
|
+
except RequestException as error:
|
|
77
|
+
print(settings.ERROR_ALERT, f"Network error: {error}")
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
if response.status_code == 200:
|
|
81
|
+
return response.json().get("api_token")
|
|
82
|
+
|
|
83
|
+
detail = self._detail(response)
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
response.status_code == 400
|
|
87
|
+
and detail == "authorization_pending"
|
|
88
|
+
):
|
|
89
|
+
time.sleep(interval)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if response.status_code == 400 and detail in (
|
|
93
|
+
"slow_down",
|
|
94
|
+
"slow down",
|
|
95
|
+
):
|
|
96
|
+
interval = min(interval + 5, MAX_POLL_INTERVAL)
|
|
97
|
+
time.sleep(interval)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if response.status_code == 410:
|
|
101
|
+
print(settings.ERROR_ALERT, "Authorization expired.")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
print(settings.ERROR_ALERT, f"{response.status_code}: {detail}")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
print(settings.ERROR_ALERT, "Timed out waiting for authorization.")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _detail(response) -> str:
|
|
112
|
+
try:
|
|
113
|
+
return str(response.json().get("detail", response.text))
|
|
114
|
+
except ValueError:
|
|
115
|
+
return response.text
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Command logout module."""
|
|
2
|
+
|
|
3
|
+
from rich import print # type: ignore
|
|
4
|
+
|
|
5
|
+
from dotflow.cli.command import Command
|
|
6
|
+
from dotflow.core.config_file import clear_cloud_config
|
|
7
|
+
from dotflow.settings import Settings as settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LogoutCommand(Command):
|
|
11
|
+
def setup(self):
|
|
12
|
+
if clear_cloud_config():
|
|
13
|
+
print(settings.INFO_ALERT, "Signed out.")
|
|
14
|
+
else:
|
|
15
|
+
print(settings.WARNING_ALERT, "No saved credentials.")
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Command start module"""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
from os import system
|
|
5
4
|
|
|
6
5
|
from dotflow import Config, DotFlow
|
|
@@ -9,8 +8,6 @@ from dotflow.core.exception import InvalidWorkflowFactory, WorkflowFlagConflict
|
|
|
9
8
|
from dotflow.core.module import Module
|
|
10
9
|
from dotflow.core.types.execution import TypeExecution
|
|
11
10
|
from dotflow.providers import (
|
|
12
|
-
ServerAPI,
|
|
13
|
-
ServerDefault,
|
|
14
11
|
StorageDefault,
|
|
15
12
|
StorageFile,
|
|
16
13
|
StorageGCS,
|
|
@@ -38,7 +35,7 @@ class StartCommand(Command):
|
|
|
38
35
|
initial_context=self.params.initial_context,
|
|
39
36
|
)
|
|
40
37
|
|
|
41
|
-
workflow.start(mode=self.params.mode)
|
|
38
|
+
workflow.start(mode=self.params.mode, resume=self.params.resume)
|
|
42
39
|
|
|
43
40
|
def _start_from_factory(self):
|
|
44
41
|
step_only_flags = {
|
|
@@ -54,40 +51,18 @@ class StartCommand(Command):
|
|
|
54
51
|
if not callable(factory):
|
|
55
52
|
raise InvalidWorkflowFactory(factory=self.params.workflow)
|
|
56
53
|
|
|
57
|
-
|
|
54
|
+
workflow = factory()
|
|
58
55
|
|
|
59
|
-
if not isinstance(
|
|
56
|
+
if not isinstance(workflow, DotFlow):
|
|
60
57
|
raise InvalidWorkflowFactory(factory=self.params.workflow)
|
|
61
58
|
|
|
62
|
-
|
|
59
|
+
workflow.start(mode=self.params.mode, resume=self.params.resume)
|
|
63
60
|
|
|
64
61
|
def _new_workflow(self):
|
|
65
|
-
config = self._build_config()
|
|
66
|
-
workflow_id = os.getenv("WORKFLOW_ID")
|
|
67
|
-
|
|
68
|
-
if config is None:
|
|
69
|
-
return DotFlow(workflow_id=workflow_id)
|
|
70
|
-
return DotFlow(config=config, workflow_id=workflow_id)
|
|
71
|
-
|
|
72
|
-
def _build_config(self):
|
|
73
62
|
storage = self._build_storage()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
return Config(
|
|
80
|
-
storage=storage or StorageDefault(),
|
|
81
|
-
server=server or ServerDefault(),
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
def _build_server(self):
|
|
85
|
-
base_url = os.getenv("SERVER_BASE_URL")
|
|
86
|
-
user_token = os.getenv("SERVER_USER_TOKEN")
|
|
87
|
-
|
|
88
|
-
if not base_url or not user_token:
|
|
89
|
-
return None
|
|
90
|
-
return ServerAPI(base_url=base_url, user_token=user_token)
|
|
63
|
+
if storage is None:
|
|
64
|
+
return DotFlow()
|
|
65
|
+
return DotFlow(config=Config(storage=storage))
|
|
91
66
|
|
|
92
67
|
def _build_storage(self):
|
|
93
68
|
if not self.params.storage:
|
|
@@ -9,6 +9,8 @@ from dotflow.cli.commands import (
|
|
|
9
9
|
DeployCommand,
|
|
10
10
|
InitCommand,
|
|
11
11
|
LogCommand,
|
|
12
|
+
LoginCommand,
|
|
13
|
+
LogoutCommand,
|
|
12
14
|
ScheduleCommand,
|
|
13
15
|
StartCommand,
|
|
14
16
|
)
|
|
@@ -42,12 +44,26 @@ class Command:
|
|
|
42
44
|
|
|
43
45
|
self.setup_init()
|
|
44
46
|
self.setup_logs()
|
|
47
|
+
self.setup_login()
|
|
48
|
+
self.setup_logout()
|
|
45
49
|
self.setup_start()
|
|
46
50
|
self.setup_schedule()
|
|
47
51
|
self.setup_cloud()
|
|
48
52
|
self.setup_deploy()
|
|
49
53
|
self.command()
|
|
50
54
|
|
|
55
|
+
def setup_login(self):
|
|
56
|
+
cmd = self.subparsers.add_parser(
|
|
57
|
+
"login", help="Authenticate the CLI via browser"
|
|
58
|
+
)
|
|
59
|
+
cmd.set_defaults(exec=LoginCommand)
|
|
60
|
+
|
|
61
|
+
def setup_logout(self):
|
|
62
|
+
cmd = self.subparsers.add_parser(
|
|
63
|
+
"logout", help="Remove saved CLI credentials"
|
|
64
|
+
)
|
|
65
|
+
cmd.set_defaults(exec=LogoutCommand)
|
|
66
|
+
|
|
51
67
|
def setup_init(self):
|
|
52
68
|
self.cmd_init = self.subparsers.add_parser(
|
|
53
69
|
"init", help="Scaffold a new dotflow project"
|
|
@@ -93,6 +109,12 @@ class Command:
|
|
|
93
109
|
TypeExecution.PARALLEL,
|
|
94
110
|
],
|
|
95
111
|
)
|
|
112
|
+
self.cmd_start.add_argument(
|
|
113
|
+
"--resume",
|
|
114
|
+
action="store_true",
|
|
115
|
+
default=False,
|
|
116
|
+
help="Enable checkpoint-based resume",
|
|
117
|
+
)
|
|
96
118
|
|
|
97
119
|
cmd_start_parser.set_defaults(exec=StartCommand)
|
|
98
120
|
|
|
@@ -45,3 +45,26 @@ class S3(ObjectStorage):
|
|
|
45
45
|
Body=dumps(data),
|
|
46
46
|
ContentType="application/json",
|
|
47
47
|
)
|
|
48
|
+
|
|
49
|
+
def delete_prefix(self, sub_prefix: str) -> None:
|
|
50
|
+
"""Delete every object whose key starts with prefix + sub_prefix.
|
|
51
|
+
|
|
52
|
+
Empty ``sub_prefix`` is rejected to avoid accidentally wiping
|
|
53
|
+
the entire bucket prefix.
|
|
54
|
+
"""
|
|
55
|
+
if not sub_prefix:
|
|
56
|
+
raise ValueError("delete_prefix requires a non-empty sub_prefix")
|
|
57
|
+
|
|
58
|
+
full_prefix = f"{self.prefix}{sub_prefix}"
|
|
59
|
+
paginator = self._s3.get_paginator("list_objects_v2")
|
|
60
|
+
|
|
61
|
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
|
|
62
|
+
objects = [
|
|
63
|
+
{"Key": item["Key"]} for item in page.get("Contents", [])
|
|
64
|
+
]
|
|
65
|
+
if not objects:
|
|
66
|
+
continue
|
|
67
|
+
self._s3.delete_objects(
|
|
68
|
+
Bucket=self.bucket,
|
|
69
|
+
Delete={"Objects": objects},
|
|
70
|
+
)
|
|
@@ -43,3 +43,17 @@ class GCS(ObjectStorage):
|
|
|
43
43
|
dumps(data),
|
|
44
44
|
content_type="application/json",
|
|
45
45
|
)
|
|
46
|
+
|
|
47
|
+
def delete_prefix(self, sub_prefix: str) -> None:
|
|
48
|
+
"""Delete every blob whose name starts with prefix + sub_prefix.
|
|
49
|
+
|
|
50
|
+
Empty ``sub_prefix`` is rejected to avoid accidentally wiping
|
|
51
|
+
the entire bucket prefix.
|
|
52
|
+
"""
|
|
53
|
+
if not sub_prefix:
|
|
54
|
+
raise ValueError("delete_prefix requires a non-empty sub_prefix")
|
|
55
|
+
|
|
56
|
+
full_prefix = f"{self.prefix}{sub_prefix}"
|
|
57
|
+
|
|
58
|
+
for blob in self._client.list_blobs(self._bucket, prefix=full_prefix):
|
|
59
|
+
blob.delete()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Persistent CLI config stored at ``~/.dotflow/config.json``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR_NAME = ".dotflow"
|
|
11
|
+
CONFIG_FILE_NAME = "config.json"
|
|
12
|
+
CLOUD_SECTION = "cloud"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_path() -> Path:
|
|
16
|
+
"""Return the absolute path of the CLI config file."""
|
|
17
|
+
return Path.home() / CONFIG_DIR_NAME / CONFIG_FILE_NAME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_cloud_config() -> dict[str, str]:
|
|
21
|
+
"""Read the ``cloud`` section from the config file. ``{}`` when absent."""
|
|
22
|
+
path = config_path()
|
|
23
|
+
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
raw = path.read_text(encoding="utf-8")
|
|
29
|
+
except OSError:
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(raw)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
section = data.get(CLOUD_SECTION) if isinstance(data, dict) else None
|
|
38
|
+
if not isinstance(section, dict):
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
key: str(value)
|
|
43
|
+
for key, value in section.items()
|
|
44
|
+
if isinstance(value, str)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_cloud_config(token: str, base_url: str) -> Path:
|
|
49
|
+
"""Persist ``cloud`` with the given token/base_url. Returns the file path."""
|
|
50
|
+
path = config_path()
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
payload = {
|
|
54
|
+
CLOUD_SECTION: {
|
|
55
|
+
"base_url": base_url,
|
|
56
|
+
"token": token,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
content = json.dumps(payload, indent=2) + "\n"
|
|
60
|
+
|
|
61
|
+
fd = os.open(
|
|
62
|
+
str(path),
|
|
63
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
64
|
+
0o600,
|
|
65
|
+
)
|
|
66
|
+
with contextlib.suppress(OSError):
|
|
67
|
+
os.fchmod(fd, 0o600)
|
|
68
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
69
|
+
fh.write(content)
|
|
70
|
+
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_cloud_config() -> bool:
|
|
75
|
+
"""Remove the config file. Returns True when something was deleted."""
|
|
76
|
+
path = config_path()
|
|
77
|
+
|
|
78
|
+
if not path.exists():
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
path.unlink()
|
|
83
|
+
return True
|
|
84
|
+
except OSError:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve(key: str, env_var: str) -> str | None:
|
|
89
|
+
"""Resolve a setting with env > file precedence. ``None`` when absent."""
|
|
90
|
+
env_value = os.environ.get(env_var)
|
|
91
|
+
|
|
92
|
+
if env_value:
|
|
93
|
+
return env_value
|
|
94
|
+
|
|
95
|
+
return load_cloud_config().get(key) or None
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
from functools import partial
|
|
6
7
|
from uuid import uuid4
|
|
7
8
|
|
|
8
9
|
from dotflow.core.config import Config
|
|
9
10
|
from dotflow.core.task import TaskBuilder
|
|
10
11
|
from dotflow.core.workflow import Manager
|
|
12
|
+
from dotflow.logging import logger
|
|
13
|
+
from dotflow.utils import hostname
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
class DotFlow:
|
|
@@ -33,9 +36,16 @@ class DotFlow:
|
|
|
33
36
|
workflow_id (Optional[str]): Fixed workflow identifier for checkpoint
|
|
34
37
|
resume. If not provided, a random UUID is generated.
|
|
35
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
|
+
|
|
36
44
|
Attributes:
|
|
37
45
|
workflow_id (UUID):
|
|
38
46
|
|
|
47
|
+
name (str):
|
|
48
|
+
|
|
39
49
|
task (List[Task]):
|
|
40
50
|
|
|
41
51
|
start (Manager):
|
|
@@ -47,30 +57,48 @@ class DotFlow:
|
|
|
47
57
|
self,
|
|
48
58
|
config: Config | None = None,
|
|
49
59
|
workflow_id: str | None = None,
|
|
60
|
+
name: str | None = None,
|
|
50
61
|
) -> None:
|
|
62
|
+
workflow_id = workflow_id or os.getenv("WORKFLOW_ID")
|
|
51
63
|
self._externally_provided_id = workflow_id is not None
|
|
52
64
|
self.workflow_id = workflow_id or uuid4()
|
|
65
|
+
self.name = name or hostname()
|
|
53
66
|
self._config = config if config else Config()
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
self._config.server.create_workflow(workflow=self.workflow_id)
|
|
67
|
+
self._manager: Manager | None = None
|
|
68
|
+
self._last_run_signature: tuple = ()
|
|
57
69
|
|
|
58
70
|
self.task = TaskBuilder(
|
|
59
71
|
config=self._config,
|
|
60
72
|
workflow_id=self.workflow_id,
|
|
73
|
+
workflow_name=self.name,
|
|
61
74
|
)
|
|
62
75
|
|
|
63
|
-
self.
|
|
64
|
-
|
|
65
|
-
tasks=self.task.queue,
|
|
66
|
-
workflow_id=self.workflow_id,
|
|
67
|
-
config=self._config,
|
|
68
|
-
)
|
|
76
|
+
if not self._externally_provided_id:
|
|
77
|
+
self._config.server.create_workflow(workflow=self)
|
|
69
78
|
|
|
70
79
|
self.schedule = partial(
|
|
71
80
|
self._config.scheduler.start, workflow=self.start
|
|
72
81
|
)
|
|
73
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
|
+
|
|
74
102
|
def result_task(self):
|
|
75
103
|
"""
|
|
76
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))
|
|
@@ -14,7 +14,8 @@ class Module:
|
|
|
14
14
|
|
|
15
15
|
@classmethod
|
|
16
16
|
def import_module(cls, value: str):
|
|
17
|
-
|
|
17
|
+
separator = ":" if ":" in value else "."
|
|
18
|
+
module_path, _, attr_name = value.rpartition(separator)
|
|
18
19
|
|
|
19
20
|
if not module_path:
|
|
20
21
|
raise ImportModuleError(module=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]:
|