runtime-narrative 1.3.0__tar.gz → 1.5.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.
- {runtime_narrative-1.3.0/runtime_narrative.egg-info → runtime_narrative-1.5.0}/PKG-INFO +53 -2
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/README.md +52 -1
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/pyproject.toml +1 -1
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/__init__.py +3 -1
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/celery.py +2 -10
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/decorators.py +6 -2
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/events.py +2 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/grpc_interceptor.py +2 -10
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/middleware.py +4 -13
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/middleware_django.py +3 -11
- runtime_narrative-1.5.0/runtime_narrative/renderer/coalescing_renderer.py +179 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/console.py +64 -19
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/json_renderer.py +2 -0
- runtime_narrative-1.5.0/runtime_narrative/renderer_defaults.py +73 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/stage.py +13 -2
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/story.py +16 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0/runtime_narrative.egg-info}/PKG-INFO +53 -2
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/SOURCES.txt +5 -0
- runtime_narrative-1.5.0/tests/test_coalescing_renderer.py +198 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_console_renderer.py +151 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_json_renderer.py +28 -0
- runtime_narrative-1.5.0/tests/test_module_capture.py +103 -0
- runtime_narrative-1.5.0/tests/test_renderer_defaults.py +132 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/LICENSE +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/__init__.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/anthropic.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/base.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/deduplication.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/ollama.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/cli.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/context.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/diagnostics.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/failure.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/instrumentation.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/logging_bridge.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/outcome.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/__init__.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/alert_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/filter_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/html_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/persistence_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/task_group.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/testing.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/entry_points.txt +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/requires.txt +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/top_level.txt +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/setup.cfg +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_alert_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_analyzers.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_anthropic_analyzer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_async_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_celery.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_decorators.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_deduplication.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_diagnostics.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_dry_run.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_failure.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_filter_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_grpc_interceptor.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_html_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_instrumentation.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_instrumentation_phase2.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_issues.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_logging_bridge.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_middleware.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_middleware_django.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_middleware_propagation.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_otel_log_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_otel_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_outcome.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_persistence_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_prometheus_renderer.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_redaction_extended.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_stage.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_stage_metadata.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_story.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_structured_analysis.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_task_group.py +0 -0
- {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_testing_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-narrative
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis
|
|
5
5
|
Author-email: Shashank Raj <shashank.raj28@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -234,6 +234,55 @@ with story("GET /api/call") as runtime:
|
|
|
234
234
|
|
|
235
235
|
---
|
|
236
236
|
|
|
237
|
+
## Rich logs to a file, and no more flooded polling loops
|
|
238
|
+
|
|
239
|
+
`ConsoleRenderer` writes its colored, human-readable output to any file-like object, not just the terminal — so the same troubleshooting-friendly narrative can land in a log file alongside the structured JSON stream:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
with open("narrative.log.txt", "a", encoding="utf-8") as log_file:
|
|
243
|
+
with story("Import Pipeline", renderers=[ConsoleRenderer(output=log_file), JsonRenderer("narrative.log")]):
|
|
244
|
+
...
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Every auto-instrumentation entry point (FastAPI/Starlette middleware, Django middleware, Celery, gRPC interceptors) picks this up automatically via two environment variables, with no code changes:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
RUNTIME_NARRATIVE_RICH_LOG_FILE=/var/log/app/narrative.log.txt # rich console output also goes here
|
|
251
|
+
RUNTIME_NARRATIVE_RICH_LOG_CONSOLE=0 # optional: stop echoing it to the terminal too
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Long-running, poll-heavy stages (status checks every couple of seconds during a big upload or pipeline run) no longer flood the log either. `CoalescingRenderer` wraps any other renderer and collapses a run of identical back-to-back stages into one summary line — total call count and total time, not one line per poll:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from runtime_narrative import ConsoleRenderer, CoalescingRenderer
|
|
258
|
+
|
|
259
|
+
with story("Process Upload", renderers=[CoalescingRenderer(ConsoleRenderer())]):
|
|
260
|
+
for _ in range(45):
|
|
261
|
+
with stage("Check Pipeline Status"):
|
|
262
|
+
poll()
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
267
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.010s)
|
|
268
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
269
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.005s)
|
|
270
|
+
[abcdef] 'Check Pipeline Status' repeated 43 more times (45 total) over 90.400s (avg 2.009s/call)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Every `ConsoleRenderer` line also carries a `YYYY-MM-DD HH:MM:SS.mmm` timestamp, and the module that opened a story or stage — auto-detected with a single cheap frame lookup, no stack walking — shown once on `Story started` and again only when a stage transitions to a different module, so multi-module workflows stay traceable without repeating the tag on every line:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
2026-07-03 12:03:41.208 [abcdef] ▶ Story started: Process Upload (app.routes.upload)
|
|
277
|
+
2026-07-03 12:03:41.209 [abcdef] ▶ Stage started: Validate Input (app.validators)
|
|
278
|
+
2026-07-03 12:03:41.210 [abcdef] ✔ Stage completed: Validate Input (0.001s)
|
|
279
|
+
2026-07-03 12:03:41.211 [abcdef] ▶ Stage started: Insert Record (app.db)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Run: `uv run python examples/colorful_errors_and_emojis.py` — full reference: [WIKI §10.1](WIKI.md#101-consolerenderer), [§10.1b](WIKI.md#101b-coalescingrenderer)
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
237
286
|
## Feature tour
|
|
238
287
|
|
|
239
288
|
Everything below works the same way in every context (sync/async, decorators, auto-instrumentation, any framework middleware). One line each here; full detail and every parameter in the Wiki.
|
|
@@ -244,7 +293,7 @@ Everything below works the same way in every context (sync/async, decorators, au
|
|
|
244
293
|
| Auto-instrumentation | `@narrative_class`, `@no_stage`, `instrument_module()`, `auto_instrument()` — instrument classes/modules with zero call-site changes | [WIKI §8](WIKI.md#8-auto-instrumentation) |
|
|
245
294
|
| Failure diagnostics | Lean/rich modes, production traceback caps, secret redaction, `FailureDiagnosticsConfig` | [WIKI §9](WIKI.md#9-failure-diagnostics) |
|
|
246
295
|
| Failure analyzers | `OllamaFailureAnalyzer`, `LLMFailureAnalyzer`, `AnthropicFailureAnalyzer`, `DeduplicatingAnalyzer`, `background_analysis=True` | [WIKI §9](WIKI.md#9-failure-diagnostics), [§16](WIKI.md#16-background-analysis) |
|
|
247
|
-
| Renderers | `ConsoleRenderer
|
|
296
|
+
| Renderers | `ConsoleRenderer` (optional file `output=`), `JsonRenderer`/`RotatingJsonRenderer`, `HtmlReportRenderer`, `SqliteStoryRenderer`, `OtelRenderer`/`OtelLogRenderer`/`OtelMetricsRenderer`, `PrometheusRenderer`, `AlertRoutingRenderer`, `FilteredRenderer`, `CoalescingRenderer` | [WIKI §10](WIKI.md#10-renderers) |
|
|
248
297
|
| Framework integrations | FastAPI/Starlette middleware, Django ASGI/WSGI middleware, Celery task base class, gRPC interceptors | [WIKI §11](WIKI.md#11-framework-integrations) |
|
|
249
298
|
| Async task groups | `NarrativeTaskGroup` — concurrent `asyncio` tasks under one shared story | [WIKI §12](WIKI.md#12-async-task-groups) |
|
|
250
299
|
| Persistence & CLI | `SqliteStoryRenderer` + `runtime-narrative failures` / `runtime-narrative story <id>` | [WIKI §13](WIKI.md#13-sqlite-persistence-and-cli) |
|
|
@@ -325,6 +374,8 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
325
374
|
| `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
|
|
326
375
|
| `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
|
|
327
376
|
| `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
|
|
377
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
|
|
378
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
|
|
328
379
|
|
|
329
380
|
---
|
|
330
381
|
|
|
@@ -175,6 +175,55 @@ with story("GET /api/call") as runtime:
|
|
|
175
175
|
|
|
176
176
|
---
|
|
177
177
|
|
|
178
|
+
## Rich logs to a file, and no more flooded polling loops
|
|
179
|
+
|
|
180
|
+
`ConsoleRenderer` writes its colored, human-readable output to any file-like object, not just the terminal — so the same troubleshooting-friendly narrative can land in a log file alongside the structured JSON stream:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
with open("narrative.log.txt", "a", encoding="utf-8") as log_file:
|
|
184
|
+
with story("Import Pipeline", renderers=[ConsoleRenderer(output=log_file), JsonRenderer("narrative.log")]):
|
|
185
|
+
...
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Every auto-instrumentation entry point (FastAPI/Starlette middleware, Django middleware, Celery, gRPC interceptors) picks this up automatically via two environment variables, with no code changes:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
RUNTIME_NARRATIVE_RICH_LOG_FILE=/var/log/app/narrative.log.txt # rich console output also goes here
|
|
192
|
+
RUNTIME_NARRATIVE_RICH_LOG_CONSOLE=0 # optional: stop echoing it to the terminal too
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Long-running, poll-heavy stages (status checks every couple of seconds during a big upload or pipeline run) no longer flood the log either. `CoalescingRenderer` wraps any other renderer and collapses a run of identical back-to-back stages into one summary line — total call count and total time, not one line per poll:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from runtime_narrative import ConsoleRenderer, CoalescingRenderer
|
|
199
|
+
|
|
200
|
+
with story("Process Upload", renderers=[CoalescingRenderer(ConsoleRenderer())]):
|
|
201
|
+
for _ in range(45):
|
|
202
|
+
with stage("Check Pipeline Status"):
|
|
203
|
+
poll()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
208
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.010s)
|
|
209
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
210
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.005s)
|
|
211
|
+
[abcdef] 'Check Pipeline Status' repeated 43 more times (45 total) over 90.400s (avg 2.009s/call)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Every `ConsoleRenderer` line also carries a `YYYY-MM-DD HH:MM:SS.mmm` timestamp, and the module that opened a story or stage — auto-detected with a single cheap frame lookup, no stack walking — shown once on `Story started` and again only when a stage transitions to a different module, so multi-module workflows stay traceable without repeating the tag on every line:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
2026-07-03 12:03:41.208 [abcdef] ▶ Story started: Process Upload (app.routes.upload)
|
|
218
|
+
2026-07-03 12:03:41.209 [abcdef] ▶ Stage started: Validate Input (app.validators)
|
|
219
|
+
2026-07-03 12:03:41.210 [abcdef] ✔ Stage completed: Validate Input (0.001s)
|
|
220
|
+
2026-07-03 12:03:41.211 [abcdef] ▶ Stage started: Insert Record (app.db)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Run: `uv run python examples/colorful_errors_and_emojis.py` — full reference: [WIKI §10.1](WIKI.md#101-consolerenderer), [§10.1b](WIKI.md#101b-coalescingrenderer)
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
178
227
|
## Feature tour
|
|
179
228
|
|
|
180
229
|
Everything below works the same way in every context (sync/async, decorators, auto-instrumentation, any framework middleware). One line each here; full detail and every parameter in the Wiki.
|
|
@@ -185,7 +234,7 @@ Everything below works the same way in every context (sync/async, decorators, au
|
|
|
185
234
|
| Auto-instrumentation | `@narrative_class`, `@no_stage`, `instrument_module()`, `auto_instrument()` — instrument classes/modules with zero call-site changes | [WIKI §8](WIKI.md#8-auto-instrumentation) |
|
|
186
235
|
| Failure diagnostics | Lean/rich modes, production traceback caps, secret redaction, `FailureDiagnosticsConfig` | [WIKI §9](WIKI.md#9-failure-diagnostics) |
|
|
187
236
|
| Failure analyzers | `OllamaFailureAnalyzer`, `LLMFailureAnalyzer`, `AnthropicFailureAnalyzer`, `DeduplicatingAnalyzer`, `background_analysis=True` | [WIKI §9](WIKI.md#9-failure-diagnostics), [§16](WIKI.md#16-background-analysis) |
|
|
188
|
-
| Renderers | `ConsoleRenderer
|
|
237
|
+
| Renderers | `ConsoleRenderer` (optional file `output=`), `JsonRenderer`/`RotatingJsonRenderer`, `HtmlReportRenderer`, `SqliteStoryRenderer`, `OtelRenderer`/`OtelLogRenderer`/`OtelMetricsRenderer`, `PrometheusRenderer`, `AlertRoutingRenderer`, `FilteredRenderer`, `CoalescingRenderer` | [WIKI §10](WIKI.md#10-renderers) |
|
|
189
238
|
| Framework integrations | FastAPI/Starlette middleware, Django ASGI/WSGI middleware, Celery task base class, gRPC interceptors | [WIKI §11](WIKI.md#11-framework-integrations) |
|
|
190
239
|
| Async task groups | `NarrativeTaskGroup` — concurrent `asyncio` tasks under one shared story | [WIKI §12](WIKI.md#12-async-task-groups) |
|
|
191
240
|
| Persistence & CLI | `SqliteStoryRenderer` + `runtime-narrative failures` / `runtime-narrative story <id>` | [WIKI §13](WIKI.md#13-sqlite-persistence-and-cli) |
|
|
@@ -266,6 +315,8 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
266
315
|
| `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
|
|
267
316
|
| `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
|
|
268
317
|
| `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
|
|
318
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
|
|
319
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
|
|
269
320
|
|
|
270
321
|
---
|
|
271
322
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runtime-narrative"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
8
8
|
description = "Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.5.0"
|
|
2
2
|
|
|
3
3
|
from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
|
|
4
4
|
from .context import has_active_story
|
|
@@ -19,6 +19,7 @@ from .logging_bridge import NarrativeLogHandler
|
|
|
19
19
|
from .outcome import http_outcome
|
|
20
20
|
from .renderer.console import ConsoleRenderer
|
|
21
21
|
from .renderer.filter_renderer import FilteredRenderer
|
|
22
|
+
from .renderer.coalescing_renderer import CoalescingRenderer
|
|
22
23
|
from .renderer.json_renderer import JsonRenderer, RotatingJsonRenderer
|
|
23
24
|
from .stage import stage
|
|
24
25
|
from .story import story, StoryRuntime
|
|
@@ -106,6 +107,7 @@ __all__ = [
|
|
|
106
107
|
"RuntimeNarrativeMiddleware",
|
|
107
108
|
"ConsoleRenderer",
|
|
108
109
|
"FilteredRenderer",
|
|
110
|
+
"CoalescingRenderer",
|
|
109
111
|
"JsonRenderer",
|
|
110
112
|
"RotatingJsonRenderer",
|
|
111
113
|
"OtelRenderer",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Sequence
|
|
5
4
|
|
|
5
|
+
from .renderer_defaults import default_renderers
|
|
6
6
|
from .story import story
|
|
7
7
|
|
|
8
8
|
try:
|
|
@@ -15,14 +15,6 @@ except ImportError:
|
|
|
15
15
|
_TaskBase = object
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def _default_renderers() -> tuple:
|
|
19
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
20
|
-
from .renderer.console import ConsoleRenderer
|
|
21
|
-
return (ConsoleRenderer(),)
|
|
22
|
-
from .renderer.json_renderer import JsonRenderer
|
|
23
|
-
return (JsonRenderer(),)
|
|
24
|
-
|
|
25
|
-
|
|
26
18
|
class NarrativeTask(_TaskBase): # type: ignore[valid-type,misc]
|
|
27
19
|
abstract = True
|
|
28
20
|
|
|
@@ -47,7 +39,7 @@ class NarrativeTask(_TaskBase): # type: ignore[valid-type,misc]
|
|
|
47
39
|
request = getattr(self, "request", None)
|
|
48
40
|
task_id = getattr(request, "id", None) or "unknown"
|
|
49
41
|
story_name = f"{self.name} [task_id={task_id}]"
|
|
50
|
-
renderers = self.narrative_renderers if self.narrative_renderers is not None else
|
|
42
|
+
renderers = self.narrative_renderers if self.narrative_renderers is not None else default_renderers()
|
|
51
43
|
with story(
|
|
52
44
|
story_name,
|
|
53
45
|
renderers=renderers,
|
|
@@ -41,6 +41,10 @@ def runtime_narrative_story(
|
|
|
41
41
|
"failure_diagnostics": failure_diagnostics,
|
|
42
42
|
"allow_rich_in_production": allow_rich_in_production,
|
|
43
43
|
"app_roots": app_roots,
|
|
44
|
+
# Without this, story() would auto-detect the caller's frame as
|
|
45
|
+
# this decorator's own wrapper closure, reporting every decorated
|
|
46
|
+
# story as "runtime_narrative.decorators" instead of func's module.
|
|
47
|
+
"module": func.__module__,
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
if inspect.iscoroutinefunction(func):
|
|
@@ -72,14 +76,14 @@ def runtime_narrative_stage(name: str | None = None) -> Callable[[F], F]:
|
|
|
72
76
|
|
|
73
77
|
@wraps(func)
|
|
74
78
|
async def async_wrapper(*args: Any, **kwargs: Any):
|
|
75
|
-
async with stage(stage_name):
|
|
79
|
+
async with stage(stage_name, module=func.__module__):
|
|
76
80
|
return await func(*args, **kwargs)
|
|
77
81
|
|
|
78
82
|
return async_wrapper # type: ignore[return-value]
|
|
79
83
|
|
|
80
84
|
@wraps(func)
|
|
81
85
|
def sync_wrapper(*args: Any, **kwargs: Any):
|
|
82
|
-
with stage(stage_name):
|
|
86
|
+
with stage(stage_name, module=func.__module__):
|
|
83
87
|
return func(*args, **kwargs)
|
|
84
88
|
|
|
85
89
|
return sync_wrapper # type: ignore[return-value]
|
|
@@ -12,6 +12,7 @@ class StoryStarted:
|
|
|
12
12
|
timestamp: datetime
|
|
13
13
|
parent_story_id: str | None = None
|
|
14
14
|
root_story_id: str = ""
|
|
15
|
+
module: str = ""
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@dataclass
|
|
@@ -23,6 +24,7 @@ class StageStarted:
|
|
|
23
24
|
parent_stage_name: str | None = None
|
|
24
25
|
story_name: str = ""
|
|
25
26
|
root_story_id: str = ""
|
|
27
|
+
module: str = ""
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
@dataclass
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Callable, Sequence
|
|
5
4
|
|
|
5
|
+
from .renderer_defaults import default_renderers
|
|
6
6
|
from .story import story
|
|
7
7
|
|
|
8
8
|
try:
|
|
@@ -24,17 +24,9 @@ except ImportError:
|
|
|
24
24
|
_AsyncBase = object
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _default_renderers() -> tuple:
|
|
28
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
29
|
-
from .renderer.console import ConsoleRenderer
|
|
30
|
-
return (ConsoleRenderer(),)
|
|
31
|
-
from .renderer.json_renderer import JsonRenderer
|
|
32
|
-
return (JsonRenderer(),)
|
|
33
|
-
|
|
34
|
-
|
|
35
27
|
def _story_kwargs(interceptor: Any) -> dict:
|
|
36
28
|
return {
|
|
37
|
-
"renderers": interceptor._renderers or
|
|
29
|
+
"renderers": interceptor._renderers or default_renderers(),
|
|
38
30
|
"failure_analyzer": interceptor._failure_analyzer,
|
|
39
31
|
"diagnostics_config": interceptor._diagnostics_config,
|
|
40
32
|
"runtime_environment": interceptor._runtime_environment,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Callable, Sequence
|
|
5
4
|
|
|
6
5
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
@@ -9,6 +8,7 @@ from starlette.responses import Response
|
|
|
9
8
|
|
|
10
9
|
from .diagnostics import FailureDiagnosticsConfig
|
|
11
10
|
from .outcome import http_outcome
|
|
11
|
+
from .renderer_defaults import default_renderers
|
|
12
12
|
from .story import story
|
|
13
13
|
|
|
14
14
|
try:
|
|
@@ -19,17 +19,6 @@ except ImportError:
|
|
|
19
19
|
_OTEL_PROPAGATION_AVAILABLE = False
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def _default_middleware_renderers() -> tuple:
|
|
23
|
-
"""Return ConsoleRenderer when attached to a real terminal, JsonRenderer otherwise."""
|
|
24
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
25
|
-
from .renderer.console import ConsoleRenderer
|
|
26
|
-
return (ConsoleRenderer(),)
|
|
27
|
-
from .renderer.json_renderer import JsonRenderer
|
|
28
|
-
return (JsonRenderer(),)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
22
|
class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
34
23
|
"""
|
|
35
24
|
FastAPI/Starlette middleware that wraps every HTTP request in a runtime_narrative story.
|
|
@@ -40,6 +29,8 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
|
40
29
|
When no ``renderers`` are passed, the middleware auto-selects:
|
|
41
30
|
- ``ConsoleRenderer`` if ``sys.stdout`` is a real TTY (local dev server)
|
|
42
31
|
- ``JsonRenderer`` otherwise (production, Docker, CI — any non-interactive environment)
|
|
32
|
+
- Set ``RUNTIME_NARRATIVE_RICH_LOG_FILE=<path>`` to also (or instead, combined with
|
|
33
|
+
``RUNTIME_NARRATIVE_RICH_LOG_CONSOLE=0``) write the human-readable narrative to a file.
|
|
43
34
|
|
|
44
35
|
Usage::
|
|
45
36
|
|
|
@@ -79,7 +70,7 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
|
79
70
|
skip_if: Callable[[Request], bool] | None = None,
|
|
80
71
|
):
|
|
81
72
|
super().__init__(app)
|
|
82
|
-
self._renderers = tuple(renderers) if renderers is not None else
|
|
73
|
+
self._renderers = tuple(renderers) if renderers is not None else default_renderers()
|
|
83
74
|
self._failure_analyzer = failure_analyzer
|
|
84
75
|
self._diagnostics_config = diagnostics_config
|
|
85
76
|
self._runtime_environment = runtime_environment
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Sequence
|
|
5
4
|
|
|
6
5
|
from .diagnostics import FailureDiagnosticsConfig
|
|
7
6
|
from .outcome import http_outcome
|
|
7
|
+
from .renderer_defaults import default_renderers
|
|
8
8
|
from .story import story
|
|
9
9
|
|
|
10
10
|
try:
|
|
@@ -14,14 +14,6 @@ except ImportError:
|
|
|
14
14
|
_DJANGO_AVAILABLE = False
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def _default_renderers() -> tuple:
|
|
18
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
19
|
-
from .renderer.console import ConsoleRenderer
|
|
20
|
-
return (ConsoleRenderer(),)
|
|
21
|
-
from .renderer.json_renderer import JsonRenderer
|
|
22
|
-
return (JsonRenderer(),)
|
|
23
|
-
|
|
24
|
-
|
|
25
17
|
class RuntimeNarrativeDjangoMiddleware:
|
|
26
18
|
async_capable = True
|
|
27
19
|
sync_capable = False
|
|
@@ -45,7 +37,7 @@ class RuntimeNarrativeDjangoMiddleware:
|
|
|
45
37
|
"Install it with: pip install django"
|
|
46
38
|
)
|
|
47
39
|
self.get_response = get_response
|
|
48
|
-
self._renderers = tuple(renderers) if renderers is not None else
|
|
40
|
+
self._renderers = tuple(renderers) if renderers is not None else default_renderers()
|
|
49
41
|
self._failure_analyzer = failure_analyzer
|
|
50
42
|
self._diagnostics_config = diagnostics_config
|
|
51
43
|
self._runtime_environment = runtime_environment
|
|
@@ -97,7 +89,7 @@ class RuntimeNarrativeDjangoSyncMiddleware:
|
|
|
97
89
|
"Install it with: pip install django"
|
|
98
90
|
)
|
|
99
91
|
self.get_response = get_response
|
|
100
|
-
self._renderers = tuple(renderers) if renderers is not None else
|
|
92
|
+
self._renderers = tuple(renderers) if renderers is not None else default_renderers()
|
|
101
93
|
self._failure_analyzer = failure_analyzer
|
|
102
94
|
self._diagnostics_config = diagnostics_config
|
|
103
95
|
self._runtime_environment = runtime_environment
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..events import LogRecorded
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CoalescingRenderer:
|
|
10
|
+
"""Wraps another renderer, collapsing a run of identical back-to-back stages --
|
|
11
|
+
e.g. a "poll status every 2s until done" loop -- into a single summary line
|
|
12
|
+
instead of flooding the log with one StageStarted/StageCompleted pair per
|
|
13
|
+
iteration.
|
|
14
|
+
|
|
15
|
+
The wrapped renderer still sees the first `threshold` occurrences of a
|
|
16
|
+
repeated (story_id, stage_name) pair in full, so the pattern is visible as
|
|
17
|
+
it starts. From the next occurrence on, StageStarted/StageCompleted for
|
|
18
|
+
that stage are suppressed and accumulated instead of forwarded. As soon as
|
|
19
|
+
a different event arrives for that story (a different stage, a failure, or
|
|
20
|
+
the story completing), the run is flushed as one LogRecorded summary line
|
|
21
|
+
-- reporting the total call count and total time spent in that stage --
|
|
22
|
+
forwarded through the wrapped renderer before the triggering event.
|
|
23
|
+
|
|
24
|
+
Only wrap the human-facing renderer(s) with this (typically ConsoleRenderer).
|
|
25
|
+
Renderers meant for full-fidelity machine consumption -- JsonRenderer,
|
|
26
|
+
SqliteStoryRenderer, OtelRenderer, and friends -- should keep receiving
|
|
27
|
+
every real event and should NOT be wrapped.
|
|
28
|
+
|
|
29
|
+
Example -- a status-polling loop inside one long-running story::
|
|
30
|
+
|
|
31
|
+
with story("Process Upload", renderers=[CoalescingRenderer(ConsoleRenderer())]):
|
|
32
|
+
with stage("Upload File"):
|
|
33
|
+
...
|
|
34
|
+
while not done:
|
|
35
|
+
with stage("Check Pipeline Status"):
|
|
36
|
+
done = check_status()
|
|
37
|
+
time.sleep(2)
|
|
38
|
+
|
|
39
|
+
Only the first two (default `threshold`) status checks print a
|
|
40
|
+
StageStarted/StageCompleted pair; the rest are folded into one line like::
|
|
41
|
+
|
|
42
|
+
'Check Pipeline Status' repeated 41 more times (43 total) over 84.200s (avg 1.958s/call)
|
|
43
|
+
|
|
44
|
+
Limitations: a run is keyed on (story_id, stage_name) alone, without regard
|
|
45
|
+
to nesting -- a differently named stage nested *inside* the repeated one
|
|
46
|
+
(e.g. a "Parse Response" sub-stage on every poll) counts as "a different
|
|
47
|
+
stage" and ends the run early. Keep the polling loop's stage as a single,
|
|
48
|
+
unnested stage per iteration to get full coalescing. Also, if the stage
|
|
49
|
+
that's mid-run raises before completing, that final in-flight call's
|
|
50
|
+
duration is not included in the summary total (though it is still counted).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, renderer: Any, *, threshold: int = 2) -> None:
|
|
54
|
+
if threshold < 1:
|
|
55
|
+
raise ValueError("threshold must be >= 1")
|
|
56
|
+
self._renderer = renderer
|
|
57
|
+
self._threshold = threshold
|
|
58
|
+
self._runs: dict[str, dict[str, Any]] = {}
|
|
59
|
+
if inspect.iscoroutinefunction(getattr(renderer, "handle", None)):
|
|
60
|
+
self.handle = self._handle_async
|
|
61
|
+
else:
|
|
62
|
+
self.handle = self._handle_sync
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Shared run-tracking logic (pure, no I/O).
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _track_stage_started(self, event: Any) -> tuple[Any | None, bool]:
|
|
69
|
+
"""Returns (summary_event_to_flush_first_or_None, should_forward_this_event)."""
|
|
70
|
+
story_id = event.story_id
|
|
71
|
+
stage_name = event.stage_name
|
|
72
|
+
run = self._runs.get(story_id)
|
|
73
|
+
|
|
74
|
+
if run is not None and run["stage_name"] == stage_name:
|
|
75
|
+
run["count"] += 1
|
|
76
|
+
return None, run["count"] <= self._threshold
|
|
77
|
+
|
|
78
|
+
summary = self._pop_summary(story_id)
|
|
79
|
+
self._runs[story_id] = {
|
|
80
|
+
"stage_name": stage_name,
|
|
81
|
+
"count": 1,
|
|
82
|
+
"total_duration": 0.0,
|
|
83
|
+
"first_started_at": event.timestamp,
|
|
84
|
+
"story_name": event.story_name,
|
|
85
|
+
"root_story_id": event.root_story_id or story_id,
|
|
86
|
+
}
|
|
87
|
+
return summary, True
|
|
88
|
+
|
|
89
|
+
def _track_stage_completed(self, event: Any) -> tuple[Any | None, bool]:
|
|
90
|
+
story_id = event.story_id
|
|
91
|
+
run = self._runs.get(story_id)
|
|
92
|
+
if run is None or run["stage_name"] != event.stage_name:
|
|
93
|
+
# Unbalanced (shouldn't normally happen) -- forward as-is.
|
|
94
|
+
return None, True
|
|
95
|
+
run["total_duration"] += event.duration_seconds
|
|
96
|
+
return None, run["count"] <= self._threshold
|
|
97
|
+
|
|
98
|
+
def _pop_summary(self, story_id: str) -> Any | None:
|
|
99
|
+
run = self._runs.pop(story_id, None)
|
|
100
|
+
if run is None or run["count"] <= self._threshold:
|
|
101
|
+
return None
|
|
102
|
+
suppressed = run["count"] - self._threshold
|
|
103
|
+
avg = run["total_duration"] / run["count"] if run["count"] else 0.0
|
|
104
|
+
return LogRecorded(
|
|
105
|
+
story_id=story_id,
|
|
106
|
+
story_name=run["story_name"],
|
|
107
|
+
root_story_id=run["root_story_id"],
|
|
108
|
+
stage_name=run["stage_name"],
|
|
109
|
+
level="INFO",
|
|
110
|
+
logger_name="runtime_narrative.coalesce",
|
|
111
|
+
message=(
|
|
112
|
+
f"{run['stage_name']!r} repeated {suppressed} more time"
|
|
113
|
+
f"{'s' if suppressed != 1 else ''} ({run['count']} total) "
|
|
114
|
+
f"over {run['total_duration']:.3f}s (avg {avg:.3f}s/call)"
|
|
115
|
+
),
|
|
116
|
+
timestamp=run["first_started_at"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _flush(self, story_id: str) -> Any | None:
|
|
120
|
+
return self._pop_summary(story_id)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Sync / async dispatch
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _handle_sync(self, event: object) -> None:
|
|
127
|
+
event_name = event.__class__.__name__
|
|
128
|
+
story_id = getattr(event, "story_id", None)
|
|
129
|
+
|
|
130
|
+
if event_name == "StageStarted":
|
|
131
|
+
summary, forward = self._track_stage_started(event)
|
|
132
|
+
if summary is not None:
|
|
133
|
+
self._renderer.handle(summary)
|
|
134
|
+
if forward:
|
|
135
|
+
self._renderer.handle(event)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if event_name == "StageCompleted":
|
|
139
|
+
summary, forward = self._track_stage_completed(event)
|
|
140
|
+
if summary is not None:
|
|
141
|
+
self._renderer.handle(summary)
|
|
142
|
+
if forward:
|
|
143
|
+
self._renderer.handle(event)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if story_id is not None:
|
|
147
|
+
summary = self._flush(story_id)
|
|
148
|
+
if summary is not None:
|
|
149
|
+
self._renderer.handle(summary)
|
|
150
|
+
self._renderer.handle(event)
|
|
151
|
+
|
|
152
|
+
async def _handle_async(self, event: object) -> None:
|
|
153
|
+
event_name = event.__class__.__name__
|
|
154
|
+
story_id = getattr(event, "story_id", None)
|
|
155
|
+
|
|
156
|
+
if event_name == "StageStarted":
|
|
157
|
+
summary, forward = self._track_stage_started(event)
|
|
158
|
+
if summary is not None:
|
|
159
|
+
await self._renderer.handle(summary)
|
|
160
|
+
if forward:
|
|
161
|
+
await self._renderer.handle(event)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if event_name == "StageCompleted":
|
|
165
|
+
summary, forward = self._track_stage_completed(event)
|
|
166
|
+
if summary is not None:
|
|
167
|
+
await self._renderer.handle(summary)
|
|
168
|
+
if forward:
|
|
169
|
+
await self._renderer.handle(event)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if story_id is not None:
|
|
173
|
+
summary = self._flush(story_id)
|
|
174
|
+
if summary is not None:
|
|
175
|
+
await self._renderer.handle(summary)
|
|
176
|
+
await self._renderer.handle(event)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["CoalescingRenderer"]
|