labnotes 0.0.100__tar.gz → 0.0.102__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 (172) hide show
  1. {labnotes-0.0.100 → labnotes-0.0.102}/PKG-INFO +7 -1
  2. {labnotes-0.0.100 → labnotes-0.0.102}/README.md +6 -0
  3. labnotes-0.0.102/src/labnotes/compose_experiment.py +8 -0
  4. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/context.md +2 -0
  5. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/context_guide.py +6 -2
  6. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/create_experiment.py +4 -0
  7. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/create_note.py +2 -0
  8. labnotes-0.0.102/src/labnotes/default_config_text.py +10 -0
  9. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/init.py +2 -1
  10. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/models.py +4 -0
  11. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/note_path.py +1 -1
  12. labnotes-0.0.102/src/labnotes/read_config.py +56 -0
  13. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/read_path.py +19 -4
  14. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/prepare_experiment.py +10 -2
  15. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/read_experiment_config.py +9 -2
  16. labnotes-0.0.102/src/labnotes/tables/experiment_toml.py +5 -0
  17. labnotes-0.0.102/src/labnotes/tables/experiments_ddl.py +34 -0
  18. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/open_tables.py +23 -25
  19. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/read_blocks.py +4 -2
  20. labnotes-0.0.102/src/labnotes/tables/read_experiment.py +29 -0
  21. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/read_file.py +4 -2
  22. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/read_log.py +4 -2
  23. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/read_run.py +4 -2
  24. labnotes-0.0.100/src/labnotes/default_config_text.py +0 -7
  25. labnotes-0.0.100/src/labnotes/read_config.py +0 -36
  26. labnotes-0.0.100/src/labnotes/tables/read_experiment.py +0 -21
  27. {labnotes-0.0.100 → labnotes-0.0.102}/.gitignore +0 -0
  28. {labnotes-0.0.100 → labnotes-0.0.102}/CHANGELOG.md +0 -0
  29. {labnotes-0.0.100 → labnotes-0.0.102}/MIGRATIONS.md +0 -0
  30. {labnotes-0.0.100 → labnotes-0.0.102}/pyproject.toml +0 -0
  31. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/__init__.py +0 -0
  32. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/__main__.py +0 -0
  33. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/asset_url.py +0 -0
  34. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/check_counts.py +0 -0
  35. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/cli.py +0 -0
  36. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/count_groups.py +0 -0
  37. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/count_runs.py +0 -0
  38. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/create_app.py +0 -0
  39. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/__init__.py +0 -0
  40. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/control_run.py +0 -0
  41. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/daemon_app.py +0 -0
  42. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/daemon_client.py +0 -0
  43. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/daemon_running.py +0 -0
  44. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/daemon_server.py +0 -0
  45. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/describe_status.py +0 -0
  46. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/ensure_daemon.py +0 -0
  47. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/exit_when_idle.py +0 -0
  48. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/remote_start.py +0 -0
  49. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/run_daemon.py +0 -0
  50. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/run_logs.py +0 -0
  51. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/serve_daemon.py +0 -0
  52. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/socket_path.py +0 -0
  53. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/stop_runs.py +0 -0
  54. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/watch_daemon.py +0 -0
  55. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon/watch_status.py +0 -0
  56. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/daemon_command.py +0 -0
  57. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/data_html.py +0 -0
  58. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/default_counts.py +0 -0
  59. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/drain_watch.py +0 -0
  60. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/experiment_notes.py +0 -0
  61. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/experiment_runs.py +0 -0
  62. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/experiments.py +0 -0
  63. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/file_changes.py +0 -0
  64. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/format_duration.py +0 -0
  65. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/format_started.py +0 -0
  66. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/format_value.py +0 -0
  67. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/group_runs.py +0 -0
  68. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/invocation_started.py +0 -0
  69. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/is_positive_int.py +0 -0
  70. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/list_experiments.py +0 -0
  71. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/list_runs.py +0 -0
  72. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/load_config.py +0 -0
  73. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/load_experiment.py +0 -0
  74. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/load_run.py +0 -0
  75. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/log.py +0 -0
  76. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/log_path.py +0 -0
  77. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/modes.py +0 -0
  78. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/nests_runs.py +0 -0
  79. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/notebook_kernel.py +0 -0
  80. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/output_html.py +0 -0
  81. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/parse_run_view.py +0 -0
  82. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/print_rows.py +0 -0
  83. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/python_blocks.py +0 -0
  84. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/render_markdown.py +0 -0
  85. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/run_command.py +0 -0
  86. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/run_control.py +0 -0
  87. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/run_output.py +0 -0
  88. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/run_status.py +0 -0
  89. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/__init__.py +0 -0
  90. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/describe_event.py +0 -0
  91. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/execute_run.py +0 -0
  92. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/hold_folder.py +0 -0
  93. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/new_param_run_id.py +0 -0
  94. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/new_run_id.py +0 -0
  95. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  96. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/param_args.py +0 -0
  97. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/param_grid.py +0 -0
  98. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/param_run_name.py +0 -0
  99. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/plan_runs.py +0 -0
  100. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/run_experiment.py +0 -0
  101. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/run_folder.py +0 -0
  102. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/run_headless.py +0 -0
  103. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/runs_parent.py +0 -0
  104. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/shell_command.py +0 -0
  105. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/signal_tree.py +0 -0
  106. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/stop_then_kill.py +0 -0
  107. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/wait_for_tree.py +0 -0
  108. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/runner/write_run_json.py +0 -0
  109. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/save_conflict_copy.py +0 -0
  110. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/save_note.py +0 -0
  111. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/saved_outputs.py +0 -0
  112. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/serve_app.py +0 -0
  113. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/server.py +0 -0
  114. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/shared_params.py +0 -0
  115. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/sort_href.py +0 -0
  116. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/sql_quote.py +0 -0
  117. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/elapsed.js +0 -0
  118. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/experiment.js +0 -0
  119. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/labnotes.css +0 -0
  120. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/local-time.js +0 -0
  121. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/new-note.js +0 -0
  122. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/note-edit.js +0 -0
  123. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/note-editor.js +0 -0
  124. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/row-link.js +0 -0
  125. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/run-cell.js +0 -0
  126. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/run-output.js +0 -0
  127. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/static/view-toggle.js +0 -0
  128. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/summarize_config.py +0 -0
  129. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/__init__.py +0 -0
  130. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/experiment_rows.py +0 -0
  131. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/experiment_run_ids.py +0 -0
  132. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/file_prefix.py +0 -0
  133. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/file_text.py +0 -0
  134. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/iso_time.py +0 -0
  135. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/list_experiment_runs.py +0 -0
  136. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/note_text.py +0 -0
  137. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/path_glob.py +0 -0
  138. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/path_pattern.py +0 -0
  139. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tables/summarize_experiments.py +0 -0
  140. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/templates/base.html +0 -0
  141. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/templates/editor_imports.html +0 -0
  142. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/templates/experiment.html +0 -0
  143. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/templates/index.html +0 -0
  144. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/templates/note.html +0 -0
  145. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/templates/run.html +0 -0
  146. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/__init__.py +0 -0
  147. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/browse_app.py +0 -0
  148. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/browse_tui.py +0 -0
  149. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/dim.py +0 -0
  150. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/experiment_screen.py +0 -0
  151. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/experiments_screen.py +0 -0
  152. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/fill_table.py +0 -0
  153. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/format_elapsed.py +0 -0
  154. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/group_cells.py +0 -0
  155. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/invocation_columns.py +0 -0
  156. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/invocation_rows.py +0 -0
  157. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/leave_prompt.py +0 -0
  158. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/log_block.py +0 -0
  159. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/log_blocks.py +0 -0
  160. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/reload_on_change.py +0 -0
  161. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/run_app.py +0 -0
  162. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/run_cells.py +0 -0
  163. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/run_output.py +0 -0
  164. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/run_screen.py +0 -0
  165. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/run_tui.py +0 -0
  166. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/runs_table.py +0 -0
  167. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/tui/stopped_title.py +0 -0
  168. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/wait_for_note.py +0 -0
  169. {labnotes-0.0.100 → labnotes-0.0.102}/src/labnotes/watch_rows.py +0 -0
  170. {labnotes-0.0.100 → labnotes-0.0.102}/testing-conventions.toml +0 -0
  171. {labnotes-0.0.100 → labnotes-0.0.102}/tests/integration/conftest.py +0 -0
  172. {labnotes-0.0.100 → labnotes-0.0.102}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.100
3
+ Version: 0.0.102
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,12 @@ 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}`. An `[experiment]` table in
55
+ `.labnotes.toml` takes the same keys as `experiment.toml` and is the base
56
+ that `experiment.toml` layers over.
57
+
52
58
  `labnotes experiments` reads three tables from those paths:
53
59
 
54
60
  - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
@@ -25,6 +25,12 @@ 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}`. An `[experiment]` table in
31
+ `.labnotes.toml` takes the same keys as `experiment.toml` and is the base
32
+ that `experiment.toml` layers over.
33
+
28
34
  `labnotes experiments` reads three tables from those paths:
29
35
 
30
36
  - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
@@ -0,0 +1,8 @@
1
+ def compose_experiment(
2
+ base: dict[str, object], layer: dict[str, object]
3
+ ) -> dict[str, object]:
4
+ composed = {**base, **layer}
5
+ base_params, layer_params = base.get("params"), layer.get("params")
6
+ if isinstance(base_params, dict) and isinstance(layer_params, dict):
7
+ composed["params"] = {**base_params, **layer_params}
8
+ return composed
@@ -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. An `[experiment]` table in `.labnotes.toml` holds the same keys as `experiment.toml` and is the base each `experiment.toml` layers over (top-level keys replace, `params` merge by name). With no `[experiments] path`, it also makes the root the one experiment.
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,10 @@ 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
52
+ # [experiment] in .labnotes.toml, under each experiment.toml.
53
+ defaults: dict[str, object] = field(default_factory=dict)
50
54
 
51
55
 
52
56
  @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
@@ -0,0 +1,56 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+
4
+ from labnotes.models import LabnotesConfig
5
+ from labnotes.is_positive_int import is_positive_int
6
+ from labnotes.modes import DEFAULT_MODE, MODES
7
+ from labnotes.runner.read_experiment_config import SETTINGS
8
+ from labnotes.read_path import DEFAULT_PATHS, read_path
9
+
10
+ CONFIG_FILE = ".labnotes.toml"
11
+
12
+
13
+ def read_config(path: Path) -> LabnotesConfig:
14
+ try:
15
+ with path.open("rb") as file:
16
+ values = tomllib.load(file)
17
+ except tomllib.TOMLDecodeError as error:
18
+ raise ValueError(f"{path}: {error}") from error
19
+ unknown = sorted(values.keys() - {*DEFAULT_PATHS, "experiment"})
20
+ if unknown:
21
+ raise ValueError(f"{path}: unknown tables {', '.join(unknown)}")
22
+ experiments = values.get("experiments", {})
23
+ defaults = values.get("experiment", {})
24
+ if not isinstance(defaults, dict):
25
+ raise ValueError(f"{path}: experiment must be a table, like [experiment]")
26
+ unknown = sorted(defaults.keys() - SETTINGS)
27
+ if unknown:
28
+ raise ValueError(f"{path}: unknown keys in [experiment]: {', '.join(unknown)}")
29
+ root = path.resolve().parent
30
+ # An [experiments] path means many experiments, each taking [experiment] as
31
+ # its defaults; without one, [experiment] makes the root the experiment.
32
+ single = (root / "experiment.toml").is_file() or (
33
+ "experiment" in values
34
+ and not (isinstance(experiments, dict) and "path" in experiments)
35
+ )
36
+ paths = {
37
+ table: read_path(path, table, values.get(table, {}), single=single)
38
+ for table in DEFAULT_PATHS
39
+ }
40
+ mode = experiments.get("mode", DEFAULT_MODE)
41
+ if mode not in MODES:
42
+ raise ValueError(f"{path}: [experiments] mode must be matrix or adhoc")
43
+ concurrency = experiments.get("concurrency", 1)
44
+ if not is_positive_int(concurrency):
45
+ raise ValueError(
46
+ f"{path}: [experiments] concurrency must be a positive integer"
47
+ )
48
+ return LabnotesConfig(
49
+ path.resolve(),
50
+ root,
51
+ **paths,
52
+ mode=mode,
53
+ concurrency=concurrency,
54
+ experiment=root.name if single else None,
55
+ defaults=defaults,
56
+ )
@@ -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
@@ -11,13 +11,21 @@ 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"
17
21
  source = await file_text(config.root, experiment_config)
18
- if source is None:
22
+ if source is None and not config.defaults:
19
23
  raise ValueError(f"No experiment.toml for {name} at {experiment_config}.")
20
24
  experiment = read_experiment_config(
21
- experiment_config, source, config.mode, config.concurrency
25
+ config.path if source is None else experiment_config,
26
+ source or "",
27
+ config.mode,
28
+ config.concurrency,
29
+ config.defaults,
22
30
  )
23
31
  return experiment, experiment.concurrency if concurrency is None else concurrency
@@ -1,7 +1,9 @@
1
1
  import json
2
2
  import tomllib
3
3
  from pathlib import Path
4
+ from typing import Any
4
5
 
6
+ from labnotes.compose_experiment import compose_experiment
5
7
  from labnotes.default_counts import DEFAULT_COUNTS
6
8
  from labnotes.models import ExperimentConfig
7
9
  from labnotes.is_positive_int import is_positive_int
@@ -11,12 +13,17 @@ SETTINGS = {"name", "command", "repeats", "params", "counts", "mode", "concurren
11
13
 
12
14
 
13
15
  def read_experiment_config(
14
- path: Path, source: str, mode: str, concurrency: int
16
+ path: Path,
17
+ source: str,
18
+ mode: str,
19
+ concurrency: int,
20
+ defaults: dict[str, object] | None = None,
15
21
  ) -> ExperimentConfig:
16
22
  try:
17
- values = tomllib.loads(source)
23
+ layer = tomllib.loads(source)
18
24
  except tomllib.TOMLDecodeError as error:
19
25
  raise ValueError(f"{path}: {error}") from error
26
+ values: dict[str, Any] = compose_experiment(defaults or {}, layer)
20
27
  unknown = sorted(values.keys() - SETTINGS)
21
28
  if unknown:
22
29
  raise ValueError(f"{path}: unknown keys {', '.join(unknown)}")
@@ -0,0 +1,5 @@
1
+ import posixpath
2
+
3
+
4
+ def experiment_toml(template: str) -> str:
5
+ return posixpath.join(template, "experiment.toml")
@@ -0,0 +1,34 @@
1
+ import json
2
+
3
+ from labnotes.models import LabnotesConfig
4
+ from labnotes.sql_quote import quote_string
5
+
6
+ # experiments is a view, so files and runs can't declare a foreign key to it.
7
+ EXPERIMENTS = """
8
+ CREATE TABLE experiment_configs (
9
+ experiment TEXT PRIMARY KEY, config TEXT, source TEXT
10
+ );
11
+ CREATE VIEW experiments AS
12
+ SELECT name, coalesce(config ->> 'name', name) AS display_name, config, source
13
+ FROM (
14
+ SELECT
15
+ names.name,
16
+ coalesce(experiment_configs.config, {defaults}) AS config,
17
+ experiment_configs.source
18
+ FROM (
19
+ SELECT experiment AS name FROM experiment_configs
20
+ UNION SELECT experiment FROM files
21
+ UNION SELECT experiment FROM runs{one}
22
+ ) AS names
23
+ LEFT JOIN experiment_configs ON experiment_configs.experiment = names.name
24
+ );
25
+ """
26
+
27
+
28
+ def experiments_ddl(config: LabnotesConfig) -> str:
29
+ defaults = json.dumps(config.defaults, default=str) if config.defaults else None
30
+ one = config.experiment
31
+ return EXPERIMENTS.format(
32
+ defaults="NULL" if defaults is None else quote_string(defaults),
33
+ one="" if one is None else f"\n UNION SELECT {quote_string(one)}",
34
+ )
@@ -3,6 +3,8 @@ 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
7
+ from labnotes.tables.experiments_ddl import experiments_ddl
6
8
  from labnotes.tables.path_glob import path_glob
7
9
  from labnotes.tables.read_blocks import read_blocks
8
10
  from labnotes.tables.read_experiment import read_experiment
@@ -10,24 +12,6 @@ from labnotes.tables.read_file import read_file
10
12
  from labnotes.tables.read_log import read_log
11
13
  from labnotes.tables.read_run import read_run
12
14
 
13
- # experiments is a view, so files and runs can't declare a foreign key to it.
14
- EXPERIMENTS = """
15
- CREATE TABLE experiment_configs (
16
- experiment TEXT PRIMARY KEY, config TEXT, source TEXT
17
- );
18
- CREATE VIEW experiments AS
19
- SELECT
20
- names.name,
21
- coalesce(experiment_configs.config ->> 'name', names.name) AS display_name,
22
- experiment_configs.config,
23
- experiment_configs.source
24
- FROM (
25
- SELECT experiment AS name FROM experiment_configs
26
- UNION SELECT experiment FROM files
27
- UNION SELECT experiment FROM runs
28
- ) AS names
29
- LEFT JOIN experiment_configs ON experiment_configs.experiment = names.name;
30
- """
31
15
  FILES = """
32
16
  CREATE TABLE files (
33
17
  experiment TEXT, filename TEXT, path TEXT PRIMARY KEY, ctime TEXT, mtime TEXT,
@@ -66,31 +50,45 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
66
50
  name="files",
67
51
  ddl=FILES,
68
52
  glob=path_glob(config.files),
69
- on_file=partial(read_file, root, config.files),
53
+ on_file=partial(
54
+ read_file, root, config.files, experiment=config.experiment
55
+ ),
70
56
  ),
71
57
  Table(
72
58
  name="runs",
73
59
  ddl=RUNS,
74
60
  glob=path_glob(f"{config.runs}/run.json"),
75
- on_file=partial(read_run, root, config.runs),
61
+ on_file=partial(
62
+ read_run, root, config.runs, experiment=config.experiment
63
+ ),
76
64
  ),
77
65
  Table(
78
66
  name="logs",
79
67
  ddl=LOGS,
80
68
  glob=path_glob(f"{config.runs}/std*.log"),
81
- on_file=partial(read_log, root, config.runs),
69
+ on_file=partial(
70
+ read_log, root, config.runs, experiment=config.experiment
71
+ ),
82
72
  ),
83
73
  Table(
84
74
  name="blocks",
85
75
  ddl=BLOCKS,
86
76
  glob=path_glob(f"{config.runs}/log.jsonl"),
87
- on_file=partial(read_blocks, root, config.runs),
77
+ on_file=partial(
78
+ read_blocks, root, config.runs, experiment=config.experiment
79
+ ),
88
80
  ),
89
81
  Table(
90
82
  name="experiment_configs",
91
- ddl=EXPERIMENTS,
92
- glob=path_glob(f"{config.experiments}/experiment.toml"),
93
- on_file=partial(read_experiment, root, config.experiments),
83
+ ddl=experiments_ddl(config),
84
+ glob=path_glob(experiment_toml(config.experiments)),
85
+ on_file=partial(
86
+ read_experiment,
87
+ root,
88
+ config.experiments,
89
+ experiment=config.experiment,
90
+ defaults=config.defaults,
91
+ ),
94
92
  ),
95
93
  ],
96
94
  # Run folders are often gitignored (DVC, large outputs); scan them anyway.
@@ -3,14 +3,16 @@ from pathlib import Path
3
3
  from labnotes.tables.path_pattern import path_pattern
4
4
 
5
5
 
6
- def read_blocks(root: Path, template: str, path: str) -> list[dict[str, object]]:
6
+ def read_blocks(
7
+ root: Path, template: str, path: str, experiment: str | None = None
8
+ ) -> list[dict[str, object]]:
7
9
  relative = Path(path).relative_to(root).as_posix()
8
10
  found = path_pattern(f"{template}/log.jsonl").fullmatch(relative)
9
11
  if found is None:
10
12
  return []
11
13
  return [
12
14
  {
13
- "experiment": found["experiment"],
15
+ "experiment": experiment or found["experiment"],
14
16
  "run": found["run"],
15
17
  "size": Path(path).stat().st_size,
16
18
  }
@@ -0,0 +1,29 @@
1
+ import json
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ from labnotes.compose_experiment import compose_experiment
6
+ from labnotes.tables.experiment_toml import experiment_toml
7
+ from labnotes.tables.path_pattern import path_pattern
8
+
9
+
10
+ def read_experiment(
11
+ root: Path,
12
+ template: str,
13
+ path: str,
14
+ experiment: str | None = None,
15
+ defaults: dict[str, object] | None = None,
16
+ ) -> list[dict[str, object]]:
17
+ relative = Path(path).relative_to(root).as_posix()
18
+ found = path_pattern(experiment_toml(template)).fullmatch(relative)
19
+ if found is None:
20
+ return []
21
+ source = Path(path).read_text(encoding="utf-8")
22
+ config = compose_experiment(defaults or {}, tomllib.loads(source))
23
+ return [
24
+ {
25
+ "experiment": experiment or found["experiment"],
26
+ "config": json.dumps(config),
27
+ "source": source,
28
+ }
29
+ ]
@@ -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"),
@@ -1,7 +0,0 @@
1
- from labnotes.read_path import DEFAULT_PATHS
2
-
3
-
4
- def default_config_text() -> str:
5
- return "\n".join(
6
- f'[{table}]\npath = "{template}"\n' for table, template in DEFAULT_PATHS.items()
7
- )
@@ -1,36 +0,0 @@
1
- import tomllib
2
- from pathlib import Path
3
-
4
- from labnotes.models import LabnotesConfig
5
- from labnotes.is_positive_int import is_positive_int
6
- from labnotes.modes import DEFAULT_MODE, MODES
7
- from labnotes.read_path import DEFAULT_PATHS, read_path
8
-
9
- CONFIG_FILE = ".labnotes.toml"
10
-
11
-
12
- def read_config(path: Path) -> LabnotesConfig:
13
- try:
14
- with path.open("rb") as file:
15
- values = tomllib.load(file)
16
- except tomllib.TOMLDecodeError as error:
17
- raise ValueError(f"{path}: {error}") from error
18
- unknown = sorted(values.keys() - DEFAULT_PATHS.keys())
19
- if unknown:
20
- raise ValueError(f"{path}: unknown tables {', '.join(unknown)}")
21
- paths = {
22
- table: read_path(path, table, values.get(table, {})) for table in DEFAULT_PATHS
23
- }
24
- experiments = values.get("experiments", {})
25
- mode = experiments.get("mode", DEFAULT_MODE)
26
- if mode not in MODES:
27
- raise ValueError(f"{path}: [experiments] mode must be matrix or adhoc")
28
- concurrency = experiments.get("concurrency", 1)
29
- if not is_positive_int(concurrency):
30
- raise ValueError(
31
- f"{path}: [experiments] concurrency must be a positive integer"
32
- )
33
- path = path.resolve()
34
- return LabnotesConfig(
35
- path, path.parent, **paths, mode=mode, concurrency=concurrency
36
- )
@@ -1,21 +0,0 @@
1
- import json
2
- import tomllib
3
- from pathlib import Path
4
-
5
- from labnotes.tables.path_pattern import path_pattern
6
-
7
-
8
- def read_experiment(root: Path, template: str, path: str) -> list[dict[str, object]]:
9
- relative = Path(path).relative_to(root).as_posix()
10
- found = path_pattern(f"{template}/experiment.toml").fullmatch(relative)
11
- if found is None:
12
- return []
13
- source = Path(path).read_text(encoding="utf-8")
14
- config = tomllib.loads(source)
15
- return [
16
- {
17
- "experiment": found["experiment"],
18
- "config": json.dumps(config),
19
- "source": source,
20
- }
21
- ]
File without changes
File without changes
File without changes
File without changes