flowtoy 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.
- flowtoy/__init__.py +3 -0
- flowtoy/__main__.py +9 -0
- flowtoy/api.py +87 -0
- flowtoy/cli.py +324 -0
- flowtoy/config.py +34 -0
- flowtoy/providers/__init__.py +82 -0
- flowtoy/providers/env.py +18 -0
- flowtoy/providers/process.py +214 -0
- flowtoy/providers/rest.py +52 -0
- flowtoy/providers/result.py +68 -0
- flowtoy/runner.py +427 -0
- flowtoy/runner_api.py +145 -0
- flowtoy/templating.py +43 -0
- flowtoy/tui.py +341 -0
- flowtoy/webui.py +64 -0
- flowtoy-0.1.0.dist-info/METADATA +20 -0
- flowtoy-0.1.0.dist-info/RECORD +20 -0
- flowtoy-0.1.0.dist-info/WHEEL +5 -0
- flowtoy-0.1.0.dist-info/entry_points.txt +7 -0
- flowtoy-0.1.0.dist-info/licenses/LICENSE +28 -0
flowtoy/__init__.py
ADDED
flowtoy/__main__.py
ADDED
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"]
|
flowtoy/providers/env.py
ADDED
|
@@ -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={})
|