labnotes 0.0.111__tar.gz → 0.0.113__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 (204) hide show
  1. {labnotes-0.0.111 → labnotes-0.0.113}/PKG-INFO +1 -1
  2. labnotes-0.0.113/src/labnotes/__init__.py +4 -0
  3. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/context.md +7 -0
  4. labnotes-0.0.113/src/labnotes/is_non_negative_number.py +10 -0
  5. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/load_experiment.py +2 -0
  6. labnotes-0.0.113/src/labnotes/memory_interval.py +2 -0
  7. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/models.py +2 -0
  8. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/execute_run.py +15 -1
  9. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/read_experiment_config.py +27 -1
  10. labnotes-0.0.113/src/labnotes/runner/record_memory.py +18 -0
  11. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/run_experiment.py +1 -0
  12. labnotes-0.0.113/src/labnotes/runs_view_command.py +4 -0
  13. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/open_tables.py +35 -1
  14. labnotes-0.0.113/src/labnotes/tables/read_memory.py +34 -0
  15. labnotes-0.0.113/src/labnotes/tables/read_run_file.py +21 -0
  16. labnotes-0.0.113/src/labnotes/tui/browse_app.py +39 -0
  17. labnotes-0.0.113/src/labnotes/tui/check_view_response.py +43 -0
  18. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/experiment_screen.py +56 -4
  19. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/experiments_screen.py +10 -2
  20. labnotes-0.0.113/src/labnotes/tui/format_cell.py +27 -0
  21. labnotes-0.0.113/src/labnotes/tui/group_cells.py +36 -0
  22. labnotes-0.0.113/src/labnotes/tui/invocation_columns.py +9 -0
  23. labnotes-0.0.113/src/labnotes/tui/invocation_rows.py +32 -0
  24. labnotes-0.0.113/src/labnotes/tui/native_rows.py +24 -0
  25. labnotes-0.0.113/src/labnotes/tui/run_cells.py +22 -0
  26. labnotes-0.0.113/src/labnotes/tui/runs_view.py +81 -0
  27. labnotes-0.0.113/src/labnotes/tui/view_column.py +15 -0
  28. labnotes-0.0.113/src/labnotes/tui/view_process.py +75 -0
  29. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/utils/process_alive.py +1 -1
  30. labnotes-0.0.113/src/labnotes/utils/tree_rss.py +6 -0
  31. labnotes-0.0.111/src/labnotes/utils/tree_rss.py → labnotes-0.0.113/src/labnotes/utils/tree_usage.py +2 -2
  32. labnotes-0.0.113/src/labnotes/view/__init__.py +3 -0
  33. labnotes-0.0.113/src/labnotes/view/answer.py +11 -0
  34. labnotes-0.0.113/src/labnotes/view/build.py +5 -0
  35. labnotes-0.0.113/src/labnotes/view/respond.py +13 -0
  36. labnotes-0.0.113/src/labnotes/view/serve.py +21 -0
  37. {labnotes-0.0.111 → labnotes-0.0.113}/testing-conventions.toml +12 -0
  38. labnotes-0.0.111/src/labnotes/__init__.py +0 -3
  39. labnotes-0.0.111/src/labnotes/tui/browse_app.py +0 -25
  40. labnotes-0.0.111/src/labnotes/tui/group_cells.py +0 -27
  41. labnotes-0.0.111/src/labnotes/tui/invocation_columns.py +0 -9
  42. labnotes-0.0.111/src/labnotes/tui/invocation_rows.py +0 -32
  43. labnotes-0.0.111/src/labnotes/tui/run_cells.py +0 -29
  44. {labnotes-0.0.111 → labnotes-0.0.113}/.gitignore +0 -0
  45. {labnotes-0.0.111 → labnotes-0.0.113}/CHANGELOG.md +0 -0
  46. {labnotes-0.0.111 → labnotes-0.0.113}/MIGRATIONS.md +0 -0
  47. {labnotes-0.0.111 → labnotes-0.0.113}/README.md +0 -0
  48. {labnotes-0.0.111 → labnotes-0.0.113}/pyproject.toml +0 -0
  49. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/__main__.py +0 -0
  50. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/asset_url.py +0 -0
  51. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/check_counts.py +0 -0
  52. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/cli.py +0 -0
  53. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/compose_experiment.py +0 -0
  54. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/context_guide.py +0 -0
  55. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/count_groups.py +0 -0
  56. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/count_runs.py +0 -0
  57. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/create_app.py +0 -0
  58. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/create_experiment.py +0 -0
  59. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/create_note.py +0 -0
  60. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/__init__.py +0 -0
  61. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/control_run.py +0 -0
  62. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/daemon_app.py +0 -0
  63. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/daemon_client.py +0 -0
  64. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/daemon_running.py +0 -0
  65. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/daemon_server.py +0 -0
  66. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/describe_status.py +0 -0
  67. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/ensure_daemon.py +0 -0
  68. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/exit_when_idle.py +0 -0
  69. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/finalize_lost_runs.py +0 -0
  70. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/remote_start.py +0 -0
  71. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/run_daemon.py +0 -0
  72. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/run_logs.py +0 -0
  73. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/serve_daemon.py +0 -0
  74. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/socket_path.py +0 -0
  75. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/stop_runs.py +0 -0
  76. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/watch_daemon.py +0 -0
  77. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon/watch_status.py +0 -0
  78. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/daemon_command.py +0 -0
  79. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/data_html.py +0 -0
  80. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/default_config_text.py +0 -0
  81. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/default_counts.py +0 -0
  82. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/drain_watch.py +0 -0
  83. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/experiment_notes.py +0 -0
  84. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/experiment_runs.py +0 -0
  85. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/experiments.py +0 -0
  86. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/file_changes.py +0 -0
  87. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/format_duration.py +0 -0
  88. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/format_started.py +0 -0
  89. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/format_value.py +0 -0
  90. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/group_runs.py +0 -0
  91. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/init.py +0 -0
  92. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/invocation_started.py +0 -0
  93. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/is_non_negative_int.py +0 -0
  94. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/is_positive_int.py +0 -0
  95. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/list_experiments.py +0 -0
  96. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/list_runs.py +0 -0
  97. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/load_config.py +0 -0
  98. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/load_run.py +0 -0
  99. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/log.py +0 -0
  100. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/log_path.py +0 -0
  101. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/modes.py +0 -0
  102. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/nests_runs.py +0 -0
  103. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/note_path.py +0 -0
  104. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/notebook_kernel.py +0 -0
  105. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/output_html.py +0 -0
  106. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/parse_run_view.py +0 -0
  107. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/print_rows.py +0 -0
  108. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/python_blocks.py +0 -0
  109. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/read_config.py +0 -0
  110. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/read_path.py +0 -0
  111. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/render_markdown.py +0 -0
  112. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/retries.py +0 -0
  113. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/run_command.py +0 -0
  114. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/run_control.py +0 -0
  115. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/run_output.py +0 -0
  116. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/run_status.py +0 -0
  117. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/__init__.py +0 -0
  118. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/describe_event.py +0 -0
  119. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/exit_signal.py +0 -0
  120. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/hold_folder.py +0 -0
  121. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/invocation_meta.py +0 -0
  122. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/new_param_run_id.py +0 -0
  123. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/new_run_id.py +0 -0
  124. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  125. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/param_args.py +0 -0
  126. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/param_grid.py +0 -0
  127. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/param_run_name.py +0 -0
  128. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/plan_runs.py +0 -0
  129. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/prepare_experiment.py +0 -0
  130. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/run_folder.py +0 -0
  131. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/run_headless.py +0 -0
  132. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/runs_parent.py +0 -0
  133. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/shell_command.py +0 -0
  134. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/signal_tree.py +0 -0
  135. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/stop_then_kill.py +0 -0
  136. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/wait_for_tree.py +0 -0
  137. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/runner/write_run_json.py +0 -0
  138. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/save_conflict_copy.py +0 -0
  139. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/save_note.py +0 -0
  140. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/saved_outputs.py +0 -0
  141. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/serve_app.py +0 -0
  142. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/server.py +0 -0
  143. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/shared_params.py +0 -0
  144. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/sort_href.py +0 -0
  145. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/sql_quote.py +0 -0
  146. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/elapsed.js +0 -0
  147. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/experiment.js +0 -0
  148. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/labnotes.css +0 -0
  149. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/local-time.js +0 -0
  150. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/new-note.js +0 -0
  151. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/note-edit.js +0 -0
  152. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/note-editor.js +0 -0
  153. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/row-link.js +0 -0
  154. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/run-cell.js +0 -0
  155. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/run-output.js +0 -0
  156. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/static/view-toggle.js +0 -0
  157. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/summarize_config.py +0 -0
  158. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/__init__.py +0 -0
  159. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/experiment_rows.py +0 -0
  160. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/experiment_run_ids.py +0 -0
  161. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/experiment_toml.py +0 -0
  162. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/experiments_ddl.py +0 -0
  163. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/file_prefix.py +0 -0
  164. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/file_text.py +0 -0
  165. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/iso_time.py +0 -0
  166. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/list_experiment_runs.py +0 -0
  167. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/note_text.py +0 -0
  168. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/path_glob.py +0 -0
  169. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/path_pattern.py +0 -0
  170. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/read_blocks.py +0 -0
  171. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/read_experiment.py +0 -0
  172. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/read_file.py +0 -0
  173. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/read_log.py +0 -0
  174. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/read_run.py +0 -0
  175. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tables/summarize_experiments.py +0 -0
  176. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/templates/base.html +0 -0
  177. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/templates/editor_imports.html +0 -0
  178. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/templates/experiment.html +0 -0
  179. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/templates/index.html +0 -0
  180. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/templates/note.html +0 -0
  181. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/templates/run.html +0 -0
  182. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/__init__.py +0 -0
  183. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/browse_tui.py +0 -0
  184. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/config.py +0 -0
  185. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/dim.py +0 -0
  186. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/fill_table.py +0 -0
  187. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/format_elapsed.py +0 -0
  188. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/leave_prompt.py +0 -0
  189. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/log_block.py +0 -0
  190. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/log_blocks.py +0 -0
  191. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/reload_on_change.py +0 -0
  192. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/run_app.py +0 -0
  193. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/run_output.py +0 -0
  194. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/run_screen.py +0 -0
  195. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/run_tui.py +0 -0
  196. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/runs_table.py +0 -0
  197. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/tui/stopped_title.py +0 -0
  198. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/utils/__init__.py +0 -0
  199. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/utils/format_bytes.py +0 -0
  200. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/utils/run_usage.py +0 -0
  201. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/wait_for_note.py +0 -0
  202. {labnotes-0.0.111 → labnotes-0.0.113}/src/labnotes/watch_rows.py +0 -0
  203. {labnotes-0.0.111 → labnotes-0.0.113}/tests/integration/conftest.py +0 -0
  204. {labnotes-0.0.111 → labnotes-0.0.113}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.111
3
+ Version: 0.0.113
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>
@@ -0,0 +1,4 @@
1
+ from labnotes import view
2
+ from labnotes.log import log
3
+
4
+ __all__ = ["log", "view"]
@@ -123,6 +123,13 @@ shows a waiting retry as `retry in 30s` and the end as `gave up (exit 75)`;
123
123
 
124
124
  Runs can take a long time. Start `run` in the background, then check on it with `labnotes experiments foo runs --format json`; a run with no `ended` is still going. Each run lists `size`, the bytes in its run folder, and a running run lists `memory`, the resident bytes of its command and everything it started.
125
125
 
126
+ While a run goes, labnotes appends a memory sample to `memory.jsonl` in its run
127
+ folder every 5 seconds: one JSON line, `{"time": <unix seconds>, "rss": <bytes>,
128
+ "processes": <count>}`, for the command and everything it started. It keeps
129
+ every sample, so a run killed for memory still shows the climb.
130
+ `memory_interval` in `experiment.toml` (or `[experiment]` in `.labnotes.toml`)
131
+ sets the seconds between samples; 0 turns it off.
132
+
126
133
  In a terminal, `run` shows a live table of runs (status, time, memory and
127
134
  folder size) and stays
128
135
  open when they finish. Failed runs show their status in red; `f` hides or shows them, and `r` on one
@@ -0,0 +1,10 @@
1
+ import math
2
+
3
+
4
+ def is_non_negative_number(value: object) -> bool:
5
+ return (
6
+ isinstance(value, int | float)
7
+ and not isinstance(value, bool)
8
+ and math.isfinite(value)
9
+ and value >= 0
10
+ )
@@ -19,6 +19,7 @@ from labnotes.nests_runs import nests_runs
19
19
  from labnotes.parse_run_view import parse_run_view
20
20
  from labnotes.python_blocks import python_blocks
21
21
  from labnotes.render_markdown import render_markdown
22
+ from labnotes.runs_view_command import runs_view_command
22
23
  from labnotes.sort_href import sort_href
23
24
  from labnotes.sql_quote import quote_string
24
25
  from labnotes.summarize_config import summarize_config
@@ -88,6 +89,7 @@ async def load_experiment(
88
89
  "run_columns": shown,
89
90
  "run_view": view,
90
91
  "run_choices": choices,
92
+ "runs_view": runs_view_command(config),
91
93
  "sort_hrefs": {
92
94
  column: sort_href(view, column) for column in ["status", *shown]
93
95
  },
@@ -0,0 +1,2 @@
1
+ # Seconds between memory samples, unless experiment.toml sets `memory_interval`.
2
+ DEFAULT_MEMORY_INTERVAL = 5.0
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Any, Protocol
5
5
 
6
6
  from labnotes.default_counts import DEFAULT_COUNTS
7
+ from labnotes.memory_interval import DEFAULT_MEMORY_INTERVAL
7
8
  from labnotes.modes import DEFAULT_MODE
8
9
  from labnotes.retries import DEFAULT_RETRIES
9
10
 
@@ -66,3 +67,4 @@ class ExperimentConfig:
66
67
  concurrency: int = 1
67
68
  retries: int = DEFAULT_RETRIES
68
69
  meta: str | None = None
70
+ memory_interval: float = DEFAULT_MEMORY_INTERVAL
@@ -9,6 +9,7 @@ import psutil
9
9
 
10
10
  from labnotes.log_path import LOG_ENV
11
11
  from labnotes.runner.exit_signal import exit_signal
12
+ from labnotes.runner.record_memory import record_memory
12
13
  from labnotes.runner.shell_command import shell_command
13
14
  from labnotes.runner.stop_then_kill import GRACE_SECONDS
14
15
  from labnotes.runner.wait_for_tree import wait_for_tree
@@ -30,6 +31,8 @@ async def execute_run(
30
31
  stop: Callable[[], None] = lambda: None,
31
32
  stopped: Callable[[], bool] = lambda: False,
32
33
  meta: dict[str, object] | None = None,
34
+ *,
35
+ memory_interval: float,
33
36
  ) -> dict[str, object]:
34
37
  resolved = shell_command(command, folder, params)
35
38
  run: dict[str, object] = {
@@ -69,9 +72,17 @@ async def execute_run(
69
72
  else:
70
73
  running.add(process)
71
74
  run["pid"] = process.pid
75
+ created = None
72
76
  with contextlib.suppress(psutil.Error):
73
- run["pid_created"] = psutil.Process(process.pid).create_time()
77
+ run["pid_created"] = created = psutil.Process(process.pid).create_time()
74
78
  write_run_json(folder, run)
79
+ sampler = None
80
+ if memory_interval > 0 and created is not None:
81
+ sampler = asyncio.create_task(
82
+ record_memory(
83
+ folder / "memory.jsonl", process.pid, created, memory_interval
84
+ )
85
+ )
75
86
  exited = asyncio.ensure_future(process.wait())
76
87
  kill_later = None
77
88
  try:
@@ -100,6 +111,9 @@ async def execute_run(
100
111
  finally:
101
112
  if kill_later is not None:
102
113
  kill_later.cancel()
114
+ if sampler is not None:
115
+ sampler.cancel()
116
+ await asyncio.wait({sampler})
103
117
  running.discard(process)
104
118
  run["ended"] = iso_time(time.time())
105
119
  write_run_json(folder, run)
@@ -7,6 +7,8 @@ from labnotes.compose_experiment import compose_experiment
7
7
  from labnotes.default_counts import DEFAULT_COUNTS
8
8
  from labnotes.models import ExperimentConfig
9
9
  from labnotes.is_non_negative_int import is_non_negative_int
10
+ from labnotes.is_non_negative_number import is_non_negative_number
11
+ from labnotes.memory_interval import DEFAULT_MEMORY_INTERVAL
10
12
  from labnotes.is_positive_int import is_positive_int
11
13
  from labnotes.modes import MODES
12
14
 
@@ -20,7 +22,10 @@ SETTINGS = {
20
22
  "concurrency",
21
23
  "retries",
22
24
  "meta",
25
+ "memory_interval",
26
+ "views",
23
27
  }
28
+ VIEWS = {"runs"}
24
29
 
25
30
 
26
31
  def read_experiment_config(
@@ -73,6 +78,27 @@ def read_experiment_config(
73
78
  meta = values.get("meta")
74
79
  if meta is not None and (not isinstance(meta, str) or not meta.strip()):
75
80
  raise ValueError(f"{path}: meta must be a string")
81
+ memory_interval = values.get("memory_interval", DEFAULT_MEMORY_INTERVAL)
82
+ if not is_non_negative_number(memory_interval):
83
+ raise ValueError(f"{path}: memory_interval must be 0 or more seconds")
84
+ views = values.get("views", {})
85
+ if not isinstance(views, dict):
86
+ raise ValueError(f"{path}: views must be a table, like [views]")
87
+ unknown = sorted(views.keys() - VIEWS)
88
+ if unknown:
89
+ raise ValueError(f"{path}: unknown keys in views: {', '.join(unknown)}")
90
+ runs = views.get("runs")
91
+ if runs is not None and (not isinstance(runs, str) or not runs.strip()):
92
+ raise ValueError(f"{path}: views.runs must be a command")
76
93
  return ExperimentConfig(
77
- command, repeats, params, name, counts, mode, concurrency, retries, meta
94
+ command,
95
+ repeats,
96
+ params,
97
+ name,
98
+ counts,
99
+ mode,
100
+ concurrency,
101
+ retries,
102
+ meta,
103
+ memory_interval,
78
104
  )
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from labnotes.utils.tree_usage import tree_usage
7
+
8
+
9
+ async def record_memory(path: Path, pid: int, created: float, interval: float) -> None:
10
+ with path.open("a", encoding="utf-8") as samples:
11
+ while True:
12
+ usage = tree_usage(pid, created)
13
+ if usage is not None:
14
+ rss, processes = usage
15
+ sample = {"time": time.time(), "rss": rss, "processes": processes}
16
+ samples.write(json.dumps(sample) + "\n")
17
+ samples.flush()
18
+ await asyncio.sleep(interval)
@@ -261,6 +261,7 @@ async def run_experiment(
261
261
  stop=lambda: kill(run),
262
262
  stopped=lambda: run in stopped,
263
263
  meta=meta,
264
+ memory_interval=experiment.memory_interval,
264
265
  )
265
266
  del running[run], trees[run]
266
267
  report({"event": "ended", "run": run, **result})
@@ -0,0 +1,4 @@
1
+ def runs_view_command(config: dict[str, object] | None) -> str | None:
2
+ views = (config or {}).get("views")
3
+ command = views.get("runs") if isinstance(views, dict) else None
4
+ return command if isinstance(command, str) and command.strip() else None
@@ -10,7 +10,9 @@ from labnotes.tables.read_blocks import read_blocks
10
10
  from labnotes.tables.read_experiment import read_experiment
11
11
  from labnotes.tables.read_file import read_file
12
12
  from labnotes.tables.read_log import read_log
13
+ from labnotes.tables.read_memory import read_memory
13
14
  from labnotes.tables.read_run import read_run
15
+ from labnotes.tables.read_run_file import read_run_file
14
16
 
15
17
  FILES = """
16
18
  CREATE TABLE files (
@@ -39,10 +41,33 @@ CREATE TABLE blocks (
39
41
  PRIMARY KEY (experiment, run)
40
42
  )
41
43
  """
44
+ MEMORY = """
45
+ CREATE TABLE memory (
46
+ experiment TEXT, run TEXT, line INTEGER, time REAL, rss INTEGER, processes INTEGER,
47
+ PRIMARY KEY (experiment, run, line)
48
+ )
49
+ """
50
+
51
+ # Every file in a run folder, so a change anywhere in it is a watch event.
52
+ RUN_FILES = """
53
+ CREATE TABLE run_files (
54
+ experiment TEXT, path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER
55
+ )
56
+ """
42
57
 
43
58
 
44
- def open_tables(config: LabnotesConfig) -> DirSQL:
59
+ def open_tables(config: LabnotesConfig, run_files: bool = False) -> DirSQL:
45
60
  root = config.root
61
+ extra = [
62
+ Table(
63
+ name="run_files",
64
+ ddl=RUN_FILES,
65
+ glob=path_glob(f"{config.runs}/**"),
66
+ on_file=partial(
67
+ read_run_file, root, config.runs, experiment=config.experiment
68
+ ),
69
+ )
70
+ ]
46
71
  return DirSQL(
47
72
  str(root),
48
73
  tables=[
@@ -78,6 +103,14 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
78
103
  read_blocks, root, config.runs, experiment=config.experiment
79
104
  ),
80
105
  ),
106
+ Table(
107
+ name="memory",
108
+ ddl=MEMORY,
109
+ glob=path_glob(f"{config.runs}/memory.jsonl"),
110
+ on_file=partial(
111
+ read_memory, root, config.runs, experiment=config.experiment
112
+ ),
113
+ ),
81
114
  Table(
82
115
  name="experiment_configs",
83
116
  ddl=experiments_ddl(config),
@@ -90,6 +123,7 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
90
123
  defaults=config.defaults,
91
124
  ),
92
125
  ),
126
+ *(extra if run_files else []),
93
127
  ],
94
128
  # Run folders are often gitignored (DVC, large outputs); scan them anyway.
95
129
  no_ignore=True,
@@ -0,0 +1,34 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from labnotes.tables.path_pattern import path_pattern
5
+
6
+
7
+ def read_memory(
8
+ root: Path, template: str, path: str, experiment: str | None = None
9
+ ) -> list[dict[str, object]]:
10
+ relative = Path(path).relative_to(root).as_posix()
11
+ found = path_pattern(f"{template}/memory.jsonl").fullmatch(relative)
12
+ if found is None:
13
+ return []
14
+ rows: list[dict[str, object]] = []
15
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
16
+ for line, text in enumerate(lines):
17
+ # A run killed mid-write leaves a cut-off last line.
18
+ try:
19
+ sample = json.loads(text)
20
+ except json.JSONDecodeError:
21
+ continue
22
+ if not isinstance(sample, dict):
23
+ continue
24
+ rows.append(
25
+ {
26
+ "experiment": experiment or found["experiment"],
27
+ "run": found["run"],
28
+ "line": line,
29
+ "time": sample.get("time"),
30
+ "rss": sample.get("rss"),
31
+ "processes": sample.get("processes"),
32
+ }
33
+ )
34
+ return rows
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.tables.path_pattern import path_pattern
4
+
5
+
6
+ def read_run_file(
7
+ root: Path, template: str, path: str, experiment: str | None = None
8
+ ) -> list[dict[str, object]]:
9
+ relative = Path(path).relative_to(root).as_posix()
10
+ found = path_pattern(f"{template}/**").fullmatch(relative)
11
+ if found is None:
12
+ return []
13
+ stat = Path(path).stat()
14
+ return [
15
+ {
16
+ "experiment": experiment or found["experiment"],
17
+ "path": relative,
18
+ "size": stat.st_size,
19
+ "mtime_ns": stat.st_mtime_ns,
20
+ }
21
+ ]
@@ -0,0 +1,39 @@
1
+ from textual.app import App
2
+ from textual.binding import Binding
3
+
4
+ from labnotes.models import LabnotesConfig
5
+ from labnotes.tables.open_tables import open_tables
6
+ from labnotes.tui.experiment_screen import ExperimentScreen
7
+ from labnotes.tui.experiments_screen import ExperimentsScreen
8
+ from labnotes.tui.runs_view import Request
9
+ from labnotes.tui.view_process import ViewProcess
10
+
11
+
12
+ class BrowseApp(App[BaseException | None]):
13
+ BINDINGS = [Binding("ctrl+c", "quit", "Quit")]
14
+
15
+ def __init__(self, config: LabnotesConfig, name: str | None) -> None:
16
+ super().__init__()
17
+ self.config = config
18
+ self.experiment_name = name
19
+ self.title = config.root.name
20
+ self.views: dict[str, ViewProcess] = {}
21
+
22
+ def on_mount(self) -> None:
23
+ tables = open_tables(self.config, run_files=True)
24
+ self.push_screen(ExperimentsScreen(tables, self.config, self.view_request))
25
+ if self.experiment_name is not None:
26
+ self.push_screen(
27
+ ExperimentScreen(
28
+ tables, self.config, self.experiment_name, self.view_request
29
+ )
30
+ )
31
+
32
+ def view_request(self, command: str) -> Request:
33
+ if command not in self.views:
34
+ self.views[command] = ViewProcess(command, self.config.root)
35
+ return self.views[command].request
36
+
37
+ async def on_unmount(self) -> None:
38
+ for process in self.views.values():
39
+ await process.close()
@@ -0,0 +1,43 @@
1
+ import json
2
+ from collections.abc import Mapping
3
+ from typing import Any
4
+
5
+ from labnotes.tui.view_column import view_column
6
+
7
+ SHAPE = "the response must be an object with columns and rows"
8
+
9
+
10
+ def check_view_response(
11
+ reply: str, request: Mapping[str, Any]
12
+ ) -> tuple[list[dict[str, str]], dict[str, dict[str, Any]]]:
13
+ try:
14
+ response = json.loads(reply)
15
+ except json.JSONDecodeError as error:
16
+ raise ValueError(f"invalid JSON: {error}") from error
17
+ if not isinstance(response, dict) or response.keys() != {"columns", "rows"}:
18
+ raise ValueError(SHAPE)
19
+ if not isinstance(response["columns"], list):
20
+ raise ValueError("columns must be a list")
21
+ columns = [view_column(entry) for entry in response["columns"]]
22
+ keys = set()
23
+ for each in columns:
24
+ if each["key"] in keys:
25
+ raise ValueError(f"column {each['key']} is listed twice")
26
+ keys.add(each["key"])
27
+ rows = response["rows"]
28
+ if not isinstance(rows, dict):
29
+ raise ValueError("rows must be an object")
30
+ asked = request["rows"]
31
+ for folder in asked:
32
+ if folder not in rows:
33
+ raise ValueError(f"no row for {folder}")
34
+ for folder, row in rows.items():
35
+ if folder not in asked:
36
+ raise ValueError(f"a row for {folder}, which wasn't asked for")
37
+ if not isinstance(row, dict):
38
+ raise ValueError(f"{folder}: the row must be an object")
39
+ # Native values may ride along unshown, so {**row, ...} needs no filtering.
40
+ stray = sorted(row.keys() - keys - asked[folder].keys())
41
+ if stray:
42
+ raise ValueError(f"{folder}: unknown columns {', '.join(stray)}")
43
+ return columns, rows
@@ -1,3 +1,4 @@
1
+ from collections.abc import Callable
1
2
  from typing import Any
2
3
 
3
4
  from dirsql import DirSQL
@@ -5,7 +6,7 @@ from starlette.datastructures import QueryParams
5
6
  from textual.app import ComposeResult
6
7
  from textual.binding import Binding
7
8
  from textual.screen import Screen
8
- from textual.widgets import DataTable, Footer, Header
9
+ from textual.widgets import DataTable, Footer, Header, Static
9
10
  from textual.worker import Worker, WorkerState
10
11
 
11
12
  from labnotes.load_experiment import load_experiment
@@ -13,9 +14,11 @@ from labnotes.models import LabnotesConfig
13
14
  from labnotes.tui.fill_table import fill_table
14
15
  from labnotes.tui.invocation_columns import invocation_columns
15
16
  from labnotes.tui.invocation_rows import invocation_rows
17
+ from labnotes.tui.native_rows import native_rows
16
18
  from labnotes.tui.reload_on_change import reload_on_change
17
19
  from labnotes.tui.run_screen import RunScreen
18
20
  from labnotes.tui.runs_table import RunsTable
21
+ from labnotes.tui.runs_view import Request, RunsView
19
22
  from labnotes.tui.stopped_title import stopped_title
20
23
  from labnotes.watch_rows import watch_rows
21
24
 
@@ -23,22 +26,35 @@ WATCHED = {"runs", "experiment_configs"}
23
26
 
24
27
 
25
28
  class ExperimentScreen(Screen[None]):
29
+ DEFAULT_CSS = """
30
+ ExperimentScreen RunsTable { height: 1fr; }
31
+ ExperimentScreen #view-status { color: $text-warning; }
32
+ """
26
33
  BINDINGS = [
27
34
  Binding("enter", "open_row", "Open", priority=True),
28
35
  Binding("escape", "app.pop_screen", "Back"),
29
36
  ]
30
37
 
31
- def __init__(self, tables: DirSQL, config: LabnotesConfig, name: str) -> None:
38
+ def __init__(
39
+ self,
40
+ tables: DirSQL,
41
+ config: LabnotesConfig,
42
+ name: str,
43
+ views: Callable[[str], Request],
44
+ ) -> None:
32
45
  super().__init__()
33
46
  self.tables = tables
34
47
  self.config = config
35
48
  self.experiment_name = name
49
+ self.views = views
50
+ self.view: RunsView | None = None
36
51
  self.experiment: dict[str, Any] = {}
37
52
  self.opened: set[str] = set()
38
53
 
39
54
  def compose(self) -> ComposeResult:
40
55
  yield Header()
41
56
  yield RunsTable(id="runs", cursor_type="row")
57
+ yield Static(id="view-status")
42
58
  yield Footer()
43
59
 
44
60
  def on_mount(self) -> None:
@@ -75,14 +91,50 @@ class ExperimentScreen(Screen[None]):
75
91
  return
76
92
  self.experiment = loaded
77
93
  self.sub_title = str(loaded["display_name"])
94
+ command = loaded["runs_view"]
95
+ if command is not None and self.view is None:
96
+ self.view = RunsView(self.views(str(command)))
97
+ self.run_worker(self.watch_files(self.view), exit_on_error=False)
98
+ if self.view is not None:
99
+ self.view.update(invocation_columns(loaded), native_rows(loaded))
100
+ self.ask(self.view)
78
101
  self.redraw()
79
102
 
103
+ async def watch_files(self, view: RunsView) -> None:
104
+ async for row in watch_rows(self.tables, {"run_files"}, self.experiment_name):
105
+ view.touch(str(row["path"]))
106
+ self.ask(view)
107
+
108
+ def ask(self, view: RunsView) -> None:
109
+ request = view.next_request()
110
+ if request is not None:
111
+ self.run_worker(self.answer(view, request), exit_on_error=False)
112
+
113
+ async def answer(self, view: RunsView, request: dict[str, Any]) -> None:
114
+ if await view.exchange(request):
115
+ self.redraw()
116
+ else:
117
+ self.show_status()
118
+ self.ask(view)
119
+
80
120
  def redraw(self) -> None:
121
+ if self.view is None:
122
+ columns = invocation_columns(self.experiment)
123
+ rows = native_rows(self.experiment)
124
+ else:
125
+ columns, rows = self.view.table()
81
126
  fill_table(
82
127
  self.query_one(DataTable),
83
- invocation_columns(self.experiment),
84
- invocation_rows(self.experiment, self.opened),
128
+ [column["label"] for column in columns],
129
+ invocation_rows(self.experiment, self.opened, columns, rows),
85
130
  )
131
+ self.show_status()
132
+
133
+ def show_status(self) -> None:
134
+ text = "" if self.view is None else self.view.status()
135
+ status = self.query_one("#view-status", Static)
136
+ status.update(text)
137
+ status.display = bool(text)
86
138
 
87
139
  def action_open_row(self) -> None:
88
140
  table = self.query_one(DataTable)
@@ -1,3 +1,5 @@
1
+ from collections.abc import Callable
2
+
1
3
  from dirsql import DirSQL
2
4
  from textual.app import ComposeResult
3
5
  from textual.screen import Screen
@@ -10,6 +12,7 @@ from labnotes.tui.experiment_screen import ExperimentScreen
10
12
  from labnotes.tui.fill_table import fill_table
11
13
  from labnotes.tui.reload_on_change import reload_on_change
12
14
  from labnotes.tui.runs_table import RunsTable
15
+ from labnotes.tui.runs_view import Request
13
16
  from labnotes.tui.stopped_title import stopped_title
14
17
  from labnotes.watch_rows import watch_rows
15
18
 
@@ -17,10 +20,13 @@ WATCHED = {"files", "runs", "experiment_configs"}
17
20
 
18
21
 
19
22
  class ExperimentsScreen(Screen[None]):
20
- def __init__(self, tables: DirSQL, config: LabnotesConfig) -> None:
23
+ def __init__(
24
+ self, tables: DirSQL, config: LabnotesConfig, views: Callable[[str], Request]
25
+ ) -> None:
21
26
  super().__init__()
22
27
  self.tables = tables
23
28
  self.config = config
29
+ self.views = views
24
30
 
25
31
  def compose(self) -> ComposeResult:
26
32
  yield Header()
@@ -57,5 +63,7 @@ class ExperimentsScreen(Screen[None]):
57
63
 
58
64
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
59
65
  self.app.push_screen(
60
- ExperimentScreen(self.tables, self.config, str(event.row_key.value))
66
+ ExperimentScreen(
67
+ self.tables, self.config, str(event.row_key.value), self.views
68
+ )
61
69
  )
@@ -0,0 +1,27 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from rich.text import Text
4
+
5
+ from labnotes.format_value import format_value
6
+ from labnotes.tui.dim import dim
7
+ from labnotes.tui.format_elapsed import format_elapsed
8
+
9
+
10
+ def format_cell(key: str, value: object, live: bool) -> object:
11
+ if key == "status" and value == "passed":
12
+ return Text("✓", style="green")
13
+ if key == "status" and value == "failed":
14
+ return Text("✕", style="red")
15
+ if key == "status" and value == "unfinished":
16
+ return Text("◐", style="yellow") if live else dim("–")
17
+ if key == "duration" and isinstance(value, int) and not isinstance(value, bool):
18
+ return format_elapsed(value)
19
+ if key == "started" and isinstance(value, str):
20
+ try:
21
+ started = datetime.fromisoformat(value)
22
+ except ValueError:
23
+ return value
24
+ if started.tzinfo is not None:
25
+ started = started.astimezone(UTC)
26
+ return started.strftime("%Y-%m-%d %H:%M:%S")
27
+ return format_value(value)
@@ -0,0 +1,36 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from typing import Any
3
+
4
+ from rich.text import Text
5
+
6
+ from labnotes.format_value import format_value
7
+
8
+
9
+ def group_cells(
10
+ group: Mapping[str, Any],
11
+ columns: Sequence[Mapping[str, str]],
12
+ live: bool,
13
+ opened: bool,
14
+ ) -> list[object]:
15
+ cells: list[object] = []
16
+ for column in columns:
17
+ key = column["key"]
18
+ if key == "status":
19
+ status = Text.assemble(
20
+ (f"{group['passed']}✓", "green"), " ", (f"{group['failed']}✕", "red")
21
+ )
22
+ if group["unfinished"]:
23
+ mark, style = ("◐", "yellow") if live else ("–", "dim")
24
+ status.append(f" {group['unfinished']}{mark}", style=style)
25
+ cells.append(status)
26
+ elif key == "id":
27
+ cells.append(f"{'▾' if opened else '▸'} {group['name']}")
28
+ elif key == "runs":
29
+ cells.append(str(len(group["runs"])))
30
+ elif key == "started":
31
+ cells.append(group["started"] or "")
32
+ elif key in group["shared"]:
33
+ cells.append(format_value(group["shared"][key]))
34
+ else:
35
+ cells.append("")
36
+ return cells
@@ -0,0 +1,9 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+
5
+ def invocation_columns(experiment: Mapping[str, Any]) -> list[dict[str, str]]:
6
+ keys = list(experiment["run_columns"])
7
+ if experiment["run_groups"] is not None:
8
+ keys.insert(1, "runs")
9
+ return [{"key": "status", "label": ""}, *({"key": k, "label": k} for k in keys)]