flexiflow 0.3.1__tar.gz
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.
- flexiflow-0.3.1/PKG-INFO +15 -0
- flexiflow-0.3.1/README.md +469 -0
- flexiflow-0.3.1/flexiflow/__init__.py +82 -0
- flexiflow-0.3.1/flexiflow/__main__.py +5 -0
- flexiflow-0.3.1/flexiflow/api.py +30 -0
- flexiflow-0.3.1/flexiflow/cli.py +117 -0
- flexiflow-0.3.1/flexiflow/component.py +52 -0
- flexiflow-0.3.1/flexiflow/config_loader.py +130 -0
- flexiflow-0.3.1/flexiflow/engine.py +50 -0
- flexiflow-0.3.1/flexiflow/errors.py +250 -0
- flexiflow-0.3.1/flexiflow/event_manager.py +191 -0
- flexiflow-0.3.1/flexiflow/explain.py +658 -0
- flexiflow-0.3.1/flexiflow/extras/__init__.py +31 -0
- flexiflow-0.3.1/flexiflow/extras/persist_json.py +172 -0
- flexiflow-0.3.1/flexiflow/extras/persist_sqlite.py +229 -0
- flexiflow-0.3.1/flexiflow/extras/retry.py +77 -0
- flexiflow-0.3.1/flexiflow/imports.py +47 -0
- flexiflow-0.3.1/flexiflow/logger.py +39 -0
- flexiflow-0.3.1/flexiflow/pack_loader.py +308 -0
- flexiflow-0.3.1/flexiflow/reload.py +20 -0
- flexiflow-0.3.1/flexiflow/state_machine.py +90 -0
- flexiflow-0.3.1/flexiflow/statepack.py +211 -0
- flexiflow-0.3.1/flexiflow/visualize.py +186 -0
- flexiflow-0.3.1/flexiflow.egg-info/PKG-INFO +15 -0
- flexiflow-0.3.1/flexiflow.egg-info/SOURCES.txt +45 -0
- flexiflow-0.3.1/flexiflow.egg-info/dependency_links.txt +1 -0
- flexiflow-0.3.1/flexiflow.egg-info/entry_points.txt +2 -0
- flexiflow-0.3.1/flexiflow.egg-info/requires.txt +13 -0
- flexiflow-0.3.1/flexiflow.egg-info/top_level.txt +1 -0
- flexiflow-0.3.1/pyproject.toml +22 -0
- flexiflow-0.3.1/setup.cfg +4 -0
- flexiflow-0.3.1/tests/test_cli_smoke.py +60 -0
- flexiflow-0.3.1/tests/test_engine.py +47 -0
- flexiflow-0.3.1/tests/test_errors.py +253 -0
- flexiflow-0.3.1/tests/test_event_manager.py +211 -0
- flexiflow-0.3.1/tests/test_example_integration.py +193 -0
- flexiflow-0.3.1/tests/test_explain.py +749 -0
- flexiflow-0.3.1/tests/test_imports.py +299 -0
- flexiflow-0.3.1/tests/test_observability.py +173 -0
- flexiflow-0.3.1/tests/test_pack_loader.py +297 -0
- flexiflow-0.3.1/tests/test_persist_json.py +133 -0
- flexiflow-0.3.1/tests/test_persist_sqlite.py +263 -0
- flexiflow-0.3.1/tests/test_public_api.py +81 -0
- flexiflow-0.3.1/tests/test_retry.py +90 -0
- flexiflow-0.3.1/tests/test_state_machine.py +116 -0
- flexiflow-0.3.1/tests/test_statepack.py +334 -0
- flexiflow-0.3.1/tests/test_visualize.py +402 -0
flexiflow-0.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flexiflow
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: A small async component engine with events, state machines, and a minimal CLI.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: PyYAML>=6.0
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
10
|
+
Requires-Dist: coverage>=7.0; extra == "dev"
|
|
11
|
+
Provides-Extra: reload
|
|
12
|
+
Requires-Dist: watchfiles>=0.21; extra == "reload"
|
|
13
|
+
Provides-Extra: api
|
|
14
|
+
Requires-Dist: fastapi>=0.110; extra == "api"
|
|
15
|
+
Requires-Dist: uvicorn>=0.27; extra == "api"
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# FlexiFlow
|
|
2
|
+
|
|
3
|
+
FlexiFlow is a small, library-first async engine that wires:
|
|
4
|
+
|
|
5
|
+
- Components (rules + state machine)
|
|
6
|
+
- An async event bus (pub/sub with priority, filters, and sequential/concurrent delivery)
|
|
7
|
+
- Structured logging with correlation IDs
|
|
8
|
+
- A minimal CLI for demos
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Editable install:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Dev dependencies (tests):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e ".[dev]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Optional hot reload:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e ".[reload]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Optional API:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -e ".[api]"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
flexiflow register --config examples/config.yaml --start
|
|
40
|
+
flexiflow handle --config examples/config.yaml confirm --content confirmed
|
|
41
|
+
flexiflow handle --config examples/config.yaml complete
|
|
42
|
+
flexiflow update_rules --config examples/config.yaml examples/new_rules.yaml
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Environment variable config
|
|
46
|
+
|
|
47
|
+
If you omit `--config`, FlexiFlow will use:
|
|
48
|
+
|
|
49
|
+
- `FLEXIFLOW_CONFIG=/path/to/config.yaml`
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
set FLEXIFLOW_CONFIG=examples/config.yaml
|
|
55
|
+
flexiflow register --start
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Message types in the default state machine
|
|
59
|
+
|
|
60
|
+
- start
|
|
61
|
+
- confirm (requires content == "confirmed")
|
|
62
|
+
- cancel
|
|
63
|
+
- complete
|
|
64
|
+
- error
|
|
65
|
+
- acknowledge
|
|
66
|
+
|
|
67
|
+
## Events
|
|
68
|
+
|
|
69
|
+
FlexiFlow uses an async pub/sub event bus with priorities, optional filters, and two delivery modes.
|
|
70
|
+
|
|
71
|
+
### Subscribing
|
|
72
|
+
|
|
73
|
+
`subscribe()` registers a handler and returns a `SubscriptionHandle` you can use for cleanup.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
handle = await bus.subscribe("my.event", "my_component", handler, priority=2)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **Priorities**: `1` (highest) .. `5` (lowest, default=3)
|
|
80
|
+
- **Optional filter**: `filter_fn(event_name, data) -> bool` gates whether a handler runs
|
|
81
|
+
|
|
82
|
+
### Unsubscribing
|
|
83
|
+
|
|
84
|
+
For long-running processes, unsubscribe to avoid leaks:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
bus.unsubscribe(handle) # returns True if removed
|
|
88
|
+
bus.unsubscribe_all("my_component") # returns count removed
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Publishing
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
await bus.publish("my.event", data, delivery="sequential", on_error="continue")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Delivery modes
|
|
98
|
+
|
|
99
|
+
| Mode | Behavior |
|
|
100
|
+
|------|----------|
|
|
101
|
+
| **sequential** (default) | Handlers run in priority order (1→5). Each awaited before the next. |
|
|
102
|
+
| **concurrent** | All handlers scheduled concurrently. Priority affects launch order, not completion. |
|
|
103
|
+
|
|
104
|
+
#### Error policy
|
|
105
|
+
|
|
106
|
+
| Policy | Behavior |
|
|
107
|
+
|--------|----------|
|
|
108
|
+
| **continue** (default) | Logs exceptions and proceeds to remaining handlers |
|
|
109
|
+
| **raise** | Raises immediately on first exception |
|
|
110
|
+
|
|
111
|
+
### Notes
|
|
112
|
+
|
|
113
|
+
- If an event has no subscribers, publishing is a no-op.
|
|
114
|
+
- In concurrent mode, completion order is non-deterministic by design.
|
|
115
|
+
|
|
116
|
+
## Retry Decorator
|
|
117
|
+
|
|
118
|
+
For handlers that need retry logic, use the optional decorator:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from flexiflow.extras.retry import retry_async, RetryConfig
|
|
122
|
+
|
|
123
|
+
@retry_async(RetryConfig(max_attempts=3, base_delay=0.2, jitter=0.2))
|
|
124
|
+
async def my_handler(data):
|
|
125
|
+
# will retry up to 3 times with exponential backoff
|
|
126
|
+
...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This keeps retry policy out of the engine and at the handler boundary where it belongs.
|
|
130
|
+
|
|
131
|
+
## Observability
|
|
132
|
+
|
|
133
|
+
FlexiFlow emits structured events for observability. Subscribe to these to build monitoring, metrics, or debugging tools.
|
|
134
|
+
|
|
135
|
+
| Event | When | Payload |
|
|
136
|
+
|-------|------|---------|
|
|
137
|
+
| `engine.component.registered` | Component registered with engine | `{component}` |
|
|
138
|
+
| `component.message.received` | Message received by component | `{component, message}` |
|
|
139
|
+
| `state.changed` | State machine transition | `{component, from_state, to_state}` |
|
|
140
|
+
| `event.handler.failed` | Handler raised exception (continue mode) | `{event_name, component_name, exception}` |
|
|
141
|
+
|
|
142
|
+
### Example: Logging all state changes
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
async def log_transitions(data):
|
|
146
|
+
print(f"{data['component']}: {data['from_state']} → {data['to_state']}")
|
|
147
|
+
|
|
148
|
+
await bus.subscribe("state.changed", "logger", log_transitions)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Design notes
|
|
152
|
+
|
|
153
|
+
- Observability events are **fire-and-forget** — they never block core execution
|
|
154
|
+
- `event.handler.failed` does **not** fire recursively (handlers for this event that fail are swallowed)
|
|
155
|
+
- These events are separate from logging — use both as needed
|
|
156
|
+
|
|
157
|
+
## Custom States
|
|
158
|
+
|
|
159
|
+
Load custom state classes via dotted paths:
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
initial_state: "mypkg.states:MyInitialState"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The class must be a `State` subclass. It will be auto-registered under its class name.
|
|
166
|
+
|
|
167
|
+
### State Packs
|
|
168
|
+
|
|
169
|
+
For components that need multiple custom states, use the `states:` mapping to register them all up-front:
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
name: my_component
|
|
173
|
+
states:
|
|
174
|
+
InitialState: "mypkg.states:InitialState"
|
|
175
|
+
Processing: "mypkg.states:ProcessingState"
|
|
176
|
+
Complete: "mypkg.states:CompleteState"
|
|
177
|
+
initial_state: InitialState
|
|
178
|
+
rules: []
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The mapping:
|
|
182
|
+
- Keys are the registry names (used for lookups)
|
|
183
|
+
- Values are dotted paths (`module:ClassName`)
|
|
184
|
+
- All states are registered before `initial_state` is resolved
|
|
185
|
+
- `initial_state` can reference a key from the mapping or be a dotted path itself
|
|
186
|
+
|
|
187
|
+
## Persistence
|
|
188
|
+
|
|
189
|
+
Save and restore component state with the JSON persistence adapter:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from flexiflow.extras import save_component, load_snapshot, restore_component
|
|
193
|
+
|
|
194
|
+
# Save current state
|
|
195
|
+
save_component(component, "state.json", metadata={"version": "1.0"})
|
|
196
|
+
|
|
197
|
+
# Later: restore from file
|
|
198
|
+
snapshot = load_snapshot("state.json")
|
|
199
|
+
restored = restore_component(snapshot, engine)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### What's persisted
|
|
203
|
+
|
|
204
|
+
- Component name
|
|
205
|
+
- Current state class name
|
|
206
|
+
- Rules list
|
|
207
|
+
- Optional user metadata
|
|
208
|
+
|
|
209
|
+
### What's NOT persisted
|
|
210
|
+
|
|
211
|
+
- Event subscriptions
|
|
212
|
+
- Handler functions
|
|
213
|
+
- Logger/event bus references (re-injected on restore)
|
|
214
|
+
|
|
215
|
+
### SQLite Persistence
|
|
216
|
+
|
|
217
|
+
For production use cases needing history and durability, use the SQLite adapter:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
import sqlite3
|
|
221
|
+
from flexiflow.extras import (
|
|
222
|
+
save_snapshot_sqlite,
|
|
223
|
+
load_latest_snapshot_sqlite,
|
|
224
|
+
list_snapshots_sqlite,
|
|
225
|
+
)
|
|
226
|
+
from flexiflow.extras import ComponentSnapshot
|
|
227
|
+
|
|
228
|
+
conn = sqlite3.connect("state.db")
|
|
229
|
+
|
|
230
|
+
# Save a snapshot
|
|
231
|
+
snapshot = ComponentSnapshot(
|
|
232
|
+
name="my_component",
|
|
233
|
+
current_state="AwaitingConfirmation",
|
|
234
|
+
rules=[{"key": "value"}],
|
|
235
|
+
metadata={"version": "1.0"},
|
|
236
|
+
)
|
|
237
|
+
row_id = save_snapshot_sqlite(conn, snapshot)
|
|
238
|
+
|
|
239
|
+
# Load the latest snapshot for a component
|
|
240
|
+
latest = load_latest_snapshot_sqlite(conn, "my_component")
|
|
241
|
+
|
|
242
|
+
# List snapshot history
|
|
243
|
+
history = list_snapshots_sqlite(conn, "my_component", limit=10)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The SQLite adapter:
|
|
247
|
+
- Uses the same `ComponentSnapshot` model as JSON persistence
|
|
248
|
+
- Stores full history (not just latest state)
|
|
249
|
+
- Returns `None` for missing components (not an exception)
|
|
250
|
+
- Raises `ValueError` for corrupt JSON in stored rows
|
|
251
|
+
|
|
252
|
+
**Retention**: Snapshots accumulate indefinitely. Use `prune_snapshots_sqlite()` to clean up:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
from flexiflow.extras import prune_snapshots_sqlite
|
|
256
|
+
|
|
257
|
+
# Keep only the 10 most recent snapshots
|
|
258
|
+
deleted = prune_snapshots_sqlite(conn, "my_component", keep_last=10)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Examples
|
|
262
|
+
|
|
263
|
+
See [`examples/embedded_app/`](examples/embedded_app/) for a complete working example showing:
|
|
264
|
+
|
|
265
|
+
- Custom states with state packs
|
|
266
|
+
- SQLite persistence with automatic snapshots on state change
|
|
267
|
+
- Observability event subscriptions
|
|
268
|
+
- Retention management with pruning
|
|
269
|
+
|
|
270
|
+
## Cookbook
|
|
271
|
+
|
|
272
|
+
### Embedded Usage Pattern
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
import sqlite3
|
|
276
|
+
from flexiflow.component import AsyncComponent
|
|
277
|
+
from flexiflow.config_loader import ConfigLoader
|
|
278
|
+
from flexiflow.engine import FlexiFlowEngine
|
|
279
|
+
from flexiflow.state_machine import StateMachine
|
|
280
|
+
from flexiflow.extras import (
|
|
281
|
+
ComponentSnapshot,
|
|
282
|
+
load_latest_snapshot_sqlite,
|
|
283
|
+
save_snapshot_sqlite,
|
|
284
|
+
prune_snapshots_sqlite,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Load config (registers custom states)
|
|
288
|
+
config = ConfigLoader.load_component_config("config.yaml")
|
|
289
|
+
conn = sqlite3.connect("state.db")
|
|
290
|
+
engine = FlexiFlowEngine()
|
|
291
|
+
|
|
292
|
+
# Restore or create component
|
|
293
|
+
snapshot = load_latest_snapshot_sqlite(conn, config.name)
|
|
294
|
+
if snapshot:
|
|
295
|
+
component = AsyncComponent(
|
|
296
|
+
name=snapshot.name,
|
|
297
|
+
rules=list(snapshot.rules),
|
|
298
|
+
state_machine=StateMachine.from_name(snapshot.current_state),
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
component = AsyncComponent(
|
|
302
|
+
name=config.name,
|
|
303
|
+
rules=list(config.rules),
|
|
304
|
+
state_machine=StateMachine.from_name(config.initial_state),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
engine.register(component)
|
|
308
|
+
|
|
309
|
+
# Save on every state change
|
|
310
|
+
async def on_state_changed(data):
|
|
311
|
+
snapshot = ComponentSnapshot(
|
|
312
|
+
name=data["component"],
|
|
313
|
+
current_state=data["to_state"],
|
|
314
|
+
rules=component.rules,
|
|
315
|
+
metadata={},
|
|
316
|
+
)
|
|
317
|
+
save_snapshot_sqlite(conn, snapshot)
|
|
318
|
+
prune_snapshots_sqlite(conn, data["component"], keep_last=50)
|
|
319
|
+
|
|
320
|
+
await engine.event_bus.subscribe("state.changed", "persister", on_state_changed)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### JSON vs SQLite Persistence
|
|
324
|
+
|
|
325
|
+
| Feature | JSON | SQLite |
|
|
326
|
+
|---------|------|--------|
|
|
327
|
+
| Single file | ✅ | ✅ |
|
|
328
|
+
| History | ❌ (overwrites) | ✅ (appends) |
|
|
329
|
+
| Retention | N/A | `prune_snapshots_sqlite()` |
|
|
330
|
+
| Restore | `load_snapshot()` | `load_latest_snapshot_sqlite()` |
|
|
331
|
+
| Best for | Dev/debugging | Production |
|
|
332
|
+
|
|
333
|
+
## Errors & Troubleshooting
|
|
334
|
+
|
|
335
|
+
FlexiFlow errors follow a structured format: **What / Why / Fix / Context**.
|
|
336
|
+
|
|
337
|
+
### Error Format
|
|
338
|
+
|
|
339
|
+
Every FlexiFlow error includes:
|
|
340
|
+
|
|
341
|
+
| Field | Purpose |
|
|
342
|
+
|-------|---------|
|
|
343
|
+
| **What** | What went wrong (first line) |
|
|
344
|
+
| **Why** | Explanation of the cause |
|
|
345
|
+
| **Fix** | Actionable guidance to resolve |
|
|
346
|
+
| **Context** | Debugging metadata (paths, types, values) |
|
|
347
|
+
|
|
348
|
+
### Example Errors
|
|
349
|
+
|
|
350
|
+
**State not found:**
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
Unknown state 'BadState' not found in registry.
|
|
354
|
+
Why: The state name was not registered. Available: InitialState, ProcessingRequest, AwaitingConfirmation (+2 more)
|
|
355
|
+
Fix: Check spelling or register the state with DEFAULT_REGISTRY.register() before use.
|
|
356
|
+
Context: state_name='BadState'
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Config error:**
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
Missing required field 'name' in config.
|
|
363
|
+
Why: The configuration file is missing a required field.
|
|
364
|
+
Fix: Add 'name: your_component_name' to the config file.
|
|
365
|
+
Context: path='config.yaml'
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**Import error:**
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
Invalid dotted path 'mypkg.states.MyState'.
|
|
372
|
+
Why: Dotted paths must use ':' to separate module from symbol.
|
|
373
|
+
Fix: Use format 'module.path:ClassName' (e.g., 'mypkg.states:MyState').
|
|
374
|
+
Context: dotted_path='mypkg.states.MyState'
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Exception Hierarchy
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
FlexiFlowError (base)
|
|
381
|
+
├── ConfigError # Configuration validation failures
|
|
382
|
+
├── StateError # State registry/machine errors
|
|
383
|
+
├── PersistenceError # JSON/SQLite persistence errors
|
|
384
|
+
└── ImportError_ # Dotted path import failures
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
All exceptions inherit from `FlexiFlowError`, so you can catch broadly or specifically:
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
from flexiflow import FlexiFlowError, StateError
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
sm = StateMachine.from_name("BadState")
|
|
394
|
+
except StateError as e:
|
|
395
|
+
print(f"State issue: {e}")
|
|
396
|
+
except FlexiFlowError as e:
|
|
397
|
+
print(f"FlexiFlow error: {e}")
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Reporting Issues
|
|
401
|
+
|
|
402
|
+
When filing a GitHub issue, **paste the full error message** including the Context line. This helps us reproduce and fix the problem faster.
|
|
403
|
+
|
|
404
|
+
## Config Introspection
|
|
405
|
+
|
|
406
|
+
Use `explain()` to validate a config file before loading it. This helps catch errors early and understand what FlexiFlow will do with your config.
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
from flexiflow import explain
|
|
410
|
+
|
|
411
|
+
result = explain("config.yaml")
|
|
412
|
+
|
|
413
|
+
if result.is_valid:
|
|
414
|
+
print("Config is valid!")
|
|
415
|
+
print(f"Component: {result.name}")
|
|
416
|
+
print(f"Initial state: {result.initial_state}")
|
|
417
|
+
print(f"Rules: {result.rules_count}")
|
|
418
|
+
else:
|
|
419
|
+
print("Config has errors:")
|
|
420
|
+
for error in result.errors:
|
|
421
|
+
print(f" - {error.what}")
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### What explain() Does
|
|
425
|
+
|
|
426
|
+
- Parses the YAML and validates structure
|
|
427
|
+
- Resolves all dotted paths (without registering states)
|
|
428
|
+
- Checks that initial_state exists
|
|
429
|
+
- Lists built-in and custom states
|
|
430
|
+
- Collects warnings and errors
|
|
431
|
+
|
|
432
|
+
### What explain() Does NOT Do
|
|
433
|
+
|
|
434
|
+
- Register states in the global registry
|
|
435
|
+
- Create components or state machines
|
|
436
|
+
- Raise exceptions (errors are collected, not thrown)
|
|
437
|
+
|
|
438
|
+
### Human-Readable Output
|
|
439
|
+
|
|
440
|
+
Use `result.format()` for a formatted explanation:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
result = explain("config.yaml")
|
|
444
|
+
print(result.format())
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Output:
|
|
448
|
+
```
|
|
449
|
+
FlexiFlow Config Explanation
|
|
450
|
+
========================================
|
|
451
|
+
Source: config.yaml
|
|
452
|
+
|
|
453
|
+
Component:
|
|
454
|
+
name: my_component
|
|
455
|
+
initial_state: InitialState
|
|
456
|
+
rules: 3 rule(s)
|
|
457
|
+
|
|
458
|
+
States:
|
|
459
|
+
✓ CustomState: mypkg.states:CustomState
|
|
460
|
+
Built-in: AwaitingConfirmation, ErrorHandling, InitialState, ProcessingRequest
|
|
461
|
+
|
|
462
|
+
Warnings:
|
|
463
|
+
⚠ No rules defined
|
|
464
|
+
Fix: Add rules if your component needs them.
|
|
465
|
+
|
|
466
|
+
Status: ✓ Valid - config will load successfully
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
This is especially useful for debugging CI failures or validating configs before deployment.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""FlexiFlow - A lightweight async component engine with events and state machines.
|
|
2
|
+
|
|
3
|
+
Quick Start:
|
|
4
|
+
from flexiflow import FlexiFlowEngine, AsyncComponent, StateMachine, State
|
|
5
|
+
|
|
6
|
+
class MyState(State):
|
|
7
|
+
async def handle_message(self, message, component):
|
|
8
|
+
if message.get("type") == "done":
|
|
9
|
+
return True, MyState()
|
|
10
|
+
return False, self
|
|
11
|
+
|
|
12
|
+
engine = FlexiFlowEngine()
|
|
13
|
+
component = AsyncComponent(
|
|
14
|
+
name="my_component",
|
|
15
|
+
state_machine=StateMachine(current_state=MyState()),
|
|
16
|
+
)
|
|
17
|
+
engine.register(component)
|
|
18
|
+
await component.handle_message({"type": "done"})
|
|
19
|
+
|
|
20
|
+
For config-driven usage:
|
|
21
|
+
from flexiflow import ConfigLoader, AsyncComponent, StateMachine, FlexiFlowEngine
|
|
22
|
+
|
|
23
|
+
config = ConfigLoader.load_component_config("config.yaml")
|
|
24
|
+
component = AsyncComponent(
|
|
25
|
+
name=config.name,
|
|
26
|
+
rules=list(config.rules),
|
|
27
|
+
state_machine=StateMachine.from_name(config.initial_state),
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .component import AsyncComponent
|
|
32
|
+
from .config_loader import ComponentConfig, ConfigLoader
|
|
33
|
+
from .engine import FlexiFlowEngine
|
|
34
|
+
from .errors import (
|
|
35
|
+
ConfigError,
|
|
36
|
+
FlexiFlowError,
|
|
37
|
+
ImportError_,
|
|
38
|
+
PersistenceError,
|
|
39
|
+
StateError,
|
|
40
|
+
)
|
|
41
|
+
from .explain import ConfigExplanation, Diagnostic, explain
|
|
42
|
+
from .pack_loader import collect_provided_keys, load_packs
|
|
43
|
+
from .state_machine import DEFAULT_REGISTRY, State, StateRegistry, StateMachine
|
|
44
|
+
from .statepack import MappingPack, StateSpec, StatePack, TransitionSpec
|
|
45
|
+
from .visualize import visualize
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Core
|
|
49
|
+
"FlexiFlowEngine",
|
|
50
|
+
"AsyncComponent",
|
|
51
|
+
"StateMachine",
|
|
52
|
+
"State",
|
|
53
|
+
# Config
|
|
54
|
+
"ConfigLoader",
|
|
55
|
+
"ComponentConfig",
|
|
56
|
+
# StatePacks (public types for users defining packs)
|
|
57
|
+
"StatePack",
|
|
58
|
+
"StateSpec",
|
|
59
|
+
"TransitionSpec",
|
|
60
|
+
# Registry (advanced)
|
|
61
|
+
"StateRegistry",
|
|
62
|
+
"DEFAULT_REGISTRY",
|
|
63
|
+
# Errors
|
|
64
|
+
"FlexiFlowError",
|
|
65
|
+
"ConfigError",
|
|
66
|
+
"StateError",
|
|
67
|
+
"PersistenceError",
|
|
68
|
+
"ImportError_",
|
|
69
|
+
# Introspection
|
|
70
|
+
"explain",
|
|
71
|
+
"ConfigExplanation",
|
|
72
|
+
"Diagnostic",
|
|
73
|
+
# Visualization
|
|
74
|
+
"visualize",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# Internal imports available but not in __all__:
|
|
78
|
+
# - load_packs, collect_provided_keys: internal pack loading machinery
|
|
79
|
+
# - MappingPack: internal adapter for legacy states: dict format
|
|
80
|
+
# - PackInfo: internal dataclass for ConfigExplanation.packs
|
|
81
|
+
|
|
82
|
+
__version__ = "0.4.1"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from fastapi import FastAPI, HTTPException
|
|
7
|
+
except ImportError: # optional dependency
|
|
8
|
+
FastAPI = None
|
|
9
|
+
HTTPException = Exception
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_app(engine: Any):
|
|
13
|
+
if FastAPI is None:
|
|
14
|
+
raise RuntimeError("fastapi is not installed. Install with: pip install -e '.[api]'")
|
|
15
|
+
|
|
16
|
+
app = FastAPI()
|
|
17
|
+
|
|
18
|
+
@app.get("/health")
|
|
19
|
+
async def health() -> Dict[str, str]:
|
|
20
|
+
return {"status": "ok"}
|
|
21
|
+
|
|
22
|
+
@app.post("/components/{name}/message")
|
|
23
|
+
async def send_message(name: str, message: Dict[str, Any]):
|
|
24
|
+
component = engine.get(name)
|
|
25
|
+
if component is None:
|
|
26
|
+
raise HTTPException(status_code=404, detail="Component not found")
|
|
27
|
+
await component.handle_message(message)
|
|
28
|
+
return {"status": "sent"}
|
|
29
|
+
|
|
30
|
+
return app
|