flowsh-cli 0.5.0__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.5.0
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.5.0"
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.5.0"
6
+ __version__ = "0.6.0"
7
7
 
8
8
  __all__ = [
9
9
  "Workflow",
@@ -154,7 +154,33 @@ 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()
158
184
 
159
185
 
160
186
  class WorkflowParam(StrictModel):
@@ -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, WorkflowParam
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:
@@ -221,8 +230,64 @@ def render_harness(workflow: Workflow) -> str:
221
230
  def render_step(index: int, step: Step, used_function_names: set[str] | None = None) -> list[str]:
222
231
  function_name = step_function_name(index, step.name, used_function_names)
223
232
  title = step.name or default_step_title(index, step)
224
- lines = [section(f"Step {index} ({step.type}): {title}"), f"{function_name}() {{"]
225
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] = []
226
291
  if isinstance(step, VarsStep):
227
292
  lines.append(" local status=0")
228
293
  for name, command in step.values.items():
@@ -269,7 +334,6 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
269
334
  for var in seen:
270
335
  lines.append(f' _p=\'${{{var}}}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
271
336
  lines.append(f' _p=\'${var}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
272
-
273
337
  lines.append(f" local agent={bash_quote(step.agent or '')}")
274
338
  lines.append(f" local model={bash_quote(step.model or '')}")
275
339
  lines.append(f" local command={bash_quote(step.command or '')}")
@@ -278,14 +342,34 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
278
342
  lines.append(
279
343
  ' run_agent "$prompt" "$agent" "$model" "$command" "$dangerously_skip_permissions"'
280
344
  )
345
+ elif isinstance(step, ForStep):
346
+ raise AssertionError("nested for steps are not supported")
281
347
  else:
282
348
  raise AssertionError(f"Unsupported step type: {step}")
283
-
284
- runner = "run_stateful_step" if isinstance(step, VarsStep) else "run_step"
285
- lines.extend(["}", f"{runner} {function_name}", ""])
286
349
  return lines
287
350
 
288
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
+
289
373
  def default_step_title(index: int, step: Step) -> str:
290
374
  if isinstance(step, VarsStep):
291
375
  return ", ".join(step.values.keys())
@@ -293,6 +377,10 @@ def default_step_title(index: int, step: Step) -> str:
293
377
  return truncate_one_line(step.run)
294
378
  if isinstance(step, AgentStep):
295
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)"
296
384
  return f"step {index}"
297
385
 
298
386
 
File without changes