labnotes 0.2.15__tar.gz → 0.2.17__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.15 → labnotes-0.2.17}/PKG-INFO +1 -1
  2. labnotes-0.2.17/conftest.py +9 -0
  3. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/cli.py +2 -0
  4. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/create_app.py +1 -1
  5. labnotes-0.2.17/src/labnotes/daemon/__init__.py +3 -0
  6. labnotes-0.2.17/src/labnotes/daemon/client.py +35 -0
  7. labnotes-0.2.17/src/labnotes/daemon/command.py +32 -0
  8. labnotes-0.2.17/src/labnotes/daemon/ensure.py +46 -0
  9. labnotes-0.2.17/src/labnotes/daemon/marker.py +27 -0
  10. labnotes-0.2.17/src/labnotes/daemon/server.py +237 -0
  11. labnotes-0.2.17/src/labnotes/daemon/socket_path.py +51 -0
  12. labnotes-0.2.17/src/labnotes/daemon_command.py +20 -0
  13. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiments.py +35 -3
  14. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/list_experiments.py +15 -4
  15. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/describe_event.py +2 -0
  16. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_experiment.py +27 -18
  17. labnotes-0.2.17/src/labnotes/runner/run_state.py +62 -0
  18. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/start_experiment.py +18 -2
  19. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiment_rows.py +6 -0
  20. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/index.html +1 -0
  21. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/experiments_screen.py +10 -0
  22. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_app.py +26 -4
  23. {labnotes-0.2.15 → labnotes-0.2.17}/testing-conventions.toml +43 -2
  24. {labnotes-0.2.15 → labnotes-0.2.17}/.gitignore +0 -0
  25. {labnotes-0.2.15 → labnotes-0.2.17}/CHANGELOG.md +0 -0
  26. {labnotes-0.2.15 → labnotes-0.2.17}/MIGRATIONS.md +0 -0
  27. {labnotes-0.2.15 → labnotes-0.2.17}/README.md +0 -0
  28. {labnotes-0.2.15 → labnotes-0.2.17}/pyproject.toml +0 -0
  29. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/__init__.py +0 -0
  30. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/__main__.py +0 -0
  31. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/asset_url.py +0 -0
  32. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/check_counts.py +0 -0
  33. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/command_line_group.py +0 -0
  34. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/compose_experiment.py +0 -0
  35. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/config_migration.py +0 -0
  36. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/context.md +0 -0
  37. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/context_guide.py +0 -0
  38. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/copy_experiment.py +0 -0
  39. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/count_groups.py +0 -0
  40. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/count_runs.py +0 -0
  41. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/create_experiment.py +0 -0
  42. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/create_note.py +0 -0
  43. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/data_html.py +0 -0
  44. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/default_config_text.py +0 -0
  45. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/default_counts.py +0 -0
  46. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/drain_watch.py +0 -0
  47. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiment_definition.py +0 -0
  48. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiment_notes.py +0 -0
  49. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiment_runs.py +0 -0
  50. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/file_changes.py +0 -0
  51. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/format_duration.py +0 -0
  52. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/format_started.py +0 -0
  53. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/format_value.py +0 -0
  54. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/get_state.py +0 -0
  55. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/grid_cell.py +0 -0
  56. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/group_runs.py +0 -0
  57. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/init.py +0 -0
  58. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/invocation_started.py +0 -0
  59. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/is_non_negative_int.py +0 -0
  60. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/is_non_negative_number.py +0 -0
  61. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/is_positive_int.py +0 -0
  62. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/latest_log.py +0 -0
  63. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/list_runs.py +0 -0
  64. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/load_config.py +0 -0
  65. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/load_experiment.py +0 -0
  66. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/load_run.py +0 -0
  67. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/log.py +0 -0
  68. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/log_path.py +0 -0
  69. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/memory_interval.py +0 -0
  70. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/models.py +0 -0
  71. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/nests_runs.py +0 -0
  72. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/note_path.py +0 -0
  73. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/notebook_kernel.py +0 -0
  74. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/output_html.py +0 -0
  75. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/param_names.py +0 -0
  76. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_param_value.py +0 -0
  77. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_params.py +0 -0
  78. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_run_view.py +0 -0
  79. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_subset.py +0 -0
  80. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/pivot_runs.py +0 -0
  81. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/print_rows.py +0 -0
  82. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/python_blocks.py +0 -0
  83. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/read_config.py +0 -0
  84. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/read_path.py +0 -0
  85. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/render_markdown.py +0 -0
  86. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/retries.py +0 -0
  87. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/run_columns.py +0 -0
  88. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/run_output.py +0 -0
  89. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/__init__.py +0 -0
  90. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/cgroup_memory.py +0 -0
  91. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/check_args.py +0 -0
  92. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/check_hook_placeholders.py +0 -0
  93. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/check_subset.py +0 -0
  94. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/claim_run.py +0 -0
  95. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/command_flags.py +0 -0
  96. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/container_cgroup.py +0 -0
  97. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/container_memory.py +0 -0
  98. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/continue_run.py +0 -0
  99. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/docker_output.py +0 -0
  100. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/execute_run.py +0 -0
  101. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/exit_signal.py +0 -0
  102. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/fill_dirs.py +0 -0
  103. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/fill_experiment_env.py +0 -0
  104. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/finalize_lost_runs.py +0 -0
  105. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/first_invocation.py +0 -0
  106. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/flag_name.py +0 -0
  107. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/group_args.py +0 -0
  108. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/hold_experiment.py +0 -0
  109. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/in_subset.py +0 -0
  110. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/invocation_hook.py +0 -0
  111. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/invocation_meta.py +0 -0
  112. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/new_run_id.py +0 -0
  113. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  114. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/override_flags.py +0 -0
  115. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/param_args.py +0 -0
  116. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/param_grid.py +0 -0
  117. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/placeholders.py +0 -0
  118. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/plan_runs.py +0 -0
  119. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/prepare_experiment.py +0 -0
  120. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/project_snapshot.py +0 -0
  121. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/read_experiment_config.py +0 -0
  122. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/record_memory.py +0 -0
  123. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_folder.py +0 -0
  124. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_git.py +0 -0
  125. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_headless.py +0 -0
  126. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_locked.py +0 -0
  127. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/runs_parent.py +0 -0
  128. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/shell_command.py +0 -0
  129. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/signal_tree.py +0 -0
  130. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/stop_then_kill.py +0 -0
  131. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/untracked_diff.py +0 -0
  132. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/wait_for_tree.py +0 -0
  133. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/write_run_json.py +0 -0
  134. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runs_view_command.py +0 -0
  135. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/save_conflict_copy.py +0 -0
  136. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/save_note.py +0 -0
  137. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/saved_outputs.py +0 -0
  138. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/serve_app.py +0 -0
  139. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/server.py +0 -0
  140. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/set_state.py +0 -0
  141. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/shared_params.py +0 -0
  142. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/sort_href.py +0 -0
  143. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/sql_quote.py +0 -0
  144. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/state_path.py +0 -0
  145. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/elapsed.js +0 -0
  146. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/experiment.js +0 -0
  147. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/labnotes.css +0 -0
  148. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/local-time.js +0 -0
  149. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/new-note.js +0 -0
  150. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/note-edit.js +0 -0
  151. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/note-editor.js +0 -0
  152. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/row-link.js +0 -0
  153. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/run-cell.js +0 -0
  154. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/run-output.js +0 -0
  155. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/view-toggle.js +0 -0
  156. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/summarize_config.py +0 -0
  157. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/__init__.py +0 -0
  158. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiment_run_ids.py +0 -0
  159. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiment_toml.py +0 -0
  160. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiments_ddl.py +0 -0
  161. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/file_prefix.py +0 -0
  162. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/file_text.py +0 -0
  163. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/iso_time.py +0 -0
  164. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/list_experiment_runs.py +0 -0
  165. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/list_resumable_runs.py +0 -0
  166. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/note_text.py +0 -0
  167. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/open_tables.py +0 -0
  168. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/path_glob.py +0 -0
  169. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/path_pattern.py +0 -0
  170. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_blocks.py +0 -0
  171. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_experiment.py +0 -0
  172. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_file.py +0 -0
  173. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_log.py +0 -0
  174. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_memory.py +0 -0
  175. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_run.py +0 -0
  176. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_run_file.py +0 -0
  177. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/runs_columns.py +0 -0
  178. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/summarize_experiments.py +0 -0
  179. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/base.html +0 -0
  180. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/editor_imports.html +0 -0
  181. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/experiment.html +0 -0
  182. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/note.html +0 -0
  183. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/run.html +0 -0
  184. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/__init__.py +0 -0
  185. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/browse_app.py +0 -0
  186. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/browse_tui.py +0 -0
  187. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/check_view_response.py +0 -0
  188. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/config.py +0 -0
  189. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/dim.py +0 -0
  190. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/experiment_screen.py +0 -0
  191. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/fill_table.py +0 -0
  192. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/format_cell.py +0 -0
  193. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/format_elapsed.py +0 -0
  194. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/guard_view.py +0 -0
  195. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/leave_prompt.py +0 -0
  196. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/load_view.py +0 -0
  197. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/log_block.py +0 -0
  198. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/log_blocks.py +0 -0
  199. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/native_rows.py +0 -0
  200. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/reload_on_change.py +0 -0
  201. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_cells.py +0 -0
  202. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_output.py +0 -0
  203. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_panel.py +0 -0
  204. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_tui.py +0 -0
  205. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/runs_browser.py +0 -0
  206. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/runs_table.py +0 -0
  207. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/runs_view.py +0 -0
  208. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/stopped_title.py +0 -0
  209. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/table_columns.py +0 -0
  210. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/table_rows.py +0 -0
  211. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_column.py +0 -0
  212. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_pane.py +0 -0
  213. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_process.py +0 -0
  214. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_switcher.py +0 -0
  215. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/__init__.py +0 -0
  216. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/format_bytes.py +0 -0
  217. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/process_alive.py +0 -0
  218. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/run_usage.py +0 -0
  219. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/tree_rss.py +0 -0
  220. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/tree_usage.py +0 -0
  221. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/__init__.py +0 -0
  222. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/answer.py +0 -0
  223. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/build.py +0 -0
  224. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/changed.py +0 -0
  225. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/experiment.py +0 -0
  226. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/respond.py +0 -0
  227. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/serve.py +0 -0
  228. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view_specs.py +0 -0
  229. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/wait_for_note.py +0 -0
  230. {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/watch_rows.py +0 -0
  231. {labnotes-0.2.15 → labnotes-0.2.17}/tests/conftest.py +0 -0
  232. {labnotes-0.2.15 → labnotes-0.2.17}/tests/e2e/conftest.py +0 -0
  233. {labnotes-0.2.15 → labnotes-0.2.17}/tests/integration/conftest.py +0 -0
  234. {labnotes-0.2.15 → labnotes-0.2.17}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.2.15
3
+ Version: 0.2.17
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -0,0 +1,9 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.fixture(autouse=True)
5
+ def state_home(
6
+ tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
7
+ ) -> None:
8
+ # Run journals live under XDG_STATE_HOME; keep tests out of the real one.
9
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path_factory.mktemp("state")))
@@ -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,6 +6,7 @@ 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
9
10
  from labnotes.runner.start_experiment import start_experiment
10
11
  from labnotes.utils.format_bytes import format_bytes
11
12
  from labnotes.load_config import load_config
@@ -43,7 +44,7 @@ class PassthroughCommand(click.Command):
43
44
  context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
44
45
  )
45
46
  @click.argument("name", required=False)
46
- @click.argument("action", required=False, metavar="[runs|run]")
47
+ @click.argument("action", required=False, metavar="[runs|run|stop]")
47
48
  @click.option(
48
49
  "--format",
49
50
  "output_format",
@@ -58,6 +59,11 @@ class PassthroughCommand(click.Command):
58
59
  " .labnotes.toml; 1 if none sets it.",
59
60
  )
60
61
  @click.option("--run-id", help="With run: restart only this failed run by id.")
62
+ @click.option(
63
+ "--headless",
64
+ is_flag=True,
65
+ help="With run: keep the scheduler in this foreground process.",
66
+ )
61
67
  @click.option(
62
68
  "-e",
63
69
  "--experiment-config",
@@ -116,6 +122,7 @@ def experiments(
116
122
  output_format: str | None,
117
123
  concurrency: int | None,
118
124
  run_id: str | None,
125
+ headless: bool,
119
126
  experiment_config: Path | None,
120
127
  timeout: int | None,
121
128
  setup: str | None,
@@ -141,6 +148,8 @@ def experiments(
141
148
  for flag, value in given.items():
142
149
  if value is not None:
143
150
  raise click.UsageError(f"{flag} needs new.")
151
+ if headless and action != "run":
152
+ raise click.UsageError("--headless needs run.")
144
153
  match name, action:
145
154
  case "new", _:
146
155
  try:
@@ -174,7 +183,7 @@ def experiments(
174
183
  run_id,
175
184
  )
176
185
  try:
177
- if sys.stdout.isatty() and sys.stderr.isatty():
186
+ if not headless and sys.stdout.isatty() and sys.stderr.isatty():
178
187
  results = run_tui(start, name, config, experiment_config)
179
188
  else:
180
189
  results = run_headless(start)
@@ -190,6 +199,29 @@ def experiments(
190
199
  passed = len(results) - failed - retried
191
200
  click.echo(f"{passed} passed, {failed} failed.{again}")
192
201
  return
202
+ case str(), "stop":
203
+ if (
204
+ output_format is not None
205
+ or concurrency is not None
206
+ or run_id is not None
207
+ or experiment_config is not None
208
+ or timeout is not None
209
+ or setup is not None
210
+ or teardown is not None
211
+ or passthrough is not None
212
+ or ctx.args
213
+ ):
214
+ raise click.UsageError("stop takes no run or listing options.")
215
+ try:
216
+ reply = daemon_command(config, name, {"command": "stop_all"})
217
+ except ValueError as error:
218
+ raise click.ClickException(str(error)) from error
219
+ if not reply.get("ok"):
220
+ raise click.ClickException(
221
+ str(reply.get("error", "Daemon rejected stop"))
222
+ )
223
+ click.echo(f"Asked daemon for {name} to stop all runs.")
224
+ return
193
225
  if passthrough is not None:
194
226
  raise click.UsageError("-- ARGS needs run.")
195
227
  if run_id is not None:
@@ -197,7 +229,7 @@ def experiments(
197
229
  if ctx.args:
198
230
  raise click.UsageError(f"Unexpected {ctx.args[0]}. --PARAM=VALUE needs run.")
199
231
  if action not in (None, "runs"):
200
- raise click.UsageError(f"Unknown action {action}. Use runs or run.")
232
+ raise click.UsageError(f"Unknown action {action}. Use runs, run or stop.")
201
233
  if output_format is None and sys.stdout.isatty() and sys.stderr.isatty():
202
234
  try:
203
235
  browse_tui(config, name)
@@ -5,24 +5,35 @@ from dirsql import DirSQL
5
5
 
6
6
  from labnotes.check_counts import check_counts
7
7
  from labnotes.count_runs import count_runs
8
+ from labnotes.daemon.marker import daemon_marker
8
9
  from labnotes.experiment_runs import experiment_runs
10
+ from labnotes.models import LabnotesConfig
9
11
  from labnotes.summarize_config import summarize_config
10
12
 
11
13
 
12
- async def list_experiments(tables: DirSQL) -> list[dict[str, object]]:
14
+ async def list_experiments(
15
+ tables: DirSQL, config: LabnotesConfig | None = None
16
+ ) -> list[dict[str, object]]:
13
17
  rows = await tables.query(
14
18
  "SELECT name, display_name, config FROM experiments ORDER BY name"
15
19
  )
16
20
  listed = []
17
21
  for row in rows:
18
22
  name = str(row["name"])
19
- config = None if row["config"] is None else json.loads(str(row["config"]))
20
- summary = summarize_config(config)
23
+ definition = None if row["config"] is None else json.loads(str(row["config"]))
24
+ summary = summarize_config(definition)
21
25
  varies = cast(dict[str, list[object]], summary["varies"])
22
26
  source = experiment_runs(name, list(varies))
23
27
  summary = await check_counts(tables, source, summary)
24
28
  [totals] = await count_runs(tables, source, str(summary["completed_run"]))
29
+ marker = await daemon_marker(tables, config, name) if config is not None else {}
25
30
  listed.append(
26
- {"name": name, "display_name": row["display_name"], **summary, **totals}
31
+ {
32
+ "name": name,
33
+ "display_name": row["display_name"],
34
+ **summary,
35
+ **totals,
36
+ **marker,
37
+ }
27
38
  )
28
39
  return listed
@@ -45,6 +45,8 @@ def describe_event(event: dict[str, object]) -> str:
45
45
  return "Tearing down."
46
46
  case "teardown failed":
47
47
  return str(event["error"])
48
+ case "daemon error":
49
+ return str(event["error"])
48
50
  case "stopping":
49
51
  return "Stopping runs. Ctrl-C again to kill them."
50
52
  case _: