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.
Files changed (129) hide show
  1. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/PKG-INFO +2 -3
  2. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/__init__.py +1 -1
  3. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/flow.py +2 -0
  4. dotflow-1.0.1/dotflow/abc/storage.py +77 -0
  5. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/__init__.py +4 -0
  6. dotflow-1.0.1/dotflow/cli/commands/login.py +115 -0
  7. dotflow-1.0.1/dotflow/cli/commands/logout.py +15 -0
  8. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/start.py +3 -3
  9. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/setup.py +16 -0
  10. dotflow-1.0.1/dotflow/cloud/aws/services/s3.py +148 -0
  11. dotflow-1.0.1/dotflow/cloud/gcp/services/gcs.py +110 -0
  12. dotflow-1.0.1/dotflow/core/config_file.py +95 -0
  13. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/dotflow.py +35 -9
  14. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/engine.py +2 -0
  15. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/exception.py +18 -0
  16. dotflow-1.0.1/dotflow/core/fingerprint.py +45 -0
  17. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/serializers/task.py +7 -0
  18. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/serializers/transport.py +2 -0
  19. dotflow-1.0.1/dotflow/core/serializers/workflow.py +28 -0
  20. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/task.py +11 -4
  21. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/__init__.py +3 -0
  22. dotflow-1.0.1/dotflow/core/types/input_change.py +31 -0
  23. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/workflow.py +64 -3
  24. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/server_default.py +14 -24
  25. dotflow-1.0.1/dotflow/providers/storage_default.py +118 -0
  26. dotflow-1.0.1/dotflow/providers/storage_file.py +206 -0
  27. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/storage_gcs.py +72 -3
  28. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/storage_s3.py +68 -3
  29. dotflow-1.0.1/dotflow/testing/__init__.py +5 -0
  30. dotflow-1.0.1/dotflow/testing/storage_contract.py +135 -0
  31. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/utils/__init__.py +2 -1
  32. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/utils/tools.py +9 -0
  33. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/pyproject.toml +4 -6
  34. dotflow-1.0.0.dev3/dotflow/abc/storage.py +0 -25
  35. dotflow-1.0.0.dev3/dotflow/cloud/aws/services/s3.py +0 -47
  36. dotflow-1.0.0.dev3/dotflow/cloud/gcp/services/gcs.py +0 -45
  37. dotflow-1.0.0.dev3/dotflow/core/serializers/workflow.py +0 -14
  38. dotflow-1.0.0.dev3/dotflow/providers/storage_default.py +0 -22
  39. dotflow-1.0.0.dev3/dotflow/providers/storage_file.py +0 -72
  40. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/LICENSE +0 -0
  41. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/README.md +0 -0
  42. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/__init__.py +0 -0
  43. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/file.py +0 -0
  44. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/http.py +0 -0
  45. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/log.py +0 -0
  46. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/metrics.py +0 -0
  47. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/notify.py +0 -0
  48. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/scheduler.py +0 -0
  49. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/server.py +0 -0
  50. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/tcp.py +0 -0
  51. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/abc/tracer.py +0 -0
  52. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/__init__.py +0 -0
  53. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/command.py +0 -0
  54. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/cloud.py +0 -0
  55. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/deploy.py +0 -0
  56. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/init.py +0 -0
  57. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/log.py +0 -0
  58. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/schedule.py +0 -0
  59. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/commands/storage.py +0 -0
  60. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/validators/__init__.py +0 -0
  61. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cli/validators/start.py +0 -0
  62. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/__init__.py +0 -0
  63. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/__init__.py +0 -0
  64. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/constants.py +0 -0
  65. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/__init__.py +0 -0
  66. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/fc.py +0 -0
  67. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/deployers/fc_scheduled.py +0 -0
  68. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/services/__init__.py +0 -0
  69. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/alibaba/services/cr.py +0 -0
  70. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/__init__.py +0 -0
  71. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/constants.py +0 -0
  72. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/__init__.py +0 -0
  73. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/base_lambda.py +0 -0
  74. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/ecs.py +0 -0
  75. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/ecs_scheduled.py +0 -0
  76. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_.py +0 -0
  77. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_api.py +0 -0
  78. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_s3.py +0 -0
  79. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/deployers/lambda_sqs.py +0 -0
  80. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/schedule.py +0 -0
  81. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/__init__.py +0 -0
  82. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/cloudwatch.py +0 -0
  83. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/ecr.py +0 -0
  84. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/aws/services/iam.py +0 -0
  85. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/core.py +0 -0
  86. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/__init__.py +0 -0
  87. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/constants.py +0 -0
  88. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/deployers/__init__.py +0 -0
  89. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/deployers/cloudrun.py +0 -0
  90. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/services/__init__.py +0 -0
  91. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/services/apis.py +0 -0
  92. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/gcp/services/artifact_registry.py +0 -0
  93. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/__init__.py +0 -0
  94. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/constants.py +0 -0
  95. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/deployers/__init__.py +0 -0
  96. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/cloud/github/deployers/actions.py +0 -0
  97. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/__init__.py +0 -0
  98. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/action.py +0 -0
  99. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/config.py +0 -0
  100. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/context.py +0 -0
  101. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/decorators/__init__.py +0 -0
  102. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/decorators/time.py +0 -0
  103. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/module.py +0 -0
  104. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/serializers/__init__.py +0 -0
  105. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/execution.py +0 -0
  106. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/overlap.py +0 -0
  107. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/status.py +0 -0
  108. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/storage.py +0 -0
  109. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/core/types/workflow.py +0 -0
  110. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/logging.py +0 -0
  111. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/main.py +0 -0
  112. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/__init__.py +0 -0
  113. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/log_default.py +0 -0
  114. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/log_opentelemetry.py +0 -0
  115. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/log_sentry.py +0 -0
  116. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/metrics_default.py +0 -0
  117. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/metrics_opentelemetry.py +0 -0
  118. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/notify_default.py +0 -0
  119. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/notify_discord.py +0 -0
  120. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/notify_telegram.py +0 -0
  121. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/scheduler_cron.py +0 -0
  122. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/scheduler_default.py +0 -0
  123. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/tracer_default.py +0 -0
  124. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/tracer_opentelemetry.py +0 -0
  125. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/providers/tracer_sentry.py +0 -0
  126. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/settings.py +0 -0
  127. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/types.py +0 -0
  128. {dotflow-1.0.0.dev3 → dotflow-1.0.1}/dotflow/utils/basic_functions.py +0 -0
  129. {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.0.dev3
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.9
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
@@ -1,6 +1,6 @@
1
1
  """Dotflow __init__ module."""
2
2
 
3
- __version__ = "1.0.0.dev3"
3
+ __version__ = "1.0.1"
4
4
  __description__ = "🎲 Dotflow turns an idea into flow!"
5
5
 
6
6
  from .core.action import Action as action
@@ -1,5 +1,7 @@
1
1
  """Flow ABC"""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from abc import ABC, abstractmethod
4
6
  from uuid import UUID
5
7
 
@@ -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
- result = factory()
54
+ workflow = factory()
55
55
 
56
- if not isinstance(result, DotFlow):
56
+ if not isinstance(workflow, DotFlow):
57
57
  raise InvalidWorkflowFactory(factory=self.params.workflow)
58
58
 
59
- result.start(mode=self.params.mode, resume=self.params.resume)
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