labnotes 0.0.112__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.112 → labnotes-0.0.113}/PKG-INFO +1 -1
  2. labnotes-0.0.113/src/labnotes/__init__.py +4 -0
  3. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/load_experiment.py +2 -0
  4. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/read_experiment_config.py +11 -0
  5. labnotes-0.0.113/src/labnotes/runs_view_command.py +4 -0
  6. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/open_tables.py +20 -1
  7. labnotes-0.0.113/src/labnotes/tables/read_run_file.py +21 -0
  8. labnotes-0.0.113/src/labnotes/tui/browse_app.py +39 -0
  9. labnotes-0.0.113/src/labnotes/tui/check_view_response.py +43 -0
  10. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/experiment_screen.py +56 -4
  11. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/experiments_screen.py +10 -2
  12. labnotes-0.0.113/src/labnotes/tui/format_cell.py +27 -0
  13. labnotes-0.0.113/src/labnotes/tui/group_cells.py +36 -0
  14. labnotes-0.0.113/src/labnotes/tui/invocation_columns.py +9 -0
  15. labnotes-0.0.113/src/labnotes/tui/invocation_rows.py +32 -0
  16. labnotes-0.0.113/src/labnotes/tui/native_rows.py +24 -0
  17. labnotes-0.0.113/src/labnotes/tui/run_cells.py +22 -0
  18. labnotes-0.0.113/src/labnotes/tui/runs_view.py +81 -0
  19. labnotes-0.0.113/src/labnotes/tui/view_column.py +15 -0
  20. labnotes-0.0.113/src/labnotes/tui/view_process.py +75 -0
  21. labnotes-0.0.113/src/labnotes/view/__init__.py +3 -0
  22. labnotes-0.0.113/src/labnotes/view/answer.py +11 -0
  23. labnotes-0.0.113/src/labnotes/view/build.py +5 -0
  24. labnotes-0.0.113/src/labnotes/view/respond.py +13 -0
  25. labnotes-0.0.113/src/labnotes/view/serve.py +21 -0
  26. {labnotes-0.0.112 → labnotes-0.0.113}/testing-conventions.toml +12 -0
  27. labnotes-0.0.112/src/labnotes/__init__.py +0 -3
  28. labnotes-0.0.112/src/labnotes/tui/browse_app.py +0 -25
  29. labnotes-0.0.112/src/labnotes/tui/group_cells.py +0 -27
  30. labnotes-0.0.112/src/labnotes/tui/invocation_columns.py +0 -9
  31. labnotes-0.0.112/src/labnotes/tui/invocation_rows.py +0 -32
  32. labnotes-0.0.112/src/labnotes/tui/run_cells.py +0 -29
  33. {labnotes-0.0.112 → labnotes-0.0.113}/.gitignore +0 -0
  34. {labnotes-0.0.112 → labnotes-0.0.113}/CHANGELOG.md +0 -0
  35. {labnotes-0.0.112 → labnotes-0.0.113}/MIGRATIONS.md +0 -0
  36. {labnotes-0.0.112 → labnotes-0.0.113}/README.md +0 -0
  37. {labnotes-0.0.112 → labnotes-0.0.113}/pyproject.toml +0 -0
  38. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/__main__.py +0 -0
  39. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/asset_url.py +0 -0
  40. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/check_counts.py +0 -0
  41. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/cli.py +0 -0
  42. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/compose_experiment.py +0 -0
  43. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/context.md +0 -0
  44. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/context_guide.py +0 -0
  45. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/count_groups.py +0 -0
  46. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/count_runs.py +0 -0
  47. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/create_app.py +0 -0
  48. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/create_experiment.py +0 -0
  49. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/create_note.py +0 -0
  50. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/__init__.py +0 -0
  51. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/control_run.py +0 -0
  52. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/daemon_app.py +0 -0
  53. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/daemon_client.py +0 -0
  54. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/daemon_running.py +0 -0
  55. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/daemon_server.py +0 -0
  56. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/describe_status.py +0 -0
  57. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/ensure_daemon.py +0 -0
  58. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/exit_when_idle.py +0 -0
  59. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/finalize_lost_runs.py +0 -0
  60. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/remote_start.py +0 -0
  61. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/run_daemon.py +0 -0
  62. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/run_logs.py +0 -0
  63. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/serve_daemon.py +0 -0
  64. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/socket_path.py +0 -0
  65. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/stop_runs.py +0 -0
  66. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/watch_daemon.py +0 -0
  67. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon/watch_status.py +0 -0
  68. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/daemon_command.py +0 -0
  69. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/data_html.py +0 -0
  70. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/default_config_text.py +0 -0
  71. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/default_counts.py +0 -0
  72. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/drain_watch.py +0 -0
  73. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/experiment_notes.py +0 -0
  74. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/experiment_runs.py +0 -0
  75. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/experiments.py +0 -0
  76. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/file_changes.py +0 -0
  77. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/format_duration.py +0 -0
  78. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/format_started.py +0 -0
  79. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/format_value.py +0 -0
  80. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/group_runs.py +0 -0
  81. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/init.py +0 -0
  82. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/invocation_started.py +0 -0
  83. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/is_non_negative_int.py +0 -0
  84. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/is_non_negative_number.py +0 -0
  85. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/is_positive_int.py +0 -0
  86. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/list_experiments.py +0 -0
  87. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/list_runs.py +0 -0
  88. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/load_config.py +0 -0
  89. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/load_run.py +0 -0
  90. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/log.py +0 -0
  91. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/log_path.py +0 -0
  92. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/memory_interval.py +0 -0
  93. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/models.py +0 -0
  94. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/modes.py +0 -0
  95. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/nests_runs.py +0 -0
  96. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/note_path.py +0 -0
  97. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/notebook_kernel.py +0 -0
  98. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/output_html.py +0 -0
  99. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/parse_run_view.py +0 -0
  100. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/print_rows.py +0 -0
  101. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/python_blocks.py +0 -0
  102. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/read_config.py +0 -0
  103. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/read_path.py +0 -0
  104. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/render_markdown.py +0 -0
  105. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/retries.py +0 -0
  106. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/run_command.py +0 -0
  107. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/run_control.py +0 -0
  108. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/run_output.py +0 -0
  109. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/run_status.py +0 -0
  110. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/__init__.py +0 -0
  111. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/describe_event.py +0 -0
  112. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/execute_run.py +0 -0
  113. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/exit_signal.py +0 -0
  114. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/hold_folder.py +0 -0
  115. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/invocation_meta.py +0 -0
  116. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/new_param_run_id.py +0 -0
  117. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/new_run_id.py +0 -0
  118. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  119. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/param_args.py +0 -0
  120. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/param_grid.py +0 -0
  121. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/param_run_name.py +0 -0
  122. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/plan_runs.py +0 -0
  123. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/prepare_experiment.py +0 -0
  124. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/record_memory.py +0 -0
  125. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/run_experiment.py +0 -0
  126. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/run_folder.py +0 -0
  127. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/run_headless.py +0 -0
  128. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/runs_parent.py +0 -0
  129. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/shell_command.py +0 -0
  130. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/signal_tree.py +0 -0
  131. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/stop_then_kill.py +0 -0
  132. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/wait_for_tree.py +0 -0
  133. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/runner/write_run_json.py +0 -0
  134. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/save_conflict_copy.py +0 -0
  135. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/save_note.py +0 -0
  136. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/saved_outputs.py +0 -0
  137. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/serve_app.py +0 -0
  138. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/server.py +0 -0
  139. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/shared_params.py +0 -0
  140. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/sort_href.py +0 -0
  141. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/sql_quote.py +0 -0
  142. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/elapsed.js +0 -0
  143. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/experiment.js +0 -0
  144. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/labnotes.css +0 -0
  145. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/local-time.js +0 -0
  146. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/new-note.js +0 -0
  147. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/note-edit.js +0 -0
  148. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/note-editor.js +0 -0
  149. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/row-link.js +0 -0
  150. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/run-cell.js +0 -0
  151. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/run-output.js +0 -0
  152. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/static/view-toggle.js +0 -0
  153. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/summarize_config.py +0 -0
  154. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/__init__.py +0 -0
  155. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/experiment_rows.py +0 -0
  156. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/experiment_run_ids.py +0 -0
  157. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/experiment_toml.py +0 -0
  158. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/experiments_ddl.py +0 -0
  159. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/file_prefix.py +0 -0
  160. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/file_text.py +0 -0
  161. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/iso_time.py +0 -0
  162. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/list_experiment_runs.py +0 -0
  163. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/note_text.py +0 -0
  164. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/path_glob.py +0 -0
  165. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/path_pattern.py +0 -0
  166. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/read_blocks.py +0 -0
  167. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/read_experiment.py +0 -0
  168. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/read_file.py +0 -0
  169. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/read_log.py +0 -0
  170. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/read_memory.py +0 -0
  171. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/read_run.py +0 -0
  172. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tables/summarize_experiments.py +0 -0
  173. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/templates/base.html +0 -0
  174. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/templates/editor_imports.html +0 -0
  175. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/templates/experiment.html +0 -0
  176. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/templates/index.html +0 -0
  177. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/templates/note.html +0 -0
  178. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/templates/run.html +0 -0
  179. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/__init__.py +0 -0
  180. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/browse_tui.py +0 -0
  181. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/config.py +0 -0
  182. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/dim.py +0 -0
  183. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/fill_table.py +0 -0
  184. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/format_elapsed.py +0 -0
  185. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/leave_prompt.py +0 -0
  186. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/log_block.py +0 -0
  187. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/log_blocks.py +0 -0
  188. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/reload_on_change.py +0 -0
  189. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/run_app.py +0 -0
  190. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/run_output.py +0 -0
  191. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/run_screen.py +0 -0
  192. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/run_tui.py +0 -0
  193. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/runs_table.py +0 -0
  194. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/tui/stopped_title.py +0 -0
  195. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/utils/__init__.py +0 -0
  196. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/utils/format_bytes.py +0 -0
  197. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/utils/process_alive.py +0 -0
  198. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/utils/run_usage.py +0 -0
  199. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/utils/tree_rss.py +0 -0
  200. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/utils/tree_usage.py +0 -0
  201. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/wait_for_note.py +0 -0
  202. {labnotes-0.0.112 → labnotes-0.0.113}/src/labnotes/watch_rows.py +0 -0
  203. {labnotes-0.0.112 → labnotes-0.0.113}/tests/integration/conftest.py +0 -0
  204. {labnotes-0.0.112 → 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.112
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"]
@@ -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
  },
@@ -23,7 +23,9 @@ SETTINGS = {
23
23
  "retries",
24
24
  "meta",
25
25
  "memory_interval",
26
+ "views",
26
27
  }
28
+ VIEWS = {"runs"}
27
29
 
28
30
 
29
31
  def read_experiment_config(
@@ -79,6 +81,15 @@ def read_experiment_config(
79
81
  memory_interval = values.get("memory_interval", DEFAULT_MEMORY_INTERVAL)
80
82
  if not is_non_negative_number(memory_interval):
81
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")
82
93
  return ExperimentConfig(
83
94
  command,
84
95
  repeats,
@@ -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
@@ -12,6 +12,7 @@ from labnotes.tables.read_file import read_file
12
12
  from labnotes.tables.read_log import read_log
13
13
  from labnotes.tables.read_memory import read_memory
14
14
  from labnotes.tables.read_run import read_run
15
+ from labnotes.tables.read_run_file import read_run_file
15
16
 
16
17
  FILES = """
17
18
  CREATE TABLE files (
@@ -47,9 +48,26 @@ CREATE TABLE memory (
47
48
  )
48
49
  """
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
+ """
50
57
 
51
- def open_tables(config: LabnotesConfig) -> DirSQL:
58
+
59
+ def open_tables(config: LabnotesConfig, run_files: bool = False) -> DirSQL:
52
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
+ ]
53
71
  return DirSQL(
54
72
  str(root),
55
73
  tables=[
@@ -105,6 +123,7 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
105
123
  defaults=config.defaults,
106
124
  ),
107
125
  ),
126
+ *(extra if run_files else []),
108
127
  ],
109
128
  # Run folders are often gitignored (DVC, large outputs); scan them anyway.
110
129
  no_ignore=True,
@@ -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)]
@@ -0,0 +1,32 @@
1
+ from collections.abc import Collection, Mapping, Sequence
2
+ from typing import Any
3
+
4
+ from labnotes.tui.group_cells import group_cells
5
+ from labnotes.tui.run_cells import run_cells
6
+
7
+
8
+ def invocation_rows(
9
+ experiment: Mapping[str, Any],
10
+ opened: Collection[str],
11
+ columns: Sequence[Mapping[str, str]],
12
+ rows: Mapping[str, Mapping[str, object]],
13
+ ) -> list[tuple[str, list[object]]]:
14
+ live = experiment["runs_live"]
15
+ groups = experiment["run_groups"]
16
+
17
+ def cells(run: Mapping[str, object], nested: bool) -> list[object]:
18
+ return run_cells(rows[str(run["folder"])], columns, nested, live)
19
+
20
+ if groups is None:
21
+ return [(str(run["id"]), cells(run, False)) for run in experiment["runs_list"]]
22
+ shown = []
23
+ for group in groups:
24
+ if group["flat"]:
25
+ [run, *_] = group["runs"]
26
+ shown.append((str(run["id"]), cells(run, True)))
27
+ continue
28
+ is_open = group["name"] in opened
29
+ shown.append((group["name"], group_cells(group, columns, live, is_open)))
30
+ if is_open:
31
+ shown += [(str(run["id"]), cells(run, True)) for run in group["runs"]]
32
+ return shown
@@ -0,0 +1,24 @@
1
+ import json
2
+ from collections.abc import Mapping
3
+ from typing import Any
4
+
5
+ JSON_FIELDS = ("params", "meta", "run")
6
+
7
+
8
+ def native_row(run: Mapping[str, Any]) -> dict[str, Any]:
9
+ row = {key: value for key, value in run.items() if not key.startswith("labnotes_")}
10
+ for key in JSON_FIELDS:
11
+ if isinstance(row.get(key), str):
12
+ row[key] = json.loads(row[key])
13
+ if run["labnotes_unfinished"]:
14
+ row["status"] = "unfinished"
15
+ elif run["labnotes_completed"]:
16
+ row["status"] = "passed"
17
+ else:
18
+ row["status"] = "failed"
19
+ row["started"] = run["created"]
20
+ return row
21
+
22
+
23
+ def native_rows(experiment: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
24
+ return {str(run["folder"]): native_row(run) for run in experiment["runs_list"]}
@@ -0,0 +1,22 @@
1
+ from collections.abc import Mapping, Sequence
2
+
3
+ from labnotes.tui.format_cell import format_cell
4
+
5
+
6
+ def run_cells(
7
+ row: Mapping[str, object],
8
+ columns: Sequence[Mapping[str, str]],
9
+ nested: bool,
10
+ live: bool,
11
+ ) -> list[object]:
12
+ cells: list[object] = []
13
+ for column in columns:
14
+ key = column["key"]
15
+ if key not in row:
16
+ cells.append("")
17
+ elif key == "id" and nested:
18
+ child = str(row[key]).partition("/")[2]
19
+ cells.append(f" {child}" if child else str(row[key]))
20
+ else:
21
+ cells.append(format_cell(key, row[key], live))
22
+ return cells
@@ -0,0 +1,81 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import Any
3
+
4
+ from labnotes.tui.check_view_response import check_view_response
5
+
6
+ type Columns = list[dict[str, str]]
7
+ type Rows = dict[str, dict[str, Any]]
8
+ type Request = Callable[[dict[str, Any]], Awaitable[tuple[str, float]]]
9
+
10
+ SLOW_SECONDS = 0.1
11
+
12
+
13
+ class RunsView:
14
+ """Track which runs the view worker must see again, and its last good answer."""
15
+
16
+ def __init__(self, request: Request) -> None:
17
+ self.request = request
18
+ self.columns: Columns = []
19
+ self.native: Rows = {}
20
+ self.sent_columns: Columns | None = None
21
+ self.sent: Rows = {}
22
+ self.pending: set[str] = set()
23
+ self.busy = False
24
+ self.shown_columns: Columns | None = None
25
+ self.shown: Rows = {}
26
+ self.error: str | None = None
27
+ self.elapsed: float | None = None
28
+
29
+ def update(self, columns: Columns, native: Rows) -> None:
30
+ if columns != self.sent_columns:
31
+ self.pending |= native.keys()
32
+ self.pending |= {f for f, row in native.items() if self.sent.get(f) != row}
33
+ self.pending &= native.keys()
34
+ self.sent = {f: row for f, row in self.sent.items() if f in native}
35
+ self.shown = {f: row for f, row in self.shown.items() if f in native}
36
+ self.columns, self.native = columns, native
37
+
38
+ def touch(self, path: str) -> None:
39
+ owners = [f for f in self.native if path.startswith(f"{f}/")]
40
+ if owners:
41
+ self.pending.add(max(owners, key=len))
42
+
43
+ def next_request(self) -> dict[str, Any] | None:
44
+ if self.busy or not self.pending:
45
+ return None
46
+ rows = {folder: self.native[folder] for folder in sorted(self.pending)}
47
+ self.pending = set()
48
+ self.busy = True
49
+ self.sent_columns = self.columns
50
+ self.sent.update(rows)
51
+ return {"columns": self.columns, "rows": rows}
52
+
53
+ async def exchange(self, request: dict[str, Any]) -> bool:
54
+ """Send a request and keep a valid answer; report whether rows changed."""
55
+ try:
56
+ reply, self.elapsed = await self.request(request)
57
+ columns, rows = check_view_response(reply, request)
58
+ except (ValueError, OSError) as error:
59
+ self.error = str(error)
60
+ return False
61
+ finally:
62
+ self.busy = False
63
+ changed = columns != self.shown_columns or any(
64
+ self.shown.get(folder) != row for folder, row in rows.items()
65
+ )
66
+ self.error = None
67
+ self.shown_columns = columns
68
+ self.shown.update(rows)
69
+ return changed
70
+
71
+ def table(self) -> tuple[Columns, Rows]:
72
+ rows = {f: self.shown.get(f, row) for f, row in self.native.items()}
73
+ return self.columns if self.shown_columns is None else self.shown_columns, rows
74
+
75
+ def status(self) -> str:
76
+ notes = []
77
+ if self.error is not None:
78
+ notes.append(f"runs view: {self.error}")
79
+ if self.elapsed is not None and self.elapsed > SLOW_SECONDS:
80
+ notes.append(f"runs view took {round(self.elapsed * 1000)} ms")
81
+ return " · ".join(notes)
@@ -0,0 +1,15 @@
1
+ def view_column(entry: object) -> dict[str, str]:
2
+ if isinstance(entry, str):
3
+ return {"key": entry, "label": entry}
4
+ if not isinstance(entry, dict):
5
+ raise ValueError("a column must be a string or an object")
6
+ key = entry.get("key")
7
+ if not isinstance(key, str):
8
+ raise ValueError("a column needs a string key")
9
+ label = entry.get("label", key)
10
+ if not isinstance(label, str):
11
+ raise ValueError(f"column {key}: label must be a string")
12
+ unknown = sorted(entry.keys() - {"key", "label"})
13
+ if unknown:
14
+ raise ValueError(f"column {key}: unknown fields {', '.join(unknown)}")
15
+ return {"key": key, "label": label}