Skip to content

Commit 1054708

Browse files
committed
Address fifth review: expression parsing, fan-out output, URL install, gate options
- Move string literal parsing before operator detection in expressions so quoted strings with operators (e.g. 'a in b') are not mis-parsed - Fan-out: remove max_concurrency from persisted output, fix docstring to reflect sequential execution - workflow add: support URL sources with HTTPS/redirect validation, validate workflow ID is non-empty before writing files - Deduplicate local install logic via _validate_and_install_local() - Remove 'edit' gate option from speckit workflow (not implemented)
1 parent 3942369 commit 1054708

5 files changed

Lines changed: 92 additions & 52 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 71 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4294,53 +4294,87 @@ def workflow_add(
42944294
registry = WorkflowRegistry(project_root)
42954295
workflows_dir = project_root / ".specify" / "workflows"
42964296

4297+
def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
4298+
"""Validate and install a workflow from a local YAML file."""
4299+
try:
4300+
definition = WorkflowDefinition.from_yaml(yaml_path)
4301+
except (ValueError, yaml.YAMLError) as exc:
4302+
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
4303+
raise typer.Exit(1)
4304+
if not definition.id or not definition.id.strip():
4305+
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
4306+
raise typer.Exit(1)
4307+
dest_dir = workflows_dir / definition.id
4308+
dest_dir.mkdir(parents=True, exist_ok=True)
4309+
import shutil
4310+
shutil.copy2(yaml_path, dest_dir / "workflow.yml")
4311+
registry.add(definition.id, {
4312+
"name": definition.name,
4313+
"version": definition.version,
4314+
"description": definition.description,
4315+
"source": source_label,
4316+
})
4317+
console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed")
4318+
4319+
# Try as URL (http/https)
4320+
if source.startswith("http://") or source.startswith("https://"):
4321+
from ipaddress import ip_address
4322+
from urllib.parse import urlparse
4323+
from urllib.request import urlopen # noqa: S310
4324+
4325+
parsed_src = urlparse(source)
4326+
src_host = parsed_src.hostname or ""
4327+
src_loopback = src_host == "localhost"
4328+
if not src_loopback:
4329+
try:
4330+
src_loopback = ip_address(src_host).is_loopback
4331+
except ValueError:
4332+
pass
4333+
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
4334+
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
4335+
raise typer.Exit(1)
4336+
4337+
import tempfile
4338+
try:
4339+
with urlopen(source, timeout=30) as resp: # noqa: S310
4340+
final_url = resp.geturl()
4341+
final_parsed = urlparse(final_url)
4342+
final_host = final_parsed.hostname or ""
4343+
final_lb = final_host == "localhost"
4344+
if not final_lb:
4345+
try:
4346+
final_lb = ip_address(final_host).is_loopback
4347+
except ValueError:
4348+
pass
4349+
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
4350+
console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}")
4351+
raise typer.Exit(1)
4352+
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
4353+
tmp.write(resp.read())
4354+
tmp_path = Path(tmp.name)
4355+
except typer.Exit:
4356+
raise
4357+
except Exception as exc:
4358+
console.print(f"[red]Error:[/red] Failed to download workflow: {exc}")
4359+
raise typer.Exit(1)
4360+
try:
4361+
_validate_and_install_local(tmp_path, source)
4362+
finally:
4363+
tmp_path.unlink(missing_ok=True)
4364+
return
4365+
42974366
# Try as a local file/directory
42984367
source_path = Path(source)
42994368
if source_path.exists():
43004369
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
4301-
# Install from local YAML file
4302-
try:
4303-
definition = WorkflowDefinition.from_yaml(source_path)
4304-
except (ValueError, yaml.YAMLError) as exc:
4305-
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
4306-
raise typer.Exit(1)
4307-
4308-
dest_dir = workflows_dir / definition.id
4309-
dest_dir.mkdir(parents=True, exist_ok=True)
4310-
import shutil
4311-
shutil.copy2(source_path, dest_dir / "workflow.yml")
4312-
4313-
registry.add(definition.id, {
4314-
"name": definition.name,
4315-
"version": definition.version,
4316-
"description": definition.description,
4317-
"source": str(source_path),
4318-
})
4319-
console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed")
4370+
_validate_and_install_local(source_path, str(source_path))
43204371
return
43214372
elif source_path.is_dir():
43224373
wf_file = source_path / "workflow.yml"
43234374
if not wf_file.exists():
43244375
console.print(f"[red]Error:[/red] No workflow.yml found in {source}")
43254376
raise typer.Exit(1)
4326-
try:
4327-
definition = WorkflowDefinition.from_yaml(wf_file)
4328-
except (ValueError, yaml.YAMLError) as exc:
4329-
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
4330-
raise typer.Exit(1)
4331-
4332-
dest_dir = workflows_dir / definition.id
4333-
dest_dir.mkdir(parents=True, exist_ok=True)
4334-
import shutil
4335-
shutil.copy2(wf_file, dest_dir / "workflow.yml")
4336-
4337-
registry.add(definition.id, {
4338-
"name": definition.name,
4339-
"version": definition.version,
4340-
"description": definition.description,
4341-
"source": str(source_path),
4342-
})
4343-
console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed")
4377+
_validate_and_install_local(wf_file, str(source_path))
43444378
return
43454379

43464380
# Try from catalog

src/specify_cli/workflows/engine.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,10 +626,13 @@ def _execute_steps(
626626
):
627627
break
628628
context.item = None
629-
# Update fan-out step output with collected results
630-
result.output["results"] = fan_out_results
631-
context.steps[step_id]["output"] = result.output
632-
state.step_results[step_id]["output"] = result.output
629+
# Fan-out items are executed sequentially in this engine,
630+
# so do not surface max_concurrency in persisted output.
631+
fan_out_output = dict(result.output)
632+
fan_out_output.pop("max_concurrency", None)
633+
fan_out_output["results"] = fan_out_results
634+
context.steps[step_id]["output"] = fan_out_output
635+
state.step_results[step_id]["output"] = fan_out_output
633636

634637
def _resolve_inputs(
635638
self,

src/specify_cli/workflows/expressions.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
143143
return _filter_default(value)
144144
return value
145145

146+
# String literal — check before operators so quoted strings
147+
# containing operator keywords (e.g. 'a in b') are not mis-parsed.
148+
if (expr.startswith("'") and expr.endswith("'")) or (
149+
expr.startswith('"') and expr.endswith('"')
150+
):
151+
return expr[1:-1]
152+
146153
# Boolean operators
147154
if " and " in expr:
148155
parts = expr.split(" and ", 1)
@@ -183,12 +190,6 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
183190
if op == " not in ":
184191
return left not in right if right is not None else True
185192

186-
# String literal
187-
if (expr.startswith("'") and expr.endswith("'")) or (
188-
expr.startswith('"') and expr.endswith('"')
189-
):
190-
return expr[1:-1]
191-
192193
# Numeric literal
193194
try:
194195
if "." in expr:

src/specify_cli/workflows/steps/fan_out/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Fan-out step — parallel dispatch over a collection."""
1+
"""Fan-out step — dispatch a step template over a collection."""
22

33
from __future__ import annotations
44

@@ -9,10 +9,12 @@
99

1010

1111
class FanOutStep(StepBase):
12-
"""Parallel dispatch over ``items:`` collection.
12+
"""Dispatch a step template for each item in a collection.
1313
14-
Iterates over items and dispatches the nested ``step:`` template
15-
for each item, up to ``max_concurrency:`` at a time.
14+
The engine executes the nested ``step:`` template once per item,
15+
setting ``context.item`` for each iteration. Execution is
16+
currently sequential; ``max_concurrency`` is accepted but not
17+
enforced.
1618
"""
1719

1820
type_key = "fan-out"

workflows/speckit/workflow.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ steps:
3131
- id: review-spec
3232
type: gate
3333
message: "Review the generated spec before planning."
34-
options: [approve, edit, reject]
34+
options: [approve, reject]
3535
on_reject: abort
3636

3737
- id: plan

0 commit comments

Comments
 (0)