labnotes 0.0.99__tar.gz → 0.0.100__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 (166) hide show
  1. {labnotes-0.0.99 → labnotes-0.0.100}/PKG-INFO +1 -1
  2. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/run_output.py +27 -0
  3. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/labnotes.css +7 -0
  4. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/run-output.js +29 -0
  5. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/open_tables.py +13 -0
  6. labnotes-0.0.100/src/labnotes/tables/read_blocks.py +17 -0
  7. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/templates/run.html +2 -0
  8. labnotes-0.0.100/src/labnotes/tui/log_block.py +31 -0
  9. labnotes-0.0.100/src/labnotes/tui/log_blocks.py +55 -0
  10. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/run_app.py +5 -0
  11. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/run_screen.py +3 -0
  12. {labnotes-0.0.99 → labnotes-0.0.100}/.gitignore +0 -0
  13. {labnotes-0.0.99 → labnotes-0.0.100}/CHANGELOG.md +0 -0
  14. {labnotes-0.0.99 → labnotes-0.0.100}/MIGRATIONS.md +0 -0
  15. {labnotes-0.0.99 → labnotes-0.0.100}/README.md +0 -0
  16. {labnotes-0.0.99 → labnotes-0.0.100}/pyproject.toml +0 -0
  17. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/__init__.py +0 -0
  18. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/__main__.py +0 -0
  19. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/asset_url.py +0 -0
  20. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/check_counts.py +0 -0
  21. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/cli.py +0 -0
  22. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/context.md +0 -0
  23. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/context_guide.py +0 -0
  24. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/count_groups.py +0 -0
  25. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/count_runs.py +0 -0
  26. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/create_app.py +0 -0
  27. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/create_experiment.py +0 -0
  28. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/create_note.py +0 -0
  29. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/__init__.py +0 -0
  30. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/control_run.py +0 -0
  31. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/daemon_app.py +0 -0
  32. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/daemon_client.py +0 -0
  33. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/daemon_running.py +0 -0
  34. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/daemon_server.py +0 -0
  35. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/describe_status.py +0 -0
  36. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/ensure_daemon.py +0 -0
  37. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/exit_when_idle.py +0 -0
  38. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/remote_start.py +0 -0
  39. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/run_daemon.py +0 -0
  40. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/run_logs.py +0 -0
  41. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/serve_daemon.py +0 -0
  42. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/socket_path.py +0 -0
  43. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/stop_runs.py +0 -0
  44. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/watch_daemon.py +0 -0
  45. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon/watch_status.py +0 -0
  46. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/daemon_command.py +0 -0
  47. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/data_html.py +0 -0
  48. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/default_config_text.py +0 -0
  49. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/default_counts.py +0 -0
  50. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/drain_watch.py +0 -0
  51. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/experiment_notes.py +0 -0
  52. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/experiment_runs.py +0 -0
  53. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/experiments.py +0 -0
  54. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/file_changes.py +0 -0
  55. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/format_duration.py +0 -0
  56. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/format_started.py +0 -0
  57. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/format_value.py +0 -0
  58. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/group_runs.py +0 -0
  59. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/init.py +0 -0
  60. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/invocation_started.py +0 -0
  61. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/is_positive_int.py +0 -0
  62. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/list_experiments.py +0 -0
  63. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/list_runs.py +0 -0
  64. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/load_config.py +0 -0
  65. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/load_experiment.py +0 -0
  66. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/load_run.py +0 -0
  67. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/log.py +0 -0
  68. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/log_path.py +0 -0
  69. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/models.py +0 -0
  70. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/modes.py +0 -0
  71. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/nests_runs.py +0 -0
  72. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/note_path.py +0 -0
  73. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/notebook_kernel.py +0 -0
  74. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/output_html.py +0 -0
  75. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/parse_run_view.py +0 -0
  76. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/print_rows.py +0 -0
  77. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/python_blocks.py +0 -0
  78. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/read_config.py +0 -0
  79. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/read_path.py +0 -0
  80. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/render_markdown.py +0 -0
  81. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/run_command.py +0 -0
  82. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/run_control.py +0 -0
  83. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/run_status.py +0 -0
  84. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/__init__.py +0 -0
  85. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/describe_event.py +0 -0
  86. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/execute_run.py +0 -0
  87. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/hold_folder.py +0 -0
  88. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/new_param_run_id.py +0 -0
  89. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/new_run_id.py +0 -0
  90. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  91. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/param_args.py +0 -0
  92. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/param_grid.py +0 -0
  93. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/param_run_name.py +0 -0
  94. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/plan_runs.py +0 -0
  95. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/prepare_experiment.py +0 -0
  96. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/read_experiment_config.py +0 -0
  97. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/run_experiment.py +0 -0
  98. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/run_folder.py +0 -0
  99. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/run_headless.py +0 -0
  100. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/runs_parent.py +0 -0
  101. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/shell_command.py +0 -0
  102. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/signal_tree.py +0 -0
  103. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/stop_then_kill.py +0 -0
  104. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/wait_for_tree.py +0 -0
  105. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/runner/write_run_json.py +0 -0
  106. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/save_conflict_copy.py +0 -0
  107. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/save_note.py +0 -0
  108. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/saved_outputs.py +0 -0
  109. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/serve_app.py +0 -0
  110. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/server.py +0 -0
  111. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/shared_params.py +0 -0
  112. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/sort_href.py +0 -0
  113. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/sql_quote.py +0 -0
  114. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/elapsed.js +0 -0
  115. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/experiment.js +0 -0
  116. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/local-time.js +0 -0
  117. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/new-note.js +0 -0
  118. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/note-edit.js +0 -0
  119. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/note-editor.js +0 -0
  120. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/row-link.js +0 -0
  121. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/run-cell.js +0 -0
  122. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/static/view-toggle.js +0 -0
  123. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/summarize_config.py +0 -0
  124. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/__init__.py +0 -0
  125. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/experiment_rows.py +0 -0
  126. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/experiment_run_ids.py +0 -0
  127. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/file_prefix.py +0 -0
  128. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/file_text.py +0 -0
  129. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/iso_time.py +0 -0
  130. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/list_experiment_runs.py +0 -0
  131. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/note_text.py +0 -0
  132. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/path_glob.py +0 -0
  133. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/path_pattern.py +0 -0
  134. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/read_experiment.py +0 -0
  135. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/read_file.py +0 -0
  136. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/read_log.py +0 -0
  137. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/read_run.py +0 -0
  138. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tables/summarize_experiments.py +0 -0
  139. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/templates/base.html +0 -0
  140. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/templates/editor_imports.html +0 -0
  141. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/templates/experiment.html +0 -0
  142. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/templates/index.html +0 -0
  143. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/templates/note.html +0 -0
  144. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/__init__.py +0 -0
  145. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/browse_app.py +0 -0
  146. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/browse_tui.py +0 -0
  147. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/dim.py +0 -0
  148. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/experiment_screen.py +0 -0
  149. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/experiments_screen.py +0 -0
  150. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/fill_table.py +0 -0
  151. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/format_elapsed.py +0 -0
  152. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/group_cells.py +0 -0
  153. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/invocation_columns.py +0 -0
  154. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/invocation_rows.py +0 -0
  155. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/leave_prompt.py +0 -0
  156. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/reload_on_change.py +0 -0
  157. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/run_cells.py +0 -0
  158. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/run_output.py +0 -0
  159. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/run_tui.py +0 -0
  160. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/runs_table.py +0 -0
  161. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/tui/stopped_title.py +0 -0
  162. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/wait_for_note.py +0 -0
  163. {labnotes-0.0.99 → labnotes-0.0.100}/src/labnotes/watch_rows.py +0 -0
  164. {labnotes-0.0.99 → labnotes-0.0.100}/testing-conventions.toml +0 -0
  165. {labnotes-0.0.99 → labnotes-0.0.100}/tests/integration/conftest.py +0 -0
  166. {labnotes-0.0.99 → labnotes-0.0.100}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.99
3
+ Version: 0.0.100
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -43,8 +43,30 @@ async def run_output(
43
43
  last, mid_line = stream, not text.endswith("\n")
44
44
  yield f"data: {json.dumps({'stream': stream, 'text': text})}\n\n"
45
45
 
46
+ blocks_read = 0
47
+
48
+ def read_blocks() -> Iterator[str]:
49
+ nonlocal blocks_read
50
+ path = folder / "log.jsonl"
51
+ if not path.exists():
52
+ return
53
+ with path.open("rb") as log:
54
+ log.seek(blocks_read)
55
+ data = log.read()
56
+ whole = data[: data.rfind(b"\n") + 1]
57
+ blocks_read += len(whole)
58
+ for line in whole.splitlines():
59
+ try:
60
+ record = json.loads(line)
61
+ except json.JSONDecodeError:
62
+ continue
63
+ if isinstance(record, dict):
64
+ yield f"event: block\ndata: {json.dumps(record)}\n\n"
65
+
46
66
  for message in read(STREAMS):
47
67
  yield message
68
+ for message in read_blocks():
69
+ yield message
48
70
  exit_code = run["exit_code"]
49
71
  if run["ended"] is None:
50
72
  async for event in events:
@@ -54,6 +76,9 @@ async def run_output(
54
76
  if event.table == "logs" and row["run"] == run_id:
55
77
  for message in read([row["stream"]]):
56
78
  yield message
79
+ elif event.table == "blocks" and row["run"] == run_id:
80
+ for message in read_blocks():
81
+ yield message
57
82
  elif (
58
83
  event.table == "runs"
59
84
  and row["id"] == run_id
@@ -63,4 +88,6 @@ async def run_output(
63
88
  break
64
89
  for message in read(STREAMS):
65
90
  yield message
91
+ for message in read_blocks():
92
+ yield message
66
93
  yield f"event: end\ndata: {json.dumps(exit_code)}\n\n"
@@ -302,6 +302,13 @@ h3 {
302
302
  /* Run output */
303
303
  .run-state.good { color: var(--good); }
304
304
  .run-state.bad { color: var(--bad); }
305
+ .run-blocks { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; margin-bottom: 1rem; padding: 0.5rem 0; max-height: 50vh; overflow: auto; }
306
+ .run-blocks[hidden] { display: none; }
307
+ .log-block { display: block; width: 100%; padding: 0.2rem 1.25rem; border: 0; background: none; color: inherit; font: inherit; font-family: var(--mono); font-size: 0.82rem; text-align: left; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
308
+ .log-block::before { content: "▸ "; color: var(--ink-3); }
309
+ .log-block[aria-expanded="true"] { white-space: pre-wrap; overflow-wrap: anywhere; }
310
+ .log-block[aria-expanded="true"]::before { content: "▾ "; }
311
+ .log-block:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
305
312
  .run-log { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; }
306
313
  .run-log header { display: flex; gap: 1rem; padding: 0.4rem 1.25rem; border-bottom: 1px solid var(--rule); font-family: var(--mono); font-size: 0.75rem; color: var(--ink-3); }
307
314
  .run-log header span::before { content: "■ "; }
@@ -4,12 +4,41 @@ const output = document.querySelector(".run-output");
4
4
  const state = document.querySelector(".run-state");
5
5
  const source = new EventSource(output.dataset.url);
6
6
 
7
+ const blocks = document.querySelector(".run-blocks");
8
+
7
9
  let parsers = {};
10
+ let streaming = new Map();
8
11
 
9
12
  // The server resends the whole log on each connection, so a reconnect starts clean.
10
13
  source.addEventListener("open", () => {
11
14
  output.replaceChildren();
12
15
  parsers = {};
16
+ blocks.replaceChildren();
17
+ blocks.hidden = true;
18
+ streaming = new Map();
19
+ });
20
+
21
+ source.addEventListener("block", (event) => {
22
+ const { id, phase, content = "" } = JSON.parse(event.data);
23
+ const open = streaming.get(id);
24
+ if (open && (phase === "delta" || phase === "stop")) {
25
+ open.textContent += content;
26
+ if (phase === "stop") streaming.delete(id);
27
+ return;
28
+ }
29
+ if (phase === "stop") return;
30
+ const line = document.createElement("button");
31
+ line.type = "button";
32
+ line.className = "log-block";
33
+ line.setAttribute("aria-expanded", "false");
34
+ line.textContent = content;
35
+ line.addEventListener("click", () => {
36
+ const expanded = line.getAttribute("aria-expanded") === "true";
37
+ line.setAttribute("aria-expanded", String(!expanded));
38
+ });
39
+ if (phase === "start" || phase === "delta") streaming.set(id, line);
40
+ blocks.append(line);
41
+ blocks.hidden = false;
13
42
  });
14
43
 
15
44
  source.addEventListener("message", (event) => {
@@ -4,6 +4,7 @@ from dirsql import DirSQL, Table
4
4
 
5
5
  from labnotes.models import LabnotesConfig
6
6
  from labnotes.tables.path_glob import path_glob
7
+ from labnotes.tables.read_blocks import read_blocks
7
8
  from labnotes.tables.read_experiment import read_experiment
8
9
  from labnotes.tables.read_file import read_file
9
10
  from labnotes.tables.read_log import read_log
@@ -48,6 +49,12 @@ CREATE TABLE logs (
48
49
  PRIMARY KEY (experiment, run, stream)
49
50
  )
50
51
  """
52
+ BLOCKS = """
53
+ CREATE TABLE blocks (
54
+ experiment TEXT, run TEXT, size INTEGER,
55
+ PRIMARY KEY (experiment, run)
56
+ )
57
+ """
51
58
 
52
59
 
53
60
  def open_tables(config: LabnotesConfig) -> DirSQL:
@@ -73,6 +80,12 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
73
80
  glob=path_glob(f"{config.runs}/std*.log"),
74
81
  on_file=partial(read_log, root, config.runs),
75
82
  ),
83
+ Table(
84
+ name="blocks",
85
+ ddl=BLOCKS,
86
+ glob=path_glob(f"{config.runs}/log.jsonl"),
87
+ on_file=partial(read_blocks, root, config.runs),
88
+ ),
76
89
  Table(
77
90
  name="experiment_configs",
78
91
  ddl=EXPERIMENTS,
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.tables.path_pattern import path_pattern
4
+
5
+
6
+ def read_blocks(root: Path, template: str, path: str) -> list[dict[str, object]]:
7
+ relative = Path(path).relative_to(root).as_posix()
8
+ found = path_pattern(f"{template}/log.jsonl").fullmatch(relative)
9
+ if found is None:
10
+ return []
11
+ return [
12
+ {
13
+ "experiment": found["experiment"],
14
+ "run": found["run"],
15
+ "size": Path(path).stat().st_size,
16
+ }
17
+ ]
@@ -11,6 +11,8 @@
11
11
  </p>
12
12
  </section>
13
13
 
14
+ <section class="run-blocks" hidden></section>
15
+
14
16
  <section class="run-log">
15
17
  <header><span class="stdout">stdout</span><span class="stderr">stderr</span></header>
16
18
  <pre class="run-output" data-url="{{ url_for('output', name=run.experiment, run=run.id) }}"></pre>
@@ -0,0 +1,31 @@
1
+ from rich.text import Text
2
+ from textual.events import Click
3
+ from textual.widgets import Static
4
+
5
+
6
+ class LogBlock(Static):
7
+ DEFAULT_CSS = """
8
+ LogBlock { height: auto; }
9
+ LogBlock.-collapsed { height: 1; text-wrap: nowrap; text-overflow: ellipsis; }
10
+ """
11
+
12
+ def __init__(self, body: str) -> None:
13
+ super().__init__(classes="-collapsed")
14
+ self.body = body
15
+ self.expanded = False
16
+ self.show()
17
+
18
+ def extend(self, text: str) -> None:
19
+ self.body += text
20
+ self.show()
21
+
22
+ def on_click(self, _: Click) -> None:
23
+ self.expanded = not self.expanded
24
+ self.set_class(not self.expanded, "-collapsed")
25
+ self.show()
26
+
27
+ def show(self) -> None:
28
+ if self.expanded:
29
+ self.update(Text(f"▾ {self.body}"))
30
+ else:
31
+ self.update(Text(f"▸ {self.body}".replace("\n", " ")))
@@ -0,0 +1,55 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from textual.containers import VerticalScroll
5
+
6
+ from labnotes.tui.log_block import LogBlock
7
+
8
+ STREAMING = ("start", "delta")
9
+
10
+
11
+ class LogBlocks(VerticalScroll):
12
+ DEFAULT_CSS = "LogBlocks { height: auto; max-height: 50%; }"
13
+
14
+ def __init__(self, id: str | None = None) -> None:
15
+ super().__init__(id=id)
16
+ self.reset()
17
+
18
+ def reset(self) -> None:
19
+ self.read_to = 0
20
+ self.streaming: dict[object, LogBlock] = {}
21
+ self.remove_children()
22
+ self.display = False
23
+
24
+ def follow(self, folder: Path) -> None:
25
+ path = folder / "log.jsonl"
26
+ if not path.exists():
27
+ return
28
+ with path.open("rb") as log:
29
+ log.seek(self.read_to)
30
+ data = log.read()
31
+ complete = data[: data.rfind(b"\n") + 1]
32
+ self.read_to += len(complete)
33
+ for line in complete.splitlines():
34
+ try:
35
+ record = json.loads(line)
36
+ except json.JSONDecodeError:
37
+ continue
38
+ if isinstance(record, dict):
39
+ self.add(record)
40
+
41
+ def add(self, record: dict[str, object]) -> None:
42
+ phase, key = record.get("phase"), record.get("id")
43
+ body = str(record.get("content", ""))
44
+ if phase == "delta" and key in self.streaming:
45
+ self.streaming[key].extend(body)
46
+ return
47
+ if phase == "stop":
48
+ if key in self.streaming:
49
+ self.streaming.pop(key).extend(body)
50
+ return
51
+ block = LogBlock(body)
52
+ if phase in STREAMING:
53
+ self.streaming[key] = block
54
+ self.mount(block)
55
+ self.display = True
@@ -15,6 +15,7 @@ from labnotes.runner.param_args import param_args
15
15
  from labnotes.tui.dim import dim
16
16
  from labnotes.tui.format_elapsed import format_elapsed
17
17
  from labnotes.tui.leave_prompt import LeavePrompt
18
+ from labnotes.tui.log_blocks import LogBlocks
18
19
  from labnotes.tui.run_output import RunOutput
19
20
  from labnotes.tui.runs_table import RunsTable
20
21
 
@@ -83,6 +84,7 @@ class RunApp(App[None]):
83
84
  def compose(self) -> ComposeResult:
84
85
  yield Header()
85
86
  yield RunsTable(id="runs", cursor_type="row")
87
+ yield LogBlocks(id="blocks")
86
88
  yield RunOutput(id="output")
87
89
  yield Input(id="filter", placeholder="Filter params")
88
90
  yield Static(id="status")
@@ -379,6 +381,7 @@ class RunApp(App[None]):
379
381
 
380
382
  def view(self, run: str) -> None:
381
383
  self.viewing = run
384
+ self.query_one(LogBlocks).reset()
382
385
  self.query_one(RunOutput).reset()
383
386
  self.query_one(DataTable).display = False
384
387
  self.query_one(RunOutput).display = True
@@ -389,11 +392,13 @@ class RunApp(App[None]):
389
392
 
390
393
  def follow(self) -> None:
391
394
  if self.viewing in self.folders:
395
+ self.query_one(LogBlocks).follow(self.folders[self.viewing])
392
396
  self.query_one(RunOutput).follow(self.folders[self.viewing])
393
397
 
394
398
  def action_leave(self) -> None:
395
399
  if self.viewing is not None:
396
400
  self.viewing = None
401
+ self.query_one(LogBlocks).display = False
397
402
  self.query_one(RunOutput).display = False
398
403
  self.query_one(DataTable).display = True
399
404
  self.sub_title = ""
@@ -5,6 +5,7 @@ from textual.binding import Binding
5
5
  from textual.screen import Screen
6
6
  from textual.widgets import Footer, Header
7
7
 
8
+ from labnotes.tui.log_blocks import LogBlocks
8
9
  from labnotes.tui.run_output import RunOutput
9
10
 
10
11
 
@@ -18,6 +19,7 @@ class RunScreen(Screen[None]):
18
19
 
19
20
  def compose(self) -> ComposeResult:
20
21
  yield Header()
22
+ yield LogBlocks(id="blocks")
21
23
  yield RunOutput(id="output")
22
24
  yield Footer()
23
25
 
@@ -27,4 +29,5 @@ class RunScreen(Screen[None]):
27
29
  self.set_interval(0.25, self.follow)
28
30
 
29
31
  def follow(self) -> None:
32
+ self.query_one(LogBlocks).follow(self.folder)
30
33
  self.query_one(RunOutput).follow(self.folder)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes