sawmill-parser 0.3.2__tar.gz → 0.4.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 (151) hide show
  1. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/PKG-INFO +5 -5
  2. sawmill_parser-0.4.0/SAWMILL_STREAMING_SPEC.md +66 -0
  3. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/index.rst +8 -0
  4. sawmill_parser-0.4.0/docs/performance/overview.rst +103 -0
  5. sawmill_parser-0.4.0/docs/performance/plugin-api-2.rst +97 -0
  6. sawmill_parser-0.4.0/docs/performance/store-investigation.rst +127 -0
  7. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/plugin-guide/what-is-a-plugin.rst +4 -4
  8. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/plugin-guide/writing-a-plugin.rst +27 -10
  9. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/pyproject.toml +3 -3
  10. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/cli/commands.py +50 -9
  11. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/cli/processing.py +9 -4
  12. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/plugin.py +12 -2
  13. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/models/message.py +20 -9
  14. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/plugin/__init__.py +15 -9
  15. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/plugin/hookspec.py +18 -11
  16. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/app.py +254 -59
  17. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/widgets/footer.py +1 -6
  18. sawmill_parser-0.4.0/sawmill/tui/widgets/help_modal.py +68 -0
  19. sawmill_parser-0.4.0/tests/core/test_plugin_load.py +25 -0
  20. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/models/test_message.py +19 -0
  21. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/plugin/test_hookspec.py +8 -10
  22. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/plugins/test_vivado.py +11 -11
  23. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_app.py +1 -1
  24. sawmill_parser-0.4.0/tests/tui/test_windowed_table.py +192 -0
  25. sawmill_parser-0.4.0/tools/bench/baseline.json +56 -0
  26. sawmill_parser-0.4.0/tools/bench/bench_message.py +157 -0
  27. sawmill_parser-0.4.0/tools/bench/gen_xcelium_log.py +146 -0
  28. sawmill_parser-0.4.0/tools/bench/pr1.json +59 -0
  29. sawmill_parser-0.4.0/tools/bench/pr2.json +59 -0
  30. sawmill_parser-0.4.0/tools/bench/run_bench.py +246 -0
  31. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/generator.py +1 -1
  32. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/claude.md.j2 +11 -6
  33. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/plugin.py.j2 +17 -13
  34. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/test_parsing.py.j2 +13 -11
  35. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/test_plugin_contract.py.j2 +6 -4
  36. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/tests/test_generator.py +1 -1
  37. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/.gitattributes +0 -0
  38. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/.github/workflows/docs.yml +0 -0
  39. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/.github/workflows/publish.yml +0 -0
  40. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/.gitignore +0 -0
  41. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/.pre-commit-config.yaml +0 -0
  42. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/CHANGELOG.md +0 -0
  43. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/CLAUDE.md +0 -0
  44. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/Dockerfile +0 -0
  45. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/PRD.md +0 -0
  46. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/PROMPT.md +0 -0
  47. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/README.md +0 -0
  48. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/STATUS.md +0 -0
  49. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/assets/sawmill.png +0 -0
  50. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/Makefile +0 -0
  51. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/conf.py +0 -0
  52. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/development/contributing.rst +0 -0
  53. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/getting-started/quickstart.rst +0 -0
  54. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/getting-started/what-is-sawmill.rst +0 -0
  55. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/plugin-guide/pre-defined-filters.rst +0 -0
  56. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/reference/api.rst +0 -0
  57. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/reference/cli-reference.rst +0 -0
  58. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/reference/file-formats.rst +0 -0
  59. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/user-guide/ci-integration.rst +0 -0
  60. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/user-guide/cli-usage.rst +0 -0
  61. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/user-guide/sawmill-directory.rst +0 -0
  62. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/user-guide/tui-usage.rst +0 -0
  63. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/docs/user-guide/waivers.rst +0 -0
  64. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/ralph-loop.sh +0 -0
  65. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/__init__.py +0 -0
  66. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/__main__.py +0 -0
  67. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/cli/__init__.py +0 -0
  68. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/cli/formatting.py +0 -0
  69. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/cli/reporting.py +0 -0
  70. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/__init__.py +0 -0
  71. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/aggregation.py +0 -0
  72. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/config.py +0 -0
  73. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/filter.py +0 -0
  74. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/severity.py +0 -0
  75. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/suppress.py +0 -0
  76. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/core/waiver.py +0 -0
  77. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/models/__init__.py +0 -0
  78. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/models/filter_def.py +0 -0
  79. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/models/plugin_api.py +0 -0
  80. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/models/waiver.py +0 -0
  81. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/__init__.py +0 -0
  82. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/base.tcss +0 -0
  83. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/filter_parser.py +0 -0
  84. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/session.py +0 -0
  85. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/theme.py +0 -0
  86. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/widgets/__init__.py +0 -0
  87. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/widgets/quit_modal.py +0 -0
  88. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/tui/widgets/waive_modal.py +0 -0
  89. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/utils/__init__.py +0 -0
  90. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/utils/author.py +0 -0
  91. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/utils/dirs.py +0 -0
  92. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/utils/git.py +0 -0
  93. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sawmill/utils/toml.py +0 -0
  94. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/sourceme +0 -0
  95. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/__init__.py +0 -0
  96. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/conftest.py +0 -0
  97. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/__init__.py +0 -0
  98. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_aggregation.py +0 -0
  99. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_config.py +0 -0
  100. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_filter.py +0 -0
  101. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_plugin_autodetect.py +0 -0
  102. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_plugin_manager.py +0 -0
  103. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_suppress.py +0 -0
  104. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_waiver.py +0 -0
  105. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/core/test_waiver_matching.py +0 -0
  106. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/fixtures/vivado.log +0 -0
  107. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/models/__init__.py +0 -0
  108. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/models/test_filter_def.py +0 -0
  109. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/plugin/__init__.py +0 -0
  110. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/plugins/__init__.py +0 -0
  111. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_ci_mode.py +0 -0
  112. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_ci_report.py +0 -0
  113. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_ci_waivers.py +0 -0
  114. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_cli.py +0 -0
  115. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_cli_formats.py +0 -0
  116. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_cli_id_filter.py +0 -0
  117. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_cli_integration.py +0 -0
  118. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_cli_plugin_discovery.py +0 -0
  119. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_cli_summary.py +0 -0
  120. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_fixtures.py +0 -0
  121. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_plugin_api.py +0 -0
  122. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_project_setup.py +0 -0
  123. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/test_waiver_generation.py +0 -0
  124. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/__init__.py +0 -0
  125. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_base_styles.py +0 -0
  126. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_filter_parser.py +0 -0
  127. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_footer.py +0 -0
  128. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_persistence.py +0 -0
  129. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_suppress.py +0 -0
  130. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_tabs.py +0 -0
  131. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/tui/test_waive.py +0 -0
  132. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/utils/__init__.py +0 -0
  133. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/utils/test_dirs.py +0 -0
  134. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/utils/test_git.py +0 -0
  135. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tests/utils/test_toml.py +0 -0
  136. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/pyproject.toml +0 -0
  137. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/__init__.py +0 -0
  138. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/cli.py +0 -0
  139. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/names.py +0 -0
  140. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/conftest.py.j2 +0 -0
  141. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/gitignore.j2 +0 -0
  142. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/init.py.j2 +0 -0
  143. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/makefile.j2 +0 -0
  144. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/patterns.py.j2 +0 -0
  145. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/pyproject.toml.j2 +0 -0
  146. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/readme.md.j2 +0 -0
  147. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/sample_log.j2 +0 -0
  148. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/test_detection.py.j2 +0 -0
  149. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/src/sawmill_plugin_generator/templates/test_severity.py.j2 +0 -0
  150. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/tests/test_cli.py +0 -0
  151. {sawmill_parser-0.3.2 → sawmill_parser-0.4.0}/tools/sawmill-plugin-generator/tests/test_names.py +0 -0
@@ -1,10 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sawmill-parser
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A configurable TUI for post-processing and filtering structured tool logs
5
- Project-URL: Homepage, https://github.com/sawmill/sawmill
6
- Project-URL: Documentation, https://github.com/sawmill/sawmill#readme
7
- Project-URL: Repository, https://github.com/sawmill/sawmill
5
+ Project-URL: Homepage, https://github.com/sjalloq/sawmill
6
+ Project-URL: Documentation, https://sjalloq.github.io/sawmill
7
+ Project-URL: Repository, https://github.com/sjalloq/sawmill
8
8
  Author: Sawmill Contributors
9
9
  License: MIT
10
10
  Keywords: eda,filter,log,terminal,tui
@@ -0,0 +1,66 @@
1
+ # Sawmill performance: findings and plan
2
+
3
+ Working notes for making chip-level logs (100 MB+, 1M+ messages) usable.
4
+ Supersedes the earlier streaming spec, whose diagnosis was wrong.
5
+
6
+ ## 1. What was measured (2026-11-04)
7
+
8
+ Synthetic Xcelium log, 200k messages, 24 MB, Python 3.14, Textual DataTable.
9
+
10
+ | Step | Time |
11
+ |---|---|
12
+ | Plugin parse alone | 0.7 s |
13
+ | TUI load until table drawn | 9.4 s |
14
+ | Filter logic excluding table rebuild | 0.14 s |
15
+ | Clear filter / sort / severity toggle | 6 to 9 s |
16
+ | First scroll after rebuild | 5 s |
17
+ | RSS after parse / after table populated | 219 MB / 404 MB |
18
+
19
+ - About 90 % of load time and 95 % of every interactive action is spent
20
+ inside `DataTable.add_row` and the deferred `_update_dimensions` pass that
21
+ measures every cell. DataTable virtualises rendering, not population.
22
+ - Parse is bound by Python object creation (dict per match, Message, FileRef,
23
+ metadata dict), not regex (under 20 %) or I/O (about 6 %).
24
+ - Memory per Message was about 820 B: 184 B metadata dict, 167 B raw_text,
25
+ 104 B slots object, 86 B content (always a substring of raw_text), plus
26
+ unshared copies of message_id, category and file path.
27
+
28
+ ## 2. Done
29
+
30
+ - Plugin hook `load_and_parse` returns `Iterator[Message]` (plugin API 2).
31
+ `sawmill.core.plugin.load_messages()` wraps list-returning plugins.
32
+ Plugins open the file eagerly so errors surface at call time.
33
+ - `Message` and `FileRef` are `@dataclass(slots=True)` with int coercion.
34
+ - Xcelium plugin: single regex, no per-match dict, interned strings, shared
35
+ per-tool metadata dict. 820 B to 520 B per message, output identical.
36
+ - Benchmarks in `tools/bench/` report current and peak RSS and go through
37
+ the real filter helpers.
38
+
39
+ ## 3. In progress
40
+
41
+ - Windowed DataTable: the table holds a window of rows around the cursor;
42
+ `_filtered_messages` stays the full list. Branch `perf/windowed-table`.
43
+ - One waiver match per message; redundant list copies removed.
44
+
45
+ ## 4. Next: store investigation
46
+
47
+ After the view is fixed, measure a real 130 MB log. If memory is still the
48
+ complaint, compare on a branch:
49
+
50
+ - Intern-only (current design, about 500 B/message, no new dependency).
51
+ - DuckDB: columnar, dictionary-encoded, vectorised filter/sort/group-by,
52
+ native regex, spill to disk, `LIMIT/OFFSET` paging. Roughly 150 to 250 MB
53
+ per 1M messages. Cost: 50 MB wheel, 100 ms import, SQL in the app, waiver
54
+ and suppression logic expressed as predicates.
55
+ - Polars: same idea, dataframe API, lighter, no spill.
56
+ - Hand-rolled columnar (int arrays plus byte offsets, text re-read from
57
+ mmap): about 40 B/message but a third plugin contract and binary-mode
58
+ offsets in every plugin. Only if the above are insufficient.
59
+
60
+ Decide with numbers from the real log, not estimates.
61
+
62
+ ## 5. Dropped
63
+
64
+ Background parse thread, stats-only mode, Rust hot loop and FSM preamble
65
+ matcher are parked. Parse is under 10 % of load; none of these move the
66
+ user-visible numbers until the view and store are settled.
@@ -33,6 +33,14 @@ parse each tool's output.
33
33
  plugin-guide/pre-defined-filters
34
34
  plugin-guide/writing-a-plugin
35
35
 
36
+ .. toctree::
37
+ :maxdepth: 2
38
+ :caption: Performance
39
+
40
+ performance/overview
41
+ performance/plugin-api-2
42
+ performance/store-investigation
43
+
36
44
  .. toctree::
37
45
  :maxdepth: 2
38
46
  :caption: Reference
@@ -0,0 +1,103 @@
1
+ Performance overview
2
+ ====================
3
+
4
+ This page records what was measured, what was changed, and the current
5
+ numbers.
6
+
7
+ What was measured
8
+ -----------------
9
+
10
+ Measurements were taken on 2026-11-04 with a synthetic Xcelium log from
11
+ ``tools/bench/gen_xcelium_log.py`` (200k messages, 24 MB).
12
+
13
+ .. list-table:: Before
14
+ :header-rows: 1
15
+
16
+ * - Step
17
+ - Before
18
+ * - Plugin parse alone
19
+ - 0.7 s
20
+ * - TUI load until table drawn
21
+ - 9.4 s
22
+ * - Filter logic excluding table rebuild
23
+ - 0.14 s
24
+ * - Clear filter / sort / severity toggle
25
+ - 6 to 9 s
26
+ * - First scroll after rebuild
27
+ - 5 s
28
+ * - RSS after parse / after table drawn
29
+ - 219 MB / 404 MB
30
+
31
+ Diagnosis
32
+ ---------
33
+
34
+ - About 90% of load and 95% of each interactive action was Textual
35
+ ``DataTable.add_row`` plus its deferred ``_update_dimensions`` cell
36
+ measurement. ``DataTable`` virtualises rendering, not population.
37
+ - Parse was bound by Python object creation (about 40%: per-match dict,
38
+ ``Message``, ``FileRef``, metadata dict). Regex was under 20%, I/O
39
+ about 6%.
40
+ - Memory per ``Message`` was about 820 B:
41
+
42
+ - metadata dict: 184 B
43
+ - ``raw_text``: 167 B
44
+ - slots object: 104 B
45
+ - ``content``: 86 B (always a substring of ``raw_text``)
46
+ - unshared ``message_id`` / ``category`` / path strings: about 144 B
47
+
48
+ What was changed
49
+ ----------------
50
+
51
+ - **Plugin API 2**: ``load_and_parse`` returns ``Iterator[Message]``.
52
+ API 1 plugins returning lists keep working. See :doc:`plugin-api-2`.
53
+ - **Message model**: ``Message`` and ``FileRef`` are
54
+ ``@dataclass(slots=True)`` (``FileRef`` is frozen).
55
+ ``__post_init__`` coerces ``start_line``, ``end_line`` and ``line`` to
56
+ ``int``. No Pydantic on the hot path.
57
+ - **Windowed table**: the TUI populates only a window of rows around the
58
+ cursor, ``max(3 × visible rows, 200)``, and re-windows centred on the
59
+ cursor as it moves. The subtitle shows ``row i of N``.
60
+ - **Waivers**: matched once per message; redundant list copies removed.
61
+
62
+ Benchmarks
63
+ ----------
64
+
65
+ - ``tools/bench/gen_xcelium_log.py``: synthetic log generator.
66
+ - ``tools/bench/run_bench.py``: size sweep, fresh subprocess per size,
67
+ current RSS from ``/proc/self/statm`` plus peak.
68
+ - ``tools/bench/bench_message.py``: ``Message`` construction cost.
69
+
70
+ Current numbers
71
+ ---------------
72
+
73
+ .. list-table:: 200k messages
74
+ :header-rows: 1
75
+
76
+ * - Action
77
+ - Before
78
+ - After
79
+ * - Load
80
+ - 9.7 s
81
+ - 0.24 s
82
+ * - Clear filter
83
+ - 8.87 s
84
+ - 0.12 s
85
+ * - Sort
86
+ - 8.93 s
87
+ - 0.26 s
88
+ * - Severity toggle
89
+ - 6.66 s
90
+ - 0.18 s
91
+ * - RSS after table drawn
92
+ - 332 MB
93
+ - 158 MB
94
+
95
+ At 1M messages:
96
+
97
+ - parse: 3.5 s
98
+ - load: 0.49 s
99
+ - filter / sort / toggles: 0.36 to 0.70 s
100
+ - RSS: about 550 MB after parse, 660 MB at end
101
+
102
+ A column store was also evaluated and not adopted; see
103
+ :doc:`store-investigation`.
@@ -0,0 +1,97 @@
1
+ Plugin API 2
2
+ ============
3
+
4
+ ``SAWMILL_PLUGIN_API`` is ``"2"``. The change is to ``load_and_parse``,
5
+ which now streams messages.
6
+
7
+ The contract
8
+ ------------
9
+
10
+ .. code-block:: python
11
+
12
+ @hookspec
13
+ def load_and_parse(self, path: Path) -> Iterator[Message]: ...
14
+
15
+ - Plugins should be generators reading the file line by line.
16
+ - Yield each ``Message`` as soon as it is complete.
17
+ - The ``SawmillPlugin`` base class default is a generator that yields
18
+ nothing.
19
+ - ``[tool.sawmill] plugin_api`` in a plugin's ``pyproject.toml`` is
20
+ informational; nothing enforces it.
21
+
22
+ Migrating from API 1
23
+ --------------------
24
+
25
+ API 1 returned ``list[Message]``. To migrate:
26
+
27
+ - Replace ``messages.append(msg)`` with ``yield msg``.
28
+ - Drop the final ``return messages``.
29
+ - Flush any in-flight multi-line message after the loop with a final
30
+ ``yield``.
31
+
32
+ The ``load_messages`` shim
33
+ --------------------------
34
+
35
+ The base app calls plugins through ``sawmill.core.plugin.load_messages``:
36
+
37
+ .. code-block:: python
38
+
39
+ def load_messages(plugin, path) -> Iterator[Message]:
40
+ return iter(plugin.load_and_parse(path))
41
+
42
+ A legacy list is wrapped with ``iter()``, so API 1 plugins keep working
43
+ unchanged. They do not get the memory benefit.
44
+
45
+ Eager-open pattern
46
+ ------------------
47
+
48
+ A plain generator does not run until the first ``next()``, so a missing
49
+ file would raise deep inside the consumer. Open the file in an outer
50
+ function and return an inner generator over the handle:
51
+
52
+ .. code-block:: python
53
+
54
+ @hookimpl
55
+ def load_and_parse(self, path):
56
+ f = open(path, errors="replace") # raises here if missing
57
+ return self._iter_messages(f)
58
+
59
+ def _iter_messages(self, f):
60
+ try:
61
+ current = None
62
+ for line_num, line in enumerate(f, start=1):
63
+ ... # build or extend current; yield when complete
64
+ if current is not None:
65
+ yield current # flush the in-flight message
66
+ finally:
67
+ f.close()
68
+
69
+ Caveat: a generator that is never iterated keeps its file handle open
70
+ until garbage collection.
71
+
72
+ Allocation guidance
73
+ -------------------
74
+
75
+ Parse time is dominated by Python object creation, not regex or I/O.
76
+
77
+ - Read with ``for line in f``, never ``readlines()``.
78
+ - Use one combined regex per line rather than two tried in sequence.
79
+ - Use ``m.groups()`` or a ``NamedTuple``, not a dict per line.
80
+ - Intern repeated strings (tool, rule / message ID, file path) through a
81
+ small cache dict.
82
+ - Share one ``metadata`` dict per constant value, or omit ``metadata``.
83
+
84
+ Measured effect on an Xcelium plugin at 200k messages, output identical:
85
+
86
+ - memory: 820 → 520 B per message
87
+ - parse: 0.67 → 0.63 s
88
+
89
+ The remaining per-message cost is mostly the ``Message`` object itself.
90
+ The realistic Python ceiling is about 2 µs and 400 B per message.
91
+
92
+ Generated plugins
93
+ -----------------
94
+
95
+ The plugin generator template ``plugin.py.j2`` (under
96
+ ``tools/sawmill-plugin-generator/``) emits a generator-shaped
97
+ ``load_and_parse``.
@@ -0,0 +1,127 @@
1
+ Column-store investigation
2
+ ==========================
3
+
4
+ A spike tested whether a column store would beat a list of ``Message``
5
+ objects. The code lives on branch ``spike/duckdb-store``
6
+ (``tools/bench/store_spike.py``).
7
+
8
+ Stores compared
9
+ ---------------
10
+
11
+ Three stores behind one query interface:
12
+
13
+ - **List**: list of ``Message`` (today).
14
+ - **DuckDB**: in-memory table, ingested in 50k-row batches via pyarrow.
15
+ ``executemany`` was unusable (25 s per 50k rows).
16
+ - **Polars**: DataFrame built from dicts of columns per batch.
17
+
18
+ Pandas was ruled out up front: per-row string handling is Python
19
+ objects anyway, and the wheel is heavy.
20
+
21
+ Results
22
+ -------
23
+
24
+ 1M messages, 128 MB log. "DuckDB file" is file-backed with a 256 MB
25
+ memory limit and late materialisation.
26
+
27
+ .. list-table::
28
+ :header-rows: 1
29
+
30
+ * -
31
+ - List
32
+ - DuckDB in-memory
33
+ - DuckDB file
34
+ - Polars
35
+ * - Ingest
36
+ - 3.56 s
37
+ - 4.03 s
38
+ - 3.87 s
39
+ - 2.33 s
40
+ * - Time to first batch
41
+ - 0.15 s
42
+ - 0.16 s
43
+ - 0.18 s
44
+ - 0.12 s
45
+ * - RSS after ingest
46
+ - 531 MB
47
+ - 495 MB
48
+ - 294 MB
49
+ - 423 MB
50
+ * - Severity filter
51
+ - 22 ms
52
+ - 4 ms
53
+ - 3 ms
54
+ - 6 ms
55
+ * - ID glob
56
+ - 200 ms
57
+ - 1 ms
58
+ - 1 ms
59
+ - 6 ms
60
+ * - Regex (common pattern)
61
+ - 439 ms
62
+ - 19 ms
63
+ - 22 ms
64
+ - 7 ms
65
+ * - ID counts (stats)
66
+ - 46 ms
67
+ - 3 ms
68
+ - 1 ms
69
+ - 1 ms
70
+ * - Sort by ID count + page of 200
71
+ - 398 ms
72
+ - 113 ms
73
+ - 124 ms
74
+ - 32 ms
75
+ * - Combined keystroke (filter + sort + page)
76
+ - 389 ms
77
+ - 35 ms
78
+ - 36 ms
79
+ - 12 ms
80
+
81
+ Other findings
82
+ --------------
83
+
84
+ - With a 256 MB ``memory_limit``, a full-row ``ORDER BY`` raised
85
+ ``OutOfMemoryException`` even at 200k. Sorting only key columns, then
86
+ joining back the 200 page rows (late materialisation), fixed it and
87
+ was 5 to 10× faster generally.
88
+ - Without a limit and without late materialisation, RSS reached 2.4 GB.
89
+ - Parsing to ``Message`` costs about 0.8 s per 1M over yielding tuples
90
+ (2.58 vs 1.78 s). Ingest only improves if plugins yield tuples.
91
+ - Import time (warm): duckdb 45 ms, polars 48 ms, pyarrow 12 ms.
92
+ - Installed size: duckdb about 65 MB, pyarrow about 165 MB (needed for
93
+ DuckDB ingest), polars about 180 MB.
94
+
95
+ Awkward points
96
+ --------------
97
+
98
+ - Regex dialect changes from Python ``re`` to RE2 (DuckDB) or Rust regex
99
+ (Polars): no backreferences or lookaround. Saved filters and waiver
100
+ content patterns could break.
101
+ - Waivers would become a table joined on ``message_id`` with
102
+ ``regexp_matches(raw_text, p, 's')``.
103
+ - Suppressions become ``message_id IN (...)`` and
104
+ ``NOT regexp_matches``.
105
+ - The TUI, waiver generation and export hold ``Message`` objects and
106
+ would move to row indexes.
107
+ - ``metadata`` has no column.
108
+ - A tuple-yielding plugin hook is needed to realise the ingest gain.
109
+
110
+ Recommendation
111
+ --------------
112
+
113
+ Not adopted.
114
+
115
+ - The windowed view removed the user-visible problem.
116
+ - A store buys 10× faster keystrokes on any backend, but only
117
+ file-backed DuckDB with a limit saves memory (about 45%).
118
+ - Revisit only if a real 130 MB log shows memory is still the complaint,
119
+ or sub-50 ms filtering becomes a feature. File-backed DuckDB is then
120
+ the path.
121
+
122
+ Parked alternatives:
123
+
124
+ - background parse thread
125
+ - Rust inner loop
126
+ - hand-rolled columnar arrays with byte offsets (about 40 B per message,
127
+ but a third plugin contract)
@@ -22,10 +22,10 @@ interface:
22
22
  Detection --- examine a file and return a confidence score (0.0 to
23
23
  1.0) indicating how well this plugin can parse it.
24
24
 
25
- ``load_and_parse(path) -> list[Message]``
26
- Parsing --- open the file, parse every line, group multi-line
27
- messages, extract severity and message IDs, and return a flat list
28
- of ``Message`` objects.
25
+ ``load_and_parse(path) -> Iterator[Message]``
26
+ Parsing --- open the file, parse it line by line, group multi-line
27
+ messages, extract severity and message IDs, and yield each
28
+ ``Message`` as it is completed (plugin API 2).
29
29
 
30
30
  ``get_severity_levels() -> list[dict]`` (**required**)
31
31
  Define the tool's severity levels with ``id``, ``name``, ``level``
@@ -88,11 +88,13 @@ extension, or first few lines of the file:
88
88
  pass
89
89
  return 0.0
90
90
 
91
- ``load_and_parse(path) -> list[Message]`` (optional)
92
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
91
+ ``load_and_parse(path) -> Iterator[Message]`` (optional)
92
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
93
93
 
94
- Open the file, parse every line, group multi-line messages, and return
95
- a flat list of ``Message`` objects:
94
+ Open the file, parse it line by line, group multi-line messages, and
95
+ ``yield`` each ``Message`` as soon as it is complete. Since plugin API 2
96
+ this hook is a generator; returning a list still works but forces the
97
+ whole log into memory. See :doc:`/performance/plugin-api-2`.
96
98
 
97
99
  .. code-block:: python
98
100
 
@@ -100,20 +102,24 @@ a flat list of ``Message`` objects:
100
102
 
101
103
  @hookimpl
102
104
  def load_and_parse(self, path):
103
- messages = []
104
- with open(path, errors="replace") as f:
105
+ f = open(path, errors="replace") # open eagerly: errors surface here
106
+ return self._iter_messages(f)
107
+
108
+ def _iter_messages(self, f):
109
+ try:
105
110
  for line_num, line in enumerate(f, start=1):
106
111
  severity, msg_id, content = self._parse_line(line)
107
112
  if severity:
108
- messages.append(Message(
113
+ yield Message(
109
114
  start_line=line_num,
110
115
  end_line=line_num,
111
116
  raw_text=line.rstrip(),
112
117
  content=content,
113
118
  severity=severity,
114
119
  message_id=msg_id,
115
- ))
116
- return messages
120
+ )
121
+ finally:
122
+ f.close()
117
123
 
118
124
  ``get_severity_levels() -> list[dict]`` (**required**)
119
125
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -235,10 +241,21 @@ The typical workflow is:
235
241
  message ID format, and multi-line grouping rules.
236
242
  2. Write regex patterns in ``patterns.py``.
237
243
  3. Implement ``can_handle()`` to detect the tool's log files.
238
- 4. Implement ``load_and_parse()`` to parse lines into messages.
244
+ 4. Implement ``load_and_parse()`` as a generator that yields messages.
239
245
  5. Implement ``get_severity_levels()`` with the correct level mapping.
240
246
  6. Optionally add ``get_filters()`` for common categories.
241
247
 
248
+ Performance checklist
249
+ ---------------------
250
+
251
+ - Make ``load_and_parse()`` a generator; read with ``for line in f``,
252
+ never ``readlines()``.
253
+ - Open the file eagerly so missing-file errors are raised at the call.
254
+ - Use one combined regex per line; avoid a dict per match.
255
+ - Intern repeated strings and share or omit ``metadata`` dicts.
256
+
257
+ Details and measurements: :doc:`/performance/plugin-api-2`.
258
+
242
259
  Testing
243
260
  -------
244
261
 
@@ -56,9 +56,9 @@ docs = [
56
56
  sawmill = "sawmill.__main__:cli"
57
57
 
58
58
  [project.urls]
59
- Homepage = "https://github.com/sawmill/sawmill"
60
- Documentation = "https://github.com/sawmill/sawmill#readme"
61
- Repository = "https://github.com/sawmill/sawmill"
59
+ Homepage = "https://github.com/sjalloq/sawmill"
60
+ Documentation = "https://sjalloq.github.io/sawmill"
61
+ Repository = "https://github.com/sjalloq/sawmill"
62
62
 
63
63
  [tool.hatch.version]
64
64
  source = "vcs"
@@ -27,6 +27,53 @@ from sawmill.models.waiver import Waiver
27
27
 
28
28
  click.rich_click.TEXT_MARKUP = "rich"
29
29
  click.rich_click.SHOW_ARGUMENTS = True
30
+ click.rich_click.OPTION_GROUPS = {
31
+ "sawmill": [
32
+ {
33
+ "name": "General",
34
+ "options": [
35
+ "--version",
36
+ "--list-plugins",
37
+ "--plugin",
38
+ "--show-info",
39
+ "--batch",
40
+ "--help",
41
+ ],
42
+ },
43
+ {
44
+ "name": "Filtering (works in both TUI and batch mode)",
45
+ "options": [
46
+ "--severity",
47
+ "--filter",
48
+ "--suppress",
49
+ "--suppress-id",
50
+ "--id",
51
+ "--category",
52
+ "--waivers",
53
+ ],
54
+ },
55
+ {
56
+ "name": "Batch mode (implies --batch)",
57
+ "options": [
58
+ "--check",
59
+ "--fail-on",
60
+ "--show-waived",
61
+ "--report-unused",
62
+ "--report",
63
+ "--generate-waivers",
64
+ "--waiver-level",
65
+ "--summary",
66
+ "--group-by",
67
+ "--top",
68
+ "--format",
69
+ ],
70
+ },
71
+ {
72
+ "name": "Display",
73
+ "options": ["--list-severity", "--list-groupings"],
74
+ },
75
+ ],
76
+ }
30
77
 
31
78
 
32
79
  def _get_implemented_hooks(plugin) -> list[str]:
@@ -169,7 +216,7 @@ def _get_implemented_hooks(plugin) -> list[str]:
169
216
  @click.option(
170
217
  "--batch",
171
218
  is_flag=True,
172
- help="Run in batch mode (no TUI). Implied by any output/filter/check flags.",
219
+ help="Force batch mode (no TUI).",
173
220
  )
174
221
  @click.pass_context
175
222
  def cli(
@@ -404,19 +451,13 @@ def cli(
404
451
  return
405
452
 
406
453
  # Determine if batch mode is needed.
407
- # Explicit --batch flag, or any output/filter/check flag implies batch.
454
+ # Explicit --batch flag, or batch-only flags imply batch.
455
+ # Filtering flags (--severity, --filter, --suppress, etc.) work in both modes.
408
456
  is_batch = batch or any(
409
457
  [
410
- severity is not None,
411
- filter_pattern is not None,
412
- suppress_patterns,
413
- suppress_ids,
414
- id_patterns,
415
- categories,
416
458
  generate_waivers,
417
459
  check,
418
460
  fail_on is not None,
419
- waivers is not None,
420
461
  show_waived,
421
462
  report_unused,
422
463
  report_file is not None,
@@ -17,6 +17,7 @@ from sawmill.core.plugin import (
17
17
  NoPluginFoundError,
18
18
  PluginConflictError,
19
19
  get_plugin_manager,
20
+ load_messages,
20
21
  select_plugin,
21
22
  )
22
23
  from sawmill.core.severity import (
@@ -116,8 +117,11 @@ def process_log_file(
116
117
  console.print("\nUse --list-severity to see all available levels.")
117
118
  ctx.exit(1)
118
119
 
119
- # Load and parse the file using the plugin
120
- messages = plugin.load_and_parse(path)
120
+ # Load and parse the file using the plugin.
121
+ # PR1: the plugin now streams via Iterator[Message]; the base app
122
+ # still consumes eagerly. PR3 will move this onto a background
123
+ # worker and drop the list() collapse.
124
+ messages = list(load_messages(plugin, path))
121
125
 
122
126
  # Apply severity filter
123
127
  if severity:
@@ -217,8 +221,9 @@ def generate_waivers(
217
221
  stderr_console.print(f"[red]Error:[/red] {e}")
218
222
  ctx.exit(1)
219
223
 
220
- # Load and parse the file using the plugin
221
- messages = plugin.load_and_parse(path)
224
+ # Load and parse the file using the plugin (eager collapse — see note in
225
+ # process_file for the streaming migration plan).
226
+ messages = list(load_messages(plugin, path))
222
227
 
223
228
  # Get severity levels from plugin
224
229
  severity_levels = get_severity_levels(plugin)
@@ -6,20 +6,30 @@ via Python entry points and registration with pluggy.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from collections.abc import Iterator
9
10
  from pathlib import Path
10
- from typing import TYPE_CHECKING
11
+ from typing import TYPE_CHECKING, Any
11
12
 
12
13
  import pluggy
13
14
 
14
15
  from sawmill.plugin import SawmillHookSpec, SawmillPlugin
15
16
 
16
17
  if TYPE_CHECKING:
17
- pass
18
+ from sawmill.models.message import Message
18
19
 
19
20
  # Entry point group name for sawmill plugins
20
21
  ENTRY_POINT_GROUP = "sawmill.plugins"
21
22
 
22
23
 
24
+ def load_messages(plugin: Any, path: Path) -> Iterator[Message]:
25
+ """Call ``plugin.load_and_parse(path)`` and return an iterator.
26
+
27
+ Plugin API 2 returns an iterator; legacy (API 1) plugins return a list.
28
+ Wrapping in ``iter()`` lets both work and passes iterators through unchanged.
29
+ """
30
+ return iter(plugin.load_and_parse(path))
31
+
32
+
23
33
  class PluginError(Exception):
24
34
  """Base exception for plugin-related errors."""
25
35