labnotes 0.0.99__tar.gz → 0.0.101__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 (168) hide show
  1. {labnotes-0.0.99 → labnotes-0.0.101}/PKG-INFO +5 -1
  2. {labnotes-0.0.99 → labnotes-0.0.101}/README.md +4 -0
  3. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/context.md +2 -0
  4. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/context_guide.py +6 -2
  5. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/create_experiment.py +4 -0
  6. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/create_note.py +2 -0
  7. labnotes-0.0.101/src/labnotes/default_config_text.py +10 -0
  8. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/init.py +2 -1
  9. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/models.py +2 -0
  10. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/note_path.py +1 -1
  11. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/read_config.py +10 -3
  12. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/read_path.py +19 -4
  13. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/run_output.py +27 -0
  14. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/prepare_experiment.py +4 -0
  15. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/labnotes.css +7 -0
  16. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/run-output.js +29 -0
  17. labnotes-0.0.101/src/labnotes/tables/experiment_toml.py +5 -0
  18. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/open_tables.py +32 -5
  19. labnotes-0.0.101/src/labnotes/tables/read_blocks.py +19 -0
  20. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/read_experiment.py +6 -3
  21. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/read_file.py +4 -2
  22. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/read_log.py +4 -2
  23. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/read_run.py +4 -2
  24. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/templates/run.html +2 -0
  25. labnotes-0.0.101/src/labnotes/tui/log_block.py +31 -0
  26. labnotes-0.0.101/src/labnotes/tui/log_blocks.py +55 -0
  27. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/run_app.py +5 -0
  28. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/run_screen.py +3 -0
  29. labnotes-0.0.99/src/labnotes/default_config_text.py +0 -7
  30. {labnotes-0.0.99 → labnotes-0.0.101}/.gitignore +0 -0
  31. {labnotes-0.0.99 → labnotes-0.0.101}/CHANGELOG.md +0 -0
  32. {labnotes-0.0.99 → labnotes-0.0.101}/MIGRATIONS.md +0 -0
  33. {labnotes-0.0.99 → labnotes-0.0.101}/pyproject.toml +0 -0
  34. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/__init__.py +0 -0
  35. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/__main__.py +0 -0
  36. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/asset_url.py +0 -0
  37. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/check_counts.py +0 -0
  38. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/cli.py +0 -0
  39. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/count_groups.py +0 -0
  40. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/count_runs.py +0 -0
  41. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/create_app.py +0 -0
  42. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/__init__.py +0 -0
  43. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/control_run.py +0 -0
  44. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/daemon_app.py +0 -0
  45. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/daemon_client.py +0 -0
  46. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/daemon_running.py +0 -0
  47. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/daemon_server.py +0 -0
  48. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/describe_status.py +0 -0
  49. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/ensure_daemon.py +0 -0
  50. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/exit_when_idle.py +0 -0
  51. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/remote_start.py +0 -0
  52. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/run_daemon.py +0 -0
  53. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/run_logs.py +0 -0
  54. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/serve_daemon.py +0 -0
  55. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/socket_path.py +0 -0
  56. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/stop_runs.py +0 -0
  57. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/watch_daemon.py +0 -0
  58. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon/watch_status.py +0 -0
  59. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/daemon_command.py +0 -0
  60. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/data_html.py +0 -0
  61. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/default_counts.py +0 -0
  62. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/drain_watch.py +0 -0
  63. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/experiment_notes.py +0 -0
  64. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/experiment_runs.py +0 -0
  65. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/experiments.py +0 -0
  66. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/file_changes.py +0 -0
  67. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/format_duration.py +0 -0
  68. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/format_started.py +0 -0
  69. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/format_value.py +0 -0
  70. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/group_runs.py +0 -0
  71. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/invocation_started.py +0 -0
  72. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/is_positive_int.py +0 -0
  73. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/list_experiments.py +0 -0
  74. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/list_runs.py +0 -0
  75. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/load_config.py +0 -0
  76. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/load_experiment.py +0 -0
  77. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/load_run.py +0 -0
  78. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/log.py +0 -0
  79. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/log_path.py +0 -0
  80. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/modes.py +0 -0
  81. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/nests_runs.py +0 -0
  82. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/notebook_kernel.py +0 -0
  83. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/output_html.py +0 -0
  84. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/parse_run_view.py +0 -0
  85. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/print_rows.py +0 -0
  86. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/python_blocks.py +0 -0
  87. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/render_markdown.py +0 -0
  88. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/run_command.py +0 -0
  89. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/run_control.py +0 -0
  90. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/run_status.py +0 -0
  91. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/__init__.py +0 -0
  92. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/describe_event.py +0 -0
  93. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/execute_run.py +0 -0
  94. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/hold_folder.py +0 -0
  95. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/new_param_run_id.py +0 -0
  96. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/new_run_id.py +0 -0
  97. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  98. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/param_args.py +0 -0
  99. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/param_grid.py +0 -0
  100. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/param_run_name.py +0 -0
  101. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/plan_runs.py +0 -0
  102. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/read_experiment_config.py +0 -0
  103. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/run_experiment.py +0 -0
  104. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/run_folder.py +0 -0
  105. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/run_headless.py +0 -0
  106. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/runs_parent.py +0 -0
  107. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/shell_command.py +0 -0
  108. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/signal_tree.py +0 -0
  109. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/stop_then_kill.py +0 -0
  110. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/wait_for_tree.py +0 -0
  111. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/runner/write_run_json.py +0 -0
  112. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/save_conflict_copy.py +0 -0
  113. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/save_note.py +0 -0
  114. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/saved_outputs.py +0 -0
  115. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/serve_app.py +0 -0
  116. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/server.py +0 -0
  117. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/shared_params.py +0 -0
  118. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/sort_href.py +0 -0
  119. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/sql_quote.py +0 -0
  120. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/elapsed.js +0 -0
  121. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/experiment.js +0 -0
  122. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/local-time.js +0 -0
  123. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/new-note.js +0 -0
  124. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/note-edit.js +0 -0
  125. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/note-editor.js +0 -0
  126. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/row-link.js +0 -0
  127. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/run-cell.js +0 -0
  128. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/static/view-toggle.js +0 -0
  129. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/summarize_config.py +0 -0
  130. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/__init__.py +0 -0
  131. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/experiment_rows.py +0 -0
  132. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/experiment_run_ids.py +0 -0
  133. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/file_prefix.py +0 -0
  134. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/file_text.py +0 -0
  135. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/iso_time.py +0 -0
  136. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/list_experiment_runs.py +0 -0
  137. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/note_text.py +0 -0
  138. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/path_glob.py +0 -0
  139. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/path_pattern.py +0 -0
  140. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tables/summarize_experiments.py +0 -0
  141. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/templates/base.html +0 -0
  142. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/templates/editor_imports.html +0 -0
  143. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/templates/experiment.html +0 -0
  144. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/templates/index.html +0 -0
  145. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/templates/note.html +0 -0
  146. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/__init__.py +0 -0
  147. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/browse_app.py +0 -0
  148. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/browse_tui.py +0 -0
  149. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/dim.py +0 -0
  150. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/experiment_screen.py +0 -0
  151. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/experiments_screen.py +0 -0
  152. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/fill_table.py +0 -0
  153. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/format_elapsed.py +0 -0
  154. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/group_cells.py +0 -0
  155. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/invocation_columns.py +0 -0
  156. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/invocation_rows.py +0 -0
  157. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/leave_prompt.py +0 -0
  158. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/reload_on_change.py +0 -0
  159. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/run_cells.py +0 -0
  160. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/run_output.py +0 -0
  161. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/run_tui.py +0 -0
  162. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/runs_table.py +0 -0
  163. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/tui/stopped_title.py +0 -0
  164. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/wait_for_note.py +0 -0
  165. {labnotes-0.0.99 → labnotes-0.0.101}/src/labnotes/watch_rows.py +0 -0
  166. {labnotes-0.0.99 → labnotes-0.0.101}/testing-conventions.toml +0 -0
  167. {labnotes-0.0.99 → labnotes-0.0.101}/tests/integration/conftest.py +0 -0
  168. {labnotes-0.0.99 → labnotes-0.0.101}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.99
3
+ Version: 0.0.101
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>
@@ -49,6 +49,10 @@ names: the experiments path must use `{experiment}`, and the runs path both.
49
49
  labnotes reads the file from the working folder, or from the path given with
50
50
  `-c`.
51
51
 
52
+ When `experiment.toml` sits next to `.labnotes.toml`, the root is the one
53
+ experiment, named after its folder. Paths then drop `{experiment}`: files
54
+ default to `files/` and runs to `runs/{run}`.
55
+
52
56
  `labnotes experiments` reads three tables from those paths:
53
57
 
54
58
  - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
@@ -25,6 +25,10 @@ names: the experiments path must use `{experiment}`, and the runs path both.
25
25
  labnotes reads the file from the working folder, or from the path given with
26
26
  `-c`.
27
27
 
28
+ When `experiment.toml` sits next to `.labnotes.toml`, the root is the one
29
+ experiment, named after its folder. Paths then drop `{experiment}`: files
30
+ default to `files/` and runs to `runs/{run}`.
31
+
28
32
  `labnotes experiments` reads three tables from those paths:
29
33
 
30
34
  - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
@@ -15,6 +15,8 @@ path = "{experiment}/runs/{run}" # one folder per run
15
15
 
16
16
  Paths are relative to the root. `{experiment}` and `{run}` stand for folder names; the experiments and files paths use `{experiment}`, the runs path both. A project laid out differently keeps its folders and changes the paths: look at where its experiments, notes and runs live, then write the templates that match.
17
17
 
18
+ When `experiment.toml` sits next to `.labnotes.toml`, the root is the one experiment, named after its folder. Paths then drop `{experiment}`: files default to `files/`, runs to `runs/{run}`, and `[experiments] path` is rejected.
19
+
18
20
  `labnotes experiments` reads three tables from those paths:
19
21
 
20
22
  - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
@@ -17,8 +17,12 @@ def context_guide(config: LabnotesConfig | None, cwd: Path) -> str:
17
17
  project = (
18
18
  f"Config: {config.path}\n"
19
19
  f"Root: {config.root}\n"
20
- f"Experiments: {config.experiments}\n"
21
- f"Files: {config.files}\n"
20
+ + (
21
+ f"Experiment: {config.experiment} (the root is the one experiment)\n"
22
+ if config.experiment
23
+ else f"Experiments: {config.experiments}\n"
24
+ )
25
+ + f"Files: {config.files}\n"
22
26
  f"Runs: {config.runs}"
23
27
  )
24
28
  return f"labnotes {version('labnotes')}\n\n{project}\n\n{guide}"
@@ -18,6 +18,10 @@ def create_experiment(config: LabnotesConfig, name: str | None) -> Path:
18
18
  def folder(candidate: str) -> Path:
19
19
  return config.root / config.experiments.replace("{experiment}", candidate)
20
20
 
21
+ if config.experiment is not None:
22
+ raise ValueError(
23
+ f"{config.root} is the experiment {config.experiment}; it can't hold others."
24
+ )
21
25
  if name is None:
22
26
  name = new_run_id(lambda candidate: folder(candidate).exists())
23
27
  if name == "new":
@@ -8,6 +8,8 @@ def create_note(config: LabnotesConfig, experiment: str) -> Path | None:
8
8
  def folder(template: str) -> Path:
9
9
  return config.root / template.replace("{experiment}", experiment)
10
10
 
11
+ if config.experiment not in (None, experiment):
12
+ return None
11
13
  if experiment.startswith(".") or not folder(config.experiments).is_dir():
12
14
  return None
13
15
  notes = folder(file_prefix(config.files))
@@ -0,0 +1,10 @@
1
+ from labnotes.read_path import DEFAULT_PATHS, SINGLE_PATHS
2
+
3
+
4
+ def default_config_text(*, single: bool = False) -> str:
5
+ paths = SINGLE_PATHS if single else DEFAULT_PATHS
6
+ return "\n".join(
7
+ f'[{table}]\npath = "{template}"\n'
8
+ for table, template in paths.items()
9
+ if template
10
+ )
@@ -11,7 +11,8 @@ def init() -> None:
11
11
  path = Path.cwd() / CONFIG_FILE
12
12
  try:
13
13
  with path.open("x", encoding="utf-8") as file:
14
- file.write(default_config_text())
14
+ single = (path.parent / "experiment.toml").is_file()
15
+ file.write(default_config_text(single=single))
15
16
  except FileExistsError as error:
16
17
  raise click.ClickException(f"{path} already exists.") from error
17
18
  click.echo(f"Wrote {path}")
@@ -47,6 +47,8 @@ class LabnotesConfig:
47
47
  runs: str
48
48
  mode: str = DEFAULT_MODE
49
49
  concurrency: int = 1
50
+ # Set when the config's folder is itself the one experiment.
51
+ experiment: str | None = None
50
52
 
51
53
 
52
54
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ def note_path(config: LabnotesConfig, experiment: str, note: str) -> Path | None
10
10
  if ".." in PurePosixPath(relative).parts or not note.endswith(".md"):
11
11
  return None
12
12
  found = path_pattern(config.files).fullmatch(relative)
13
- if found is None or found["experiment"] != experiment:
13
+ if found is None or (config.experiment or found["experiment"]) != experiment:
14
14
  return None
15
15
  path = config.root / relative
16
16
  return path if path.is_relative_to(config.root) and path.is_file() else None
@@ -18,8 +18,11 @@ def read_config(path: Path) -> LabnotesConfig:
18
18
  unknown = sorted(values.keys() - DEFAULT_PATHS.keys())
19
19
  if unknown:
20
20
  raise ValueError(f"{path}: unknown tables {', '.join(unknown)}")
21
+ root = path.resolve().parent
22
+ single = (root / "experiment.toml").is_file()
21
23
  paths = {
22
- table: read_path(path, table, values.get(table, {})) for table in DEFAULT_PATHS
24
+ table: read_path(path, table, values.get(table, {}), single=single)
25
+ for table in DEFAULT_PATHS
23
26
  }
24
27
  experiments = values.get("experiments", {})
25
28
  mode = experiments.get("mode", DEFAULT_MODE)
@@ -30,7 +33,11 @@ def read_config(path: Path) -> LabnotesConfig:
30
33
  raise ValueError(
31
34
  f"{path}: [experiments] concurrency must be a positive integer"
32
35
  )
33
- path = path.resolve()
34
36
  return LabnotesConfig(
35
- path, path.parent, **paths, mode=mode, concurrency=concurrency
37
+ path.resolve(),
38
+ root,
39
+ **paths,
40
+ mode=mode,
41
+ concurrency=concurrency,
42
+ experiment=root.name if single else None,
36
43
  )
@@ -6,31 +6,46 @@ DEFAULT_PATHS = {
6
6
  "files": "{experiment}/files/",
7
7
  "runs": "{experiment}/runs/{run}",
8
8
  }
9
+ # One experiment at the config's folder, so no {experiment} to fill.
10
+ SINGLE_PATHS = {"experiments": "", "files": "files/", "runs": "runs/{run}"}
9
11
  PLACEHOLDERS = {
10
12
  "experiments": {"experiment"},
11
13
  "files": {"experiment"},
12
14
  "runs": {"experiment", "run"},
13
15
  }
16
+ # experiments is missing: one experiment has no folder to point at.
17
+ SINGLE_PLACEHOLDERS: dict[str, set[str]] = {"files": set(), "runs": {"run"}}
14
18
  # Keys other readers own, so the path check lets them through.
15
19
  OTHER_KEYS = {"experiments": {"mode", "concurrency"}}
16
20
  PLACEHOLDER = re.compile(r"\{([^}]*)\}")
21
+ SINGLE = "experiment.toml sits next to .labnotes.toml, so this folder is the experiment"
17
22
 
18
23
 
19
- def read_path(config: Path, table: str, section: object) -> str:
24
+ def read_path(config: Path, table: str, section: object, single: bool = False) -> str:
20
25
  if not isinstance(section, dict):
21
26
  raise ValueError(f"{config}: {table} must be a table, like [{table}]")
22
27
  unknown = sorted(section.keys() - {"path", *OTHER_KEYS.get(table, ())})
23
28
  if unknown:
24
29
  raise ValueError(f"{config}: unknown keys in [{table}]: {', '.join(unknown)}")
25
- template = section.get("path", DEFAULT_PATHS[table])
30
+ defaults = SINGLE_PATHS if single else DEFAULT_PATHS
31
+ if single and table not in SINGLE_PLACEHOLDERS:
32
+ if "path" in section:
33
+ raise ValueError(f"{config}: [experiments] path doesn't apply; {SINGLE}")
34
+ return SINGLE_PATHS[table]
35
+ template = section.get("path", defaults[table])
26
36
  if not isinstance(template, str):
27
37
  raise ValueError(f"{config}: [{table}] path must be a string")
28
38
  if PurePosixPath(template).is_absolute() or template.startswith("~"):
29
39
  raise ValueError(
30
40
  f"{config}: [{table}] path must be relative to the config's folder"
31
41
  )
32
- required = PLACEHOLDERS[table]
33
- if set(PLACEHOLDER.findall(template)) != required:
42
+ used = set(PLACEHOLDER.findall(template))
43
+ if single and "experiment" in used:
44
+ raise ValueError(f"{config}: [{table}] path can't use {{experiment}}; {SINGLE}")
45
+ required = SINGLE_PLACEHOLDERS[table] if single else PLACEHOLDERS[table]
46
+ if used != required:
34
47
  expected = " and ".join(f"{{{name}}}" for name in sorted(required))
48
+ if not expected:
49
+ raise ValueError(f"{config}: [{table}] path can't use placeholders")
35
50
  raise ValueError(f"{config}: [{table}] path must use exactly {expected}")
36
51
  return template
@@ -43,8 +43,30 @@ async def run_output(
43
43
  last, mid_line = stream, not text.endswith("\n")
44
44
  yield f"data: {json.dumps({'stream': stream, 'text': text})}\n\n"
45
45
 
46
+ blocks_read = 0
47
+
48
+ def read_blocks() -> Iterator[str]:
49
+ nonlocal blocks_read
50
+ path = folder / "log.jsonl"
51
+ if not path.exists():
52
+ return
53
+ with path.open("rb") as log:
54
+ log.seek(blocks_read)
55
+ data = log.read()
56
+ whole = data[: data.rfind(b"\n") + 1]
57
+ blocks_read += len(whole)
58
+ for line in whole.splitlines():
59
+ try:
60
+ record = json.loads(line)
61
+ except json.JSONDecodeError:
62
+ continue
63
+ if isinstance(record, dict):
64
+ yield f"event: block\ndata: {json.dumps(record)}\n\n"
65
+
46
66
  for message in read(STREAMS):
47
67
  yield message
68
+ for message in read_blocks():
69
+ yield message
48
70
  exit_code = run["exit_code"]
49
71
  if run["ended"] is None:
50
72
  async for event in events:
@@ -54,6 +76,9 @@ async def run_output(
54
76
  if event.table == "logs" and row["run"] == run_id:
55
77
  for message in read([row["stream"]]):
56
78
  yield message
79
+ elif event.table == "blocks" and row["run"] == run_id:
80
+ for message in read_blocks():
81
+ yield message
57
82
  elif (
58
83
  event.table == "runs"
59
84
  and row["id"] == run_id
@@ -63,4 +88,6 @@ async def run_output(
63
88
  break
64
89
  for message in read(STREAMS):
65
90
  yield message
91
+ for message in read_blocks():
92
+ yield message
66
93
  yield f"event: end\ndata: {json.dumps(exit_code)}\n\n"
@@ -11,6 +11,10 @@ async def prepare_experiment(
11
11
  experiment_config: Path | None,
12
12
  concurrency: int | None,
13
13
  ) -> tuple[ExperimentConfig, int]:
14
+ if config.experiment not in (None, name):
15
+ raise ValueError(
16
+ f"No experiment {name}; this project is the experiment {config.experiment}."
17
+ )
14
18
  if experiment_config is None:
15
19
  folder = config.experiments.replace("{experiment}", name)
16
20
  experiment_config = config.root / folder / "experiment.toml"
@@ -302,6 +302,13 @@ h3 {
302
302
  /* Run output */
303
303
  .run-state.good { color: var(--good); }
304
304
  .run-state.bad { color: var(--bad); }
305
+ .run-blocks { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; margin-bottom: 1rem; padding: 0.5rem 0; max-height: 50vh; overflow: auto; }
306
+ .run-blocks[hidden] { display: none; }
307
+ .log-block { display: block; width: 100%; padding: 0.2rem 1.25rem; border: 0; background: none; color: inherit; font: inherit; font-family: var(--mono); font-size: 0.82rem; text-align: left; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
308
+ .log-block::before { content: "▸ "; color: var(--ink-3); }
309
+ .log-block[aria-expanded="true"] { white-space: pre-wrap; overflow-wrap: anywhere; }
310
+ .log-block[aria-expanded="true"]::before { content: "▾ "; }
311
+ .log-block:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
305
312
  .run-log { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; }
306
313
  .run-log header { display: flex; gap: 1rem; padding: 0.4rem 1.25rem; border-bottom: 1px solid var(--rule); font-family: var(--mono); font-size: 0.75rem; color: var(--ink-3); }
307
314
  .run-log header span::before { content: "■ "; }
@@ -4,12 +4,41 @@ const output = document.querySelector(".run-output");
4
4
  const state = document.querySelector(".run-state");
5
5
  const source = new EventSource(output.dataset.url);
6
6
 
7
+ const blocks = document.querySelector(".run-blocks");
8
+
7
9
  let parsers = {};
10
+ let streaming = new Map();
8
11
 
9
12
  // The server resends the whole log on each connection, so a reconnect starts clean.
10
13
  source.addEventListener("open", () => {
11
14
  output.replaceChildren();
12
15
  parsers = {};
16
+ blocks.replaceChildren();
17
+ blocks.hidden = true;
18
+ streaming = new Map();
19
+ });
20
+
21
+ source.addEventListener("block", (event) => {
22
+ const { id, phase, content = "" } = JSON.parse(event.data);
23
+ const open = streaming.get(id);
24
+ if (open && (phase === "delta" || phase === "stop")) {
25
+ open.textContent += content;
26
+ if (phase === "stop") streaming.delete(id);
27
+ return;
28
+ }
29
+ if (phase === "stop") return;
30
+ const line = document.createElement("button");
31
+ line.type = "button";
32
+ line.className = "log-block";
33
+ line.setAttribute("aria-expanded", "false");
34
+ line.textContent = content;
35
+ line.addEventListener("click", () => {
36
+ const expanded = line.getAttribute("aria-expanded") === "true";
37
+ line.setAttribute("aria-expanded", String(!expanded));
38
+ });
39
+ if (phase === "start" || phase === "delta") streaming.set(id, line);
40
+ blocks.append(line);
41
+ blocks.hidden = false;
13
42
  });
14
43
 
15
44
  source.addEventListener("message", (event) => {
@@ -0,0 +1,5 @@
1
+ import posixpath
2
+
3
+
4
+ def experiment_toml(template: str) -> str:
5
+ return posixpath.join(template, "experiment.toml")
@@ -3,7 +3,9 @@ from functools import partial
3
3
  from dirsql import DirSQL, Table
4
4
 
5
5
  from labnotes.models import LabnotesConfig
6
+ from labnotes.tables.experiment_toml import experiment_toml
6
7
  from labnotes.tables.path_glob import path_glob
8
+ from labnotes.tables.read_blocks import read_blocks
7
9
  from labnotes.tables.read_experiment import read_experiment
8
10
  from labnotes.tables.read_file import read_file
9
11
  from labnotes.tables.read_log import read_log
@@ -48,6 +50,12 @@ CREATE TABLE logs (
48
50
  PRIMARY KEY (experiment, run, stream)
49
51
  )
50
52
  """
53
+ BLOCKS = """
54
+ CREATE TABLE blocks (
55
+ experiment TEXT, run TEXT, size INTEGER,
56
+ PRIMARY KEY (experiment, run)
57
+ )
58
+ """
51
59
 
52
60
 
53
61
  def open_tables(config: LabnotesConfig) -> DirSQL:
@@ -59,25 +67,44 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
59
67
  name="files",
60
68
  ddl=FILES,
61
69
  glob=path_glob(config.files),
62
- on_file=partial(read_file, root, config.files),
70
+ on_file=partial(
71
+ read_file, root, config.files, experiment=config.experiment
72
+ ),
63
73
  ),
64
74
  Table(
65
75
  name="runs",
66
76
  ddl=RUNS,
67
77
  glob=path_glob(f"{config.runs}/run.json"),
68
- on_file=partial(read_run, root, config.runs),
78
+ on_file=partial(
79
+ read_run, root, config.runs, experiment=config.experiment
80
+ ),
69
81
  ),
70
82
  Table(
71
83
  name="logs",
72
84
  ddl=LOGS,
73
85
  glob=path_glob(f"{config.runs}/std*.log"),
74
- on_file=partial(read_log, root, config.runs),
86
+ on_file=partial(
87
+ read_log, root, config.runs, experiment=config.experiment
88
+ ),
89
+ ),
90
+ Table(
91
+ name="blocks",
92
+ ddl=BLOCKS,
93
+ glob=path_glob(f"{config.runs}/log.jsonl"),
94
+ on_file=partial(
95
+ read_blocks, root, config.runs, experiment=config.experiment
96
+ ),
75
97
  ),
76
98
  Table(
77
99
  name="experiment_configs",
78
100
  ddl=EXPERIMENTS,
79
- glob=path_glob(f"{config.experiments}/experiment.toml"),
80
- on_file=partial(read_experiment, root, config.experiments),
101
+ glob=path_glob(experiment_toml(config.experiments)),
102
+ on_file=partial(
103
+ read_experiment,
104
+ root,
105
+ config.experiments,
106
+ experiment=config.experiment,
107
+ ),
81
108
  ),
82
109
  ],
83
110
  # Run folders are often gitignored (DVC, large outputs); scan them anyway.
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.tables.path_pattern import path_pattern
4
+
5
+
6
+ def read_blocks(
7
+ root: Path, template: str, path: str, experiment: str | None = None
8
+ ) -> list[dict[str, object]]:
9
+ relative = Path(path).relative_to(root).as_posix()
10
+ found = path_pattern(f"{template}/log.jsonl").fullmatch(relative)
11
+ if found is None:
12
+ return []
13
+ return [
14
+ {
15
+ "experiment": experiment or found["experiment"],
16
+ "run": found["run"],
17
+ "size": Path(path).stat().st_size,
18
+ }
19
+ ]
@@ -2,19 +2,22 @@ import json
2
2
  import tomllib
3
3
  from pathlib import Path
4
4
 
5
+ from labnotes.tables.experiment_toml import experiment_toml
5
6
  from labnotes.tables.path_pattern import path_pattern
6
7
 
7
8
 
8
- def read_experiment(root: Path, template: str, path: str) -> list[dict[str, object]]:
9
+ def read_experiment(
10
+ root: Path, template: str, path: str, experiment: str | None = None
11
+ ) -> list[dict[str, object]]:
9
12
  relative = Path(path).relative_to(root).as_posix()
10
- found = path_pattern(f"{template}/experiment.toml").fullmatch(relative)
13
+ found = path_pattern(experiment_toml(template)).fullmatch(relative)
11
14
  if found is None:
12
15
  return []
13
16
  source = Path(path).read_text(encoding="utf-8")
14
17
  config = tomllib.loads(source)
15
18
  return [
16
19
  {
17
- "experiment": found["experiment"],
20
+ "experiment": experiment or found["experiment"],
18
21
  "config": json.dumps(config),
19
22
  "source": source,
20
23
  }
@@ -6,7 +6,9 @@ from labnotes.tables.note_text import note_text
6
6
  from labnotes.tables.path_pattern import path_pattern
7
7
 
8
8
 
9
- def read_file(root: Path, template: str, path: str) -> list[dict[str, object]]:
9
+ def read_file(
10
+ root: Path, template: str, path: str, experiment: str | None = None
11
+ ) -> list[dict[str, object]]:
10
12
  relative = Path(path).relative_to(root).as_posix()
11
13
  found = path_pattern(template).fullmatch(relative)
12
14
  prefix = path_pattern(file_prefix(template).rstrip("/")).match(relative)
@@ -15,7 +17,7 @@ def read_file(root: Path, template: str, path: str) -> list[dict[str, object]]:
15
17
  stat = Path(path).stat()
16
18
  return [
17
19
  {
18
- "experiment": found["experiment"],
20
+ "experiment": experiment or found["experiment"],
19
21
  "filename": relative[prefix.end() :].lstrip("/"),
20
22
  "path": relative,
21
23
  "ctime": iso_time(stat.st_ctime),
@@ -5,14 +5,16 @@ from labnotes.tables.path_pattern import path_pattern
5
5
  STREAMS = ("stdout", "stderr")
6
6
 
7
7
 
8
- def read_log(root: Path, template: str, path: str) -> list[dict[str, object]]:
8
+ def read_log(
9
+ root: Path, template: str, path: str, experiment: str | None = None
10
+ ) -> list[dict[str, object]]:
9
11
  relative = Path(path).relative_to(root).as_posix()
10
12
  found = path_pattern(f"{template}/{{stream}}.log").fullmatch(relative)
11
13
  if found is None or found["stream"] not in STREAMS:
12
14
  return []
13
15
  return [
14
16
  {
15
- "experiment": found["experiment"],
17
+ "experiment": experiment or found["experiment"],
16
18
  "run": found["run"],
17
19
  "stream": found["stream"],
18
20
  "size": Path(path).stat().st_size,
@@ -4,7 +4,9 @@ from pathlib import Path
4
4
  from labnotes.tables.path_pattern import path_pattern
5
5
 
6
6
 
7
- def read_run(root: Path, template: str, path: str) -> list[dict[str, object]]:
7
+ def read_run(
8
+ root: Path, template: str, path: str, experiment: str | None = None
9
+ ) -> list[dict[str, object]]:
8
10
  relative = Path(path).relative_to(root).as_posix()
9
11
  found = path_pattern(f"{template}/run.json").fullmatch(relative)
10
12
  if found is None:
@@ -13,7 +15,7 @@ def read_run(root: Path, template: str, path: str) -> list[dict[str, object]]:
13
15
  folder = relative.removesuffix("/run.json")
14
16
  return [
15
17
  {
16
- "experiment": found["experiment"],
18
+ "experiment": experiment or found["experiment"],
17
19
  "id": found["run"],
18
20
  "folder": folder,
19
21
  "created": run.get("created"),
@@ -11,6 +11,8 @@
11
11
  </p>
12
12
  </section>
13
13
 
14
+ <section class="run-blocks" hidden></section>
15
+
14
16
  <section class="run-log">
15
17
  <header><span class="stdout">stdout</span><span class="stderr">stderr</span></header>
16
18
  <pre class="run-output" data-url="{{ url_for('output', name=run.experiment, run=run.id) }}"></pre>
@@ -0,0 +1,31 @@
1
+ from rich.text import Text
2
+ from textual.events import Click
3
+ from textual.widgets import Static
4
+
5
+
6
+ class LogBlock(Static):
7
+ DEFAULT_CSS = """
8
+ LogBlock { height: auto; }
9
+ LogBlock.-collapsed { height: 1; text-wrap: nowrap; text-overflow: ellipsis; }
10
+ """
11
+
12
+ def __init__(self, body: str) -> None:
13
+ super().__init__(classes="-collapsed")
14
+ self.body = body
15
+ self.expanded = False
16
+ self.show()
17
+
18
+ def extend(self, text: str) -> None:
19
+ self.body += text
20
+ self.show()
21
+
22
+ def on_click(self, _: Click) -> None:
23
+ self.expanded = not self.expanded
24
+ self.set_class(not self.expanded, "-collapsed")
25
+ self.show()
26
+
27
+ def show(self) -> None:
28
+ if self.expanded:
29
+ self.update(Text(f"▾ {self.body}"))
30
+ else:
31
+ self.update(Text(f"▸ {self.body}".replace("\n", " ")))
@@ -0,0 +1,55 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from textual.containers import VerticalScroll
5
+
6
+ from labnotes.tui.log_block import LogBlock
7
+
8
+ STREAMING = ("start", "delta")
9
+
10
+
11
+ class LogBlocks(VerticalScroll):
12
+ DEFAULT_CSS = "LogBlocks { height: auto; max-height: 50%; }"
13
+
14
+ def __init__(self, id: str | None = None) -> None:
15
+ super().__init__(id=id)
16
+ self.reset()
17
+
18
+ def reset(self) -> None:
19
+ self.read_to = 0
20
+ self.streaming: dict[object, LogBlock] = {}
21
+ self.remove_children()
22
+ self.display = False
23
+
24
+ def follow(self, folder: Path) -> None:
25
+ path = folder / "log.jsonl"
26
+ if not path.exists():
27
+ return
28
+ with path.open("rb") as log:
29
+ log.seek(self.read_to)
30
+ data = log.read()
31
+ complete = data[: data.rfind(b"\n") + 1]
32
+ self.read_to += len(complete)
33
+ for line in complete.splitlines():
34
+ try:
35
+ record = json.loads(line)
36
+ except json.JSONDecodeError:
37
+ continue
38
+ if isinstance(record, dict):
39
+ self.add(record)
40
+
41
+ def add(self, record: dict[str, object]) -> None:
42
+ phase, key = record.get("phase"), record.get("id")
43
+ body = str(record.get("content", ""))
44
+ if phase == "delta" and key in self.streaming:
45
+ self.streaming[key].extend(body)
46
+ return
47
+ if phase == "stop":
48
+ if key in self.streaming:
49
+ self.streaming.pop(key).extend(body)
50
+ return
51
+ block = LogBlock(body)
52
+ if phase in STREAMING:
53
+ self.streaming[key] = block
54
+ self.mount(block)
55
+ self.display = True
@@ -15,6 +15,7 @@ from labnotes.runner.param_args import param_args
15
15
  from labnotes.tui.dim import dim
16
16
  from labnotes.tui.format_elapsed import format_elapsed
17
17
  from labnotes.tui.leave_prompt import LeavePrompt
18
+ from labnotes.tui.log_blocks import LogBlocks
18
19
  from labnotes.tui.run_output import RunOutput
19
20
  from labnotes.tui.runs_table import RunsTable
20
21
 
@@ -83,6 +84,7 @@ class RunApp(App[None]):
83
84
  def compose(self) -> ComposeResult:
84
85
  yield Header()
85
86
  yield RunsTable(id="runs", cursor_type="row")
87
+ yield LogBlocks(id="blocks")
86
88
  yield RunOutput(id="output")
87
89
  yield Input(id="filter", placeholder="Filter params")
88
90
  yield Static(id="status")
@@ -379,6 +381,7 @@ class RunApp(App[None]):
379
381
 
380
382
  def view(self, run: str) -> None:
381
383
  self.viewing = run
384
+ self.query_one(LogBlocks).reset()
382
385
  self.query_one(RunOutput).reset()
383
386
  self.query_one(DataTable).display = False
384
387
  self.query_one(RunOutput).display = True
@@ -389,11 +392,13 @@ class RunApp(App[None]):
389
392
 
390
393
  def follow(self) -> None:
391
394
  if self.viewing in self.folders:
395
+ self.query_one(LogBlocks).follow(self.folders[self.viewing])
392
396
  self.query_one(RunOutput).follow(self.folders[self.viewing])
393
397
 
394
398
  def action_leave(self) -> None:
395
399
  if self.viewing is not None:
396
400
  self.viewing = None
401
+ self.query_one(LogBlocks).display = False
397
402
  self.query_one(RunOutput).display = False
398
403
  self.query_one(DataTable).display = True
399
404
  self.sub_title = ""