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.
- {labnotes-0.0.2 → labnotes-0.0.3}/PKG-INFO +17 -1
- labnotes-0.0.3/README.md +25 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/pyproject.toml +5 -0
- labnotes-0.0.3/src/labnotes/cell_progress.py +22 -0
- labnotes-0.0.3/src/labnotes/cli.py +51 -0
- labnotes-0.0.3/src/labnotes/count_runs.py +16 -0
- labnotes-0.0.3/src/labnotes/create_app.py +74 -0
- labnotes-0.0.3/src/labnotes/drain_watch.py +6 -0
- labnotes-0.0.3/src/labnotes/dump_experiments.py +15 -0
- labnotes-0.0.3/src/labnotes/format_value.py +11 -0
- labnotes-0.0.3/src/labnotes/list_experiments.py +23 -0
- labnotes-0.0.3/src/labnotes/list_runs.py +12 -0
- labnotes-0.0.3/src/labnotes/load_experiment.py +48 -0
- labnotes-0.0.3/src/labnotes/pivot_cells.py +21 -0
- labnotes-0.0.3/src/labnotes/render_markdown.py +9 -0
- labnotes-0.0.3/src/labnotes/run_columns.py +6 -0
- labnotes-0.0.3/src/labnotes/sql_quote.py +6 -0
- labnotes-0.0.3/src/labnotes/static/labnotes.css +345 -0
- labnotes-0.0.3/src/labnotes/study_progress.py +36 -0
- labnotes-0.0.3/src/labnotes/summarize_design.py +23 -0
- labnotes-0.0.3/src/labnotes/templates/base.html +19 -0
- labnotes-0.0.3/src/labnotes/templates/experiment.html +152 -0
- labnotes-0.0.3/src/labnotes/templates/index.html +49 -0
- labnotes-0.0.3/src/labnotes/templates/macros.html +10 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/uv.lock +270 -0
- labnotes-0.0.2/README.md +0 -13
- labnotes-0.0.2/src/labnotes/cli.py +0 -29
- labnotes-0.0.2/src/labnotes/list_experiments.py +0 -17
- {labnotes-0.0.2 → labnotes-0.0.3}/.gitignore +0 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/CHANGELOG.md +0 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/MIGRATIONS.md +0 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/open_experiments.py +0 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/open_runs.py +0 -0
- {labnotes-0.0.2 → labnotes-0.0.3}/src/labnotes/read_design.py +0 -0
- {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.
|
|
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.
|
labnotes-0.0.3/README.md
ADDED
|
@@ -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,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,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
|
+
}
|