runtime-narrative 0.1.0__tar.gz → 0.2.0__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 (39) hide show
  1. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/LICENSE +21 -21
  2. runtime_narrative-0.2.0/PKG-INFO +408 -0
  3. runtime_narrative-0.2.0/README.MD +373 -0
  4. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/pyproject.toml +68 -58
  5. runtime_narrative-0.2.0/runtime_narrative/__init__.py +31 -0
  6. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/analyzers/__init__.py +3 -3
  7. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/analyzers/ollama.py +220 -179
  8. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/context.py +11 -11
  9. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/decorators.py +87 -69
  10. runtime_narrative-0.2.0/runtime_narrative/diagnostics.py +389 -0
  11. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/events.py +84 -66
  12. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/failure.py +63 -63
  13. runtime_narrative-0.2.0/runtime_narrative/middleware.py +96 -0
  14. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/renderer/__init__.py +11 -11
  15. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/renderer/console.py +289 -218
  16. runtime_narrative-0.2.0/runtime_narrative/renderer/json_renderer.py +174 -0
  17. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative/stage.py +93 -58
  18. runtime_narrative-0.2.0/runtime_narrative/story.py +436 -0
  19. runtime_narrative-0.2.0/runtime_narrative.egg-info/PKG-INFO +408 -0
  20. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative.egg-info/SOURCES.txt +10 -1
  21. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/setup.cfg +4 -4
  22. runtime_narrative-0.2.0/tests/test_async_renderer.py +28 -0
  23. runtime_narrative-0.2.0/tests/test_decorators.py +35 -0
  24. runtime_narrative-0.2.0/tests/test_diagnostics.py +206 -0
  25. runtime_narrative-0.2.0/tests/test_failure.py +19 -0
  26. runtime_narrative-0.2.0/tests/test_json_renderer.py +50 -0
  27. runtime_narrative-0.2.0/tests/test_middleware.py +64 -0
  28. runtime_narrative-0.2.0/tests/test_stage.py +24 -0
  29. runtime_narrative-0.2.0/tests/test_story.py +120 -0
  30. runtime_narrative-0.1.0/PKG-INFO +0 -195
  31. runtime_narrative-0.1.0/README.MD +0 -160
  32. runtime_narrative-0.1.0/runtime_narrative/__init__.py +0 -23
  33. runtime_narrative-0.1.0/runtime_narrative/middleware.py +0 -58
  34. runtime_narrative-0.1.0/runtime_narrative/renderer/json_renderer.py +0 -90
  35. runtime_narrative-0.1.0/runtime_narrative/story.py +0 -162
  36. runtime_narrative-0.1.0/runtime_narrative.egg-info/PKG-INFO +0 -195
  37. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  38. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative.egg-info/requires.txt +0 -0
  39. {runtime_narrative-0.1.0 → runtime_narrative-0.2.0}/runtime_narrative.egg-info/top_level.txt +0 -0
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 Shashank Raj
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Shashank Raj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,408 @@
1
+ Metadata-Version: 2.4
2
+ Name: runtime-narrative
3
+ Version: 0.2.0
4
+ Summary: Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis
5
+ Author-email: Shashank Raj <shashank.raj28@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sraj0501/runtime_narrative
8
+ Project-URL: Repository, https://github.com/sraj0501/runtime_narrative
9
+ Project-URL: Bug Tracker, https://github.com/sraj0501/runtime_narrative/issues
10
+ Keywords: logging,observability,tracing,fastapi,debugging,diagnostics,runtime_narrative
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Logging
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: console
28
+ Requires-Dist: typer>=0.9.0; extra == "console"
29
+ Provides-Extra: fastapi
30
+ Requires-Dist: starlette>=0.27.0; extra == "fastapi"
31
+ Provides-Extra: all
32
+ Requires-Dist: typer>=0.9.0; extra == "all"
33
+ Requires-Dist: starlette>=0.27.0; extra == "all"
34
+ Dynamic: license-file
35
+
36
+ # runtime-narrative
37
+
38
+ **Turn any Python application into a traceable story. Get minimal logs when everything works — and surgical, LLM-powered diagnostics the moment something breaks.**
39
+
40
+ ---
41
+
42
+ ## The idea
43
+
44
+ Most logging tells you *that* something failed. `runtime-narrative` tells you *why* — with full awareness of every step that succeeded before the failure, what was supposed to happen next, and (optionally) a plain-English suggestion for how to fix it.
45
+
46
+ You model your application's execution as a **story** made up of **stages**. Each function or logical unit of work becomes a stage. The library watches everything:
47
+
48
+ - **When a stage passes:** one line — `✔ Stage completed: Validate Input (0.003s)`. No noise.
49
+ - **When anything fails:** a structured failure report with the exact file, line number, failing statement, the full timeline of what succeeded before it, and — if you plug in an LLM — a concrete logical fix suggestion.
50
+
51
+ This combines debugging and logging into a single mechanism: logs are minimal until something breaks, then they are explicit and actionable.
52
+
53
+ ---
54
+
55
+ ## Install
56
+
57
+ Zero dependencies at the core:
58
+
59
+ ```bash
60
+ pip install runtime-narrative
61
+ ```
62
+
63
+ Optional extras:
64
+
65
+ ```bash
66
+ pip install "runtime-narrative[console]" # colored terminal output (typer)
67
+ pip install "runtime-narrative[fastapi]" # FastAPI/Starlette middleware
68
+ pip install "runtime-narrative[all]" # everything
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Quick start
74
+
75
+ ```python
76
+ from runtime_narrative import story, stage
77
+
78
+ with story("Import Customers"):
79
+ with stage("Load CSV"):
80
+ rows = load_csv("customers.csv")
81
+
82
+ with stage("Validate Data"):
83
+ validate(rows)
84
+
85
+ with stage("Insert Records"):
86
+ db.insert(rows)
87
+ ```
88
+
89
+ **Everything works — minimal output:**
90
+
91
+ ```
92
+ ▶ Story started: Import Customers
93
+ ✔ Stage completed: Load CSV (0.012s)
94
+ ✔ Stage completed: Validate Data (0.004s)
95
+ ✔ Stage completed: Insert Records (0.089s)
96
+ ▶ Story ended: SUCCESS
97
+ ```
98
+
99
+ **Something fails — full context, no guessing:**
100
+
101
+ ```
102
+ ▶ Story started: Import Customers
103
+ ✔ Stage completed: Load CSV (0.012s)
104
+ ✔ Stage completed: Validate Data (0.004s)
105
+
106
+ ❌ Failure detected
107
+ Story: Import Customers
108
+ Stage: Insert Records
109
+ Error: ValueError - duplicate customer id
110
+ Location: app/db.py:47 (insert_row)
111
+ Code: raise ValueError("duplicate customer id")
112
+ Recent stages: Load CSV=completed (0.012s) | Validate Data=completed (0.004s) | Insert Records=failed (0.001s)
113
+ Progress: 66% (2 / 3)
114
+ ```
115
+
116
+ The library knows what succeeded before the failure. That context is always part of the report.
117
+
118
+ Async code uses identical syntax with `async with`:
119
+
120
+ ```python
121
+ async with story("Import Customers"):
122
+ async with stage("Load CSV"):
123
+ rows = await load_csv("customers.csv")
124
+
125
+ async with stage("Insert Records"):
126
+ await db.insert(rows)
127
+ ```
128
+
129
+ ---
130
+
131
+ ## LLM-powered failure analysis (optional)
132
+
133
+ Plug in any local or remote LLM. When a failure occurs, the library packages the story name, stage name, error type, exact failing line, exception chain, and traceback — and asks the LLM for a targeted diagnostic.
134
+
135
+ ```python
136
+ from runtime_narrative import story, stage, OllamaFailureAnalyzer
137
+
138
+ analyzer = OllamaFailureAnalyzer(model="llama3")
139
+
140
+ with story("Import Customers", failure_analyzer=analyzer):
141
+ with stage("Load CSV"):
142
+ rows = load_csv("customers.csv")
143
+ with stage("Insert Records"):
144
+ db.insert(rows)
145
+ ```
146
+
147
+ The LLM response is structured and rendered inline:
148
+
149
+ ```
150
+ +-- LLM Debug -----------------------------------------------------------+
151
+ | Exact Why |
152
+ | The INSERT fails because customer_id already exists in the customers |
153
+ | table (UNIQUE constraint). The error is raised at db.py:47. |
154
+ | |
155
+ | Evidence |
156
+ | ValueError: duplicate customer id — raised after catching a |
157
+ | sqlite3.IntegrityError from the underlying INSERT call. |
158
+ | |
159
+ | Targeted Fix |
160
+ | Use INSERT OR IGNORE, or check for existence before inserting. |
161
+ | Alternatively, catch the duplicate and return the existing record. |
162
+ | |
163
+ >> Code Changes |
164
+ | db.py:47 — wrap the insert in try/except IntegrityError and handle |
165
+ | the duplicate case explicitly rather than re-raising ValueError. |
166
+ +------------------------------------------------------------------------+
167
+ ```
168
+
169
+ > **Note:** The LLM suggests logical fixes only — it does not rewrite your code. The suggestion names the exact location, explains what went wrong mechanically, and tells you what to change. What you change is up to you.
170
+
171
+ ### Analyzer options
172
+
173
+ | Class | API | Use case |
174
+ |---|---|---|
175
+ | `OllamaFailureAnalyzer` | Ollama native `/api/generate` | Local Ollama |
176
+ | `LLMFailureAnalyzer` | OpenAI-compatible `/v1/chat/completions` | vLLM, llama.cpp, LM Studio, Ollama OpenAI mode, any hosted API |
177
+
178
+ ```python
179
+ from runtime_narrative import LLMFailureAnalyzer
180
+
181
+ analyzer = LLMFailureAnalyzer(
182
+ model="llama3",
183
+ endpoint="http://localhost:8000/v1/chat/completions",
184
+ )
185
+ ```
186
+
187
+ Both fall back silently if the endpoint is unreachable — your application's exception still propagates normally.
188
+
189
+ ### Background analysis
190
+
191
+ For latency-sensitive services, use `background_analysis=True`. The `FailureOccurred` event is emitted immediately (so your error response is not delayed), and the LLM runs as a background task. When it finishes, a `LLMAnalysisReady` event is emitted:
192
+
193
+ ```python
194
+ async with story("Process Order", failure_analyzer=analyzer, background_analysis=True):
195
+ async with stage("Charge Payment"):
196
+ await charge(order)
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Diagnostics depth
202
+
203
+ The library operates in two modes, controlled by environment variable or per-story kwargs:
204
+
205
+ | Mode | What you get |
206
+ |---|---|
207
+ | `lean` (default) | Error type, message, exact location, source line, exception chain, compressed stack summary |
208
+ | `rich` | Everything above + source code snippet (±2 lines around the error) + local variable values at the failing frame, with automatic redaction of secrets (`password`, `token`, `api_key`, etc.) |
209
+
210
+ ```bash
211
+ # Enable rich diagnostics for a run
212
+ RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICS=rich python myapp.py
213
+ ```
214
+
215
+ Rich mode is automatically downgraded to lean in production unless explicitly allowed:
216
+
217
+ ```bash
218
+ RUNTIME_NARRATIVE_ENV=production
219
+ RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION=true # override when needed
220
+ ```
221
+
222
+ Per-story configuration:
223
+
224
+ ```python
225
+ from runtime_narrative import story, FailureDiagnosticsConfig
226
+
227
+ async with story(
228
+ "Import Customers",
229
+ runtime_environment="development",
230
+ failure_diagnostics="rich",
231
+ app_roots=("/path/to/my/app",), # optional; default uses cwd
232
+ ):
233
+ ...
234
+
235
+ # Or pass a fully built config
236
+ cfg = FailureDiagnosticsConfig(failure_diagnostics="rich", app_roots=("/app",))
237
+ async with story("Import Customers", diagnostics_config=cfg):
238
+ ...
239
+ ```
240
+
241
+ ---
242
+
243
+ ## Server deployments — structured JSON logs
244
+
245
+ For production or any environment where you need machine-readable output, swap `ConsoleRenderer` for `JsonRenderer`. It emits one JSON object per lifecycle event — compatible with any structured log collector (Datadog, CloudWatch, Loki, OpenTelemetry log exporters):
246
+
247
+ ```python
248
+ from runtime_narrative import story, stage, JsonRenderer
249
+
250
+ async with story("Process Payment", renderers=[JsonRenderer()]):
251
+ async with stage("Validate Card"):
252
+ ...
253
+ async with stage("Charge"):
254
+ ...
255
+ ```
256
+
257
+ On success, output is minimal — one object per event:
258
+
259
+ ```json
260
+ {"event": "StoryStarted", "story_id": "abc-123", "story_name": "Process Payment", "timestamp": "..."}
261
+ {"event": "StageCompleted", "story_id": "abc-123", "stage_name": "Validate Card", "duration_seconds": 0.003, "timestamp": "..."}
262
+ {"event": "StoryCompleted", "story_id": "abc-123", "success": true, "progress": {"percent": 100, ...}, "timestamp": "..."}
263
+ ```
264
+
265
+ On failure, `FailureOccurred` carries the full diagnostics payload — exact location, stack frame classification, source snippet, local variables (rich mode), traceback — all in a structured, queryable form:
266
+
267
+ ```json
268
+ {
269
+ "event": "FailureOccurred",
270
+ "story_id": "abc-123",
271
+ "stage_name": "Charge",
272
+ "error_type": "TimeoutError",
273
+ "location": {"filename": "payment.py", "lineno": 82, "function": "charge_card", "source_line": "..."},
274
+ "llm_analysis": "...",
275
+ "diagnostics_mode": "lean",
276
+ "stack_frames": [...],
277
+ "compressed_stack_summary": "2 app frame(s), 4 other/hidden in full stack (6 total)",
278
+ "stage_timeline": "Validate Card=completed (0.003s) | Charge=failed (0.012s)"
279
+ }
280
+ ```
281
+
282
+ Write to a file instead of stdout:
283
+
284
+ ```python
285
+ JsonRenderer(output=open("narrative.log", "a"))
286
+ ```
287
+
288
+ ---
289
+
290
+ ## FastAPI / Starlette middleware
291
+
292
+ Add the middleware once and every request becomes a story automatically. Route handlers only need to declare stages:
293
+
294
+ ```python
295
+ from fastapi import FastAPI
296
+ from runtime_narrative import RuntimeNarrativeMiddleware, JsonRenderer, OllamaFailureAnalyzer
297
+
298
+ app = FastAPI()
299
+ app.add_middleware(
300
+ RuntimeNarrativeMiddleware,
301
+ renderers=[JsonRenderer()], # structured logs for prod
302
+ failure_analyzer=OllamaFailureAnalyzer(model="llama3"),
303
+ runtime_environment="production", # enforces lean + traceback cap
304
+ )
305
+
306
+ @app.post("/orders")
307
+ async def create_order(payload: OrderIn):
308
+ with stage("Validate Input"):
309
+ validate(payload)
310
+
311
+ with stage("Persist Order"):
312
+ order = await db.insert(payload)
313
+
314
+ return {"id": order.id}
315
+ ```
316
+
317
+ Each request becomes a story named `"POST /orders"`. If the handler raises, the middleware captures the full failure context before returning the error response.
318
+
319
+ ---
320
+
321
+ ## Decorators
322
+
323
+ Wrap entire functions without changing their call sites. The library detects `async def` automatically:
324
+
325
+ ```python
326
+ from runtime_narrative import runtime_narrative_story, runtime_narrative_stage
327
+
328
+ @runtime_narrative_story(failure_analyzer=analyzer)
329
+ async def run_pipeline():
330
+ await load_data()
331
+ await transform()
332
+ await export()
333
+
334
+ @runtime_narrative_stage("Load Source Data")
335
+ async def load_data():
336
+ ...
337
+ ```
338
+
339
+ All `story()` kwargs — `failure_analyzer`, `failure_diagnostics`, `runtime_environment`, `background_analysis`, `renderers`, etc. — are forwarded from `@runtime_narrative_story`.
340
+
341
+ ---
342
+
343
+ ## Custom renderer
344
+
345
+ Any object with a `handle(event)` method is a valid renderer. Async renderers (`async def handle`) are awaited automatically inside `async with story(...)`:
346
+
347
+ ```python
348
+ class SlackRenderer:
349
+ async def handle(self, event):
350
+ if event.__class__.__name__ == "FailureOccurred":
351
+ await slack.post(
352
+ f"*{event.story_name}* failed at *{event.stage_name}*\n"
353
+ f"`{event.error_type}: {event.error_message}`"
354
+ )
355
+
356
+ async with story("Nightly ETL", renderers=[SlackRenderer()]):
357
+ ...
358
+ ```
359
+
360
+ Events you will receive: `StoryStarted`, `StageStarted`, `StageCompleted`, `FailureOccurred`, `StoryCompleted`, `LLMAnalysisReady` (only when `background_analysis=True`).
361
+
362
+ ---
363
+
364
+ ## Custom failure analyzer
365
+
366
+ Any object with an `analyze_failure(...)` method works. Add `analyze_failure_async(...)` for native async — otherwise the sync version is called via `asyncio.to_thread` so it never blocks the event loop:
367
+
368
+ ```python
369
+ class MyAnalyzer:
370
+ async def analyze_failure_async(
371
+ self, *, story_name, stage_name, failure, stage_timeline, progress_percent
372
+ ):
373
+ # failure is a FailureSummary:
374
+ # .error_type, .error_message, .filename, .lineno,
375
+ # .function, .source_line, .traceback_text, .exception_chain
376
+ result = await my_llm_client.complete(build_prompt(failure))
377
+ return result.text
378
+
379
+ async with story("Import", failure_analyzer=MyAnalyzer()):
380
+ ...
381
+ ```
382
+
383
+ ---
384
+
385
+ ## Environment variables
386
+
387
+ | Variable | Values | Default | Effect |
388
+ |---|---|---|---|
389
+ | `RUNTIME_NARRATIVE_ENV` | `development`, `production` | `development` | Production caps traceback length and forces lean mode |
390
+ | `RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICS` | `lean`, `rich` | `lean` | `rich` captures local variables at the failing frames |
391
+ | `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard for rich diagnostics |
392
+
393
+ ---
394
+
395
+ ## Philosophy
396
+
397
+ - **Zero noise on success.** One line per stage. No log spam when things work.
398
+ - **Full context on failure.** The library already knows what succeeded, what failed, and where. It uses that to give you an actionable report, not a raw stacktrace dropped into a log file.
399
+ - **LLM is optional, never required.** Every feature works without an LLM. The analyzer is purely additive. If it fails to respond, your exception still propagates normally.
400
+ - **Logical fixes, not code rewrites.** The LLM suggestion names the exact mechanism and location of the failure, and tells you what logic to change. It does not generate code diffs.
401
+ - **Async-first, sync-compatible.** Both `with story()` and `async with story()` work. The library never blocks the event loop — failure diagnostics and LLM calls both run via `asyncio.to_thread`.
402
+ - **No framework lock-in.** Use it in a script, a FastAPI app, a Celery worker, a CLI, or a data pipeline. The only required hook is wrapping your code in `story()` / `stage()`.
403
+
404
+ ---
405
+
406
+ ## License
407
+
408
+ MIT