orchestration-engine 0.9.0__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.
@@ -0,0 +1,54 @@
1
+ import importlib.metadata
2
+ import logging
3
+
4
+ from .builder import WorkflowBuilder, workflow_to_dict
5
+ from .errors import (
6
+ OrchestrationError,
7
+ PersistenceError,
8
+ WorkflowDefinitionError,
9
+ WorkflowValidationError,
10
+ )
11
+ from .loader import (
12
+ load_workflow_from_json,
13
+ load_workflow_from_yaml,
14
+ run_workflow_from_file,
15
+ )
16
+ from .models import Hooks, RetryPolicy, Task, TaskResult, TaskState, Workflow, WorkflowStatus
17
+ from .persistence import get_connection, get_run_history, get_workflow_history, list_runs
18
+ from .runner import resume_run, run_workflow
19
+ from .status import get_run_status, get_workflow_status
20
+
21
+ logging.getLogger("orchestration_engine").addHandler(logging.NullHandler())
22
+
23
+ try:
24
+ __version__ = importlib.metadata.version("orchestration-engine")
25
+ except importlib.metadata.PackageNotFoundError:
26
+ __version__ = "0.9.0"
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "Task",
31
+ "TaskResult",
32
+ "Workflow",
33
+ "RetryPolicy",
34
+ "TaskState",
35
+ "WorkflowStatus",
36
+ "Hooks",
37
+ "WorkflowBuilder",
38
+ "workflow_to_dict",
39
+ "run_workflow",
40
+ "run_workflow_from_file",
41
+ "resume_run",
42
+ "load_workflow_from_json",
43
+ "load_workflow_from_yaml",
44
+ "get_workflow_status",
45
+ "get_run_status",
46
+ "get_workflow_history",
47
+ "get_run_history",
48
+ "list_runs",
49
+ "get_connection",
50
+ "OrchestrationError",
51
+ "WorkflowDefinitionError",
52
+ "WorkflowValidationError",
53
+ "PersistenceError",
54
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from orchestration_engine.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Self
4
+
5
+ from orchestration_engine.errors import WorkflowValidationError
6
+ from orchestration_engine.models import RetryPolicy, Task, TaskState, Workflow
7
+ from orchestration_engine.validation import get_workflow_validation_error
8
+
9
+
10
+ class WorkflowBuilder:
11
+ """
12
+ Fluent builder for constructing and validating Workflow instances.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ workflow_id: str,
18
+ name: str = "",
19
+ description: str = "",
20
+ default_timeout_seconds: float | None = None,
21
+ ) -> None:
22
+ self._workflow_id = workflow_id
23
+ self._name = name or workflow_id
24
+ self._description = description
25
+ self._default_timeout_seconds = default_timeout_seconds
26
+ self._tasks: list[Task] = []
27
+
28
+ def name(self, name: str) -> Self:
29
+ self._name = name
30
+ return self
31
+
32
+ def description(self, description: str) -> Self:
33
+ self._description = description
34
+ return self
35
+
36
+ def default_timeout(self, seconds: float | None) -> Self:
37
+ self._default_timeout_seconds = seconds
38
+ return self
39
+
40
+ def task(
41
+ self,
42
+ task_id: str,
43
+ name: str = "",
44
+ description: str = "",
45
+ dependencies: list[str] | None = None,
46
+ max_attempts: int = 1,
47
+ delay_seconds: float = 1.0,
48
+ timeout_seconds: float | None = 30.0,
49
+ retry_policy: RetryPolicy | None = None,
50
+ ) -> Self:
51
+ if retry_policy is None:
52
+ policy = RetryPolicy(
53
+ max_attempts=max_attempts, delay_seconds=delay_seconds
54
+ )
55
+ else:
56
+ policy = retry_policy
57
+
58
+ effective_timeout = timeout_seconds
59
+ if (
60
+ timeout_seconds == 30.0
61
+ and self._default_timeout_seconds is not None
62
+ ):
63
+ effective_timeout = self._default_timeout_seconds
64
+
65
+ task = Task(
66
+ task_id=task_id,
67
+ workflow_id=self._workflow_id,
68
+ name=name or task_id,
69
+ description=description,
70
+ dependencies=list(dependencies or []),
71
+ state=TaskState.PENDING,
72
+ retry_policy=policy,
73
+ timeout_seconds=effective_timeout,
74
+ )
75
+ self._tasks.append(task)
76
+ return self
77
+
78
+ def build(self) -> Workflow:
79
+ workflow = Workflow(
80
+ workflow_id=self._workflow_id,
81
+ name=self._name,
82
+ description=self._description,
83
+ tasks=list(self._tasks),
84
+ default_timeout_seconds=self._default_timeout_seconds,
85
+ )
86
+ error = get_workflow_validation_error(workflow)
87
+ if error:
88
+ raise WorkflowValidationError(f"Invalid workflow: {error}")
89
+ return workflow
90
+
91
+
92
+ def workflow_to_dict(workflow: Workflow) -> dict[str, Any]:
93
+ """
94
+ Serialize a Workflow instance to a JSON-compatible dictionary.
95
+
96
+ Round-trips losslessly with load_workflow_from_json.
97
+ """
98
+ return {
99
+ "workflow_id": workflow.workflow_id,
100
+ "name": workflow.name,
101
+ "description": workflow.description,
102
+ "default_timeout_seconds": workflow.default_timeout_seconds,
103
+ "tasks": [
104
+ {
105
+ "task_id": t.task_id,
106
+ "name": t.name,
107
+ "description": t.description,
108
+ "dependencies": list(t.dependencies),
109
+ "retry_policy": {
110
+ "max_attempts": t.retry_policy.max_attempts,
111
+ "delay_seconds": t.retry_policy.delay_seconds,
112
+ },
113
+ "timeout_seconds": t.timeout_seconds,
114
+ }
115
+ for t in workflow.tasks
116
+ ],
117
+ }
@@ -0,0 +1,454 @@
1
+ """Command-line interface for the orchestration engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable
13
+ from typing import Any, cast
14
+
15
+ from orchestration_engine.errors import (
16
+ OrchestrationError,
17
+ PersistenceError,
18
+ WorkflowDefinitionError,
19
+ WorkflowValidationError,
20
+ )
21
+ from orchestration_engine.loader import load_workflow_from_json
22
+ from orchestration_engine.models import Workflow, WorkflowStatus
23
+ from orchestration_engine.persistence import (
24
+ get_connection,
25
+ get_run_history,
26
+ list_runs,
27
+ )
28
+ from orchestration_engine.runner import resume_run, run_workflow
29
+ from orchestration_engine.status import get_run_status
30
+
31
+ logger = logging.getLogger("orchestration_engine")
32
+
33
+
34
+ def _load_runner(runner_spec: str) -> Callable[..., Any]:
35
+ """
36
+ Resolve a task runner callable from a 'module:function' string via importlib.
37
+
38
+ Note: This dynamically imports user-specified code and prepends os.getcwd()
39
+ to sys.path. It operates under the same trust model as executing a Python script.
40
+ """
41
+ if ":" not in runner_spec:
42
+ raise ValueError(
43
+ f"Invalid runner specification '{runner_spec}'. Expected format 'module:function'."
44
+ )
45
+ module_name, func_name = runner_spec.split(":", 1)
46
+ if not module_name.strip() or not func_name.strip():
47
+ raise ValueError(
48
+ f"Invalid runner specification '{runner_spec}'. Expected format 'module:function'."
49
+ )
50
+
51
+ cwd = os.getcwd()
52
+ if cwd not in sys.path:
53
+ sys.path.insert(0, cwd)
54
+
55
+ try:
56
+ mod = importlib.import_module(module_name)
57
+ except Exception as exc:
58
+ raise ImportError(f"Could not import module '{module_name}': {exc}") from exc
59
+
60
+ if not hasattr(mod, func_name):
61
+ raise AttributeError(f"Module '{module_name}' has no attribute '{func_name}'")
62
+
63
+ runner = getattr(mod, func_name)
64
+ if not callable(runner):
65
+ raise TypeError(f"Runner '{runner_spec}' is not callable.")
66
+
67
+ return cast(Callable[..., Any], runner)
68
+
69
+
70
+ def _load_workflow_file(path: str) -> Workflow:
71
+ """Load a workflow definition from a JSON or YAML file."""
72
+ if not os.path.exists(path):
73
+ raise FileNotFoundError(f"Workflow file not found: '{path}'")
74
+
75
+ if os.path.getsize(path) > 10 * 1024 * 1024:
76
+ raise WorkflowDefinitionError(f"Workflow file '{path}' exceeds maximum size of 10 MB")
77
+
78
+ ext = os.path.splitext(path)[1].lower()
79
+ if ext in (".yaml", ".yml"):
80
+ try:
81
+ from orchestration_engine.loader import load_workflow_from_yaml
82
+ except ImportError as exc:
83
+ raise WorkflowValidationError(
84
+ "YAML support requires the 'yaml' extra. "
85
+ "Install with: pip install 'orchestration-engine[yaml]'"
86
+ ) from exc
87
+ return load_workflow_from_yaml(path, collect_errors=True)
88
+
89
+ with open(path, encoding="utf-8") as f:
90
+ try:
91
+ data = json.load(f)
92
+ except json.JSONDecodeError as exc:
93
+ raise WorkflowDefinitionError(f"Invalid JSON in '{path}': {exc}") from exc
94
+
95
+ if not isinstance(data, dict):
96
+ raise WorkflowDefinitionError(f"Root of workflow JSON in '{path}' must be an object")
97
+
98
+ return load_workflow_from_json(data, collect_errors=True)
99
+
100
+
101
+ def _cmd_validate(args: argparse.Namespace) -> int:
102
+ try:
103
+ workflow = _load_workflow_file(args.workflow_file)
104
+ except (WorkflowValidationError, WorkflowDefinitionError, FileNotFoundError, ValueError) as exc:
105
+ print(f"Validation error: {exc}", file=sys.stderr)
106
+ return 2
107
+ except Exception as exc:
108
+ print(f"Internal error: {exc}", file=sys.stderr)
109
+ return 3
110
+
111
+ if args.json:
112
+ print(
113
+ json.dumps(
114
+ {
115
+ "workflow_id": workflow.workflow_id,
116
+ "tasks_count": len(workflow.tasks),
117
+ "valid": True,
118
+ }
119
+ )
120
+ )
121
+ else:
122
+ print(f"Workflow '{workflow.workflow_id}' is valid ({len(workflow.tasks)} tasks).")
123
+ return 0
124
+
125
+
126
+ def _cmd_run(args: argparse.Namespace) -> int:
127
+ try:
128
+ workflow = _load_workflow_file(args.workflow_file)
129
+ runner = _load_runner(args.runner)
130
+ if args.max_concurrency < 1:
131
+ raise ValueError(f"max_concurrency must be >= 1, got {args.max_concurrency}")
132
+ except (
133
+ WorkflowValidationError,
134
+ WorkflowDefinitionError,
135
+ FileNotFoundError,
136
+ ValueError,
137
+ ImportError,
138
+ AttributeError,
139
+ TypeError,
140
+ ) as exc:
141
+ print(f"Error: {exc}", file=sys.stderr)
142
+ return 2
143
+ except Exception as exc:
144
+ print(f"Internal error: {exc}", file=sys.stderr)
145
+ return 3
146
+
147
+ try:
148
+ conn = get_connection(args.db) if args.db else get_connection(":memory:")
149
+ except PersistenceError as exc:
150
+ print(f"Database error: {exc}", file=sys.stderr)
151
+ return 3
152
+
153
+ try:
154
+ actual_run_id = run_workflow(
155
+ workflow,
156
+ task_states={},
157
+ time_source=time.time,
158
+ task_runner=runner,
159
+ conn=conn,
160
+ run_id=args.run_id,
161
+ max_concurrency=args.max_concurrency,
162
+ executor=args.executor,
163
+ )
164
+ st = get_run_status(conn, actual_run_id)
165
+ except (
166
+ WorkflowValidationError,
167
+ WorkflowDefinitionError,
168
+ OrchestrationError,
169
+ ValueError,
170
+ ) as exc:
171
+ print(f"Execution error: {exc}", file=sys.stderr)
172
+ return 2
173
+ except PersistenceError as exc:
174
+ print(f"Database error during run: {exc}", file=sys.stderr)
175
+ return 3
176
+ except Exception as exc:
177
+ print(f"Internal error during run: {exc}", file=sys.stderr)
178
+ return 3
179
+
180
+ status_str = st.value if st is not None else "unknown"
181
+ if args.json:
182
+ print(
183
+ json.dumps(
184
+ {
185
+ "run_id": actual_run_id,
186
+ "workflow_id": workflow.workflow_id,
187
+ "status": status_str,
188
+ }
189
+ )
190
+ )
191
+ else:
192
+ print(f"Workflow run '{actual_run_id}' completed with status: {status_str}")
193
+
194
+ return 0 if st == WorkflowStatus.SUCCEEDED else 1
195
+
196
+
197
+ def _cmd_resume(args: argparse.Namespace) -> int:
198
+ try:
199
+ runner = _load_runner(args.runner)
200
+ if args.max_concurrency < 1:
201
+ raise ValueError(f"max_concurrency must be >= 1, got {args.max_concurrency}")
202
+ except (ValueError, ImportError, AttributeError, TypeError) as exc:
203
+ print(f"Error: {exc}", file=sys.stderr)
204
+ return 2
205
+ except Exception as exc:
206
+ print(f"Internal error: {exc}", file=sys.stderr)
207
+ return 3
208
+
209
+ try:
210
+ conn = get_connection(args.db)
211
+ except PersistenceError as exc:
212
+ print(f"Database error: {exc}", file=sys.stderr)
213
+ return 3
214
+
215
+ try:
216
+ st = resume_run(
217
+ conn=conn,
218
+ run_id=args.run_id,
219
+ task_runner=runner,
220
+ time_source=time.time,
221
+ max_concurrency=args.max_concurrency,
222
+ executor=args.executor,
223
+ )
224
+ except (
225
+ WorkflowValidationError,
226
+ WorkflowDefinitionError,
227
+ OrchestrationError,
228
+ ValueError,
229
+ ) as exc:
230
+ print(f"Resume error: {exc}", file=sys.stderr)
231
+ return 2
232
+ except PersistenceError as exc:
233
+ print(f"Database error during resume: {exc}", file=sys.stderr)
234
+ return 3
235
+ except Exception as exc:
236
+ print(f"Internal error during resume: {exc}", file=sys.stderr)
237
+ return 3
238
+
239
+ status_str = st.value
240
+ if args.json:
241
+ print(json.dumps({"run_id": args.run_id, "status": status_str}))
242
+ else:
243
+ print(f"Resumed run '{args.run_id}' completed with status: {status_str}")
244
+
245
+ return 0 if st == WorkflowStatus.SUCCEEDED else 1
246
+
247
+
248
+ def _cmd_status(args: argparse.Namespace) -> int:
249
+ try:
250
+ conn = get_connection(args.db)
251
+ st = get_run_status(conn, args.run_id)
252
+ except PersistenceError as exc:
253
+ print(f"Database error: {exc}", file=sys.stderr)
254
+ return 3
255
+ except OrchestrationError as exc:
256
+ print(f"Error: {exc}", file=sys.stderr)
257
+ return 2
258
+ except Exception as exc:
259
+ print(f"Internal error: {exc}", file=sys.stderr)
260
+ return 3
261
+
262
+ if args.json:
263
+ print(json.dumps({"run_id": args.run_id, "status": st.value}))
264
+ else:
265
+ print(f"Run {args.run_id}: {st.value}")
266
+ return 0
267
+
268
+
269
+ def _cmd_history(args: argparse.Namespace) -> int:
270
+ try:
271
+ conn = get_connection(args.db)
272
+ get_run_status(conn, args.run_id)
273
+ events = get_run_history(conn, args.run_id)
274
+ except PersistenceError as exc:
275
+ print(f"Database error: {exc}", file=sys.stderr)
276
+ return 3
277
+ except OrchestrationError as exc:
278
+ print(f"Error: {exc}", file=sys.stderr)
279
+ return 2
280
+ except Exception as exc:
281
+ print(f"Internal error: {exc}", file=sys.stderr)
282
+ return 3
283
+
284
+ if args.json:
285
+ output_events = [
286
+ {
287
+ "id": row[0],
288
+ "workflow_id": row[1],
289
+ "task_id": row[2],
290
+ "old_state": row[3],
291
+ "new_state": row[4],
292
+ "timestamp": row[5],
293
+ "attempt_count": row[6],
294
+ }
295
+ for row in events
296
+ ]
297
+ print(json.dumps(output_events, indent=2))
298
+ else:
299
+ if not events:
300
+ print(f"No history events recorded for run '{args.run_id}'.")
301
+ else:
302
+ for row in events:
303
+ print(f"[{row[5]:.2f}] Task '{row[2]}': {row[3]} -> {row[4]} (attempt {row[6]})")
304
+ return 0
305
+
306
+
307
+ def _cmd_runs(args: argparse.Namespace) -> int:
308
+ try:
309
+ conn = get_connection(args.db)
310
+ runs_list = list_runs(conn, workflow_id=args.workflow)
311
+ except PersistenceError as exc:
312
+ print(f"Database error: {exc}", file=sys.stderr)
313
+ return 3
314
+ except Exception as exc:
315
+ print(f"Internal error: {exc}", file=sys.stderr)
316
+ return 3
317
+
318
+ if args.json:
319
+ runs_data = [
320
+ {
321
+ "run_id": row[0],
322
+ "workflow_id": row[1],
323
+ "started_at": row[2],
324
+ "finished_at": row[3],
325
+ "status": row[4],
326
+ }
327
+ for row in runs_list
328
+ ]
329
+ print(json.dumps(runs_data, indent=2))
330
+ else:
331
+ if not runs_list:
332
+ print("No runs found.")
333
+ else:
334
+ for row in runs_list:
335
+ print(
336
+ f"{row[0]} workflow={row[1]} status={row[4]} "
337
+ f"started={row[2]} finished={row[3]}"
338
+ )
339
+ return 0
340
+
341
+
342
+ def build_parser() -> argparse.ArgumentParser:
343
+ common = argparse.ArgumentParser(add_help=False)
344
+ common.add_argument(
345
+ "--log-level",
346
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
347
+ type=str.upper,
348
+ help="Configure log level for orchestration_engine.",
349
+ )
350
+
351
+ parser = argparse.ArgumentParser(
352
+ prog="orchestration-engine",
353
+ description="Fault-tolerant, dependency-aware workflow orchestration engine.",
354
+ parents=[common],
355
+ )
356
+
357
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
358
+
359
+ # validate
360
+ val_p = subparsers.add_parser(
361
+ "validate", parents=[common], help="Validate a workflow definition file."
362
+ )
363
+ val_p.add_argument("workflow_file", help="Path to workflow JSON or YAML file.")
364
+ val_p.add_argument("--json", action="store_true", help="Output result as JSON.")
365
+ val_p.set_defaults(func=_cmd_validate)
366
+
367
+ # run
368
+ run_p = subparsers.add_parser("run", parents=[common], help="Run a workflow to completion.")
369
+ run_p.add_argument("workflow_file", help="Path to workflow JSON or YAML file.")
370
+ run_p.add_argument(
371
+ "--runner", required=True, help="Task runner callable in 'module:function' format."
372
+ )
373
+ run_p.add_argument(
374
+ "--db", default=None, help="Path to SQLite database (defaults to in-memory)."
375
+ )
376
+ run_p.add_argument("--run-id", default=None, help="Custom identifier for this run.")
377
+ run_p.add_argument(
378
+ "--max-concurrency", type=int, default=1, help="Maximum concurrent tasks (default: 1)."
379
+ )
380
+ run_p.add_argument(
381
+ "--executor",
382
+ choices=["thread", "process"],
383
+ default="thread",
384
+ help="Coordinator executor backend.",
385
+ )
386
+ run_p.add_argument("--json", action="store_true", help="Output result as JSON.")
387
+ run_p.set_defaults(func=_cmd_run)
388
+
389
+ # resume
390
+ res_p = subparsers.add_parser(
391
+ "resume", parents=[common], help="Resume an interrupted run."
392
+ )
393
+ res_p.add_argument("run_id", help="Run identifier to resume.")
394
+ res_p.add_argument(
395
+ "--runner", required=True, help="Task runner callable in 'module:function' format."
396
+ )
397
+ res_p.add_argument("--db", required=True, help="Path to SQLite database.")
398
+ res_p.add_argument(
399
+ "--max-concurrency", type=int, default=1, help="Maximum concurrent tasks (default: 1)."
400
+ )
401
+ res_p.add_argument(
402
+ "--executor",
403
+ choices=["thread", "process"],
404
+ default="thread",
405
+ help="Coordinator executor backend.",
406
+ )
407
+ res_p.add_argument("--json", action="store_true", help="Output result as JSON.")
408
+ res_p.set_defaults(func=_cmd_resume)
409
+
410
+ # status
411
+ stat_p = subparsers.add_parser("status", parents=[common], help="Check run status.")
412
+ stat_p.add_argument("run_id", help="Run identifier.")
413
+ stat_p.add_argument("--db", required=True, help="Path to SQLite database.")
414
+ stat_p.add_argument("--json", action="store_true", help="Output result as JSON.")
415
+ stat_p.set_defaults(func=_cmd_status)
416
+
417
+ # history
418
+ hist_p = subparsers.add_parser(
419
+ "history", parents=[common], help="Show task transition history."
420
+ )
421
+ hist_p.add_argument("run_id", help="Run identifier.")
422
+ hist_p.add_argument("--db", required=True, help="Path to SQLite database.")
423
+ hist_p.add_argument("--json", action="store_true", help="Output result as JSON.")
424
+ hist_p.set_defaults(func=_cmd_history)
425
+
426
+ # runs
427
+ runs_p = subparsers.add_parser("runs", parents=[common], help="List workflow runs.")
428
+ runs_p.add_argument("--db", required=True, help="Path to SQLite database.")
429
+ runs_p.add_argument("--workflow", default=None, help="Filter runs by workflow identifier.")
430
+ runs_p.add_argument("--json", action="store_true", help="Output result as JSON.")
431
+ runs_p.set_defaults(func=_cmd_runs)
432
+
433
+ return parser
434
+
435
+
436
+ def main(argv: list[str] | None = None) -> int:
437
+ """Entry point for the orchestration-engine CLI."""
438
+ parser = build_parser()
439
+ try:
440
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
441
+ except SystemExit as exc:
442
+ return int(exc.code) if isinstance(exc.code, int) else 2
443
+
444
+ if args.log_level:
445
+ logging.basicConfig(
446
+ level=getattr(logging, args.log_level.upper()),
447
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
448
+ )
449
+
450
+ if not hasattr(args, "func"):
451
+ parser.print_help(sys.stderr)
452
+ return 2
453
+
454
+ return int(args.func(args))
@@ -0,0 +1,23 @@
1
+ """
2
+ Error hierarchy for orchestration engine.
3
+ """
4
+
5
+
6
+ class OrchestrationError(Exception):
7
+ """Base class for all orchestration engine exceptions."""
8
+
9
+
10
+ class WorkflowDefinitionError(OrchestrationError, ValueError):
11
+ """Raised when a workflow definition is invalid, incomplete, or malformed."""
12
+
13
+ def __init__(self, message: str, errors: list[str] | None = None) -> None:
14
+ super().__init__(message)
15
+ self.errors: list[str] = list(errors or [])
16
+
17
+
18
+ class WorkflowValidationError(OrchestrationError, ValueError):
19
+ """Raised when a workflow fails validation (cycles, invalid IDs, self-dependencies)."""
20
+
21
+
22
+ class PersistenceError(OrchestrationError):
23
+ """Raised when a persistence or database operation fails."""