flowsh-cli 0.4.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowsh-cli
3
- Version: 0.4.2
3
+ Version: 0.6.0
4
4
  Summary: Generate Bash harness scripts from workflow YAML files.
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "flowsh-cli"
7
- version = "0.4.2"
7
+ version = "0.6.0"
8
8
  description = "Generate Bash harness scripts from workflow YAML files."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,7 +3,7 @@
3
3
  from flowsh_cli.models import Workflow, WorkflowParseError, parse_workflows
4
4
  from flowsh_cli.render import harness_path, render_harness
5
5
 
6
- __version__ = "0.4.2"
6
+ __version__ = "0.6.0"
7
7
 
8
8
  __all__ = [
9
9
  "Workflow",
@@ -154,12 +154,52 @@ class VarsStep(BaseStep):
154
154
  return value
155
155
 
156
156
 
157
- Step = Annotated[VarsStep | BashStep | AgentStep, Field(discriminator="type")]
157
+ class ForStep(BaseStep):
158
+ type: Literal["for"]
159
+ in_: str = Field(..., validation_alias="in")
160
+ item: str = Field(...)
161
+ steps: list[Step] = Field(min_length=1)
162
+
163
+ @field_validator("in_", "item")
164
+ @classmethod
165
+ def validate_var_name(cls, value: str) -> str:
166
+ if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", value):
167
+ raise ValueError("must match ^[A-Z_][A-Z0-9_]*$")
168
+ return value
169
+
170
+
171
+ class ParallelStep(BaseStep):
172
+ type: Literal["parallel"]
173
+ steps: list[Step] = Field(min_length=1)
174
+
175
+
176
+ Step = Annotated[
177
+ VarsStep | BashStep | AgentStep | ForStep | ParallelStep,
178
+ Field(discriminator="type"),
179
+ ]
180
+
181
+
182
+ ForStep.model_rebuild()
183
+ ParallelStep.model_rebuild()
184
+
185
+
186
+ class WorkflowParam(StrictModel):
187
+ name: str
188
+ description: str | None = None
189
+ required: bool = False
190
+
191
+ @field_validator("name")
192
+ @classmethod
193
+ def validate_name(cls, value: str) -> str:
194
+ if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", value):
195
+ raise ValueError("must match ^[A-Z_][A-Z0-9_]*$")
196
+ return value
158
197
 
159
198
 
160
199
  class Workflow(StrictModel):
161
200
  id: str
162
201
  name: str
202
+ params: list[WorkflowParam] = []
163
203
  steps: list[Step]
164
204
 
165
205
  @field_validator("id")
@@ -3,7 +3,16 @@ from __future__ import annotations
3
3
  import re
4
4
  from pathlib import Path
5
5
 
6
- from flowsh_cli.models import AgentStep, BashStep, Step, VarsStep, Workflow
6
+ from flowsh_cli.models import (
7
+ AgentStep,
8
+ BashStep,
9
+ ForStep,
10
+ ParallelStep,
11
+ Step,
12
+ VarsStep,
13
+ Workflow,
14
+ WorkflowParam,
15
+ )
7
16
 
8
17
 
9
18
  def harness_path(workflow: Workflow) -> Path:
@@ -26,13 +35,7 @@ def render_harness(workflow: Workflow) -> str:
26
35
  'LOG_BASENAME="flowsh-${WORKFLOW_SLUG}-${LOG_TIMESTAMP}-$$.log"',
27
36
  "",
28
37
  section("Argument handling"),
29
- "DRY_RUN=false",
30
- 'if [[ $# -eq 1 && "$1" == "--dry-run" ]]; then',
31
- " DRY_RUN=true",
32
- "elif [[ $# -gt 0 ]]; then",
33
- ' printf "Usage: %s [--dry-run]\\n" "$0" >&2',
34
- " exit 2",
35
- "fi",
38
+ *_render_arg_block(workflow.params),
36
39
  "",
37
40
  section("refuse_symlink_path() - keep generated logs inside plain relative paths"),
38
41
  "refuse_symlink_path() {",
@@ -227,8 +230,64 @@ def render_harness(workflow: Workflow) -> str:
227
230
  def render_step(index: int, step: Step, used_function_names: set[str] | None = None) -> list[str]:
228
231
  function_name = step_function_name(index, step.name, used_function_names)
229
232
  title = step.name or default_step_title(index, step)
230
- lines = [section(f"Step {index} ({step.type}): {title}"), f"{function_name}() {{"]
231
233
 
234
+ prefix_lines: list[str] = []
235
+
236
+ if isinstance(step, ForStep):
237
+ inner_fns: list[str] = []
238
+ for i, inner_step in enumerate(step.steps, start=1):
239
+ inner_fn = _for_inner_function_name(index, i, inner_step, used_function_names)
240
+ inner_fns.append(inner_fn)
241
+ inner_title = inner_step.name or default_step_title(i, inner_step)
242
+ prefix_lines.append(section(f"For-inner step ({inner_step.type}): {inner_title}"))
243
+ prefix_lines.append(f"{inner_fn}() {{")
244
+ prefix_lines.extend(_render_step_body(inner_step))
245
+ prefix_lines.append("}")
246
+ prefix_lines.append("")
247
+
248
+ body_lines = [
249
+ f" while IFS= read -r {step.item}; do",
250
+ f" export {step.item}",
251
+ *[f" run_step {fn}" for fn in inner_fns],
252
+ f' done <<< "${{{step.in_}}}"',
253
+ ]
254
+ elif isinstance(step, ParallelStep):
255
+ child_fns: list[str] = []
256
+ for i, child in enumerate(step.steps, start=1):
257
+ child_title = child.name or default_step_title(i, child)
258
+ child_fn = step_function_name(i, child.name, used_function_names)
259
+ child_fns.append(child_fn)
260
+ prefix_lines.append(section(f"Parallel child {i} ({child.type}): {child_title}"))
261
+ prefix_lines.append(f"{child_fn}() {{")
262
+ prefix_lines.extend(_render_step_body(child))
263
+ prefix_lines.append("}")
264
+ prefix_lines.append("")
265
+
266
+ body_lines = [" local status=0"]
267
+ for child_fn in child_fns:
268
+ body_lines.append(f' "{child_fn}" &')
269
+ body_lines.append(f" local pid_{child_fn}=$!")
270
+ for child_fn in child_fns:
271
+ body_lines.append(f' wait "$pid_{child_fn}" || status=$?')
272
+ body_lines.append(' return "$status"')
273
+ else:
274
+ body_lines = _render_step_body(step)
275
+
276
+ runner = "run_stateful_step" if isinstance(step, (VarsStep, ForStep)) else "run_step"
277
+ outer_lines = [
278
+ section(f"Step {index} ({step.type}): {title}"),
279
+ f"{function_name}() {{",
280
+ *body_lines,
281
+ "}",
282
+ f"{runner} {function_name}",
283
+ "",
284
+ ]
285
+ return prefix_lines + outer_lines
286
+
287
+
288
+ def _render_step_body(step: Step) -> list[str]:
289
+ """Return indented body lines for a step function (no wrapper, no run_step call)."""
290
+ lines: list[str] = []
232
291
  if isinstance(step, VarsStep):
233
292
  lines.append(" local status=0")
234
293
  for name, command in step.values.items():
@@ -275,7 +334,6 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
275
334
  for var in seen:
276
335
  lines.append(f' _p=\'${{{var}}}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
277
336
  lines.append(f' _p=\'${var}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
278
-
279
337
  lines.append(f" local agent={bash_quote(step.agent or '')}")
280
338
  lines.append(f" local model={bash_quote(step.model or '')}")
281
339
  lines.append(f" local command={bash_quote(step.command or '')}")
@@ -284,14 +342,34 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
284
342
  lines.append(
285
343
  ' run_agent "$prompt" "$agent" "$model" "$command" "$dangerously_skip_permissions"'
286
344
  )
345
+ elif isinstance(step, ForStep):
346
+ raise AssertionError("nested for steps are not supported")
287
347
  else:
288
348
  raise AssertionError(f"Unsupported step type: {step}")
289
-
290
- runner = "run_stateful_step" if isinstance(step, VarsStep) else "run_step"
291
- lines.extend(["}", f"{runner} {function_name}", ""])
292
349
  return lines
293
350
 
294
351
 
352
+ def _for_inner_function_name(
353
+ outer_index: int,
354
+ inner_index: int,
355
+ step: Step,
356
+ used_function_names: set[str] | None,
357
+ ) -> str:
358
+ source = step.name or f"step_{inner_index}"
359
+ slug = re.sub(r"[^A-Za-z0-9_]+", "_", source).strip("_").lower()
360
+ if not slug or slug[0].isdigit():
361
+ slug = f"step_{slug}" if slug else f"step_{inner_index}"
362
+ base = f"for_{outer_index}_{slug}"
363
+ fn = base
364
+ suffix = 2
365
+ while used_function_names is not None and fn in used_function_names:
366
+ fn = f"{base}_{suffix}"
367
+ suffix += 1
368
+ if used_function_names is not None:
369
+ used_function_names.add(fn)
370
+ return fn
371
+
372
+
295
373
  def default_step_title(index: int, step: Step) -> str:
296
374
  if isinstance(step, VarsStep):
297
375
  return ", ".join(step.values.keys())
@@ -299,6 +377,10 @@ def default_step_title(index: int, step: Step) -> str:
299
377
  return truncate_one_line(step.run)
300
378
  if isinstance(step, AgentStep):
301
379
  return truncate_one_line(step.prompt)
380
+ if isinstance(step, ForStep):
381
+ return f"for {step.item} in {step.in_}"
382
+ if isinstance(step, ParallelStep):
383
+ return f"parallel ({len(step.steps)} steps)"
302
384
  return f"step {index}"
303
385
 
304
386
 
@@ -353,6 +435,69 @@ def bash_quote(value: str) -> str:
353
435
  return "'" + value.replace("'", "'\\''") + "'"
354
436
 
355
437
 
438
+ def _render_arg_block(params: list[WorkflowParam]) -> list[str]:
439
+ if not params:
440
+ return [
441
+ "DRY_RUN=false",
442
+ 'if [[ $# -eq 1 && "$1" == "--dry-run" ]]; then',
443
+ " DRY_RUN=true",
444
+ "elif [[ $# -gt 0 ]]; then",
445
+ ' printf "Usage: %s [--dry-run]\\n" "$0" >&2',
446
+ " exit 2",
447
+ "fi",
448
+ ]
449
+
450
+ usage_parts: list[str] = ["[--dry-run]"]
451
+ for p in params:
452
+ usage_parts.append(f"<{p.name}>" if p.required else f"[{p.name}]")
453
+ usage_str = " ".join(usage_parts)
454
+
455
+ lines: list[str] = [
456
+ "DRY_RUN=false",
457
+ "POSITIONAL_ARGS=()",
458
+ "",
459
+ "while [[ $# -gt 0 ]]; do",
460
+ ' case "$1" in',
461
+ " --dry-run)",
462
+ " DRY_RUN=true",
463
+ " shift",
464
+ " ;;",
465
+ " --*)",
466
+ ' printf "Unknown option: %s\\n" "$1" >&2',
467
+ " exit 2",
468
+ " ;;",
469
+ " *)",
470
+ ' POSITIONAL_ARGS+=("$1")',
471
+ " shift",
472
+ " ;;",
473
+ " esac",
474
+ "done",
475
+ "",
476
+ ]
477
+
478
+ for idx, param in enumerate(params):
479
+ lines += [
480
+ f"if [[ ${{#POSITIONAL_ARGS[@]}} -gt {idx} ]]; then",
481
+ f' {param.name}="${{POSITIONAL_ARGS[{idx}]}}"',
482
+ f" export {param.name}",
483
+ "fi",
484
+ ]
485
+
486
+ lines.append("")
487
+
488
+ required = [p for p in params if p.required]
489
+ if required:
490
+ for param in required:
491
+ lines += [
492
+ f'if [[ -z "${{{param.name}:-}}" ]]; then',
493
+ f' printf "Usage: %s {usage_str}\\n" "$0" >&2',
494
+ " exit 2",
495
+ "fi",
496
+ ]
497
+
498
+ return lines
499
+
500
+
356
501
  def section(title: str) -> str:
357
502
  safe_title = truncate_one_line(title, limit=120)
358
503
  return "\n".join(
File without changes