labnotes 0.0.46__tar.gz → 0.0.47__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 (100) hide show
  1. {labnotes-0.0.46 → labnotes-0.0.47}/PKG-INFO +1 -1
  2. {labnotes-0.0.46 → labnotes-0.0.47}/pyproject.toml +1 -0
  3. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/context.md +3 -3
  4. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/create_app.py +1 -7
  5. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/load_experiment.py +7 -5
  6. labnotes-0.0.47/src/labnotes/static/experiment.js +21 -0
  7. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/static/labnotes.css +93 -105
  8. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/open_tables.py +5 -2
  9. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/read_experiment.py +9 -3
  10. labnotes-0.0.47/src/labnotes/templates/experiment.html +106 -0
  11. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tui/run_app.py +34 -24
  12. labnotes-0.0.47/testing-conventions.toml +11 -0
  13. {labnotes-0.0.46 → labnotes-0.0.47}/uv.lock +100 -0
  14. labnotes-0.0.46/src/labnotes/pivot_cells.py +0 -21
  15. labnotes-0.0.46/src/labnotes/templates/experiment.html +0 -167
  16. labnotes-0.0.46/testing-conventions.toml +0 -5
  17. {labnotes-0.0.46 → labnotes-0.0.47}/.gitignore +0 -0
  18. {labnotes-0.0.46 → labnotes-0.0.47}/CHANGELOG.md +0 -0
  19. {labnotes-0.0.46 → labnotes-0.0.47}/MIGRATIONS.md +0 -0
  20. {labnotes-0.0.46 → labnotes-0.0.47}/README.md +0 -0
  21. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/__init__.py +0 -0
  22. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/asset_url.py +0 -0
  23. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/cell_progress.py +0 -0
  24. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/check_counts.py +0 -0
  25. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/cli.py +0 -0
  26. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/context_guide.py +0 -0
  27. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/count_runs.py +0 -0
  28. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/create_experiment.py +0 -0
  29. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/data_html.py +0 -0
  30. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/default_config_text.py +0 -0
  31. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/default_counts.py +0 -0
  32. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/drain_watch.py +0 -0
  33. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/experiment_notes.py +0 -0
  34. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/experiment_progress.py +0 -0
  35. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/experiment_runs.py +0 -0
  36. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/experiments.py +0 -0
  37. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/format_value.py +0 -0
  38. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/init.py +0 -0
  39. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/is_positive_int.py +0 -0
  40. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/list_experiments.py +0 -0
  41. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/list_runs.py +0 -0
  42. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/load_config.py +0 -0
  43. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/models.py +0 -0
  44. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/modes.py +0 -0
  45. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/notebook_kernel.py +0 -0
  46. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/output_html.py +0 -0
  47. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/parse_run_view.py +0 -0
  48. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/print_rows.py +0 -0
  49. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/python_blocks.py +0 -0
  50. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/read_config.py +0 -0
  51. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/read_path.py +0 -0
  52. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/render_markdown.py +0 -0
  53. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/__init__.py +0 -0
  54. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/describe_event.py +0 -0
  55. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/execute_run.py +0 -0
  56. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/hold_folder.py +0 -0
  57. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/new_invocation_id.py +0 -0
  58. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/new_run_id.py +0 -0
  59. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  60. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/param_args.py +0 -0
  61. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/param_grid.py +0 -0
  62. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/plan_runs.py +0 -0
  63. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/read_experiment_config.py +0 -0
  64. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/run_experiment.py +0 -0
  65. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/run_folder.py +0 -0
  66. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/run_headless.py +0 -0
  67. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/runs_parent.py +0 -0
  68. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/shell_command.py +0 -0
  69. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/start_experiment.py +0 -0
  70. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/stop_then_kill.py +0 -0
  71. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/wait_for_group.py +0 -0
  72. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/runner/write_run_json.py +0 -0
  73. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/saved_outputs.py +0 -0
  74. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/serve_app.py +0 -0
  75. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/server.py +0 -0
  76. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/sort_href.py +0 -0
  77. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/sql_quote.py +0 -0
  78. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/static/row-link.js +0 -0
  79. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/static/run-cell.js +0 -0
  80. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/static/view-toggle.js +0 -0
  81. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/stop_idle_kernels.py +0 -0
  82. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/summarize_config.py +0 -0
  83. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/__init__.py +0 -0
  84. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/experiment_rows.py +0 -0
  85. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/file_prefix.py +0 -0
  86. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/iso_time.py +0 -0
  87. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/list_experiment_runs.py +0 -0
  88. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/path_glob.py +0 -0
  89. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/path_pattern.py +0 -0
  90. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/read_file.py +0 -0
  91. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/read_run.py +0 -0
  92. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tables/summarize_experiments.py +0 -0
  93. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/templates/base.html +0 -0
  94. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/templates/index.html +0 -0
  95. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/templates/macros.html +0 -0
  96. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tui/__init__.py +0 -0
  97. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tui/dim.py +0 -0
  98. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tui/format_elapsed.py +0 -0
  99. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tui/run_tui.py +0 -0
  100. {labnotes-0.0.46 → labnotes-0.0.47}/src/labnotes/tui/runs_table.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.46
3
+ Version: 0.0.47
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>
@@ -35,6 +35,7 @@ dev = [
35
35
  "curtaincall>=0.3.0",
36
36
  "httpx2>=2.13.1",
37
37
  "mypy>=2.4.0",
38
+ "playwright>=1.64.0",
38
39
  "pytest>=8.0",
39
40
  "ruff==0.8.4",
40
41
  ]
@@ -101,8 +101,8 @@ the end of the queue. `/` filters rows by params text; Enter keeps the filter,
101
101
  Escape clears it. `D` deselects every pending run the filter shows. `x` stops the
102
102
  running run under the cursor; `x` again kills it. `+` and
103
103
  `-` change how many run at once; lowering it lets running runs
104
- finish. Enter on a run follows its `stdout.log`; `e` switches to
105
- `stderr.log` and back, and Escape returns to the table. An `adhoc` experiment that
104
+ finish. Enter on a run follows its `stdout.log` and `stderr.log` together, as
105
+ they're written, and Escape returns to the table. An `adhoc` experiment that
106
106
  plans a single run opens straight on its output. `q` quits. Elsewhere, or with `--no-tui`, it prints a
107
107
  line per event instead.
108
108
 
@@ -117,4 +117,4 @@ line per event instead.
117
117
  - A second `run` of the same experiment fails while the first holds the lock.
118
118
  - It exits 1 if any run failed.
119
119
 
120
- `uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. A grid cell is full once it has `repeats` runs that count.
120
+ `uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file.
@@ -2,7 +2,6 @@ import asyncio
2
2
  from collections.abc import AsyncIterator
3
3
  from contextlib import asynccontextmanager
4
4
  from pathlib import Path
5
- from typing import cast
6
5
 
7
6
  from starlette.applications import Starlette
8
7
  from starlette.exceptions import HTTPException
@@ -20,7 +19,6 @@ from labnotes.list_experiments import list_experiments
20
19
  from labnotes.load_experiment import load_experiment
21
20
  from labnotes.models import LabnotesConfig
22
21
  from labnotes.notebook_kernel import NotebookKernel
23
- from labnotes.pivot_cells import pivot_cells
24
22
  from labnotes.python_blocks import python_blocks
25
23
  from labnotes.saved_outputs import saved_outputs
26
24
  from labnotes.stop_idle_kernels import stop_idle_kernels
@@ -69,12 +67,8 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
69
67
  )
70
68
  if loaded is None:
71
69
  raise HTTPException(404, "No such experiment")
72
- varies = cast(dict[str, list[object]], loaded["varies"])
73
- cells = cast(list[dict[str, object]], loaded["cells"])
74
70
  return templates.TemplateResponse(
75
- request,
76
- "experiment.html",
77
- {"experiment": loaded, "pivot": pivot_cells(varies, cells)},
71
+ request, "experiment.html", {"experiment": loaded}
78
72
  )
79
73
 
80
74
  async def note_content(request: Request) -> str:
@@ -5,9 +5,9 @@ from typing import cast
5
5
 
6
6
  from dirsql import DirSQL
7
7
 
8
- from labnotes.experiment_notes import experiment_notes
9
8
  from labnotes.check_counts import check_counts
10
- from labnotes.experiment_progress import experiment_progress
9
+ from labnotes.count_runs import count_runs
10
+ from labnotes.experiment_notes import experiment_notes
11
11
  from labnotes.experiment_runs import experiment_runs
12
12
  from labnotes.list_runs import list_runs
13
13
  from labnotes.parse_run_view import parse_run_view
@@ -31,7 +31,7 @@ async def load_experiment(
31
31
  outputs_for: Callable[[str, str], Sequence[str]] = _no_outputs,
32
32
  ) -> dict[str, object] | None:
33
33
  found = await tables.query(
34
- "SELECT name, display_name, config FROM experiments"
34
+ "SELECT name, display_name, config, source FROM experiments"
35
35
  f" WHERE name = {quote_string(name)}"
36
36
  )
37
37
  if not found:
@@ -42,15 +42,17 @@ async def load_experiment(
42
42
  varies = cast(dict[str, list[object]], summary["varies"])
43
43
  source = experiment_runs(name, list(varies))
44
44
  summary = await check_counts(tables, source, summary)
45
- progress = await experiment_progress(tables, source, summary)
46
45
  shown = ["id", *(key for key in varies if key != "id")]
47
46
  view = parse_run_view(params, shown)
47
+ [totals] = await count_runs(tables, source, str(summary["completed_run"]))
48
48
  notes = await experiment_notes(tables, root, name)
49
49
  return {
50
50
  "name": row["name"],
51
51
  "display_name": row["display_name"],
52
+ "config_source": row["source"],
53
+ "counts_set": "counts" in (config or {}),
52
54
  **summary,
53
- **progress,
55
+ **totals,
54
56
  "notes": [
55
57
  {
56
58
  "name": note["name"],
@@ -0,0 +1,21 @@
1
+ const summary = document.querySelector(".config-summary");
2
+ const open = document.querySelector(".config-open");
3
+ const dialog = document.querySelector(".config-file");
4
+
5
+ if (summary) {
6
+ const fit = () => {
7
+ open.hidden = summary.scrollWidth <= summary.clientWidth;
8
+ };
9
+ new ResizeObserver(fit).observe(summary);
10
+ open.addEventListener("click", () => dialog.showModal());
11
+ dialog.querySelector(".config-close").addEventListener("click", () => dialog.close());
12
+ }
13
+
14
+ for (const row of document.querySelectorAll(".run-row")) {
15
+ row.addEventListener("click", () => {
16
+ if (getSelection().toString()) return;
17
+ const record = row.nextElementSibling;
18
+ record.hidden = !record.hidden;
19
+ row.setAttribute("aria-expanded", String(!record.hidden));
20
+ });
21
+ }
@@ -112,10 +112,6 @@ h3 {
112
112
  .study { font-family: var(--mono); font-size: 0.82rem; color: var(--ink-3); margin: 0.35rem 0 0; }
113
113
  .intro .study { font-size: 0.9rem; margin-top: 0.6rem; }
114
114
 
115
- .toc { display: flex; gap: 1.4rem; margin-top: 1.4rem; font-size: 0.88rem; }
116
- .toc a { color: var(--ink-2); text-decoration: none; }
117
- .toc a:hover { color: var(--accent); }
118
-
119
115
  .empty, .notice { color: var(--ink-2); }
120
116
  .notice {
121
117
  margin-top: 1.25rem;
@@ -181,31 +177,6 @@ h3 {
181
177
  .progress-line .num { text-align: right; }
182
178
  .pct { color: var(--ink-2); }
183
179
 
184
- /* Headline figures */
185
- .figures {
186
- display: grid;
187
- grid-template-columns: 2fr 1fr 1fr;
188
- gap: 1px;
189
- background: var(--rule);
190
- border: 1px solid var(--rule);
191
- border-radius: 10px;
192
- overflow: hidden;
193
- margin-bottom: 3.5rem;
194
- }
195
- .figure { background: var(--surface); padding: 1.3rem 1.5rem 1.4rem; }
196
- .figure-label { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
197
- .figure-value { font-size: 2.4rem; font-weight: 500; letter-spacing: -0.03em; line-height: 1.15; margin: 0.35rem 0 0.6rem; font-variant-numeric: tabular-nums; }
198
- .figure .meter { height: 6px; border-radius: 3px; margin-bottom: 0.7rem; }
199
- .figure-note { color: var(--ink-2); font-size: 0.86rem; }
200
-
201
- .block { margin-bottom: 3.5rem; }
202
-
203
- /* Design */
204
- .design-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 2.5rem; margin-bottom: 1.75rem; }
205
- .factors { display: grid; grid-template-columns: max-content 1fr; gap: 0.6rem 1.25rem; margin: 0; align-items: baseline; }
206
- .factors dt { font-family: var(--mono); font-size: 0.82rem; color: var(--ink-2); }
207
- .factors dd { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
208
-
209
180
  .chip {
210
181
  font-family: var(--mono);
211
182
  font-size: 0.8rem;
@@ -217,30 +188,100 @@ h3 {
217
188
  }
218
189
  .chip.quiet { background: var(--chip); border-color: var(--rule); }
219
190
 
220
- .facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1.25rem; margin: 0; padding-top: 1.25rem; border-top: 1px dashed var(--rule); font-size: 0.88rem; }
221
- .facts dt { color: var(--ink-3); }
222
- .facts dd { margin: 0; }
223
-
224
- /* Progress grid */
225
- .scroll { overflow-x: auto; }
226
- .grid { border-collapse: separate; border-spacing: 2px; font-size: 0.86rem; }
227
- .grid th { font-weight: 500; text-align: left; white-space: nowrap; }
228
- .grid thead th { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-2); padding: 0 0.75rem 0.5rem; vertical-align: bottom; }
229
- .grid thead th.label { color: var(--ink-3); }
230
- .grid thead th.axis { font-family: var(--sans); font-size: 0.66rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); padding-bottom: 0.15rem; border-bottom: 1px solid var(--rule); }
231
- .grid .axis-row + tr th { padding-top: 0.45rem; }
232
- .grid tbody th { font-family: var(--mono); font-size: 0.8rem; color: var(--ink-2); padding: 0 1rem 0 0.75rem; }
233
- .grid tbody tr + tr th { border-top: 1px solid transparent; }
234
- .cell {
235
- min-width: 6.5rem;
236
- padding: 0.55rem 0.75rem 0.65rem;
191
+ /* experiment.toml line */
192
+ .config-line { display: flex; align-items: baseline; gap: 0.75rem; margin-top: 1.1rem; }
193
+ .config-summary {
194
+ flex: 1;
195
+ min-width: 0;
196
+ margin: 0;
197
+ overflow: hidden;
198
+ white-space: nowrap;
199
+ text-overflow: ellipsis;
200
+ font-size: 0.88rem;
201
+ color: var(--ink-3);
202
+ }
203
+ .setting { margin-left: 1.1rem; }
204
+ .setting b { font-weight: 600; color: var(--ink); }
205
+ .config-open, .config-close {
206
+ font: inherit;
207
+ font-family: var(--mono);
208
+ font-size: 0.75rem;
209
+ padding: 0.15rem 0.6rem;
210
+ border: 1px solid var(--rule-strong);
211
+ border-radius: 5px;
212
+ background: var(--surface);
213
+ color: var(--ink-2);
214
+ cursor: pointer;
215
+ white-space: nowrap;
216
+ }
217
+ .config-open:hover, .config-close:hover { color: var(--accent); border-color: var(--accent); }
218
+ .config-file {
219
+ width: min(48rem, 90vw);
220
+ padding: 0;
221
+ border: 1px solid var(--rule);
222
+ border-radius: 10px;
223
+ background: var(--surface);
224
+ color: var(--ink);
225
+ }
226
+ .config-file::backdrop { background: color-mix(in srgb, var(--ink) 30%, transparent); }
227
+ .config-file header {
228
+ display: flex;
229
+ justify-content: space-between;
230
+ align-items: center;
231
+ padding: 0.6rem 1.25rem;
232
+ border-bottom: 1px solid var(--rule);
233
+ font-family: var(--mono);
234
+ font-size: 0.78rem;
235
+ color: var(--ink-3);
236
+ }
237
+ .config-file pre { margin: 0; padding: 1rem 1.25rem; max-height: 70vh; overflow: auto; font-size: 0.82rem; }
238
+
239
+ /* Tabs */
240
+ .tabs { display: flex; gap: 1.6rem; border-bottom: 1px solid var(--rule); margin-bottom: 1.75rem; font-size: 0.95rem; }
241
+ .tabs a { color: var(--ink-2); text-decoration: none; padding: 0.5rem 0; margin-bottom: -1px; border-bottom: 2px solid transparent; }
242
+ .tabs a:hover { color: var(--ink); }
243
+ .tabs a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--accent); }
244
+ .tabs .count { font-size: 0.8rem; color: var(--ink-3); margin-left: 0.3rem; }
245
+ .tabs.sub { gap: 1.2rem; margin-top: -1rem; font-family: var(--mono); font-size: 0.8rem; }
246
+
247
+ /* Runs table */
248
+ .narrowed { color: var(--ink-2); font-size: 0.88rem; margin: 0 0 0.75rem; }
249
+ .narrowed .clear { margin-left: 0.8rem; color: var(--ink-3); }
250
+ .runs-table {
251
+ width: 100%;
252
+ border-collapse: separate;
253
+ border-spacing: 0;
237
254
  background: var(--surface);
238
255
  border: 1px solid var(--rule);
239
- border-radius: 6px;
256
+ border-radius: 10px;
257
+ font-size: 0.86rem;
240
258
  }
241
- .cell .num { display: block; margin-bottom: 0.4rem; }
242
- .cell.empty .num { color: var(--ink-3); }
243
- .cell.done { border-color: color-mix(in srgb, var(--accent) 35%, var(--rule)); }
259
+ .runs-table th, .runs-table td { padding: 0.55rem 1rem; text-align: left; border-bottom: 1px solid var(--rule); }
260
+ .runs-table th { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); white-space: nowrap; }
261
+ .runs-table th:first-child, .runs-table .run-row td:first-child { width: 4rem; }
262
+ .runs-table .sort { color: inherit; text-decoration: none; }
263
+ .runs-table .sort:hover, .runs-table .sorted { color: var(--ink); }
264
+ .runs-table .arrow { margin-left: 0.3rem; color: var(--accent); }
265
+ .runs-table .filters td { padding-top: 0.4rem; padding-bottom: 0.4rem; background: var(--paper); }
266
+ .runs-table .filters input, .runs-table .filters select {
267
+ width: 100%;
268
+ min-width: 0;
269
+ font: inherit;
270
+ font-size: 0.82rem;
271
+ padding: 0.2rem 0.45rem;
272
+ color: var(--ink);
273
+ background: var(--surface);
274
+ border: 1px solid var(--rule);
275
+ border-radius: 5px;
276
+ }
277
+ .runs-table .filters select { padding: 0.2rem 0; }
278
+ .runs-table .filters input:focus, .runs-table .filters select:focus { outline: none; border-color: var(--accent); }
279
+ .runs-table tbody tr:last-child td { border-bottom: 0; }
280
+ .run-row { cursor: pointer; }
281
+ .run-row td { max-width: 24rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
282
+ .run-row:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
283
+ .run-row[aria-expanded="true"] { background: color-mix(in srgb, var(--accent) 6%, transparent); }
284
+ .record-row td { background: var(--paper); }
244
285
 
245
286
  /* Notes */
246
287
  .note {
@@ -288,56 +329,6 @@ h3 {
288
329
  .prose th { font-weight: 600; border-bottom-color: var(--rule-strong); }
289
330
  .prose hr { border: 0; border-top: 1px solid var(--rule); margin: 2em 0; }
290
331
 
291
- /* Runs */
292
- .runs {
293
- --row: 2.75rem repeat(var(--columns), minmax(0, 1fr));
294
- background: var(--surface);
295
- border: 1px solid var(--rule);
296
- border-radius: 10px;
297
- overflow: hidden;
298
- font-size: 0.86rem;
299
- }
300
- .run-head, .run summary, .run-filters {
301
- display: grid;
302
- grid-template-columns: var(--row);
303
- gap: 1rem;
304
- padding: 0.6rem 1.25rem;
305
- align-items: center;
306
- }
307
- .run-head {
308
- font-size: 0.72rem;
309
- font-weight: 600;
310
- letter-spacing: 0.08em;
311
- text-transform: uppercase;
312
- color: var(--ink-3);
313
- border-bottom: 1px solid var(--rule);
314
- }
315
- .run-head .sort { color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
316
- .run-head .sort:hover, .run-head .sorted { color: var(--ink); }
317
- .run-head .arrow { margin-left: 0.3rem; color: var(--accent); }
318
- .run-filters { padding-top: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--rule); background: var(--paper); }
319
- .run-filters input, .run-filters select {
320
- width: 100%;
321
- min-width: 0;
322
- font: inherit;
323
- font-size: 0.82rem;
324
- padding: 0.2rem 0.45rem;
325
- color: var(--ink);
326
- background: var(--surface);
327
- border: 1px solid var(--rule);
328
- border-radius: 5px;
329
- }
330
- .run-filters select { padding: 0.2rem 0; }
331
- .run-filters input:focus, .run-filters select:focus { outline: none; border-color: var(--accent); }
332
- .run-empty { margin: 0; padding: 1rem 1.25rem; }
333
- h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; color: var(--ink-3); }
334
- .run { border-bottom: 1px solid var(--rule); }
335
- .run:last-child { border-bottom: 0; }
336
- .run summary { cursor: pointer; list-style: none; }
337
- .run summary::-webkit-details-marker { display: none; }
338
- .run summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
339
- .run summary:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
340
- .run[open] summary { background: color-mix(in srgb, var(--accent) 6%, transparent); }
341
332
  .status { font-size: 0.8rem; font-weight: 700; }
342
333
  .status.good { color: var(--good); }
343
334
  .status.bad { color: var(--bad); }
@@ -348,9 +339,7 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
348
339
  grid-template-columns: max-content minmax(0, 1fr);
349
340
  gap: 0.45rem 1.5rem;
350
341
  margin: 0;
351
- padding: 1rem 1.25rem 1.25rem 3.75rem;
352
- border-top: 1px dashed var(--rule);
353
- background: var(--paper);
342
+ padding: 0.5rem 0 0.75rem 2.25rem;
354
343
  }
355
344
  .record dt { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-3); }
356
345
  .record dd { margin: 0; min-width: 0; }
@@ -403,7 +392,6 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
403
392
  @media (max-width: 52rem) {
404
393
  main, .masthead { padding-left: 1.1rem; padding-right: 1.1rem; }
405
394
  h1 { font-size: 2rem; }
406
- .figures { grid-template-columns: 1fr; }
407
395
  .ledger .design { display: none; }
408
396
  .progress-col { width: auto; }
409
397
  }
@@ -429,7 +417,7 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
429
417
  }
430
418
  .cell .run:hover, .note-name .restart:hover { color: var(--accent); border-color: var(--accent); }
431
419
  .cell .run:disabled, .note-name .restart:disabled { cursor: progress; opacity: 0.6; }
432
- .note-name:has(.restart) { display: flex; align-items: center; justify-content: space-between; padding-top: 0.45rem; padding-bottom: 0.45rem; }
420
+ .note-name:has(.restart) { display: flex; align-items: center; justify-content: flex-end; padding-top: 0.45rem; padding-bottom: 0.45rem; }
433
421
  .cell-output { display: block; }
434
422
  .cell-output:not(:empty) { margin-top: 0.4rem; padding-left: 0.9rem; border-left: 2px solid var(--accent-soft); overflow-x: auto; }
435
423
  .cell-output pre { background: none; border: 0; padding: 0.3rem 0; }
@@ -10,12 +10,15 @@ from labnotes.tables.read_run import read_run
10
10
 
11
11
  # experiments is a view, so files and runs can't declare a foreign key to it.
12
12
  EXPERIMENTS = """
13
- CREATE TABLE experiment_configs (experiment TEXT PRIMARY KEY, config TEXT);
13
+ CREATE TABLE experiment_configs (
14
+ experiment TEXT PRIMARY KEY, config TEXT, source TEXT
15
+ );
14
16
  CREATE VIEW experiments AS
15
17
  SELECT
16
18
  names.name,
17
19
  coalesce(experiment_configs.config ->> 'name', names.name) AS display_name,
18
- experiment_configs.config
20
+ experiment_configs.config,
21
+ experiment_configs.source
19
22
  FROM (
20
23
  SELECT experiment AS name FROM experiment_configs
21
24
  UNION SELECT experiment FROM files
@@ -10,6 +10,12 @@ def read_experiment(root: Path, template: str, path: str) -> list[dict[str, obje
10
10
  found = path_pattern(f"{template}/experiment.toml").fullmatch(relative)
11
11
  if found is None:
12
12
  return []
13
- with Path(path).open("rb") as file:
14
- config = tomllib.load(file)
15
- return [{"experiment": found["experiment"], "config": json.dumps(config)}]
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
+ ]
@@ -0,0 +1,106 @@
1
+ {% extends "base.html" %}
2
+ {% set e = experiment %}
3
+ {% set tab = 'notebooks' if request.query_params.get('tab') == 'notebooks' else 'runs' %}
4
+ {% block title %}{{ e.display_name }} · labnotes{% endblock %}
5
+ {% block crumbs %}<span class="crumb"><a href="{{ url_for('index') }}">Experiments</a></span><span class="crumb">{{ e.display_name }}</span>{% endblock %}
6
+ {% block main %}
7
+ <section class="intro">
8
+ <h1>{{ e.display_name }}</h1>
9
+ {% if e.display_name != e.name %}<p class="study">{{ e.name }}</p>{% endif %}
10
+ {% if e.config_source is not none %}
11
+ <div class="config-line">
12
+ <p class="config-summary">
13
+ <span class="chip quiet">{{ e.mode }}</span>
14
+ {% for key, value in e.other.items() %}<span class="setting">{{ key }} <code>{{ value | value }}</code></span>{% endfor %}
15
+ {% if e.runs_per_cell %}<span class="setting">repeats <b class="num">{{ e.runs_per_cell }}</b></span>{% endif %}
16
+ {% for key, values in e.varies.items() %}<span class="setting">{{ key }}{% for value in values %} <span class="chip">{{ value | value }}</span>{% endfor %}</span>{% endfor %}
17
+ {% if e.counts_error %}<span class="setting counts-error">{{ e.counts_error }}</span>{% elif e.counts_set %}<span class="setting">counts <code>{{ e.completed_run }}</code></span>{% endif %}
18
+ </p>
19
+ <button type="button" class="config-open">experiment.toml</button>
20
+ </div>
21
+ <dialog class="config-file">
22
+ <header>experiment.toml<button type="button" class="config-close">Close</button></header>
23
+ <pre>{{ e.config_source }}</pre>
24
+ </dialog>
25
+ {% endif %}
26
+ </section>
27
+
28
+ <nav class="tabs">
29
+ <a href="?"{% if tab == 'runs' %} aria-current="page"{% endif %}>Runs <span class="count num">{{ e.runs }}</span></a>
30
+ <a href="?tab=notebooks"{% if tab == 'notebooks' %} aria-current="page"{% endif %}>Notebooks <span class="count num">{{ e.notes | length }}</span></a>
31
+ </nav>
32
+
33
+ {% if tab == 'notebooks' %}
34
+ {% set chosen = request.query_params.get('note') %}
35
+ {% set note = (e.notes | selectattr('name', 'equalto', chosen) | first) or (e.notes | first) %}
36
+ {% if note %}
37
+ <nav class="tabs sub">
38
+ {% for each in e.notes %}<a href="?tab=notebooks&amp;note={{ each.name | urlencode }}"{% if each.name == note.name %} aria-current="page"{% endif %}>{{ each.name }}</a>{% endfor %}
39
+ </nav>
40
+ <article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}" data-restart-url="{{ url_for('restart_kernel', name=e.name, note=note.name) }}">
41
+ {% if note.runnable %}<header class="note-name"><button type="button" class="restart" title="Stop this note's Python kernel. The next Run starts a fresh one.">Restart</button></header>{% endif %}
42
+ <div class="prose">{{ note.html | safe }}</div>
43
+ </article>
44
+ <script src="{{ asset('run-cell.js') }}" defer></script>
45
+ {% else %}
46
+ <p class="empty">No notebooks yet.</p>
47
+ {% endif %}
48
+ {% else %}
49
+ <section id="runs">
50
+ {% set view = e.run_view %}
51
+ {% set narrowed = view.status or view.filters %}
52
+ {% if narrowed %}<p class="narrowed"><span class="num">{{ e.runs_list | length }}</span> of <span class="num">{{ e.runs }}</span> <a class="clear" href="{{ '?sort=' ~ (view.sort | urlencode) ~ ('&order=desc' if view.descending else '') if view.sort else '?' }}#runs">Clear filters</a></p>{% endif %}
53
+ {% if e.runs %}
54
+ <form id="run-filters" method="get" action="#runs">
55
+ {% if view.sort %}<input type="hidden" name="sort" value="{{ view.sort }}">{% endif %}
56
+ {% if view.descending %}<input type="hidden" name="order" value="desc">{% endif %}
57
+ <button type="submit" hidden>Filter</button>
58
+ </form>
59
+ <div class="scroll">
60
+ <table class="runs-table">
61
+ <thead>
62
+ <tr>
63
+ {% for column in ['status'] + e.run_columns %}
64
+ {% set sorted = view.sort == column %}
65
+ <th scope="col"{% if sorted %} aria-sort="{{ 'descending' if view.descending else 'ascending' }}"{% endif %}><a class="sort{{ ' sorted' if sorted }}" href="{{ e.sort_hrefs[column] }}" title="Sort by {{ column }}">{{ '' if column == 'status' else column }}{% if sorted %}<span class="arrow">{{ '↓' if view.descending else '↑' }}</span>{% endif %}</a></th>
66
+ {% endfor %}
67
+ </tr>
68
+ <tr class="filters">
69
+ <td><select form="run-filters" name="status" aria-label="Filter by status" onchange="this.form.requestSubmit()">
70
+ <option value=""{{ ' selected' if not view.status }}>all</option>
71
+ <option value="complete"{{ ' selected' if view.status == 'complete' }}>✓</option>
72
+ <option value="incomplete"{{ ' selected' if view.status == 'incomplete' }}>✕</option>
73
+ </select></td>
74
+ {% for column in e.run_columns %}<td><input form="run-filters" type="search" name="f.{{ column }}" value="{{ view.filters.get(column, '') }}" placeholder="filter" aria-label="Filter {{ column }}"></td>{% endfor %}
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ {% for run in e.runs_list %}
79
+ <tr class="run-row" aria-expanded="false">
80
+ <td><span class="status {{ 'good' if run.labnotes_completed else 'bad' }}" title="{{ 'complete' if run.labnotes_completed else 'not complete' }}">{{ '✓' if run.labnotes_completed else '✕' }}</span></td>
81
+ {% for column in e.run_columns %}<td class="{{ 'mono' if loop.first }}">{{ run[column] | value }}</td>{% endfor %}
82
+ </tr>
83
+ <tr class="record-row" hidden>
84
+ <td colspan="{{ e.run_columns | length + 1 }}">
85
+ <dl class="record">
86
+ {% for key, value in run.items() if key != 'labnotes_completed' %}
87
+ <dt>{{ key }}</dt>
88
+ {% set shown = value | value %}
89
+ <dd>{% if shown | length > 80 %}<pre>{{ shown }}</pre>{% else %}<code>{{ shown }}</code>{% endif %}</dd>
90
+ {% endfor %}
91
+ </dl>
92
+ </td>
93
+ </tr>
94
+ {% else %}
95
+ <tr><td class="empty" colspan="{{ e.run_columns | length + 1 }}">No runs match these filters.</td></tr>
96
+ {% endfor %}
97
+ </tbody>
98
+ </table>
99
+ </div>
100
+ {% else %}
101
+ <p class="empty">No runs recorded yet.</p>
102
+ {% endif %}
103
+ </section>
104
+ {% endif %}
105
+ <script src="{{ asset('experiment.js') }}" defer></script>
106
+ {% endblock %}
@@ -17,7 +17,7 @@ from labnotes.tui.format_elapsed import format_elapsed
17
17
  from labnotes.tui.runs_table import RunsTable
18
18
 
19
19
  COLUMNS = ("run", "params", "status", "time")
20
- OTHER_STREAM = {"stdout": "stderr", "stderr": "stdout"}
20
+ STREAMS = ("stdout", "stderr")
21
21
  PENDING, PASSED, FAILED = "○", "✓", "✗"
22
22
  SPINNER = "|/-\\"
23
23
  SKIPPED, DESELECTED = "–", "⊘"
@@ -33,7 +33,6 @@ class RunApp(App[None]):
33
33
  Binding("f", "hide_failed", "Hide failed"),
34
34
  Binding("r", "retry", "Retry"),
35
35
  Binding("slash", "filter", "Filter", key_display="/"),
36
- Binding("e", "switch_output", "stdout/stderr"),
37
36
  Binding("escape", "leave", "Back"),
38
37
  Binding("plus,equals_sign", "concurrency(1)", "More at once", key_display="+"),
39
38
  Binding("minus", "concurrency(-1)", "Fewer at once", key_display="-"),
@@ -67,9 +66,13 @@ class RunApp(App[None]):
67
66
  self.hiding = False
68
67
  self.folders: dict[str, Path] = {}
69
68
  self.viewing: str | None = None
70
- self.stream = "stdout"
71
- self.offset: int
72
- self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
69
+ self.offsets: dict[str, int]
70
+ self.decoders = {
71
+ stream: codecs.getincrementaldecoder("utf-8")(errors="replace")
72
+ for stream in STREAMS
73
+ }
74
+ self.last_stream: str | None
75
+ self.mid_line: bool
73
76
 
74
77
  def compose(self) -> ComposeResult:
75
78
  yield Header()
@@ -115,7 +118,7 @@ class RunApp(App[None]):
115
118
  self.show(describe_event(event))
116
119
  match event["mode"], event["runs"]:
117
120
  case "adhoc", [only]:
118
- self.view(only["run"], "stdout") # type: ignore[index]
121
+ self.view(only["run"]) # type: ignore[index]
119
122
  case "started":
120
123
  self.idle = False
121
124
  self.pending.discard(run)
@@ -273,34 +276,41 @@ class RunApp(App[None]):
273
276
  field.focus()
274
277
 
275
278
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
276
- self.view(str(event.row_key.value), "stdout")
279
+ self.view(str(event.row_key.value))
277
280
 
278
- def view(self, run: str, stream: str) -> None:
281
+ def view(self, run: str) -> None:
279
282
  self.viewing = run
280
- self.stream = stream
281
- self.offset = 0
282
- self.decoder.reset()
283
+ self.offsets = dict.fromkeys(STREAMS, 0)
284
+ for decoder in self.decoders.values():
285
+ decoder.reset()
286
+ self.last_stream = None
287
+ self.mid_line = False
283
288
  self.query_one(Log).clear()
284
289
  self.query_one(DataTable).display = False
285
290
  self.query_one(Log).display = True
286
- self.show(f"{run} {stream}. e switches, Escape goes back.")
291
+ self.show(f"{run} stdout and stderr. Escape goes back.")
287
292
  self.follow()
288
293
 
289
294
  def follow(self) -> None:
290
295
  if self.viewing not in self.folders:
291
296
  return
292
- path = self.folders[self.viewing] / f"{self.stream}.log"
293
- if not path.exists():
294
- return
295
- with path.open("rb") as log:
296
- log.seek(self.offset)
297
- data = log.read()
298
- self.offset += len(data)
299
- self.query_one(Log).write(self.decoder.decode(data))
300
-
301
- def action_switch_output(self) -> None:
302
- if self.viewing is not None:
303
- self.view(self.viewing, OTHER_STREAM[self.stream])
297
+ for stream in STREAMS:
298
+ path = self.folders[self.viewing] / f"{stream}.log"
299
+ if not path.exists():
300
+ continue
301
+ with path.open("rb") as log:
302
+ log.seek(self.offsets[stream])
303
+ data = log.read()
304
+ self.offsets[stream] += len(data)
305
+ text = self.decoders[stream].decode(data)
306
+ if not text:
307
+ continue
308
+ # Don't glue one stream's unfinished line onto the other's output.
309
+ if self.mid_line and stream != self.last_stream:
310
+ text = "\n" + text
311
+ self.query_one(Log).write(text)
312
+ self.last_stream = stream
313
+ self.mid_line = not text.endswith("\n")
304
314
 
305
315
  def action_leave(self) -> None:
306
316
  if self.viewing is not None:
@@ -0,0 +1,11 @@
1
+ [[python.exempt]]
2
+ path = "src/labnotes/runner/hold_folder.py"
3
+ rules = ["mutation"]
4
+ lines = [12]
5
+ reason = "LOCK_EX and LOCK_NB are disjoint bits, so | equals + and ^"
6
+
7
+ [[python.exempt]]
8
+ path = "src/labnotes/tui/run_app.py"
9
+ rules = ["mutation"]
10
+ lines = [309]
11
+ reason = "last_stream is always the same str object taken from STREAMS, so is not equals !="