labnotes 0.0.105__tar.gz → 0.0.107__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.105 → labnotes-0.0.107}/PKG-INFO +1 -1
  2. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/context.md +12 -6
  3. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/describe_status.py +5 -0
  4. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/watch_daemon.py +21 -2
  5. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/experiments.py +8 -0
  6. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/execute_run.py +17 -1
  7. labnotes-0.0.107/src/labnotes/runner/exit_signal.py +11 -0
  8. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/run_experiment.py +1 -0
  9. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/experiment_rows.py +6 -1
  10. labnotes-0.0.107/src/labnotes/tui/config.py +1 -0
  11. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/run_app.py +58 -20
  12. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/run_tui.py +2 -1
  13. labnotes-0.0.107/src/labnotes/utils/__init__.py +0 -0
  14. labnotes-0.0.107/src/labnotes/utils/format_bytes.py +5 -0
  15. labnotes-0.0.107/src/labnotes/utils/run_usage.py +31 -0
  16. labnotes-0.0.107/src/labnotes/utils/tree_rss.py +27 -0
  17. {labnotes-0.0.105 → labnotes-0.0.107}/testing-conventions.toml +12 -0
  18. {labnotes-0.0.105 → labnotes-0.0.107}/.gitignore +0 -0
  19. {labnotes-0.0.105 → labnotes-0.0.107}/CHANGELOG.md +0 -0
  20. {labnotes-0.0.105 → labnotes-0.0.107}/MIGRATIONS.md +0 -0
  21. {labnotes-0.0.105 → labnotes-0.0.107}/README.md +0 -0
  22. {labnotes-0.0.105 → labnotes-0.0.107}/pyproject.toml +0 -0
  23. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/__init__.py +0 -0
  24. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/__main__.py +0 -0
  25. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/asset_url.py +0 -0
  26. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/check_counts.py +0 -0
  27. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/cli.py +0 -0
  28. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/compose_experiment.py +0 -0
  29. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/context_guide.py +0 -0
  30. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/count_groups.py +0 -0
  31. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/count_runs.py +0 -0
  32. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/create_app.py +0 -0
  33. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/create_experiment.py +0 -0
  34. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/create_note.py +0 -0
  35. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/__init__.py +0 -0
  36. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/control_run.py +0 -0
  37. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/daemon_app.py +0 -0
  38. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/daemon_client.py +0 -0
  39. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/daemon_running.py +0 -0
  40. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/daemon_server.py +0 -0
  41. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/ensure_daemon.py +0 -0
  42. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/exit_when_idle.py +0 -0
  43. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/remote_start.py +0 -0
  44. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/run_daemon.py +0 -0
  45. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/run_logs.py +0 -0
  46. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/serve_daemon.py +0 -0
  47. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/socket_path.py +0 -0
  48. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/stop_runs.py +0 -0
  49. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon/watch_status.py +0 -0
  50. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/daemon_command.py +0 -0
  51. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/data_html.py +0 -0
  52. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/default_config_text.py +0 -0
  53. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/default_counts.py +0 -0
  54. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/drain_watch.py +0 -0
  55. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/experiment_notes.py +0 -0
  56. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/experiment_runs.py +0 -0
  57. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/file_changes.py +0 -0
  58. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/format_duration.py +0 -0
  59. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/format_started.py +0 -0
  60. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/format_value.py +0 -0
  61. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/group_runs.py +0 -0
  62. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/init.py +0 -0
  63. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/invocation_started.py +0 -0
  64. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/is_non_negative_int.py +0 -0
  65. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/is_positive_int.py +0 -0
  66. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/list_experiments.py +0 -0
  67. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/list_runs.py +0 -0
  68. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/load_config.py +0 -0
  69. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/load_experiment.py +0 -0
  70. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/load_run.py +0 -0
  71. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/log.py +0 -0
  72. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/log_path.py +0 -0
  73. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/models.py +0 -0
  74. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/modes.py +0 -0
  75. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/nests_runs.py +0 -0
  76. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/note_path.py +0 -0
  77. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/notebook_kernel.py +0 -0
  78. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/output_html.py +0 -0
  79. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/parse_run_view.py +0 -0
  80. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/print_rows.py +0 -0
  81. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/python_blocks.py +0 -0
  82. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/read_config.py +0 -0
  83. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/read_path.py +0 -0
  84. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/render_markdown.py +0 -0
  85. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/retries.py +0 -0
  86. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/run_command.py +0 -0
  87. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/run_control.py +0 -0
  88. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/run_output.py +0 -0
  89. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/run_status.py +0 -0
  90. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/__init__.py +0 -0
  91. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/describe_event.py +0 -0
  92. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/hold_folder.py +0 -0
  93. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/new_param_run_id.py +0 -0
  94. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/new_run_id.py +0 -0
  95. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  96. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/param_args.py +0 -0
  97. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/param_grid.py +0 -0
  98. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/param_run_name.py +0 -0
  99. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/plan_runs.py +0 -0
  100. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/prepare_experiment.py +0 -0
  101. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/read_experiment_config.py +0 -0
  102. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/run_folder.py +0 -0
  103. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/run_headless.py +0 -0
  104. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/runs_parent.py +0 -0
  105. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/shell_command.py +0 -0
  106. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/signal_tree.py +0 -0
  107. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/stop_then_kill.py +0 -0
  108. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/wait_for_tree.py +0 -0
  109. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/runner/write_run_json.py +0 -0
  110. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/save_conflict_copy.py +0 -0
  111. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/save_note.py +0 -0
  112. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/saved_outputs.py +0 -0
  113. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/serve_app.py +0 -0
  114. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/server.py +0 -0
  115. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/shared_params.py +0 -0
  116. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/sort_href.py +0 -0
  117. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/sql_quote.py +0 -0
  118. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/elapsed.js +0 -0
  119. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/experiment.js +0 -0
  120. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/labnotes.css +0 -0
  121. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/local-time.js +0 -0
  122. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/new-note.js +0 -0
  123. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/note-edit.js +0 -0
  124. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/note-editor.js +0 -0
  125. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/row-link.js +0 -0
  126. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/run-cell.js +0 -0
  127. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/run-output.js +0 -0
  128. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/static/view-toggle.js +0 -0
  129. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/summarize_config.py +0 -0
  130. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/__init__.py +0 -0
  131. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/experiment_run_ids.py +0 -0
  132. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/experiment_toml.py +0 -0
  133. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/experiments_ddl.py +0 -0
  134. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/file_prefix.py +0 -0
  135. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/file_text.py +0 -0
  136. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/iso_time.py +0 -0
  137. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/list_experiment_runs.py +0 -0
  138. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/note_text.py +0 -0
  139. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/open_tables.py +0 -0
  140. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/path_glob.py +0 -0
  141. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/path_pattern.py +0 -0
  142. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/read_blocks.py +0 -0
  143. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/read_experiment.py +0 -0
  144. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/read_file.py +0 -0
  145. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/read_log.py +0 -0
  146. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/read_run.py +0 -0
  147. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tables/summarize_experiments.py +0 -0
  148. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/templates/base.html +0 -0
  149. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/templates/editor_imports.html +0 -0
  150. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/templates/experiment.html +0 -0
  151. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/templates/index.html +0 -0
  152. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/templates/note.html +0 -0
  153. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/templates/run.html +0 -0
  154. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/__init__.py +0 -0
  155. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/browse_app.py +0 -0
  156. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/browse_tui.py +0 -0
  157. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/dim.py +0 -0
  158. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/experiment_screen.py +0 -0
  159. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/experiments_screen.py +0 -0
  160. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/fill_table.py +0 -0
  161. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/format_elapsed.py +0 -0
  162. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/group_cells.py +0 -0
  163. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/invocation_columns.py +0 -0
  164. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/invocation_rows.py +0 -0
  165. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/leave_prompt.py +0 -0
  166. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/log_block.py +0 -0
  167. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/log_blocks.py +0 -0
  168. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/reload_on_change.py +0 -0
  169. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/run_cells.py +0 -0
  170. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/run_output.py +0 -0
  171. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/run_screen.py +0 -0
  172. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/runs_table.py +0 -0
  173. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/tui/stopped_title.py +0 -0
  174. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/wait_for_note.py +0 -0
  175. {labnotes-0.0.105 → labnotes-0.0.107}/src/labnotes/watch_rows.py +0 -0
  176. {labnotes-0.0.105 → labnotes-0.0.107}/tests/integration/conftest.py +0 -0
  177. {labnotes-0.0.105 → labnotes-0.0.107}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.105
3
+ Version: 0.0.107
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>
@@ -75,8 +75,11 @@ script so it writes its outputs there.
75
75
  Each param is appended as `--name value`, with the name as written (use
76
76
  `source-language` for a `--source-language` flag), booleans as 0 or 1, and lists
77
77
  and tables as JSON. labnotes writes `run.json` (params, created, ended, exit
78
- code), `stdout.log` and `stderr.log` to the
79
- run folder.
78
+ code, `ended_by`, `signal`), `stdout.log` and `stderr.log` to the
79
+ run folder. `ended_by` is `"timeout"`, `"stop"` (labnotes stopped it),
80
+ `"signal"` (a signal labnotes didn't send, like the out-of-memory killer) or
81
+ null (the script exited itself). `signal` is the signal number read from the
82
+ exit code (negative, or 128 + N from the shell), else null.
80
83
  A run counts when it exited 0 and its `run.json` doesn't hold
81
84
  `"excluded": true`, so excluding a run by hand means adding that key; the next
82
85
  `run` replaces it. Other keys you add to `run.json` are kept and show in `run`.
@@ -109,9 +112,10 @@ shows a waiting retry as `retry in 30s` and the end as `gave up (exit 75)`;
109
112
  `run status --json` reports `retrying`, `retried`, `gave up` and
110
113
  `retry cancelled` events.
111
114
 
112
- 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.
113
116
 
114
- 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
115
119
  open when they finish. Failed runs show their status in red; `f` hides or shows them, and `r` on one
116
120
  runs it again under a new id, with `retry_of` set. `j` and `k` move the cursor, `g` and `G` jump to the
117
121
  first and last row. Runs not started yet show grayed as `pending`, and as
@@ -133,7 +137,8 @@ going. Elsewhere, it prints a line per event instead.
133
137
  - `-e PATH` reads another file instead of `experiment.toml`.
134
138
  - `--timeout SECONDS` stops any one run still going after that many seconds:
135
139
  SIGTERM, then SIGKILL 5 seconds later. Its
136
- `run.json` gets `"error": "timed out after ..."` next to its exit code.
140
+ `run.json` gets `"error": "timed out after ..."` and `"ended_by": "timeout"`
141
+ next to its exit code.
137
142
  Unset, runs have no time limit.
138
143
  - `run` hands its runs to the project's labnotes daemon, starting it if
139
144
  needed, and shows or waits for them. The daemon listens on a socket in
@@ -146,7 +151,8 @@ going. Elsewhere, it prints a line per event instead.
146
151
  - `labnotes run stop` stops every run the daemon has going: SIGTERM, then
147
152
  SIGKILL 5 seconds later.
148
153
  - `labnotes run status` prints what the daemon has going, a line per event so
149
- 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
150
156
  waits for a daemon when none is up. `--json` prints one JSON object per line.
151
157
  Following doesn't keep the daemon up.
152
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
@@ -7,6 +8,7 @@ from pathlib import Path
7
8
  import psutil
8
9
 
9
10
  from labnotes.log_path import LOG_ENV
11
+ from labnotes.runner.exit_signal import exit_signal
10
12
  from labnotes.runner.shell_command import shell_command
11
13
  from labnotes.runner.stop_then_kill import GRACE_SECONDS
12
14
  from labnotes.runner.wait_for_tree import wait_for_tree
@@ -26,6 +28,7 @@ async def execute_run(
26
28
  retry_of: str | None = None,
27
29
  timeout: float | None = None,
28
30
  stop: Callable[[], None] = lambda: None,
31
+ stopped: Callable[[], bool] = lambda: False,
29
32
  ) -> dict[str, object]:
30
33
  run: dict[str, object] = {
31
34
  "params": params,
@@ -33,6 +36,8 @@ async def execute_run(
33
36
  "ended": None,
34
37
  "exit_code": None,
35
38
  "error": None,
39
+ "ended_by": None,
40
+ "signal": None,
36
41
  }
37
42
  if retry_of is not None:
38
43
  run["retry_of"] = retry_of
@@ -58,6 +63,10 @@ async def execute_run(
58
63
  run["error"] = str(error)
59
64
  else:
60
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)
61
70
  exited = asyncio.ensure_future(process.wait())
62
71
  kill_later = None
63
72
  try:
@@ -70,7 +79,14 @@ async def execute_run(
70
79
  kill_later = asyncio.get_running_loop().call_later(
71
80
  GRACE_SECONDS, stop
72
81
  )
73
- run["exit_code"] = await exited
82
+ run["exit_code"] = code = await exited
83
+ run["signal"] = exit_signal(code)
84
+ if kill_later is not None:
85
+ run["ended_by"] = "timeout"
86
+ elif stopping.is_set() or stopped():
87
+ run["ended_by"] = "stop"
88
+ elif run["signal"] is not None:
89
+ run["ended_by"] = "signal"
74
90
  # The shell can exit on SIGTERM before what it started. Once
75
91
  # stopping, the run stays killable until everything signalled
76
92
  # is gone. A normal exit ends the run even if helpers linger.
@@ -0,0 +1,11 @@
1
+ import signal
2
+
3
+
4
+ def exit_signal(exit_code: int | None) -> int | None:
5
+ if exit_code is None:
6
+ return None
7
+ if exit_code < 0:
8
+ return -exit_code
9
+ # A shell reports a child killed by signal N as exit 128 + N.
10
+ number = exit_code - 128
11
+ return number if 0 < number < signal.NSIG else None
@@ -255,6 +255,7 @@ async def run_experiment(
255
255
  parents.get(run),
256
256
  timeout=timeout,
257
257
  stop=lambda: kill(run),
258
+ stopped=lambda: run in stopped,
258
259
  )
259
260
  del running[run], trees[run]
260
261
  report({"event": "ended", "run": run, **result})
@@ -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
@@ -69,3 +69,15 @@ path = "src/labnotes/runner/run_experiment.py"
69
69
  rules = ["mutation"]
70
70
  lines = [184, 263]
71
71
  reason = "a retry is only scheduled below the budget and each one adds one, so tried never passes retries: >= equals ==; both sides are small ints, which CPython caches, so is equals =="
72
+
73
+ [[python.exempt]]
74
+ path = "src/labnotes/runner/exit_signal.py"
75
+ rules = ["mutation"]
76
+ lines = [10]
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
File without changes