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.
Files changed (86) hide show
  1. {runtime_narrative-1.5.0/runtime_narrative.egg-info → runtime_narrative-1.5.2}/PKG-INFO +2 -1
  2. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/README.md +1 -0
  3. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/pyproject.toml +1 -1
  4. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/__init__.py +1 -1
  5. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/diagnostics.py +122 -1
  6. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/failure.py +18 -1
  7. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/console.py +3 -1
  8. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2/runtime_narrative.egg-info}/PKG-INFO +2 -1
  9. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_console_renderer.py +28 -0
  10. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_diagnostics.py +83 -0
  11. runtime_narrative-1.5.2/tests/test_failure.py +55 -0
  12. runtime_narrative-1.5.0/tests/test_failure.py +0 -19
  13. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/LICENSE +0 -0
  14. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/__init__.py +0 -0
  15. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/anthropic.py +0 -0
  16. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/base.py +0 -0
  17. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/deduplication.py +0 -0
  18. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/analyzers/ollama.py +0 -0
  19. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/celery.py +0 -0
  20. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/cli.py +0 -0
  21. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/context.py +0 -0
  22. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/decorators.py +0 -0
  23. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/events.py +0 -0
  24. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/grpc_interceptor.py +0 -0
  25. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/instrumentation.py +0 -0
  26. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/logging_bridge.py +0 -0
  27. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/middleware.py +0 -0
  28. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/middleware_django.py +0 -0
  29. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/outcome.py +0 -0
  30. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/__init__.py +0 -0
  31. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/alert_renderer.py +0 -0
  32. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/coalescing_renderer.py +0 -0
  33. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/filter_renderer.py +0 -0
  34. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/html_renderer.py +0 -0
  35. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/json_renderer.py +0 -0
  36. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
  37. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
  38. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/otel_renderer.py +0 -0
  39. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/persistence_renderer.py +0 -0
  40. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
  41. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/renderer_defaults.py +0 -0
  42. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/stage.py +0 -0
  43. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/story.py +0 -0
  44. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/task_group.py +0 -0
  45. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative/testing.py +0 -0
  46. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/SOURCES.txt +0 -0
  47. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  48. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/entry_points.txt +0 -0
  49. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/requires.txt +0 -0
  50. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/runtime_narrative.egg-info/top_level.txt +0 -0
  51. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/setup.cfg +0 -0
  52. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_alert_renderer.py +0 -0
  53. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_analyzers.py +0 -0
  54. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_anthropic_analyzer.py +0 -0
  55. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_async_renderer.py +0 -0
  56. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_celery.py +0 -0
  57. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_coalescing_renderer.py +0 -0
  58. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_decorators.py +0 -0
  59. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_deduplication.py +0 -0
  60. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_dry_run.py +0 -0
  61. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_filter_renderer.py +0 -0
  62. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_grpc_interceptor.py +0 -0
  63. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_html_renderer.py +0 -0
  64. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_instrumentation.py +0 -0
  65. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_instrumentation_phase2.py +0 -0
  66. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_issues.py +0 -0
  67. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_json_renderer.py +0 -0
  68. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_logging_bridge.py +0 -0
  69. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_middleware.py +0 -0
  70. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_middleware_django.py +0 -0
  71. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_middleware_propagation.py +0 -0
  72. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_module_capture.py +0 -0
  73. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_otel_log_renderer.py +0 -0
  74. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_otel_metrics_renderer.py +0 -0
  75. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_otel_renderer.py +0 -0
  76. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_outcome.py +0 -0
  77. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_persistence_renderer.py +0 -0
  78. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_prometheus_renderer.py +0 -0
  79. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_redaction_extended.py +0 -0
  80. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_renderer_defaults.py +0 -0
  81. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_stage.py +0 -0
  82. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_stage_metadata.py +0 -0
  83. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_story.py +0 -0
  84. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_structured_analysis.py +0 -0
  85. {runtime_narrative-1.5.0 → runtime_narrative-1.5.2}/tests/test_task_group.py +0 -0
  86. {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.0
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.0"
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,4 +1,4 @@
1
- __version__ = "1.5.0"
1
+ __version__ = "1.5.2"
2
2
 
3
3
  from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
4
4
  from .context import has_active_story
@@ -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=_infer_exact_cause(exc_type.__name__, str(exc), source_line),
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
- current = current.__cause__ or current.__context__
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} {where}", fg=self._failure_heading_color)
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.0
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