labnotes 0.2.16__tar.gz → 0.2.18__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 (234) hide show
  1. {labnotes-0.2.16 → labnotes-0.2.18}/PKG-INFO +1 -1
  2. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/cli.py +2 -0
  3. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/create_app.py +1 -1
  4. labnotes-0.2.18/src/labnotes/daemon/__init__.py +3 -0
  5. labnotes-0.2.18/src/labnotes/daemon/client.py +35 -0
  6. labnotes-0.2.18/src/labnotes/daemon/command.py +32 -0
  7. labnotes-0.2.18/src/labnotes/daemon/ensure.py +46 -0
  8. labnotes-0.2.18/src/labnotes/daemon/marker.py +27 -0
  9. labnotes-0.2.18/src/labnotes/daemon/server.py +237 -0
  10. labnotes-0.2.18/src/labnotes/daemon/socket_path.py +51 -0
  11. labnotes-0.2.18/src/labnotes/daemon_command.py +20 -0
  12. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiments.py +85 -18
  13. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/list_experiments.py +15 -4
  14. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/describe_event.py +2 -0
  15. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiment_rows.py +6 -0
  16. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/index.html +1 -0
  17. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/experiments_screen.py +10 -0
  18. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/leave_prompt.py +18 -0
  19. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_app.py +129 -8
  20. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_tui.py +9 -2
  21. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/runs_browser.py +14 -20
  22. {labnotes-0.2.16 → labnotes-0.2.18}/testing-conventions.toml +43 -2
  23. {labnotes-0.2.16 → labnotes-0.2.18}/.gitignore +0 -0
  24. {labnotes-0.2.16 → labnotes-0.2.18}/CHANGELOG.md +0 -0
  25. {labnotes-0.2.16 → labnotes-0.2.18}/MIGRATIONS.md +0 -0
  26. {labnotes-0.2.16 → labnotes-0.2.18}/README.md +0 -0
  27. {labnotes-0.2.16 → labnotes-0.2.18}/conftest.py +0 -0
  28. {labnotes-0.2.16 → labnotes-0.2.18}/pyproject.toml +0 -0
  29. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/__init__.py +0 -0
  30. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/__main__.py +0 -0
  31. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/asset_url.py +0 -0
  32. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/check_counts.py +0 -0
  33. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/command_line_group.py +0 -0
  34. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/compose_experiment.py +0 -0
  35. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/config_migration.py +0 -0
  36. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/context.md +0 -0
  37. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/context_guide.py +0 -0
  38. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/copy_experiment.py +0 -0
  39. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/count_groups.py +0 -0
  40. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/count_runs.py +0 -0
  41. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/create_experiment.py +0 -0
  42. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/create_note.py +0 -0
  43. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/data_html.py +0 -0
  44. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/default_config_text.py +0 -0
  45. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/default_counts.py +0 -0
  46. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/drain_watch.py +0 -0
  47. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiment_definition.py +0 -0
  48. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiment_notes.py +0 -0
  49. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiment_runs.py +0 -0
  50. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/file_changes.py +0 -0
  51. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/format_duration.py +0 -0
  52. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/format_started.py +0 -0
  53. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/format_value.py +0 -0
  54. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/get_state.py +0 -0
  55. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/grid_cell.py +0 -0
  56. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/group_runs.py +0 -0
  57. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/init.py +0 -0
  58. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/invocation_started.py +0 -0
  59. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/is_non_negative_int.py +0 -0
  60. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/is_non_negative_number.py +0 -0
  61. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/is_positive_int.py +0 -0
  62. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/latest_log.py +0 -0
  63. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/list_runs.py +0 -0
  64. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/load_config.py +0 -0
  65. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/load_experiment.py +0 -0
  66. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/load_run.py +0 -0
  67. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/log.py +0 -0
  68. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/log_path.py +0 -0
  69. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/memory_interval.py +0 -0
  70. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/models.py +0 -0
  71. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/nests_runs.py +0 -0
  72. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/note_path.py +0 -0
  73. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/notebook_kernel.py +0 -0
  74. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/output_html.py +0 -0
  75. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/param_names.py +0 -0
  76. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_param_value.py +0 -0
  77. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_params.py +0 -0
  78. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_run_view.py +0 -0
  79. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_subset.py +0 -0
  80. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/pivot_runs.py +0 -0
  81. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/print_rows.py +0 -0
  82. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/python_blocks.py +0 -0
  83. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/read_config.py +0 -0
  84. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/read_path.py +0 -0
  85. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/render_markdown.py +0 -0
  86. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/retries.py +0 -0
  87. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/run_columns.py +0 -0
  88. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/run_output.py +0 -0
  89. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/__init__.py +0 -0
  90. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/cgroup_memory.py +0 -0
  91. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/check_args.py +0 -0
  92. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/check_hook_placeholders.py +0 -0
  93. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/check_subset.py +0 -0
  94. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/claim_run.py +0 -0
  95. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/command_flags.py +0 -0
  96. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/container_cgroup.py +0 -0
  97. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/container_memory.py +0 -0
  98. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/continue_run.py +0 -0
  99. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/docker_output.py +0 -0
  100. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/execute_run.py +0 -0
  101. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/exit_signal.py +0 -0
  102. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/fill_dirs.py +0 -0
  103. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/fill_experiment_env.py +0 -0
  104. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/finalize_lost_runs.py +0 -0
  105. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/first_invocation.py +0 -0
  106. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/flag_name.py +0 -0
  107. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/group_args.py +0 -0
  108. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/hold_experiment.py +0 -0
  109. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/in_subset.py +0 -0
  110. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/invocation_hook.py +0 -0
  111. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/invocation_meta.py +0 -0
  112. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/new_run_id.py +0 -0
  113. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  114. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/override_flags.py +0 -0
  115. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/param_args.py +0 -0
  116. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/param_grid.py +0 -0
  117. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/placeholders.py +0 -0
  118. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/plan_runs.py +0 -0
  119. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/prepare_experiment.py +0 -0
  120. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/project_snapshot.py +0 -0
  121. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/read_experiment_config.py +0 -0
  122. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/record_memory.py +0 -0
  123. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_experiment.py +0 -0
  124. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_folder.py +0 -0
  125. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_git.py +0 -0
  126. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_headless.py +0 -0
  127. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_locked.py +0 -0
  128. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_state.py +0 -0
  129. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/runs_parent.py +0 -0
  130. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/shell_command.py +0 -0
  131. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/signal_tree.py +0 -0
  132. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/start_experiment.py +0 -0
  133. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/stop_then_kill.py +0 -0
  134. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/untracked_diff.py +0 -0
  135. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/wait_for_tree.py +0 -0
  136. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/write_run_json.py +0 -0
  137. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runs_view_command.py +0 -0
  138. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/save_conflict_copy.py +0 -0
  139. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/save_note.py +0 -0
  140. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/saved_outputs.py +0 -0
  141. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/serve_app.py +0 -0
  142. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/server.py +0 -0
  143. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/set_state.py +0 -0
  144. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/shared_params.py +0 -0
  145. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/sort_href.py +0 -0
  146. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/sql_quote.py +0 -0
  147. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/state_path.py +0 -0
  148. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/elapsed.js +0 -0
  149. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/experiment.js +0 -0
  150. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/labnotes.css +0 -0
  151. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/local-time.js +0 -0
  152. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/new-note.js +0 -0
  153. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/note-edit.js +0 -0
  154. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/note-editor.js +0 -0
  155. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/row-link.js +0 -0
  156. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/run-cell.js +0 -0
  157. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/run-output.js +0 -0
  158. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/view-toggle.js +0 -0
  159. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/summarize_config.py +0 -0
  160. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/__init__.py +0 -0
  161. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiment_run_ids.py +0 -0
  162. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiment_toml.py +0 -0
  163. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiments_ddl.py +0 -0
  164. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/file_prefix.py +0 -0
  165. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/file_text.py +0 -0
  166. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/iso_time.py +0 -0
  167. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/list_experiment_runs.py +0 -0
  168. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/list_resumable_runs.py +0 -0
  169. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/note_text.py +0 -0
  170. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/open_tables.py +0 -0
  171. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/path_glob.py +0 -0
  172. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/path_pattern.py +0 -0
  173. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_blocks.py +0 -0
  174. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_experiment.py +0 -0
  175. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_file.py +0 -0
  176. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_log.py +0 -0
  177. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_memory.py +0 -0
  178. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_run.py +0 -0
  179. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_run_file.py +0 -0
  180. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/runs_columns.py +0 -0
  181. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/summarize_experiments.py +0 -0
  182. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/base.html +0 -0
  183. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/editor_imports.html +0 -0
  184. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/experiment.html +0 -0
  185. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/note.html +0 -0
  186. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/run.html +0 -0
  187. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/__init__.py +0 -0
  188. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/browse_app.py +0 -0
  189. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/browse_tui.py +0 -0
  190. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/check_view_response.py +0 -0
  191. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/config.py +0 -0
  192. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/dim.py +0 -0
  193. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/experiment_screen.py +0 -0
  194. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/fill_table.py +0 -0
  195. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/format_cell.py +0 -0
  196. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/format_elapsed.py +0 -0
  197. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/guard_view.py +0 -0
  198. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/load_view.py +0 -0
  199. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/log_block.py +0 -0
  200. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/log_blocks.py +0 -0
  201. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/native_rows.py +0 -0
  202. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/reload_on_change.py +0 -0
  203. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_cells.py +0 -0
  204. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_output.py +0 -0
  205. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_panel.py +0 -0
  206. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/runs_table.py +0 -0
  207. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/runs_view.py +0 -0
  208. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/stopped_title.py +0 -0
  209. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/table_columns.py +0 -0
  210. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/table_rows.py +0 -0
  211. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_column.py +0 -0
  212. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_pane.py +0 -0
  213. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_process.py +0 -0
  214. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_switcher.py +0 -0
  215. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/__init__.py +0 -0
  216. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/format_bytes.py +0 -0
  217. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/process_alive.py +0 -0
  218. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/run_usage.py +0 -0
  219. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/tree_rss.py +0 -0
  220. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/tree_usage.py +0 -0
  221. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/__init__.py +0 -0
  222. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/answer.py +0 -0
  223. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/build.py +0 -0
  224. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/changed.py +0 -0
  225. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/experiment.py +0 -0
  226. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/respond.py +0 -0
  227. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/serve.py +0 -0
  228. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view_specs.py +0 -0
  229. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/wait_for_note.py +0 -0
  230. {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/watch_rows.py +0 -0
  231. {labnotes-0.2.16 → labnotes-0.2.18}/tests/conftest.py +0 -0
  232. {labnotes-0.2.16 → labnotes-0.2.18}/tests/e2e/conftest.py +0 -0
  233. {labnotes-0.2.16 → labnotes-0.2.18}/tests/integration/conftest.py +0 -0
  234. {labnotes-0.2.16 → labnotes-0.2.18}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.2.16
3
+ Version: 0.2.18
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>
@@ -4,6 +4,7 @@ import click
4
4
 
5
5
  from labnotes.command_line_group import CommandLineGroup
6
6
  from labnotes.context_guide import context_guide
7
+ from labnotes.daemon_command import daemon
7
8
  from labnotes.experiments import experiments
8
9
  from labnotes.init import init
9
10
  from labnotes.load_config import load_config
@@ -42,3 +43,4 @@ def context(ctx: click.Context) -> None:
42
43
  main.add_command(experiments)
43
44
  main.add_command(init)
44
45
  main.add_command(server)
46
+ main.add_command(daemon)
@@ -63,7 +63,7 @@ def create_app(config: LabnotesConfig) -> Starlette:
63
63
  await kernel.stop()
64
64
 
65
65
  async def index(request: Request) -> Response:
66
- experiments = await list_experiments(request.app.state.tables)
66
+ experiments = await list_experiments(request.app.state.tables, config)
67
67
  return templates.TemplateResponse(
68
68
  request,
69
69
  "index.html",
@@ -0,0 +1,3 @@
1
+ """One local scheduler process per experiment and labnotes version."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,35 @@
1
+ """Read the socket record and confirm its daemon owns the lock."""
2
+
3
+ import fcntl
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from labnotes.daemon.socket_path import lock_path, socket_record
8
+ from labnotes.models import LabnotesConfig
9
+
10
+
11
+ def daemon_socket(config: LabnotesConfig, name: str) -> Path | None:
12
+ """Return a socket only while its daemon owns the version-keyed lock."""
13
+ record = socket_record(config, name)
14
+ try:
15
+ path = Path(record.read_text().strip())
16
+ except FileNotFoundError:
17
+ return None
18
+ lock = lock_path(config, name)
19
+ try:
20
+ descriptor = os.open(lock, os.O_RDONLY)
21
+ except FileNotFoundError:
22
+ path.unlink(missing_ok=True)
23
+ record.unlink(missing_ok=True)
24
+ return None
25
+ try:
26
+ try:
27
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
28
+ except BlockingIOError:
29
+ return path
30
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
31
+ finally:
32
+ os.close(descriptor)
33
+ path.unlink(missing_ok=True)
34
+ record.unlink(missing_ok=True)
35
+ return None
@@ -0,0 +1,32 @@
1
+ """Exchange one JSON command and one reply with a live daemon."""
2
+
3
+ import json
4
+ import socket
5
+
6
+ from labnotes.daemon.client import daemon_socket
7
+ from labnotes.daemon.socket_path import socket_record
8
+ from labnotes.models import LabnotesConfig
9
+
10
+
11
+ def command(
12
+ config: LabnotesConfig, name: str, message: dict[str, object]
13
+ ) -> dict[str, object]:
14
+ """Send a request and read exactly one reply from the live daemon."""
15
+ path = daemon_socket(config, name)
16
+ if path is None:
17
+ raise ValueError(f"No daemon is running for {name}.")
18
+ try:
19
+ with socket.socket(socket.AF_UNIX) as client:
20
+ client.settimeout(10)
21
+ client.connect(str(path))
22
+ client.sendall((json.dumps(message) + "\n").encode())
23
+ with client.makefile("r") as replies:
24
+ line = replies.readline()
25
+ except OSError as error:
26
+ path.unlink(missing_ok=True)
27
+ socket_record(config, name).unlink(missing_ok=True)
28
+ raise ValueError(f"No daemon is running for {name}.") from error
29
+ if not line:
30
+ raise ValueError(f"Daemon for {name} closed without a reply.")
31
+ reply: dict[str, object] = json.loads(line)
32
+ return reply
@@ -0,0 +1,46 @@
1
+ """Start a detached experiment daemon when no live owner exists."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ import sys
6
+
7
+ from labnotes.daemon.client import daemon_socket
8
+ from labnotes.daemon.socket_path import discovery_folder
9
+ from labnotes.models import LabnotesConfig
10
+
11
+ START_SECONDS = 10
12
+
13
+
14
+ async def ensure_daemon(config: LabnotesConfig, name: str) -> None:
15
+ """Return once a detached daemon holds the experiment's socket."""
16
+ if daemon_socket(config, name) is not None:
17
+ return
18
+ folder = discovery_folder(config, name)
19
+ folder.mkdir(parents=True, exist_ok=True)
20
+ errors = folder / "daemon.stderr"
21
+ errors.touch(mode=0o600, exist_ok=True)
22
+ with errors.open("ab") as stream:
23
+ process = subprocess.Popen(
24
+ [
25
+ sys.executable,
26
+ "-m",
27
+ "labnotes",
28
+ "-c",
29
+ str(config.path.resolve()),
30
+ "daemon",
31
+ name,
32
+ ],
33
+ cwd=config.root,
34
+ stdin=subprocess.DEVNULL,
35
+ stdout=subprocess.DEVNULL,
36
+ stderr=stream,
37
+ start_new_session=True,
38
+ )
39
+ for _ in range(START_SECONDS * 10):
40
+ if daemon_socket(config, name) is not None:
41
+ return
42
+ if (status := process.poll()) is not None and status != 0:
43
+ detail = errors.read_text().strip()
44
+ raise ValueError(detail or f"Daemon for {name} exited during startup.")
45
+ await asyncio.sleep(0.1)
46
+ raise ValueError(f"Daemon for {name} did not start in {START_SECONDS}s.")
@@ -0,0 +1,27 @@
1
+ """Read the running-cell count from run folders for a live daemon."""
2
+
3
+ from dirsql import DirSQL
4
+
5
+ from labnotes.daemon.client import daemon_socket
6
+ from labnotes.models import LabnotesConfig
7
+ from labnotes.runner.claim_run import LOCK_FILE
8
+ from labnotes.runner.run_locked import run_locked
9
+ from labnotes.sql_quote import quote_string
10
+
11
+
12
+ async def daemon_marker(
13
+ tables: DirSQL, config: LabnotesConfig, name: str
14
+ ) -> dict[str, object]:
15
+ """Mark a live owner and count its unfinished cells from disk-backed rows."""
16
+ if daemon_socket(config, name) is None:
17
+ return {"daemon": False, "running_cells": 0}
18
+ rows = await tables.query(
19
+ "SELECT folder FROM runs"
20
+ f" WHERE experiment = {quote_string(name)} AND ended IS NULL"
21
+ )
22
+ running = 0
23
+ for row in rows:
24
+ folder = config.root / str(row["folder"])
25
+ if (folder / LOCK_FILE).exists() and run_locked(folder):
26
+ running += 1
27
+ return {"daemon": True, "running_cells": running}
@@ -0,0 +1,237 @@
1
+ """Own experiment invocations behind a private request/response socket."""
2
+
3
+ import asyncio
4
+ import fcntl
5
+ import json
6
+ import os
7
+ import signal
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ from labnotes.daemon.socket_path import lock_path, socket_path, socket_record
14
+ from labnotes.models import Controls, LabnotesConfig
15
+ from labnotes.runner.start_experiment import start_experiment
16
+
17
+ IDLE_SECONDS = 5.0
18
+ START_WAIT_SECONDS = 30.0
19
+
20
+
21
+ @dataclass
22
+ class Invocation:
23
+ """Keep controls with the one invocation that produced a journal."""
24
+
25
+ task: asyncio.Task[None] | None = None
26
+ controls: Controls | None = None
27
+ stop_requested: bool = False
28
+
29
+
30
+ class ExperimentDaemon:
31
+ """Serve commands while cells run or a client remains attached."""
32
+
33
+ def __init__(self, config: LabnotesConfig, name: str) -> None:
34
+ self.config = config
35
+ self.name = name
36
+ self.active: dict[str, Invocation] = {}
37
+ self.clients = 0
38
+ self.closing = False
39
+ self.last_busy = time.monotonic()
40
+
41
+ def stop_all(self) -> None:
42
+ """Ask every invocation to stop, including one still preparing."""
43
+ for invocation in self.active.values():
44
+ invocation.stop_requested = True
45
+ if invocation.controls is not None:
46
+ invocation.controls.interrupt()
47
+
48
+ async def start(self, options: dict[str, object]) -> dict[str, object]:
49
+ """Start one independent invocation and return its journal path."""
50
+ selected = start_experiment(
51
+ self.config,
52
+ self.name,
53
+ Path(str(options["experiment_config"]))
54
+ if options.get("experiment_config") is not None
55
+ else None,
56
+ cast(int | None, options.get("concurrency")),
57
+ cast(int | None, options.get("timeout")),
58
+ cast(dict[str, list[str]] | None, options.get("only")),
59
+ cast(list[str] | None, options.get("args")),
60
+ cast(str | None, options.get("setup")),
61
+ cast(str | None, options.get("teardown")),
62
+ cast(str | None, options.get("command_line")),
63
+ cast(str | None, options.get("run_id")),
64
+ )
65
+ path = str(selected.run_state.path)
66
+ invocation = Invocation()
67
+ self.active[path] = invocation
68
+ ready: asyncio.Future[None] = asyncio.get_running_loop().create_future()
69
+
70
+ def listen(controls: Controls) -> None:
71
+ invocation.controls = controls
72
+ controls.finish()
73
+ if invocation.stop_requested:
74
+ controls.interrupt()
75
+ if not ready.done():
76
+ ready.set_result(None)
77
+
78
+ async def run() -> None:
79
+ try:
80
+ await selected(lambda event: None, listen)
81
+ except Exception as error:
82
+ if not ready.done():
83
+ ready.set_exception(error)
84
+ else:
85
+ selected.run_state.record(
86
+ {"event": "daemon error", "error": str(error)}
87
+ )
88
+ finally:
89
+ self.active.pop(path, None)
90
+
91
+ task = asyncio.create_task(run())
92
+ invocation.task = task
93
+ try:
94
+ await asyncio.wait_for(ready, timeout=START_WAIT_SECONDS)
95
+ except TimeoutError:
96
+ task.cancel()
97
+ await asyncio.gather(task, return_exceptions=True)
98
+ return {"ok": False, "error": "Experiment preparation timed out"}
99
+ except ValueError as error:
100
+ return {"ok": False, "error": str(error)}
101
+ return {"ok": True, "journal": path}
102
+
103
+ async def dispatch(self, message: dict[str, object]) -> dict[str, object]:
104
+ """Apply one command and provide one success or error reply."""
105
+ action = message.get("command")
106
+ if action == "ping":
107
+ return {"ok": True}
108
+ if action in ("start", "restart"):
109
+ options = message.get("options", {})
110
+ if not isinstance(options, dict):
111
+ return {"ok": False, "error": "options must be an object"}
112
+ if action == "restart" and "run_id" not in options:
113
+ return {"ok": False, "error": "restart needs run_id"}
114
+ return await self.start(options)
115
+ if action == "stop_all":
116
+ self.stop_all()
117
+ return {"ok": True}
118
+ if action == "stop_one":
119
+ run = message.get("run")
120
+ if not isinstance(run, str):
121
+ return {"ok": False, "error": "stop_one needs a run id"}
122
+ for invocation in self.active.values():
123
+ if invocation.controls is not None:
124
+ invocation.controls.kill(run)
125
+ return {"ok": True}
126
+ if action == "set_concurrency":
127
+ count = message.get("count")
128
+ if not isinstance(count, int) or count < 1:
129
+ return {"ok": False, "error": "count must be positive"}
130
+ journal = message.get("journal")
131
+ targets = (
132
+ [self.active[str(journal)]]
133
+ if journal is not None and str(journal) in self.active
134
+ else list(self.active.values())
135
+ if journal is None
136
+ else []
137
+ )
138
+ if not targets:
139
+ return {"ok": False, "error": "No active invocation"}
140
+ for invocation in targets:
141
+ if invocation.controls is not None:
142
+ invocation.controls.set_concurrency(count)
143
+ return {"ok": True}
144
+ if action == "attach":
145
+ return {"ok": True}
146
+ return {"ok": False, "error": f"Unknown command {action}"}
147
+
148
+ async def handle(
149
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
150
+ ) -> None:
151
+ """Hold an attach connection open without streaming events to it."""
152
+ self.clients += 1
153
+ try:
154
+ line = await reader.readline()
155
+ if not line:
156
+ return
157
+ message = json.loads(line)
158
+ if not isinstance(message, dict):
159
+ reply = {"ok": False, "error": "Request must be an object"}
160
+ else:
161
+ try:
162
+ reply = await self.dispatch(message)
163
+ except Exception as error:
164
+ # A request always gets one error reply, even on internal failures.
165
+ reply = {"ok": False, "error": str(error)}
166
+ writer.write((json.dumps(reply) + "\n").encode())
167
+ await writer.drain()
168
+ if isinstance(message, dict) and message.get("command") == "attach":
169
+ await reader.read()
170
+ except (json.JSONDecodeError, asyncio.LimitOverrunError):
171
+ writer.write(b'{"ok":false,"error":"Invalid request"}\n')
172
+ await writer.drain()
173
+ finally:
174
+ self.clients -= 1
175
+ writer.close()
176
+ await writer.wait_closed()
177
+
178
+ async def wait_until_idle(self) -> None:
179
+ """Leave a short window for another TUI after the last cell ends."""
180
+ while True:
181
+ if self.closing:
182
+ return
183
+ if self.active or self.clients:
184
+ self.last_busy = time.monotonic()
185
+ elif time.monotonic() - self.last_busy >= IDLE_SECONDS:
186
+ return
187
+ await asyncio.sleep(0.2)
188
+
189
+
190
+ async def serve_daemon(config: LabnotesConfig, name: str) -> None:
191
+ """Hold the experiment/version lock for one Unix server lifetime."""
192
+ lock = lock_path(config, name)
193
+ lock.parent.mkdir(parents=True, exist_ok=True)
194
+ lock.touch(mode=0o600, exist_ok=True)
195
+ descriptor = os.open(lock, os.O_RDONLY)
196
+ try:
197
+ for _ in range(10):
198
+ try:
199
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
200
+ except BlockingIOError:
201
+ await asyncio.sleep(0.1)
202
+ else:
203
+ break
204
+ else:
205
+ return
206
+ path = socket_path(config, name)
207
+ path.unlink(missing_ok=True)
208
+ daemon = ExperimentDaemon(config, name)
209
+ server = await asyncio.start_unix_server(daemon.handle, path=str(path))
210
+ path.chmod(0o600)
211
+ record = socket_record(config, name)
212
+ pending = record.with_suffix(".socket.tmp")
213
+ pending.write_text(str(path), encoding="utf-8")
214
+ pending.chmod(0o600)
215
+ pending.replace(record)
216
+ loop = asyncio.get_running_loop()
217
+
218
+ def close() -> None:
219
+ daemon.closing = True
220
+ daemon.stop_all()
221
+
222
+ loop.add_signal_handler(signal.SIGTERM, close)
223
+ loop.add_signal_handler(signal.SIGINT, close)
224
+ loop.add_signal_handler(signal.SIGHUP, lambda: None)
225
+ try:
226
+ await daemon.wait_until_idle()
227
+ finally:
228
+ server.close()
229
+ await server.wait_closed()
230
+ daemon.stop_all()
231
+ await asyncio.gather(
232
+ *(item.task for item in daemon.active.values() if item.task is not None)
233
+ )
234
+ record.unlink(missing_ok=True)
235
+ path.unlink(missing_ok=True)
236
+ finally:
237
+ os.close(descriptor)
@@ -0,0 +1,51 @@
1
+ """Locate the private Unix socket and its on-disk discovery record."""
2
+
3
+ import hashlib
4
+ import os
5
+ import tempfile
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+
9
+ from labnotes.models import LabnotesConfig
10
+ from labnotes.runner.run_state import journal_folder
11
+ from labnotes.runner.runs_parent import runs_parent
12
+
13
+
14
+ def daemon_key(config: LabnotesConfig, name: str) -> str:
15
+ """Key an experiment by its resolved path and installed package version."""
16
+ folder = config.root / config.experiments.replace("{experiment}", name)
17
+ identity = f"{folder.resolve()}\0{version('labnotes')}"
18
+ return hashlib.sha256(identity.encode()).hexdigest()[:16]
19
+
20
+
21
+ def runtime_folder() -> Path:
22
+ """Keep socket names short even when the experiment path is deep."""
23
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
24
+ folder = (
25
+ Path(runtime) / "labnotes"
26
+ if runtime
27
+ else Path(tempfile.gettempdir()) / f"labnotes-{os.getuid()}"
28
+ )
29
+ folder.mkdir(mode=0o700, parents=True, exist_ok=True)
30
+ folder.chmod(0o700)
31
+ return folder
32
+
33
+
34
+ def socket_path(config: LabnotesConfig, name: str) -> Path:
35
+ """Return the version-keyed bind path outside the experiment tree."""
36
+ return runtime_folder() / f"{daemon_key(config, name)}.sock"
37
+
38
+
39
+ def discovery_folder(config: LabnotesConfig, name: str) -> Path:
40
+ """Find daemon metadata alongside the experiment's run journals."""
41
+ return journal_folder(runs_parent(config.root, config.runs, name))
42
+
43
+
44
+ def socket_record(config: LabnotesConfig, name: str) -> Path:
45
+ """Point clients to the current socket without guessing runtime settings."""
46
+ return discovery_folder(config, name) / f"{daemon_key(config, name)}.socket"
47
+
48
+
49
+ def lock_path(config: LabnotesConfig, name: str) -> Path:
50
+ """Serialize one daemon per experiment and package version."""
51
+ return discovery_folder(config, name) / f"{daemon_key(config, name)}.lock"
@@ -0,0 +1,20 @@
1
+ """Internal CLI entry point for a detached experiment daemon."""
2
+
3
+ import asyncio
4
+
5
+ import click
6
+
7
+ from labnotes.daemon.server import serve_daemon
8
+ from labnotes.load_config import load_config
9
+ from labnotes.read_config import CONFIG_FILE
10
+
11
+
12
+ @click.command(hidden=True)
13
+ @click.argument("name")
14
+ @click.pass_context
15
+ def daemon(ctx: click.Context, name: str) -> None:
16
+ """Serve one experiment from a detached process."""
17
+ config = load_config(ctx)
18
+ if config is None:
19
+ raise click.ClickException(f"No {CONFIG_FILE} here.")
20
+ asyncio.run(serve_daemon(config, name))
@@ -6,7 +6,10 @@ import click
6
6
 
7
7
  from labnotes.command_line_group import COMMAND_LINE
8
8
  from labnotes.create_experiment import create_experiment
9
+ from labnotes.daemon.command import command as daemon_command
10
+ from labnotes.daemon.ensure import ensure_daemon
9
11
  from labnotes.runner.start_experiment import start_experiment
12
+ from labnotes.runner.run_state import RunState
10
13
  from labnotes.utils.format_bytes import format_bytes
11
14
  from labnotes.load_config import load_config
12
15
  from labnotes.parse_params import parse_params
@@ -43,7 +46,7 @@ class PassthroughCommand(click.Command):
43
46
  context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
44
47
  )
45
48
  @click.argument("name", required=False)
46
- @click.argument("action", required=False, metavar="[runs|run]")
49
+ @click.argument("action", required=False, metavar="[runs|run|stop]")
47
50
  @click.option(
48
51
  "--format",
49
52
  "output_format",
@@ -58,6 +61,11 @@ class PassthroughCommand(click.Command):
58
61
  " .labnotes.toml; 1 if none sets it.",
59
62
  )
60
63
  @click.option("--run-id", help="With run: restart only this failed run by id.")
64
+ @click.option(
65
+ "--headless",
66
+ is_flag=True,
67
+ help="With run: keep the scheduler in this foreground process.",
68
+ )
61
69
  @click.option(
62
70
  "-e",
63
71
  "--experiment-config",
@@ -116,6 +124,7 @@ def experiments(
116
124
  output_format: str | None,
117
125
  concurrency: int | None,
118
126
  run_id: str | None,
127
+ headless: bool,
119
128
  experiment_config: Path | None,
120
129
  timeout: int | None,
121
130
  setup: str | None,
@@ -141,6 +150,8 @@ def experiments(
141
150
  for flag, value in given.items():
142
151
  if value is not None:
143
152
  raise click.UsageError(f"{flag} needs new.")
153
+ if headless and action != "run":
154
+ raise click.UsageError("--headless needs run.")
144
155
  match name, action:
145
156
  case "new", _:
146
157
  try:
@@ -160,24 +171,57 @@ def experiments(
160
171
  only = parse_subset(ctx.args)
161
172
  except ValueError as error:
162
173
  raise click.UsageError(str(error)) from error
163
- start = start_experiment(
164
- config,
165
- name,
166
- experiment_config,
167
- concurrency,
168
- timeout,
169
- only,
170
- passthrough or None,
171
- setup,
172
- teardown,
173
- ctx.meta.get(COMMAND_LINE),
174
- run_id,
175
- )
176
174
  try:
177
- if sys.stdout.isatty() and sys.stderr.isatty():
178
- results = run_tui(start, name, config, experiment_config)
175
+ if not headless and sys.stdout.isatty() and sys.stderr.isatty():
176
+ asyncio.run(ensure_daemon(config, name))
177
+ reply = daemon_command(
178
+ config,
179
+ name,
180
+ {
181
+ "command": "restart" if run_id else "start",
182
+ "options": {
183
+ "experiment_config": str(experiment_config)
184
+ if experiment_config
185
+ else None,
186
+ "concurrency": concurrency,
187
+ "timeout": timeout,
188
+ "only": only,
189
+ "args": passthrough or None,
190
+ "setup": setup,
191
+ "teardown": teardown,
192
+ "command_line": ctx.meta.get(COMMAND_LINE),
193
+ "run_id": run_id,
194
+ },
195
+ },
196
+ )
197
+ if not reply.get("ok"):
198
+ raise ValueError(
199
+ str(reply.get("error", "Daemon rejected start"))
200
+ )
201
+ run_tui(
202
+ None,
203
+ name,
204
+ config,
205
+ experiment_config,
206
+ daemon_journal=RunState(Path(str(reply["journal"]))),
207
+ )
208
+ return
179
209
  else:
180
- results = run_headless(start)
210
+ results = run_headless(
211
+ start_experiment(
212
+ config,
213
+ name,
214
+ experiment_config,
215
+ concurrency,
216
+ timeout,
217
+ only,
218
+ passthrough or None,
219
+ setup,
220
+ teardown,
221
+ ctx.meta.get(COMMAND_LINE),
222
+ run_id,
223
+ )
224
+ )
181
225
  except ValueError as error:
182
226
  raise click.ClickException(str(error)) from error
183
227
  superseded = {r["retry_of"] for r in results if "retry_of" in r}
@@ -190,6 +234,29 @@ def experiments(
190
234
  passed = len(results) - failed - retried
191
235
  click.echo(f"{passed} passed, {failed} failed.{again}")
192
236
  return
237
+ case str(), "stop":
238
+ if (
239
+ output_format is not None
240
+ or concurrency is not None
241
+ or run_id is not None
242
+ or experiment_config is not None
243
+ or timeout is not None
244
+ or setup is not None
245
+ or teardown is not None
246
+ or passthrough is not None
247
+ or ctx.args
248
+ ):
249
+ raise click.UsageError("stop takes no run or listing options.")
250
+ try:
251
+ reply = daemon_command(config, name, {"command": "stop_all"})
252
+ except ValueError as error:
253
+ raise click.ClickException(str(error)) from error
254
+ if not reply.get("ok"):
255
+ raise click.ClickException(
256
+ str(reply.get("error", "Daemon rejected stop"))
257
+ )
258
+ click.echo(f"Asked daemon for {name} to stop all runs.")
259
+ return
193
260
  if passthrough is not None:
194
261
  raise click.UsageError("-- ARGS needs run.")
195
262
  if run_id is not None:
@@ -197,7 +264,7 @@ def experiments(
197
264
  if ctx.args:
198
265
  raise click.UsageError(f"Unexpected {ctx.args[0]}. --PARAM=VALUE needs run.")
199
266
  if action not in (None, "runs"):
200
- raise click.UsageError(f"Unknown action {action}. Use runs or run.")
267
+ raise click.UsageError(f"Unknown action {action}. Use runs, run or stop.")
201
268
  if output_format is None and sys.stdout.isatty() and sys.stderr.isatty():
202
269
  try:
203
270
  browse_tui(config, name)