dotflow 1.0.0.dev3__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.dev3 → dotflow-1.0.1}/PKG-INFO +2 -3
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/__init__.py +1 -1
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/flow.py +2 -0
- dotflow-1.0.1/dotflow/abc/storage.py +77 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/__init__.py +4 -0
- dotflow-1.0.1/dotflow/cli/commands/login.py +115 -0
- dotflow-1.0.1/dotflow/cli/commands/logout.py +15 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/start.py +3 -3
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/setup.py +16 -0
- 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.1/dotflow/core/config_file.py +95 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/dotflow.py +35 -9
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/engine.py +2 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/exception.py +18 -0
- dotflow-1.0.1/dotflow/core/fingerprint.py +45 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/serializers/task.py +7 -0
- {dotflow-1.0.0.dev3 → 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.dev3 → dotflow-1.0.1}/dotflow/core/task.py +11 -4
- {dotflow-1.0.0.dev3 → 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.dev3 → dotflow-1.0.1}/dotflow/core/workflow.py +64 -3
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/server_default.py +14 -24
- 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.dev3 → dotflow-1.0.1}/dotflow/providers/storage_gcs.py +72 -3
- {dotflow-1.0.0.dev3 → 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.dev3 → dotflow-1.0.1}/dotflow/utils/__init__.py +2 -1
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/utils/tools.py +9 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/pyproject.toml +4 -6
- dotflow-1.0.0.dev3/dotflow/abc/storage.py +0 -25
- dotflow-1.0.0.dev3/dotflow/cloud/aws/services/s3.py +0 -47
- dotflow-1.0.0.dev3/dotflow/cloud/gcp/services/gcs.py +0 -45
- dotflow-1.0.0.dev3/dotflow/core/serializers/workflow.py +0 -14
- dotflow-1.0.0.dev3/dotflow/providers/storage_default.py +0 -22
- dotflow-1.0.0.dev3/dotflow/providers/storage_file.py +0 -72
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/LICENSE +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/README.md +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/file.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/http.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/log.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/metrics.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/notify.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/scheduler.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/server.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/tcp.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/tracer.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/command.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/cloud.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/deploy.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/init.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/log.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/schedule.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/storage.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/validators/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/validators/start.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/constants.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/fc.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/fc_scheduled.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/services/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/services/cr.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/constants.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/base_lambda.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/ecs.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/ecs_scheduled.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_api.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_s3.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_sqs.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/schedule.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/cloudwatch.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/ecr.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/iam.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/core.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/constants.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/deployers/cloudrun.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/services/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/services/apis.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/services/artifact_registry.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/constants.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/deployers/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/deployers/actions.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/action.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/config.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/context.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/decorators/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/decorators/time.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/module.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/serializers/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/execution.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/overlap.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/status.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/storage.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/workflow.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/logging.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/main.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/__init__.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/log_default.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/log_opentelemetry.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/log_sentry.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/metrics_default.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/metrics_opentelemetry.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/notify_default.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/notify_discord.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/notify_telegram.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/scheduler_cron.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/scheduler_default.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/tracer_default.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/tracer_opentelemetry.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/tracer_sentry.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/settings.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/types.py +0 -0
- {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/utils/basic_functions.py +0 -0
- {dotflow-1.0.0.dev3 → 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
|
|
@@ -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.")
|
|
@@ -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()
|
|
@@ -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"
|
|
@@ -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()
|
|
@@ -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
|