labnotes 0.0.13__tar.gz → 0.0.15__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 (92) hide show
  1. {labnotes-0.0.13 → labnotes-0.0.15}/.gitignore +1 -0
  2. labnotes-0.0.15/PKG-INFO +136 -0
  3. labnotes-0.0.15/README.md +113 -0
  4. {labnotes-0.0.13 → labnotes-0.0.15}/pyproject.toml +10 -1
  5. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/cli.py +4 -0
  6. labnotes-0.0.15/src/labnotes/context.md +80 -0
  7. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/context_guide.py +4 -2
  8. labnotes-0.0.15/src/labnotes/default_config_text.py +7 -0
  9. labnotes-0.0.15/src/labnotes/experiments.py +76 -0
  10. labnotes-0.0.15/src/labnotes/init.py +17 -0
  11. labnotes-0.0.15/src/labnotes/models.py +32 -0
  12. labnotes-0.0.15/src/labnotes/print_rows.py +19 -0
  13. labnotes-0.0.15/src/labnotes/read_config.py +27 -0
  14. labnotes-0.0.15/src/labnotes/read_path.py +34 -0
  15. labnotes-0.0.15/src/labnotes/runner/__init__.py +0 -0
  16. labnotes-0.0.15/src/labnotes/runner/describe_event.py +17 -0
  17. labnotes-0.0.15/src/labnotes/runner/execute_run.py +49 -0
  18. labnotes-0.0.15/src/labnotes/runner/lock_path.py +7 -0
  19. labnotes-0.0.15/src/labnotes/runner/new_run_id.py +11 -0
  20. labnotes-0.0.15/src/labnotes/runner/param_args.py +16 -0
  21. labnotes-0.0.15/src/labnotes/runner/param_grid.py +6 -0
  22. labnotes-0.0.15/src/labnotes/runner/plan_runs.py +9 -0
  23. labnotes-0.0.15/src/labnotes/runner/read_experiment_config.py +35 -0
  24. labnotes-0.0.15/src/labnotes/runner/run_experiment.py +66 -0
  25. labnotes-0.0.15/src/labnotes/runner/run_folder.py +5 -0
  26. labnotes-0.0.15/src/labnotes/runner/run_headless.py +15 -0
  27. labnotes-0.0.15/src/labnotes/runner/shell_command.py +9 -0
  28. labnotes-0.0.15/src/labnotes/runner/start_experiment.py +32 -0
  29. labnotes-0.0.15/src/labnotes/runner/write_run_json.py +8 -0
  30. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/server.py +4 -12
  31. labnotes-0.0.15/src/labnotes/tables/__init__.py +0 -0
  32. labnotes-0.0.15/src/labnotes/tables/experiment_rows.py +17 -0
  33. labnotes-0.0.15/src/labnotes/tables/file_prefix.py +9 -0
  34. labnotes-0.0.15/src/labnotes/tables/iso_time.py +5 -0
  35. labnotes-0.0.15/src/labnotes/tables/list_experiment_runs.py +10 -0
  36. labnotes-0.0.15/src/labnotes/tables/open_tables.py +63 -0
  37. labnotes-0.0.15/src/labnotes/tables/path_glob.py +8 -0
  38. labnotes-0.0.15/src/labnotes/tables/path_pattern.py +23 -0
  39. labnotes-0.0.15/src/labnotes/tables/read_experiment.py +15 -0
  40. labnotes-0.0.15/src/labnotes/tables/read_file.py +23 -0
  41. labnotes-0.0.15/src/labnotes/tables/read_run.py +27 -0
  42. labnotes-0.0.15/src/labnotes/tables/summarize_experiments.py +27 -0
  43. labnotes-0.0.15/src/labnotes/tui/__init__.py +0 -0
  44. labnotes-0.0.15/src/labnotes/tui/format_elapsed.py +8 -0
  45. labnotes-0.0.15/src/labnotes/tui/run_app.py +90 -0
  46. labnotes-0.0.15/src/labnotes/tui/run_tui.py +8 -0
  47. {labnotes-0.0.13 → labnotes-0.0.15}/uv.lock +82 -0
  48. labnotes-0.0.13/PKG-INFO +0 -68
  49. labnotes-0.0.13/README.md +0 -49
  50. labnotes-0.0.13/src/labnotes/context.md +0 -18
  51. labnotes-0.0.13/src/labnotes/models.py +0 -17
  52. labnotes-0.0.13/src/labnotes/read_config.py +0 -23
  53. {labnotes-0.0.13 → labnotes-0.0.15}/CHANGELOG.md +0 -0
  54. {labnotes-0.0.13 → labnotes-0.0.15}/MIGRATIONS.md +0 -0
  55. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/__init__.py +0 -0
  56. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/asset_url.py +0 -0
  57. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/cell_progress.py +0 -0
  58. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/count_runs.py +0 -0
  59. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/create_app.py +0 -0
  60. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/data_html.py +0 -0
  61. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/drain_watch.py +0 -0
  62. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/format_value.py +0 -0
  63. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/list_experiments.py +0 -0
  64. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/list_runs.py +0 -0
  65. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/load_config.py +0 -0
  66. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/load_experiment.py +0 -0
  67. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/notebook_kernel.py +0 -0
  68. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/open_experiments.py +0 -0
  69. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/open_runs.py +0 -0
  70. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/output_html.py +0 -0
  71. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/parse_run_view.py +0 -0
  72. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/pivot_cells.py +0 -0
  73. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/python_blocks.py +0 -0
  74. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/read_design.py +0 -0
  75. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/read_note.py +0 -0
  76. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/render_markdown.py +0 -0
  77. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/run_columns.py +0 -0
  78. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/saved_outputs.py +0 -0
  79. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/serve_app.py +0 -0
  80. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/sort_href.py +0 -0
  81. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/sql_quote.py +0 -0
  82. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/static/labnotes.css +0 -0
  83. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/static/row-link.js +0 -0
  84. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/static/run-cell.js +0 -0
  85. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/static/view-toggle.js +0 -0
  86. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/stop_idle_kernels.py +0 -0
  87. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/study_progress.py +0 -0
  88. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/summarize_design.py +0 -0
  89. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/templates/base.html +0 -0
  90. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/templates/experiment.html +0 -0
  91. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/templates/index.html +0 -0
  92. {labnotes-0.0.13 → labnotes-0.0.15}/src/labnotes/templates/macros.html +0 -0
@@ -2,6 +2,7 @@ target/
2
2
  node_modules/
3
3
  dist/
4
4
  coverage/
5
+ .coverage
5
6
  *.log
6
7
  .DS_Store
7
8
 
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: labnotes
3
+ Version: 0.0.15
4
+ Summary: Browse experiment designs, notes, and run data from a folder.
5
+ Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
+ Author-email: Kevin Scott <me@thekevinscott.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: click>=8.5.0
10
+ Requires-Dist: dirsql>=0.4.110
11
+ Requires-Dist: filelock>=4.2.0
12
+ Requires-Dist: haikunator>=2.1.0
13
+ Requires-Dist: ipykernel>=7.4.0
14
+ Requires-Dist: jinja2>=3.1.6
15
+ Requires-Dist: jupyter-client>=8.10.0
16
+ Requires-Dist: markdown-it-py>=4.2.0
17
+ Requires-Dist: pyyaml>=6.0.3
18
+ Requires-Dist: rich>=14
19
+ Requires-Dist: starlette>=1.8.0
20
+ Requires-Dist: textual>=8.2.8
21
+ Requires-Dist: uvicorn>=0.54.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # labnotes
25
+
26
+ Browse experiment designs, notes, and run data from a folder.
27
+
28
+ ```sh
29
+ uvx labnotes server
30
+ ```
31
+
32
+ A project's root is the folder holding its `.labnotes.toml`. `labnotes init`
33
+ writes one with the default layout:
34
+
35
+ ```toml
36
+ [experiments]
37
+ path = "{experiment}"
38
+
39
+ [files]
40
+ path = "{experiment}/files/"
41
+
42
+ [runs]
43
+ path = "{experiment}/runs/{run}"
44
+ ```
45
+
46
+ Each path is relative to the root. `{experiment}` and `{run}` stand for folder
47
+ names: the experiments path must use `{experiment}`, and the runs path both.
48
+ labnotes reads the file from the working folder, or from the path given with
49
+ `-c`.
50
+
51
+ `labnotes experiments` reads three tables from those paths:
52
+
53
+ - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
54
+ JSON. An experiment is a folder matching the experiments path that holds an
55
+ `experiment.toml`, files or runs.
56
+ - `files`: `experiment`, `filename`, `path`, `ctime`, `mtime`.
57
+ - `runs`: `experiment`, `id`, `folder`, `created`, `ended`, `exit_code`,
58
+ `error`, `stdout`, `stderr`, and `params` as JSON, read from each run
59
+ folder's `run.json`.
60
+
61
+ Keys come from folder names, so renaming a folder renames its rows on the next
62
+ read. Paths are relative to the root, and times are UTC ISO 8601.
63
+
64
+ ```sh
65
+ labnotes experiments # name, last_run, created, files, runs
66
+ labnotes experiments foo # the same, plus config
67
+ labnotes experiments foo runs # foo's runs, oldest first
68
+ ```
69
+
70
+ Each takes `--format table` (the default) or `--format json`.
71
+
72
+ `labnotes experiments foo run` runs `foo` from its `experiment.toml`, in the
73
+ experiments folder:
74
+
75
+ ```toml
76
+ command = "uv run scripts/study.py --output {run_dir}"
77
+ repeats = 5
78
+
79
+ [params]
80
+ model = ["opus", "sonnet"]
81
+ effort = ["low", "high"]
82
+ ```
83
+
84
+ Each combination of `[params]` runs `repeats` times, round-robin. Only runs
85
+ that exit 0 count, so running again fills in what failed or never ran.
86
+ Each run gets a new run folder. `command` runs in a shell from the root, with
87
+ `{run_dir}` replaced by the run folder's absolute path. Pass it to the
88
+ script so it writes its outputs there.
89
+ Each param is appended as `--name value`, with the name as written (use
90
+ `source-language` for a `--source-language` flag), booleans as 0 or 1, and lists
91
+ and tables as JSON. labnotes writes `run.json` (params, created, ended, exit
92
+ code), `stdout.log` and `stderr.log` to the run folder.
93
+
94
+ In a terminal, `run` shows a live table of runs (status and time) and stays
95
+ open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
96
+ line per event instead.
97
+
98
+ - `--concurrency N` runs N at a time (default 1).
99
+ - `-e PATH` reads another file instead of `experiment.toml`.
100
+ - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
101
+ kills them.
102
+ - A second `run` of the same experiment fails while the first holds the lock.
103
+ - It exits 1 if any run failed.
104
+
105
+ `labnotes server` doesn't read `.labnotes.toml` yet. Give it the two folders
106
+ it uses with `--experiments-folder` and `--data-folder`:
107
+
108
+ - `--experiments-folder` holds one subfolder per experiment, each with a
109
+ `design.yaml` (with a `study` key) and any number of markdown notes.
110
+ - `--data-folder` holds the run data and a `.dirsql.toml` that declares a
111
+ `runs` table with a `study` column. An experiment's runs are the rows whose
112
+ `study` matches its design's.
113
+
114
+ That serves a local site at http://127.0.0.1:8000: a table of experiments,
115
+ and a page per experiment with its parameters, its notes, progress per cell of
116
+ the design grid, and every run so far. Both folders are watched, so a reload
117
+ shows new runs and edited notes.
118
+
119
+ `design.yaml` keys the site reads: `study`, `fixed`, `varies` (each key a list
120
+ of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
121
+ `runs` table that marks a run as complete. Anything else shows up as-is.
122
+
123
+ `-h`/`--host` and `-p`/`--port` change where it listens; `-h 0.0.0.0` (or a
124
+ Tailscale IP) serves beyond localhost. Every other uvicorn option passes
125
+ through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
126
+ `--ssl-*` set, and the rest. `labnotes server --help` lists them. `LABNOTES_EXPERIMENTS_FOLDER` and
127
+ `LABNOTES_DATA_FOLDER` can stand in for the folder flags.
128
+
129
+ ## Agent skill
130
+
131
+ ```sh
132
+ npx skills add thekevinscott/labnotes
133
+ ```
134
+
135
+ The skill tells a coding agent to run `labnotes context`, which prints a usage
136
+ guide for the installed version.
@@ -0,0 +1,113 @@
1
+ # labnotes
2
+
3
+ Browse experiment designs, notes, and run data from a folder.
4
+
5
+ ```sh
6
+ uvx labnotes server
7
+ ```
8
+
9
+ A project's root is the folder holding its `.labnotes.toml`. `labnotes init`
10
+ writes one with the default layout:
11
+
12
+ ```toml
13
+ [experiments]
14
+ path = "{experiment}"
15
+
16
+ [files]
17
+ path = "{experiment}/files/"
18
+
19
+ [runs]
20
+ path = "{experiment}/runs/{run}"
21
+ ```
22
+
23
+ Each path is relative to the root. `{experiment}` and `{run}` stand for folder
24
+ names: the experiments path must use `{experiment}`, and the runs path both.
25
+ labnotes reads the file from the working folder, or from the path given with
26
+ `-c`.
27
+
28
+ `labnotes experiments` reads three tables from those paths:
29
+
30
+ - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
31
+ JSON. An experiment is a folder matching the experiments path that holds an
32
+ `experiment.toml`, files or runs.
33
+ - `files`: `experiment`, `filename`, `path`, `ctime`, `mtime`.
34
+ - `runs`: `experiment`, `id`, `folder`, `created`, `ended`, `exit_code`,
35
+ `error`, `stdout`, `stderr`, and `params` as JSON, read from each run
36
+ folder's `run.json`.
37
+
38
+ Keys come from folder names, so renaming a folder renames its rows on the next
39
+ read. Paths are relative to the root, and times are UTC ISO 8601.
40
+
41
+ ```sh
42
+ labnotes experiments # name, last_run, created, files, runs
43
+ labnotes experiments foo # the same, plus config
44
+ labnotes experiments foo runs # foo's runs, oldest first
45
+ ```
46
+
47
+ Each takes `--format table` (the default) or `--format json`.
48
+
49
+ `labnotes experiments foo run` runs `foo` from its `experiment.toml`, in the
50
+ experiments folder:
51
+
52
+ ```toml
53
+ command = "uv run scripts/study.py --output {run_dir}"
54
+ repeats = 5
55
+
56
+ [params]
57
+ model = ["opus", "sonnet"]
58
+ effort = ["low", "high"]
59
+ ```
60
+
61
+ Each combination of `[params]` runs `repeats` times, round-robin. Only runs
62
+ that exit 0 count, so running again fills in what failed or never ran.
63
+ Each run gets a new run folder. `command` runs in a shell from the root, with
64
+ `{run_dir}` replaced by the run folder's absolute path. Pass it to the
65
+ script so it writes its outputs there.
66
+ Each param is appended as `--name value`, with the name as written (use
67
+ `source-language` for a `--source-language` flag), booleans as 0 or 1, and lists
68
+ and tables as JSON. labnotes writes `run.json` (params, created, ended, exit
69
+ code), `stdout.log` and `stderr.log` to the run folder.
70
+
71
+ In a terminal, `run` shows a live table of runs (status and time) and stays
72
+ open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
73
+ line per event instead.
74
+
75
+ - `--concurrency N` runs N at a time (default 1).
76
+ - `-e PATH` reads another file instead of `experiment.toml`.
77
+ - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
78
+ kills them.
79
+ - A second `run` of the same experiment fails while the first holds the lock.
80
+ - It exits 1 if any run failed.
81
+
82
+ `labnotes server` doesn't read `.labnotes.toml` yet. Give it the two folders
83
+ it uses with `--experiments-folder` and `--data-folder`:
84
+
85
+ - `--experiments-folder` holds one subfolder per experiment, each with a
86
+ `design.yaml` (with a `study` key) and any number of markdown notes.
87
+ - `--data-folder` holds the run data and a `.dirsql.toml` that declares a
88
+ `runs` table with a `study` column. An experiment's runs are the rows whose
89
+ `study` matches its design's.
90
+
91
+ That serves a local site at http://127.0.0.1:8000: a table of experiments,
92
+ and a page per experiment with its parameters, its notes, progress per cell of
93
+ the design grid, and every run so far. Both folders are watched, so a reload
94
+ shows new runs and edited notes.
95
+
96
+ `design.yaml` keys the site reads: `study`, `fixed`, `varies` (each key a list
97
+ of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
98
+ `runs` table that marks a run as complete. Anything else shows up as-is.
99
+
100
+ `-h`/`--host` and `-p`/`--port` change where it listens; `-h 0.0.0.0` (or a
101
+ Tailscale IP) serves beyond localhost. Every other uvicorn option passes
102
+ through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
103
+ `--ssl-*` set, and the rest. `labnotes server --help` lists them. `LABNOTES_EXPERIMENTS_FOLDER` and
104
+ `LABNOTES_DATA_FOLDER` can stand in for the folder flags.
105
+
106
+ ## Agent skill
107
+
108
+ ```sh
109
+ npx skills add thekevinscott/labnotes
110
+ ```
111
+
112
+ The skill tells a coding agent to run `labnotes context`, which prints a usage
113
+ guide for the installed version.
@@ -13,12 +13,16 @@ authors = [{ name = "Kevin Scott", email = "me@thekevinscott.com" }]
13
13
  dependencies = [
14
14
  "click>=8.5.0",
15
15
  "dirsql>=0.4.110",
16
+ "filelock>=4.2.0",
17
+ "haikunator>=2.1.0",
16
18
  "ipykernel>=7.4.0",
17
19
  "jinja2>=3.1.6",
18
20
  "jupyter-client>=8.10.0",
19
21
  "markdown-it-py>=4.2.0",
20
22
  "pyyaml>=6.0.3",
23
+ "rich>=14",
21
24
  "starlette>=1.8.0",
25
+ "textual>=8.2.8",
22
26
  "uvicorn>=0.54.0",
23
27
  ]
24
28
 
@@ -51,7 +55,7 @@ exclude = ["*_test.py"]
51
55
  packages = ["src/labnotes"]
52
56
 
53
57
  [tool.pytest.ini_options]
54
- testpaths = ["src/labnotes"]
58
+ testpaths = ["src/labnotes", "tests"]
55
59
  python_files = "*_test.py"
56
60
 
57
61
  [tool.ruff.lint]
@@ -61,3 +65,8 @@ select = ["E4", "E7", "E9", "F", "TID252"]
61
65
 
62
66
  [tool.ruff.lint.flake8-tidy-imports]
63
67
  ban-relative-imports = "all"
68
+
69
+ # haikunator ships no type hints.
70
+ [[tool.mypy.overrides]]
71
+ module = ["haikunator"]
72
+ ignore_missing_imports = true
@@ -3,6 +3,8 @@ from pathlib import Path
3
3
  import click
4
4
 
5
5
  from labnotes.context_guide import context_guide
6
+ from labnotes.experiments import experiments
7
+ from labnotes.init import init
6
8
  from labnotes.load_config import load_config
7
9
  from labnotes.read_config import CONFIG_FILE
8
10
  from labnotes.server import server
@@ -35,4 +37,6 @@ def context(ctx: click.Context) -> None:
35
37
  click.echo(context_guide(load_config(ctx), Path.cwd()), nl=False)
36
38
 
37
39
 
40
+ main.add_command(experiments)
41
+ main.add_command(init)
38
42
  main.add_command(server)
@@ -0,0 +1,80 @@
1
+ ## Usage
2
+
3
+ A project that uses labnotes has a `.labnotes.toml` at its root; the folder holding it is the root. labnotes reads it from the working folder, so run labnotes from the project root, or pass `-c PATH`. The lines above this guide name the one found and its paths, or say there is none. `labnotes init` writes one with the default layout:
4
+
5
+ ```toml
6
+ [experiments]
7
+ path = "{experiment}" # one folder per experiment
8
+
9
+ [files]
10
+ path = "{experiment}/files/" # an experiment's notes and other files
11
+
12
+ [runs]
13
+ path = "{experiment}/runs/{run}" # one folder per run
14
+ ```
15
+
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
+
18
+ `labnotes experiments` reads three tables from those paths:
19
+
20
+ - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
21
+ JSON. An experiment is a folder matching the experiments path that holds an
22
+ `experiment.toml`, files or runs.
23
+ - `files`: `experiment`, `filename`, `path`, `ctime`, `mtime`.
24
+ - `runs`: `experiment`, `id`, `folder`, `created`, `ended`, `exit_code`,
25
+ `error`, `stdout`, `stderr`, and `params` as JSON, read from each run
26
+ folder's `run.json`.
27
+
28
+ Keys come from folder names, so renaming a folder renames its rows on the next
29
+ read. Paths are relative to the root, and times are UTC ISO 8601.
30
+
31
+ ```sh
32
+ labnotes experiments # name, last_run, created, files, runs
33
+ labnotes experiments foo # the same, plus config
34
+ labnotes experiments foo runs # foo's runs, oldest first
35
+ ```
36
+
37
+ Each takes `--format table` (the default) or `--format json`; use `--format json` when you parse the output.
38
+
39
+ `labnotes experiments foo run` runs `foo` from its `experiment.toml`, in the
40
+ experiments folder:
41
+
42
+ ```toml
43
+ command = "uv run scripts/study.py --output {run_dir}"
44
+ repeats = 5
45
+
46
+ [params]
47
+ model = ["opus", "sonnet"]
48
+ effort = ["low", "high"]
49
+ ```
50
+
51
+ Each combination of `[params]` runs `repeats` times, round-robin. Only runs
52
+ that exit 0 count, so running again fills in what failed or never ran.
53
+ Each run gets a new run folder. `command` runs in a shell from the root, with
54
+ `{run_dir}` replaced by the run folder's absolute path. Pass it to the
55
+ script so it writes its outputs there.
56
+ Each param is appended as `--name value`, with the name as written (use
57
+ `source-language` for a `--source-language` flag), booleans as 0 or 1, and lists
58
+ and tables as JSON. labnotes writes `run.json` (params, created, ended, exit
59
+ code), `stdout.log` and `stderr.log` to the run folder.
60
+
61
+ In a terminal, `run` shows a live table of runs (status and time) and stays
62
+ open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
63
+ line per event instead.
64
+
65
+ - `--concurrency N` runs N at a time (default 1).
66
+ - `-e PATH` reads another file instead of `experiment.toml`.
67
+ - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
68
+ kills them.
69
+ - A second `run` of the same experiment fails while the first holds the lock.
70
+ - It exits 1 if any run failed.
71
+
72
+ `labnotes server` doesn't read `.labnotes.toml` yet: pass `--experiments-folder` and `--data-folder`, or set `LABNOTES_EXPERIMENTS_FOLDER` and `LABNOTES_DATA_FOLDER`. The experiments folder has one subfolder per experiment, each with a `design.yaml` and markdown notes. The data folder holds run data and a `.dirsql.toml` declaring a `runs` table with a `study` column.
73
+
74
+ The run data lives in the data folder. Query it with [dirsql](https://dirsql.dev) and the data folder's `.dirsql.toml`, which declares the `runs` table:
75
+
76
+ ```sh
77
+ uvx dirsql -c data/.dirsql.toml "SELECT * FROM runs LIMIT 1" --format json
78
+ ```
79
+
80
+ `uvx labnotes server` serves the experiments and their runs as a local site.
@@ -16,7 +16,9 @@ def context_guide(config: LabnotesConfig | None, cwd: Path) -> str:
16
16
  else:
17
17
  project = (
18
18
  f"Config: {config.path}\n"
19
- f"Experiments folder: {config.experiments_folder}\n"
20
- f"Data folder: {config.data_folder}"
19
+ f"Root: {config.root}\n"
20
+ f"Experiments: {config.experiments}\n"
21
+ f"Files: {config.files}\n"
22
+ f"Runs: {config.runs}"
21
23
  )
22
24
  return f"labnotes {version('labnotes')}\n\n{project}\n\n{guide}"
@@ -0,0 +1,7 @@
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
+ )
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ import sys
3
+ from functools import partial
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from labnotes.load_config import load_config
9
+ from labnotes.print_rows import print_rows
10
+ from labnotes.read_config import CONFIG_FILE
11
+ from labnotes.runner.run_headless import run_headless
12
+ from labnotes.runner.start_experiment import start_experiment
13
+ from labnotes.tables.experiment_rows import experiment_rows
14
+ from labnotes.tui.run_tui import run_tui
15
+
16
+
17
+ @click.command(help="List experiments, show one, list its runs, or run it.")
18
+ @click.argument("name", required=False)
19
+ @click.argument("action", required=False, type=click.Choice(["runs", "run"]))
20
+ @click.option(
21
+ "--format",
22
+ "output_format",
23
+ type=click.Choice(["table", "json"]),
24
+ default="table",
25
+ show_default=True,
26
+ )
27
+ @click.option(
28
+ "--concurrency",
29
+ type=click.IntRange(min=1),
30
+ default=1,
31
+ show_default=True,
32
+ help="With run: how many runs at once.",
33
+ )
34
+ @click.option(
35
+ "-e",
36
+ "--experiment-config",
37
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
38
+ help="With run: read this file instead of the experiment's experiment.toml.",
39
+ )
40
+ @click.option(
41
+ "--no-tui",
42
+ is_flag=True,
43
+ help="With run: print a line per event instead of the live view.",
44
+ )
45
+ @click.pass_context
46
+ def experiments(
47
+ ctx: click.Context,
48
+ name: str | None,
49
+ action: str | None,
50
+ output_format: str,
51
+ concurrency: int,
52
+ experiment_config: Path | None,
53
+ no_tui: bool,
54
+ ) -> None:
55
+ config = load_config(ctx)
56
+ if config is None:
57
+ raise click.ClickException(
58
+ f"No {CONFIG_FILE} here. Run labnotes init, or pass -c PATH."
59
+ )
60
+ match name, action:
61
+ case str(), "run":
62
+ live = sys.stdout.isatty() and sys.stderr.isatty() and not no_tui
63
+ drive = partial(run_tui, title=name) if live else run_headless
64
+ try:
65
+ results = start_experiment(
66
+ config, name, experiment_config, concurrency, drive
67
+ )
68
+ except ValueError as error:
69
+ raise click.ClickException(str(error)) from error
70
+ failed = sum(result["exit_code"] != 0 for result in results)
71
+ click.echo(f"{len(results) - failed} passed, {failed} failed.")
72
+ ctx.exit(1 if failed else 0)
73
+ rows = asyncio.run(experiment_rows(config, name, action))
74
+ if rows is None:
75
+ raise click.ClickException(f"No experiment named {name}.")
76
+ print_rows(rows, output_format)
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from labnotes.default_config_text import default_config_text
6
+ from labnotes.read_config import CONFIG_FILE
7
+
8
+
9
+ @click.command(help=f"Write a {CONFIG_FILE} with the default layout here.")
10
+ def init() -> None:
11
+ path = Path.cwd() / CONFIG_FILE
12
+ try:
13
+ with path.open("x", encoding="utf-8") as file:
14
+ file.write(default_config_text())
15
+ except FileExistsError as error:
16
+ raise click.ClickException(f"{path} already exists.") from error
17
+ click.echo(f"Wrote {path}")
@@ -0,0 +1,32 @@
1
+ from collections.abc import Callable, Coroutine
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ Report = Callable[[dict[str, object]], None]
7
+ Listen = Callable[[Callable[[], None]], None]
8
+ Start = Callable[[Report, Listen], Coroutine[Any, Any, list[dict[str, object]]]]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RunView:
13
+ sort: str | None = None
14
+ descending: bool = False
15
+ status: str | None = None
16
+ filters: dict[str, str] = field(default_factory=dict)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LabnotesConfig:
21
+ path: Path
22
+ root: Path
23
+ experiments: str
24
+ files: str
25
+ runs: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ExperimentConfig:
30
+ command: str
31
+ repeats: int
32
+ params: dict[str, list[object]]
@@ -0,0 +1,19 @@
1
+ import json
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+
8
+ def print_rows(rows: list[dict[str, object]], output_format: str) -> None:
9
+ match output_format:
10
+ case "json":
11
+ click.echo(json.dumps(rows, indent=2))
12
+ return
13
+ if not rows:
14
+ click.echo("No rows.")
15
+ return
16
+ table = Table(*rows[0].keys())
17
+ for row in rows:
18
+ table.add_row(*("" if value is None else str(value) for value in row.values()))
19
+ Console().print(table)
@@ -0,0 +1,27 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+
4
+ from labnotes.models import LabnotesConfig
5
+ from labnotes.read_path import DEFAULT_PATHS, read_path
6
+
7
+ CONFIG_FILE = ".labnotes.toml"
8
+
9
+
10
+ def read_config(path: Path) -> LabnotesConfig:
11
+ try:
12
+ with path.open("rb") as file:
13
+ values = tomllib.load(file)
14
+ except tomllib.TOMLDecodeError as error:
15
+ raise ValueError(f"{path}: {error}") from error
16
+ unknown = sorted(values.keys() - DEFAULT_PATHS.keys())
17
+ if unknown:
18
+ raise ValueError(f"{path}: unknown tables {', '.join(unknown)}")
19
+ path = path.resolve()
20
+ return LabnotesConfig(
21
+ path,
22
+ path.parent,
23
+ **{
24
+ table: read_path(path, table, values.get(table, {}))
25
+ for table in DEFAULT_PATHS
26
+ },
27
+ )
@@ -0,0 +1,34 @@
1
+ import re
2
+ from pathlib import Path, PurePosixPath
3
+
4
+ DEFAULT_PATHS = {
5
+ "experiments": "{experiment}",
6
+ "files": "{experiment}/files/",
7
+ "runs": "{experiment}/runs/{run}",
8
+ }
9
+ PLACEHOLDERS = {
10
+ "experiments": {"experiment"},
11
+ "files": {"experiment"},
12
+ "runs": {"experiment", "run"},
13
+ }
14
+ PLACEHOLDER = re.compile(r"\{([^}]*)\}")
15
+
16
+
17
+ def read_path(config: Path, table: str, section: object) -> str:
18
+ if not isinstance(section, dict):
19
+ raise ValueError(f"{config}: {table} must be a table, like [{table}]")
20
+ unknown = sorted(section.keys() - {"path"})
21
+ if unknown:
22
+ raise ValueError(f"{config}: unknown keys in [{table}]: {', '.join(unknown)}")
23
+ template = section.get("path", DEFAULT_PATHS[table])
24
+ if not isinstance(template, str):
25
+ raise ValueError(f"{config}: [{table}] path must be a string")
26
+ if PurePosixPath(template).is_absolute() or template.startswith("~"):
27
+ raise ValueError(
28
+ f"{config}: [{table}] path must be relative to the config's folder"
29
+ )
30
+ required = PLACEHOLDERS[table]
31
+ if set(PLACEHOLDER.findall(template)) != required:
32
+ expected = " and ".join(f"{{{name}}}" for name in sorted(required))
33
+ raise ValueError(f"{config}: [{table}] path must use exactly {expected}")
34
+ return template
File without changes
@@ -0,0 +1,17 @@
1
+ from labnotes.runner.param_args import param_args
2
+
3
+
4
+ def describe_event(event: dict[str, object]) -> str:
5
+ match event["event"]:
6
+ case "planned":
7
+ return f"{event['runs']} runs to go, {event['concurrency']} at a time."
8
+ case "started":
9
+ args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
10
+ return f"{event['run']} started {args}".rstrip()
11
+ case "ended":
12
+ outcome = event["error"] or f"exited {event['exit_code']}"
13
+ return f"{event['run']} {outcome}"
14
+ case "stopping":
15
+ return "Stopping runs. Ctrl-C again to kill them."
16
+ case _:
17
+ return "Killing runs."