labnotes 0.0.14__tar.gz → 0.0.16__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 (89) hide show
  1. {labnotes-0.0.14 → labnotes-0.0.16}/.gitignore +1 -0
  2. {labnotes-0.0.14 → labnotes-0.0.16}/PKG-INFO +38 -1
  3. {labnotes-0.0.14 → labnotes-0.0.16}/README.md +34 -0
  4. {labnotes-0.0.14 → labnotes-0.0.16}/pyproject.toml +8 -0
  5. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/context.md +42 -0
  6. labnotes-0.0.16/src/labnotes/experiments.py +76 -0
  7. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/models.py +13 -0
  8. labnotes-0.0.16/src/labnotes/runner/describe_event.py +17 -0
  9. labnotes-0.0.16/src/labnotes/runner/execute_run.py +49 -0
  10. labnotes-0.0.16/src/labnotes/runner/lock_path.py +7 -0
  11. labnotes-0.0.16/src/labnotes/runner/new_run_id.py +11 -0
  12. labnotes-0.0.16/src/labnotes/runner/param_args.py +16 -0
  13. labnotes-0.0.16/src/labnotes/runner/param_grid.py +6 -0
  14. labnotes-0.0.16/src/labnotes/runner/plan_runs.py +9 -0
  15. labnotes-0.0.16/src/labnotes/runner/read_experiment_config.py +35 -0
  16. labnotes-0.0.16/src/labnotes/runner/run_experiment.py +66 -0
  17. labnotes-0.0.16/src/labnotes/runner/run_folder.py +5 -0
  18. labnotes-0.0.16/src/labnotes/runner/run_headless.py +17 -0
  19. labnotes-0.0.16/src/labnotes/runner/shell_command.py +9 -0
  20. labnotes-0.0.16/src/labnotes/runner/start_experiment.py +32 -0
  21. labnotes-0.0.16/src/labnotes/runner/stop_then_kill.py +15 -0
  22. labnotes-0.0.16/src/labnotes/runner/write_run_json.py +8 -0
  23. labnotes-0.0.16/src/labnotes/tables/__init__.py +0 -0
  24. labnotes-0.0.16/src/labnotes/tui/__init__.py +0 -0
  25. labnotes-0.0.16/src/labnotes/tui/format_elapsed.py +8 -0
  26. labnotes-0.0.16/src/labnotes/tui/run_app.py +93 -0
  27. labnotes-0.0.16/src/labnotes/tui/run_tui.py +8 -0
  28. {labnotes-0.0.14 → labnotes-0.0.16}/uv.lock +67 -0
  29. labnotes-0.0.14/src/labnotes/experiments.py +0 -33
  30. {labnotes-0.0.14 → labnotes-0.0.16}/CHANGELOG.md +0 -0
  31. {labnotes-0.0.14 → labnotes-0.0.16}/MIGRATIONS.md +0 -0
  32. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/__init__.py +0 -0
  33. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/asset_url.py +0 -0
  34. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/cell_progress.py +0 -0
  35. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/cli.py +0 -0
  36. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/context_guide.py +0 -0
  37. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/count_runs.py +0 -0
  38. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/create_app.py +0 -0
  39. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/data_html.py +0 -0
  40. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/default_config_text.py +0 -0
  41. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/drain_watch.py +0 -0
  42. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/format_value.py +0 -0
  43. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/init.py +0 -0
  44. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/list_experiments.py +0 -0
  45. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/list_runs.py +0 -0
  46. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/load_config.py +0 -0
  47. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/load_experiment.py +0 -0
  48. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/notebook_kernel.py +0 -0
  49. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/open_experiments.py +0 -0
  50. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/open_runs.py +0 -0
  51. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/output_html.py +0 -0
  52. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/parse_run_view.py +0 -0
  53. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/pivot_cells.py +0 -0
  54. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/print_rows.py +0 -0
  55. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/python_blocks.py +0 -0
  56. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/read_config.py +0 -0
  57. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/read_design.py +0 -0
  58. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/read_note.py +0 -0
  59. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/read_path.py +0 -0
  60. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/render_markdown.py +0 -0
  61. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/run_columns.py +0 -0
  62. {labnotes-0.0.14/src/labnotes/tables → labnotes-0.0.16/src/labnotes/runner}/__init__.py +0 -0
  63. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/saved_outputs.py +0 -0
  64. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/serve_app.py +0 -0
  65. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/server.py +0 -0
  66. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/sort_href.py +0 -0
  67. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/sql_quote.py +0 -0
  68. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/static/labnotes.css +0 -0
  69. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/static/row-link.js +0 -0
  70. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/static/run-cell.js +0 -0
  71. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/static/view-toggle.js +0 -0
  72. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/stop_idle_kernels.py +0 -0
  73. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/study_progress.py +0 -0
  74. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/summarize_design.py +0 -0
  75. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/experiment_rows.py +0 -0
  76. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/file_prefix.py +0 -0
  77. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/iso_time.py +0 -0
  78. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/list_experiment_runs.py +0 -0
  79. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/open_tables.py +0 -0
  80. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/path_glob.py +0 -0
  81. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/path_pattern.py +0 -0
  82. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/read_experiment.py +0 -0
  83. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/read_file.py +0 -0
  84. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/read_run.py +0 -0
  85. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/tables/summarize_experiments.py +0 -0
  86. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/templates/base.html +0 -0
  87. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/templates/experiment.html +0 -0
  88. {labnotes-0.0.14 → labnotes-0.0.16}/src/labnotes/templates/index.html +0 -0
  89. {labnotes-0.0.14 → labnotes-0.0.16}/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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.14
3
+ Version: 0.0.16
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>
@@ -8,6 +8,8 @@ License-Expression: MIT
8
8
  Requires-Python: >=3.12
9
9
  Requires-Dist: click>=8.5.0
10
10
  Requires-Dist: dirsql>=0.4.110
11
+ Requires-Dist: filelock>=4.2.0
12
+ Requires-Dist: haikunator>=2.1.0
11
13
  Requires-Dist: ipykernel>=7.4.0
12
14
  Requires-Dist: jinja2>=3.1.6
13
15
  Requires-Dist: jupyter-client>=8.10.0
@@ -15,6 +17,7 @@ Requires-Dist: markdown-it-py>=4.2.0
15
17
  Requires-Dist: pyyaml>=6.0.3
16
18
  Requires-Dist: rich>=14
17
19
  Requires-Dist: starlette>=1.8.0
20
+ Requires-Dist: textual>=8.2.8
18
21
  Requires-Dist: uvicorn>=0.54.0
19
22
  Description-Content-Type: text/markdown
20
23
 
@@ -66,6 +69,40 @@ labnotes experiments foo runs # foo's runs, oldest first
66
69
 
67
70
  Each takes `--format table` (the default) or `--format json`.
68
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. Closing the terminal or sending SIGTERM stops them the same way,
102
+ then kills any still going 5 seconds later.
103
+ - A second `run` of the same experiment fails while the first holds the lock.
104
+ - It exits 1 if any run failed.
105
+
69
106
  `labnotes server` doesn't read `.labnotes.toml` yet. Give it the two folders
70
107
  it uses with `--experiments-folder` and `--data-folder`:
71
108
 
@@ -46,6 +46,40 @@ labnotes experiments foo runs # foo's runs, oldest first
46
46
 
47
47
  Each takes `--format table` (the default) or `--format json`.
48
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. Closing the terminal or sending SIGTERM stops them the same way,
79
+ then kills any still going 5 seconds later.
80
+ - A second `run` of the same experiment fails while the first holds the lock.
81
+ - It exits 1 if any run failed.
82
+
49
83
  `labnotes server` doesn't read `.labnotes.toml` yet. Give it the two folders
50
84
  it uses with `--experiments-folder` and `--data-folder`:
51
85
 
@@ -13,6 +13,8 @@ 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",
@@ -20,6 +22,7 @@ dependencies = [
20
22
  "pyyaml>=6.0.3",
21
23
  "rich>=14",
22
24
  "starlette>=1.8.0",
25
+ "textual>=8.2.8",
23
26
  "uvicorn>=0.54.0",
24
27
  ]
25
28
 
@@ -62,3 +65,8 @@ select = ["E4", "E7", "E9", "F", "TID252"]
62
65
 
63
66
  [tool.ruff.lint.flake8-tidy-imports]
64
67
  ban-relative-imports = "all"
68
+
69
+ # haikunator ships no type hints.
70
+ [[tool.mypy.overrides]]
71
+ module = ["haikunator"]
72
+ ignore_missing_imports = true
@@ -36,6 +36,48 @@ labnotes experiments foo runs # foo's runs, oldest first
36
36
 
37
37
  Each takes `--format table` (the default) or `--format json`; use `--format json` when you parse the output.
38
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
+ A script run by labnotes should:
62
+
63
+ - take each param as a `--name value` flag, parsing lists and tables from JSON;
64
+ - take its output folder as an argument, passed in `command` as `{run_dir}`, and write everything it produces there;
65
+ - exit non-zero when it fails, so the run counts as failed and runs again next time.
66
+
67
+ Runs can take a long time. Start `run` in the background, then check on it with `labnotes experiments foo runs`; a run with no `ended` is still going.
68
+
69
+ In a terminal, `run` shows a live table of runs (status and time) and stays
70
+ open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
71
+ line per event instead.
72
+
73
+ - `--concurrency N` runs N at a time (default 1).
74
+ - `-e PATH` reads another file instead of `experiment.toml`.
75
+ - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
76
+ kills them. Closing the terminal or sending SIGTERM stops them the same way,
77
+ then kills any still going 5 seconds later.
78
+ - A second `run` of the same experiment fails while the first holds the lock.
79
+ - It exits 1 if any run failed.
80
+
39
81
  `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.
40
82
 
41
83
  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:
@@ -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)
@@ -1,5 +1,11 @@
1
+ from collections.abc import Callable, Coroutine
1
2
  from dataclasses import dataclass, field
2
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]]]]
3
9
 
4
10
 
5
11
  @dataclass(frozen=True)
@@ -17,3 +23,10 @@ class LabnotesConfig:
17
23
  experiments: str
18
24
  files: str
19
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,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."
@@ -0,0 +1,49 @@
1
+ import asyncio
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from labnotes.runner.shell_command import shell_command
6
+ from labnotes.runner.write_run_json import write_run_json
7
+ from labnotes.tables.iso_time import iso_time
8
+
9
+
10
+ async def execute_run(
11
+ root: Path,
12
+ folder: Path,
13
+ command: str,
14
+ params: dict[str, object],
15
+ running: set[asyncio.subprocess.Process],
16
+ ) -> dict[str, object]:
17
+ run: dict[str, object] = {
18
+ "params": params,
19
+ "created": iso_time(time.time()),
20
+ "ended": None,
21
+ "exit_code": None,
22
+ "error": None,
23
+ }
24
+ write_run_json(folder, run)
25
+ with (
26
+ (folder / "stdout.log").open("wb") as stdout,
27
+ (folder / "stderr.log").open("wb") as stderr,
28
+ ):
29
+ try:
30
+ process = await asyncio.create_subprocess_shell(
31
+ shell_command(command, folder, params),
32
+ cwd=root,
33
+ stdout=stdout,
34
+ stderr=stderr,
35
+ # Own process group, so Ctrl-C reaches labnotes first and the
36
+ # runner decides how each run stops.
37
+ start_new_session=True,
38
+ )
39
+ except OSError as error:
40
+ run["error"] = str(error)
41
+ else:
42
+ running.add(process)
43
+ try:
44
+ run["exit_code"] = await process.wait()
45
+ finally:
46
+ running.discard(process)
47
+ run["ended"] = iso_time(time.time())
48
+ write_run_json(folder, run)
49
+ return run
@@ -0,0 +1,7 @@
1
+ import posixpath
2
+ from pathlib import Path
3
+
4
+
5
+ def lock_path(root: Path, template: str, experiment: str) -> Path:
6
+ head = template.replace("{experiment}", experiment).split("{run}")[0]
7
+ return root / posixpath.dirname(head) / ".labnotes.lock"
@@ -0,0 +1,11 @@
1
+ from collections.abc import Callable
2
+
3
+ from haikunator import Haikunator
4
+
5
+
6
+ def new_run_id(taken: Callable[[str], bool]) -> str:
7
+ name = Haikunator().haikunate(token_length=0)
8
+ candidate, suffix = name, 2
9
+ while taken(candidate):
10
+ candidate, suffix = f"{name}-{suffix}", suffix + 1
11
+ return candidate
@@ -0,0 +1,16 @@
1
+ import json
2
+
3
+
4
+ def param_args(params: dict[str, object]) -> list[str]:
5
+ args: list[str] = []
6
+ for name, value in params.items():
7
+ if isinstance(value, bool):
8
+ text = "1" if value else "0"
9
+ elif isinstance(value, str):
10
+ text = value
11
+ elif isinstance(value, list | dict):
12
+ text = json.dumps(value)
13
+ else:
14
+ text = str(value)
15
+ args += [f"--{name}", text]
16
+ return args
@@ -0,0 +1,6 @@
1
+ from itertools import product
2
+
3
+
4
+ def param_grid(params: dict[str, list[object]]) -> list[dict[str, object]]:
5
+ choices = ([(name, value) for value in values] for name, values in params.items())
6
+ return [dict(cell) for cell in product(*choices)]
@@ -0,0 +1,9 @@
1
+ def plan_runs(
2
+ cells: list[dict[str, object]], repeats: int, finished: list[dict[str, object]]
3
+ ) -> list[dict[str, object]]:
4
+ return [
5
+ cell
6
+ for round_ in range(repeats)
7
+ for cell in cells
8
+ if round_ < repeats - finished.count(cell)
9
+ ]
@@ -0,0 +1,35 @@
1
+ import json
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ from labnotes.models import ExperimentConfig
6
+
7
+ SETTINGS = {"command", "repeats", "params"}
8
+
9
+
10
+ def read_experiment_config(path: Path) -> ExperimentConfig:
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() - SETTINGS)
17
+ if unknown:
18
+ raise ValueError(f"{path}: unknown keys {', '.join(unknown)}")
19
+ command = values.get("command")
20
+ if not isinstance(command, str) or not command.strip():
21
+ raise ValueError(f"{path}: command must be a string")
22
+ repeats = values.get("repeats", 1)
23
+ if isinstance(repeats, bool) or not isinstance(repeats, int) or repeats < 1:
24
+ raise ValueError(f"{path}: repeats must be a positive integer")
25
+ params = values.get("params", {})
26
+ if not isinstance(params, dict):
27
+ raise ValueError(f"{path}: params must be a table, like [params]")
28
+ for name, options in params.items():
29
+ if not isinstance(options, list) or not options:
30
+ raise ValueError(f"{path}: params.{name} must be a non-empty list")
31
+ try:
32
+ json.dumps(params)
33
+ except TypeError as error:
34
+ raise ValueError(f"{path}: params can't hold dates or times") from error
35
+ return ExperimentConfig(command, repeats, params)
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import os
5
+ import signal
6
+
7
+ from labnotes.models import ExperimentConfig, LabnotesConfig, Listen, Report
8
+ from labnotes.runner.execute_run import execute_run
9
+ from labnotes.runner.new_run_id import new_run_id
10
+ from labnotes.runner.param_grid import param_grid
11
+ from labnotes.runner.plan_runs import plan_runs
12
+ from labnotes.runner.run_folder import run_folder
13
+ from labnotes.tables.list_experiment_runs import list_experiment_runs
14
+ from labnotes.tables.open_tables import open_tables
15
+
16
+
17
+ async def run_experiment(
18
+ config: LabnotesConfig,
19
+ name: str,
20
+ experiment: ExperimentConfig,
21
+ concurrency: int,
22
+ report: Report,
23
+ listen: Listen,
24
+ ) -> list[dict[str, object]]:
25
+ runs = await list_experiment_runs(open_tables(config), name)
26
+ finished = [json.loads(str(run["params"])) for run in runs if run["exit_code"] == 0]
27
+ plan = plan_runs(param_grid(experiment.params), experiment.repeats, finished)
28
+ report({"event": "planned", "runs": len(plan), "concurrency": concurrency})
29
+ running: set[asyncio.subprocess.Process] = set()
30
+ results: list[dict[str, object]] = []
31
+ stopping = False
32
+
33
+ def signal_runs(signal_number: int) -> None:
34
+ for process in running:
35
+ with contextlib.suppress(ProcessLookupError):
36
+ os.killpg(process.pid, signal_number)
37
+
38
+ def interrupt() -> None:
39
+ nonlocal stopping
40
+ plan.clear()
41
+ if not stopping:
42
+ stopping = True
43
+ report({"event": "stopping"})
44
+ signal_runs(signal.SIGTERM)
45
+ elif running:
46
+ report({"event": "killing"})
47
+ signal_runs(signal.SIGKILL)
48
+
49
+ async def work() -> None:
50
+ while plan:
51
+ params = plan.pop(0)
52
+ run = new_run_id(
53
+ lambda id_: run_folder(config.root, config.runs, name, id_).exists()
54
+ )
55
+ folder = run_folder(config.root, config.runs, name, run)
56
+ folder.mkdir(parents=True)
57
+ report({"event": "started", "run": run, "params": params})
58
+ result = await execute_run(
59
+ config.root, folder, experiment.command, params, running
60
+ )
61
+ report({"event": "ended", "run": run, **result})
62
+ results.append(result)
63
+
64
+ listen(interrupt)
65
+ await asyncio.gather(*(work() for _ in range(concurrency)))
66
+ return results
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def run_folder(root: Path, template: str, experiment: str, run: str) -> Path:
5
+ return root / template.replace("{experiment}", experiment).replace("{run}", run)
@@ -0,0 +1,17 @@
1
+ import asyncio
2
+ import signal
3
+ from collections.abc import Callable
4
+
5
+ import click
6
+
7
+ from labnotes.models import Start
8
+ from labnotes.runner.describe_event import describe_event
9
+ from labnotes.runner.stop_then_kill import stop_then_kill
10
+
11
+
12
+ def run_headless(start: Start) -> list[dict[str, object]]:
13
+ def listen(interrupt: Callable[[], None]) -> None:
14
+ asyncio.get_running_loop().add_signal_handler(signal.SIGINT, interrupt)
15
+ stop_then_kill(interrupt, signal.SIGTERM, signal.SIGHUP)
16
+
17
+ return asyncio.run(start(lambda event: click.echo(describe_event(event)), listen))
@@ -0,0 +1,9 @@
1
+ import shlex
2
+ from pathlib import Path
3
+
4
+ from labnotes.runner.param_args import param_args
5
+
6
+
7
+ def shell_command(command: str, folder: Path, params: dict[str, object]) -> str:
8
+ filled = command.replace("{run_dir}", shlex.quote(str(folder)))
9
+ return f"{filled} {shlex.join(param_args(params))}".rstrip()
@@ -0,0 +1,32 @@
1
+ from collections.abc import Callable
2
+ from functools import partial
3
+ from pathlib import Path
4
+
5
+ from filelock import FileLock, Timeout
6
+
7
+ from labnotes.models import LabnotesConfig, Start
8
+ from labnotes.runner.lock_path import lock_path
9
+ from labnotes.runner.read_experiment_config import read_experiment_config
10
+ from labnotes.runner.run_experiment import run_experiment
11
+
12
+
13
+ def start_experiment(
14
+ config: LabnotesConfig,
15
+ name: str,
16
+ experiment_config: Path | None,
17
+ concurrency: int,
18
+ drive: Callable[[Start], list[dict[str, object]]],
19
+ ) -> list[dict[str, object]]:
20
+ if experiment_config is None:
21
+ folder = config.experiments.replace("{experiment}", name)
22
+ experiment_config = config.root / folder / "experiment.toml"
23
+ if not experiment_config.is_file():
24
+ raise ValueError(f"No experiment.toml for {name} at {experiment_config}.")
25
+ experiment = read_experiment_config(experiment_config)
26
+ lock = lock_path(config.root, config.runs, name)
27
+ lock.parent.mkdir(parents=True, exist_ok=True)
28
+ try:
29
+ with FileLock(lock, blocking=False):
30
+ return drive(partial(run_experiment, config, name, experiment, concurrency))
31
+ except Timeout as error:
32
+ raise ValueError(f"{name} is already running ({lock} is held).") from error
@@ -0,0 +1,15 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+
4
+ GRACE_SECONDS = 5
5
+
6
+
7
+ def stop_then_kill(interrupt: Callable[[], None], *signals: int) -> None:
8
+ loop = asyncio.get_running_loop()
9
+
10
+ def stop() -> None:
11
+ interrupt()
12
+ loop.call_later(GRACE_SECONDS, interrupt)
13
+
14
+ for signal_number in signals:
15
+ loop.add_signal_handler(signal_number, stop)
@@ -0,0 +1,8 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def write_run_json(folder: Path, run: dict[str, object]) -> None:
6
+ partial = folder / "run.json.tmp"
7
+ partial.write_text(json.dumps(run, indent=2) + "\n", encoding="utf-8")
8
+ partial.replace(folder / "run.json")
File without changes
File without changes
@@ -0,0 +1,8 @@
1
+ def format_elapsed(seconds: float) -> str:
2
+ minutes, rest = divmod(int(seconds), 60)
3
+ hours, minutes = divmod(minutes, 60)
4
+ if hours:
5
+ return f"{hours}h {minutes:02}m"
6
+ if minutes:
7
+ return f"{minutes}m {rest:02}s"
8
+ return f"{rest}s"
@@ -0,0 +1,93 @@
1
+ import signal
2
+ import time
3
+ from collections.abc import Callable
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.widgets import DataTable, Footer, Header, Static
8
+
9
+ from labnotes.models import Start
10
+ from labnotes.runner.describe_event import describe_event
11
+ from labnotes.runner.stop_then_kill import stop_then_kill
12
+ from labnotes.runner.param_args import param_args
13
+ from labnotes.tui.format_elapsed import format_elapsed
14
+
15
+
16
+ class RunApp(App[None]):
17
+ BINDINGS = [
18
+ Binding("ctrl+c", "interrupt", "Stop runs"),
19
+ Binding("q", "quit", "Quit"),
20
+ ]
21
+
22
+ def __init__(self, start: Start, title: str) -> None:
23
+ super().__init__()
24
+ self.start = start
25
+ self.title = title
26
+ self.results: list[dict[str, object]] = []
27
+ self.interrupt: Callable[[], None] = lambda: None
28
+ self.done = False
29
+ self.started: dict[str, float] = {}
30
+
31
+ def compose(self) -> ComposeResult:
32
+ yield Header()
33
+ yield DataTable(id="runs", cursor_type="none")
34
+ yield Static(id="status")
35
+ yield Footer()
36
+
37
+ def on_mount(self) -> None:
38
+ table = self.query_one(DataTable)
39
+ for column in ("run", "params", "status", "time"):
40
+ table.add_column(column, key=column)
41
+
42
+ def on_ready(self) -> None:
43
+ self.set_interval(1, self.tick)
44
+ self.run_worker(self.drive())
45
+
46
+ async def drive(self) -> None:
47
+ self.results = await self.start(self.show_event, self.listen)
48
+ self.done = True
49
+ failed = sum(result["exit_code"] != 0 for result in self.results)
50
+ passed = len(self.results) - failed
51
+ self.show(f"{passed} passed, {failed} failed. q to quit.")
52
+
53
+ def listen(self, interrupt: Callable[[], None]) -> None:
54
+ self.interrupt = interrupt
55
+ stop_then_kill(interrupt, signal.SIGTERM, signal.SIGHUP)
56
+
57
+ def show_event(self, event: dict[str, object]) -> None:
58
+ table = self.query_one(DataTable)
59
+ run = str(event.get("run"))
60
+ match event["event"]:
61
+ case "started":
62
+ self.started[run] = time.monotonic()
63
+ args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
64
+ table.add_row(run, args, "running", "0s", key=run)
65
+ case "ended":
66
+ outcome = event["error"] or f"exited {event['exit_code']}"
67
+ table.update_cell(run, "status", outcome, update_width=True)
68
+ self.tick()
69
+ del self.started[run]
70
+ case _:
71
+ self.show(describe_event(event))
72
+
73
+ def tick(self) -> None:
74
+ table = self.query_one(DataTable)
75
+ for run, began in self.started.items():
76
+ table.update_cell(
77
+ run, "time", format_elapsed(time.monotonic() - began), update_width=True
78
+ )
79
+
80
+ def show(self, text: str) -> None:
81
+ self.query_one("#status", Static).update(text)
82
+
83
+ def action_interrupt(self) -> None:
84
+ if self.done:
85
+ self.exit()
86
+ else:
87
+ self.interrupt()
88
+
89
+ async def action_quit(self) -> None:
90
+ if self.done:
91
+ self.exit()
92
+ else:
93
+ self.show("Runs are still going. Ctrl-C stops them.")
@@ -0,0 +1,8 @@
1
+ from labnotes.models import Start
2
+ from labnotes.tui.run_app import RunApp
3
+
4
+
5
+ def run_tui(start: Start, title: str) -> list[dict[str, object]]:
6
+ app = RunApp(start, title)
7
+ app.run()
8
+ return app.results
@@ -273,6 +273,15 @@ wheels = [
273
273
  { url = "https://files.pythonhosted.org/packages/4d/dd/8bc67e7d5ffc1d88aa5af511189dfb76dc4e1e5b808fab186146ef2663e5/executing-2.3.0-py3-none-any.whl", hash = "sha256:736e859c9f8701f11fcf516856f26f562e04776387824b43a35a1dfe21c84122", size = 29393, upload-time = "2026-10-10T14:06:02.777Z" },
274
274
  ]
275
275
 
276
+ [[package]]
277
+ name = "filelock"
278
+ version = "4.2.0"
279
+ source = { registry = "https://pypi.org/simple" }
280
+ sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" }
281
+ wheels = [
282
+ { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" },
283
+ ]
284
+
276
285
  [[package]]
277
286
  name = "h11"
278
287
  version = "0.16.0"
@@ -282,6 +291,15 @@ wheels = [
282
291
  { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
283
292
  ]
284
293
 
294
+ [[package]]
295
+ name = "haikunator"
296
+ version = "2.1.0"
297
+ source = { registry = "https://pypi.org/simple" }
298
+ sdist = { url = "https://files.pythonhosted.org/packages/af/58/6a000ee0ec34cac5c78669359a8b1db969f1f511454a140ad3d193714ba2/haikunator-2.1.0.zip", hash = "sha256:91ee3949a3a613cac037ddde0b16b17062e248376b11491436e49d5ddc75ff9b", size = 4933, upload-time = "2016-09-20T17:36:00.843Z" }
299
+ wheels = [
300
+ { url = "https://files.pythonhosted.org/packages/43/fa/130968f1a1bb1461c287b9ff35c630460801783243acda2cbf3a4c5964a5/haikunator-2.1.0-py2.py3-none-any.whl", hash = "sha256:66f68b15345b279f78a5fffd4ab56cfb19a9dbb1f41b7f442472efd4cb83458e", size = 4595, upload-time = "2016-09-20T17:35:58.142Z" },
301
+ ]
302
+
285
303
  [[package]]
286
304
  name = "httpcore2"
287
305
  version = "2.13.1"
@@ -455,6 +473,8 @@ source = { editable = "." }
455
473
  dependencies = [
456
474
  { name = "click" },
457
475
  { name = "dirsql" },
476
+ { name = "filelock" },
477
+ { name = "haikunator" },
458
478
  { name = "ipykernel" },
459
479
  { name = "jinja2" },
460
480
  { name = "jupyter-client" },
@@ -462,6 +482,7 @@ dependencies = [
462
482
  { name = "pyyaml" },
463
483
  { name = "rich" },
464
484
  { name = "starlette" },
485
+ { name = "textual" },
465
486
  { name = "uvicorn" },
466
487
  ]
467
488
 
@@ -478,6 +499,8 @@ dev = [
478
499
  requires-dist = [
479
500
  { name = "click", specifier = ">=8.5.0" },
480
501
  { name = "dirsql", specifier = ">=0.4.110" },
502
+ { name = "filelock", specifier = ">=4.2.0" },
503
+ { name = "haikunator", specifier = ">=2.1.0" },
481
504
  { name = "ipykernel", specifier = ">=7.4.0" },
482
505
  { name = "jinja2", specifier = ">=3.1.6" },
483
506
  { name = "jupyter-client", specifier = ">=8.10.0" },
@@ -485,6 +508,7 @@ requires-dist = [
485
508
  { name = "pyyaml", specifier = ">=6.0.3" },
486
509
  { name = "rich", specifier = ">=14" },
487
510
  { name = "starlette", specifier = ">=1.8.0" },
511
+ { name = "textual", specifier = ">=8.2.8" },
488
512
  { name = "uvicorn", specifier = ">=0.54.0" },
489
513
  ]
490
514
 
@@ -598,6 +622,15 @@ wheels = [
598
622
  { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" },
599
623
  ]
600
624
 
625
+ [[package]]
626
+ name = "linkify-it-py"
627
+ version = "2.2.0"
628
+ source = { registry = "https://pypi.org/simple" }
629
+ sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
630
+ wheels = [
631
+ { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
632
+ ]
633
+
601
634
  [[package]]
602
635
  name = "markdown-it-py"
603
636
  version = "4.2.0"
@@ -610,6 +643,11 @@ wheels = [
610
643
  { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
611
644
  ]
612
645
 
646
+ [package.optional-dependencies]
647
+ linkify = [
648
+ { name = "linkify-it-py" },
649
+ ]
650
+
613
651
  [[package]]
614
652
  name = "markupsafe"
615
653
  version = "3.0.4"
@@ -735,6 +773,18 @@ wheels = [
735
773
  { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
736
774
  ]
737
775
 
776
+ [[package]]
777
+ name = "mdit-py-plugins"
778
+ version = "0.6.1"
779
+ source = { registry = "https://pypi.org/simple" }
780
+ dependencies = [
781
+ { name = "markdown-it-py" },
782
+ ]
783
+ sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
784
+ wheels = [
785
+ { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
786
+ ]
787
+
738
788
  [[package]]
739
789
  name = "mdurl"
740
790
  version = "0.1.2"
@@ -1145,6 +1195,23 @@ wheels = [
1145
1195
  { url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
1146
1196
  ]
1147
1197
 
1198
+ [[package]]
1199
+ name = "textual"
1200
+ version = "8.2.8"
1201
+ source = { registry = "https://pypi.org/simple" }
1202
+ dependencies = [
1203
+ { name = "markdown-it-py", extra = ["linkify"] },
1204
+ { name = "mdit-py-plugins" },
1205
+ { name = "platformdirs" },
1206
+ { name = "pygments" },
1207
+ { name = "rich" },
1208
+ { name = "typing-extensions" },
1209
+ ]
1210
+ sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
1211
+ wheels = [
1212
+ { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
1213
+ ]
1214
+
1148
1215
  [[package]]
1149
1216
  name = "tornado"
1150
1217
  version = "6.5.10"
@@ -1,33 +0,0 @@
1
- import asyncio
2
-
3
- import click
4
-
5
- from labnotes.load_config import load_config
6
- from labnotes.print_rows import print_rows
7
- from labnotes.read_config import CONFIG_FILE
8
- from labnotes.tables.experiment_rows import experiment_rows
9
-
10
-
11
- @click.command(help="List experiments, show one, or list its runs.")
12
- @click.argument("name", required=False)
13
- @click.argument("action", required=False, type=click.Choice(["runs"]))
14
- @click.option(
15
- "--format",
16
- "output_format",
17
- type=click.Choice(["table", "json"]),
18
- default="table",
19
- show_default=True,
20
- )
21
- @click.pass_context
22
- def experiments(
23
- ctx: click.Context, name: str | None, action: str | None, output_format: str
24
- ) -> None:
25
- config = load_config(ctx)
26
- if config is None:
27
- raise click.ClickException(
28
- f"No {CONFIG_FILE} here. Run labnotes init, or pass -c PATH."
29
- )
30
- rows = asyncio.run(experiment_rows(config, name, action))
31
- if rows is None:
32
- raise click.ClickException(f"No experiment named {name}.")
33
- print_rows(rows, output_format)
File without changes
File without changes
File without changes