yaml-workflow 0.1.2__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,5 @@
1
+ """
2
+ YAML Workflow Engine - A simple workflow engine using YAML configuration
3
+ """
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
yaml_workflow/cli.py ADDED
@@ -0,0 +1,590 @@
1
+ """
2
+ Command-line interface for the workflow engine.
3
+ """
4
+
5
+ import argparse
6
+ import importlib.resources
7
+ import json
8
+ import logging
9
+ import shutil
10
+ import sys
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ import yaml
16
+
17
+ from . import __version__ # Import version
18
+ from .engine import WorkflowEngine
19
+ from .exceptions import WorkflowError
20
+ from .workspace import get_workspace_info
21
+
22
+
23
+ class WorkflowArgumentParser(argparse.ArgumentParser):
24
+ """Custom argument parser that handles workflow parameters."""
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self.workflow_params = []
29
+
30
+ def error(self, message):
31
+ """Custom error handling for workflow parameters."""
32
+ if "unrecognized arguments" in message:
33
+ # Check if the unrecognized argument is a parameter
34
+ args = message.split(": ")[-1].split()
35
+ for arg in args:
36
+ # Skip standard flags like --version, --help
37
+ if arg in ["--version", "--help"]:
38
+ super().error(message)
39
+ return
40
+ if "=" in arg:
41
+ self.workflow_params.append(arg)
42
+ else:
43
+ # If it's not a parameter, raise an error
44
+ print(
45
+ f"Invalid parameter format: {arg}\nParameters must be in the format: name=value",
46
+ file=sys.stderr,
47
+ )
48
+ sys.exit(1)
49
+ else:
50
+ super().error(message)
51
+
52
+ def parse_args(self, args=None, namespace=None):
53
+ """Parse arguments and collect workflow parameters."""
54
+ self.workflow_params = []
55
+ args = super().parse_args(args, namespace)
56
+ if hasattr(args, "params"):
57
+ args.params.extend(self.workflow_params)
58
+ return args
59
+
60
+
61
+ def parse_params(args_list: List[str]) -> Dict[str, str]:
62
+ """Parse command line parameters."""
63
+ result = {}
64
+ for arg in args_list:
65
+ try:
66
+ name, value = arg.split("=", 1)
67
+ # Remove leading '--' if present
68
+ name = name.lstrip("-")
69
+ result[name.strip()] = value.strip()
70
+ except ValueError:
71
+ raise ValueError(
72
+ f"Invalid parameter format: {arg}\nParameters must be in the format: name=value"
73
+ )
74
+ return result
75
+
76
+
77
+ def run_workflow(args):
78
+ """Run a workflow."""
79
+ try:
80
+ try:
81
+ param_dict = parse_params(args.params)
82
+ except ValueError as e:
83
+ print(str(e), file=sys.stderr)
84
+ sys.exit(1)
85
+
86
+ # If resuming, check the existing workspace first
87
+ if args.resume and args.workspace:
88
+ workspace_path = Path(args.workspace)
89
+ if workspace_path.exists():
90
+ metadata_path = workspace_path / ".workflow_metadata.json"
91
+ if metadata_path.exists():
92
+ try:
93
+ with open(metadata_path) as f:
94
+ metadata = json.load(f)
95
+ except json.JSONDecodeError as e:
96
+ raise ValueError(
97
+ f"Cannot resume: Invalid metadata file format - {str(e)}"
98
+ )
99
+ except Exception as e:
100
+ raise ValueError(
101
+ f"Cannot resume: Failed to read metadata file - {str(e)}"
102
+ )
103
+
104
+ if metadata.get("execution_state", {}).get("status") == "failed":
105
+ failed_step = metadata["execution_state"].get("failed_step")
106
+ if failed_step:
107
+ print(
108
+ f"Found failed workflow state, resuming from step: {failed_step['step_name']}"
109
+ )
110
+ else:
111
+ raise ValueError("No failed step found to resume from.")
112
+ else:
113
+ raise ValueError(
114
+ "Cannot resume: workflow is not in failed state"
115
+ )
116
+ else:
117
+ raise ValueError("Cannot resume: No workflow metadata found")
118
+ else:
119
+ raise ValueError("Cannot resume: Workspace directory not found")
120
+
121
+ # Create workflow engine
122
+ engine = WorkflowEngine(
123
+ workflow=args.workflow, workspace=args.workspace, base_dir=args.base_dir
124
+ )
125
+
126
+ # Parse skip steps
127
+ skip_step_list = []
128
+ if args.skip_steps:
129
+ skip_step_list = [step.strip() for step in args.skip_steps.split(",")]
130
+ print(f"Skipping steps: {', '.join(skip_step_list)}")
131
+
132
+ # Handle start-from and resume logic
133
+ start_from_step = None
134
+ resume_from = None
135
+
136
+ # Check start-from first (takes precedence)
137
+ if args.start_from:
138
+ start_from_step = args.start_from
139
+ print(f"Starting workflow from step: {start_from_step}")
140
+ # Check resume flag - only if workflow is in failed state
141
+ elif args.resume:
142
+ state = engine.state
143
+ if state.metadata["execution_state"]["status"] == "failed":
144
+ failed_step = state.metadata["execution_state"]["failed_step"]
145
+ if failed_step:
146
+ resume_from = failed_step["step_name"]
147
+ print(f"Resuming workflow from failed step: {resume_from}")
148
+ else:
149
+ raise ValueError("No failed step found to resume from.")
150
+ else:
151
+ raise ValueError("Cannot resume: workflow is not in failed state")
152
+
153
+ # Run workflow with appropriate parameters
154
+ results = engine.run(
155
+ param_dict,
156
+ resume_from=resume_from,
157
+ start_from=start_from_step,
158
+ skip_steps=skip_step_list,
159
+ flow=args.flow,
160
+ )
161
+
162
+ # Print completion status
163
+ print("\n=== Workflow Status ===")
164
+ if resume_from:
165
+ print(f"✓ Workflow resumed from '{resume_from}' and completed successfully")
166
+ elif start_from_step:
167
+ print(
168
+ f"✓ Workflow started from '{start_from_step}' and completed successfully"
169
+ )
170
+ else:
171
+ print("✓ Workflow completed successfully")
172
+
173
+ if skip_step_list:
174
+ print(f"• Skipped steps: {', '.join(skip_step_list)}")
175
+ if args.flow:
176
+ print(f"• Flow executed: {args.flow}")
177
+
178
+ # Print step outputs in a clean format
179
+ if results.get("outputs"):
180
+ print("\n=== Step Outputs ===")
181
+ for step_name, output in results["outputs"].items():
182
+ # Skip empty outputs or None values
183
+ if output is None or (isinstance(output, str) and not output.strip()):
184
+ continue
185
+ print(f"\n• {step_name}:")
186
+ if isinstance(output, (dict, list)):
187
+ formatted_output = json.dumps(output, indent=2)
188
+ print(" " + formatted_output.replace("\n", "\n "))
189
+ else:
190
+ print(" " + str(output).replace("\n", "\n "))
191
+
192
+ print("\n=== Workspace Info ===")
193
+ print(f"• Location: {engine.workspace}")
194
+ # Get run number from the workspace metadata
195
+ run_number = engine.state.metadata.get("run_number", "unknown")
196
+ print(f"• Run number: {run_number}")
197
+
198
+ except WorkflowError as e:
199
+ print(f"Workflow error: {str(e)}", file=sys.stderr)
200
+ sys.exit(1)
201
+ except Exception as e:
202
+ print(f"Error: {str(e)}", file=sys.stderr)
203
+ sys.exit(1)
204
+
205
+
206
+ def list_workflows(args):
207
+ """List available workflows."""
208
+ workflow_dir = Path(args.base_dir)
209
+ if not workflow_dir.exists():
210
+ print(f"Directory not found: {workflow_dir}", file=sys.stderr)
211
+ sys.exit(1)
212
+
213
+ print("\nAvailable workflows:")
214
+ # Recursively find all .yaml files
215
+ found = False
216
+ for workflow in sorted(workflow_dir.rglob("*.yaml")):
217
+ try:
218
+ # Try to load the file to verify it's a valid workflow
219
+ with open(workflow) as f:
220
+ content = yaml.safe_load(f)
221
+
222
+ # Handle both top-level workflow and direct steps format
223
+ if isinstance(content, dict):
224
+ if "workflow" in content:
225
+ content = content["workflow"]
226
+
227
+ # Check if it's a valid workflow file
228
+ if "steps" in content:
229
+ name = content.get("usage", {}).get("name") or workflow.stem
230
+ desc = content.get("usage", {}).get(
231
+ "description", "No description available"
232
+ )
233
+ print(f"\n- {workflow.relative_to(workflow_dir)}")
234
+ print(f" Name: {name}")
235
+ print(f" Description: {desc}")
236
+ found = True
237
+
238
+ except Exception:
239
+ # Skip files that can't be parsed as YAML
240
+ continue
241
+
242
+ if not found:
243
+ print(
244
+ "No workflow files found. Workflows should be YAML files containing 'steps' section."
245
+ )
246
+ print(
247
+ f"\nMake sure you have workflow YAML files in the '{workflow_dir}' directory."
248
+ )
249
+ print("You can specify a different directory with --base-dir option.")
250
+ print()
251
+
252
+
253
+ def validate_workflow(args):
254
+ """Validate a workflow file."""
255
+ try:
256
+ # Just try to create the engine, which will validate the workflow
257
+ WorkflowEngine(args.workflow)
258
+ print("Workflow validation successful")
259
+ except Exception as e:
260
+ print(f"Validation failed: {e}", file=sys.stderr)
261
+ sys.exit(1)
262
+
263
+
264
+ def list_workspaces(args):
265
+ """List workflow run directories."""
266
+ base_dir_path = Path(args.base_dir)
267
+ if not base_dir_path.exists():
268
+ print(f"Base directory not found: {base_dir_path}", file=sys.stderr)
269
+ sys.exit(1)
270
+
271
+ # Get all run directories
272
+ runs = []
273
+ pattern = f"*_run_*" if not args.workflow else f"{args.workflow}_run_*"
274
+
275
+ for run_dir in base_dir_path.glob(pattern):
276
+ if run_dir.is_dir():
277
+ try:
278
+ info = get_workspace_info(run_dir)
279
+ runs.append(
280
+ {
281
+ "name": run_dir.name,
282
+ "created": datetime.fromisoformat(info["created_at"]),
283
+ "size": info["size"],
284
+ "files": info["files"],
285
+ }
286
+ )
287
+ except Exception as e:
288
+ print(f"Warning: Could not get info for {run_dir}: {e}")
289
+
290
+ # Sort by creation time
291
+ runs.sort(key=lambda x: x["created"], reverse=True)
292
+
293
+ if not runs:
294
+ print("No workflow runs found.")
295
+ return
296
+
297
+ print("\nWorkflow runs:")
298
+ for run in runs:
299
+ size_mb = run["size"] / (1024 * 1024)
300
+ age = datetime.now() - run["created"]
301
+ print(f"- {run['name']}")
302
+ print(f" Created: {run['created'].isoformat()} ({age.days} days ago)")
303
+ print(f" Size: {size_mb:.1f} MB")
304
+ print(f" Files: {run['files']}")
305
+ print()
306
+
307
+
308
+ def clean_workspaces(args):
309
+ """Clean up old workflow runs."""
310
+ base_dir_path = Path(args.base_dir)
311
+ if not base_dir_path.exists():
312
+ print(f"Base directory not found: {base_dir_path}", file=sys.stderr)
313
+ sys.exit(1)
314
+
315
+ cutoff = datetime.now() - timedelta(days=args.older_than)
316
+ pattern = f"*_run_*" if not args.workflow else f"{args.workflow}_run_*"
317
+
318
+ to_delete = []
319
+ for run_dir in base_dir_path.glob(pattern):
320
+ if run_dir.is_dir():
321
+ try:
322
+ info = get_workspace_info(run_dir)
323
+ created = datetime.fromisoformat(info["created_at"])
324
+ if created < cutoff:
325
+ to_delete.append((run_dir, info))
326
+ except Exception as e:
327
+ print(f"Warning: Could not process {run_dir}: {e}")
328
+
329
+ if not to_delete:
330
+ print("No old workflow runs to clean up.")
331
+ return
332
+
333
+ print("\nWorkflow runs to remove:")
334
+ total_size = 0
335
+ for run_dir, info in to_delete:
336
+ size_mb = info["size"] / (1024 * 1024)
337
+ total_size += info["size"]
338
+ age = datetime.now() - datetime.fromisoformat(info["created_at"])
339
+ print(f"- {run_dir.name}")
340
+ print(f" Age: {age.days} days")
341
+ print(f" Size: {size_mb:.1f} MB")
342
+
343
+ total_size_mb = total_size / (1024 * 1024)
344
+ print(f"\nTotal space to be freed: {total_size_mb:.1f} MB")
345
+
346
+ if not args.dry_run:
347
+ for run_dir, _ in to_delete:
348
+ try:
349
+ shutil.rmtree(run_dir)
350
+ print(f"Removed: {run_dir}")
351
+ except Exception as e:
352
+ print(f"Error removing {run_dir}: {e}")
353
+ else:
354
+ print("\nDry run - no files were deleted")
355
+
356
+
357
+ def remove_workspaces(args):
358
+ """Remove specific workflow runs."""
359
+ base_dir_path = Path(args.base_dir)
360
+ if not base_dir_path.exists():
361
+ print(f"Base directory not found: {base_dir_path}", file=sys.stderr)
362
+ sys.exit(1)
363
+
364
+ to_remove = []
365
+ for run_name in args.runs:
366
+ run_dir = base_dir_path / run_name
367
+ if not run_dir.exists():
368
+ print(f"Warning: Run directory not found: {run_dir}")
369
+ continue
370
+ if not run_dir.is_dir():
371
+ print(f"Warning: Not a directory: {run_dir}")
372
+ continue
373
+ to_remove.append(run_dir)
374
+
375
+ if not to_remove:
376
+ print("No valid run directories to remove.")
377
+ return
378
+
379
+ print("\nWorkflow runs to remove:")
380
+ total_size = 0
381
+ for run_dir in to_remove:
382
+ try:
383
+ info = get_workspace_info(run_dir)
384
+ size_mb = info["size"] / (1024 * 1024)
385
+ total_size += info["size"]
386
+ print(f"- {run_dir.name}")
387
+ print(f" Size: {size_mb:.1f} MB")
388
+ print(f" Files: {info['files']}")
389
+ except Exception as e:
390
+ print(f"Warning: Could not get info for {run_dir}: {e}")
391
+
392
+ total_size_mb = total_size / (1024 * 1024)
393
+ print(f"\nTotal space to be freed: {total_size_mb:.1f} MB")
394
+
395
+ if not args.force:
396
+ response = input("\nAre you sure you want to remove these runs? [y/N] ")
397
+ if response.lower() != "y":
398
+ print("Operation cancelled.")
399
+ return
400
+
401
+ for run_dir in to_remove:
402
+ try:
403
+ shutil.rmtree(run_dir)
404
+ print(f"Removed: {run_dir}")
405
+ except Exception as e:
406
+ print(f"Error removing {run_dir}: {e}")
407
+
408
+
409
+ def init_project(args):
410
+ """Initialize a new project with example workflows."""
411
+ try:
412
+ # Create target directory if it doesn't exist
413
+ target_dir = Path(args.dir)
414
+ target_dir.mkdir(parents=True, exist_ok=True)
415
+
416
+ # Get examples directory from package
417
+ examples_dir = Path(__file__).parent / "examples"
418
+
419
+ if args.example:
420
+ # Copy specific example
421
+ example_file = examples_dir / f"{args.example}.yaml"
422
+ if not example_file.exists():
423
+ print(f"Example '{args.example}' not found", file=sys.stderr)
424
+ sys.exit(1)
425
+ shutil.copy2(example_file, target_dir)
426
+ print(f"Initialized project with example: {args.example}")
427
+ else:
428
+ # Copy all examples
429
+ for example in examples_dir.glob("*.yaml"):
430
+ shutil.copy2(example, target_dir)
431
+ print(f"Initialized project with examples in: {target_dir}")
432
+
433
+ except Exception as e:
434
+ print(f"Error initializing project: {e}", file=sys.stderr)
435
+ sys.exit(1)
436
+
437
+
438
+ def main():
439
+ """Main entry point for the CLI."""
440
+ parser = WorkflowArgumentParser(description="YAML Workflow Engine CLI")
441
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
442
+ parser.description = f"""YAML Workflow Engine CLI v{__version__}
443
+
444
+ Commands:
445
+ run Run a workflow
446
+ list List available workflows
447
+ validate Validate a workflow file
448
+ workspace Workspace management commands
449
+ init Initialize a new project with example workflows
450
+ """
451
+ parser.add_argument(
452
+ "--version",
453
+ action="version",
454
+ version=f"%(prog)s {__version__}",
455
+ help="Show program version and exit",
456
+ )
457
+
458
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
459
+
460
+ # Run command
461
+ run_parser = subparsers.add_parser("run", help="Run a workflow", add_help=True)
462
+ run_parser.add_argument("workflow", help="Path to workflow file")
463
+ run_parser.add_argument("--workspace", help="Custom workspace directory")
464
+ run_parser.add_argument(
465
+ "--base-dir", default="runs", help="Base directory for workflow runs"
466
+ )
467
+ run_parser.add_argument(
468
+ "--resume", action="store_true", help="Resume workflow from last failed step"
469
+ )
470
+ run_parser.add_argument(
471
+ "--start-from", help="Start workflow execution from specified step"
472
+ )
473
+ run_parser.add_argument(
474
+ "--skip-steps", help="Comma-separated list of steps to skip during execution"
475
+ )
476
+ run_parser.add_argument(
477
+ "--flow",
478
+ help="Name of the flow to execute (default: use flow specified in workflow file)",
479
+ )
480
+ run_parser.add_argument(
481
+ "params", nargs="*", help="Parameters in the format name=value or --name=value"
482
+ )
483
+
484
+ # List command
485
+ list_parser = subparsers.add_parser("list", help="List available workflows")
486
+ list_parser.add_argument(
487
+ "--base-dir", default="workflows", help="Base directory containing workflows"
488
+ )
489
+
490
+ # Validate command
491
+ validate_parser = subparsers.add_parser("validate", help="Validate a workflow file")
492
+ validate_parser.add_argument("workflow", help="Path to workflow file")
493
+
494
+ # Workspace commands
495
+ workspace_parser = subparsers.add_parser(
496
+ "workspace", help="Workspace management commands"
497
+ )
498
+ workspace_subparsers = workspace_parser.add_subparsers(
499
+ dest="workspace_command", help="Workspace commands"
500
+ )
501
+
502
+ # Workspace list command
503
+ workspace_list_parser = workspace_subparsers.add_parser(
504
+ "list", help="List workflow run directories"
505
+ )
506
+ workspace_list_parser.add_argument(
507
+ "--base-dir", "-b", default="runs", help="Base directory for workflow runs"
508
+ )
509
+ workspace_list_parser.add_argument(
510
+ "--workflow", "-w", help="Filter by workflow name"
511
+ )
512
+
513
+ # Workspace clean command
514
+ workspace_clean_parser = workspace_subparsers.add_parser(
515
+ "clean", help="Clean up old workflow runs"
516
+ )
517
+ workspace_clean_parser.add_argument(
518
+ "--base-dir", "-b", default="runs", help="Base directory for workflow runs"
519
+ )
520
+ workspace_clean_parser.add_argument(
521
+ "--older-than", "-o", type=int, default=30, help="Remove runs older than N days"
522
+ )
523
+ workspace_clean_parser.add_argument(
524
+ "--workflow", "-w", help="Clean only runs of this workflow"
525
+ )
526
+ workspace_clean_parser.add_argument(
527
+ "--dry-run",
528
+ "-n",
529
+ action="store_true",
530
+ help="Show what would be deleted without actually deleting",
531
+ )
532
+
533
+ # Workspace remove command
534
+ workspace_remove_parser = workspace_subparsers.add_parser(
535
+ "remove", help="Remove specific workflow runs"
536
+ )
537
+ workspace_remove_parser.add_argument(
538
+ "runs", nargs="+", help="Names of runs to remove"
539
+ )
540
+ workspace_remove_parser.add_argument(
541
+ "--base-dir", "-b", default="runs", help="Base directory for workflow runs"
542
+ )
543
+ workspace_remove_parser.add_argument(
544
+ "--force", "-f", action="store_true", help="Don't ask for confirmation"
545
+ )
546
+
547
+ # Init command
548
+ init_parser = subparsers.add_parser(
549
+ "init", help="Initialize a new project with example workflows"
550
+ )
551
+ init_parser.add_argument(
552
+ "--dir", default="workflows", help="Directory to create workflows in"
553
+ )
554
+ init_parser.add_argument("--example", help="Specific example workflow to copy")
555
+
556
+ args = parser.parse_args()
557
+
558
+ if not args.command:
559
+ parser.print_help()
560
+ sys.exit(1)
561
+
562
+ try:
563
+ if args.command == "run":
564
+ run_workflow(args)
565
+ elif args.command == "list":
566
+ list_workflows(args)
567
+ elif args.command == "validate":
568
+ validate_workflow(args)
569
+ elif args.command == "workspace":
570
+ if args.workspace_command == "list":
571
+ list_workspaces(args)
572
+ elif args.workspace_command == "clean":
573
+ clean_workspaces(args)
574
+ elif args.workspace_command == "remove":
575
+ remove_workspaces(args)
576
+ else:
577
+ workspace_parser.print_help()
578
+ sys.exit(1)
579
+ elif args.command == "init":
580
+ init_project(args)
581
+ except KeyboardInterrupt:
582
+ print("\nOperation cancelled by user", file=sys.stderr)
583
+ sys.exit(1)
584
+ except Exception as e:
585
+ print(f"Error: {str(e)}", file=sys.stderr)
586
+ sys.exit(1)
587
+
588
+
589
+ if __name__ == "__main__":
590
+ main()