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.
Files changed (47) hide show
  1. flexiflow-0.3.1/PKG-INFO +15 -0
  2. flexiflow-0.3.1/README.md +469 -0
  3. flexiflow-0.3.1/flexiflow/__init__.py +82 -0
  4. flexiflow-0.3.1/flexiflow/__main__.py +5 -0
  5. flexiflow-0.3.1/flexiflow/api.py +30 -0
  6. flexiflow-0.3.1/flexiflow/cli.py +117 -0
  7. flexiflow-0.3.1/flexiflow/component.py +52 -0
  8. flexiflow-0.3.1/flexiflow/config_loader.py +130 -0
  9. flexiflow-0.3.1/flexiflow/engine.py +50 -0
  10. flexiflow-0.3.1/flexiflow/errors.py +250 -0
  11. flexiflow-0.3.1/flexiflow/event_manager.py +191 -0
  12. flexiflow-0.3.1/flexiflow/explain.py +658 -0
  13. flexiflow-0.3.1/flexiflow/extras/__init__.py +31 -0
  14. flexiflow-0.3.1/flexiflow/extras/persist_json.py +172 -0
  15. flexiflow-0.3.1/flexiflow/extras/persist_sqlite.py +229 -0
  16. flexiflow-0.3.1/flexiflow/extras/retry.py +77 -0
  17. flexiflow-0.3.1/flexiflow/imports.py +47 -0
  18. flexiflow-0.3.1/flexiflow/logger.py +39 -0
  19. flexiflow-0.3.1/flexiflow/pack_loader.py +308 -0
  20. flexiflow-0.3.1/flexiflow/reload.py +20 -0
  21. flexiflow-0.3.1/flexiflow/state_machine.py +90 -0
  22. flexiflow-0.3.1/flexiflow/statepack.py +211 -0
  23. flexiflow-0.3.1/flexiflow/visualize.py +186 -0
  24. flexiflow-0.3.1/flexiflow.egg-info/PKG-INFO +15 -0
  25. flexiflow-0.3.1/flexiflow.egg-info/SOURCES.txt +45 -0
  26. flexiflow-0.3.1/flexiflow.egg-info/dependency_links.txt +1 -0
  27. flexiflow-0.3.1/flexiflow.egg-info/entry_points.txt +2 -0
  28. flexiflow-0.3.1/flexiflow.egg-info/requires.txt +13 -0
  29. flexiflow-0.3.1/flexiflow.egg-info/top_level.txt +1 -0
  30. flexiflow-0.3.1/pyproject.toml +22 -0
  31. flexiflow-0.3.1/setup.cfg +4 -0
  32. flexiflow-0.3.1/tests/test_cli_smoke.py +60 -0
  33. flexiflow-0.3.1/tests/test_engine.py +47 -0
  34. flexiflow-0.3.1/tests/test_errors.py +253 -0
  35. flexiflow-0.3.1/tests/test_event_manager.py +211 -0
  36. flexiflow-0.3.1/tests/test_example_integration.py +193 -0
  37. flexiflow-0.3.1/tests/test_explain.py +749 -0
  38. flexiflow-0.3.1/tests/test_imports.py +299 -0
  39. flexiflow-0.3.1/tests/test_observability.py +173 -0
  40. flexiflow-0.3.1/tests/test_pack_loader.py +297 -0
  41. flexiflow-0.3.1/tests/test_persist_json.py +133 -0
  42. flexiflow-0.3.1/tests/test_persist_sqlite.py +263 -0
  43. flexiflow-0.3.1/tests/test_public_api.py +81 -0
  44. flexiflow-0.3.1/tests/test_retry.py +90 -0
  45. flexiflow-0.3.1/tests/test_state_machine.py +116 -0
  46. flexiflow-0.3.1/tests/test_statepack.py +334 -0
  47. flexiflow-0.3.1/tests/test_visualize.py +402 -0
@@ -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,5 @@
1
+ """Entry point for `python -m flexiflow`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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