pyworkflow-engine 0.1.7__py3-none-any.whl

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 (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,651 @@
1
+ """
2
+ Docker infrastructure management utilities.
3
+
4
+ This module provides functions for managing Docker Compose services,
5
+ generating docker-compose.yml files, and checking service health.
6
+ """
7
+
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+
18
+ def check_docker_available() -> tuple[bool, str | None]:
19
+ """
20
+ Check if Docker and Docker Compose are available and running.
21
+
22
+ Returns:
23
+ Tuple of (available, error_message)
24
+ - (True, None) if Docker is available
25
+ - (False, error_message) if not available
26
+
27
+ Example:
28
+ >>> available, error = check_docker_available()
29
+ >>> if not available:
30
+ ... print(f"Docker error: {error}")
31
+ """
32
+ # Check if docker command exists
33
+ if not shutil.which("docker"):
34
+ return False, "Docker is not installed. Install from: https://docs.docker.com/get-docker/"
35
+
36
+ # Check if docker daemon is running
37
+ try:
38
+ result = subprocess.run(
39
+ ["docker", "info"],
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=5,
43
+ )
44
+ if result.returncode != 0:
45
+ return False, "Docker daemon is not running. Please start Docker."
46
+ except subprocess.TimeoutExpired:
47
+ return False, "Docker daemon is not responding"
48
+ except Exception as e:
49
+ return False, f"Error checking Docker: {str(e)}"
50
+
51
+ # Check docker compose command (modern: 'docker compose')
52
+ try:
53
+ result = subprocess.run(
54
+ ["docker", "compose", "version"],
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=5,
58
+ )
59
+ if result.returncode == 0:
60
+ return True, None
61
+ except Exception:
62
+ pass
63
+
64
+ # Fallback: check legacy docker-compose
65
+ if shutil.which("docker-compose"):
66
+ return True, None
67
+
68
+ return False, "Docker Compose is not available. Upgrade Docker to get 'docker compose'."
69
+
70
+
71
+ def generate_docker_compose_content(
72
+ storage_type: str,
73
+ storage_path: str | None = None,
74
+ dynamodb_endpoint_url: str | None = None,
75
+ ) -> str:
76
+ """
77
+ Generate docker-compose.yml content for PyWorkflow services.
78
+
79
+ Args:
80
+ storage_type: Storage backend type ("sqlite", "file", "memory", "dynamodb")
81
+ storage_path: Path to storage (for file/sqlite backends)
82
+ dynamodb_endpoint_url: Optional local DynamoDB endpoint URL
83
+
84
+ Returns:
85
+ docker-compose.yml content as string
86
+
87
+ Example:
88
+ >>> compose_content = generate_docker_compose_content(
89
+ ... storage_type="sqlite",
90
+ ... storage_path="./pyworkflow_data/pyworkflow.db"
91
+ ... )
92
+ """
93
+ # Normalize storage path - extract directory for volume mapping
94
+ if not storage_path:
95
+ volume_mapping = "./pyworkflow_data"
96
+ else:
97
+ # For SQLite, storage_path is a file (e.g., ./pyworkflow_data/pyworkflow.db)
98
+ # We need to mount the directory, not the file
99
+ from pathlib import Path
100
+
101
+ path_obj = Path(storage_path)
102
+ if storage_type == "sqlite" and path_obj.suffix == ".db":
103
+ # Mount the parent directory
104
+ volume_mapping = str(path_obj.parent)
105
+ else:
106
+ # For file storage, it's already a directory
107
+ volume_mapping = storage_path
108
+
109
+ # Ensure volume_mapping is a proper path (starts with ./ or / for bind mount)
110
+ if not volume_mapping.startswith(("./", "/", "~")):
111
+ volume_mapping = f"./{volume_mapping}"
112
+
113
+ # DynamoDB Local service (only for dynamodb storage type with local endpoint)
114
+ dynamodb_service = ""
115
+ dynamodb_depends = ""
116
+ dynamodb_env = ""
117
+ if storage_type == "dynamodb":
118
+ dynamodb_service = """
119
+ dynamodb-local:
120
+ image: amazon/dynamodb-local:latest
121
+ container_name: pyworkflow-dynamodb-local
122
+ ports:
123
+ - "8000:8000"
124
+ command: "-jar DynamoDBLocal.jar -sharedDb -inMemory"
125
+ healthcheck:
126
+ test: ["CMD-SHELL", "curl -s http://localhost:8000 || exit 1"]
127
+ interval: 5s
128
+ timeout: 3s
129
+ retries: 5
130
+ restart: unless-stopped
131
+ """
132
+ dynamodb_depends = """
133
+ dynamodb-local:
134
+ condition: service_healthy"""
135
+ dynamodb_env = """
136
+ - DASHBOARD_DYNAMODB_ENDPOINT_URL=http://dynamodb-local:8000"""
137
+
138
+ template = f"""services:
139
+ redis:
140
+ image: redis:7-alpine
141
+ container_name: pyworkflow-redis
142
+ ports:
143
+ - "6379:6379"
144
+ volumes:
145
+ - redis_data:/data
146
+ healthcheck:
147
+ test: ["CMD", "redis-cli", "ping"]
148
+ interval: 5s
149
+ timeout: 3s
150
+ retries: 5
151
+ restart: unless-stopped
152
+ {dynamodb_service}
153
+ dashboard-backend:
154
+ image: yashabro/pyworkflow-dashboard-backend:latest
155
+ platform: linux/amd64
156
+ container_name: pyworkflow-dashboard-backend
157
+ working_dir: /app/project
158
+ ports:
159
+ - "8585:8585"
160
+ environment:
161
+ - DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
162
+ - DASHBOARD_STORAGE_TYPE={storage_type}
163
+ - DASHBOARD_STORAGE_PATH=/app/project/pyworkflow_data
164
+ - DASHBOARD_HOST=0.0.0.0
165
+ - DASHBOARD_PORT=8585
166
+ - DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
167
+ - PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
168
+ - PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
169
+ - PYTHONPATH=/app/project{dynamodb_env}
170
+ volumes:
171
+ - .:/app/project:ro
172
+ - {volume_mapping}:/app/project/pyworkflow_data
173
+ depends_on:
174
+ redis:
175
+ condition: service_healthy{dynamodb_depends}
176
+ restart: unless-stopped
177
+
178
+ dashboard-frontend:
179
+ image: yashabro/pyworkflow-dashboard-frontend:latest
180
+ platform: linux/amd64
181
+ container_name: pyworkflow-dashboard-frontend
182
+ ports:
183
+ - "5173:80"
184
+ environment:
185
+ - VITE_API_URL=http://localhost:8585
186
+ depends_on:
187
+ - dashboard-backend
188
+ restart: unless-stopped
189
+
190
+ volumes:
191
+ redis_data:
192
+ driver: local
193
+ """
194
+
195
+ return template
196
+
197
+
198
+ def generate_postgres_docker_compose_content(
199
+ postgres_host: str = "postgres",
200
+ postgres_port: int = 5432,
201
+ postgres_user: str = "pyworkflow",
202
+ postgres_password: str = "pyworkflow",
203
+ postgres_database: str = "pyworkflow",
204
+ ) -> str:
205
+ """
206
+ Generate docker-compose.yml content for PostgreSQL-based PyWorkflow services.
207
+
208
+ Args:
209
+ postgres_host: PostgreSQL host (container name for docker networking)
210
+ postgres_port: PostgreSQL port
211
+ postgres_user: PostgreSQL user
212
+ postgres_password: PostgreSQL password
213
+ postgres_database: PostgreSQL database name
214
+
215
+ Returns:
216
+ docker-compose.yml content as string
217
+
218
+ Example:
219
+ >>> compose_content = generate_postgres_docker_compose_content()
220
+ """
221
+ template = f"""services:
222
+ postgres:
223
+ image: postgres:16-alpine
224
+ container_name: pyworkflow-postgres
225
+ ports:
226
+ - "{postgres_port}:5432"
227
+ environment:
228
+ - POSTGRES_USER={postgres_user}
229
+ - POSTGRES_PASSWORD={postgres_password}
230
+ - POSTGRES_DB={postgres_database}
231
+ volumes:
232
+ - postgres_data:/var/lib/postgresql/data
233
+ healthcheck:
234
+ test: ["CMD-SHELL", "pg_isready -U {postgres_user} -d {postgres_database}"]
235
+ interval: 5s
236
+ timeout: 3s
237
+ retries: 5
238
+ restart: unless-stopped
239
+
240
+ redis:
241
+ image: redis:7-alpine
242
+ container_name: pyworkflow-redis
243
+ ports:
244
+ - "6379:6379"
245
+ volumes:
246
+ - redis_data:/data
247
+ healthcheck:
248
+ test: ["CMD", "redis-cli", "ping"]
249
+ interval: 5s
250
+ timeout: 3s
251
+ retries: 5
252
+ restart: unless-stopped
253
+
254
+ dashboard-backend:
255
+ image: yashabro/pyworkflow-dashboard-backend:latest
256
+ container_name: pyworkflow-dashboard-backend
257
+ working_dir: /app/project
258
+ ports:
259
+ - "8585:8585"
260
+ environment:
261
+ - DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
262
+ - DASHBOARD_STORAGE_TYPE=postgres
263
+ - DASHBOARD_POSTGRES_HOST={postgres_host}
264
+ - DASHBOARD_POSTGRES_PORT=5432
265
+ - DASHBOARD_POSTGRES_USER={postgres_user}
266
+ - DASHBOARD_POSTGRES_PASSWORD={postgres_password}
267
+ - DASHBOARD_POSTGRES_DATABASE={postgres_database}
268
+ - DASHBOARD_HOST=0.0.0.0
269
+ - DASHBOARD_PORT=8585
270
+ - DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
271
+ - PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
272
+ - PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
273
+ - PYTHONPATH=/app/project
274
+ volumes:
275
+ - .:/app/project:ro
276
+ depends_on:
277
+ postgres:
278
+ condition: service_healthy
279
+ redis:
280
+ condition: service_healthy
281
+ restart: unless-stopped
282
+
283
+ dashboard-frontend:
284
+ image: yashabro/pyworkflow-dashboard-frontend:latest
285
+ container_name: pyworkflow-dashboard-frontend
286
+ ports:
287
+ - "5173:80"
288
+ environment:
289
+ - VITE_API_URL=http://localhost:8585
290
+ depends_on:
291
+ - dashboard-backend
292
+ restart: unless-stopped
293
+
294
+ volumes:
295
+ postgres_data:
296
+ driver: local
297
+ redis_data:
298
+ driver: local
299
+ """
300
+
301
+ return template
302
+
303
+
304
+ def write_docker_compose(content: str, path: Path) -> None:
305
+ """
306
+ Write docker-compose.yml to file.
307
+
308
+ Args:
309
+ content: docker-compose.yml content
310
+ path: Target file path
311
+
312
+ Example:
313
+ >>> compose_content = generate_docker_compose_content("sqlite")
314
+ >>> write_docker_compose(compose_content, Path("./docker-compose.yml"))
315
+ """
316
+ path.write_text(content)
317
+
318
+
319
+ def get_docker_compose_command() -> list[str]:
320
+ """
321
+ Get the appropriate docker compose command for the platform.
322
+
323
+ Returns:
324
+ Command as list (e.g., ["docker", "compose"] or ["docker-compose"])
325
+
326
+ Example:
327
+ >>> cmd = get_docker_compose_command()
328
+ >>> # Use in subprocess: subprocess.run(cmd + ["up", "-d"])
329
+ """
330
+ # Try modern 'docker compose' first
331
+ try:
332
+ result = subprocess.run(
333
+ ["docker", "compose", "version"],
334
+ capture_output=True,
335
+ timeout=2,
336
+ )
337
+ if result.returncode == 0:
338
+ return ["docker", "compose"]
339
+ except Exception:
340
+ pass
341
+
342
+ # Fall back to legacy 'docker-compose'
343
+ if shutil.which("docker-compose"):
344
+ return ["docker-compose"]
345
+
346
+ # Default to modern syntax
347
+ return ["docker", "compose"]
348
+
349
+
350
+ def run_docker_command(
351
+ args: list[str],
352
+ compose_file: Path | None = None,
353
+ capture_output: bool = False,
354
+ stream_output: bool = False,
355
+ ) -> tuple[bool, str]:
356
+ """
357
+ Run a docker compose command.
358
+
359
+ Args:
360
+ args: Command arguments (e.g., ["up", "-d"])
361
+ compose_file: Path to docker-compose.yml (default: ./docker-compose.yml)
362
+ capture_output: If True, capture and return output
363
+ stream_output: If True, stream output to console in real-time
364
+
365
+ Returns:
366
+ Tuple of (success, output_or_error_message)
367
+
368
+ Example:
369
+ >>> success, output = run_docker_command(["up", "-d"])
370
+ >>> if not success:
371
+ ... print(f"Error: {output}")
372
+ """
373
+ cmd = get_docker_compose_command()
374
+
375
+ if compose_file:
376
+ cmd.extend(["-f", str(compose_file)])
377
+
378
+ cmd.extend(args)
379
+
380
+ try:
381
+ if stream_output:
382
+ # Stream output in real-time with spinner
383
+ import sys
384
+ import threading
385
+
386
+ # ANSI codes
387
+ GRAY = "\033[90m"
388
+ RESET = "\033[0m"
389
+ CLEAR_LINE = "\033[K"
390
+
391
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
392
+ spinner_running = True
393
+ current_frame = [0]
394
+
395
+ def spinner_thread():
396
+ """Background thread for spinner animation."""
397
+ while spinner_running:
398
+ frame = spinner_frames[current_frame[0] % len(spinner_frames)]
399
+ # Print spinner at current position
400
+ sys.stdout.write(f"{GRAY}{frame} Working...{RESET}{CLEAR_LINE}\r")
401
+ sys.stdout.flush()
402
+ current_frame[0] += 1
403
+ import time
404
+
405
+ time.sleep(0.1)
406
+ # Clear spinner when done
407
+ sys.stdout.write(f"{CLEAR_LINE}\r")
408
+ sys.stdout.flush()
409
+
410
+ # Start spinner in background
411
+ spinner = threading.Thread(target=spinner_thread, daemon=True)
412
+ spinner.start()
413
+
414
+ process = subprocess.Popen(
415
+ cmd,
416
+ stdout=subprocess.PIPE,
417
+ stderr=subprocess.STDOUT,
418
+ text=True,
419
+ bufsize=1,
420
+ )
421
+
422
+ output_lines = []
423
+ if process.stdout:
424
+ for line in process.stdout:
425
+ # Clear spinner line, print log in gray, restore spinner
426
+ sys.stdout.write(f"{CLEAR_LINE}\r")
427
+ print(f"{GRAY} {line.rstrip()}{RESET}")
428
+ output_lines.append(line)
429
+
430
+ process.wait(timeout=600) # 10 minute timeout for builds
431
+ spinner_running = False
432
+ spinner.join(timeout=0.5)
433
+
434
+ output = "".join(output_lines)
435
+
436
+ if process.returncode == 0:
437
+ return True, output if capture_output else "Success"
438
+ else:
439
+ return False, output
440
+
441
+ else:
442
+ # Capture output
443
+ result = subprocess.run(
444
+ cmd,
445
+ capture_output=True,
446
+ text=True,
447
+ timeout=300, # 5 minute timeout
448
+ )
449
+
450
+ if result.returncode == 0:
451
+ return True, result.stdout if capture_output else "Success"
452
+ else:
453
+ error_msg = result.stderr or result.stdout or "Unknown error"
454
+ return False, error_msg
455
+
456
+ except subprocess.TimeoutExpired:
457
+ return False, "Command timed out"
458
+ except Exception as e:
459
+ return False, f"Error running docker command: {str(e)}"
460
+
461
+
462
+ def wait_for_tcp_port(
463
+ host: str,
464
+ port: int,
465
+ timeout: int = 30,
466
+ ) -> bool:
467
+ """
468
+ Wait for a TCP port to become available.
469
+
470
+ Args:
471
+ host: Hostname or IP address
472
+ port: Port number
473
+ timeout: Maximum wait time in seconds
474
+
475
+ Returns:
476
+ True if port is available, False if timeout
477
+
478
+ Example:
479
+ >>> if wait_for_tcp_port("localhost", 6379, timeout=10):
480
+ ... print("Redis is ready!")
481
+ """
482
+ start_time = time.time()
483
+
484
+ while time.time() - start_time < timeout:
485
+ try:
486
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
487
+ sock.settimeout(1)
488
+ result = sock.connect_ex((host, port))
489
+ sock.close()
490
+
491
+ if result == 0:
492
+ return True
493
+
494
+ except Exception:
495
+ pass
496
+
497
+ time.sleep(0.5)
498
+
499
+ return False
500
+
501
+
502
+ def wait_for_http_service(
503
+ url: str,
504
+ timeout: int = 30,
505
+ expected_status: int = 200,
506
+ ) -> bool:
507
+ """
508
+ Wait for an HTTP service to become available.
509
+
510
+ Args:
511
+ url: Service URL to check
512
+ timeout: Maximum wait time in seconds
513
+ expected_status: Expected HTTP status code
514
+
515
+ Returns:
516
+ True if service responds with expected status, False if timeout
517
+
518
+ Example:
519
+ >>> if wait_for_http_service("http://localhost:8585/api/v1/health"):
520
+ ... print("Dashboard backend is ready!")
521
+ """
522
+ start_time = time.time()
523
+
524
+ while time.time() - start_time < timeout:
525
+ try:
526
+ response = httpx.get(url, timeout=2.0)
527
+ if response.status_code == expected_status or response.status_code < 500:
528
+ return True
529
+ except (httpx.ConnectError, httpx.TimeoutException):
530
+ pass
531
+ except Exception:
532
+ # Other errors might indicate the service is up but returning an error
533
+ return True
534
+
535
+ time.sleep(1)
536
+
537
+ return False
538
+
539
+
540
+ def check_service_health(service_checks: dict[str, dict[str, Any]]) -> dict[str, bool]:
541
+ """
542
+ Check health of multiple services.
543
+
544
+ Args:
545
+ service_checks: Dict mapping service names to check configs
546
+ Example:
547
+ {
548
+ "redis": {"type": "tcp", "host": "localhost", "port": 6379},
549
+ "backend": {"type": "http", "url": "http://localhost:8585/api/v1/health"}
550
+ }
551
+
552
+ Returns:
553
+ Dict mapping service names to health status (True/False)
554
+
555
+ Example:
556
+ >>> checks = {
557
+ ... "Redis": {"type": "tcp", "host": "localhost", "port": 6379},
558
+ ... "Dashboard": {"type": "http", "url": "http://localhost:8585/api/v1/health"}
559
+ ... }
560
+ >>> results = check_service_health(checks)
561
+ >>> for service, healthy in results.items():
562
+ ... print(f"{service}: {'✓' if healthy else '✗'}")
563
+ """
564
+ results = {}
565
+
566
+ for service_name, check_config in service_checks.items():
567
+ check_type = check_config.get("type")
568
+
569
+ if check_type == "tcp":
570
+ host = check_config.get("host", "localhost")
571
+ port = check_config["port"]
572
+ results[service_name] = wait_for_tcp_port(host, port, timeout=5)
573
+
574
+ elif check_type == "http":
575
+ url = check_config["url"]
576
+ expected_status = check_config.get("expected_status", 200)
577
+ results[service_name] = wait_for_http_service(
578
+ url, timeout=5, expected_status=expected_status
579
+ )
580
+
581
+ else:
582
+ results[service_name] = False
583
+
584
+ return results
585
+
586
+
587
+ def get_service_logs(
588
+ service_name: str,
589
+ compose_file: Path | None = None,
590
+ lines: int = 50,
591
+ ) -> str:
592
+ """
593
+ Get logs from a docker compose service.
594
+
595
+ Args:
596
+ service_name: Name of the service
597
+ compose_file: Path to docker-compose.yml
598
+ lines: Number of log lines to retrieve
599
+
600
+ Returns:
601
+ Service logs as string
602
+
603
+ Example:
604
+ >>> logs = get_service_logs("dashboard-backend", lines=20)
605
+ >>> print(logs)
606
+ """
607
+ success, output = run_docker_command(
608
+ ["logs", "--tail", str(lines), service_name],
609
+ compose_file=compose_file,
610
+ capture_output=True,
611
+ )
612
+
613
+ return output if success else f"Error getting logs: {output}"
614
+
615
+
616
+ def stop_services(compose_file: Path | None = None) -> tuple[bool, str]:
617
+ """
618
+ Stop all docker compose services.
619
+
620
+ Args:
621
+ compose_file: Path to docker-compose.yml
622
+
623
+ Returns:
624
+ Tuple of (success, message)
625
+
626
+ Example:
627
+ >>> success, msg = stop_services()
628
+ >>> if success:
629
+ ... print("Services stopped successfully")
630
+ """
631
+ return run_docker_command(["down"], compose_file=compose_file)
632
+
633
+
634
+ def restart_service(
635
+ service_name: str,
636
+ compose_file: Path | None = None,
637
+ ) -> tuple[bool, str]:
638
+ """
639
+ Restart a specific docker compose service.
640
+
641
+ Args:
642
+ service_name: Name of the service to restart
643
+ compose_file: Path to docker-compose.yml
644
+
645
+ Returns:
646
+ Tuple of (success, message)
647
+
648
+ Example:
649
+ >>> success, msg = restart_service("dashboard-backend")
650
+ """
651
+ return run_docker_command(["restart", service_name], compose_file=compose_file)