dotflow 1.0.0.dev4__tar.gz → 1.0.2__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.dev4 → dotflow-1.0.2}/PKG-INFO +2 -3
  2. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/__init__.py +1 -1
  3. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/flow.py +2 -0
  4. dotflow-1.0.2/dotflow/abc/storage.py +77 -0
  5. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/cloud.py +1 -0
  6. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/login.py +2 -10
  7. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/start.py +3 -3
  8. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/setup.py +0 -11
  9. dotflow-1.0.2/dotflow/cloud/aws/services/s3.py +148 -0
  10. dotflow-1.0.2/dotflow/cloud/gcp/services/gcs.py +110 -0
  11. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/dotflow.py +35 -9
  12. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/engine.py +2 -0
  13. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/exception.py +18 -0
  14. dotflow-1.0.2/dotflow/core/fingerprint.py +45 -0
  15. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/serializers/task.py +7 -0
  16. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/serializers/transport.py +2 -0
  17. dotflow-1.0.2/dotflow/core/serializers/workflow.py +28 -0
  18. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/task.py +11 -4
  19. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/types/__init__.py +3 -0
  20. dotflow-1.0.2/dotflow/core/types/input_change.py +31 -0
  21. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/workflow.py +64 -3
  22. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/server_default.py +6 -9
  23. dotflow-1.0.2/dotflow/providers/storage_default.py +118 -0
  24. dotflow-1.0.2/dotflow/providers/storage_file.py +206 -0
  25. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/storage_gcs.py +72 -3
  26. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/storage_s3.py +68 -3
  27. dotflow-1.0.2/dotflow/testing/__init__.py +5 -0
  28. dotflow-1.0.2/dotflow/testing/storage_contract.py +135 -0
  29. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/utils/__init__.py +2 -1
  30. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/utils/tools.py +9 -0
  31. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/pyproject.toml +6 -8
  32. dotflow-1.0.0.dev4/dotflow/abc/storage.py +0 -25
  33. dotflow-1.0.0.dev4/dotflow/cloud/aws/services/s3.py +0 -47
  34. dotflow-1.0.0.dev4/dotflow/cloud/gcp/services/gcs.py +0 -45
  35. dotflow-1.0.0.dev4/dotflow/core/serializers/workflow.py +0 -14
  36. dotflow-1.0.0.dev4/dotflow/providers/storage_default.py +0 -22
  37. dotflow-1.0.0.dev4/dotflow/providers/storage_file.py +0 -72
  38. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/LICENSE +0 -0
  39. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/README.md +0 -0
  40. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/__init__.py +0 -0
  41. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/file.py +0 -0
  42. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/http.py +0 -0
  43. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/log.py +0 -0
  44. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/metrics.py +0 -0
  45. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/notify.py +0 -0
  46. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/scheduler.py +0 -0
  47. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/server.py +0 -0
  48. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/tcp.py +0 -0
  49. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/abc/tracer.py +0 -0
  50. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/__init__.py +0 -0
  51. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/command.py +0 -0
  52. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/__init__.py +0 -0
  53. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/deploy.py +0 -0
  54. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/init.py +0 -0
  55. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/log.py +0 -0
  56. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/logout.py +0 -0
  57. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/schedule.py +0 -0
  58. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/commands/storage.py +0 -0
  59. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/validators/__init__.py +0 -0
  60. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cli/validators/start.py +0 -0
  61. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/__init__.py +0 -0
  62. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/__init__.py +0 -0
  63. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/constants.py +0 -0
  64. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/deployers/__init__.py +0 -0
  65. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/deployers/fc.py +0 -0
  66. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/deployers/fc_scheduled.py +0 -0
  67. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/services/__init__.py +0 -0
  68. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/alibaba/services/cr.py +0 -0
  69. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/__init__.py +0 -0
  70. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/constants.py +0 -0
  71. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/__init__.py +0 -0
  72. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/base_lambda.py +0 -0
  73. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/ecs.py +0 -0
  74. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/ecs_scheduled.py +0 -0
  75. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/lambda_.py +0 -0
  76. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/lambda_api.py +0 -0
  77. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/lambda_s3.py +0 -0
  78. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/deployers/lambda_sqs.py +0 -0
  79. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/schedule.py +0 -0
  80. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/services/__init__.py +0 -0
  81. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/services/cloudwatch.py +0 -0
  82. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/services/ecr.py +0 -0
  83. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/aws/services/iam.py +0 -0
  84. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/core.py +0 -0
  85. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/__init__.py +0 -0
  86. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/constants.py +0 -0
  87. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/deployers/__init__.py +0 -0
  88. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/deployers/cloudrun.py +0 -0
  89. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/services/__init__.py +0 -0
  90. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/services/apis.py +0 -0
  91. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/gcp/services/artifact_registry.py +0 -0
  92. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/github/__init__.py +0 -0
  93. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/github/constants.py +0 -0
  94. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/github/deployers/__init__.py +0 -0
  95. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/cloud/github/deployers/actions.py +0 -0
  96. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/__init__.py +0 -0
  97. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/action.py +0 -0
  98. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/config.py +0 -0
  99. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/config_file.py +0 -0
  100. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/context.py +0 -0
  101. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/decorators/__init__.py +0 -0
  102. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/decorators/time.py +0 -0
  103. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/module.py +0 -0
  104. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/serializers/__init__.py +0 -0
  105. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/types/execution.py +0 -0
  106. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/types/overlap.py +0 -0
  107. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/types/status.py +0 -0
  108. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/types/storage.py +0 -0
  109. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/core/types/workflow.py +0 -0
  110. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/logging.py +0 -0
  111. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/main.py +0 -0
  112. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/__init__.py +0 -0
  113. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/log_default.py +0 -0
  114. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/log_opentelemetry.py +0 -0
  115. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/log_sentry.py +0 -0
  116. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/metrics_default.py +0 -0
  117. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/metrics_opentelemetry.py +0 -0
  118. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/notify_default.py +0 -0
  119. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/notify_discord.py +0 -0
  120. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/notify_telegram.py +0 -0
  121. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/scheduler_cron.py +0 -0
  122. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/scheduler_default.py +0 -0
  123. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/tracer_default.py +0 -0
  124. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/tracer_opentelemetry.py +0 -0
  125. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/providers/tracer_sentry.py +0 -0
  126. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/settings.py +0 -0
  127. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/types.py +0 -0
  128. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/dotflow/utils/basic_functions.py +0 -0
  129. {dotflow-1.0.0.dev4 → dotflow-1.0.2}/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.dev4
3
+ Version: 1.0.2
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.dev4"
3
+ __version__ = "1.0.2"
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
@@ -169,6 +169,7 @@ class CloudGenerateCommand(Command):
169
169
  content = content.replace("{{PROJECT_NAME}}", project_name)
170
170
  content = content.replace("{{MODULE_NAME}}", module_name)
171
171
 
172
+ filepath.parent.mkdir(parents=True, exist_ok=True)
172
173
  filepath.write_text(content)
173
174
  print(f" Created: {filepath}")
174
175
 
@@ -23,14 +23,8 @@ MAX_POLL_INTERVAL = 60
23
23
 
24
24
  class LoginCommand(Command):
25
25
  def setup(self):
26
- token = getattr(self.params, "token", None)
27
26
  base_url = self._resolve_base_url()
28
27
 
29
- if token:
30
- save_cloud_config(token=token, base_url=base_url)
31
- print(settings.INFO_ALERT, "Token saved.")
32
- return
33
-
34
28
  handshake = self._start_device(base_url)
35
29
  if handshake is None:
36
30
  return
@@ -49,10 +43,8 @@ class LoginCommand(Command):
49
43
  print(settings.INFO_ALERT, "Authenticated.")
50
44
 
51
45
  def _resolve_base_url(self) -> str:
52
- """Flag or env var wins over the default."""
53
- explicit = getattr(self.params, "base_url", None) or os.environ.get(
54
- "SERVER_BASE_URL"
55
- )
46
+ """``SERVER_BASE_URL`` env var wins over the default."""
47
+ explicit = os.environ.get("SERVER_BASE_URL")
56
48
  return explicit.rstrip("/") if explicit else DEFAULT_BASE_URL
57
49
 
58
50
  def _start_device(self, base_url: str) -> dict | None:
@@ -51,12 +51,12 @@ class StartCommand(Command):
51
51
  if not callable(factory):
52
52
  raise InvalidWorkflowFactory(factory=self.params.workflow)
53
53
 
54
- 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()
@@ -56,17 +56,6 @@ class Command:
56
56
  cmd = self.subparsers.add_parser(
57
57
  "login", help="Authenticate the CLI via browser"
58
58
  )
59
- cmd_group = cmd.add_argument_group("Usage: dotflow login [OPTIONS]")
60
- cmd_group.add_argument(
61
- "--base-url",
62
- default=None,
63
- help="Override the Dotflow Cloud API base URL",
64
- )
65
- cmd_group.add_argument(
66
- "--token",
67
- default=None,
68
- help="Skip the browser flow and save a pre-obtained API token",
69
- )
70
59
  cmd.set_defaults(exec=LoginCommand)
71
60
 
72
61
  def setup_logout(self):
@@ -0,0 +1,148 @@
1
+ """S3 operations — read and write objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from json import dumps, loads
6
+
7
+ from dotflow.cloud.core import ObjectStorage
8
+ from dotflow.core.exception import ModuleNotFound
9
+
10
+ _PRECONDITION_CODES = {
11
+ "PreconditionFailed",
12
+ "ConditionalRequestConflict",
13
+ }
14
+
15
+
16
+ class S3(ObjectStorage):
17
+ """Amazon S3 object storage."""
18
+
19
+ def __init__(self, bucket: str, prefix: str = "", region: str = None):
20
+ try:
21
+ import boto3
22
+ except ImportError:
23
+ raise ModuleNotFound(
24
+ module="boto3", library="dotflow[aws]"
25
+ ) from None
26
+
27
+ self._s3 = boto3.client("s3", region_name=region)
28
+ self.bucket = bucket
29
+ self.prefix = prefix
30
+
31
+ self._s3.head_bucket(Bucket=self.bucket)
32
+
33
+ def read(self, key: str) -> list:
34
+ """Read a JSON list from S3. Returns empty list if key not found."""
35
+ try:
36
+ response = self._s3.get_object(
37
+ Bucket=self.bucket,
38
+ Key=f"{self.prefix}{key}",
39
+ )
40
+ data = response["Body"].read().decode("utf-8")
41
+ return loads(data)
42
+ except self._s3.exceptions.NoSuchKey:
43
+ return []
44
+
45
+ def write(self, key: str, data: list) -> None:
46
+ """Write a JSON list to S3."""
47
+ self._s3.put_object(
48
+ Bucket=self.bucket,
49
+ Key=f"{self.prefix}{key}",
50
+ Body=dumps(data),
51
+ ContentType="application/json",
52
+ )
53
+
54
+ def delete(self, key: str) -> bool:
55
+ """Delete a single object."""
56
+ from botocore.exceptions import ClientError
57
+
58
+ full_key = f"{self.prefix}{key}"
59
+
60
+ try:
61
+ self._s3.head_object(Bucket=self.bucket, Key=full_key)
62
+ except ClientError as error:
63
+ code = error.response.get("Error", {}).get("Code")
64
+
65
+ if code in ("404", "NoSuchKey", "NotFound"):
66
+ return False
67
+
68
+ raise
69
+
70
+ self._s3.delete_object(Bucket=self.bucket, Key=full_key)
71
+
72
+ return True
73
+
74
+ def read_with_etag(self, key: str) -> tuple[list, str | None]:
75
+ """Return (data, etag). Etag is None when key is missing."""
76
+ try:
77
+ response = self._s3.get_object(
78
+ Bucket=self.bucket,
79
+ Key=f"{self.prefix}{key}",
80
+ )
81
+ data = response["Body"].read().decode("utf-8")
82
+
83
+ return loads(data), response.get("ETag")
84
+ except self._s3.exceptions.NoSuchKey:
85
+ return [], None
86
+
87
+ def write_if_match(self, key: str, data: list, etag: str | None) -> bool:
88
+ """Conditional PutObject. Returns False on precondition failure."""
89
+ from botocore.exceptions import ClientError
90
+
91
+ kwargs = {
92
+ "Bucket": self.bucket,
93
+ "Key": f"{self.prefix}{key}",
94
+ "Body": dumps(data),
95
+ "ContentType": "application/json",
96
+ }
97
+
98
+ if etag is None:
99
+ kwargs["IfNoneMatch"] = "*"
100
+ else:
101
+ kwargs["IfMatch"] = etag
102
+
103
+ try:
104
+ self._s3.put_object(**kwargs)
105
+ return True
106
+ except ClientError as error:
107
+ code = error.response.get("Error", {}).get("Code")
108
+
109
+ if code in _PRECONDITION_CODES:
110
+ return False
111
+
112
+ raise
113
+
114
+ def list_keys(self, sub_prefix: str) -> list[str]:
115
+ """Return keys starting with sub_prefix."""
116
+ full_prefix = f"{self.prefix}{sub_prefix}"
117
+ paginator = self._s3.get_paginator("list_objects_v2")
118
+ names = []
119
+ offset = len(self.prefix)
120
+
121
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
122
+ for item in page.get("Contents", []):
123
+ names.append(item["Key"][offset:])
124
+
125
+ return names
126
+
127
+ def delete_prefix(self, sub_prefix: str) -> None:
128
+ """Delete every object whose key starts with prefix + sub_prefix.
129
+
130
+ Empty ``sub_prefix`` is rejected to avoid accidentally wiping
131
+ the entire bucket prefix.
132
+ """
133
+ if not sub_prefix:
134
+ raise ValueError("delete_prefix requires a non-empty sub_prefix")
135
+
136
+ full_prefix = f"{self.prefix}{sub_prefix}"
137
+ paginator = self._s3.get_paginator("list_objects_v2")
138
+
139
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
140
+ objects = [
141
+ {"Key": item["Key"]} for item in page.get("Contents", [])
142
+ ]
143
+ if not objects:
144
+ continue
145
+ self._s3.delete_objects(
146
+ Bucket=self.bucket,
147
+ Delete={"Objects": objects},
148
+ )
@@ -0,0 +1,110 @@
1
+ """GCS operations — read and write objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from json import dumps, loads
6
+
7
+ from dotflow.cloud.core import ObjectStorage
8
+ from dotflow.core.exception import ModuleNotFound
9
+
10
+
11
+ class GCS(ObjectStorage):
12
+ """Google Cloud Storage object storage."""
13
+
14
+ def __init__(self, bucket: str, prefix: str = "", project: str = None):
15
+ try:
16
+ from google.api_core.exceptions import NotFound
17
+ from google.cloud import storage as gcs
18
+ except ImportError:
19
+ raise ModuleNotFound(
20
+ module="google-cloud-storage",
21
+ library="dotflow[gcp]",
22
+ ) from None
23
+
24
+ self._not_found = NotFound
25
+ self._client = gcs.Client(project=project)
26
+ self._bucket = self._client.bucket(bucket)
27
+ self._bucket.reload()
28
+ self.prefix = prefix
29
+
30
+ def read(self, key: str) -> list:
31
+ """Read a JSON list from GCS. Returns empty list if key not found."""
32
+ blob = self._bucket.blob(f"{self.prefix}{key}")
33
+ try:
34
+ data = blob.download_as_text()
35
+ return loads(data)
36
+ except self._not_found:
37
+ return []
38
+
39
+ def write(self, key: str, data: list) -> None:
40
+ """Write a JSON list to GCS."""
41
+ blob = self._bucket.blob(f"{self.prefix}{key}")
42
+ blob.upload_from_string(
43
+ dumps(data),
44
+ content_type="application/json",
45
+ )
46
+
47
+ def delete(self, key: str) -> bool:
48
+ """Delete a single blob."""
49
+ blob = self._bucket.blob(f"{self.prefix}{key}")
50
+
51
+ try:
52
+ blob.delete()
53
+ return True
54
+ except self._not_found:
55
+ return False
56
+
57
+ def read_with_generation(self, key: str) -> tuple[list, int | None]:
58
+ """Return (data, generation). Generation is None when missing."""
59
+ blob = self._bucket.blob(f"{self.prefix}{key}")
60
+
61
+ try:
62
+ data = blob.download_as_text()
63
+ return loads(data), blob.generation
64
+ except self._not_found:
65
+ return [], None
66
+
67
+ def write_if_generation_match(
68
+ self, key: str, data: list, generation: int | None
69
+ ) -> bool:
70
+ """Conditional upload. Returns False on precondition failure."""
71
+ from google.api_core.exceptions import PreconditionFailed
72
+
73
+ blob = self._bucket.blob(f"{self.prefix}{key}")
74
+ precondition = generation if generation is not None else 0
75
+
76
+ try:
77
+ blob.upload_from_string(
78
+ dumps(data),
79
+ content_type="application/json",
80
+ if_generation_match=precondition,
81
+ )
82
+ return True
83
+ except PreconditionFailed:
84
+ return False
85
+
86
+ def list_keys(self, sub_prefix: str) -> list[str]:
87
+ """Return blob names starting with sub_prefix."""
88
+ full_prefix = f"{self.prefix}{sub_prefix}"
89
+ offset = len(self.prefix)
90
+
91
+ return [
92
+ blob.name[offset:]
93
+ for blob in self._client.list_blobs(
94
+ self._bucket, prefix=full_prefix
95
+ )
96
+ ]
97
+
98
+ def delete_prefix(self, sub_prefix: str) -> None:
99
+ """Delete every blob whose name starts with prefix + sub_prefix.
100
+
101
+ Empty ``sub_prefix`` is rejected to avoid accidentally wiping
102
+ the entire bucket prefix.
103
+ """
104
+ if not sub_prefix:
105
+ raise ValueError("delete_prefix requires a non-empty sub_prefix")
106
+
107
+ full_prefix = f"{self.prefix}{sub_prefix}"
108
+
109
+ for blob in self._client.list_blobs(self._bucket, prefix=full_prefix):
110
+ blob.delete()
@@ -9,6 +9,8 @@ from uuid import uuid4
9
9
  from dotflow.core.config import Config
10
10
  from dotflow.core.task import TaskBuilder
11
11
  from dotflow.core.workflow import Manager
12
+ from dotflow.logging import logger
13
+ from dotflow.utils import hostname
12
14
 
13
15
 
14
16
  class DotFlow:
@@ -34,9 +36,16 @@ class DotFlow:
34
36
  workflow_id (Optional[str]): Fixed workflow identifier for checkpoint
35
37
  resume. If not provided, a random UUID is generated.
36
38
 
39
+ name (Optional[str]): Human-readable label sent to the managed
40
+ server on registration. Defaults to the machine hostname so
41
+ runs from different hosts stay distinguishable in the
42
+ dashboard.
43
+
37
44
  Attributes:
38
45
  workflow_id (UUID):
39
46
 
47
+ name (str):
48
+
40
49
  task (List[Task]):
41
50
 
42
51
  start (Manager):
@@ -48,31 +57,48 @@ class DotFlow:
48
57
  self,
49
58
  config: Config | None = None,
50
59
  workflow_id: str | None = None,
60
+ name: str | None = None,
51
61
  ) -> None:
52
62
  workflow_id = workflow_id or os.getenv("WORKFLOW_ID")
53
63
  self._externally_provided_id = workflow_id is not None
54
64
  self.workflow_id = workflow_id or uuid4()
65
+ self.name = name or hostname()
55
66
  self._config = config if config else Config()
56
-
57
- if not self._externally_provided_id:
58
- self._config.server.create_workflow(workflow=self.workflow_id)
67
+ self._manager: Manager | None = None
68
+ self._last_run_signature: tuple = ()
59
69
 
60
70
  self.task = TaskBuilder(
61
71
  config=self._config,
62
72
  workflow_id=self.workflow_id,
73
+ workflow_name=self.name,
63
74
  )
64
75
 
65
- self.start = partial(
66
- Manager,
67
- tasks=self.task.queue,
68
- workflow_id=self.workflow_id,
69
- config=self._config,
70
- )
76
+ if not self._externally_provided_id:
77
+ self._config.server.create_workflow(workflow=self)
71
78
 
72
79
  self.schedule = partial(
73
80
  self._config.scheduler.start, workflow=self.start
74
81
  )
75
82
 
83
+ def start(self, **kwargs) -> Manager:
84
+ """Run the workflow once; duplicate calls return the original Manager."""
85
+ signature = tuple(task.task_id for task in self.task.queue)
86
+ if self._manager is not None and signature == self._last_run_signature:
87
+ logger.warning(
88
+ "DotFlow.start() already ran for %s; ignoring duplicate call",
89
+ self.workflow_id,
90
+ )
91
+ return self._manager
92
+
93
+ self._last_run_signature = signature
94
+ self._manager = Manager(
95
+ tasks=self.task.queue,
96
+ workflow_id=self.workflow_id,
97
+ config=self._config,
98
+ **kwargs,
99
+ )
100
+ return self._manager
101
+
76
102
  def result_task(self):
77
103
  """
78
104
  Returns:
@@ -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))
@@ -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