runtime-narrative 1.2.1__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {runtime_narrative-1.2.1/runtime_narrative.egg-info → runtime_narrative-1.3.0}/PKG-INFO +24 -1
  2. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/README.md +23 -0
  3. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/pyproject.toml +5 -1
  4. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/__init__.py +3 -1
  5. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/events.py +1 -0
  6. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/middleware.py +5 -1
  7. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/middleware_django.py +9 -2
  8. runtime_narrative-1.3.0/runtime_narrative/outcome.py +17 -0
  9. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/console.py +11 -1
  10. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/json_renderer.py +1 -0
  11. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/story.py +10 -0
  12. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0/runtime_narrative.egg-info}/PKG-INFO +24 -1
  13. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/SOURCES.txt +2 -0
  14. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_console_renderer.py +36 -0
  15. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_json_renderer.py +17 -0
  16. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_middleware.py +21 -0
  17. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_middleware_django.py +36 -0
  18. runtime_narrative-1.3.0/tests/test_outcome.py +13 -0
  19. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_story.py +28 -0
  20. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/LICENSE +0 -0
  21. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/__init__.py +0 -0
  22. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/anthropic.py +0 -0
  23. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/base.py +0 -0
  24. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/deduplication.py +0 -0
  25. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/ollama.py +0 -0
  26. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/celery.py +0 -0
  27. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/cli.py +0 -0
  28. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/context.py +0 -0
  29. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/decorators.py +0 -0
  30. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/diagnostics.py +0 -0
  31. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/failure.py +0 -0
  32. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/grpc_interceptor.py +0 -0
  33. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/instrumentation.py +0 -0
  34. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/logging_bridge.py +0 -0
  35. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/__init__.py +0 -0
  36. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/alert_renderer.py +0 -0
  37. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/filter_renderer.py +0 -0
  38. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/html_renderer.py +0 -0
  39. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
  40. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
  41. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_renderer.py +0 -0
  42. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/persistence_renderer.py +0 -0
  43. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
  44. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/stage.py +0 -0
  45. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/task_group.py +0 -0
  46. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/testing.py +0 -0
  47. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  48. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/entry_points.txt +0 -0
  49. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/requires.txt +0 -0
  50. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/top_level.txt +0 -0
  51. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/setup.cfg +0 -0
  52. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_alert_renderer.py +0 -0
  53. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_analyzers.py +0 -0
  54. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_anthropic_analyzer.py +0 -0
  55. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_async_renderer.py +0 -0
  56. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_celery.py +0 -0
  57. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_decorators.py +0 -0
  58. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_deduplication.py +0 -0
  59. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_diagnostics.py +0 -0
  60. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_dry_run.py +0 -0
  61. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_failure.py +0 -0
  62. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_filter_renderer.py +0 -0
  63. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_grpc_interceptor.py +0 -0
  64. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_html_renderer.py +0 -0
  65. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_instrumentation.py +0 -0
  66. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_instrumentation_phase2.py +0 -0
  67. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_issues.py +0 -0
  68. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_logging_bridge.py +0 -0
  69. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_middleware_propagation.py +0 -0
  70. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_otel_log_renderer.py +0 -0
  71. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_otel_metrics_renderer.py +0 -0
  72. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_otel_renderer.py +0 -0
  73. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_persistence_renderer.py +0 -0
  74. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_prometheus_renderer.py +0 -0
  75. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_redaction_extended.py +0 -0
  76. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_stage.py +0 -0
  77. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_stage_metadata.py +0 -0
  78. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_structured_analysis.py +0 -0
  79. {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_task_group.py +0 -0
  80. {runtime_narrative-1.2.1 → runtime_narrative-1.3.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.2.1
3
+ Version: 1.3.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
@@ -211,6 +211,29 @@ Run: `uv run python examples/logging_bridge.py`, `uv run python examples/structu
211
211
 
212
212
  ---
213
213
 
214
+ ## Story outcomes: fold the access log into the story line
215
+
216
+ `StoryCompleted` carries an `outcome` label. The FastAPI/Starlette and Django middlewares set it automatically from the response status, and `ConsoleRenderer` then renders a single self-contained line per request — story name, result, and HTTP status together:
217
+
218
+ ```
219
+ [d7678e] ▶ Story started: GET /api/call
220
+ [d7678e] ▶ Story ended: GET /api/call - SUCCESS (200 OK, 0.023s)
221
+ ```
222
+
223
+ Since the story line now carries everything the server access log would print, you can silence the duplicate line — e.g. `uvicorn.run(app, access_log=False)`.
224
+
225
+ Outside middleware, set an outcome on any story yourself:
226
+
227
+ ```python
228
+ from runtime_narrative import http_outcome, story
229
+
230
+ with story("GET /api/call") as runtime:
231
+ ...
232
+ runtime.set_outcome(http_outcome(200)) # or any short label, e.g. "3 rows"
233
+ ```
234
+
235
+ ---
236
+
214
237
  ## Feature tour
215
238
 
216
239
  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.
@@ -152,6 +152,29 @@ Run: `uv run python examples/logging_bridge.py`, `uv run python examples/structu
152
152
 
153
153
  ---
154
154
 
155
+ ## Story outcomes: fold the access log into the story line
156
+
157
+ `StoryCompleted` carries an `outcome` label. The FastAPI/Starlette and Django middlewares set it automatically from the response status, and `ConsoleRenderer` then renders a single self-contained line per request — story name, result, and HTTP status together:
158
+
159
+ ```
160
+ [d7678e] ▶ Story started: GET /api/call
161
+ [d7678e] ▶ Story ended: GET /api/call - SUCCESS (200 OK, 0.023s)
162
+ ```
163
+
164
+ Since the story line now carries everything the server access log would print, you can silence the duplicate line — e.g. `uvicorn.run(app, access_log=False)`.
165
+
166
+ Outside middleware, set an outcome on any story yourself:
167
+
168
+ ```python
169
+ from runtime_narrative import http_outcome, story
170
+
171
+ with story("GET /api/call") as runtime:
172
+ ...
173
+ runtime.set_outcome(http_outcome(200)) # or any short label, e.g. "3 rows"
174
+ ```
175
+
176
+ ---
177
+
155
178
  ## Feature tour
156
179
 
157
180
  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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "runtime-narrative"
7
- version = "1.2.1"
7
+ version = "1.3.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"
@@ -84,13 +84,17 @@ packages = ["runtime_narrative", "runtime_narrative.renderer", "runtime_narrativ
84
84
  [dependency-groups]
85
85
  dev = [
86
86
  "build>=1.4.0",
87
+ "fastapi>=0.100.0",
87
88
  "httpx>=0.27.0",
88
89
  "opentelemetry-api>=1.20.0",
89
90
  "opentelemetry-sdk>=1.20.0",
90
91
  "prometheus-client>=0.19.0",
92
+ "pydantic>=2.0.0",
91
93
  "pytest>=8.0.0",
92
94
  "starlette>=0.27.0",
93
95
  "twine>=6.2.0",
96
+ "typer>=0.9.0",
97
+ "uvicorn>=0.23.0",
94
98
  ]
95
99
 
96
100
  [tool.pytest.ini_options]
@@ -1,4 +1,4 @@
1
- __version__ = "1.2.1"
1
+ __version__ = "1.3.0"
2
2
 
3
3
  from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
4
4
  from .context import has_active_story
@@ -16,6 +16,7 @@ from .events import (
16
16
  )
17
17
  from .instrumentation import auto_instrument, instrument_module, narrative_class, narrative_stage, no_stage
18
18
  from .logging_bridge import NarrativeLogHandler
19
+ from .outcome import http_outcome
19
20
  from .renderer.console import ConsoleRenderer
20
21
  from .renderer.filter_renderer import FilteredRenderer
21
22
  from .renderer.json_renderer import JsonRenderer, RotatingJsonRenderer
@@ -120,6 +121,7 @@ __all__ = [
120
121
  "LLMAnalysisReady",
121
122
  "LogRecorded",
122
123
  "NarrativeLogHandler",
124
+ "http_outcome",
123
125
  "FailureDiagnosticsConfig",
124
126
  "build_enriched_failure",
125
127
  "effective_diagnostics_mode",
@@ -82,6 +82,7 @@ class StoryCompleted:
82
82
  duration_seconds: float = 0.0
83
83
  parent_story_id: str | None = None
84
84
  root_story_id: str = ""
85
+ outcome: str = ""
85
86
 
86
87
 
87
88
  @dataclass
@@ -8,6 +8,7 @@ from starlette.requests import Request
8
8
  from starlette.responses import Response
9
9
 
10
10
  from .diagnostics import FailureDiagnosticsConfig
11
+ from .outcome import http_outcome
11
12
  from .story import story
12
13
 
13
14
  try:
@@ -27,6 +28,8 @@ def _default_middleware_renderers() -> tuple:
27
28
  return (JsonRenderer(),)
28
29
 
29
30
 
31
+
32
+
30
33
  class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
31
34
  """
32
35
  FastAPI/Starlette middleware that wraps every HTTP request in a runtime_narrative story.
@@ -107,8 +110,9 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
107
110
  allow_rich_in_production=self._allow_rich_in_production,
108
111
  app_roots=self._app_roots,
109
112
  redact_extra=self._redact_extra,
110
- ):
113
+ ) as runtime:
111
114
  response = await call_next(request)
115
+ runtime.set_outcome(http_outcome(response.status_code))
112
116
  return response
113
117
  finally:
114
118
  if token is not None:
@@ -4,6 +4,7 @@ import sys
4
4
  from typing import Any, Sequence
5
5
 
6
6
  from .diagnostics import FailureDiagnosticsConfig
7
+ from .outcome import http_outcome
7
8
  from .story import story
8
9
 
9
10
  try:
@@ -65,8 +66,11 @@ class RuntimeNarrativeDjangoMiddleware:
65
66
  allow_rich_in_production=self._allow_rich_in_production,
66
67
  app_roots=self._app_roots,
67
68
  redact_extra=self._redact_extra,
68
- ):
69
+ ) as runtime:
69
70
  response = await self.get_response(request)
71
+ status_code = getattr(response, "status_code", None)
72
+ if status_code is not None:
73
+ runtime.set_outcome(http_outcome(status_code))
70
74
  return response
71
75
 
72
76
 
@@ -114,8 +118,11 @@ class RuntimeNarrativeDjangoSyncMiddleware:
114
118
  allow_rich_in_production=self._allow_rich_in_production,
115
119
  app_roots=self._app_roots,
116
120
  redact_extra=self._redact_extra,
117
- ):
121
+ ) as runtime:
118
122
  response = self.get_response(request)
123
+ status_code = getattr(response, "status_code", None)
124
+ if status_code is not None:
125
+ runtime.set_outcome(http_outcome(status_code))
119
126
  return response
120
127
 
121
128
 
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from http import HTTPStatus
4
+
5
+
6
+ def http_outcome(status_code: int) -> str:
7
+ """Format an HTTP status code as a "200 OK"-style story outcome label.
8
+
9
+ Sync by design: pure data transformation with no I/O.
10
+ """
11
+ try:
12
+ return f"{status_code} {HTTPStatus(status_code).phrase}"
13
+ except ValueError:
14
+ return str(status_code)
15
+
16
+
17
+ __all__ = ["http_outcome"]
@@ -425,7 +425,17 @@ class ConsoleRenderer:
425
425
  self._secho(f"{indent}{tag} ", fg=tag_color, bold=True, nl=False)
426
426
  self._secho(f"{self._glyph_arrow} Story ended: ", fg=color, bold=True, nl=False)
427
427
  duration = getattr(event, "duration_seconds", 0.0)
428
- self._secho(f"{state} ({duration:.3f}s)", fg=value_color, bold=True)
428
+ outcome = getattr(event, "outcome", "")
429
+ if outcome:
430
+ # Outcome-bearing stories (e.g. HTTP requests) get a self-contained
431
+ # summary line: name + state + outcome, replacing a separate access log.
432
+ self._secho(
433
+ f"{event.story_name} - {state} ({outcome}, {duration:.3f}s)",
434
+ fg=value_color,
435
+ bold=True,
436
+ )
437
+ else:
438
+ self._secho(f"{state} ({duration:.3f}s)", fg=value_color, bold=True)
429
439
  self._story_base_indent.pop(event.story_id, None)
430
440
  self._stage_stacks.pop(event.story_id, None)
431
441
 
@@ -129,6 +129,7 @@ class JsonRenderer:
129
129
  "duration_seconds": getattr(event, "duration_seconds", 0.0),
130
130
  "parent_story_id": getattr(event, "parent_story_id", None),
131
131
  "root_story_id": getattr(event, "root_story_id", ""),
132
+ "outcome": getattr(event, "outcome", ""),
132
133
  })
133
134
 
134
135
 
@@ -30,10 +30,18 @@ class StoryRuntime:
30
30
  parent_story_id: str | None = None
31
31
  root_story_id: str = ""
32
32
  failure_analyzer: Any = field(default=None, repr=False)
33
+ outcome: str = ""
33
34
 
34
35
  def set_total_stages(self, n: int) -> None:
35
36
  self.declared_total_stages = n
36
37
 
38
+ def set_outcome(self, outcome: str) -> None:
39
+ """Attach a short result label (e.g. "200 OK") to the StoryCompleted event.
40
+
41
+ Sync by design: a pure in-memory setter with no I/O.
42
+ """
43
+ self.outcome = outcome
44
+
37
45
  def emit(self, event: object) -> None:
38
46
  for renderer in self.renderers:
39
47
  try:
@@ -398,6 +406,7 @@ class story:
398
406
  duration_seconds=(datetime.now() - self.runtime.started_at).total_seconds(),
399
407
  parent_story_id=self.runtime.parent_story_id,
400
408
  root_story_id=self.runtime.root_story_id,
409
+ outcome=self.runtime.outcome,
401
410
  )
402
411
  )
403
412
 
@@ -489,6 +498,7 @@ class story:
489
498
  duration_seconds=(datetime.now() - self.runtime.started_at).total_seconds(),
490
499
  parent_story_id=self.runtime.parent_story_id,
491
500
  root_story_id=self.runtime.root_story_id,
501
+ outcome=self.runtime.outcome,
492
502
  )
493
503
  )
494
504
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-narrative
3
- Version: 1.2.1
3
+ Version: 1.3.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
@@ -211,6 +211,29 @@ Run: `uv run python examples/logging_bridge.py`, `uv run python examples/structu
211
211
 
212
212
  ---
213
213
 
214
+ ## Story outcomes: fold the access log into the story line
215
+
216
+ `StoryCompleted` carries an `outcome` label. The FastAPI/Starlette and Django middlewares set it automatically from the response status, and `ConsoleRenderer` then renders a single self-contained line per request — story name, result, and HTTP status together:
217
+
218
+ ```
219
+ [d7678e] ▶ Story started: GET /api/call
220
+ [d7678e] ▶ Story ended: GET /api/call - SUCCESS (200 OK, 0.023s)
221
+ ```
222
+
223
+ Since the story line now carries everything the server access log would print, you can silence the duplicate line — e.g. `uvicorn.run(app, access_log=False)`.
224
+
225
+ Outside middleware, set an outcome on any story yourself:
226
+
227
+ ```python
228
+ from runtime_narrative import http_outcome, story
229
+
230
+ with story("GET /api/call") as runtime:
231
+ ...
232
+ runtime.set_outcome(http_outcome(200)) # or any short label, e.g. "3 rows"
233
+ ```
234
+
235
+ ---
236
+
214
237
  ## Feature tour
215
238
 
216
239
  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.
@@ -14,6 +14,7 @@ runtime_narrative/instrumentation.py
14
14
  runtime_narrative/logging_bridge.py
15
15
  runtime_narrative/middleware.py
16
16
  runtime_narrative/middleware_django.py
17
+ runtime_narrative/outcome.py
17
18
  runtime_narrative/stage.py
18
19
  runtime_narrative/story.py
19
20
  runtime_narrative/task_group.py
@@ -65,6 +66,7 @@ tests/test_middleware_propagation.py
65
66
  tests/test_otel_log_renderer.py
66
67
  tests/test_otel_metrics_renderer.py
67
68
  tests/test_otel_renderer.py
69
+ tests/test_outcome.py
68
70
  tests/test_persistence_renderer.py
69
71
  tests/test_prometheus_renderer.py
70
72
  tests/test_redaction_extended.py
@@ -97,6 +97,42 @@ def test_all_event_types_render_without_typer(monkeypatch, capsys):
97
97
  assert "SUCCESS" in out
98
98
 
99
99
 
100
+ def test_story_completed_with_outcome_renders_combined_line(monkeypatch, capsys):
101
+ monkeypatch.setattr(console_mod, "typer", None)
102
+ ts = datetime(2024, 6, 1)
103
+ r = ConsoleRenderer()
104
+
105
+ r.handle(StoryCompleted(
106
+ story_id="d7678e", story_name="GET /api/call", success=True,
107
+ progress_percent=100, completed_stages=1, total_stages=1, timestamp=ts,
108
+ duration_seconds=0.023, outcome="200 OK",
109
+ ))
110
+
111
+ out = capsys.readouterr().out
112
+ # One self-contained line: name, state, and HTTP outcome together.
113
+ line = next(l for l in out.splitlines() if "Story ended" in l)
114
+ assert "GET /api/call" in line
115
+ assert "SUCCESS" in line
116
+ assert "200 OK" in line
117
+
118
+
119
+ def test_story_completed_without_outcome_keeps_legacy_line(monkeypatch, capsys):
120
+ monkeypatch.setattr(console_mod, "typer", None)
121
+ ts = datetime(2024, 6, 1)
122
+ r = ConsoleRenderer()
123
+
124
+ r.handle(StoryCompleted(
125
+ story_id="s1", story_name="My Story", success=True,
126
+ progress_percent=100, completed_stages=1, total_stages=1, timestamp=ts,
127
+ duration_seconds=0.023,
128
+ ))
129
+
130
+ out = capsys.readouterr().out
131
+ line = next(l for l in out.splitlines() if "Story ended" in l)
132
+ assert "My Story" not in line
133
+ assert "SUCCESS (0.023s)" in line
134
+
135
+
100
136
  def test_failure_event_renders_without_typer(monkeypatch, capsys):
101
137
  monkeypatch.setattr(console_mod, "typer", None)
102
138
 
@@ -151,3 +151,20 @@ def test_json_renderer_story_completed_includes_duration_and_parent_id() -> None
151
151
  assert data["duration_seconds"] == 0.42
152
152
  assert data["parent_story_id"] == "s1"
153
153
  assert data["root_story_id"] == "s1"
154
+ assert data["outcome"] == ""
155
+
156
+
157
+ def test_json_renderer_story_completed_includes_outcome() -> None:
158
+ from runtime_narrative.events import StoryCompleted
159
+
160
+ buf = StringIO()
161
+ r = JsonRenderer(output=buf)
162
+ event = StoryCompleted(
163
+ story_id="s1", story_name="GET /api/call", success=True, progress_percent=100,
164
+ completed_stages=1, total_stages=1, timestamp=datetime(2024, 6, 1),
165
+ duration_seconds=0.02, outcome="200 OK",
166
+ )
167
+ r.handle(event)
168
+ buf.seek(0)
169
+ data = json.loads(buf.read())
170
+ assert data["outcome"] == "200 OK"
@@ -62,3 +62,24 @@ def test_middleware_success_emits_story_completed() -> None:
62
62
  r = client.get("/ok")
63
63
  assert r.status_code == 200
64
64
  assert [type(e).__name__ for e in cap.events][-1] == "StoryCompleted"
65
+ completed = cap.events[-1]
66
+ assert completed.outcome == "200 OK"
67
+
68
+
69
+ def test_middleware_records_error_status_outcome() -> None:
70
+ cap = CapturingRenderer()
71
+
72
+ async def teapot(_request):
73
+ return PlainTextResponse("no", status_code=418)
74
+
75
+ app = Starlette(
76
+ routes=[Route("/tea", endpoint=teapot, methods=["GET"])],
77
+ middleware=[Middleware(RuntimeNarrativeMiddleware, renderers=[cap])],
78
+ )
79
+
80
+ client = TestClient(app)
81
+ r = client.get("/tea")
82
+ assert r.status_code == 418
83
+ completed = cap.events[-1]
84
+ assert type(completed).__name__ == "StoryCompleted"
85
+ assert completed.outcome.startswith("418")
@@ -79,6 +79,42 @@ def test_async_failure_reraises() -> None:
79
79
  asyncio.run(mw(_FakeDjangoRequest()))
80
80
 
81
81
 
82
+ class _FakeDjangoResponse:
83
+ status_code = 200
84
+
85
+
86
+ async def _ok_with_status(request):
87
+ return _FakeDjangoResponse()
88
+
89
+
90
+ def _sync_ok_with_status(request):
91
+ return _FakeDjangoResponse()
92
+
93
+
94
+ def test_async_records_status_outcome() -> None:
95
+ renderer = CapturingRenderer()
96
+ mw = _django_mod.RuntimeNarrativeDjangoMiddleware(_ok_with_status, renderers=[renderer])
97
+ asyncio.run(mw(_FakeDjangoRequest()))
98
+ completed = next(e for e in renderer.events if type(e).__name__ == "StoryCompleted")
99
+ assert completed.outcome == "200 OK"
100
+
101
+
102
+ def test_sync_records_status_outcome() -> None:
103
+ renderer = CapturingRenderer()
104
+ mw = _django_mod.RuntimeNarrativeDjangoSyncMiddleware(_sync_ok_with_status, renderers=[renderer])
105
+ mw(_FakeDjangoRequest())
106
+ completed = next(e for e in renderer.events if type(e).__name__ == "StoryCompleted")
107
+ assert completed.outcome == "200 OK"
108
+
109
+
110
+ def test_async_response_without_status_leaves_outcome_empty() -> None:
111
+ renderer = CapturingRenderer()
112
+ mw = _django_mod.RuntimeNarrativeDjangoMiddleware(_ok, renderers=[renderer])
113
+ asyncio.run(mw(_FakeDjangoRequest()))
114
+ completed = next(e for e in renderer.events if type(e).__name__ == "StoryCompleted")
115
+ assert completed.outcome == ""
116
+
117
+
82
118
  def test_sync_emits_story_events() -> None:
83
119
  renderer = CapturingRenderer()
84
120
  mw = _django_mod.RuntimeNarrativeDjangoSyncMiddleware(_sync_ok, renderers=[renderer])
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from runtime_narrative.outcome import http_outcome
4
+
5
+
6
+ def test_http_outcome_known_codes() -> None:
7
+ assert http_outcome(200) == "200 OK"
8
+ assert http_outcome(404) == "404 Not Found"
9
+ assert http_outcome(500) == "500 Internal Server Error"
10
+
11
+
12
+ def test_http_outcome_unknown_code_falls_back_to_number() -> None:
13
+ assert http_outcome(599) == "599"
@@ -273,3 +273,31 @@ def test_background_analysis_emits_llm_ready() -> None:
273
273
  assert "FailureOccurred" in kinds
274
274
  assert "StoryCompleted" in kinds
275
275
  assert "LLMAnalysisReady" in kinds
276
+
277
+
278
+ def test_sync_story_set_outcome_flows_to_story_completed() -> None:
279
+ cap = CapturingRenderer()
280
+ with story("GET /api/call", renderers=[cap]) as runtime:
281
+ runtime.set_outcome("200 OK")
282
+ completed = next(e for e in cap.events if isinstance(e, StoryCompleted))
283
+ assert completed.outcome == "200 OK"
284
+
285
+
286
+ def test_async_story_set_outcome_flows_to_story_completed() -> None:
287
+ cap = AsyncCapturingRenderer()
288
+
289
+ async def run() -> None:
290
+ async with story("GET /api/call", renderers=[cap]) as runtime:
291
+ runtime.set_outcome("201 Created")
292
+
293
+ asyncio.run(run())
294
+ completed = next(e for e in cap.events if isinstance(e, StoryCompleted))
295
+ assert completed.outcome == "201 Created"
296
+
297
+
298
+ def test_story_outcome_defaults_to_empty() -> None:
299
+ cap = CapturingRenderer()
300
+ with story("S", renderers=[cap]):
301
+ pass
302
+ completed = next(e for e in cap.events if isinstance(e, StoryCompleted))
303
+ assert completed.outcome == ""