dotflow 0.16.0.dev4__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.
Files changed (122) hide show
  1. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/PKG-INFO +2 -1
  2. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/__init__.py +1 -1
  3. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/flow.py +2 -0
  4. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/storage.py +8 -0
  5. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/__init__.py +4 -0
  6. dotflow-1.0.0/dotflow/cli/commands/login.py +115 -0
  7. dotflow-1.0.0/dotflow/cli/commands/logout.py +15 -0
  8. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/start.py +4 -4
  9. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/setup.py +22 -0
  10. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/services/s3.py +23 -0
  11. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/services/gcs.py +14 -0
  12. dotflow-1.0.0/dotflow/core/config_file.py +95 -0
  13. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/dotflow.py +37 -9
  14. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/engine.py +2 -0
  15. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/exception.py +18 -0
  16. dotflow-1.0.0/dotflow/core/fingerprint.py +45 -0
  17. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/module.py +2 -1
  18. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/serializers/task.py +7 -0
  19. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/serializers/transport.py +2 -0
  20. dotflow-1.0.0/dotflow/core/serializers/workflow.py +28 -0
  21. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/task.py +11 -4
  22. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/types/__init__.py +3 -0
  23. dotflow-1.0.0/dotflow/core/types/input_change.py +31 -0
  24. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/workflow.py +63 -2
  25. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/server_default.py +14 -24
  26. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/storage_default.py +7 -0
  27. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/storage_file.py +7 -0
  28. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/storage_gcs.py +3 -0
  29. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/storage_s3.py +3 -0
  30. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/utils/__init__.py +2 -1
  31. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/utils/tools.py +9 -0
  32. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/pyproject.toml +5 -3
  33. dotflow-0.16.0.dev4/dotflow/core/serializers/workflow.py +0 -14
  34. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/LICENSE +0 -0
  35. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/README.md +0 -0
  36. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/__init__.py +0 -0
  37. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/file.py +0 -0
  38. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/http.py +0 -0
  39. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/log.py +0 -0
  40. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/metrics.py +0 -0
  41. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/notify.py +0 -0
  42. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/scheduler.py +0 -0
  43. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/server.py +0 -0
  44. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/tcp.py +0 -0
  45. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/abc/tracer.py +0 -0
  46. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/__init__.py +0 -0
  47. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/command.py +0 -0
  48. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/cloud.py +0 -0
  49. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/deploy.py +0 -0
  50. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/init.py +0 -0
  51. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/log.py +0 -0
  52. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/schedule.py +0 -0
  53. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/commands/storage.py +0 -0
  54. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/validators/__init__.py +0 -0
  55. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cli/validators/start.py +0 -0
  56. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/__init__.py +0 -0
  57. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/__init__.py +0 -0
  58. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/constants.py +0 -0
  59. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/deployers/__init__.py +0 -0
  60. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/deployers/fc.py +0 -0
  61. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/deployers/fc_scheduled.py +0 -0
  62. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/services/__init__.py +0 -0
  63. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/alibaba/services/cr.py +0 -0
  64. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/__init__.py +0 -0
  65. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/constants.py +0 -0
  66. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/__init__.py +0 -0
  67. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/base_lambda.py +0 -0
  68. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/ecs.py +0 -0
  69. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/ecs_scheduled.py +0 -0
  70. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_.py +0 -0
  71. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_api.py +0 -0
  72. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_s3.py +0 -0
  73. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/deployers/lambda_sqs.py +0 -0
  74. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/schedule.py +0 -0
  75. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/services/__init__.py +0 -0
  76. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/services/cloudwatch.py +0 -0
  77. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/services/ecr.py +0 -0
  78. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/aws/services/iam.py +0 -0
  79. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/core.py +0 -0
  80. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/__init__.py +0 -0
  81. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/constants.py +0 -0
  82. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/deployers/__init__.py +0 -0
  83. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/deployers/cloudrun.py +0 -0
  84. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/services/__init__.py +0 -0
  85. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/services/apis.py +0 -0
  86. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/gcp/services/artifact_registry.py +0 -0
  87. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/github/__init__.py +0 -0
  88. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/github/constants.py +0 -0
  89. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/github/deployers/__init__.py +0 -0
  90. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/cloud/github/deployers/actions.py +0 -0
  91. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/__init__.py +0 -0
  92. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/action.py +0 -0
  93. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/config.py +0 -0
  94. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/context.py +0 -0
  95. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/decorators/__init__.py +0 -0
  96. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/decorators/time.py +0 -0
  97. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/serializers/__init__.py +0 -0
  98. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/types/execution.py +0 -0
  99. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/types/overlap.py +0 -0
  100. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/types/status.py +0 -0
  101. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/types/storage.py +0 -0
  102. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/core/types/workflow.py +0 -0
  103. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/logging.py +0 -0
  104. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/main.py +0 -0
  105. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/__init__.py +0 -0
  106. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/log_default.py +0 -0
  107. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/log_opentelemetry.py +0 -0
  108. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/log_sentry.py +0 -0
  109. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/metrics_default.py +0 -0
  110. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/metrics_opentelemetry.py +0 -0
  111. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/notify_default.py +0 -0
  112. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/notify_discord.py +0 -0
  113. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/notify_telegram.py +0 -0
  114. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/scheduler_cron.py +0 -0
  115. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/scheduler_default.py +0 -0
  116. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/tracer_default.py +0 -0
  117. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/tracer_opentelemetry.py +0 -0
  118. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/providers/tracer_sentry.py +0 -0
  119. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/settings.py +0 -0
  120. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/types.py +0 -0
  121. {dotflow-0.16.0.dev4 → dotflow-1.0.0}/dotflow/utils/basic_functions.py +0 -0
  122. {dotflow-0.16.0.dev4 → 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.16.0.dev4
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"
@@ -1,6 +1,6 @@
1
1
  """Dotflow __init__ module."""
2
2
 
3
- __version__ = "0.16.0.dev4"
3
+ __version__ = "1.0.0"
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
 
@@ -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.")
@@ -35,7 +35,7 @@ class StartCommand(Command):
35
35
  initial_context=self.params.initial_context,
36
36
  )
37
37
 
38
- workflow.start(mode=self.params.mode)
38
+ workflow.start(mode=self.params.mode, resume=self.params.resume)
39
39
 
40
40
  def _start_from_factory(self):
41
41
  step_only_flags = {
@@ -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)
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"
@@ -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
- if not self._externally_provided_id:
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.start = partial(
64
- Manager,
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:
@@ -1,5 +1,7 @@
1
1
  """TaskEngine module"""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import re
4
6
  from collections.abc import Callable
5
7
  from concurrent.futures import (
@@ -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
- module_path, _, attr_name = value.rpartition(".")
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]:
@@ -1,5 +1,7 @@
1
1
  """Transport serializer module"""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from pydantic import BaseModel, Field, model_validator
4
6
 
5
7
  from dotflow.core.serializers.task import SerializerTask
@@ -0,0 +1,28 @@
1
+ """Workflow serializer module"""
2
+
3
+ from __future__ import annotations
4
+
5
+ # mypy: disable-error-code="misc"
6
+ from uuid import UUID
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
9
+
10
+ from dotflow.core.serializers.task import SerializerTask
11
+
12
+
13
+ class SerializerWorkflow(BaseModel):
14
+ model_config = ConfigDict(title="workflow")
15
+
16
+ workflow_id: UUID = Field(default=None)
17
+ workflow_name: str | None = Field(default=None)
18
+ tasks: list[SerializerTask] = Field(default=[])
19
+
20
+ @computed_field
21
+ @property
22
+ def id(self) -> UUID | None:
23
+ return self.workflow_id
24
+
25
+ @computed_field
26
+ @property
27
+ def name(self) -> str | None:
28
+ return self.workflow_name