labnotes 0.0.106__tar.gz → 0.0.108__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 (177) hide show
  1. {labnotes-0.0.106 → labnotes-0.0.108}/PKG-INFO +3 -2
  2. {labnotes-0.0.106 → labnotes-0.0.108}/README.md +2 -1
  3. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/context.md +5 -3
  4. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/describe_status.py +5 -0
  5. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/watch_daemon.py +21 -2
  6. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/experiments.py +8 -0
  7. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/execute_run.py +5 -0
  8. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/experiment_rows.py +6 -1
  9. labnotes-0.0.108/src/labnotes/tui/config.py +1 -0
  10. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/run_app.py +58 -20
  11. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/run_tui.py +2 -1
  12. labnotes-0.0.108/src/labnotes/utils/__init__.py +0 -0
  13. labnotes-0.0.108/src/labnotes/utils/format_bytes.py +5 -0
  14. labnotes-0.0.108/src/labnotes/utils/run_usage.py +31 -0
  15. labnotes-0.0.108/src/labnotes/utils/tree_rss.py +27 -0
  16. {labnotes-0.0.106 → labnotes-0.0.108}/testing-conventions.toml +6 -0
  17. {labnotes-0.0.106 → labnotes-0.0.108}/.gitignore +0 -0
  18. {labnotes-0.0.106 → labnotes-0.0.108}/CHANGELOG.md +0 -0
  19. {labnotes-0.0.106 → labnotes-0.0.108}/MIGRATIONS.md +0 -0
  20. {labnotes-0.0.106 → labnotes-0.0.108}/pyproject.toml +0 -0
  21. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/__init__.py +0 -0
  22. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/__main__.py +0 -0
  23. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/asset_url.py +0 -0
  24. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/check_counts.py +0 -0
  25. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/cli.py +0 -0
  26. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/compose_experiment.py +0 -0
  27. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/context_guide.py +0 -0
  28. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/count_groups.py +0 -0
  29. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/count_runs.py +0 -0
  30. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/create_app.py +0 -0
  31. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/create_experiment.py +0 -0
  32. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/create_note.py +0 -0
  33. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/__init__.py +0 -0
  34. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/control_run.py +0 -0
  35. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/daemon_app.py +0 -0
  36. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/daemon_client.py +0 -0
  37. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/daemon_running.py +0 -0
  38. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/daemon_server.py +0 -0
  39. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/ensure_daemon.py +0 -0
  40. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/exit_when_idle.py +0 -0
  41. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/remote_start.py +0 -0
  42. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/run_daemon.py +0 -0
  43. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/run_logs.py +0 -0
  44. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/serve_daemon.py +0 -0
  45. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/socket_path.py +0 -0
  46. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/stop_runs.py +0 -0
  47. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon/watch_status.py +0 -0
  48. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/daemon_command.py +0 -0
  49. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/data_html.py +0 -0
  50. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/default_config_text.py +0 -0
  51. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/default_counts.py +0 -0
  52. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/drain_watch.py +0 -0
  53. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/experiment_notes.py +0 -0
  54. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/experiment_runs.py +0 -0
  55. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/file_changes.py +0 -0
  56. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/format_duration.py +0 -0
  57. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/format_started.py +0 -0
  58. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/format_value.py +0 -0
  59. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/group_runs.py +0 -0
  60. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/init.py +0 -0
  61. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/invocation_started.py +0 -0
  62. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/is_non_negative_int.py +0 -0
  63. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/is_positive_int.py +0 -0
  64. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/list_experiments.py +0 -0
  65. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/list_runs.py +0 -0
  66. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/load_config.py +0 -0
  67. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/load_experiment.py +0 -0
  68. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/load_run.py +0 -0
  69. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/log.py +0 -0
  70. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/log_path.py +0 -0
  71. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/models.py +0 -0
  72. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/modes.py +0 -0
  73. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/nests_runs.py +0 -0
  74. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/note_path.py +0 -0
  75. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/notebook_kernel.py +0 -0
  76. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/output_html.py +0 -0
  77. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/parse_run_view.py +0 -0
  78. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/print_rows.py +0 -0
  79. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/python_blocks.py +0 -0
  80. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/read_config.py +0 -0
  81. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/read_path.py +0 -0
  82. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/render_markdown.py +0 -0
  83. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/retries.py +0 -0
  84. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/run_command.py +0 -0
  85. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/run_control.py +0 -0
  86. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/run_output.py +0 -0
  87. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/run_status.py +0 -0
  88. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/__init__.py +0 -0
  89. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/describe_event.py +0 -0
  90. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/exit_signal.py +0 -0
  91. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/hold_folder.py +0 -0
  92. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/new_param_run_id.py +0 -0
  93. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/new_run_id.py +0 -0
  94. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  95. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/param_args.py +0 -0
  96. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/param_grid.py +0 -0
  97. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/param_run_name.py +0 -0
  98. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/plan_runs.py +0 -0
  99. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/prepare_experiment.py +0 -0
  100. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/read_experiment_config.py +0 -0
  101. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/run_experiment.py +0 -0
  102. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/run_folder.py +0 -0
  103. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/run_headless.py +0 -0
  104. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/runs_parent.py +0 -0
  105. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/shell_command.py +0 -0
  106. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/signal_tree.py +0 -0
  107. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/stop_then_kill.py +0 -0
  108. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/wait_for_tree.py +0 -0
  109. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/runner/write_run_json.py +0 -0
  110. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/save_conflict_copy.py +0 -0
  111. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/save_note.py +0 -0
  112. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/saved_outputs.py +0 -0
  113. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/serve_app.py +0 -0
  114. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/server.py +0 -0
  115. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/shared_params.py +0 -0
  116. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/sort_href.py +0 -0
  117. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/sql_quote.py +0 -0
  118. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/elapsed.js +0 -0
  119. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/experiment.js +0 -0
  120. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/labnotes.css +0 -0
  121. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/local-time.js +0 -0
  122. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/new-note.js +0 -0
  123. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/note-edit.js +0 -0
  124. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/note-editor.js +0 -0
  125. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/row-link.js +0 -0
  126. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/run-cell.js +0 -0
  127. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/run-output.js +0 -0
  128. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/static/view-toggle.js +0 -0
  129. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/summarize_config.py +0 -0
  130. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/__init__.py +0 -0
  131. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/experiment_run_ids.py +0 -0
  132. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/experiment_toml.py +0 -0
  133. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/experiments_ddl.py +0 -0
  134. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/file_prefix.py +0 -0
  135. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/file_text.py +0 -0
  136. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/iso_time.py +0 -0
  137. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/list_experiment_runs.py +0 -0
  138. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/note_text.py +0 -0
  139. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/open_tables.py +0 -0
  140. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/path_glob.py +0 -0
  141. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/path_pattern.py +0 -0
  142. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/read_blocks.py +0 -0
  143. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/read_experiment.py +0 -0
  144. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/read_file.py +0 -0
  145. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/read_log.py +0 -0
  146. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/read_run.py +0 -0
  147. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tables/summarize_experiments.py +0 -0
  148. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/templates/base.html +0 -0
  149. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/templates/editor_imports.html +0 -0
  150. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/templates/experiment.html +0 -0
  151. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/templates/index.html +0 -0
  152. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/templates/note.html +0 -0
  153. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/templates/run.html +0 -0
  154. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/__init__.py +0 -0
  155. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/browse_app.py +0 -0
  156. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/browse_tui.py +0 -0
  157. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/dim.py +0 -0
  158. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/experiment_screen.py +0 -0
  159. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/experiments_screen.py +0 -0
  160. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/fill_table.py +0 -0
  161. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/format_elapsed.py +0 -0
  162. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/group_cells.py +0 -0
  163. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/invocation_columns.py +0 -0
  164. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/invocation_rows.py +0 -0
  165. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/leave_prompt.py +0 -0
  166. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/log_block.py +0 -0
  167. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/log_blocks.py +0 -0
  168. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/reload_on_change.py +0 -0
  169. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/run_cells.py +0 -0
  170. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/run_output.py +0 -0
  171. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/run_screen.py +0 -0
  172. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/runs_table.py +0 -0
  173. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/tui/stopped_title.py +0 -0
  174. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/wait_for_note.py +0 -0
  175. {labnotes-0.0.106 → labnotes-0.0.108}/src/labnotes/watch_rows.py +0 -0
  176. {labnotes-0.0.106 → labnotes-0.0.108}/tests/integration/conftest.py +0 -0
  177. {labnotes-0.0.106 → labnotes-0.0.108}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.106
3
+ Version: 0.0.108
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>
@@ -122,7 +122,8 @@ line per event instead.
122
122
  - With runs going, Ctrl-C asks first. Enter (or a second Ctrl-C) leaves them
123
123
  running. `s` stops the running scripts and records their end, then kills any
124
124
  still going 5 seconds later. Escape goes back. SIGTERM stops them like `s`.
125
- - A second `run` of the same experiment fails while the first holds the lock.
125
+ - A second `run` of the same experiment fails with "already running" while the
126
+ daemon still has the first going.
126
127
  - It exits 0 even if runs fail; the summary line counts failures. If it can't
127
128
  start or reach the daemon, it prints the cause on one line and exits 1.
128
129
 
@@ -98,7 +98,8 @@ line per event instead.
98
98
  - With runs going, Ctrl-C asks first. Enter (or a second Ctrl-C) leaves them
99
99
  running. `s` stops the running scripts and records their end, then kills any
100
100
  still going 5 seconds later. Escape goes back. SIGTERM stops them like `s`.
101
- - A second `run` of the same experiment fails while the first holds the lock.
101
+ - A second `run` of the same experiment fails with "already running" while the
102
+ daemon still has the first going.
102
103
  - It exits 0 even if runs fail; the summary line counts failures. If it can't
103
104
  start or reach the daemon, it prints the cause on one line and exits 1.
104
105
 
@@ -112,9 +112,10 @@ shows a waiting retry as `retry in 30s` and the end as `gave up (exit 75)`;
112
112
  `run status --json` reports `retrying`, `retried`, `gave up` and
113
113
  `retry cancelled` events.
114
114
 
115
- 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.
115
+ 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.
116
116
 
117
- In a terminal, `run` shows a live table of runs (status and time) and stays
117
+ In a terminal, `run` shows a live table of runs (status, time, memory and
118
+ folder size) and stays
118
119
  open when they finish. Failed runs show their status in red; `f` hides or shows them, and `r` on one
119
120
  runs it again under a new id, with `retry_of` set. `j` and `k` move the cursor, `g` and `G` jump to the
120
121
  first and last row. Runs not started yet show grayed as `pending`, and as
@@ -150,7 +151,8 @@ going. Elsewhere, it prints a line per event instead.
150
151
  - `labnotes run stop` stops every run the daemon has going: SIGTERM, then
151
152
  SIGKILL 5 seconds later.
152
153
  - `labnotes run status` prints what the daemon has going, a line per event so
153
- far plus the latest line each running run logged. `-f` keeps following, and
154
+ far plus the latest line each running run logged and what each running run
155
+ uses (`usage` events with `--json`). `-f` keeps following, and
154
156
  waits for a daemon when none is up. `--json` prints one JSON object per line.
155
157
  Following doesn't keep the daemon up.
156
158
  - `labnotes run control NAME ACTION [ARG]` sends one live-view control to a
@@ -1,3 +1,4 @@
1
+ from labnotes.utils.format_bytes import format_bytes
1
2
  from labnotes.runner.describe_event import describe_event
2
3
 
3
4
 
@@ -5,6 +6,10 @@ def describe_status(event: dict[str, object]) -> str:
5
6
  match event:
6
7
  case {"event": "output"}:
7
8
  said = f"{event['run']}: {event['line']}"
9
+ case {"event": "usage"}:
10
+ memory = format_bytes(event["memory"]) or "?" # type: ignore[arg-type]
11
+ size = format_bytes(event["size"]) or "?" # type: ignore[arg-type]
12
+ said = f"{event['run']} uses {memory} of memory, {size} on disk."
8
13
  case {"event": "done", "error": None}:
9
14
  said = "Done."
10
15
  case {"event": "done"}:
@@ -6,9 +6,11 @@ import httpx2
6
6
 
7
7
  from labnotes.daemon.daemon_client import daemon_client
8
8
  from labnotes.daemon.run_logs import RunLogs
9
+ from labnotes.utils.run_usage import run_usage
9
10
 
10
11
  Event = dict[str, object]
11
12
  TAIL_SECONDS = 1.0
13
+ USAGE_SECONDS = 30.0
12
14
 
13
15
 
14
16
  async def watch_daemon(root: Path, follow: bool, emit: Callable[[Event], None]) -> None:
@@ -23,7 +25,21 @@ async def watch_daemon(root: Path, follow: bool, emit: Callable[[Event], None])
23
25
  await asyncio.sleep(TAIL_SECONDS)
24
26
  tail()
25
27
 
28
+ async def sample() -> None:
29
+ for (experiment, run), folder in list(logs.folders.items()):
30
+ if (experiment, run) not in logs.folders:
31
+ continue
32
+ usage = await run_usage(folder, True)
33
+ if (experiment, run) in logs.folders:
34
+ emit({"event": "usage", "experiment": experiment, "run": run, **usage})
35
+
36
+ async def keep_sampling() -> None:
37
+ while True:
38
+ await asyncio.sleep(USAGE_SECONDS)
39
+ await sample()
40
+
26
41
  tailing = asyncio.create_task(keep_tailing()) if follow else None
42
+ sampling = None
27
43
  try:
28
44
  async with (
29
45
  daemon_client(root) as client,
@@ -36,8 +52,10 @@ async def watch_daemon(root: Path, follow: bool, emit: Callable[[Event], None])
36
52
  match event:
37
53
  case {"event": "replayed"}:
38
54
  tail()
55
+ await sample()
39
56
  if not follow:
40
57
  return
58
+ sampling = asyncio.create_task(keep_sampling())
41
59
  continue
42
60
  case {"event": "started", "experiment": str(name), "run": str(run)}:
43
61
  logs.add(name, run, Path(str(event["folder"])))
@@ -48,5 +66,6 @@ async def watch_daemon(root: Path, follow: bool, emit: Callable[[Event], None])
48
66
  except httpx2.TransportError:
49
67
  pass
50
68
  finally:
51
- if tailing is not None:
52
- tailing.cancel()
69
+ for task in (tailing, sampling):
70
+ if task is not None:
71
+ task.cancel()
@@ -6,6 +6,7 @@ import click
6
6
 
7
7
  from labnotes.create_experiment import create_experiment
8
8
  from labnotes.daemon.remote_start import remote_start
9
+ from labnotes.utils.format_bytes import format_bytes
9
10
  from labnotes.load_config import load_config
10
11
  from labnotes.print_rows import print_rows
11
12
  from labnotes.read_config import CONFIG_FILE
@@ -14,6 +15,8 @@ from labnotes.tables.experiment_rows import experiment_rows
14
15
  from labnotes.tui.browse_tui import browse_tui
15
16
  from labnotes.tui.run_tui import run_tui
16
17
 
18
+ BYTES = ("memory", "size")
19
+
17
20
 
18
21
  @click.command(
19
22
  help="List experiments, show one, list its runs, or run it."
@@ -103,4 +106,9 @@ def experiments(
103
106
  raise click.ClickException(str(error)) from error
104
107
  if rows is None:
105
108
  raise click.ClickException(f"No experiment named {name}.")
109
+ if output_format != "json":
110
+ rows = [
111
+ row | {key: format_bytes(row[key]) for key in BYTES if key in row} # type: ignore[arg-type]
112
+ for row in rows
113
+ ]
106
114
  print_rows(rows, output_format or "table")
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import os
3
4
  import time
4
5
  from collections.abc import Callable
@@ -62,6 +63,10 @@ async def execute_run(
62
63
  run["error"] = str(error)
63
64
  else:
64
65
  running.add(process)
66
+ run["pid"] = process.pid
67
+ with contextlib.suppress(psutil.Error):
68
+ run["pid_created"] = psutil.Process(process.pid).create_time()
69
+ write_run_json(folder, run)
65
70
  exited = asyncio.ensure_future(process.wait())
66
71
  kill_later = None
67
72
  try:
@@ -1,6 +1,7 @@
1
1
  import json
2
2
 
3
3
  from labnotes.models import LabnotesConfig
4
+ from labnotes.utils.run_usage import run_usage
4
5
  from labnotes.summarize_config import summarize_config
5
6
  from labnotes.tables.list_experiment_runs import list_experiment_runs
6
7
  from labnotes.tables.open_tables import open_tables
@@ -21,7 +22,11 @@ async def experiment_rows(
21
22
  summary = summarize_config(
22
23
  json.loads(str(stored)) if stored else None, config.mode
23
24
  )
24
- return await list_experiment_runs(
25
+ runs = await list_experiment_runs(
25
26
  tables, name, str(summary["completed_run"])
26
27
  )
28
+ for run in runs:
29
+ going = all(run[key] is None for key in ("ended", "exit_code", "error"))
30
+ run |= await run_usage(config.root / str(run["folder"]), going)
31
+ return runs
27
32
  return rows
@@ -0,0 +1 @@
1
+ RUN_COLUMNS = ("run", "params", "status", "time", "memory", "size")
@@ -1,15 +1,19 @@
1
1
  import asyncio
2
2
  import signal
3
3
  import time
4
+ from collections.abc import Sequence
4
5
  from pathlib import Path
5
6
 
6
7
  from rich.text import Text
8
+ from textual import work
7
9
  from textual.app import App, ComposeResult
8
10
  from textual.binding import Binding
9
11
  from textual.widgets import DataTable, Footer, Header, Input, Static
10
12
 
13
+ from labnotes.utils.format_bytes import format_bytes
11
14
  from labnotes.models import Controls, Start
12
15
  from labnotes.retries import RETRY_EXIT_CODE
16
+ from labnotes.utils.run_usage import run_usage
13
17
  from labnotes.runner.describe_event import describe_event
14
18
  from labnotes.runner.stop_then_kill import GRACE_SECONDS, stop_then_kill
15
19
  from labnotes.runner.param_args import param_args
@@ -20,7 +24,6 @@ from labnotes.tui.log_blocks import LogBlocks
20
24
  from labnotes.tui.run_output import RunOutput
21
25
  from labnotes.tui.runs_table import RunsTable
22
26
 
23
- COLUMNS = ("run", "params", "status", "time")
24
27
  PENDING, PASSED, FAILED = "○", "✓", "✗"
25
28
  SPINNER = "|/-\\"
26
29
  SKIPPED, DESELECTED = "–", "⊘"
@@ -50,8 +53,9 @@ class RunApp(App[None]):
50
53
  Binding("minus", "concurrency(-1)", "Fewer at once", key_display="-"),
51
54
  ]
52
55
 
53
- def __init__(self, start: Start, title: str) -> None:
56
+ def __init__(self, start: Start, title: str, columns: Sequence[str]) -> None:
54
57
  super().__init__()
58
+ self.columns = tuple(columns)
55
59
  self.start = start
56
60
  self.title = title
57
61
  self.results: list[dict[str, object]] = []
@@ -73,7 +77,7 @@ class RunApp(App[None]):
73
77
  self.frame = 0
74
78
  self.started: dict[str, float] = {}
75
79
  self.pending: set[str] = set()
76
- self.cells: dict[str, list[object]] = {}
80
+ self.cells: dict[str, dict[str, object]] = {}
77
81
  self.filter = ""
78
82
  self.failed: set[str] = set()
79
83
  self.retried: set[str] = set()
@@ -83,6 +87,8 @@ class RunApp(App[None]):
83
87
  self.viewing: str | None = None
84
88
  self.grabbed: str | None = None
85
89
  self.grabbed_from: list[str] = []
90
+ self.sampling = False
91
+ self.unmeasured: dict[str, None] = {}
86
92
 
87
93
  def compose(self) -> ComposeResult:
88
94
  yield Header()
@@ -95,7 +101,7 @@ class RunApp(App[None]):
95
101
 
96
102
  def on_mount(self) -> None:
97
103
  table = self.query_one(DataTable)
98
- for column in COLUMNS:
104
+ for column in self.columns:
99
105
  table.add_column(column, key=column)
100
106
  self.query_one(Input).display = False
101
107
  self.query_one(RunOutput).display = False
@@ -127,8 +133,9 @@ class RunApp(App[None]):
127
133
  self.title = str(event["title"])
128
134
  for planned in event["runs"]: # type: ignore[attr-defined]
129
135
  args = " ".join(param_args(planned["params"]))
130
- cells = (planned["run"].rpartition("/")[2], args, PENDING, "")
131
- self.cells[planned["run"]] = [*map(dim, cells)]
136
+ self.cells[planned["run"]] = self.waiting_cells(
137
+ planned["run"], args
138
+ )
132
139
  self.pending.add(planned["run"])
133
140
  self.redraw(table.cursor_row)
134
141
  self.show_concurrency(event)
@@ -145,13 +152,13 @@ class RunApp(App[None]):
145
152
  if run == self.next_run:
146
153
  self.show("")
147
154
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
148
- cells = (
149
- run.rpartition("/")[2],
150
- args,
151
- SPINNER[self.frame],
152
- format_elapsed(0),
153
- )
154
- for column, cell in zip(COLUMNS, cells):
155
+ shown = {
156
+ "run": run.rpartition("/")[2],
157
+ "params": args,
158
+ "status": SPINNER[self.frame],
159
+ "time": format_elapsed(0),
160
+ }
161
+ for column, cell in shown.items():
155
162
  self.set_cell(run, column, cell)
156
163
  case "ended":
157
164
  outcome = PASSED
@@ -166,6 +173,8 @@ class RunApp(App[None]):
166
173
  self.tick()
167
174
  del self.started[run]
168
175
  self.redraw(table.cursor_row)
176
+ self.unmeasured[run] = None
177
+ self.measure()
169
178
  case "queued":
170
179
  self.reposition(event, run)
171
180
  self.next_run = run
@@ -201,9 +210,7 @@ class RunApp(App[None]):
201
210
  self.retried.add(run)
202
211
  again = str(event["retry"])
203
212
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
204
- self.cells[again] = [
205
- *map(dim, (again.rpartition("/")[2], args, PENDING, ""))
206
- ]
213
+ self.cells[again] = self.waiting_cells(again, args)
207
214
  self.pending.add(again)
208
215
  self.redraw(table.cursor_row)
209
216
  self.show(describe_event(event))
@@ -244,14 +251,20 @@ class RunApp(App[None]):
244
251
  table.clear()
245
252
  for run, cells in self.cells.items():
246
253
  hidden = self.hiding and run in self.failed
247
- if self.filter in str(cells[1]) and not hidden:
248
- table.add_row(*cells, key=run)
254
+ if self.filter in str(cells.get("params", "")) and not hidden:
255
+ table.add_row(*(cells.get(c, "") for c in self.columns), key=run)
249
256
  table.move_cursor(row=row)
250
257
 
258
+ def waiting_cells(self, run: str, args: str) -> dict[str, object]:
259
+ shown = {"run": run.rpartition("/")[2], "params": args, "status": PENDING}
260
+ return {
261
+ column: dim(shown.get(column, "")) for column in {*self.columns, *shown}
262
+ }
263
+
251
264
  def set_cell(self, run: str, column: str, cell: object) -> None:
252
- self.cells[run][COLUMNS.index(column)] = cell
265
+ self.cells[run][column] = cell
253
266
  table = self.query_one(DataTable)
254
- if run in table.rows:
267
+ if run in table.rows and column in self.columns:
255
268
  table.update_cell(run, column, cell, update_width=True)
256
269
 
257
270
  def check_action(self, action: str, parameters: tuple[object, ...]) -> bool:
@@ -275,6 +288,31 @@ class RunApp(App[None]):
275
288
  wait = format_elapsed(max(0, due - now))
276
289
  outcome = f"{FAILED} exited {RETRY_EXIT_CODE}, retry in {wait}"
277
290
  self.set_cell(run, "status", Text(outcome, style="yellow"))
291
+ if self.started:
292
+ self.measure()
293
+
294
+ def measure(self) -> None:
295
+ if not self.sampling:
296
+ self.sampling = True
297
+ self.sample()
298
+
299
+ @work
300
+ async def sample(self) -> None:
301
+ try:
302
+ for run in list(self.started):
303
+ usage = await run_usage(self.folders[run], True)
304
+ if run in self.started:
305
+ self.show_usage(run, usage)
306
+ while self.unmeasured:
307
+ run = next(iter(self.unmeasured))
308
+ del self.unmeasured[run]
309
+ self.show_usage(run, await run_usage(self.folders[run], False))
310
+ finally:
311
+ self.sampling = False
312
+
313
+ def show_usage(self, run: str, usage: dict[str, int | None]) -> None:
314
+ self.set_cell(run, "memory", format_bytes(usage["memory"]))
315
+ self.set_cell(run, "size", format_bytes(usage["size"]))
278
316
 
279
317
  def spin(self) -> None:
280
318
  self.frame = (self.frame + 1) % len(SPINNER)
@@ -1,9 +1,10 @@
1
1
  from labnotes.models import Start
2
+ from labnotes.tui.config import RUN_COLUMNS
2
3
  from labnotes.tui.run_app import RunApp
3
4
 
4
5
 
5
6
  def run_tui(start: Start, title: str) -> list[dict[str, object]]:
6
- app = RunApp(start, title)
7
+ app = RunApp(start, title, RUN_COLUMNS)
7
8
  app.run()
8
9
  if app.error is not None:
9
10
  raise app.error
File without changes
@@ -0,0 +1,5 @@
1
+ from rich.filesize import decimal
2
+
3
+
4
+ def format_bytes(count: int | None) -> str:
5
+ return "" if count is None else decimal(count)
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+
3
+ from dirsql import DirSQL
4
+
5
+ from labnotes.utils.tree_rss import tree_rss
6
+
7
+
8
+ def field(name: str) -> str:
9
+ return f"(SELECT content ->> '{name}' FROM './run.json' WHERE json_valid(content))"
10
+
11
+
12
+ USAGE = (
13
+ "SELECT (SELECT SUM(size) FROM './**') AS size,"
14
+ f" {field('pid')} AS pid, {field('pid_created')} AS created,"
15
+ f" coalesce({field('ended')}, {field('exit_code')}, {field('error')}) AS done"
16
+ )
17
+
18
+
19
+ async def run_usage(folder: Path, running: bool) -> dict[str, int | None]:
20
+ # Run folders are often gitignored (DVC, large outputs); size them anyway.
21
+ [row] = await DirSQL(str(folder), tables=[], no_ignore=True).query(USAGE)
22
+ pid, created = row["pid"], row["created"]
23
+ memory = None
24
+ if (
25
+ running
26
+ and row["done"] is None
27
+ and isinstance(pid, int)
28
+ and isinstance(created, int | float)
29
+ ):
30
+ memory = tree_rss(pid, created)
31
+ return {"memory": memory, "size": row["size"]}
@@ -0,0 +1,27 @@
1
+ import contextlib
2
+
3
+ import psutil
4
+
5
+ # run.json round-trips create_time through JSON, and psutil derives it from
6
+ # boot time, so allow a little slack when matching.
7
+ SAME_START_SECONDS = 0.01
8
+
9
+
10
+ def tree_rss(pid: int, created: float) -> int | None:
11
+ try:
12
+ root = psutil.Process(pid)
13
+ if abs(root.create_time() - created) > SAME_START_SECONDS:
14
+ return None
15
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
16
+ return None
17
+ try:
18
+ tree = [root, *root.children(recursive=True)]
19
+ except psutil.NoSuchProcess:
20
+ return None
21
+ except psutil.AccessDenied:
22
+ tree = [root]
23
+ sizes = []
24
+ for process in tree:
25
+ with contextlib.suppress(psutil.NoSuchProcess, psutil.AccessDenied):
26
+ sizes.append(process.memory_info().rss)
27
+ return sum(sizes) if sizes else None
@@ -75,3 +75,9 @@ path = "src/labnotes/runner/exit_signal.py"
75
75
  rules = ["mutation"]
76
76
  lines = [10]
77
77
  reason = "exit_code is non-negative here; when bit 7 is set, - 128 equals ^ 128, and when it is clear ^ 128 lands above NSIG like a non-positive difference"
78
+
79
+ [[python.exempt]]
80
+ path = "src/labnotes/experiments.py"
81
+ rules = ["mutation"]
82
+ lines = [109]
83
+ reason = "click hands back the \"json\" choice literal itself, so is not equals !="
File without changes
File without changes
File without changes
File without changes
File without changes