runtime-narrative 1.5.0__tar.gz → 1.5.2__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.5.0/runtime_narrative.egg-info → runtime_narrative-1.5.2}/PKG-INFO +2 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/README.md +1 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/pyproject.toml +1 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/__init__.py +1 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/diagnostics.py +122 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/failure.py +18 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/console.py +3 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2/runtime_narrative.egg-info}/PKG-INFO +2 -1
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_console_renderer.py +28 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_diagnostics.py +83 -0
- runtime_narrative-1.5.2/tests/test_failure.py +55 -0
- runtime_narrative-1.5.0/tests/test_failure.py +0 -19
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/LICENSE +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/__init__.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/anthropic.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/base.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/deduplication.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/ollama.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/celery.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/cli.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/context.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/decorators.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/events.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/grpc_interceptor.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/instrumentation.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/logging_bridge.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/middleware.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/middleware_django.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/outcome.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/__init__.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/alert_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/coalescing_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/filter_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/html_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/json_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/persistence_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer_defaults.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/stage.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/story.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/task_group.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/testing.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/SOURCES.txt +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/dependency_links.txt +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/entry_points.txt +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/requires.txt +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/top_level.txt +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/setup.cfg +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_alert_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_analyzers.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_anthropic_analyzer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_async_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_celery.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_coalescing_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_decorators.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_deduplication.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_dry_run.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_filter_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_grpc_interceptor.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_html_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_instrumentation.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_instrumentation_phase2.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_issues.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_json_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_logging_bridge.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_middleware.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_middleware_django.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_middleware_propagation.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_module_capture.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_otel_log_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_otel_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_outcome.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_persistence_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_prometheus_renderer.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_redaction_extended.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_renderer_defaults.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_stage.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_stage_metadata.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_story.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_structured_analysis.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_task_group.py +0 -0
- {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/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.5.
|
|
3
|
+
Version: 1.5.2
|
|
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
|
|
@@ -353,6 +353,7 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
353
353
|
| `middleware_skip_if.py` | `RuntimeNarrativeMiddleware(skip_if=...)` for FastAPI/Starlette |
|
|
354
354
|
| `task_group.py` | `NarrativeTaskGroup` — concurrent asyncio tasks under one story |
|
|
355
355
|
| `fastapi_app/` | Full FastAPI demo app (`uv run python -m examples.fastapi_app.run`) |
|
|
356
|
+
| `fastapi_ugly_traceback_demo.py` | The same deep async bug (route → orchestrator → retry-wrapped pricing engine → `asyncio.gather` fan-out → the actual TypeError), run once with no instrumentation and once with runtime-narrative — a raw ~35-frame traceback vs. one pinpointed line, source snippet, and locals |
|
|
356
357
|
|
|
357
358
|
**Testing and lifecycle utilities**
|
|
358
359
|
| Script | Demonstrates |
|
|
@@ -294,6 +294,7 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
294
294
|
| `middleware_skip_if.py` | `RuntimeNarrativeMiddleware(skip_if=...)` for FastAPI/Starlette |
|
|
295
295
|
| `task_group.py` | `NarrativeTaskGroup` — concurrent asyncio tasks under one story |
|
|
296
296
|
| `fastapi_app/` | Full FastAPI demo app (`uv run python -m examples.fastapi_app.run`) |
|
|
297
|
+
| `fastapi_ugly_traceback_demo.py` | The same deep async bug (route → orchestrator → retry-wrapped pricing engine → `asyncio.gather` fan-out → the actual TypeError), run once with no instrumentation and once with runtime-narrative — a raw ~35-frame traceback vs. one pinpointed line, source snippet, and locals |
|
|
297
298
|
|
|
298
299
|
**Testing and lifecycle utilities**
|
|
299
300
|
| Script | Demonstrates |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runtime-narrative"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.2"
|
|
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,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import ast
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
import sys
|
|
@@ -261,6 +262,114 @@ def _should_redact_key(
|
|
|
261
262
|
return False
|
|
262
263
|
|
|
263
264
|
|
|
265
|
+
# ── Operand-type-mismatch explanation ───────────────────────────────────────
|
|
266
|
+
# Names the specific variable(s)/expression(s) behind a
|
|
267
|
+
# "TypeError: unsupported operand type(s) for OP: 'A' and 'B'" instead of just
|
|
268
|
+
# restating the exception message. Only activates in rich mode, reusing the
|
|
269
|
+
# frame locals already captured there (no extra capture cost) and the same
|
|
270
|
+
# redaction pipeline as the locals section. Anything it can't confidently
|
|
271
|
+
# resolve falls back to the generic exact_cause untouched.
|
|
272
|
+
|
|
273
|
+
_OPERAND_TYPE_ERROR_RE = re.compile(
|
|
274
|
+
r"^unsupported operand type\(s\) for (?P<op>\S+): '(?P<left_type>\w+)' and '(?P<right_type>\w+)'$"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
_OP_SYMBOL_TO_AST: dict[str, type] = {
|
|
278
|
+
"+": ast.Add, "-": ast.Sub, "*": ast.Mult, "/": ast.Div,
|
|
279
|
+
"//": ast.FloorDiv, "%": ast.Mod, "**": ast.Pow, "@": ast.MatMult,
|
|
280
|
+
"&": ast.BitAnd, "|": ast.BitOr, "^": ast.BitXor, "<<": ast.LShift, ">>": ast.RShift,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _safe_resolve_expr(node: ast.AST, frame_locals: Mapping[str, Any]) -> tuple[bool, Any]:
|
|
285
|
+
"""Resolve a bare name or a one-level constant-key subscript (e.g. ``item["price"]``)
|
|
286
|
+
against already-captured frame locals. Never calls into user code (no
|
|
287
|
+
attribute access, no function calls) — only dict/list/tuple __getitem__ on
|
|
288
|
+
values we already hold a reference to."""
|
|
289
|
+
if isinstance(node, ast.Name):
|
|
290
|
+
if node.id in frame_locals:
|
|
291
|
+
return True, frame_locals[node.id]
|
|
292
|
+
return False, None
|
|
293
|
+
if isinstance(node, ast.Subscript):
|
|
294
|
+
base_ok, base_val = _safe_resolve_expr(node.value, frame_locals)
|
|
295
|
+
if not base_ok:
|
|
296
|
+
return False, None
|
|
297
|
+
key_node = node.slice
|
|
298
|
+
if isinstance(key_node, ast.Constant):
|
|
299
|
+
try:
|
|
300
|
+
return True, base_val[key_node.value]
|
|
301
|
+
except Exception:
|
|
302
|
+
return False, None
|
|
303
|
+
return False, None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _describe_operand(
|
|
307
|
+
node: ast.AST,
|
|
308
|
+
type_name: str,
|
|
309
|
+
frame_locals: Mapping[str, Any],
|
|
310
|
+
*,
|
|
311
|
+
max_len: int,
|
|
312
|
+
extra_redact: tuple[str, ...],
|
|
313
|
+
redact_patterns: tuple[str, ...],
|
|
314
|
+
redact_callback: Any,
|
|
315
|
+
) -> str:
|
|
316
|
+
try:
|
|
317
|
+
text = ast.unparse(node)
|
|
318
|
+
except Exception:
|
|
319
|
+
text = "<expr>"
|
|
320
|
+
if isinstance(node, ast.Name) and _should_redact_key(
|
|
321
|
+
node.id, extra=extra_redact, patterns=redact_patterns, callback=redact_callback
|
|
322
|
+
):
|
|
323
|
+
return f"`{text}` ({type_name}, redacted)"
|
|
324
|
+
resolved, value = _safe_resolve_expr(node, frame_locals)
|
|
325
|
+
if resolved:
|
|
326
|
+
value_repr = _serialize_value(
|
|
327
|
+
value, max_len=max_len, depth=0, max_depth=1,
|
|
328
|
+
extra_redact=extra_redact, redact_patterns=redact_patterns, redact_callback=redact_callback,
|
|
329
|
+
)
|
|
330
|
+
return f"`{text}` = {value_repr} ({type_name})"
|
|
331
|
+
return f"`{text}` ({type_name})"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _infer_operand_type_mismatch(
|
|
335
|
+
message: str,
|
|
336
|
+
source_line: str,
|
|
337
|
+
frame_locals: Mapping[str, Any],
|
|
338
|
+
*,
|
|
339
|
+
max_len: int,
|
|
340
|
+
extra_redact: tuple[str, ...],
|
|
341
|
+
redact_patterns: tuple[str, ...],
|
|
342
|
+
redact_callback: Any,
|
|
343
|
+
) -> str | None:
|
|
344
|
+
"""For 'unsupported operand type(s) for OP: A and B', name the specific
|
|
345
|
+
operands involved and (when safely resolvable) their actual values,
|
|
346
|
+
instead of just restating the exception message. Returns None on any
|
|
347
|
+
parse/resolution failure so callers can fall back to the generic cause."""
|
|
348
|
+
m = _OPERAND_TYPE_ERROR_RE.match(message.strip())
|
|
349
|
+
if not m:
|
|
350
|
+
return None
|
|
351
|
+
op_cls = _OP_SYMBOL_TO_AST.get(m.group("op"))
|
|
352
|
+
if op_cls is None:
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
tree = ast.parse(f"async def _f():\n {source_line.strip()}")
|
|
356
|
+
except SyntaxError:
|
|
357
|
+
return None
|
|
358
|
+
binop = next(
|
|
359
|
+
(n for n in ast.walk(tree) if isinstance(n, ast.BinOp) and isinstance(n.op, op_cls)),
|
|
360
|
+
None,
|
|
361
|
+
)
|
|
362
|
+
if binop is None:
|
|
363
|
+
return None
|
|
364
|
+
kwargs = dict(max_len=max_len, extra_redact=extra_redact, redact_patterns=redact_patterns, redact_callback=redact_callback)
|
|
365
|
+
left_desc = _describe_operand(binop.left, m.group("left_type"), frame_locals, **kwargs)
|
|
366
|
+
right_desc = _describe_operand(binop.right, m.group("right_type"), frame_locals, **kwargs)
|
|
367
|
+
return (
|
|
368
|
+
f"{left_desc} and {right_desc} can't be combined with '{m.group('op')}' - "
|
|
369
|
+
f"{m.group('left_type')} and {m.group('right_type')} are incompatible operand types."
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
264
373
|
def _capture_locals_mapping(
|
|
265
374
|
locals_map: Mapping[str, Any],
|
|
266
375
|
*,
|
|
@@ -384,6 +493,18 @@ def build_enriched_failure(
|
|
|
384
493
|
elif config.max_traceback_chars is not None:
|
|
385
494
|
tb_out, truncated = _truncate_tb(full_tb, config.max_traceback_chars)
|
|
386
495
|
|
|
496
|
+
exact_cause = _infer_exact_cause(exc_type.__name__, str(exc), source_line)
|
|
497
|
+
if mode == "rich" and exc_type.__name__ == "TypeError" and frame_objs and 0 <= primary_idx < len(frame_objs):
|
|
498
|
+
enhanced_cause = _infer_operand_type_mismatch(
|
|
499
|
+
str(exc), source_line, frame_objs[primary_idx].f_locals,
|
|
500
|
+
max_len=config.max_local_value_len,
|
|
501
|
+
extra_redact=config.redact_extra,
|
|
502
|
+
redact_patterns=config.redact_patterns,
|
|
503
|
+
redact_callback=config.redact_callback,
|
|
504
|
+
)
|
|
505
|
+
if enhanced_cause is not None:
|
|
506
|
+
exact_cause = enhanced_cause
|
|
507
|
+
|
|
387
508
|
summary = FailureSummary(
|
|
388
509
|
error_type=exc_type.__name__,
|
|
389
510
|
error_message=str(exc),
|
|
@@ -392,7 +513,7 @@ def build_enriched_failure(
|
|
|
392
513
|
function=function,
|
|
393
514
|
source_line=source_line,
|
|
394
515
|
exception_chain=_build_exception_chain(exc),
|
|
395
|
-
exact_cause=
|
|
516
|
+
exact_cause=exact_cause,
|
|
396
517
|
traceback_text=tb_out,
|
|
397
518
|
)
|
|
398
519
|
|
|
@@ -19,12 +19,29 @@ class FailureSummary:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _build_exception_chain(exc: BaseException) -> str:
|
|
22
|
+
"""Walk __cause__/__context__ the same way Python's own traceback module does.
|
|
23
|
+
|
|
24
|
+
Explicit chaining (``raise X from Y``) is always followed. Implicit
|
|
25
|
+
chaining (``__context__``, set automatically when an exception is raised
|
|
26
|
+
while handling another) is followed *unless* the raiser suppressed it
|
|
27
|
+
with ``raise X from None`` — ``__suppress_context__`` is exactly that
|
|
28
|
+
signal. Ignoring it here would surface unrelated exceptions the original
|
|
29
|
+
author deliberately hid (e.g. cleanup/cancellation noise from a retry
|
|
30
|
+
wrapper's ``raise last_exc from None``), and the raw traceback wouldn't
|
|
31
|
+
show them either — so this would silently disagree with Python's own
|
|
32
|
+
output.
|
|
33
|
+
"""
|
|
22
34
|
parts: list[str] = []
|
|
23
35
|
current: BaseException | None = exc
|
|
24
36
|
depth = 0
|
|
25
37
|
while current is not None and depth < 5:
|
|
26
38
|
parts.append(f"{type(current).__name__}: {current}")
|
|
27
|
-
|
|
39
|
+
if current.__cause__ is not None:
|
|
40
|
+
current = current.__cause__
|
|
41
|
+
elif not current.__suppress_context__:
|
|
42
|
+
current = current.__context__
|
|
43
|
+
else:
|
|
44
|
+
current = None
|
|
28
45
|
depth += 1
|
|
29
46
|
return " <- ".join(parts)
|
|
30
47
|
|
|
@@ -68,10 +68,12 @@ class ConsoleRenderer:
|
|
|
68
68
|
self._glyph_arrow = "▶"
|
|
69
69
|
self._glyph_check = "✔"
|
|
70
70
|
self._glyph_cross = "❌"
|
|
71
|
+
self._glyph_dash = "—"
|
|
71
72
|
else:
|
|
72
73
|
self._glyph_arrow = ">"
|
|
73
74
|
self._glyph_check = "[ok]"
|
|
74
75
|
self._glyph_cross = "[FAIL]"
|
|
76
|
+
self._glyph_dash = "-"
|
|
75
77
|
# story_id -> indent level of that story's own Story started/ended lines
|
|
76
78
|
self._story_base_indent: dict[str, int] = {}
|
|
77
79
|
# story_id -> stack of currently open stage names, for indent depth
|
|
@@ -404,7 +406,7 @@ class ConsoleRenderer:
|
|
|
404
406
|
for label, payload in locals_by_frame.items():
|
|
405
407
|
locs = payload.get("locals", {})
|
|
406
408
|
where = f"{payload.get('filename')}:{payload.get('lineno')} in {payload.get('function')}"
|
|
407
|
-
self._secho(f" {label}
|
|
409
|
+
self._secho(f" {label} {self._glyph_dash} {where}", fg=self._failure_heading_color)
|
|
408
410
|
for k, v in locs.items():
|
|
409
411
|
self._secho(f" {k} = {v}", fg=self._failure_value_color)
|
|
410
412
|
removed = getattr(event, "redaction_removed_keys", 0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-narrative
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.2
|
|
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
|
|
@@ -353,6 +353,7 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
353
353
|
| `middleware_skip_if.py` | `RuntimeNarrativeMiddleware(skip_if=...)` for FastAPI/Starlette |
|
|
354
354
|
| `task_group.py` | `NarrativeTaskGroup` — concurrent asyncio tasks under one story |
|
|
355
355
|
| `fastapi_app/` | Full FastAPI demo app (`uv run python -m examples.fastapi_app.run`) |
|
|
356
|
+
| `fastapi_ugly_traceback_demo.py` | The same deep async bug (route → orchestrator → retry-wrapped pricing engine → `asyncio.gather` fan-out → the actual TypeError), run once with no instrumentation and once with runtime-narrative — a raw ~35-frame traceback vs. one pinpointed line, source snippet, and locals |
|
|
356
357
|
|
|
357
358
|
**Testing and lifecycle utilities**
|
|
358
359
|
| Script | Demonstrates |
|
|
@@ -40,6 +40,7 @@ def test_renderer_ascii_glyphs_on_non_unicode_terminal(monkeypatch):
|
|
|
40
40
|
assert r._glyph_arrow == ">"
|
|
41
41
|
assert r._glyph_check == "[ok]"
|
|
42
42
|
assert r._glyph_cross == "[FAIL]"
|
|
43
|
+
assert r._glyph_dash == "-"
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def test_renderer_unicode_glyphs_on_unicode_terminal(monkeypatch):
|
|
@@ -48,6 +49,33 @@ def test_renderer_unicode_glyphs_on_unicode_terminal(monkeypatch):
|
|
|
48
49
|
assert r._glyph_arrow == "▶"
|
|
49
50
|
assert r._glyph_check == "✔"
|
|
50
51
|
assert r._glyph_cross == "❌"
|
|
52
|
+
assert r._glyph_dash == "—"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_rich_locals_line_uses_ascii_dash_on_non_unicode_terminal(monkeypatch, capsys):
|
|
56
|
+
"""The 'frame_N — file:line in func' locals heading must go through the same
|
|
57
|
+
ASCII-fallback path as the other glyphs, not a hardcoded em-dash that would
|
|
58
|
+
degrade to a '?' replacement character on a non-UTF-8 stream."""
|
|
59
|
+
monkeypatch.setattr(console_mod, "_stdout_supports_unicode", lambda: False)
|
|
60
|
+
r = ConsoleRenderer()
|
|
61
|
+
event = FailureOccurred(
|
|
62
|
+
story_id="sid", story_name="S", stage_name="St",
|
|
63
|
+
error_type="TypeError", error_message="m", filename="f.py", lineno=1,
|
|
64
|
+
function="fn", source_line="raise TypeError", exception_chain="TypeError: m",
|
|
65
|
+
exact_cause="because", llm_analysis=None, stage_timeline="x",
|
|
66
|
+
progress_percent=0, completed_stages=0, total_stages=1,
|
|
67
|
+
timestamp=datetime(2024, 1, 1),
|
|
68
|
+
diagnostics_mode="rich", primary_frame_reason="innermost_app",
|
|
69
|
+
stack_frames=[], source_snippet=None, compressed_stack_summary="",
|
|
70
|
+
hidden_frame_count=0, traceback_truncated=False,
|
|
71
|
+
locals_by_frame={"frame_0": {"filename": "f.py", "lineno": 1, "function": "fn", "locals": {"x": "1"}}},
|
|
72
|
+
redaction_removed_keys=0,
|
|
73
|
+
traceback_text="Traceback...",
|
|
74
|
+
)
|
|
75
|
+
r.handle(event)
|
|
76
|
+
out = capsys.readouterr().out
|
|
77
|
+
assert "frame_0 - f.py:1 in fn" in out
|
|
78
|
+
assert "—" not in out
|
|
51
79
|
|
|
52
80
|
|
|
53
81
|
def test_secho_survives_unicode_encode_error_from_typer(monkeypatch):
|
|
@@ -252,3 +252,86 @@ def test_compressed_stack_summary_counts_app_frames() -> None:
|
|
|
252
252
|
|
|
253
253
|
assert "app frame" in enriched.compressed_stack_summary
|
|
254
254
|
assert str(len(enriched.stack_frames)) in enriched.compressed_stack_summary
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Operand-type-mismatch exact_cause enhancement ─────────────────────────────
|
|
258
|
+
|
|
259
|
+
def test_exact_cause_names_operands_on_type_mismatch_in_rich_mode() -> None:
|
|
260
|
+
cfg = FailureDiagnosticsConfig(runtime_environment="development", failure_diagnostics="rich")
|
|
261
|
+
|
|
262
|
+
def inner() -> None:
|
|
263
|
+
item = {"price": 29.99}
|
|
264
|
+
promo = {"buy": 1, "get": 1}
|
|
265
|
+
item["price"] * promo # noqa: B018 - deliberately triggers TypeError
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
inner()
|
|
269
|
+
except TypeError as e:
|
|
270
|
+
enriched = build_enriched_failure(type(e), e, e.__traceback__, config=cfg)
|
|
271
|
+
|
|
272
|
+
cause = enriched.summary.exact_cause
|
|
273
|
+
assert "item['price']" in cause
|
|
274
|
+
assert "29.99" in cause
|
|
275
|
+
assert "promo" in cause
|
|
276
|
+
assert "{'buy': 1, 'get': 1}" in cause
|
|
277
|
+
assert "float" in cause and "dict" in cause
|
|
278
|
+
# Must stay plain ASCII -- this string also flows into JSON/OTel, not just
|
|
279
|
+
# a Unicode-aware console.
|
|
280
|
+
assert all(ord(c) < 128 for c in cause)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_exact_cause_falls_back_to_generic_in_lean_mode() -> None:
|
|
284
|
+
"""The enhancement only fires in rich mode -- lean mode never inspects locals."""
|
|
285
|
+
cfg = FailureDiagnosticsConfig(runtime_environment="development", failure_diagnostics="lean")
|
|
286
|
+
|
|
287
|
+
def inner() -> None:
|
|
288
|
+
item = {"price": 29.99}
|
|
289
|
+
promo = {"buy": 1, "get": 1}
|
|
290
|
+
item["price"] * promo # noqa: B018
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
inner()
|
|
294
|
+
except TypeError as e:
|
|
295
|
+
enriched = build_enriched_failure(type(e), e, e.__traceback__, config=cfg)
|
|
296
|
+
|
|
297
|
+
assert enriched.summary.exact_cause.startswith("The statement `")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_exact_cause_redacts_operand_named_like_a_secret() -> None:
|
|
301
|
+
cfg = FailureDiagnosticsConfig(runtime_environment="development", failure_diagnostics="rich")
|
|
302
|
+
|
|
303
|
+
def inner() -> None:
|
|
304
|
+
api_token = {"scope": "read"}
|
|
305
|
+
1.5 * api_token # noqa: B018
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
inner()
|
|
309
|
+
except TypeError as e:
|
|
310
|
+
enriched = build_enriched_failure(type(e), e, e.__traceback__, config=cfg)
|
|
311
|
+
|
|
312
|
+
cause = enriched.summary.exact_cause
|
|
313
|
+
assert "redacted" in cause
|
|
314
|
+
assert "scope" not in cause
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_exact_cause_falls_back_when_operand_not_a_simple_expression() -> None:
|
|
318
|
+
"""Function calls / complex expressions aren't safely resolvable -- the
|
|
319
|
+
enhancement should still fire (naming the expression text) without
|
|
320
|
+
evaluating anything, and never raise."""
|
|
321
|
+
cfg = FailureDiagnosticsConfig(runtime_environment="development", failure_diagnostics="rich")
|
|
322
|
+
|
|
323
|
+
def get_promo() -> dict:
|
|
324
|
+
return {"buy": 1}
|
|
325
|
+
|
|
326
|
+
def inner() -> None:
|
|
327
|
+
price = 10.0
|
|
328
|
+
price * get_promo() # noqa: B018
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
inner()
|
|
332
|
+
except TypeError as e:
|
|
333
|
+
enriched = build_enriched_failure(type(e), e, e.__traceback__, config=cfg)
|
|
334
|
+
|
|
335
|
+
cause = enriched.summary.exact_cause
|
|
336
|
+
assert "get_promo()" in cause
|
|
337
|
+
assert "price" in cause
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from runtime_narrative.failure import summarize_exception
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_summarize_exception_extracts_leaf_frame() -> None:
|
|
7
|
+
def inner() -> None:
|
|
8
|
+
raise KeyError("missing")
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
inner()
|
|
12
|
+
except KeyError as e:
|
|
13
|
+
summary = summarize_exception(type(e), e, e.__traceback__)
|
|
14
|
+
|
|
15
|
+
assert summary.error_type == "KeyError"
|
|
16
|
+
assert "missing" in summary.error_message
|
|
17
|
+
assert summary.function == "inner"
|
|
18
|
+
assert "KeyError" in summary.exception_chain
|
|
19
|
+
assert "inner" in summary.traceback_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_exception_chain_respects_suppress_context() -> None:
|
|
23
|
+
"""`raise X from None` must stop the chain, matching Python's own traceback
|
|
24
|
+
output. Naively following __context__ would surface unrelated exceptions
|
|
25
|
+
(e.g. cleanup/cancellation noise) that the raiser deliberately hid."""
|
|
26
|
+
def inner() -> None:
|
|
27
|
+
try:
|
|
28
|
+
raise ValueError("unrelated cleanup noise")
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise TypeError("the real error") from None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
inner()
|
|
34
|
+
except TypeError as e:
|
|
35
|
+
summary = summarize_exception(type(e), e, e.__traceback__)
|
|
36
|
+
|
|
37
|
+
assert summary.exception_chain == "TypeError: the real error"
|
|
38
|
+
assert "ValueError" not in summary.exception_chain
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_exception_chain_follows_explicit_cause_even_with_suppress_context() -> None:
|
|
42
|
+
"""`raise X from Y` always shows Y — __suppress_context__ only governs
|
|
43
|
+
the implicit __context__ fallback, never an explicit __cause__."""
|
|
44
|
+
def inner() -> None:
|
|
45
|
+
try:
|
|
46
|
+
raise ValueError("root cause")
|
|
47
|
+
except ValueError as root:
|
|
48
|
+
raise TypeError("wrapped") from root
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
inner()
|
|
52
|
+
except TypeError as e:
|
|
53
|
+
summary = summarize_exception(type(e), e, e.__traceback__)
|
|
54
|
+
|
|
55
|
+
assert summary.exception_chain == "TypeError: wrapped <- ValueError: root cause"
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from runtime_narrative.failure import summarize_exception
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_summarize_exception_extracts_leaf_frame() -> None:
|
|
7
|
-
def inner() -> None:
|
|
8
|
-
raise KeyError("missing")
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
inner()
|
|
12
|
-
except KeyError as e:
|
|
13
|
-
summary = summarize_exception(type(e), e, e.__traceback__)
|
|
14
|
-
|
|
15
|
-
assert summary.error_type == "KeyError"
|
|
16
|
-
assert "missing" in summary.error_message
|
|
17
|
-
assert summary.function == "inner"
|
|
18
|
-
assert "KeyError" in summary.exception_chain
|
|
19
|
-
assert "inner" in summary.traceback_text
|
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/anthropic.py
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/deduplication.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/alert_renderer.py
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/filter_renderer.py
RENAMED
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/html_renderer.py
RENAMED
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/json_renderer.py
RENAMED
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_log_renderer.py
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_renderer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|