labnotes 0.0.2__tar.gz → 0.0.3__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 (36) hide show
  1. {labnotes-0.0.2 → labnotes-0.0.3}/PKG-INFO +17 -1
  2. labnotes-0.0.3/README.md +25 -0
  3. {labnotes-0.0.2 → labnotes-0.0.3}/pyproject.toml +5 -0
  4. labnotes-0.0.3/src/labnotes/cell_progress.py +22 -0
  5. labnotes-0.0.3/src/labnotes/cli.py +51 -0
  6. labnotes-0.0.3/src/labnotes/count_runs.py +16 -0
  7. labnotes-0.0.3/src/labnotes/create_app.py +74 -0
  8. labnotes-0.0.3/src/labnotes/drain_watch.py +6 -0
  9. labnotes-0.0.3/src/labnotes/dump_experiments.py +15 -0
  10. labnotes-0.0.3/src/labnotes/format_value.py +11 -0
  11. labnotes-0.0.3/src/labnotes/list_experiments.py +23 -0
  12. labnotes-0.0.3/src/labnotes/list_runs.py +12 -0
  13. labnotes-0.0.3/src/labnotes/load_experiment.py +48 -0
  14. labnotes-0.0.3/src/labnotes/pivot_cells.py +21 -0
  15. labnotes-0.0.3/src/labnotes/render_markdown.py +9 -0
  16. labnotes-0.0.3/src/labnotes/run_columns.py +6 -0
  17. labnotes-0.0.3/src/labnotes/sql_quote.py +6 -0
  18. labnotes-0.0.3/src/labnotes/static/labnotes.css +345 -0
  19. labnotes-0.0.3/src/labnotes/study_progress.py +36 -0
  20. labnotes-0.0.3/src/labnotes/summarize_design.py +23 -0
  21. labnotes-0.0.3/src/labnotes/templates/base.html +19 -0
  22. labnotes-0.0.3/src/labnotes/templates/experiment.html +152 -0
  23. labnotes-0.0.3/src/labnotes/templates/index.html +49 -0
  24. labnotes-0.0.3/src/labnotes/templates/macros.html +10 -0
  25. {labnotes-0.0.2 → labnotes-0.0.3}/uv.lock +270 -0
  26. labnotes-0.0.2/README.md +0 -13
  27. labnotes-0.0.2/src/labnotes/cli.py +0 -29
  28. labnotes-0.0.2/src/labnotes/list_experiments.py +0 -17
  29. {labnotes-0.0.2 → labnotes-0.0.3}/.gitignore +0 -0
  30. {labnotes-0.0.2 → labnotes-0.0.3}/CHANGELOG.md +0 -0
  31. {labnotes-0.0.2 → labnotes-0.0.3}/MIGRATIONS.md +0 -0
  32. {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/__init__.py +0 -0
  33. {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/open_experiments.py +0 -0
  34. {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/open_runs.py +0 -0
  35. {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/read_design.py +0 -0
  36. {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/read_note.py +0 -0
@@ -1,13 +1,17 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.2
3
+ Version: 0.0.3
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>
7
7
  License-Expression: MIT
8
8
  Requires-Python: >=3.12
9
9
  Requires-Dist: dirsql>=0.4.110
10
+ Requires-Dist: jinja2>=3.1.6
11
+ Requires-Dist: markdown-it-py>=4.2.0
10
12
  Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Dist: starlette>=1.8.0
14
+ Requires-Dist: uvicorn>=0.54.0
11
15
  Description-Content-Type: text/markdown
12
16
 
13
17
  # labnotes
@@ -23,3 +27,15 @@ uvx labnotes --experiments-folder experiments --data-folder data
23
27
  - `--data-folder` holds the run data and a `.dirsql.toml` that declares a
24
28
  `runs` table with a `study` column. An experiment's runs are the rows whose
25
29
  `study` matches its design's.
30
+
31
+ That serves a local site at http://127.0.0.1:8000: a table of experiments,
32
+ and a page per experiment with its parameters, its notes, progress per cell of
33
+ the design grid, and every run so far. Both folders are watched, so a reload
34
+ shows new runs and edited notes.
35
+
36
+ `design.yaml` keys the site reads: `study`, `fixed`, `varies` (each key a list
37
+ of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
38
+ `runs` table that marks a run as complete. Anything else shows up as-is.
39
+
40
+ `--host` and `--port` change where it listens. `--json` prints the experiments
41
+ as JSON and exits.
@@ -0,0 +1,25 @@
1
+ # labnotes
2
+
3
+ Browse experiment designs, notes, and run data from a folder.
4
+
5
+ ```sh
6
+ uvx labnotes --experiments-folder experiments --data-folder data
7
+ ```
8
+
9
+ - `--experiments-folder` holds one subfolder per experiment, each with a
10
+ `design.yaml` (with a `study` key) and any number of markdown notes.
11
+ - `--data-folder` holds the run data and a `.dirsql.toml` that declares a
12
+ `runs` table with a `study` column. An experiment's runs are the rows whose
13
+ `study` matches its design's.
14
+
15
+ That serves a local site at http://127.0.0.1:8000: a table of experiments,
16
+ and a page per experiment with its parameters, its notes, progress per cell of
17
+ the design grid, and every run so far. Both folders are watched, so a reload
18
+ shows new runs and edited notes.
19
+
20
+ `design.yaml` keys the site reads: `study`, `fixed`, `varies` (each key a list
21
+ of values), `runs_per_cell`, and `completed_run`, a SQL condition over the
22
+ `runs` table that marks a run as complete. Anything else shows up as-is.
23
+
24
+ `--host` and `--port` change where it listens. `--json` prints the experiments
25
+ as JSON and exits.
@@ -12,7 +12,11 @@ license = "MIT"
12
12
  authors = [{ name = "Kevin Scott", email = "me@thekevinscott.com" }]
13
13
  dependencies = [
14
14
  "dirsql>=0.4.110",
15
+ "jinja2>=3.1.6",
16
+ "markdown-it-py>=4.2.0",
15
17
  "pyyaml>=6.0.3",
18
+ "starlette>=1.8.0",
19
+ "uvicorn>=0.54.0",
16
20
  ]
17
21
 
18
22
  [project.urls]
@@ -23,6 +27,7 @@ labnotes = "labnotes.cli:main"
23
27
 
24
28
  [dependency-groups]
25
29
  dev = [
30
+ "httpx2>=2.13.1",
26
31
  "mypy>=2.4.0",
27
32
  "pytest>=8.0",
28
33
  "ruff==0.8.4",
@@ -0,0 +1,22 @@
1
+ from itertools import product
2
+
3
+
4
+ def _key(value: object) -> object:
5
+ return int(value) if isinstance(value, bool) else value
6
+
7
+
8
+ def cell_progress(
9
+ varies: dict[str, list[object]], counts: list[dict[str, object]]
10
+ ) -> list[dict[str, object]]:
11
+ by_cell = {tuple(_key(row[key]) for key in varies): row for row in counts}
12
+ cells = []
13
+ for values in product(*varies.values()):
14
+ row = by_cell.get(tuple(_key(value) for value in values), {})
15
+ cells.append(
16
+ {
17
+ "values": dict(zip(varies, values)),
18
+ "runs": row.get("runs", 0),
19
+ "completed": row.get("completed", 0),
20
+ }
21
+ )
22
+ return cells
@@ -0,0 +1,51 @@
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import sys
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+
8
+ import uvicorn
9
+
10
+ from labnotes.create_app import create_app
11
+ from labnotes.dump_experiments import dump_experiments
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> None:
15
+ parser = argparse.ArgumentParser(
16
+ prog="labnotes", description="Browse experiments and their run data."
17
+ )
18
+ parser.add_argument("--version", action="version", version=version("labnotes"))
19
+ parser.add_argument(
20
+ "--experiments-folder",
21
+ type=Path,
22
+ required=True,
23
+ help="one subfolder per experiment, each with a design.yaml and markdown notes",
24
+ )
25
+ parser.add_argument(
26
+ "--data-folder",
27
+ type=Path,
28
+ required=True,
29
+ help="run data, with a .dirsql.toml declaring a runs table that has a study column",
30
+ )
31
+ parser.add_argument("--host", default="127.0.0.1")
32
+ parser.add_argument("--port", type=int, default=8000)
33
+ parser.add_argument(
34
+ "--json",
35
+ action="store_true",
36
+ help="print experiments as JSON instead of serving",
37
+ )
38
+ args = parser.parse_args(argv)
39
+ if args.json:
40
+ experiments = asyncio.run(
41
+ dump_experiments(args.experiments_folder, args.data_folder)
42
+ )
43
+ print(json.dumps(experiments, indent=2))
44
+ return
45
+ print(f"labnotes at http://{args.host}:{args.port}", file=sys.stderr)
46
+ uvicorn.run(
47
+ create_app(args.experiments_folder, args.data_folder),
48
+ host=args.host,
49
+ port=args.port,
50
+ log_level="warning",
51
+ )
@@ -0,0 +1,16 @@
1
+ from dirsql import DirSQL
2
+
3
+ from labnotes.sql_quote import quote_identifier, quote_string
4
+
5
+
6
+ async def count_runs(
7
+ runs: DirSQL, study: str, completed_run: str, group_by: list[str] | None = None
8
+ ) -> list[dict[str, object]]:
9
+ keys = [quote_identifier(key) for key in group_by or []]
10
+ columns = "".join(f"{key}, " for key in keys)
11
+ group = f" GROUP BY {', '.join(keys)}" if keys else ""
12
+ return await runs.query(
13
+ f"SELECT {columns}COUNT(*) AS runs,"
14
+ f" COALESCE(SUM(CASE WHEN ({completed_run}) THEN 1 ELSE 0 END), 0) AS completed"
15
+ f" FROM runs WHERE study = {quote_string(study)}{group}"
16
+ )
@@ -0,0 +1,74 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import cast
6
+
7
+ from starlette.applications import Starlette
8
+ from starlette.exceptions import HTTPException
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+ from starlette.routing import Mount, Route
12
+ from starlette.staticfiles import StaticFiles
13
+ from starlette.templating import Jinja2Templates
14
+
15
+ from labnotes.drain_watch import drain_watch
16
+ from labnotes.format_value import format_value
17
+ from labnotes.list_experiments import list_experiments
18
+ from labnotes.load_experiment import load_experiment
19
+ from labnotes.open_experiments import open_experiments
20
+ from labnotes.open_runs import open_runs
21
+ from labnotes.pivot_cells import pivot_cells
22
+
23
+ HERE = Path(__file__).parent
24
+ templates = Jinja2Templates(directory=HERE / "templates")
25
+ templates.env.filters["value"] = format_value
26
+
27
+
28
+ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
29
+ @asynccontextmanager
30
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
31
+ app.state.experiments = open_experiments(experiments_folder)
32
+ app.state.runs = open_runs(data_folder)
33
+ await app.state.experiments.ready()
34
+ await app.state.runs.ready()
35
+ watchers = [
36
+ asyncio.create_task(drain_watch(app.state.experiments)),
37
+ asyncio.create_task(drain_watch(app.state.runs)),
38
+ ]
39
+ yield
40
+ for watcher in watchers:
41
+ watcher.cancel()
42
+
43
+ async def index(request: Request) -> Response:
44
+ state = request.app.state
45
+ experiments = await list_experiments(state.experiments, state.runs)
46
+ return templates.TemplateResponse(
47
+ request,
48
+ "index.html",
49
+ {"experiments": experiments, "folder": experiments_folder.resolve().name},
50
+ )
51
+
52
+ async def experiment(request: Request) -> Response:
53
+ state = request.app.state
54
+ loaded = await load_experiment(
55
+ state.experiments, state.runs, request.path_params["name"]
56
+ )
57
+ if loaded is None:
58
+ raise HTTPException(404, "No such experiment")
59
+ varies = cast(dict[str, list[object]], loaded["varies"])
60
+ cells = cast(list[dict[str, object]], loaded["cells"])
61
+ return templates.TemplateResponse(
62
+ request,
63
+ "experiment.html",
64
+ {"experiment": loaded, "pivot": pivot_cells(varies, cells)},
65
+ )
66
+
67
+ return Starlette(
68
+ routes=[
69
+ Route("/", index),
70
+ Route("/experiments/{name}", experiment),
71
+ Mount("/static", StaticFiles(directory=HERE / "static"), name="static"),
72
+ ],
73
+ lifespan=lifespan,
74
+ )
@@ -0,0 +1,6 @@
1
+ from dirsql import DirSQL
2
+
3
+
4
+ async def drain_watch(db: DirSQL) -> None:
5
+ async for _ in db.watch():
6
+ pass
@@ -0,0 +1,15 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.list_experiments import list_experiments
4
+ from labnotes.open_experiments import open_experiments
5
+ from labnotes.open_runs import open_runs
6
+
7
+
8
+ async def dump_experiments(
9
+ experiments_folder: Path, data_folder: Path
10
+ ) -> list[dict[str, object]]:
11
+ experiments = open_experiments(experiments_folder)
12
+ runs = open_runs(data_folder)
13
+ await experiments.ready()
14
+ await runs.ready()
15
+ return await list_experiments(experiments, runs)
@@ -0,0 +1,11 @@
1
+ import json
2
+
3
+
4
+ def format_value(value: object) -> str:
5
+ if value is None:
6
+ return "—"
7
+ if isinstance(value, bool):
8
+ return "true" if value else "false"
9
+ if isinstance(value, dict | list):
10
+ return json.dumps(value)
11
+ return str(value)
@@ -0,0 +1,23 @@
1
+ import json
2
+
3
+ from dirsql import DirSQL
4
+
5
+ from labnotes.run_columns import run_columns
6
+ from labnotes.study_progress import study_progress
7
+ from labnotes.summarize_design import summarize_design
8
+
9
+
10
+ async def list_experiments(
11
+ experiments: DirSQL, runs: DirSQL
12
+ ) -> list[dict[str, object]]:
13
+ rows = await experiments.query(
14
+ "SELECT name, study, design FROM experiments ORDER BY name"
15
+ )
16
+ columns = await run_columns(runs)
17
+ listed = []
18
+ for row in rows:
19
+ summary = summarize_design(json.loads(str(row["design"])))
20
+ study = None if row["study"] is None else str(row["study"])
21
+ progress = await study_progress(runs, study, summary, columns)
22
+ listed.append({"name": row["name"], "study": study, **summary, **progress})
23
+ return listed
@@ -0,0 +1,12 @@
1
+ from dirsql import DirSQL
2
+
3
+ from labnotes.sql_quote import quote_string
4
+
5
+
6
+ async def list_runs(
7
+ runs: DirSQL, study: str, completed_run: str
8
+ ) -> list[dict[str, object]]:
9
+ return await runs.query(
10
+ f"SELECT ({completed_run}) AS labnotes_completed, * FROM runs"
11
+ f" WHERE study = {quote_string(study)} ORDER BY 2 DESC"
12
+ )
@@ -0,0 +1,48 @@
1
+ import json
2
+ from typing import cast
3
+
4
+ from dirsql import DirSQL
5
+
6
+ from labnotes.list_runs import list_runs
7
+ from labnotes.render_markdown import render_markdown
8
+ from labnotes.run_columns import run_columns
9
+ from labnotes.sql_quote import quote_string
10
+ from labnotes.study_progress import study_progress
11
+ from labnotes.summarize_design import summarize_design
12
+
13
+
14
+ async def load_experiment(
15
+ experiments: DirSQL, runs: DirSQL, name: str
16
+ ) -> dict[str, object] | None:
17
+ found = await experiments.query(
18
+ f"SELECT name, study, design FROM experiments WHERE name = {quote_string(name)}"
19
+ )
20
+ if not found:
21
+ return None
22
+ [row] = found
23
+ notes = await experiments.query(
24
+ "SELECT name, content FROM notes"
25
+ f" WHERE experiment = {quote_string(name)} ORDER BY name"
26
+ )
27
+ summary = summarize_design(json.loads(str(row["design"])))
28
+ varies = cast(dict[str, list[object]], summary["varies"])
29
+ study = None if row["study"] is None else str(row["study"])
30
+ columns = await run_columns(runs)
31
+ progress = await study_progress(runs, study, summary, columns)
32
+ listed = (
33
+ []
34
+ if study is None
35
+ else await list_runs(runs, study, str(summary["completed_run"]))
36
+ )
37
+ return {
38
+ "name": row["name"],
39
+ "study": study,
40
+ **summary,
41
+ **progress,
42
+ "notes": [
43
+ {"name": note["name"], "html": render_markdown(str(note["content"]))}
44
+ for note in notes
45
+ ],
46
+ "runs_list": listed,
47
+ "run_columns": columns[:1] + [key for key in varies if key in columns[1:]],
48
+ }
@@ -0,0 +1,21 @@
1
+ from itertools import product
2
+
3
+
4
+ def pivot_cells(
5
+ varies: dict[str, list[object]], cells: list[dict[str, object]]
6
+ ) -> dict[str, object] | None:
7
+ if not varies or not cells:
8
+ return None
9
+ *row_keys, column_key = varies
10
+ width = len(varies[column_key])
11
+ labels = product(*(varies[key] for key in row_keys))
12
+ rows = [
13
+ {"labels": list(label), "cells": cells[start : start + width]}
14
+ for label, start in zip(labels, range(0, len(cells), width))
15
+ ]
16
+ return {
17
+ "row_keys": row_keys,
18
+ "column_key": column_key,
19
+ "columns": varies[column_key],
20
+ "rows": rows,
21
+ }
@@ -0,0 +1,9 @@
1
+ from markdown_it import MarkdownIt
2
+
3
+ _markdown = MarkdownIt("commonmark", {"html": False, "typographer": True}).enable(
4
+ ["table", "strikethrough", "replacements", "smartquotes"]
5
+ )
6
+
7
+
8
+ def render_markdown(text: str) -> str:
9
+ return _markdown.render(text)
@@ -0,0 +1,6 @@
1
+ from dirsql import DirSQL
2
+
3
+
4
+ async def run_columns(runs: DirSQL) -> list[str]:
5
+ rows = await runs.query("SELECT name FROM pragma_table_info('runs')")
6
+ return [str(row["name"]) for row in rows]
@@ -0,0 +1,6 @@
1
+ def quote_string(value: str) -> str:
2
+ return "'" + value.replace("'", "''") + "'"
3
+
4
+
5
+ def quote_identifier(name: str) -> str:
6
+ return '"' + name.replace('"', '""') + '"'