flowtoy 0.1.0__py2.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.
flowtoy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """flowtoy - minimal local-first flow runner prototype"""
2
+
3
+ __version__ = "0.1.0"
flowtoy/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .cli import cli
2
+
3
+
4
+ def main():
5
+ cli()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
flowtoy/api.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import Any, Optional
5
+
6
+ from fastapi import FastAPI
7
+
8
+ app = FastAPI()
9
+
10
+ # runtime-bound runner will be attached here
11
+ _RUNNER: Optional[Any] = None
12
+ _RUNNER_LOCK = threading.RLock()
13
+
14
+
15
+ def attach_runner(runner: Any):
16
+ """Attach the given runner instance so the API can expose its live state.
17
+
18
+ This stores a reference under a lock to make concurrent reads safer.
19
+ """
20
+ global _RUNNER
21
+ with _RUNNER_LOCK:
22
+ _RUNNER = runner
23
+
24
+
25
+ def _get_runner() -> Optional[Any]:
26
+ with _RUNNER_LOCK:
27
+ return _RUNNER
28
+
29
+
30
+ @app.get("/status")
31
+ def status():
32
+ r = _get_runner()
33
+ if r is None:
34
+ return {"status": "no-runner"}
35
+ # build richer per-step info: include timestamps and available output keys
36
+ steps_info = {}
37
+ for k, v in r.status.steps.items():
38
+ outputs = {}
39
+ try:
40
+ # flows may be mutated concurrently; guard with getattr checks
41
+ outputs = (
42
+ list(r.flows.get(k, {}).keys())
43
+ if getattr(r, "flows", None) is not None
44
+ else []
45
+ )
46
+ except Exception:
47
+ outputs = []
48
+ steps_info[k] = {
49
+ "state": v.state,
50
+ "started_at": v.started_at,
51
+ "ended_at": v.ended_at,
52
+ "notes": ([v.error] if v.error else []),
53
+ "outputs": outputs,
54
+ }
55
+
56
+ # determine currently running steps (may be multiple when parallelism is enabled)
57
+ running_steps = [
58
+ name for name, info in steps_info.items() if info.get("state") == "running"
59
+ ]
60
+ # keep a single current_step for backwards compat (first running step or None)
61
+ current_step = running_steps[0] if running_steps else None
62
+
63
+ total = len(steps_info)
64
+ completed = sum(
65
+ 1 for s in steps_info.values() if s.get("state") in ("succeeded", "failed")
66
+ )
67
+
68
+ return {
69
+ "run_id": r.status.run_id,
70
+ "started_at": r.status.started_at,
71
+ "ended_at": r.status.ended_at,
72
+ "total_steps": total,
73
+ "completed_steps": completed,
74
+ "current_step": current_step,
75
+ "running_steps": running_steps,
76
+ "running_count": len(running_steps),
77
+ "steps": steps_info,
78
+ }
79
+
80
+
81
+ @app.get("/outputs")
82
+ def outputs():
83
+ r = _get_runner()
84
+ if r is None:
85
+ return {}
86
+ # return a shallow copy to avoid concurrent mutation during JSON serialization
87
+ return dict(r.flows)
flowtoy/cli.py ADDED
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ from typing import List, Optional, Tuple
7
+
8
+ import typer
9
+ import uvicorn
10
+
11
+ from .api import app, attach_runner
12
+ from .config import load_yaml_files
13
+ from .runner import LocalRunner
14
+ from .runner_api import serve_runner_api_in_thread
15
+ from .tui import run_tui
16
+ from .webui import app as ui_app
17
+
18
+ cli = typer.Typer(
19
+ name="flowtoy", context_settings={"help_option_names": ["-h", "--help"]}
20
+ )
21
+
22
+ HELP_TEXT = """
23
+ flowtoy CLI
24
+
25
+ Commands:
26
+ run [CONFIG...] Run a flow from YAML config files
27
+ serve [CONFIG...] Serve the status API and run the flow at startup
28
+ webui [CONFIG...] Serve the UI and run the flow in background
29
+
30
+ Options:
31
+ -j, --json Print outputs as JSON
32
+ -o, --output-file Write JSON outputs to file
33
+ -q, --quiet Suppress stdout printing
34
+ -h, --help Show this help message
35
+
36
+ Examples:
37
+ flowtoy run flow.yaml
38
+ flowtoy run flow.yaml -j -o outputs.json
39
+ """
40
+
41
+
42
+ @cli.command("help")
43
+ def help_cmd():
44
+ """Show this CLI help."""
45
+ typer.echo(HELP_TEXT)
46
+
47
+
48
+ @cli.command("h")
49
+ def h_cmd():
50
+ """Alias for help."""
51
+ typer.echo(HELP_TEXT)
52
+
53
+
54
+ def run_flow(config_paths: List[str]) -> Tuple[dict, object]:
55
+ """Programmatic helper: load config, run the flow, and return (flows, status).
56
+
57
+ Returns a tuple (flows_dict, status_object).
58
+ """
59
+ cfg = load_yaml_files(config_paths)
60
+ r = LocalRunner(cfg)
61
+ r.run()
62
+ return r.flows, r.status
63
+
64
+
65
+ run_as_json_opt = typer.Option(False, "--json", "-j", help="Print outputs as JSON")
66
+ run_output_file_opt = typer.Option(
67
+ None, "--output-file", "-o", help="Write JSON outputs to file"
68
+ )
69
+ run_quiet_opt = typer.Option(False, "--quiet", "-q", help="Suppress stdout printing")
70
+ run_status_port_opt = typer.Option(
71
+ None,
72
+ "--status-port",
73
+ help=(
74
+ "Start an HTTP status server on this port so external UIs can poll runner state"
75
+ ),
76
+ )
77
+ run_max_workers_opt = typer.Option(
78
+ None, "--max-workers", help="Maximum number of worker threads for the runner"
79
+ )
80
+
81
+
82
+ @cli.command()
83
+ def run(
84
+ config: List[str],
85
+ as_json: bool = run_as_json_opt,
86
+ output_file: Optional[str] = run_output_file_opt,
87
+ quiet: bool = run_quiet_opt,
88
+ status_port: Optional[int] = run_status_port_opt,
89
+ max_workers: Optional[int] = run_max_workers_opt,
90
+ ):
91
+ """Run a flow from one or more YAML config files."""
92
+ # create the runner, attach it so the API exposes the live instance, then run
93
+ cfg = load_yaml_files(config)
94
+ logger = logging.getLogger(__name__)
95
+ # print a short summary to stdout so users can see what was loaded
96
+ flow_steps = cfg.get("flow") or []
97
+ logger.info(f"loaded config from {config}, flow steps: {len(flow_steps)}")
98
+ r = LocalRunner(cfg)
99
+ if max_workers:
100
+ r._max_workers = int(max_workers)
101
+ attach_runner(r)
102
+
103
+ status_host = "127.0.0.1"
104
+ # optionally start a simple runner status server the UI can poll
105
+ if status_port:
106
+ # enable logging so provider diagnostics are visible
107
+ logging.basicConfig(level=logging.INFO)
108
+ serve_runner_api_in_thread(r, host=status_host, port=status_port)
109
+
110
+ r.run()
111
+ flows = r.flows
112
+
113
+ if not quiet:
114
+ logger.info("run finished. outputs:")
115
+
116
+ if as_json or output_file:
117
+ payload = json.dumps(flows, indent=2)
118
+ if output_file:
119
+ with open(output_file, "w") as f:
120
+ f.write(payload)
121
+ if not quiet:
122
+ logger.info(f"wrote outputs to {output_file}")
123
+ else:
124
+ print(payload)
125
+ else:
126
+ if not quiet:
127
+ print(flows)
128
+ # If we started a status server, keep the process alive so external UIs can poll it
129
+ if status_port:
130
+ try:
131
+ if not quiet:
132
+ logger.info(
133
+ "status server running on http://%s:%d (press Ctrl-C to exit)",
134
+ status_host,
135
+ status_port,
136
+ )
137
+ # block until interrupted
138
+ while True:
139
+ import time
140
+
141
+ time.sleep(1)
142
+ except KeyboardInterrupt:
143
+ if not quiet:
144
+ logger.info("shutting down")
145
+
146
+
147
+ @cli.command()
148
+ def serve(config: List[str], host: str = "127.0.0.1", port: int = 8000):
149
+ """Serve the status API and run the flow once at startup."""
150
+ cfg = load_yaml_files(config)
151
+ r = LocalRunner(cfg)
152
+ attach_runner(r)
153
+
154
+ def _run():
155
+ r.run()
156
+
157
+ t = threading.Thread(target=_run, daemon=True)
158
+ t.start()
159
+ uvicorn.run(app, host=host, port=port)
160
+
161
+
162
+ def main():
163
+ cli()
164
+
165
+
166
+ @cli.command()
167
+ def tui(
168
+ config: Optional[List[str]] = typer.Argument(
169
+ None, help="Flow config files to run and monitor"
170
+ ),
171
+ status_url: Optional[str] = typer.Option(
172
+ None, "--status-url", help="Monitor a remote runner status endpoint"
173
+ ),
174
+ max_workers: Optional[int] = typer.Option(
175
+ None, "--max-workers", help="Maximum number of worker threads for the runner"
176
+ ),
177
+ show_logs: bool = typer.Option(
178
+ False, "--show-logs", help="Display server logs in the TUI"
179
+ ),
180
+ ):
181
+ """Run the terminal UI to monitor flow execution.
182
+
183
+ Two modes:
184
+ 1. All-in-one: flowtoy tui flow.yaml (runs flow and monitors it)
185
+ 2. External: flowtoy tui --status-url http://... (monitors remote flow)
186
+ """
187
+ # Mode 1: All-in-one - run flow and monitor it
188
+ if config:
189
+ if status_url:
190
+ typer.echo(
191
+ "Error: Cannot specify both config files and --status-url", err=True
192
+ )
193
+ raise typer.Exit(1)
194
+
195
+ # Find available port
196
+ import socket
197
+
198
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
199
+ s.bind(("127.0.0.1", 0))
200
+ s.listen(1)
201
+ port = s.getsockname()[1]
202
+
203
+ # Load config and start runner
204
+ cfg = load_yaml_files(config)
205
+ r = LocalRunner(cfg)
206
+ if max_workers:
207
+ r._max_workers = int(max_workers)
208
+ attach_runner(r)
209
+
210
+ # Create log capture if --show-logs is enabled
211
+ from .runner_api import LogCapture
212
+
213
+ log_capture = LogCapture(maxlen=100) if show_logs else None
214
+
215
+ # Start status server in background (capture logs if requested)
216
+ if log_capture:
217
+ serve_runner_api_in_thread(
218
+ r,
219
+ host="127.0.0.1",
220
+ port=port,
221
+ log_level="info",
222
+ log_capture=log_capture,
223
+ )
224
+ else:
225
+ serve_runner_api_in_thread(
226
+ r, host="127.0.0.1", port=port, log_level="error"
227
+ )
228
+
229
+ # Run flow in background thread
230
+ def _run():
231
+ r.run()
232
+
233
+ t = threading.Thread(target=_run, daemon=True)
234
+ t.start()
235
+
236
+ # Run TUI monitoring the local status server
237
+ run_tui(
238
+ status_url=f"http://127.0.0.1:{port}/status",
239
+ show_logs=show_logs,
240
+ log_capture=log_capture,
241
+ )
242
+
243
+ # Mode 2: External monitoring
244
+ else:
245
+ if not status_url:
246
+ typer.echo(
247
+ "Error: Must specify either config files or --status-url", err=True
248
+ )
249
+ raise typer.Exit(1)
250
+
251
+ if show_logs:
252
+ typer.echo(
253
+ "Warning: --show-logs only works in all-in-one mode, with config files",
254
+ err=True,
255
+ )
256
+
257
+ run_tui(status_url=status_url)
258
+
259
+
260
+ @cli.command()
261
+ def webui(
262
+ config: Optional[List[str]] = typer.Argument(
263
+ None, help="Flow config files to run and monitor"
264
+ ),
265
+ host: str = "127.0.0.1",
266
+ port: int = 8000,
267
+ status_url: Optional[str] = typer.Option(
268
+ None,
269
+ "--status-url",
270
+ help="Monitor a remote runner status endpoint instead of running a flow",
271
+ ),
272
+ max_workers: Optional[int] = typer.Option(
273
+ None, "--max-workers", help="Maximum number of worker threads for the runner"
274
+ ),
275
+ ):
276
+ """Serve the web UI to monitor flow execution.
277
+
278
+ Two modes:
279
+ 1. All-in-one: flowtoy webui flow.yaml (runs flow and serves UI)
280
+ 2. External: flowtoy webui --status-url http://... (serves UI for remote flow)
281
+ """
282
+ # Mode 1: External monitoring - serve UI for remote flow
283
+ if status_url:
284
+ if config:
285
+ typer.echo(
286
+ "Error: Cannot specify both config files and --status-url", err=True
287
+ )
288
+ raise typer.Exit(1)
289
+
290
+ # Set environment variable for webui app to proxy requests
291
+ import os
292
+
293
+ os.environ["RUNNER_STATUS_URL"] = status_url
294
+
295
+ uvicorn.run(ui_app, host=host, port=port)
296
+
297
+ # Mode 2: All-in-one - run flow and serve UI
298
+ else:
299
+ if not config:
300
+ typer.echo(
301
+ "Error: Must specify either config files or --status-url", err=True
302
+ )
303
+ raise typer.Exit(1)
304
+
305
+ # enable basic INFO logging so provider logs are visible in the demo
306
+ logging.basicConfig(level=logging.INFO)
307
+ cfg = load_yaml_files(config)
308
+ r = LocalRunner(cfg)
309
+ if max_workers:
310
+ r._max_workers = int(max_workers)
311
+ attach_runner(r)
312
+ # if STATUS_PORT env var or option is set via CLI later, we could also start
313
+ # a standalone runner status server; keep it simple for now and attach to API
314
+
315
+ def _run():
316
+ r.run()
317
+
318
+ t = threading.Thread(target=_run, daemon=True)
319
+ t.start()
320
+ uvicorn.run(ui_app, host=host, port=port)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
flowtoy/config.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any, Dict, List
5
+
6
+ import yaml
7
+
8
+
9
+ def load_yaml_files(paths: List[str]) -> Dict[str, Any]:
10
+ merged: Dict[str, Any] = {}
11
+ for p in paths:
12
+ with open(p, "r", encoding="utf-8") as f:
13
+ data = yaml.safe_load(f) or {}
14
+ merged = deep_merge(merged, data)
15
+ return merged
16
+
17
+
18
+ def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
19
+ """Deep-merge dict b into a and return the result (new dict)."""
20
+ out = copy.deepcopy(a)
21
+ for k, v in (b or {}).items():
22
+ if k in out and isinstance(out[k], dict) and isinstance(v, dict):
23
+ out[k] = deep_merge(out[k], v)
24
+ else:
25
+ out[k] = copy.deepcopy(v)
26
+ return out
27
+
28
+
29
+ def get_flow_steps(cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
30
+ return cfg.get("flow", []) or []
31
+
32
+
33
+ def get_sources(cfg: Dict[str, Any]) -> Dict[str, Any]:
34
+ return cfg.get("sources", {}) or {}
@@ -0,0 +1,82 @@
1
+ """Dynamic provider loading with lazy imports and entry point discovery.
2
+
3
+ Providers are intentionally imported only when they are first requested so
4
+ that heavy optional dependencies don't get imported at package import time.
5
+
6
+ This module exposes:
7
+ - create_provider(type_name, configuration)
8
+ dynamically loads and instantiates providers
9
+
10
+ - discover_entry_points()
11
+ finds providers registered via entry points
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from importlib.metadata import entry_points
17
+ from typing import Any, Callable, Dict
18
+
19
+ # Track if entry points have been discovered
20
+ _entry_points_discovered = False
21
+ # Cache for discovered entry points
22
+ _entry_point_providers: Dict[str, Callable[[dict], Any]] = {}
23
+
24
+
25
+ def discover_entry_points() -> Dict[str, Callable[[dict], Any]]:
26
+ """Discover providers registered via entry points.
27
+
28
+ Returns a dict mapping provider names to their constructor functions.
29
+ Entry points should be registered in the 'flowtoy.providers' group.
30
+ """
31
+ discovered = {}
32
+ try:
33
+ # Python 3.10+ API
34
+ eps = entry_points(group="flowtoy.providers")
35
+ except TypeError:
36
+ # Python 3.9 fallback - entry_points() returns SelectableGroups
37
+ all_eps = entry_points()
38
+ # SelectableGroups allows dictionary-style access
39
+ eps = all_eps["flowtoy.providers"] if "flowtoy.providers" in all_eps else []
40
+
41
+ for ep in eps:
42
+ try:
43
+ provider_class = ep.load() # type: ignore
44
+ discovered[ep.name] = provider_class # type: ignore
45
+ except Exception as e:
46
+ # Log but don't fail if a provider can't load
47
+ import sys
48
+
49
+ print(
50
+ f"Warning: Failed to load provider '{ep.name}': {e}",
51
+ file=sys.stderr,
52
+ )
53
+
54
+ return discovered
55
+
56
+
57
+ def create_provider(type_name: str, configuration: dict):
58
+ """Create an instance of the named provider.
59
+
60
+ Providers are loaded via entry points in the 'flowtoy.providers' group.
61
+ This applies to both built-in and third-party providers.
62
+
63
+ Providers are only imported when first requested (lazy loading).
64
+ """
65
+ global _entry_points_discovered
66
+
67
+ # Discover all providers on first use
68
+ if not _entry_points_discovered:
69
+ discovered = discover_entry_points()
70
+ _entry_point_providers.update(discovered)
71
+ _entry_points_discovered = True
72
+
73
+ if type_name not in _entry_point_providers:
74
+ available = ", ".join(sorted(_entry_point_providers.keys()))
75
+ raise ImportError(
76
+ f"Unknown provider type '{type_name}'. Available providers: {available}"
77
+ )
78
+
79
+ return _entry_point_providers[type_name](configuration)
80
+
81
+
82
+ __all__ = ["create_provider", "discover_entry_points"]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Dict, Optional
5
+
6
+ from .result import make_result
7
+
8
+
9
+ class EnvProvider:
10
+ type_name = "env"
11
+
12
+ def __init__(self, configuration: Dict[str, Any]):
13
+ self.configuration = configuration or {}
14
+
15
+ def call(self, input_payload: Optional[Any] = None) -> Any:
16
+ vars = self.configuration.get("vars", [])
17
+ data = {k: os.environ.get(k) for k in vars}
18
+ return make_result(success=True, code=0, data=data, notes=[], meta={})