narrativetrace 0.4.2__tar.gz → 0.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.
Files changed (266) hide show
  1. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/LICENSE +2 -2
  2. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/PKG-INFO +1 -1
  3. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/pyproject.toml +1 -1
  4. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/__init__.py +1 -1
  5. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/canonical.py +3 -2
  6. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/context.py +14 -8
  7. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/events.py +26 -4
  8. narrativetrace-0.5.0/src/narrativetrace/exception_message.py +46 -0
  9. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/export.py +2 -1
  10. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/logging_bridge.py +2 -1
  11. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/perishable.py +43 -20
  12. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/indented.py +3 -2
  13. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/markdown.py +3 -2
  14. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/prose.py +3 -2
  15. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/rendering.py +222 -33
  16. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/trace_object.py +1 -2
  17. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/tree_canonical.py +3 -2
  18. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_context.py +20 -2
  19. narrativetrace-0.5.0/tests/test_exception_message.py +47 -0
  20. narrativetrace-0.5.0/tests/test_exception_message_sinks.py +114 -0
  21. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_exclusions.py +15 -2
  22. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_exclusions_docs.py +14 -2
  23. narrativetrace-0.5.0/tests/test_group_event_ownership.py +83 -0
  24. narrativetrace-0.5.0/tests/test_no_fixed_depth_repo_paths.py +45 -0
  25. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_no_poison_contract.py +8 -10
  26. narrativetrace-0.5.0/tests/test_pipeline_perishable.py +199 -0
  27. narrativetrace-0.5.0/tests/test_render_paths_agree_on_origin.py +235 -0
  28. narrativetrace-0.5.0/tests/test_render_shared_subgraph_bound.py +264 -0
  29. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_smoke.py +1 -1
  30. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_trace_object.py +4 -0
  31. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_exec.py +1 -2
  32. narrativetrace-0.4.2/tests/test_pipeline_perishable.py +0 -77
  33. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/.gitignore +0 -0
  34. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/README.md +0 -0
  35. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_boundary.py +0 -0
  36. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_generated.py +0 -0
  37. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/add-narrative-tracing/SKILL.md +0 -0
  38. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/add-narrativetrace-clarity/SKILL.md +0 -0
  39. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/narrativetrace-debug/SKILL.md +0 -0
  40. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/narrativetrace-doctor/SKILL.md +0 -0
  41. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/narrativetrace-explain/SKILL.md +0 -0
  42. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/narrativetrace-feedback/SKILL.md +0 -0
  43. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/agents/narrativetrace-verify/SKILL.md +0 -0
  44. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/catalogue.json +0 -0
  45. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/add-narrative-tracing/SKILL.md +0 -0
  46. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/add-narrativetrace-clarity/SKILL.md +0 -0
  47. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/narrativetrace-debug/SKILL.md +0 -0
  48. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/narrativetrace-doctor/SKILL.md +0 -0
  49. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/narrativetrace-explain/SKILL.md +0 -0
  50. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/narrativetrace-feedback/SKILL.md +0 -0
  51. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/_skills/claude/narrativetrace-verify/SKILL.md +0 -0
  52. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/chapter.py +0 -0
  53. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/cli.py +0 -0
  54. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/concurrency.py +0 -0
  55. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/config.py +0 -0
  56. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/context_export.py +0 -0
  57. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/decorators.py +0 -0
  58. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/doctor/__init__.py +0 -0
  59. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/doctor/cli_bin.py +0 -0
  60. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/doctor/environment.py +0 -0
  61. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/doctor/feedback_cli.py +0 -0
  62. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/doctor/gh_auth_probe.py +0 -0
  63. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/escape.py +0 -0
  64. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/exclusions.py +0 -0
  65. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/groups.py +0 -0
  66. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/identity.py +0 -0
  67. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/ids.py +0 -0
  68. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/levels.py +0 -0
  69. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/loss.py +0 -0
  70. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/markers.py +0 -0
  71. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/metadata.py +0 -0
  72. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/namer.py +0 -0
  73. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/national_id_shapes.py +0 -0
  74. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/nodes.py +0 -0
  75. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/outcomes.py +0 -0
  76. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/__init__.py +0 -0
  77. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/approval.py +0 -0
  78. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/artifact_identity.py +0 -0
  79. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/line_diff.py +0 -0
  80. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/manifest.py +0 -0
  81. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/paths.py +0 -0
  82. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/reporter.py +0 -0
  83. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/run_identity.py +0 -0
  84. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/structural_delta.py +0 -0
  85. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/warnings.py +0 -0
  86. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/output/writer.py +0 -0
  87. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/__init__.py +0 -0
  88. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/bounded_buffer.py +0 -0
  89. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/buffered_consumer.py +0 -0
  90. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/dual_path.py +0 -0
  91. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/event_store.py +0 -0
  92. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/pipeline/subscriber.py +0 -0
  93. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/py.typed +0 -0
  94. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/redacted_paths.py +0 -0
  95. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/redaction.py +0 -0
  96. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/__init__.py +0 -0
  97. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/base.py +0 -0
  98. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/camel.py +0 -0
  99. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/concurrency.py +0 -0
  100. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/frontmatter.py +0 -0
  101. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/scenario.py +0 -0
  102. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/scenario_result.py +0 -0
  103. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/span_id.py +0 -0
  104. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/structural.py +0 -0
  105. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/value_delta.py +0 -0
  106. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/render/value_reference.py +0 -0
  107. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/secret_value_shapes.py +0 -0
  108. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/signature.py +0 -0
  109. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/span.py +0 -0
  110. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/template.py +0 -0
  111. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/tree.py +0 -0
  112. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/tree_walk.py +0 -0
  113. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/src/narrativetrace/values.py +0 -0
  114. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/conftest.py +0 -0
  115. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_adversarial_feedback_cli.py +0 -0
  116. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_cli_built_wheels.py +0 -0
  117. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_cli_feedback_arguments.py +0 -0
  118. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_cli_feedback_verb.py +0 -0
  119. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_cli_installer_arguments.py +0 -0
  120. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_cli_installer_verbs.py +0 -0
  121. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_doctor_cli_bin.py +0 -0
  122. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_doctor_environment.py +0 -0
  123. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/doctor/test_gh_auth_probe.py +0 -0
  124. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/fixtures/java-negative-expense.nt +0 -0
  125. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/live_annotation_support.py +0 -0
  126. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/logging_wiring.py +0 -0
  127. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/stress_support.py +0 -0
  128. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_adversarial_openai_plugin_pack.py +0 -0
  129. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_adversarial_skills_render.py +0 -0
  130. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_adversarial_vendor_validation.py +0 -0
  131. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_adversarial_verify_all_heavy.py +0 -0
  132. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_approval.py +0 -0
  133. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_artifact_identity.py +0 -0
  134. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_async_concurrency_kind.py +0 -0
  135. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_async_lifecycle_contract.py +0 -0
  136. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_async_snapshot_adoption.py +0 -0
  137. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_bench_capture.py +0 -0
  138. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_bench_redaction.py +0 -0
  139. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_bench_rendering.py +0 -0
  140. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_canonical.py +0 -0
  141. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_canonical_props.py +0 -0
  142. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_capture_identity.py +0 -0
  143. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_chapter.py +0 -0
  144. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_chapter_props.py +0 -0
  145. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_check_no_license_headers.py +0 -0
  146. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_cli.py +0 -0
  147. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_comment_hygiene.py +0 -0
  148. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_concurrency_info.py +0 -0
  149. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_config.py +0 -0
  150. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_console_delta.py +0 -0
  151. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_context7_check.py +0 -0
  152. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_context_export.py +0 -0
  153. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_context_extra.py +0 -0
  154. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_context_stress.py +0 -0
  155. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_contract_check.py +0 -0
  156. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_contract_dispatch_coverage.py +0 -0
  157. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_contract_lint.py +0 -0
  158. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_coverage_source_completeness.py +0 -0
  159. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_decorators.py +0 -0
  160. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_deep_fixture_budget.py +0 -0
  161. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_distribution_licensing.py +0 -0
  162. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_duplication_check.py +0 -0
  163. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_duplication_report.py +0 -0
  164. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_errors_only_level.py +0 -0
  165. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_escape.py +0 -0
  166. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_events.py +0 -0
  167. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_export.py +0 -0
  168. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_feedback_docs.py +0 -0
  169. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_framework_table_docs.py +0 -0
  170. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_fuzz_report.py +0 -0
  171. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_gate_lint_never_trusts_a_cache.py +0 -0
  172. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_generated_targets.py +0 -0
  173. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_groups.py +0 -0
  174. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_identity.py +0 -0
  175. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_identity_conformance.py +0 -0
  176. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_ids.py +0 -0
  177. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_installer_from_another_package.py +0 -0
  178. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_issue_form_publishes.py +0 -0
  179. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_levels.py +0 -0
  180. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_live_snapshot_scope_visibility.py +0 -0
  181. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_logging_bridge.py +0 -0
  182. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_logging_wiring.py +0 -0
  183. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_manifest.py +0 -0
  184. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_metadata.py +0 -0
  185. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_mutation_accounting.py +0 -0
  186. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_mutation_baseline_selection.py +0 -0
  187. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_mutation_gate.py +0 -0
  188. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_mutation_tooling.py +0 -0
  189. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_mutation_workers.py +0 -0
  190. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_namer.py +0 -0
  191. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_national_id_shapes.py +0 -0
  192. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_nested_chain_adoption.py +0 -0
  193. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_nodes.py +0 -0
  194. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_notice_render_targets.py +0 -0
  195. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_openai_plugin_pack.py +0 -0
  196. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_outcomes.py +0 -0
  197. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_output.py +0 -0
  198. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_parameter_name_redaction.py +0 -0
  199. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_parameter_value_redaction.py +0 -0
  200. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_pipeline_buffered.py +0 -0
  201. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_pipeline_dual_path.py +0 -0
  202. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_pipeline_props.py +0 -0
  203. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_pipeline_stress.py +0 -0
  204. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_pipeline_subscriber.py +0 -0
  205. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_platform_pointer_from_another_package.py +0 -0
  206. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_platform_table_docs.py +0 -0
  207. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_promotion_render.py +0 -0
  208. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_publish_agents_md_section.py +0 -0
  209. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_publish_public.py +0 -0
  210. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_publish_public_host_venv.py +0 -0
  211. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_publish_workflow_packages.py +0 -0
  212. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_quality_gate_status.py +0 -0
  213. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_redacted_paths.py +0 -0
  214. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_redacted_value_containment.py +0 -0
  215. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_redaction.py +0 -0
  216. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_render.py +0 -0
  217. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_render_reads_state.py +0 -0
  218. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_render_reentrancy.py +0 -0
  219. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_rendering.py +0 -0
  220. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_rendering_extra.py +0 -0
  221. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_run_identity.py +0 -0
  222. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_run_security_tool.py +0 -0
  223. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_scenario_result.py +0 -0
  224. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_secret_value_shapes.py +0 -0
  225. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_signature.py +0 -0
  226. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_skills_carrier_wheel_contents.py +0 -0
  227. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_skills_render.py +0 -0
  228. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_snippet_check.py +0 -0
  229. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_span.py +0 -0
  230. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_span_id.py +0 -0
  231. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_span_id_flavours.py +0 -0
  232. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_span_id_props.py +0 -0
  233. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_structlog_dependency.py +0 -0
  234. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_structural.py +0 -0
  235. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_structural_conformance.py +0 -0
  236. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_structural_delta.py +0 -0
  237. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_template.py +0 -0
  238. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_timeout_floor.py +0 -0
  239. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_trace_loss.py +0 -0
  240. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_trace_object_catch_all_warning.py +0 -0
  241. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_trace_stack_live_child.py +0 -0
  242. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_translation_check.py +0 -0
  243. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_tree.py +0 -0
  244. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_tree_canonical.py +0 -0
  245. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_tree_props.py +0 -0
  246. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_tree_walk.py +0 -0
  247. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_tree_writes_guard.py +0 -0
  248. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_value_delta.py +0 -0
  249. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_value_reference.py +0 -0
  250. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_values.py +0 -0
  251. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_vendor_validation.py +0 -0
  252. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all.py +0 -0
  253. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_arch_stress.py +0 -0
  254. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_heavy.py +0 -0
  255. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_schema.py +0 -0
  256. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_security.py +0 -0
  257. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_static.py +0 -0
  258. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_all_testrun.py +0 -0
  259. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_publication_packages.py +0 -0
  260. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_publication_provenance.py +0 -0
  261. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_verify_publication_registry.py +0 -0
  262. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_version_guard_ignores_a_stale_installed_carrier.py +0 -0
  263. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_version_literals.py +0 -0
  264. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_version_literals_props.py +0 -0
  265. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/test_writer_structural.py +0 -0
  266. {narrativetrace-0.4.2 → narrativetrace-0.5.0}/tests/wheel_support.py +0 -0
@@ -7,7 +7,7 @@ Parameters
7
7
 
8
8
  Licensor: Empower Agile
9
9
 
10
- Licensed Work: NarrativeTrace for Python (the narrativetrace PyPI distributions) version 0.4.2. The Licensed Work is (c) 2026 Empower Agile.
10
+ Licensed Work: NarrativeTrace for Python (the narrativetrace PyPI distributions) version 0.5.0. The Licensed Work is (c) 2026 Empower Agile.
11
11
 
12
12
  Additional Use Grant: You may make production use of the Licensed Work for any
13
13
  purpose, including internal use and use in products and
@@ -18,7 +18,7 @@ Additional Use Grant: You may make production use of the Licensed Work for any
18
18
  parties as a logging, tracing, or code-narrative product
19
19
  or service.
20
20
 
21
- Change Date: Four years from the date the Licensed Work is published. (2030-10-20 for this version)
21
+ Change Date: Four years from the date the Licensed Work is published. (2030-10-24 for this version)
22
22
 
23
23
  Change License: Apache License, Version 2.0
24
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: narrativetrace
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Narrative execution tracing for Python — human- and LLM-readable traces
5
5
  Project-URL: Homepage, https://github.com/narrativetrace/narrativetrace-python
6
6
  Project-URL: Source, https://github.com/narrativetrace/narrativetrace-python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "narrativetrace"
3
- version = "0.4.2"
3
+ version = "0.5.0"
4
4
  description = "Narrative execution tracing for Python — human- and LLM-readable traces"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -97,7 +97,7 @@ from narrativetrace.values import (
97
97
  StringVal,
98
98
  )
99
99
 
100
- __version__ = "0.4.2"
100
+ __version__ = "0.5.0"
101
101
 
102
102
  __all__ = [
103
103
  "NOOP_CONTEXT",
@@ -20,6 +20,7 @@ import time
20
20
  from dataclasses import dataclass, replace
21
21
  from datetime import UTC, datetime
22
22
 
23
+ from narrativetrace import exception_message
23
24
  from narrativetrace.concurrency import ThreadIdentity
24
25
  from narrativetrace.events import (
25
26
  EnterEvent,
@@ -252,7 +253,7 @@ def _split_span_name(span_name: str | None) -> tuple[str, str]:
252
253
 
253
254
  def _exit_message(span: SpanContext, outcome: TraceOutcome) -> str:
254
255
  if isinstance(outcome, Threw):
255
- return f"!! {type(outcome.exception).__name__}: {outcome.exception}"
256
+ return f"!! {type(outcome.exception).__name__}: {exception_message.text(outcome.exception)}"
256
257
  name = span.span_name if span.span_name is not None else "method"
257
258
  if isinstance(outcome, Returned) and outcome.rendered_value is not None:
258
259
  return f"← {name} returned {outcome.rendered_value}"
@@ -286,7 +287,7 @@ def _from_exit_event(event: ExitEvent, anchor: MonotonicAnchor) -> CanonicalEntr
286
287
  nt_outcome=_exit_outcome(outcome),
287
288
  nt_return_value=returned.rendered_value if returned is not None else None,
288
289
  exception_type=type(threw.exception).__name__ if threw is not None else None,
289
- exception_message=str(threw.exception) if threw is not None else None,
290
+ exception_message=exception_message.of(threw.exception) if threw is not None else None,
290
291
  nt_exception_package=_package_of(threw.exception) if threw is not None else None,
291
292
  )
292
293
  return _with_correlation(entry, span)
@@ -227,7 +227,7 @@ class NarrativeContext(ABC):
227
227
  self,
228
228
  trace_id: TraceId,
229
229
  remote_parent_span_id: SpanId | None = None,
230
- trace_flags: int = 0,
230
+ trace_flags: int | None = None,
231
231
  ) -> None:
232
232
  """Adopts an inbound (e.g. W3C ``traceparent``) trace id for the current scope.
233
233
 
@@ -236,7 +236,9 @@ class NarrativeContext(ABC):
236
236
  ``parent_span_id``, so a consumer that follows parent ids links this service's trace to the
237
237
  caller's; it never appears as a span in this scope's own tree. ``trace_flags`` is the
238
238
  ``traceparent`` flags byte (bit 0 = sampled); every span this scope creates carries it as
239
- :attr:`~narrativetrace.span.SpanContext.trace_flags`.
239
+ :attr:`~narrativetrace.span.SpanContext.trace_flags`. ``None`` (the default, a bare id with
240
+ no ``traceparent``) keeps the scope's current flags, sampled unless a flags byte was adopted
241
+ earlier; only an explicit value overwrites them.
240
242
  """
241
243
 
242
244
  def story_id(self) -> str | None:
@@ -276,6 +278,9 @@ class _RequestMetadata:
276
278
 
277
279
  _EMPTY_METADATA = _RequestMetadata()
278
280
 
281
+ # W3C trace-flags bit 0. A fresh root is sampled; only an adopted ``traceparent`` overwrites it.
282
+ _SAMPLED = 1
283
+
279
284
  MAX_ADOPTED_SPANS = 10_000
280
285
  """Ceiling on spans one stack accepts from worker scopes, and on its live-child registrations.
281
286
 
@@ -304,7 +309,7 @@ class _TraceStack:
304
309
  scoped_parent: SpanId | None = None
305
310
  snapshot_parent: SpanId | None = None
306
311
  remote_parent: SpanId | None = None
307
- trace_flags: int = 0
312
+ trace_flags: int = _SAMPLED
308
313
  story_id: str | None = None
309
314
  chapter_id: str | None = None
310
315
  metadata: _RequestMetadata = _EMPTY_METADATA
@@ -773,12 +778,13 @@ class ContextVarNarrativeContext(NarrativeContext):
773
778
  self,
774
779
  trace_id: TraceId,
775
780
  remote_parent_span_id: SpanId | None = None,
776
- trace_flags: int = 0,
781
+ trace_flags: int | None = None,
777
782
  ) -> None:
778
783
  stack = self._get_stack()
779
784
  stack.trace_id = trace_id
780
785
  stack.remote_parent = remote_parent_span_id
781
- stack.trace_flags = trace_flags
786
+ if trace_flags is not None:
787
+ stack.trace_flags = trace_flags
782
788
 
783
789
  def story_id(self) -> str | None:
784
790
  return self._get_stack().story_id
@@ -788,13 +794,13 @@ class ContextVarNarrativeContext(NarrativeContext):
788
794
 
789
795
  # -- concurrency lifecycle --------------------------------------------- #
790
796
  def on_fork_created(self, group_id: str) -> None:
791
- self._store.add(ForkCreatedEvent(group_id, _now_nanos()))
797
+ self._store.add(ForkCreatedEvent(group_id, _now_nanos(), self.current_span_id()))
792
798
 
793
799
  def on_merge(self, group_id: str, members: list[TraceNode]) -> None:
794
- self._store.add(MergeEvent(group_id, len(members), _now_nanos()))
800
+ self._store.add(MergeEvent(group_id, len(members), _now_nanos(), self.current_span_id()))
795
801
 
796
802
  def on_fire_and_forget_launched(self, group_id: str) -> None:
797
- self._store.add(FireAndForgetEvent(group_id, _now_nanos()))
803
+ self._store.add(FireAndForgetEvent(group_id, _now_nanos(), self.current_span_id()))
798
804
 
799
805
  def emit_trace_node(self, node: TraceNode, parent_span_id: SpanId | None) -> None:
800
806
  self._emit_trace_node(node, parent_span_id, TreeWalk())
@@ -53,31 +53,53 @@ class ExitEvent(TraceEvent):
53
53
 
54
54
  @dataclass(frozen=True, slots=True)
55
55
  class ForkCreatedEvent(TraceEvent):
56
- """Lifecycle marker: a fork group was created."""
56
+ """Lifecycle marker: a fork group was created.
57
+
58
+ ``owner_span_id`` is the span current on the launching thread when the group was created
59
+ (B-166); ``None`` for a group created outside every span, which no request ever removes.
60
+ """
57
61
 
58
62
  group_id: str
59
63
  timestamp_nanos: int
64
+ owner_span_id: SpanId | None = None
60
65
 
61
66
 
62
67
  @dataclass(frozen=True, slots=True)
63
68
  class MergeEvent(TraceEvent):
64
- """Lifecycle marker: a fork group merged its collected children."""
69
+ """Lifecycle marker: a fork group merged its collected children.
70
+
71
+ ``owner_span_id`` is the span current on the merging (launching) thread (B-166); ``None``
72
+ outside every span.
73
+ """
65
74
 
66
75
  group_id: str
67
76
  member_count: int
68
77
  timestamp_nanos: int
78
+ owner_span_id: SpanId | None = None
69
79
 
70
80
 
71
81
  @dataclass(frozen=True, slots=True)
72
82
  class FireAndForgetEvent(TraceEvent):
73
- """Lifecycle marker: a fire-and-forget group was launched."""
83
+ """Lifecycle marker: a fire-and-forget group was launched.
84
+
85
+ ``owner_span_id`` is the span current on the launching thread (B-166); ``None`` for a group
86
+ launched outside every span.
87
+ """
74
88
 
75
89
  group_id: str
76
90
  timestamp_nanos: int
91
+ owner_span_id: SpanId | None = None
77
92
 
78
93
 
79
94
  def span_id_of(event: TraceEvent) -> SpanId | None:
80
- """Returns the span id an event belongs to, or ``None`` for group lifecycle events."""
95
+ """Returns the span id an event belongs to.
96
+
97
+ Enter/exit events answer their own span; group lifecycle events answer the span that
98
+ launched the group (``owner_span_id``), so a request's cleanup removes them with it. A group
99
+ created outside every span has no owner: ``None``, and the event is retained (B-166).
100
+ """
81
101
  if isinstance(event, EnterEvent | ExitEvent):
82
102
  return event.span_context.span_id
103
+ if isinstance(event, ForkCreatedEvent | MergeEvent | FireAndForgetEvent):
104
+ return event.owner_span_id
83
105
  return None
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
2
+ # Licensed under the Business Source License 1.1 (see LICENSE); Change Date: four
3
+ # years from publication; Change License: Apache-2.0
4
+ # Copyright (c) 2026 Empower Agile
5
+ """The one reading of an exception's message every emitter shares (B-174).
6
+
7
+ ``Threw`` holds the live exception, unlike ``Returned``, which
8
+ holds text the value renderer already rendered, so a message never passed the redaction policy:
9
+ ``ValueError("bad token: eyJ...")`` reached every output in full while the identical string as a
10
+ parameter answered ``[REDACTED]``. Every renderer, exporter and listener reads the message here.
11
+
12
+ **@llmNote** Only the value-shape axis applies: a JWT, a Luhn-valid card number or a
13
+ ``Set-Cookie`` string inside a message is never legitimate. The name deny-list has nothing to match
14
+ on, and blanking messages by keyword would destroy the diagnostic value the message exists for.
15
+ Length is deliberately not capped.
16
+
17
+ **@sideEffects** ``str(exc)`` is application code run once per emitter; a rogue ``__str__`` that
18
+ raises degrades to ``<error>`` instead of failing the emitter.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from narrativetrace.escape import control_sanitize
24
+ from narrativetrace.redaction import REDACTED_MARKER, RedactionPolicy
25
+
26
+ UNREADABLE = "<error>"
27
+
28
+
29
+ def of(exception: BaseException | None) -> str | None:
30
+ """The message an emitter may print: replaced whole by the redaction marker when its bytes are
31
+ a credential, control-escaped always; ``None`` for no exception, ``<error>`` when unreadable."""
32
+ if exception is None:
33
+ return None
34
+ try:
35
+ raw = str(exception)
36
+ except Exception:
37
+ return UNREADABLE
38
+ if RedactionPolicy.DEFAULT.should_redact_value(raw):
39
+ return REDACTED_MARKER
40
+ return control_sanitize(raw)
41
+
42
+
43
+ def text(exception: BaseException | None) -> str:
44
+ """:func:`of` where text rather than ``None`` is needed: ``null`` for no exception."""
45
+ message = of(exception)
46
+ return "null" if message is None else message
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import json
21
21
  from typing import Any
22
22
 
23
+ from narrativetrace import exception_message
23
24
  from narrativetrace.concurrency import ConcurrencyInfo
24
25
  from narrativetrace.identity import resolve_identity
25
26
  from narrativetrace.nodes import TraceNode
@@ -151,7 +152,7 @@ def _append_outcome(event: dict[str, Any], outcome: TraceOutcome | None) -> None
151
152
  elif isinstance(outcome, Threw):
152
153
  event["outcome"] = "threw"
153
154
  event["errorType"] = type(outcome.exception).__name__
154
- event["errorMessage"] = str(outcome.exception)
155
+ event["errorMessage"] = exception_message.of(outcome.exception)
155
156
  elif isinstance(outcome, Incomplete):
156
157
  event["outcome"] = "incomplete"
157
158
  else:
@@ -44,6 +44,7 @@ from collections.abc import Iterator
44
44
  from contextlib import contextmanager
45
45
  from enum import Enum
46
46
 
47
+ from narrativetrace import exception_message
47
48
  from narrativetrace.context import ContextVarNarrativeContext
48
49
  from narrativetrace.escape import control_sanitize
49
50
  from narrativetrace.events import (
@@ -254,7 +255,7 @@ class LoggingTraceConsumer:
254
255
  self, exception: BaseException, error_context: str | None, keys: dict[str, str]
255
256
  ) -> None:
256
257
  exc_type = type(exception).__name__
257
- message = control_sanitize(str(exception))
258
+ message = exception_message.text(exception)
258
259
  if error_context is not None:
259
260
  self._logger.log(
260
261
  self._exception,
@@ -16,6 +16,7 @@ pinned .NET/TS semantics and is the more predictable contract.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import threading
19
20
  import time
20
21
  from collections.abc import Callable
21
22
  from dataclasses import dataclass
@@ -23,14 +24,25 @@ from dataclasses import dataclass
23
24
  _DEFAULT_CLOCK = time.monotonic
24
25
 
25
26
 
26
- @dataclass(slots=True)
27
+ @dataclass(slots=True, eq=False)
27
28
  class _TimedEntry[V]:
29
+ """Compared by identity: a replacement entry never equals the one it replaced."""
30
+
28
31
  value: V
29
32
  created: float
30
33
 
31
34
 
32
35
  class PerishableMap[K, V]:
33
- """A capacity- and TTL-bounded map with an eviction callback."""
36
+ """A capacity- and TTL-bounded map with an eviction callback.
37
+
38
+ Thread-safe (B-169): an :class:`~threading.RLock` guards every read-modify-write, because the
39
+ OTel listener puts and removes on caller threads. A sweep removes exactly the entry it
40
+ examined (by identity), so a replacement put under an expired key is never evicted in its
41
+ place, and an entry is called back only when its own removal won -- never twice. Callbacks run
42
+ *after* the lock is released (collect under the lock, release, then call): a callback that
43
+ re-enters the map cannot deadlock and never observes a half-finished sweep. By then every
44
+ evicted entry is already out of the map, so a callback's ``remove`` of one answers ``None``.
45
+ """
34
46
 
35
47
  def __init__(
36
48
  self,
@@ -45,43 +57,54 @@ class PerishableMap[K, V]:
45
57
  self._ttl = ttl_seconds
46
58
  self._on_evict = on_evict
47
59
  self._clock = clock
60
+ self._lock = threading.RLock()
48
61
  self._entries: dict[K, _TimedEntry[V]] = {}
49
62
 
50
63
  def put(self, key: K, value: V) -> None:
51
64
  """Inserts/overwrites, evicting expired (and, for new keys only, over-capacity) first."""
52
- self._evict_expired()
53
- if key not in self._entries: # divergence: overwrite at capacity evicts nothing
54
- self._evict_over_capacity()
55
- self._entries[key] = _TimedEntry(value, self._clock())
65
+ with self._lock:
66
+ evicted = self._evict_expired()
67
+ if key not in self._entries: # divergence: overwrite at capacity evicts nothing
68
+ evicted += self._evict_over_capacity()
69
+ self._entries[key] = _TimedEntry(value, self._clock())
70
+ for entry in evicted:
71
+ self._on_evict(entry.value)
56
72
 
57
73
  def get(self, key: K) -> V | None:
58
74
  """Returns the value for ``key`` or ``None``."""
59
- entry = self._entries.get(key)
60
- return entry.value if entry is not None else None
75
+ with self._lock:
76
+ entry = self._entries.get(key)
77
+ return entry.value if entry is not None else None
61
78
 
62
79
  def remove(self, key: K) -> V | None:
63
80
  """Removes and returns the value for ``key`` or ``None``."""
64
- entry = self._entries.pop(key, None)
65
- return entry.value if entry is not None else None
81
+ with self._lock:
82
+ entry = self._entries.pop(key, None)
83
+ return entry.value if entry is not None else None
66
84
 
67
85
  def size(self) -> int:
68
86
  """Number of entries currently held."""
69
- return len(self._entries)
87
+ with self._lock:
88
+ return len(self._entries)
70
89
 
71
- def _evict_expired(self) -> None:
90
+ def _evict_expired(self) -> list[_TimedEntry[V]]:
91
+ """Removes the expired entries; the caller (holding the lock) calls back afterwards."""
72
92
  now = self._clock()
73
- expired = [k for k, e in self._entries.items() if now - e.created > self._ttl]
74
- for key in expired:
75
- entry = self._entries.pop(key)
76
- self._on_evict(entry.value)
77
-
78
- def _evict_over_capacity(self) -> None:
93
+ evicted: list[_TimedEntry[V]] = []
94
+ for key, examined in list(self._entries.items()):
95
+ if now - examined.created > self._ttl and self._entries.get(key) is examined:
96
+ del self._entries[key]
97
+ evicted.append(examined)
98
+ return evicted
99
+
100
+ def _evict_over_capacity(self) -> list[_TimedEntry[V]]:
101
+ evicted: list[_TimedEntry[V]] = []
79
102
  while len(self._entries) >= self._max_capacity:
80
103
  oldest = self._find_oldest()
81
104
  if oldest is None:
82
105
  break
83
- entry = self._entries.pop(oldest)
84
- self._on_evict(entry.value)
106
+ evicted.append(self._entries.pop(oldest))
107
+ return evicted
85
108
 
86
109
  def _find_oldest(self) -> K | None:
87
110
  oldest_key: K | None = None
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass
16
16
 
17
+ from narrativetrace import exception_message
17
18
  from narrativetrace.escape import control_sanitize
18
19
  from narrativetrace.nodes import TraceNode
19
20
  from narrativetrace.outcomes import Incomplete, Returned, Threw, TraceOutcome
@@ -211,7 +212,7 @@ class IndentedTextRenderer:
211
212
  if isinstance(outcome, Returned):
212
213
  parts.append(f" → {_return_text(outcome.rendered_value)}")
213
214
  elif isinstance(outcome, Threw):
214
- message = control_sanitize(str(outcome.exception))
215
+ message = exception_message.text(outcome.exception)
215
216
  parts.append(f" !! {_exception_type(outcome.exception)}: {message}")
216
217
  if sig.error_context is not None:
217
218
  parts.append(f" | {control_sanitize(sig.error_context)}")
@@ -224,7 +225,7 @@ class IndentedTextRenderer:
224
225
  if isinstance(outcome, Returned):
225
226
  parts.append(f"→ {_return_text(outcome.rendered_value)}")
226
227
  elif isinstance(outcome, Threw):
227
- message = control_sanitize(str(outcome.exception))
228
+ message = exception_message.text(outcome.exception)
228
229
  parts.append(f"!! {_exception_type(outcome.exception)}: {message}")
229
230
  if sig.error_context is not None:
230
231
  parts.append(f" | {control_sanitize(sig.error_context)}")
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass
16
16
 
17
+ from narrativetrace import exception_message
17
18
  from narrativetrace.escape import markdown_code, markdown_text
18
19
  from narrativetrace.nodes import TraceNode
19
20
  from narrativetrace.outcomes import Incomplete, Returned, Threw, TraceOutcome
@@ -229,7 +230,7 @@ class MarkdownRenderer:
229
230
  elif isinstance(outcome, Threw):
230
231
  error_indent = " " * (depth + 1)
231
232
  exc_type = markdown_code(type(outcome.exception).__name__)
232
- message = markdown_text(str(outcome.exception))
233
+ message = markdown_text(exception_message.text(outcome.exception))
233
234
  parts.append(f"\n\n{error_indent}> ❌ {exc_type}: {message}")
234
235
  if sig.error_context is not None:
235
236
  parts.append(f"\n{error_indent}> {markdown_text(sig.error_context)}")
@@ -249,7 +250,7 @@ class MarkdownRenderer:
249
250
  elif isinstance(outcome, Threw):
250
251
  error_indent = " " * (depth + 1)
251
252
  exc_type = markdown_code(type(outcome.exception).__name__)
252
- message = markdown_text(str(outcome.exception))
253
+ message = markdown_text(exception_message.text(outcome.exception))
253
254
  parts.append(f"❌ {exc_type}: {message}")
254
255
  if sig.error_context is not None:
255
256
  parts.append(f"\n{error_indent}> {markdown_text(sig.error_context)}")
@@ -12,6 +12,7 @@ Every span is named by its span id in parentheses (``(#1.3)``,
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ from narrativetrace import exception_message
15
16
  from narrativetrace.escape import control_sanitize
16
17
  from narrativetrace.nodes import TraceNode
17
18
  from narrativetrace.outcomes import Incomplete, Returned, Threw, TraceOutcome
@@ -165,7 +166,7 @@ class ProseRenderer:
165
166
  if isinstance(outcome, Returned) and outcome.rendered_value is not None:
166
167
  parts.append(f"{indent} Returned {outcome.rendered_value}.\n")
167
168
  elif isinstance(outcome, Threw):
168
- message = control_sanitize(str(outcome.exception))
169
+ message = exception_message.text(outcome.exception)
169
170
  parts.append(f"{indent} {_exception_type(outcome.exception)}: {message}.\n")
170
171
  elif isinstance(outcome, Incomplete):
171
172
  parts.append(f"{indent} ⏳ in-flight.\n")
@@ -176,7 +177,7 @@ class ProseRenderer:
176
177
  if isinstance(outcome, Returned) and outcome.rendered_value is not None:
177
178
  parts.append(f", returning {outcome.rendered_value}")
178
179
  elif isinstance(outcome, Threw):
179
- message = control_sanitize(str(outcome.exception))
180
+ message = exception_message.text(outcome.exception)
180
181
  parts.append(f" — {_exception_type(outcome.exception)}: {message}")
181
182
  if sig.error_context is not None:
182
183
  parts.append(f" ({control_sanitize(sig.error_context)})")