labnotes 0.0.13__tar.gz → 0.0.14__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 (71) hide show
  1. {labnotes-0.0.13 → labnotes-0.0.14}/PKG-INFO +44 -12
  2. labnotes-0.0.14/README.md +80 -0
  3. {labnotes-0.0.13 → labnotes-0.0.14}/pyproject.toml +2 -1
  4. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/cli.py +4 -0
  5. labnotes-0.0.14/src/labnotes/context.md +47 -0
  6. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/context_guide.py +4 -2
  7. labnotes-0.0.14/src/labnotes/default_config_text.py +7 -0
  8. labnotes-0.0.14/src/labnotes/experiments.py +33 -0
  9. labnotes-0.0.14/src/labnotes/init.py +17 -0
  10. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/models.py +4 -2
  11. labnotes-0.0.14/src/labnotes/print_rows.py +19 -0
  12. labnotes-0.0.14/src/labnotes/read_config.py +27 -0
  13. labnotes-0.0.14/src/labnotes/read_path.py +34 -0
  14. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/server.py +4 -12
  15. labnotes-0.0.14/src/labnotes/tables/__init__.py +0 -0
  16. labnotes-0.0.14/src/labnotes/tables/experiment_rows.py +17 -0
  17. labnotes-0.0.14/src/labnotes/tables/file_prefix.py +9 -0
  18. labnotes-0.0.14/src/labnotes/tables/iso_time.py +5 -0
  19. labnotes-0.0.14/src/labnotes/tables/list_experiment_runs.py +10 -0
  20. labnotes-0.0.14/src/labnotes/tables/open_tables.py +63 -0
  21. labnotes-0.0.14/src/labnotes/tables/path_glob.py +8 -0
  22. labnotes-0.0.14/src/labnotes/tables/path_pattern.py +23 -0
  23. labnotes-0.0.14/src/labnotes/tables/read_experiment.py +15 -0
  24. labnotes-0.0.14/src/labnotes/tables/read_file.py +23 -0
  25. labnotes-0.0.14/src/labnotes/tables/read_run.py +27 -0
  26. labnotes-0.0.14/src/labnotes/tables/summarize_experiments.py +27 -0
  27. {labnotes-0.0.13 → labnotes-0.0.14}/uv.lock +15 -0
  28. labnotes-0.0.13/README.md +0 -49
  29. labnotes-0.0.13/src/labnotes/context.md +0 -18
  30. labnotes-0.0.13/src/labnotes/read_config.py +0 -23
  31. {labnotes-0.0.13 → labnotes-0.0.14}/.gitignore +0 -0
  32. {labnotes-0.0.13 → labnotes-0.0.14}/CHANGELOG.md +0 -0
  33. {labnotes-0.0.13 → labnotes-0.0.14}/MIGRATIONS.md +0 -0
  34. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/__init__.py +0 -0
  35. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/asset_url.py +0 -0
  36. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/cell_progress.py +0 -0
  37. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/count_runs.py +0 -0
  38. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/create_app.py +0 -0
  39. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/data_html.py +0 -0
  40. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/drain_watch.py +0 -0
  41. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/format_value.py +0 -0
  42. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/list_experiments.py +0 -0
  43. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/list_runs.py +0 -0
  44. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/load_config.py +0 -0
  45. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/load_experiment.py +0 -0
  46. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/notebook_kernel.py +0 -0
  47. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/open_experiments.py +0 -0
  48. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/open_runs.py +0 -0
  49. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/output_html.py +0 -0
  50. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/parse_run_view.py +0 -0
  51. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/pivot_cells.py +0 -0
  52. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/python_blocks.py +0 -0
  53. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/read_design.py +0 -0
  54. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/read_note.py +0 -0
  55. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/render_markdown.py +0 -0
  56. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/run_columns.py +0 -0
  57. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/saved_outputs.py +0 -0
  58. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/serve_app.py +0 -0
  59. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/sort_href.py +0 -0
  60. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/sql_quote.py +0 -0
  61. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/static/labnotes.css +0 -0
  62. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/static/row-link.js +0 -0
  63. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/static/run-cell.js +0 -0
  64. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/static/view-toggle.js +0 -0
  65. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/stop_idle_kernels.py +0 -0
  66. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/study_progress.py +0 -0
  67. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/summarize_design.py +0 -0
  68. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/templates/base.html +0 -0
  69. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/templates/experiment.html +0 -0
  70. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/templates/index.html +0 -0
  71. {labnotes-0.0.13 → labnotes-0.0.14}/src/labnotes/templates/macros.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.13
3
+ Version: 0.0.14
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>
@@ -13,6 +13,7 @@ Requires-Dist: jinja2>=3.1.6
13
13
  Requires-Dist: jupyter-client>=8.10.0
14
14
  Requires-Dist: markdown-it-py>=4.2.0
15
15
  Requires-Dist: pyyaml>=6.0.3
16
+ Requires-Dist: rich>=14
16
17
  Requires-Dist: starlette>=1.8.0
17
18
  Requires-Dist: uvicorn>=0.54.0
18
19
  Description-Content-Type: text/markdown
@@ -25,20 +26,52 @@ Browse experiment designs, notes, and run data from a folder.
25
26
  uvx labnotes server
26
27
  ```
27
28
 
28
- labnotes reads two folders, named in a `.labnotes.toml` at the project root:
29
+ A project's root is the folder holding its `.labnotes.toml`. `labnotes init`
30
+ writes one with the default layout:
29
31
 
30
32
  ```toml
31
- experiments = "experiments"
32
- data = "data"
33
+ [experiments]
34
+ path = "{experiment}"
35
+
36
+ [files]
37
+ path = "{experiment}/files/"
38
+
39
+ [runs]
40
+ path = "{experiment}/runs/{run}"
41
+ ```
42
+
43
+ Each path is relative to the root. `{experiment}` and `{run}` stand for folder
44
+ names: the experiments path must use `{experiment}`, and the runs path both.
45
+ labnotes reads the file from the working folder, or from the path given with
46
+ `-c`.
47
+
48
+ `labnotes experiments` reads three tables from those paths:
49
+
50
+ - `experiments`: `name`, and `config`, the experiment's `experiment.toml` as
51
+ JSON. An experiment is a folder matching the experiments path that holds an
52
+ `experiment.toml`, files or runs.
53
+ - `files`: `experiment`, `filename`, `path`, `ctime`, `mtime`.
54
+ - `runs`: `experiment`, `id`, `folder`, `created`, `ended`, `exit_code`,
55
+ `error`, `stdout`, `stderr`, and `params` as JSON, read from each run
56
+ folder's `run.json`.
57
+
58
+ Keys come from folder names, so renaming a folder renames its rows on the next
59
+ read. Paths are relative to the root, and times are UTC ISO 8601.
60
+
61
+ ```sh
62
+ labnotes experiments # name, last_run, created, files, runs
63
+ labnotes experiments foo # the same, plus config
64
+ labnotes experiments foo runs # foo's runs, oldest first
33
65
  ```
34
66
 
35
- Both keys are optional and default to the values above. Paths are relative to
36
- `.labnotes.toml`, or absolute, or start with `~`. labnotes reads the file from
37
- the working folder, or from the path given with `-c`.
67
+ Each takes `--format table` (the default) or `--format json`.
68
+
69
+ `labnotes server` doesn't read `.labnotes.toml` yet. Give it the two folders
70
+ it uses with `--experiments-folder` and `--data-folder`:
38
71
 
39
- - `experiments` holds one subfolder per experiment, each with a
72
+ - `--experiments-folder` holds one subfolder per experiment, each with a
40
73
  `design.yaml` (with a `study` key) and any number of markdown notes.
41
- - `data` holds the run data and a `.dirsql.toml` that declares a
74
+ - `--data-folder` holds the run data and a `.dirsql.toml` that declares a
42
75
  `runs` table with a `study` column. An experiment's runs are the rows whose
43
76
  `study` matches its design's.
44
77
 
@@ -54,9 +87,8 @@ of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
54
87
  `-h`/`--host` and `-p`/`--port` change where it listens; `-h 0.0.0.0` (or a
55
88
  Tailscale IP) serves beyond localhost. Every other uvicorn option passes
56
89
  through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
57
- `--ssl-*` set, and the rest. `labnotes server --help` lists them. `--experiments-folder`
58
- and `--data-folder`, or `LABNOTES_EXPERIMENTS_FOLDER` and
59
- `LABNOTES_DATA_FOLDER`, override `.labnotes.toml`.
90
+ `--ssl-*` set, and the rest. `labnotes server --help` lists them. `LABNOTES_EXPERIMENTS_FOLDER` and
91
+ `LABNOTES_DATA_FOLDER` can stand in for the folder flags.
60
92
 
61
93
  ## Agent skill
62
94
 
@@ -0,0 +1,80 @@
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 server` doesn't read `.labnotes.toml` yet. Give it the two folders
50
+ it uses with `--experiments-folder` and `--data-folder`:
51
+
52
+ - `--experiments-folder` holds one subfolder per experiment, each with a
53
+ `design.yaml` (with a `study` key) and any number of markdown notes.
54
+ - `--data-folder` holds the run data and a `.dirsql.toml` that declares a
55
+ `runs` table with a `study` column. An experiment's runs are the rows whose
56
+ `study` matches its design's.
57
+
58
+ That serves a local site at http://127.0.0.1:8000: a table of experiments,
59
+ and a page per experiment with its parameters, its notes, progress per cell of
60
+ the design grid, and every run so far. Both folders are watched, so a reload
61
+ shows new runs and edited notes.
62
+
63
+ `design.yaml` keys the site reads: `study`, `fixed`, `varies` (each key a list
64
+ of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
65
+ `runs` table that marks a run as complete. Anything else shows up as-is.
66
+
67
+ `-h`/`--host` and `-p`/`--port` change where it listens; `-h 0.0.0.0` (or a
68
+ Tailscale IP) serves beyond localhost. Every other uvicorn option passes
69
+ through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
70
+ `--ssl-*` set, and the rest. `labnotes server --help` lists them. `LABNOTES_EXPERIMENTS_FOLDER` and
71
+ `LABNOTES_DATA_FOLDER` can stand in for the folder flags.
72
+
73
+ ## Agent skill
74
+
75
+ ```sh
76
+ npx skills add thekevinscott/labnotes
77
+ ```
78
+
79
+ The skill tells a coding agent to run `labnotes context`, which prints a usage
80
+ guide for the installed version.
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "jupyter-client>=8.10.0",
19
19
  "markdown-it-py>=4.2.0",
20
20
  "pyyaml>=6.0.3",
21
+ "rich>=14",
21
22
  "starlette>=1.8.0",
22
23
  "uvicorn>=0.54.0",
23
24
  ]
@@ -51,7 +52,7 @@ exclude = ["*_test.py"]
51
52
  packages = ["src/labnotes"]
52
53
 
53
54
  [tool.pytest.ini_options]
54
- testpaths = ["src/labnotes"]
55
+ testpaths = ["src/labnotes", "tests"]
55
56
  python_files = "*_test.py"
56
57
 
57
58
  [tool.ruff.lint]
@@ -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,47 @@
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 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
+
41
+ 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:
42
+
43
+ ```sh
44
+ uvx dirsql -c data/.dirsql.toml "SELECT * FROM runs LIMIT 1" --format json
45
+ ```
46
+
47
+ `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,33 @@
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)
@@ -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}")
@@ -13,5 +13,7 @@ class RunView:
13
13
  @dataclass(frozen=True)
14
14
  class LabnotesConfig:
15
15
  path: Path
16
- experiments_folder: Path
17
- data_folder: Path
16
+ root: Path
17
+ experiments: str
18
+ files: str
19
+ runs: str
@@ -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
@@ -4,8 +4,6 @@ from pathlib import Path
4
4
  import click
5
5
  from uvicorn.main import main as uvicorn_main
6
6
 
7
- from labnotes.load_config import load_config
8
- from labnotes.read_config import CONFIG_FILE
9
7
  from labnotes.serve_app import DATA_FOLDER, EXPERIMENTS_FOLDER, KERNEL_IDLE_MINUTES
10
8
 
11
9
  # labnotes owns these; the rest of uvicorn's options pass straight through.
@@ -17,12 +15,14 @@ OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
17
15
  "--experiments-folder",
18
16
  type=click.Path(path_type=Path),
19
17
  envvar=EXPERIMENTS_FOLDER,
18
+ required=True,
20
19
  help="One subfolder per experiment, each with a design.yaml and markdown notes.",
21
20
  )
22
21
  @click.option(
23
22
  "--data-folder",
24
23
  type=click.Path(path_type=Path),
25
24
  envvar=DATA_FOLDER,
25
+ required=True,
26
26
  help="Run data, with a .dirsql.toml declaring a runs table that has a study column.",
27
27
  )
28
28
  @click.option(
@@ -44,19 +44,11 @@ OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
44
44
  @click.pass_context
45
45
  def server(
46
46
  ctx: click.Context,
47
- experiments_folder: Path | None,
48
- data_folder: Path | None,
47
+ experiments_folder: Path,
48
+ data_folder: Path,
49
49
  kernel_idle_minutes: int,
50
50
  **server_options: object,
51
51
  ) -> None:
52
- config = load_config(ctx)
53
- if config is not None:
54
- experiments_folder = experiments_folder or config.experiments_folder
55
- data_folder = data_folder or config.data_folder
56
- if experiments_folder is None or data_folder is None:
57
- raise click.UsageError(
58
- f"--experiments-folder and --data-folder are required without a {CONFIG_FILE}."
59
- )
60
52
  # Through the environment so --reload and --workers child processes see them.
61
53
  os.environ[EXPERIMENTS_FOLDER] = str(experiments_folder.resolve())
62
54
  os.environ[DATA_FOLDER] = str(data_folder.resolve())
File without changes
@@ -0,0 +1,17 @@
1
+ from labnotes.models import LabnotesConfig
2
+ from labnotes.tables.list_experiment_runs import list_experiment_runs
3
+ from labnotes.tables.open_tables import open_tables
4
+ from labnotes.tables.summarize_experiments import summarize_experiments
5
+
6
+
7
+ async def experiment_rows(
8
+ config: LabnotesConfig, name: str | None, action: str | None
9
+ ) -> list[dict[str, object]] | None:
10
+ tables = open_tables(config)
11
+ rows = await summarize_experiments(tables, name)
12
+ if name is not None and not rows:
13
+ return None
14
+ match name, action:
15
+ case str(), "runs":
16
+ return await list_experiment_runs(tables, name)
17
+ return rows
@@ -0,0 +1,9 @@
1
+ import re
2
+
3
+ GLOB = re.compile(r"[*?\[]")
4
+
5
+
6
+ def file_prefix(template: str) -> str:
7
+ glob = GLOB.search(template)
8
+ head = template[: glob.start()] if glob else template
9
+ return head if head.endswith("/") else head[: head.rfind("/") + 1]
@@ -0,0 +1,5 @@
1
+ from datetime import UTC, datetime
2
+
3
+
4
+ def iso_time(seconds: float) -> str:
5
+ return datetime.fromtimestamp(seconds, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -0,0 +1,10 @@
1
+ from dirsql import DirSQL
2
+
3
+ from labnotes.sql_quote import quote_string
4
+
5
+
6
+ async def list_experiment_runs(tables: DirSQL, name: str) -> list[dict[str, object]]:
7
+ return await tables.query(
8
+ "SELECT id, created, ended, exit_code, error, params, folder FROM runs"
9
+ f" WHERE experiment = {quote_string(name)} ORDER BY created, id"
10
+ )
@@ -0,0 +1,63 @@
1
+ from functools import partial
2
+
3
+ from dirsql import DirSQL, Table
4
+
5
+ from labnotes.models import LabnotesConfig
6
+ from labnotes.tables.path_glob import path_glob
7
+ from labnotes.tables.read_experiment import read_experiment
8
+ from labnotes.tables.read_file import read_file
9
+ from labnotes.tables.read_run import read_run
10
+
11
+ # experiments is a view, so files and runs can't declare a foreign key to it.
12
+ EXPERIMENTS = """
13
+ CREATE TABLE experiment_configs (experiment TEXT PRIMARY KEY, config TEXT);
14
+ CREATE VIEW experiments AS
15
+ SELECT names.name, experiment_configs.config
16
+ FROM (
17
+ SELECT experiment AS name FROM experiment_configs
18
+ UNION SELECT experiment FROM files
19
+ UNION SELECT experiment FROM runs
20
+ ) AS names
21
+ LEFT JOIN experiment_configs ON experiment_configs.experiment = names.name;
22
+ """
23
+ FILES = """
24
+ CREATE TABLE files (
25
+ experiment TEXT, filename TEXT, path TEXT PRIMARY KEY, ctime TEXT, mtime TEXT
26
+ )
27
+ """
28
+ RUNS = """
29
+ CREATE TABLE runs (
30
+ experiment TEXT, id TEXT, folder TEXT, created TEXT, ended TEXT,
31
+ exit_code INTEGER, error TEXT, stdout TEXT, stderr TEXT, params TEXT,
32
+ PRIMARY KEY (experiment, id)
33
+ )
34
+ """
35
+
36
+
37
+ def open_tables(config: LabnotesConfig) -> DirSQL:
38
+ root = config.root
39
+ return DirSQL(
40
+ str(root),
41
+ tables=[
42
+ Table(
43
+ name="files",
44
+ ddl=FILES,
45
+ glob=path_glob(config.files),
46
+ on_file=partial(read_file, root, config.files),
47
+ ),
48
+ Table(
49
+ name="runs",
50
+ ddl=RUNS,
51
+ glob=path_glob(f"{config.runs}/run.json"),
52
+ on_file=partial(read_run, root, config.runs),
53
+ ),
54
+ Table(
55
+ name="experiment_configs",
56
+ ddl=EXPERIMENTS,
57
+ glob=path_glob(f"{config.experiments}/experiment.toml"),
58
+ on_file=partial(read_experiment, root, config.experiments),
59
+ ),
60
+ ],
61
+ # Run folders are often gitignored (DVC, large outputs); scan them anyway.
62
+ no_ignore=True,
63
+ )
@@ -0,0 +1,8 @@
1
+ import re
2
+
3
+ PLACEHOLDER = re.compile(r"\{[^}]*\}")
4
+
5
+
6
+ def path_glob(template: str) -> str:
7
+ glob = PLACEHOLDER.sub("*", template)
8
+ return f"{glob}**" if glob.endswith("/") else glob
@@ -0,0 +1,23 @@
1
+ import re
2
+
3
+ TOKEN = re.compile(r"\{([^}]*)\}|\*\*|\*|\?|[^{*?]+")
4
+
5
+
6
+ def path_pattern(template: str) -> re.Pattern[str]:
7
+ parts = []
8
+ for found in TOKEN.finditer(template):
9
+ if found.group(1) is not None:
10
+ parts.append(f"(?P<{found.group(1)}>[^/]+)")
11
+ continue
12
+ match found.group(0):
13
+ case "**":
14
+ parts.append(".*")
15
+ case "*":
16
+ parts.append("[^/]*")
17
+ case "?":
18
+ parts.append("[^/]")
19
+ case token:
20
+ parts.append(re.escape(token))
21
+ if template.endswith("/"):
22
+ parts.append(".+")
23
+ return re.compile("".join(parts))
@@ -0,0 +1,15 @@
1
+ import json
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ from labnotes.tables.path_pattern import path_pattern
6
+
7
+
8
+ def read_experiment(root: Path, template: str, path: str) -> list[dict[str, object]]:
9
+ relative = Path(path).relative_to(root).as_posix()
10
+ found = path_pattern(f"{template}/experiment.toml").fullmatch(relative)
11
+ if found is None:
12
+ return []
13
+ with Path(path).open("rb") as file:
14
+ config = tomllib.load(file)
15
+ return [{"experiment": found["experiment"], "config": json.dumps(config)}]
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.tables.file_prefix import file_prefix
4
+ from labnotes.tables.iso_time import iso_time
5
+ from labnotes.tables.path_pattern import path_pattern
6
+
7
+
8
+ def read_file(root: Path, template: str, path: str) -> list[dict[str, object]]:
9
+ relative = Path(path).relative_to(root).as_posix()
10
+ found = path_pattern(template).fullmatch(relative)
11
+ prefix = path_pattern(file_prefix(template).rstrip("/")).match(relative)
12
+ if found is None or prefix is None:
13
+ return []
14
+ stat = Path(path).stat()
15
+ return [
16
+ {
17
+ "experiment": found["experiment"],
18
+ "filename": relative[prefix.end() :].lstrip("/"),
19
+ "path": relative,
20
+ "ctime": iso_time(stat.st_ctime),
21
+ "mtime": iso_time(stat.st_mtime),
22
+ }
23
+ ]
@@ -0,0 +1,27 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from labnotes.tables.path_pattern import path_pattern
5
+
6
+
7
+ def read_run(root: Path, template: str, path: str) -> list[dict[str, object]]:
8
+ relative = Path(path).relative_to(root).as_posix()
9
+ found = path_pattern(f"{template}/run.json").fullmatch(relative)
10
+ if found is None:
11
+ return []
12
+ run = json.loads(Path(path).read_text(encoding="utf-8"))
13
+ folder = relative.removesuffix("/run.json")
14
+ return [
15
+ {
16
+ "experiment": found["experiment"],
17
+ "id": found["run"],
18
+ "folder": folder,
19
+ "created": run.get("created"),
20
+ "ended": run.get("ended"),
21
+ "exit_code": run.get("exit_code"),
22
+ "error": run.get("error"),
23
+ "stdout": f"{folder}/stdout.log",
24
+ "stderr": f"{folder}/stderr.log",
25
+ "params": json.dumps(run.get("params", {})),
26
+ }
27
+ ]
@@ -0,0 +1,27 @@
1
+ from dirsql import DirSQL
2
+
3
+ from labnotes.sql_quote import quote_string
4
+
5
+ SUMMARY = """
6
+ SELECT
7
+ e.name,{config}
8
+ (SELECT max(created) FROM runs WHERE runs.experiment = e.name) AS last_run,
9
+ (
10
+ SELECT min(t) FROM (
11
+ SELECT ctime AS t FROM files WHERE files.experiment = e.name
12
+ UNION ALL SELECT created FROM runs WHERE runs.experiment = e.name
13
+ )
14
+ ) AS created,
15
+ (SELECT count(*) FROM files WHERE files.experiment = e.name) AS files,
16
+ (SELECT count(*) FROM runs WHERE runs.experiment = e.name) AS runs
17
+ FROM experiments AS e
18
+ """
19
+
20
+
21
+ async def summarize_experiments(
22
+ tables: DirSQL, name: str | None = None
23
+ ) -> list[dict[str, object]]:
24
+ if name is None:
25
+ return await tables.query(SUMMARY.format(config="") + " ORDER BY e.name")
26
+ sql = SUMMARY.format(config=" e.config,")
27
+ return await tables.query(f"{sql} WHERE e.name = {quote_string(name)}")
@@ -460,6 +460,7 @@ dependencies = [
460
460
  { name = "jupyter-client" },
461
461
  { name = "markdown-it-py" },
462
462
  { name = "pyyaml" },
463
+ { name = "rich" },
463
464
  { name = "starlette" },
464
465
  { name = "uvicorn" },
465
466
  ]
@@ -482,6 +483,7 @@ requires-dist = [
482
483
  { name = "jupyter-client", specifier = ">=8.10.0" },
483
484
  { name = "markdown-it-py", specifier = ">=4.2.0" },
484
485
  { name = "pyyaml", specifier = ">=6.0.3" },
486
+ { name = "rich", specifier = ">=14" },
485
487
  { name = "starlette", specifier = ">=1.8.0" },
486
488
  { name = "uvicorn", specifier = ">=0.54.0" },
487
489
  ]
@@ -1069,6 +1071,19 @@ wheels = [
1069
1071
  { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" },
1070
1072
  ]
1071
1073
 
1074
+ [[package]]
1075
+ name = "rich"
1076
+ version = "15.0.0"
1077
+ source = { registry = "https://pypi.org/simple" }
1078
+ dependencies = [
1079
+ { name = "markdown-it-py" },
1080
+ { name = "pygments" },
1081
+ ]
1082
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
1083
+ wheels = [
1084
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
1085
+ ]
1086
+
1072
1087
  [[package]]
1073
1088
  name = "ruff"
1074
1089
  version = "0.8.4"
labnotes-0.0.13/README.md DELETED
@@ -1,49 +0,0 @@
1
- # labnotes
2
-
3
- Browse experiment designs, notes, and run data from a folder.
4
-
5
- ```sh
6
- uvx labnotes server
7
- ```
8
-
9
- labnotes reads two folders, named in a `.labnotes.toml` at the project root:
10
-
11
- ```toml
12
- experiments = "experiments"
13
- data = "data"
14
- ```
15
-
16
- Both keys are optional and default to the values above. Paths are relative to
17
- `.labnotes.toml`, or absolute, or start with `~`. labnotes reads the file from
18
- the working folder, or from the path given with `-c`.
19
-
20
- - `experiments` holds one subfolder per experiment, each with a
21
- `design.yaml` (with a `study` key) and any number of markdown notes.
22
- - `data` holds the run data and a `.dirsql.toml` that declares a
23
- `runs` table with a `study` column. An experiment's runs are the rows whose
24
- `study` matches its design's.
25
-
26
- That serves a local site at http://127.0.0.1:8000: a table of experiments,
27
- and a page per experiment with its parameters, its notes, progress per cell of
28
- the design grid, and every run so far. Both folders are watched, so a reload
29
- shows new runs and edited notes.
30
-
31
- `design.yaml` keys the site reads: `study`, `fixed`, `varies` (each key a list
32
- of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
33
- `runs` table that marks a run as complete. Anything else shows up as-is.
34
-
35
- `-h`/`--host` and `-p`/`--port` change where it listens; `-h 0.0.0.0` (or a
36
- Tailscale IP) serves beyond localhost. Every other uvicorn option passes
37
- through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
38
- `--ssl-*` set, and the rest. `labnotes server --help` lists them. `--experiments-folder`
39
- and `--data-folder`, or `LABNOTES_EXPERIMENTS_FOLDER` and
40
- `LABNOTES_DATA_FOLDER`, override `.labnotes.toml`.
41
-
42
- ## Agent skill
43
-
44
- ```sh
45
- npx skills add thekevinscott/labnotes
46
- ```
47
-
48
- The skill tells a coding agent to run `labnotes context`, which prints a usage
49
- guide for the installed version.
@@ -1,18 +0,0 @@
1
- ## Usage
2
-
3
- labnotes reads two folders. 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. A project that uses labnotes has a `.labnotes.toml` at its 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 the folders it resolves to, or say there is none. Its keys are optional:
4
-
5
- ```toml
6
- experiments = "experiments" # the default
7
- data = "data" # relative to .labnotes.toml, absolute, or ~
8
- ```
9
-
10
- `--experiments-folder` and `--data-folder`, or `LABNOTES_EXPERIMENTS_FOLDER` and `LABNOTES_DATA_FOLDER`, override either key. Without a `.labnotes.toml`, pass both.
11
-
12
- 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:
13
-
14
- ```sh
15
- uvx dirsql -c data/.dirsql.toml "SELECT * FROM runs LIMIT 1" --format json
16
- ```
17
-
18
- `uvx labnotes server` serves the experiments and their runs as a local site.
@@ -1,23 +0,0 @@
1
- import tomllib
2
- from pathlib import Path
3
-
4
- from labnotes.models import LabnotesConfig
5
-
6
- CONFIG_FILE = ".labnotes.toml"
7
- DEFAULTS = {"experiments": "experiments", "data": "data"}
8
-
9
-
10
- def read_config(path: Path) -> LabnotesConfig:
11
- with path.open("rb") as file:
12
- values = tomllib.load(file)
13
- unknown = sorted(values.keys() - DEFAULTS.keys())
14
- if unknown:
15
- raise ValueError(f"{path}: unknown keys {', '.join(unknown)}")
16
- folder = path.resolve().parent
17
- folders = {}
18
- for key, default in DEFAULTS.items():
19
- value = values.get(key, default)
20
- if not isinstance(value, str):
21
- raise ValueError(f"{path}: {key} must be a string")
22
- folders[key] = folder / Path(value).expanduser()
23
- return LabnotesConfig(path.resolve(), folders["experiments"], folders["data"])
File without changes
File without changes
File without changes