fluxloop-cli 0.1.0__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.

Potentially problematic release.


This version of fluxloop-cli might be problematic. Click here for more details.

@@ -0,0 +1,227 @@
1
+ """
2
+ Status command for checking system and experiment status.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+
13
+ from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
14
+ from ..project_paths import resolve_config_path, resolve_root_dir, resolve_project_relative
15
+
16
+ app = typer.Typer()
17
+ console = Console()
18
+
19
+
20
+ @app.command()
21
+ def check(
22
+ verbose: bool = typer.Option(
23
+ False,
24
+ "--verbose",
25
+ "-v",
26
+ help="Show detailed information",
27
+ ),
28
+ project: Optional[str] = typer.Option(
29
+ None,
30
+ "--project",
31
+ help="Project name under the FluxLoop root",
32
+ ),
33
+ root: Path = typer.Option(
34
+ Path(DEFAULT_ROOT_DIR_NAME),
35
+ "--root",
36
+ help="FluxLoop root directory",
37
+ ),
38
+ ):
39
+ """
40
+ Check FluxLoop system status.
41
+
42
+ Verifies:
43
+ - SDK installation
44
+ - Collector connectivity
45
+ - Configuration validity
46
+ """
47
+ console.print("[bold]FluxLoop Status Check[/bold]\n")
48
+
49
+ status_table = Table(show_header=False)
50
+ status_table.add_column("Component", style="cyan")
51
+ status_table.add_column("Status")
52
+ status_table.add_column("Details", style="dim")
53
+
54
+ # Check SDK
55
+ try:
56
+ import fluxloop
57
+ sdk_version = fluxloop.__version__
58
+ status_table.add_row(
59
+ "SDK",
60
+ "[green]✓ Installed[/green]",
61
+ f"Version {sdk_version}"
62
+ )
63
+ except ImportError:
64
+ status_table.add_row(
65
+ "SDK",
66
+ "[red]✗ Not installed[/red]",
67
+ "Run: pip install fluxloop-sdk"
68
+ )
69
+
70
+ # Check collector connectivity
71
+ try:
72
+ from fluxloop import get_config
73
+ from fluxloop.client import FluxLoopClient
74
+
75
+ config = get_config()
76
+ client = FluxLoopClient()
77
+
78
+ # Try to connect (this would need a health endpoint)
79
+ status_table.add_row(
80
+ "Collector",
81
+ "[yellow]? Unknown[/yellow]",
82
+ f"URL: {config.collector_url}"
83
+ )
84
+ except Exception as e:
85
+ status_table.add_row(
86
+ "Collector",
87
+ "[red]✗ Error[/red]",
88
+ str(e) if verbose else "Connection failed"
89
+ )
90
+
91
+ # Check for configuration file
92
+ resolved_config = resolve_config_path(DEFAULT_CONFIG_PATH, project, root)
93
+ if resolved_config.exists():
94
+ status_table.add_row(
95
+ "Config",
96
+ "[green]✓ Found[/green]",
97
+ str(resolved_config)
98
+ )
99
+ else:
100
+ status_table.add_row(
101
+ "Config",
102
+ "[yellow]- Not found[/yellow]",
103
+ "Run: fluxloop init project"
104
+ )
105
+
106
+ # Check environment
107
+ import os
108
+ if os.getenv("FLUXLOOP_API_KEY"):
109
+ status_table.add_row(
110
+ "API Key",
111
+ "[green]✓ Set[/green]",
112
+ "****" + os.getenv("FLUXLOOP_API_KEY")[-4:] if verbose else "Configured"
113
+ )
114
+ else:
115
+ status_table.add_row(
116
+ "API Key",
117
+ "[yellow]- Not set[/yellow]",
118
+ "Set FLUXLOOP_API_KEY in .env"
119
+ )
120
+
121
+ console.print(status_table)
122
+
123
+ # Show recommendations
124
+ recommendations = []
125
+ if not resolved_config.exists():
126
+ recommendations.append("Initialize a project: [cyan]fluxloop init project[/cyan]")
127
+ if not os.getenv("FLUXLOOP_API_KEY"):
128
+ recommendations.append("Set up API key in .env file")
129
+
130
+ if recommendations:
131
+ console.print("\n[bold]Recommendations:[/bold]")
132
+ for rec in recommendations:
133
+ console.print(f" • {rec}")
134
+
135
+
136
+ @app.command()
137
+ def experiments(
138
+ output_dir: Path = typer.Option(
139
+ Path("./experiments"),
140
+ "--output",
141
+ "-o",
142
+ help="Directory containing experiment results",
143
+ ),
144
+ project: Optional[str] = typer.Option(
145
+ None,
146
+ "--project",
147
+ help="Project name under the FluxLoop root",
148
+ ),
149
+ root: Path = typer.Option(
150
+ Path(DEFAULT_ROOT_DIR_NAME),
151
+ "--root",
152
+ help="FluxLoop root directory",
153
+ ),
154
+ limit: int = typer.Option(
155
+ 10,
156
+ "--limit",
157
+ "-l",
158
+ help="Number of experiments to show",
159
+ ),
160
+ ):
161
+ """
162
+ List recent experiments and their results.
163
+ """
164
+ resolved_output = resolve_project_relative(output_dir, project, root)
165
+
166
+ if not resolved_output.exists():
167
+ console.print(f"[yellow]No experiments found in:[/yellow] {resolved_output}")
168
+ console.print("\nRun an experiment first: [cyan]fluxloop run experiment[/cyan]")
169
+ return
170
+
171
+ # Find experiment directories
172
+ exp_dirs = sorted(
173
+ [d for d in resolved_output.iterdir() if d.is_dir()],
174
+ key=lambda x: x.stat().st_mtime,
175
+ reverse=True
176
+ )[:limit]
177
+
178
+ if not exp_dirs:
179
+ console.print("[yellow]No experiments found[/yellow]")
180
+ return
181
+
182
+ console.print(f"[bold]Recent Experiments[/bold] (showing {len(exp_dirs)} of {len(list(resolved_output.iterdir()))})\n")
183
+
184
+ for exp_dir in exp_dirs:
185
+ # Try to load summary
186
+ summary_file = exp_dir / "summary.json"
187
+ if summary_file.exists():
188
+ import json
189
+ summary = json.loads(summary_file.read_text())
190
+
191
+ # Create mini table for each experiment
192
+ exp_panel = Panel(
193
+ f"[cyan]Name:[/cyan] {summary.get('name', 'Unknown')}\n"
194
+ f"[cyan]Date:[/cyan] {summary.get('date', 'Unknown')}\n"
195
+ f"[cyan]Runs:[/cyan] {summary.get('total_runs', 0)}\n"
196
+ f"[cyan]Success Rate:[/cyan] {summary.get('success_rate', 0)*100:.1f}%\n"
197
+ f"[cyan]Avg Duration:[/cyan] {summary.get('avg_duration_ms', 0):.0f}ms",
198
+ title=f"[bold]{exp_dir.name}[/bold]",
199
+ border_style="blue",
200
+ )
201
+ console.print(exp_panel)
202
+ else:
203
+ console.print(f"📁 {exp_dir.name} [dim](no summary available)[/dim]")
204
+
205
+ console.print() # Add spacing
206
+
207
+
208
+ @app.command()
209
+ def traces(
210
+ experiment_id: Optional[str] = typer.Argument(
211
+ None,
212
+ help="Experiment ID to show traces for",
213
+ ),
214
+ limit: int = typer.Option(
215
+ 10,
216
+ "--limit",
217
+ "-l",
218
+ help="Number of traces to show",
219
+ ),
220
+ ):
221
+ """
222
+ List recent traces from experiments.
223
+ """
224
+ # This would connect to the collector service
225
+ console.print("[yellow]Trace viewing requires collector service[/yellow]")
226
+ console.print("\nThis feature will be available when the collector is running.")
227
+ console.print("For now, check the experiment output directories for trace data.")
@@ -0,0 +1,159 @@
1
+ """
2
+ Configuration loader for experiments.
3
+ """
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+
9
+ import yaml
10
+ from pydantic import ValidationError
11
+
12
+ from .project_paths import resolve_config_path
13
+
14
+ # Add shared schemas to path
15
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "shared"))
16
+
17
+ from fluxloop.schemas import ExperimentConfig
18
+
19
+
20
+ def load_experiment_config(
21
+ config_file: Path,
22
+ *,
23
+ require_inputs_file: bool = True,
24
+ ) -> ExperimentConfig:
25
+ """
26
+ Load and validate experiment configuration from YAML file.
27
+ """
28
+ resolved_path = resolve_config_path(config_file, project=None, root=None)
29
+ if not resolved_path.exists():
30
+ raise FileNotFoundError(f"Configuration file not found: {config_file}")
31
+
32
+ # Load YAML
33
+ with open(resolved_path) as f:
34
+ data = yaml.safe_load(f)
35
+
36
+ if not data:
37
+ raise ValueError("Configuration file is empty")
38
+
39
+ # Validate and create config object
40
+ try:
41
+ config = ExperimentConfig(**data)
42
+ config.set_source_dir(resolved_path.parent)
43
+ resolved_input_count = _resolve_input_count(
44
+ config,
45
+ require_inputs_file=require_inputs_file,
46
+ )
47
+ config.set_resolved_input_count(resolved_input_count)
48
+ except ValidationError as e:
49
+ # Format validation errors nicely
50
+ errors = []
51
+ for error in e.errors():
52
+ loc = ".".join(str(x) for x in error["loc"])
53
+ msg = error["msg"]
54
+ errors.append(f" - {loc}: {msg}")
55
+
56
+ raise ValueError(
57
+ f"Invalid configuration:\n" + "\n".join(errors)
58
+ )
59
+
60
+ return config
61
+
62
+
63
+ def _resolve_input_count(
64
+ config: ExperimentConfig,
65
+ *,
66
+ require_inputs_file: bool = True,
67
+ ) -> int:
68
+ """Determine the effective number of inputs for this configuration."""
69
+ if config.inputs_file:
70
+ inputs_path = (config.get_source_dir() / Path(config.inputs_file)
71
+ if config.get_source_dir() and not Path(config.inputs_file).is_absolute()
72
+ else Path(config.inputs_file)).resolve()
73
+
74
+ if not inputs_path.exists():
75
+ if require_inputs_file:
76
+ raise FileNotFoundError(
77
+ f"Inputs file not found when loading config: {inputs_path}"
78
+ )
79
+ return len(config.base_inputs)
80
+
81
+ with open(inputs_path, "r", encoding="utf-8") as f:
82
+ payload = yaml.safe_load(f)
83
+
84
+ if not payload:
85
+ if require_inputs_file:
86
+ raise ValueError(f"Inputs file is empty: {inputs_path}")
87
+ return len(config.base_inputs)
88
+
89
+ if isinstance(payload, dict):
90
+ entries = payload.get("inputs")
91
+ if entries is None:
92
+ if require_inputs_file:
93
+ raise ValueError("Inputs file must contain an 'inputs' list when using mapping format")
94
+ return len(config.base_inputs)
95
+ elif isinstance(payload, list):
96
+ entries = payload
97
+ else:
98
+ raise ValueError("Inputs file must be either a list or a mapping with an 'inputs' key")
99
+
100
+ if not isinstance(entries, list):
101
+ if require_inputs_file:
102
+ raise ValueError("Inputs entries must be provided as a list")
103
+ return len(config.base_inputs)
104
+
105
+ return len(entries)
106
+
107
+ # No external file – rely on base_inputs multiplied by variation count
108
+ base_count = len(config.base_inputs)
109
+ variation_multiplier = max(1, config.variation_count)
110
+ return base_count * variation_multiplier if base_count else variation_multiplier
111
+
112
+
113
+ def save_experiment_config(config: ExperimentConfig, config_file: Path) -> None:
114
+ """
115
+ Save experiment configuration to YAML file.
116
+
117
+ Args:
118
+ config: ExperimentConfig object to save
119
+ config_file: Path to save configuration to
120
+ """
121
+ # Convert to dict and save
122
+ data = config.to_dict()
123
+
124
+ with open(config_file, "w") as f:
125
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
126
+
127
+
128
+ def merge_config_overrides(
129
+ config: ExperimentConfig,
130
+ overrides: Dict[str, Any]
131
+ ) -> ExperimentConfig:
132
+ """
133
+ Merge override values into configuration.
134
+
135
+ Args:
136
+ config: Base configuration
137
+ overrides: Dictionary of overrides (dot notation supported)
138
+
139
+ Returns:
140
+ New configuration with overrides applied
141
+ """
142
+ # Convert config to dict
143
+ data = config.to_dict()
144
+
145
+ # Apply overrides
146
+ for key, value in overrides.items():
147
+ # Support dot notation (e.g., "runner.timeout")
148
+ keys = key.split(".")
149
+ current = data
150
+
151
+ for k in keys[:-1]:
152
+ if k not in current:
153
+ current[k] = {}
154
+ current = current[k]
155
+
156
+ current[keys[-1]] = value
157
+
158
+ # Create new config
159
+ return ExperimentConfig(**data)
@@ -0,0 +1,12 @@
1
+ """Shared constants for the FluxLoop CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ DEFAULT_ROOT_DIR_NAME = "fluxloop"
7
+ DEFAULT_CONFIG_FILENAME = "setting.yaml"
8
+ LEGACY_CONFIG_FILENAMES = ("fluxloop.yaml",)
9
+
10
+
11
+ DEFAULT_CONFIG_PATH = Path(DEFAULT_CONFIG_FILENAME)
12
+
@@ -0,0 +1,158 @@
1
+ """Utilities for generating input datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Sequence
9
+
10
+ import yaml
11
+
12
+ from fluxloop.schemas import (
13
+ ExperimentConfig,
14
+ InputGenerationMode,
15
+ PersonaConfig,
16
+ VariationStrategy,
17
+ )
18
+
19
+ from .llm_generator import DEFAULT_STRATEGIES, LLMGenerationError, generate_llm_inputs
20
+
21
+ if TYPE_CHECKING:
22
+ from .llm_generator import LLMClient
23
+
24
+
25
+ @dataclass
26
+ class GenerationSettings:
27
+ """Options controlling input generation."""
28
+
29
+ limit: Optional[int] = None
30
+ dry_run: bool = False
31
+ mode: Optional[InputGenerationMode] = None
32
+ strategies: Optional[Sequence[VariationStrategy]] = None
33
+ use_cache: bool = True
34
+ llm_api_key_override: Optional[str] = None
35
+ llm_client: Optional["LLMClient"] = None
36
+
37
+
38
+ @dataclass
39
+ class GeneratedInput:
40
+ """Represents a single generated input entry."""
41
+
42
+ input: str
43
+ metadata: Dict[str, object] = field(default_factory=dict)
44
+
45
+
46
+ @dataclass
47
+ class GenerationResult:
48
+ """Container for generation output."""
49
+
50
+ entries: List[GeneratedInput]
51
+ metadata: Dict[str, object]
52
+
53
+ def to_yaml(self) -> str:
54
+ payload = {
55
+ "generated_at": dt.datetime.utcnow().isoformat() + "Z",
56
+ "metadata": self.metadata,
57
+ "inputs": [
58
+ {
59
+ "input": entry.input,
60
+ "metadata": entry.metadata,
61
+ }
62
+ for entry in self.entries
63
+ ],
64
+ }
65
+ return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
66
+
67
+ def to_json(self) -> str:
68
+ return json.dumps(
69
+ {
70
+ "generated_at": dt.datetime.utcnow().isoformat() + "Z",
71
+ "metadata": self.metadata,
72
+ "inputs": [
73
+ {
74
+ "input": entry.input,
75
+ "metadata": entry.metadata,
76
+ }
77
+ for entry in self.entries
78
+ ],
79
+ },
80
+ indent=2,
81
+ )
82
+
83
+
84
+ class GenerationError(Exception):
85
+ """Raised when input generation cannot proceed."""
86
+
87
+
88
+ def generate_inputs(
89
+ config: ExperimentConfig,
90
+ settings: GenerationSettings,
91
+ recording_template: Optional[Dict[str, Any]] = None,
92
+ ) -> GenerationResult:
93
+ """Generate deterministic input entries based on configuration."""
94
+ base_inputs = config.base_inputs
95
+ config_for_generation = config
96
+ if recording_template:
97
+ base_inputs = [
98
+ {
99
+ "input": recording_template["base_content"],
100
+ "metadata": {
101
+ "source": "recording",
102
+ "target": recording_template.get("target"),
103
+ },
104
+ }
105
+ ]
106
+ config_for_generation = config.model_copy(update={"base_inputs": base_inputs})
107
+
108
+ if not base_inputs:
109
+ raise GenerationError("base_inputs must be defined to generate inputs")
110
+
111
+ mode = settings.mode or config_for_generation.input_generation.mode
112
+
113
+ if mode == InputGenerationMode.LLM:
114
+ strategies: Sequence[VariationStrategy]
115
+ if settings.strategies and len(settings.strategies) > 0:
116
+ strategies = list(settings.strategies)
117
+ elif config_for_generation.variation_strategies:
118
+ strategies = config_for_generation.variation_strategies
119
+ else:
120
+ strategies = DEFAULT_STRATEGIES
121
+
122
+ try:
123
+ raw_entries = generate_llm_inputs(
124
+ config=config_for_generation,
125
+ strategies=strategies,
126
+ settings=settings,
127
+ )
128
+ except LLMGenerationError as exc:
129
+ raise GenerationError(str(exc)) from exc
130
+
131
+ entries = [
132
+ GeneratedInput(input=item["input"], metadata=item.get("metadata", {}))
133
+ for item in raw_entries
134
+ ]
135
+
136
+ metadata = {
137
+ "config_name": config_for_generation.name,
138
+ "total_base_inputs": len(base_inputs),
139
+ "total_personas": len(config_for_generation.personas),
140
+ "strategies": [strategy.value for strategy in strategies],
141
+ "limit": settings.limit,
142
+ "generation_mode": InputGenerationMode.LLM.value,
143
+ "llm_provider": config_for_generation.input_generation.llm.provider,
144
+ "llm_model": config_for_generation.input_generation.llm.model,
145
+ }
146
+
147
+ if recording_template:
148
+ for entry in entries:
149
+ entry.metadata["args_template"] = "use_recorded"
150
+ entry.metadata["template_kwargs"] = recording_template.get("full_kwargs")
151
+ metadata["recording_target"] = recording_template.get("target")
152
+ metadata["recording_base_input"] = recording_template.get("base_content")
153
+
154
+ return GenerationResult(entries=entries, metadata=metadata)
155
+
156
+ raise GenerationError(
157
+ "Only LLM-based generation is supported. Set input_generation.mode to 'llm'"
158
+ )