fluxloop-cli 0.2.28__py3-none-any.whl → 0.2.30__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.
- fluxloop_cli/__init__.py +1 -1
- fluxloop_cli/commands/init.py +68 -0
- fluxloop_cli/commands/run.py +124 -134
- fluxloop_cli/templates.py +25 -0
- fluxloop_cli/testing/__init__.py +24 -0
- fluxloop_cli/testing/pytest_plugin.py +421 -0
- fluxloop_cli/testing/types.py +188 -0
- {fluxloop_cli-0.2.28.dist-info → fluxloop_cli-0.2.30.dist-info}/METADATA +12 -1
- {fluxloop_cli-0.2.28.dist-info → fluxloop_cli-0.2.30.dist-info}/RECORD +12 -9
- {fluxloop_cli-0.2.28.dist-info → fluxloop_cli-0.2.30.dist-info}/WHEEL +0 -0
- {fluxloop_cli-0.2.28.dist-info → fluxloop_cli-0.2.30.dist-info}/entry_points.txt +0 -0
- {fluxloop_cli-0.2.28.dist-info → fluxloop_cli-0.2.30.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py
CHANGED
fluxloop_cli/commands/init.py
CHANGED
|
@@ -18,6 +18,7 @@ from ..templates import (
|
|
|
18
18
|
create_sample_agent,
|
|
19
19
|
create_gitignore,
|
|
20
20
|
create_env_file,
|
|
21
|
+
create_pytest_bridge_template,
|
|
21
22
|
)
|
|
22
23
|
from ..project_paths import resolve_root_dir, resolve_project_dir
|
|
23
24
|
from ..constants import (
|
|
@@ -191,6 +192,73 @@ def project(
|
|
|
191
192
|
console.print("8. Diagnose environment anytime: [green]fluxloop doctor[/green]")
|
|
192
193
|
|
|
193
194
|
|
|
195
|
+
@app.command("pytest-template")
|
|
196
|
+
def pytest_template(
|
|
197
|
+
project_root: Path = typer.Argument(
|
|
198
|
+
Path.cwd(),
|
|
199
|
+
help="Project root containing configs/ or setting.yaml",
|
|
200
|
+
),
|
|
201
|
+
tests_dir: str = typer.Option(
|
|
202
|
+
"tests",
|
|
203
|
+
"--tests-dir",
|
|
204
|
+
help="Directory (relative to project root) where tests live",
|
|
205
|
+
),
|
|
206
|
+
filename: str = typer.Option(
|
|
207
|
+
"test_fluxloop_smoke.py",
|
|
208
|
+
"--filename",
|
|
209
|
+
help="Test file name to create inside the tests directory",
|
|
210
|
+
),
|
|
211
|
+
force: bool = typer.Option(
|
|
212
|
+
False,
|
|
213
|
+
"--force",
|
|
214
|
+
help="Overwrite existing template without confirmation",
|
|
215
|
+
),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Scaffold a pytest smoke test that uses the FluxLoop runner fixtures.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
root_path = project_root.expanduser().resolve()
|
|
222
|
+
if not root_path.exists():
|
|
223
|
+
console.print(f"[red]Error:[/red] Project root {root_path} does not exist.")
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
|
|
226
|
+
tests_path = (root_path / tests_dir).resolve()
|
|
227
|
+
tests_path.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
|
|
229
|
+
target_file = tests_path / filename
|
|
230
|
+
|
|
231
|
+
if target_file.exists() and not force:
|
|
232
|
+
if not Confirm.ask(
|
|
233
|
+
f"{target_file} already exists. Overwrite?",
|
|
234
|
+
default=False,
|
|
235
|
+
):
|
|
236
|
+
raise typer.Abort()
|
|
237
|
+
|
|
238
|
+
configs_sim = root_path / CONFIG_DIRECTORY_NAME / CONFIG_SECTION_FILENAMES["simulation"]
|
|
239
|
+
legacy_config = root_path / "setting.yaml"
|
|
240
|
+
|
|
241
|
+
if configs_sim.exists():
|
|
242
|
+
relative_config = configs_sim.relative_to(root_path).as_posix()
|
|
243
|
+
elif legacy_config.exists():
|
|
244
|
+
relative_config = legacy_config.relative_to(root_path).as_posix()
|
|
245
|
+
else:
|
|
246
|
+
# Fall back to configs/simulation.yaml even if it does not exist yet
|
|
247
|
+
relative_config = (CONFIG_DIRECTORY_NAME + "/" + CONFIG_SECTION_FILENAMES["simulation"])
|
|
248
|
+
console.print(
|
|
249
|
+
"[yellow]Warning:[/yellow] Could not find configs/simulation.yaml or setting.yaml. "
|
|
250
|
+
"Template will reference the default simulation path."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
template_content = create_pytest_bridge_template(relative_config)
|
|
254
|
+
target_file.write_text(template_content, encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
console.print(
|
|
257
|
+
f"[green]✓[/green] Pytest template created at [cyan]{target_file}[/cyan]. "
|
|
258
|
+
"Run [bold]pytest -k fluxloop_smoke[/bold] to execute the sample test."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
194
262
|
@app.command()
|
|
195
263
|
def agent(
|
|
196
264
|
name: str = typer.Argument(
|
fluxloop_cli/commands/run.py
CHANGED
|
@@ -3,10 +3,9 @@ Run command for executing experiments and simulations.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import json
|
|
7
6
|
import sys
|
|
8
7
|
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
8
|
+
from typing import Callable, Optional
|
|
10
9
|
|
|
11
10
|
import typer
|
|
12
11
|
from rich.console import Console
|
|
@@ -28,16 +27,6 @@ app = typer.Typer()
|
|
|
28
27
|
console = Console()
|
|
29
28
|
|
|
30
29
|
|
|
31
|
-
def _emit_progress_event(event: str, **payload: object) -> None:
|
|
32
|
-
"""Emit a structured progress event to stderr for machine consumption."""
|
|
33
|
-
data = {"event": event, **payload}
|
|
34
|
-
try:
|
|
35
|
-
print(json.dumps(data, default=str), file=sys.stderr, flush=True)
|
|
36
|
-
except Exception:
|
|
37
|
-
# Fall back to repr if serialization fails
|
|
38
|
-
print({"event": event, **{k: repr(v) for k, v in payload.items()}}, file=sys.stderr, flush=True)
|
|
39
|
-
|
|
40
|
-
|
|
41
30
|
@app.command()
|
|
42
31
|
def experiment(
|
|
43
32
|
config_file: Path = typer.Option(
|
|
@@ -124,6 +113,17 @@ def experiment(
|
|
|
124
113
|
"--dry-run",
|
|
125
114
|
help="Show what would be run without executing",
|
|
126
115
|
),
|
|
116
|
+
display: bool = typer.Option(
|
|
117
|
+
True,
|
|
118
|
+
"--display/--no-display",
|
|
119
|
+
help="Show rich console output (disable for plain log streaming)",
|
|
120
|
+
),
|
|
121
|
+
yes: bool = typer.Option(
|
|
122
|
+
False,
|
|
123
|
+
"--yes",
|
|
124
|
+
"-y",
|
|
125
|
+
help="Skip confirmation prompt and run immediately",
|
|
126
|
+
),
|
|
127
127
|
):
|
|
128
128
|
"""
|
|
129
129
|
Run an experiment based on configuration file.
|
|
@@ -239,19 +239,8 @@ def experiment(
|
|
|
239
239
|
|
|
240
240
|
console.print(summary)
|
|
241
241
|
|
|
242
|
-
_emit_progress_event(
|
|
243
|
-
"experiment_summary",
|
|
244
|
-
name=config.name,
|
|
245
|
-
iterations=config.iterations,
|
|
246
|
-
personas=len(config.personas),
|
|
247
|
-
total_runs=total_runs,
|
|
248
|
-
multi_turn_enabled=bool(config.multi_turn and config.multi_turn.enabled),
|
|
249
|
-
output_dir=config.output_directory,
|
|
250
|
-
)
|
|
251
|
-
|
|
252
242
|
if dry_run:
|
|
253
243
|
console.print("\n[yellow]Dry run mode - no execution will occur[/yellow]")
|
|
254
|
-
_emit_progress_event("experiment_status", status="dry_run", name=config.name)
|
|
255
244
|
return
|
|
256
245
|
|
|
257
246
|
# Confirm execution
|
|
@@ -265,128 +254,138 @@ def experiment(
|
|
|
265
254
|
f"\nThis will execute {total_runs} runs."
|
|
266
255
|
)
|
|
267
256
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if not typer.confirm("Continue?"):
|
|
275
|
-
_emit_progress_event("experiment_status", status="aborted", reason="user_declined", name=config.name)
|
|
257
|
+
if yes:
|
|
258
|
+
proceed = True
|
|
259
|
+
else:
|
|
260
|
+
proceed = typer.confirm("Continue?")
|
|
261
|
+
if not proceed:
|
|
276
262
|
raise typer.Abort()
|
|
277
263
|
|
|
278
264
|
# Create runner
|
|
279
265
|
# Run experiment with progress tracking
|
|
280
266
|
console.print("\n[bold green]▶️ Starting experiment...[/bold green]\n")
|
|
281
267
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
else None
|
|
302
|
-
)
|
|
303
|
-
turn_task = progress.add_task(
|
|
304
|
-
"[cyan]Turn 0/0",
|
|
305
|
-
total=multi_turn_total or 1,
|
|
306
|
-
visible=False,
|
|
307
|
-
)
|
|
268
|
+
def _execute_with_callbacks(
|
|
269
|
+
progress_callback: Optional[Callable[[], None]],
|
|
270
|
+
turn_progress_callback: Optional[Callable[[int, int, Optional[str]], None]],
|
|
271
|
+
):
|
|
272
|
+
try:
|
|
273
|
+
return asyncio.run(
|
|
274
|
+
runner.run_experiment(
|
|
275
|
+
progress_callback=progress_callback,
|
|
276
|
+
turn_progress_callback=turn_progress_callback,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
console.print("\n[yellow]Experiment interrupted by user[/yellow]")
|
|
281
|
+
raise typer.Exit(1)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
console.print(f"\n[red]Error during experiment:[/red] {e}")
|
|
284
|
+
if "--debug" in sys.argv:
|
|
285
|
+
console.print_exception()
|
|
286
|
+
raise typer.Exit(1)
|
|
308
287
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
288
|
+
if display and console.is_terminal:
|
|
289
|
+
with Progress(
|
|
290
|
+
SpinnerColumn(),
|
|
291
|
+
TextColumn("[progress.description]{task.description}"),
|
|
292
|
+
BarColumn(),
|
|
293
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
294
|
+
TimeRemainingColumn(),
|
|
295
|
+
TextColumn("({task.completed} of {task.total})"),
|
|
296
|
+
console=console,
|
|
297
|
+
) as progress:
|
|
298
|
+
main_task = progress.add_task(
|
|
299
|
+
f"Running {config.name}",
|
|
300
|
+
total=total_runs,
|
|
301
|
+
)
|
|
302
|
+
multi_turn_total = (
|
|
303
|
+
config.multi_turn.max_turns
|
|
304
|
+
if config.multi_turn and config.multi_turn.max_turns
|
|
305
|
+
else None
|
|
306
|
+
)
|
|
307
|
+
turn_task = progress.add_task(
|
|
308
|
+
"[cyan]Turn 0/0",
|
|
309
|
+
total=multi_turn_total or 1,
|
|
310
|
+
visible=False,
|
|
318
311
|
)
|
|
319
312
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
313
|
+
def _progress_callback() -> None:
|
|
314
|
+
progress.advance(main_task)
|
|
315
|
+
|
|
316
|
+
def _turn_progress_callback(
|
|
317
|
+
current_turn: int,
|
|
318
|
+
total_turns: int,
|
|
319
|
+
message: Optional[str] = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
total = total_turns or 1
|
|
322
|
+
clamped_turn = max(0, current_turn)
|
|
323
|
+
if message is None:
|
|
324
|
+
completed = min(clamped_turn, total)
|
|
325
|
+
description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: complete"
|
|
326
|
+
progress.update(
|
|
327
|
+
turn_task,
|
|
328
|
+
total=total,
|
|
329
|
+
completed=completed,
|
|
330
|
+
description=description,
|
|
331
|
+
visible=False,
|
|
332
|
+
)
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
if not progress.tasks[turn_task].visible:
|
|
336
|
+
progress.update(turn_task, visible=True)
|
|
337
|
+
|
|
338
|
+
preview = message.replace("\n", " ")
|
|
339
|
+
if len(preview) > 40:
|
|
340
|
+
preview = preview[:37] + "..."
|
|
341
|
+
|
|
342
|
+
completed = max(0, min(clamped_turn - 1, total))
|
|
343
|
+
description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: {preview}"
|
|
330
344
|
progress.update(
|
|
331
345
|
turn_task,
|
|
332
346
|
total=total,
|
|
333
347
|
completed=completed,
|
|
334
348
|
description=description,
|
|
335
|
-
visible=False,
|
|
336
|
-
)
|
|
337
|
-
_emit_progress_event(
|
|
338
|
-
"turn_progress",
|
|
339
|
-
state="complete",
|
|
340
|
-
current=min(clamped_turn, total),
|
|
341
|
-
total=total,
|
|
342
|
-
experiment=config.name,
|
|
343
349
|
)
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
if not progress.tasks[turn_task].visible:
|
|
347
|
-
progress.update(turn_task, visible=True)
|
|
348
350
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
completed = max(0, min(clamped_turn - 1, total))
|
|
354
|
-
description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: {preview}"
|
|
351
|
+
results = _execute_with_callbacks(_progress_callback, _turn_progress_callback)
|
|
352
|
+
else:
|
|
353
|
+
completed_runs = 0
|
|
355
354
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
_emit_progress_event(
|
|
363
|
-
"turn_progress",
|
|
364
|
-
state="in_progress",
|
|
365
|
-
current=clamped_turn,
|
|
366
|
-
total=total,
|
|
367
|
-
preview=preview,
|
|
368
|
-
experiment=config.name,
|
|
355
|
+
def _progress_callback() -> None:
|
|
356
|
+
nonlocal completed_runs
|
|
357
|
+
completed_runs += 1
|
|
358
|
+
print(
|
|
359
|
+
f"[fluxloop] run progress {completed_runs}/{total_runs}",
|
|
360
|
+
flush=True,
|
|
369
361
|
)
|
|
370
362
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
363
|
+
def _turn_progress_callback(
|
|
364
|
+
current_turn: int,
|
|
365
|
+
total_turns: int,
|
|
366
|
+
message: Optional[str] = None,
|
|
367
|
+
) -> None:
|
|
368
|
+
total = total_turns or 1
|
|
369
|
+
clamped_turn = max(0, current_turn)
|
|
370
|
+
if message:
|
|
371
|
+
preview = message.replace("\n", " ")
|
|
372
|
+
if len(preview) > 80:
|
|
373
|
+
preview = preview[:77] + "..."
|
|
374
|
+
print(
|
|
375
|
+
f"[fluxloop] turn progress {min(clamped_turn, total)}/{total}: {preview}",
|
|
376
|
+
flush=True,
|
|
377
377
|
)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
raise typer.Exit(1)
|
|
378
|
+
else:
|
|
379
|
+
print(
|
|
380
|
+
f"[fluxloop] turn progress {min(clamped_turn, total)}/{total}: complete",
|
|
381
|
+
flush=True,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
print(
|
|
385
|
+
f"[fluxloop] running {config.name} ({total_runs} total runs)",
|
|
386
|
+
flush=True,
|
|
387
|
+
)
|
|
388
|
+
results = _execute_with_callbacks(_progress_callback, _turn_progress_callback)
|
|
390
389
|
|
|
391
390
|
config.set_resolved_input_count(results.get("input_count", config.get_input_count()))
|
|
392
391
|
|
|
@@ -399,15 +398,6 @@ def experiment(
|
|
|
399
398
|
)
|
|
400
399
|
|
|
401
400
|
_display_results(results)
|
|
402
|
-
_emit_progress_event(
|
|
403
|
-
"experiment_status",
|
|
404
|
-
status="completed",
|
|
405
|
-
name=config.name,
|
|
406
|
-
total_runs=results.get("total_runs", total_runs),
|
|
407
|
-
successful=results.get("successful"),
|
|
408
|
-
failed=results.get("failed"),
|
|
409
|
-
success_rate=results.get("success_rate"),
|
|
410
|
-
)
|
|
411
401
|
|
|
412
402
|
|
|
413
403
|
@app.command()
|
fluxloop_cli/templates.py
CHANGED
|
@@ -429,6 +429,31 @@ def create_evaluation_config() -> str:
|
|
|
429
429
|
).strip() + "\n"
|
|
430
430
|
|
|
431
431
|
|
|
432
|
+
def create_pytest_bridge_template(config_relative_path: str) -> str:
|
|
433
|
+
"""Return a ready-to-run pytest smoke test referencing the bridge fixtures."""
|
|
434
|
+
|
|
435
|
+
return dedent(
|
|
436
|
+
f"""
|
|
437
|
+
\"\"\"FluxLoop Pytest smoke test generated via `fluxloop init pytest-template`.\"\"\"
|
|
438
|
+
|
|
439
|
+
from pathlib import Path
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
443
|
+
SIMULATION_CONFIG = PROJECT_ROOT / "{config_relative_path}"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_fluxloop_smoke(fluxloop_runner):
|
|
447
|
+
result = fluxloop_runner(
|
|
448
|
+
project_root=PROJECT_ROOT,
|
|
449
|
+
simulation_config=SIMULATION_CONFIG,
|
|
450
|
+
env={{"PYTHONPATH": str(PROJECT_ROOT)}},
|
|
451
|
+
)
|
|
452
|
+
result.require_success(label="fluxloop smoke")
|
|
453
|
+
"""
|
|
454
|
+
).strip() + "\n"
|
|
455
|
+
|
|
456
|
+
|
|
432
457
|
def create_sample_agent() -> str:
|
|
433
458
|
"""Create a sample agent implementation."""
|
|
434
459
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testing utilities interface definitions for FluxLoop Pytest bridge.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .types import (
|
|
6
|
+
DEFAULT_SCENARIOS,
|
|
7
|
+
FluxLoopRunnerMode,
|
|
8
|
+
FluxLoopRunnerRequest,
|
|
9
|
+
FluxLoopRunnerOverrides,
|
|
10
|
+
FluxLoopTestError,
|
|
11
|
+
FluxLoopTestResult,
|
|
12
|
+
FluxLoopTestScenario,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_SCENARIOS",
|
|
17
|
+
"FluxLoopRunnerMode",
|
|
18
|
+
"FluxLoopRunnerOverrides",
|
|
19
|
+
"FluxLoopRunnerRequest",
|
|
20
|
+
"FluxLoopTestError",
|
|
21
|
+
"FluxLoopTestResult",
|
|
22
|
+
"FluxLoopTestScenario",
|
|
23
|
+
]
|
|
24
|
+
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest fixtures for executing FluxLoop experiments (Week 2/4 deliverables).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Mapping, MutableMapping, Optional, Sequence, Union
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from fluxloop_cli.config_loader import load_experiment_config, merge_config_overrides
|
|
21
|
+
from fluxloop_cli.runner import ExperimentRunner
|
|
22
|
+
|
|
23
|
+
from .types import (
|
|
24
|
+
FluxLoopRunnerRequest,
|
|
25
|
+
FluxLoopRunnerOverrides,
|
|
26
|
+
FluxLoopTestResult,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PathLike = Union[str, Path]
|
|
30
|
+
RunnerCallable = Callable[..., FluxLoopTestResult]
|
|
31
|
+
WORKSPACE_DIR_NAME = ".fluxloop_pytest"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_request(
|
|
35
|
+
*,
|
|
36
|
+
project_root: PathLike,
|
|
37
|
+
simulation_config: Optional[PathLike],
|
|
38
|
+
overrides: Optional[Mapping[str, Any]],
|
|
39
|
+
mode: str,
|
|
40
|
+
timeout: Optional[int],
|
|
41
|
+
env: Optional[Mapping[str, str]],
|
|
42
|
+
) -> FluxLoopRunnerRequest:
|
|
43
|
+
request = FluxLoopRunnerRequest(
|
|
44
|
+
project_root=Path(project_root),
|
|
45
|
+
simulation_config=Path(simulation_config) if simulation_config else None,
|
|
46
|
+
mode=mode,
|
|
47
|
+
)
|
|
48
|
+
if timeout is not None:
|
|
49
|
+
request.timeout_seconds = timeout
|
|
50
|
+
if overrides:
|
|
51
|
+
request.overrides.merge(overrides)
|
|
52
|
+
if env:
|
|
53
|
+
request.extra_env.update({k: str(v) for k, v in env.items()})
|
|
54
|
+
request.resolve_paths()
|
|
55
|
+
return request
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _determine_config_path(request: FluxLoopRunnerRequest) -> Path:
|
|
59
|
+
if request.simulation_config and request.simulation_config.exists():
|
|
60
|
+
return request.simulation_config
|
|
61
|
+
|
|
62
|
+
candidates: Sequence[Path] = (
|
|
63
|
+
request.project_root / "configs",
|
|
64
|
+
request.project_root / "setting.yaml",
|
|
65
|
+
)
|
|
66
|
+
for candidate in candidates:
|
|
67
|
+
if candidate.exists():
|
|
68
|
+
return candidate
|
|
69
|
+
raise FileNotFoundError(
|
|
70
|
+
f"Could not locate configs/ or setting.yaml under {request.project_root}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextlib.contextmanager
|
|
75
|
+
def _patched_environ(values: Mapping[str, str]):
|
|
76
|
+
previous: MutableMapping[str, Optional[str]] = {}
|
|
77
|
+
try:
|
|
78
|
+
for key, value in values.items():
|
|
79
|
+
previous[key] = os.environ.get(key)
|
|
80
|
+
os.environ[key] = value
|
|
81
|
+
yield
|
|
82
|
+
finally:
|
|
83
|
+
for key, old_value in previous.items():
|
|
84
|
+
if old_value is None:
|
|
85
|
+
os.environ.pop(key, None)
|
|
86
|
+
else:
|
|
87
|
+
os.environ[key] = old_value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_result_payload(runner: ExperimentRunner) -> dict[str, Any]:
|
|
91
|
+
results = runner.results
|
|
92
|
+
payload = {
|
|
93
|
+
"results": {
|
|
94
|
+
"total_runs": results.get("total_runs", 0),
|
|
95
|
+
"failed": results.get("failed", 0),
|
|
96
|
+
"success_rate": results.get("success_rate", 0.0),
|
|
97
|
+
"avg_duration_ms": results.get("avg_duration_ms", 0.0),
|
|
98
|
+
},
|
|
99
|
+
"errors": results.get("errors", []),
|
|
100
|
+
"output_dir": str(runner.output_dir),
|
|
101
|
+
}
|
|
102
|
+
return payload
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_result_object(runner: ExperimentRunner) -> FluxLoopTestResult:
|
|
106
|
+
payload = _build_result_payload(runner)
|
|
107
|
+
trace_summary_path = runner.output_dir / "trace_summary.jsonl"
|
|
108
|
+
if not trace_summary_path.exists():
|
|
109
|
+
trace_summary_path = None
|
|
110
|
+
per_trace_path = runner.output_dir / "per_trace_analysis" / "per_trace.jsonl"
|
|
111
|
+
if not per_trace_path.exists():
|
|
112
|
+
per_trace_path = None
|
|
113
|
+
return FluxLoopTestResult.from_summary(
|
|
114
|
+
payload,
|
|
115
|
+
trace_summary_path=trace_summary_path,
|
|
116
|
+
per_trace_path=per_trace_path,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_with_timeout(
|
|
121
|
+
runner: ExperimentRunner,
|
|
122
|
+
*,
|
|
123
|
+
timeout: Optional[int],
|
|
124
|
+
) -> None:
|
|
125
|
+
async def _execute():
|
|
126
|
+
coroutine = runner.run_experiment()
|
|
127
|
+
if timeout is not None:
|
|
128
|
+
return await asyncio.wait_for(coroutine, timeout=timeout)
|
|
129
|
+
return await coroutine
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
asyncio.run(_execute())
|
|
133
|
+
except asyncio.TimeoutError as exc:
|
|
134
|
+
raise AssertionError(
|
|
135
|
+
f"FluxLoop experiment exceeded timeout ({timeout}s)"
|
|
136
|
+
) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_config_with_overrides(
|
|
140
|
+
request: FluxLoopRunnerRequest,
|
|
141
|
+
*,
|
|
142
|
+
require_inputs_file: bool,
|
|
143
|
+
):
|
|
144
|
+
config_path = _determine_config_path(request)
|
|
145
|
+
config = load_experiment_config(
|
|
146
|
+
config_path,
|
|
147
|
+
require_inputs_file=require_inputs_file,
|
|
148
|
+
)
|
|
149
|
+
overrides = request.overrides.normalized()
|
|
150
|
+
if overrides:
|
|
151
|
+
source_dir = config.get_source_dir()
|
|
152
|
+
config = merge_config_overrides(config, overrides)
|
|
153
|
+
if source_dir:
|
|
154
|
+
config.set_source_dir(source_dir)
|
|
155
|
+
return config
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _run_via_sdk(
|
|
159
|
+
request: FluxLoopRunnerRequest,
|
|
160
|
+
*,
|
|
161
|
+
require_inputs_file: bool,
|
|
162
|
+
) -> FluxLoopTestResult:
|
|
163
|
+
config = _load_config_with_overrides(
|
|
164
|
+
request,
|
|
165
|
+
require_inputs_file=require_inputs_file,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
runner = ExperimentRunner(config)
|
|
169
|
+
|
|
170
|
+
with _patched_environ(request.extra_env):
|
|
171
|
+
_run_with_timeout(runner, timeout=request.timeout_seconds)
|
|
172
|
+
|
|
173
|
+
return _build_result_object(runner)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _workspace_dir(project_root: Path) -> Path:
|
|
177
|
+
workspace = project_root / WORKSPACE_DIR_NAME
|
|
178
|
+
workspace.mkdir(exist_ok=True)
|
|
179
|
+
return workspace
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _abs_path(value: Optional[str], base: Path) -> Optional[str]:
|
|
183
|
+
if not value:
|
|
184
|
+
return value
|
|
185
|
+
path = Path(value)
|
|
186
|
+
if path.is_absolute():
|
|
187
|
+
return str(path)
|
|
188
|
+
return str((base / path).resolve())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _config_to_python_dict(config) -> dict[str, Any]:
|
|
192
|
+
if hasattr(config, "model_dump"):
|
|
193
|
+
return config.model_dump(mode="json") # type: ignore[call-arg]
|
|
194
|
+
return config.to_dict()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _materialize_cli_config(
|
|
198
|
+
config,
|
|
199
|
+
request: FluxLoopRunnerRequest,
|
|
200
|
+
workspace: Path,
|
|
201
|
+
) -> tuple[Path, Path]:
|
|
202
|
+
source_dir = config.get_source_dir() or request.project_root
|
|
203
|
+
config_dict = _config_to_python_dict(config)
|
|
204
|
+
outputs_base = (workspace / "outputs").resolve()
|
|
205
|
+
outputs_base.mkdir(exist_ok=True)
|
|
206
|
+
config_dict["output_directory"] = str(outputs_base)
|
|
207
|
+
|
|
208
|
+
if config_dict.get("inputs_file"):
|
|
209
|
+
config_dict["inputs_file"] = _abs_path(config_dict["inputs_file"], source_dir)
|
|
210
|
+
|
|
211
|
+
runner_dict = config_dict.get("runner") or {}
|
|
212
|
+
if runner_dict.get("working_directory"):
|
|
213
|
+
runner_dict["working_directory"] = _abs_path(
|
|
214
|
+
runner_dict["working_directory"], source_dir
|
|
215
|
+
)
|
|
216
|
+
python_paths = runner_dict.get("python_path") or []
|
|
217
|
+
runner_dict["python_path"] = [
|
|
218
|
+
_abs_path(entry, source_dir) for entry in python_paths if entry
|
|
219
|
+
]
|
|
220
|
+
config_dict["runner"] = runner_dict
|
|
221
|
+
|
|
222
|
+
replay_args = config_dict.get("replay_args") or {}
|
|
223
|
+
if replay_args.get("recording_file"):
|
|
224
|
+
replay_args["recording_file"] = _abs_path(replay_args["recording_file"], source_dir)
|
|
225
|
+
config_dict["replay_args"] = replay_args
|
|
226
|
+
|
|
227
|
+
config_path = request.project_root / f".cli_config_{uuid.uuid4().hex}.yaml"
|
|
228
|
+
with open(config_path, "w", encoding="utf-8") as handle:
|
|
229
|
+
yaml.safe_dump(config_dict, handle, sort_keys=False)
|
|
230
|
+
return config_path, outputs_base
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _update_pythonpath(env: MutableMapping[str, str], project_root: Path) -> None:
|
|
234
|
+
existing = env.get("PYTHONPATH")
|
|
235
|
+
entries = [str(project_root)]
|
|
236
|
+
if existing:
|
|
237
|
+
entries.append(existing)
|
|
238
|
+
env["PYTHONPATH"] = os.pathsep.join(entries)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _append_log(path: Path, content: str) -> None:
|
|
242
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
243
|
+
handle.write(content)
|
|
244
|
+
if not content.endswith("\n"):
|
|
245
|
+
handle.write("\n")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _invoke_cli_command(
|
|
249
|
+
args: list[str],
|
|
250
|
+
*,
|
|
251
|
+
env: Mapping[str, str],
|
|
252
|
+
cwd: Path,
|
|
253
|
+
timeout: Optional[int],
|
|
254
|
+
stdout_log: Path,
|
|
255
|
+
stderr_log: Path,
|
|
256
|
+
) -> subprocess.CompletedProcess[str]:
|
|
257
|
+
proc = subprocess.run(
|
|
258
|
+
[sys.executable, "-m", "fluxloop_cli.main", *args],
|
|
259
|
+
text=True,
|
|
260
|
+
capture_output=True,
|
|
261
|
+
cwd=str(cwd),
|
|
262
|
+
env=dict(env),
|
|
263
|
+
timeout=timeout,
|
|
264
|
+
)
|
|
265
|
+
_append_log(stdout_log, f"$ fluxloop {' '.join(args)}")
|
|
266
|
+
_append_log(stdout_log, proc.stdout)
|
|
267
|
+
_append_log(stderr_log, proc.stderr)
|
|
268
|
+
return proc
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _latest_experiment_dir(outputs_base: Path) -> Path:
|
|
272
|
+
candidates = [p for p in outputs_base.iterdir() if p.is_dir()]
|
|
273
|
+
if not candidates:
|
|
274
|
+
raise AssertionError(f"No experiment directories found under {outputs_base}")
|
|
275
|
+
return max(candidates, key=lambda p: p.stat().st_mtime)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _run_via_cli(
|
|
279
|
+
request: FluxLoopRunnerRequest,
|
|
280
|
+
*,
|
|
281
|
+
require_inputs_file: bool,
|
|
282
|
+
) -> FluxLoopTestResult:
|
|
283
|
+
config = _load_config_with_overrides(
|
|
284
|
+
request,
|
|
285
|
+
require_inputs_file=require_inputs_file,
|
|
286
|
+
)
|
|
287
|
+
workspace = _workspace_dir(request.project_root)
|
|
288
|
+
logs_dir = workspace / "logs"
|
|
289
|
+
logs_dir.mkdir(exist_ok=True)
|
|
290
|
+
|
|
291
|
+
run_id = uuid.uuid4().hex
|
|
292
|
+
stdout_log = logs_dir / f"{run_id}.stdout.log"
|
|
293
|
+
stderr_log = logs_dir / f"{run_id}.stderr.log"
|
|
294
|
+
|
|
295
|
+
config_path, outputs_base = _materialize_cli_config(config, request, workspace)
|
|
296
|
+
|
|
297
|
+
env = os.environ.copy()
|
|
298
|
+
env.update({k: v for k, v in request.extra_env.items()})
|
|
299
|
+
_update_pythonpath(env, request.project_root)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
run_args = [
|
|
303
|
+
"run",
|
|
304
|
+
"experiment",
|
|
305
|
+
"--config",
|
|
306
|
+
str(config_path),
|
|
307
|
+
"--yes",
|
|
308
|
+
"--no-display",
|
|
309
|
+
"--no-collector",
|
|
310
|
+
]
|
|
311
|
+
run_proc = _invoke_cli_command(
|
|
312
|
+
run_args,
|
|
313
|
+
env=env,
|
|
314
|
+
cwd=request.project_root,
|
|
315
|
+
timeout=request.timeout_seconds,
|
|
316
|
+
stdout_log=stdout_log,
|
|
317
|
+
stderr_log=stderr_log,
|
|
318
|
+
)
|
|
319
|
+
if run_proc.returncode != 0:
|
|
320
|
+
raise AssertionError(
|
|
321
|
+
f"fluxloop run experiment failed (see {stderr_log})"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
experiment_dir = _latest_experiment_dir(outputs_base)
|
|
325
|
+
|
|
326
|
+
parse_args = [
|
|
327
|
+
"parse",
|
|
328
|
+
"experiment",
|
|
329
|
+
str(experiment_dir),
|
|
330
|
+
"--overwrite",
|
|
331
|
+
]
|
|
332
|
+
parse_proc = _invoke_cli_command(
|
|
333
|
+
parse_args,
|
|
334
|
+
env=env,
|
|
335
|
+
cwd=request.project_root,
|
|
336
|
+
timeout=request.timeout_seconds,
|
|
337
|
+
stdout_log=stdout_log,
|
|
338
|
+
stderr_log=stderr_log,
|
|
339
|
+
)
|
|
340
|
+
if parse_proc.returncode != 0:
|
|
341
|
+
raise AssertionError(
|
|
342
|
+
f"fluxloop parse experiment failed (see {stderr_log})"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
summary_path = experiment_dir / "summary.json"
|
|
346
|
+
if not summary_path.exists():
|
|
347
|
+
raise AssertionError(f"summary.json missing under {experiment_dir}")
|
|
348
|
+
summary_payload = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
349
|
+
summary_payload["output_dir"] = str(experiment_dir)
|
|
350
|
+
|
|
351
|
+
trace_summary_path = experiment_dir / "trace_summary.jsonl"
|
|
352
|
+
if not trace_summary_path.exists():
|
|
353
|
+
trace_summary_path = None
|
|
354
|
+
|
|
355
|
+
per_trace_path = experiment_dir / "per_trace_analysis" / "per_trace.jsonl"
|
|
356
|
+
if not per_trace_path.exists():
|
|
357
|
+
per_trace_path = None
|
|
358
|
+
|
|
359
|
+
cli_command = f"fluxloop {' '.join(run_args)}"
|
|
360
|
+
|
|
361
|
+
return FluxLoopTestResult.from_summary(
|
|
362
|
+
summary_payload,
|
|
363
|
+
trace_summary_path=trace_summary_path,
|
|
364
|
+
per_trace_path=per_trace_path,
|
|
365
|
+
stdout_path=stdout_log,
|
|
366
|
+
stderr_path=stderr_log,
|
|
367
|
+
cli_command=cli_command,
|
|
368
|
+
)
|
|
369
|
+
finally:
|
|
370
|
+
with contextlib.suppress(FileNotFoundError):
|
|
371
|
+
config_path.unlink()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _build_runner_fixture(mode: str) -> RunnerCallable:
|
|
375
|
+
def _runner(
|
|
376
|
+
*,
|
|
377
|
+
project_root: PathLike,
|
|
378
|
+
simulation_config: Optional[PathLike] = None,
|
|
379
|
+
overrides: Optional[Mapping[str, Any]] = None,
|
|
380
|
+
timeout: Optional[int] = None,
|
|
381
|
+
env: Optional[Mapping[str, str]] = None,
|
|
382
|
+
require_inputs_file: bool = True,
|
|
383
|
+
) -> FluxLoopTestResult:
|
|
384
|
+
request = _build_request(
|
|
385
|
+
project_root=project_root,
|
|
386
|
+
simulation_config=simulation_config,
|
|
387
|
+
overrides=overrides,
|
|
388
|
+
mode=mode,
|
|
389
|
+
timeout=timeout,
|
|
390
|
+
env=env,
|
|
391
|
+
)
|
|
392
|
+
if mode == "cli":
|
|
393
|
+
return _run_via_cli(
|
|
394
|
+
request,
|
|
395
|
+
require_inputs_file=require_inputs_file,
|
|
396
|
+
)
|
|
397
|
+
return _run_via_sdk(
|
|
398
|
+
request,
|
|
399
|
+
require_inputs_file=require_inputs_file,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
return _runner
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@pytest.fixture
|
|
406
|
+
def fluxloop_runner() -> RunnerCallable:
|
|
407
|
+
"""
|
|
408
|
+
Execute experiments via the SDK path (default fixture).
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
return _build_runner_fixture("sdk")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@pytest.fixture
|
|
415
|
+
def fluxloop_cli() -> RunnerCallable:
|
|
416
|
+
"""
|
|
417
|
+
Execute experiments via the CLI path (subprocess parity).
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
return _build_runner_fixture("cli")
|
|
421
|
+
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data structures for the Pytest bridge (Week 1 deliverables).
|
|
3
|
+
|
|
4
|
+
These definitions capture the request/result contract so fixtures can be
|
|
5
|
+
implemented later without changing user-facing APIs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FluxLoopRunnerMode = str # Literal["sdk", "cli"] 예정, but keep flexible for now
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FluxLoopRunnerOverrides:
|
|
20
|
+
"""User supplied key-path → value overrides for ExperimentConfig."""
|
|
21
|
+
|
|
22
|
+
values: MutableMapping[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def normalized(self) -> Dict[str, Any]:
|
|
25
|
+
"""Return a shallow copy for downstream mutation safety."""
|
|
26
|
+
return dict(self.values)
|
|
27
|
+
|
|
28
|
+
def merge(self, *overrides: Mapping[str, Any]) -> "FluxLoopRunnerOverrides":
|
|
29
|
+
merged = self.normalized()
|
|
30
|
+
for item in overrides:
|
|
31
|
+
merged.update(item)
|
|
32
|
+
self.values = merged
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class FluxLoopRunnerRequest:
|
|
38
|
+
"""
|
|
39
|
+
Normalized request payload consumed by the future pytest fixtures.
|
|
40
|
+
|
|
41
|
+
`project_root` is required so we can locate configs/ and .env files.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
project_root: Path
|
|
45
|
+
simulation_config: Optional[Path] = None
|
|
46
|
+
mode: FluxLoopRunnerMode = "sdk"
|
|
47
|
+
overrides: FluxLoopRunnerOverrides = field(default_factory=FluxLoopRunnerOverrides)
|
|
48
|
+
timeout_seconds: Optional[int] = 600
|
|
49
|
+
extra_env: MutableMapping[str, str] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def resolve_paths(self) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Ensure all paths are absolute so fixtures can run from any cwd.
|
|
54
|
+
"""
|
|
55
|
+
self.project_root = self.project_root.expanduser().resolve()
|
|
56
|
+
if self.simulation_config is not None:
|
|
57
|
+
config_path = self.simulation_config
|
|
58
|
+
if not config_path.is_absolute():
|
|
59
|
+
config_path = self.project_root / config_path
|
|
60
|
+
self.simulation_config = config_path.expanduser().resolve()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class FluxLoopTestError:
|
|
65
|
+
"""Represents a single failure entry from the experiment summary."""
|
|
66
|
+
|
|
67
|
+
iteration: Optional[int]
|
|
68
|
+
persona: Optional[str]
|
|
69
|
+
input_text: Optional[str]
|
|
70
|
+
message: str
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> "FluxLoopTestError":
|
|
74
|
+
return cls(
|
|
75
|
+
iteration=payload.get("iteration"),
|
|
76
|
+
persona=payload.get("persona"),
|
|
77
|
+
input_text=payload.get("input"),
|
|
78
|
+
message=str(payload.get("error", "")),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class FluxLoopTestResult:
|
|
84
|
+
"""Aggregate summary emitted back to pytest test functions."""
|
|
85
|
+
|
|
86
|
+
total_runs: int
|
|
87
|
+
failed_runs: int
|
|
88
|
+
success_rate: float
|
|
89
|
+
avg_duration_ms: float
|
|
90
|
+
output_dir: Path
|
|
91
|
+
summary_path: Path
|
|
92
|
+
trace_summary_path: Optional[Path]
|
|
93
|
+
per_trace_path: Optional[Path]
|
|
94
|
+
stdout_path: Optional[Path] = None
|
|
95
|
+
stderr_path: Optional[Path] = None
|
|
96
|
+
cli_command: Optional[str] = None
|
|
97
|
+
errors: Sequence[FluxLoopTestError] = field(default_factory=tuple)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def succeeded(self) -> bool:
|
|
101
|
+
return self.failed_runs == 0
|
|
102
|
+
|
|
103
|
+
def require_success(self, *, label: Optional[str] = None) -> None:
|
|
104
|
+
if self.succeeded:
|
|
105
|
+
return
|
|
106
|
+
reason = label or "FluxLoop experiment"
|
|
107
|
+
details = "; ".join(err.message for err in self.errors[:3]) or "unknown failure"
|
|
108
|
+
raise AssertionError(f"{reason} failed ({self.failed_runs} runs): {details}")
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_summary(
|
|
112
|
+
cls,
|
|
113
|
+
summary: Mapping[str, Any],
|
|
114
|
+
*,
|
|
115
|
+
trace_summary_path: Optional[Path] = None,
|
|
116
|
+
per_trace_path: Optional[Path] = None,
|
|
117
|
+
stdout_path: Optional[Path] = None,
|
|
118
|
+
stderr_path: Optional[Path] = None,
|
|
119
|
+
cli_command: Optional[str] = None,
|
|
120
|
+
) -> "FluxLoopTestResult":
|
|
121
|
+
results = summary.get("results", {})
|
|
122
|
+
errors = [FluxLoopTestError.from_dict(raw) for raw in summary.get("errors", [])]
|
|
123
|
+
output_dir = Path(summary.get("output_dir", summary.get("name", "experiments"))).expanduser()
|
|
124
|
+
summary_path = output_dir / "summary.json"
|
|
125
|
+
return cls(
|
|
126
|
+
total_runs=int(results.get("total_runs", 0)),
|
|
127
|
+
failed_runs=int(results.get("failed", 0)),
|
|
128
|
+
success_rate=float(results.get("success_rate", 0.0)),
|
|
129
|
+
avg_duration_ms=float(results.get("avg_duration_ms", 0.0)),
|
|
130
|
+
output_dir=output_dir,
|
|
131
|
+
summary_path=summary_path,
|
|
132
|
+
trace_summary_path=trace_summary_path,
|
|
133
|
+
per_trace_path=per_trace_path,
|
|
134
|
+
stdout_path=stdout_path,
|
|
135
|
+
stderr_path=stderr_path,
|
|
136
|
+
cli_command=cli_command,
|
|
137
|
+
errors=tuple(errors),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(slots=True)
|
|
142
|
+
class FluxLoopTestScenario:
|
|
143
|
+
"""Design-time catalog of QA scenarios the fixtures must support."""
|
|
144
|
+
|
|
145
|
+
name: str
|
|
146
|
+
description: str
|
|
147
|
+
tags: Sequence[str] = field(default_factory=tuple)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
DEFAULT_SCENARIOS: Sequence[FluxLoopTestScenario] = (
|
|
151
|
+
FluxLoopTestScenario(
|
|
152
|
+
name="single-turn-sync",
|
|
153
|
+
description="Basic python function target without multi-turn supervisor.",
|
|
154
|
+
tags=("sdk", "happy-path"),
|
|
155
|
+
),
|
|
156
|
+
FluxLoopTestScenario(
|
|
157
|
+
name="multi-turn-supervisor",
|
|
158
|
+
description="ConversationSupervisor enabled with scripted user turns.",
|
|
159
|
+
tags=("sdk", "multi-turn"),
|
|
160
|
+
),
|
|
161
|
+
FluxLoopTestScenario(
|
|
162
|
+
name="subprocess-jsonl",
|
|
163
|
+
description="Runner configured via subprocess JSONL stream to validate CLI parity.",
|
|
164
|
+
tags=("cli", "subprocess"),
|
|
165
|
+
),
|
|
166
|
+
FluxLoopTestScenario(
|
|
167
|
+
name="mcp-adapter",
|
|
168
|
+
description="MCP server target requiring environment injection and guard handling.",
|
|
169
|
+
tags=("sdk", "mcp"),
|
|
170
|
+
),
|
|
171
|
+
FluxLoopTestScenario(
|
|
172
|
+
name="failure-capture",
|
|
173
|
+
description="Intentional failure to verify error surfaces and pytest assertion formatting.",
|
|
174
|
+
tags=("sdk", "negative"),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = [
|
|
180
|
+
"FluxLoopRunnerMode",
|
|
181
|
+
"FluxLoopRunnerOverrides",
|
|
182
|
+
"FluxLoopRunnerRequest",
|
|
183
|
+
"FluxLoopTestError",
|
|
184
|
+
"FluxLoopTestResult",
|
|
185
|
+
"FluxLoopTestScenario",
|
|
186
|
+
"DEFAULT_SCENARIOS",
|
|
187
|
+
]
|
|
188
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fluxloop-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.30
|
|
4
4
|
Summary: FluxLoop CLI for running agent simulations
|
|
5
5
|
Author-email: FluxLoop Team <team@fluxloop.dev>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -71,6 +71,7 @@ The legacy `setting.yaml` is still supported, but new projects created with
|
|
|
71
71
|
- `fluxloop config set-llm` – update LLM provider/model in `configs/input.yaml`
|
|
72
72
|
- `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
|
|
73
73
|
- `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
|
|
74
|
+
- `--yes/-y` (for `fluxloop run experiment`) – skip the interactive confirmation prompt, ideal for CI and the Pytest bridge
|
|
74
75
|
|
|
75
76
|
### Multi-turn supervisor options
|
|
76
77
|
|
|
@@ -88,6 +89,16 @@ These flags override the values in `configs/simulation.yaml` (`multi_turn` block
|
|
|
88
89
|
|
|
89
90
|
Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
|
|
90
91
|
|
|
92
|
+
## Pytest Bridge (0.2.29+)
|
|
93
|
+
|
|
94
|
+
- `fluxloop init pytest-template [project_root]` creates `tests/test_fluxloop_smoke.py`, already wired to the new `fluxloop_runner` fixture.
|
|
95
|
+
- Fixtures live in `fluxloop_cli.testing.pytest_plugin` and return a `FluxLoopTestResult`, so you can assert on `total_runs`, `success_rate`, or call `require_success()`.
|
|
96
|
+
- Full guide + CI example: see `docs/guides/pytest_bridge.md` (includes GitHub Actions workflow at `examples/ci/fluxloop_pytest.yml`).
|
|
97
|
+
- Typical workflow:
|
|
98
|
+
1. `pip install -e packages/cli[dev]`
|
|
99
|
+
2. `fluxloop init pytest-template .`
|
|
100
|
+
3. `pytest -k fluxloop_smoke --maxfail=1`
|
|
101
|
+
|
|
91
102
|
## Evaluation Workflow
|
|
92
103
|
|
|
93
104
|
Evaluation now follows a two-step process so that multi-turn context is preserved:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
fluxloop_cli/__init__.py,sha256=
|
|
1
|
+
fluxloop_cli/__init__.py,sha256=9mSy8Uew5CRCHwCbTK63mr3OmKigybmLBSHEHuVz_1U,143
|
|
2
2
|
fluxloop_cli/arg_binder.py,sha256=6-i81kK6kmR8inNvIp0GeGY51M5Jere-hAdignMl2bE,12765
|
|
3
3
|
fluxloop_cli/config_loader.py,sha256=PYy0CfGVbU8jpPbx4sJzOu7i3BbrkQMNaRiSOp_uX9g,10307
|
|
4
4
|
fluxloop_cli/config_schema.py,sha256=JZJRcMFun5hp3vKLAyek7W3NvISyzRzZt0BZAeSU38I,2415
|
|
@@ -11,17 +11,17 @@ fluxloop_cli/main.py,sha256=apLSNIgXv-V269ttKvd-t5za2UUjePswmR5KZGhOLPE,2772
|
|
|
11
11
|
fluxloop_cli/project_paths.py,sha256=FoHp-g3aY1nytxGys85Oy3wJ6gmiKU6FVOwkgTtlHNA,4128
|
|
12
12
|
fluxloop_cli/runner.py,sha256=ee-ue9wmN7X3VKuIk1Sg3z_vYiKwypWkKxFsTccH6rA,50199
|
|
13
13
|
fluxloop_cli/target_loader.py,sha256=ACCu2izqGKoOrEiNnAajH0FgLZcw3j1pWn5rAhEuWFU,5528
|
|
14
|
-
fluxloop_cli/templates.py,sha256=
|
|
14
|
+
fluxloop_cli/templates.py,sha256=880cum_f8J40gwQiR7HbmQuqWgUXBTmji5EsaCjQDm0,20070
|
|
15
15
|
fluxloop_cli/validators.py,sha256=_bLXmxUSzVrDtLjqyTba0bDqamRIaOUHhV4xZ7K36Xw,1155
|
|
16
16
|
fluxloop_cli/commands/__init__.py,sha256=i8xTg7uE9B6ExA0EP_j2h9DZWhD-eC94a-7khXIzRwo,243
|
|
17
17
|
fluxloop_cli/commands/config.py,sha256=9moP68pcNBnJYGMcptgdU1UW-6WlI-Nan6eykPdwl8I,12121
|
|
18
18
|
fluxloop_cli/commands/doctor.py,sha256=svoz9wbLv5Byj9G1Y9GI5lYpVdoG2e7IzfG7tZxuMK0,8184
|
|
19
19
|
fluxloop_cli/commands/evaluate.py,sha256=itxb3TyfqV44DaCczC3oC94cJ2rmz2cIpY-794eXhvQ,7229
|
|
20
20
|
fluxloop_cli/commands/generate.py,sha256=6tBanI8gj5Zzwz2cv1YeW60bYiO7iWivD0AgOcJ-GUU,7172
|
|
21
|
-
fluxloop_cli/commands/init.py,sha256=
|
|
21
|
+
fluxloop_cli/commands/init.py,sha256=1KUdOs9HqwgBZB94YyKjn95TrK3-t4cmBAUAMA6uwhc,10530
|
|
22
22
|
fluxloop_cli/commands/parse.py,sha256=5dgoHt6x2h7SBf6AOlbafoIWz3psEBrJdZyErIvgSgw,15417
|
|
23
23
|
fluxloop_cli/commands/record.py,sha256=IfoXdFAentyerBrIdQeweYr_kM7DjGks2aoKjjqCZ9Q,5139
|
|
24
|
-
fluxloop_cli/commands/run.py,sha256=
|
|
24
|
+
fluxloop_cli/commands/run.py,sha256=Mw5f6OdunjaOtaZAZoT9Ct1AL8FiBksqh2S3cz8_cCk,16431
|
|
25
25
|
fluxloop_cli/commands/status.py,sha256=ERZrWoSP3V7dz5A_TEE5b8E0nGwsPggP4nXw4tLOzxE,7841
|
|
26
26
|
fluxloop_cli/evaluation/__init__.py,sha256=voMYkA4S5Gb3GyvpTU8JnKu0novOyZBEag1cYEqYi1Q,759
|
|
27
27
|
fluxloop_cli/evaluation/artifacts.py,sha256=zDVB-SSQZ1JYWLPHwrfz5AuCv46pUFZfEfFY_5doAjQ,2176
|
|
@@ -41,8 +41,11 @@ fluxloop_cli/evaluation/prompts/information_completeness.py,sha256=BjZ-wEIANWWBQ
|
|
|
41
41
|
fluxloop_cli/evaluation/prompts/intent_recognition.py,sha256=QlroUq1y0wwWsT2qcS1NXdo7OwNqUu4Tw2xx7Frr50Q,1572
|
|
42
42
|
fluxloop_cli/evaluation/prompts/response_clarity.py,sha256=1OYyx39UfS-YR__VdqhLejHNxH1NBVFm7Bha3MuMN9Y,1418
|
|
43
43
|
fluxloop_cli/evaluation/prompts/response_consistency.py,sha256=tyO3Wsf2J_CvErCuNc2bw-UQWqRW_BupaBj3CS66_Yk,1550
|
|
44
|
-
fluxloop_cli
|
|
45
|
-
fluxloop_cli
|
|
46
|
-
fluxloop_cli
|
|
47
|
-
fluxloop_cli-0.2.
|
|
48
|
-
fluxloop_cli-0.2.
|
|
44
|
+
fluxloop_cli/testing/__init__.py,sha256=3FZ0JzA6gbeDAOlvplEiNDplbOXmNT-TYQ-4C7eHUlo,482
|
|
45
|
+
fluxloop_cli/testing/pytest_plugin.py,sha256=j9fAlh_uL_yRcXPPcx1AcvnxpKfUdfPnEf5ozJOQ7aA,12533
|
|
46
|
+
fluxloop_cli/testing/types.py,sha256=CcpoSnV-eOBc_Ngvg6ZRpeb-Bnz4eT2zBdGz0-icVB4,6190
|
|
47
|
+
fluxloop_cli-0.2.30.dist-info/METADATA,sha256=gp3keZepVZ1jT9xQTBG3KpyIsFfdKTCTdfmNi6mCK80,7074
|
|
48
|
+
fluxloop_cli-0.2.30.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
49
|
+
fluxloop_cli-0.2.30.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
|
|
50
|
+
fluxloop_cli-0.2.30.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
|
|
51
|
+
fluxloop_cli-0.2.30.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|