labnotes 0.0.9__tar.gz → 0.0.11__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.9 → labnotes-0.0.11}/PKG-INFO +20 -1
- {labnotes-0.0.9 → labnotes-0.0.11}/README.md +19 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/cli.py +38 -6
- labnotes-0.0.11/src/labnotes/context.md +27 -0
- labnotes-0.0.11/src/labnotes/context_guide.py +7 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/create_app.py +26 -6
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/load_experiment.py +2 -0
- labnotes-0.0.11/src/labnotes/notebook_kernel.py +74 -0
- labnotes-0.0.11/src/labnotes/query_experiment.py +24 -0
- labnotes-0.0.11/src/labnotes/scope_runs.py +14 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/serve_app.py +4 -1
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/static/labnotes.css +5 -2
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/static/run-cell.js +21 -0
- labnotes-0.0.11/src/labnotes/stop_idle_kernels.py +13 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/templates/experiment.html +2 -2
- labnotes-0.0.9/src/labnotes/notebook_kernel.py +0 -59
- {labnotes-0.0.9 → labnotes-0.0.11}/.gitignore +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/CHANGELOG.md +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/MIGRATIONS.md +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/pyproject.toml +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/cell_progress.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/dump_experiments.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/models.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/open_experiments.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/open_runs.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/pivot_cells.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/read_design.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/read_note.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/run_columns.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/study_progress.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/summarize_design.py +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/src/labnotes/templates/macros.html +0 -0
- {labnotes-0.0.9 → labnotes-0.0.11}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: labnotes
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.11
|
|
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>
|
|
@@ -46,3 +46,22 @@ through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
|
|
|
46
46
|
`--ssl-*` set, and the rest. `labnotes --help` lists them. The folders can also
|
|
47
47
|
come from `LABNOTES_EXPERIMENTS_FOLDER` and `LABNOTES_DATA_FOLDER`. `--json`
|
|
48
48
|
prints the experiments as JSON and exits.
|
|
49
|
+
|
|
50
|
+
## From the command line
|
|
51
|
+
|
|
52
|
+
Name an experiment to print what has run for it as JSON. Add SQL to query
|
|
53
|
+
that experiment's runs: `runs` holds only its runs, `main.runs` every run.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uvx labnotes models-effort "SELECT model, COUNT(*) AS n FROM runs GROUP BY 1" \
|
|
57
|
+
--experiments-folder experiments --data-folder data
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Agent skill
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
npx skills add thekevinscott/labnotes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The skill tells a coding agent to run `labnotes context`, which prints a usage
|
|
67
|
+
guide for the installed version.
|
|
@@ -27,3 +27,22 @@ through: `--reload`, `--workers`, `--proxy-headers`, `--root-path`, the
|
|
|
27
27
|
`--ssl-*` set, and the rest. `labnotes --help` lists them. The folders can also
|
|
28
28
|
come from `LABNOTES_EXPERIMENTS_FOLDER` and `LABNOTES_DATA_FOLDER`. `--json`
|
|
29
29
|
prints the experiments as JSON and exits.
|
|
30
|
+
|
|
31
|
+
## From the command line
|
|
32
|
+
|
|
33
|
+
Name an experiment to print what has run for it as JSON. Add SQL to query
|
|
34
|
+
that experiment's runs: `runs` holds only its runs, `main.runs` every run.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uvx labnotes models-effort "SELECT model, COUNT(*) AS n FROM runs GROUP BY 1" \
|
|
38
|
+
--experiments-folder experiments --data-folder data
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Agent skill
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx skills add thekevinscott/labnotes
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The skill tells a coding agent to run `labnotes context`, which prints a usage
|
|
48
|
+
guide for the installed version.
|
|
@@ -6,29 +6,43 @@ from pathlib import Path
|
|
|
6
6
|
import click
|
|
7
7
|
from uvicorn.main import main as uvicorn_main
|
|
8
8
|
|
|
9
|
+
from labnotes.context_guide import context_guide
|
|
9
10
|
from labnotes.dump_experiments import dump_experiments
|
|
10
|
-
from labnotes.
|
|
11
|
+
from labnotes.query_experiment import query_experiment
|
|
12
|
+
from labnotes.serve_app import DATA_FOLDER, EXPERIMENTS_FOLDER, KERNEL_IDLE_MINUTES
|
|
11
13
|
|
|
12
14
|
# labnotes owns these; the rest of uvicorn's options pass straight through.
|
|
13
15
|
OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
@click.command(
|
|
18
|
+
@click.command(
|
|
19
|
+
help="Browse experiments and their run data. With EXPERIMENT, print what has"
|
|
20
|
+
" run as JSON; with SQL too, query that experiment's runs. `labnotes context`"
|
|
21
|
+
" prints a guide for coding agents."
|
|
22
|
+
)
|
|
17
23
|
@click.version_option(package_name="labnotes")
|
|
24
|
+
@click.argument("experiment", required=False)
|
|
25
|
+
@click.argument("sql", required=False)
|
|
18
26
|
@click.option(
|
|
19
27
|
"--experiments-folder",
|
|
20
28
|
type=click.Path(path_type=Path),
|
|
21
|
-
required=True,
|
|
22
29
|
envvar=EXPERIMENTS_FOLDER,
|
|
23
30
|
help="One subfolder per experiment, each with a design.yaml and markdown notes.",
|
|
24
31
|
)
|
|
25
32
|
@click.option(
|
|
26
33
|
"--data-folder",
|
|
27
34
|
type=click.Path(path_type=Path),
|
|
28
|
-
required=True,
|
|
29
35
|
envvar=DATA_FOLDER,
|
|
30
36
|
help="Run data, with a .dirsql.toml declaring a runs table that has a study column.",
|
|
31
37
|
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--kernel-idle-minutes",
|
|
40
|
+
type=click.IntRange(min=1),
|
|
41
|
+
default=30,
|
|
42
|
+
show_default=True,
|
|
43
|
+
envvar=KERNEL_IDLE_MINUTES,
|
|
44
|
+
help="Stop a note's Python kernel after this long without a run.",
|
|
45
|
+
)
|
|
32
46
|
@click.option(
|
|
33
47
|
"--json",
|
|
34
48
|
"as_json",
|
|
@@ -46,11 +60,28 @@ OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
|
|
|
46
60
|
@click.pass_context
|
|
47
61
|
def main(
|
|
48
62
|
ctx: click.Context,
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
experiment: str | None,
|
|
64
|
+
sql: str | None,
|
|
65
|
+
experiments_folder: Path | None,
|
|
66
|
+
data_folder: Path | None,
|
|
67
|
+
kernel_idle_minutes: int,
|
|
51
68
|
as_json: bool,
|
|
52
69
|
**server_options: object,
|
|
53
70
|
) -> None:
|
|
71
|
+
if experiment == "context" and sql is None:
|
|
72
|
+
click.echo(context_guide(), nl=False)
|
|
73
|
+
return
|
|
74
|
+
if experiments_folder is None or data_folder is None:
|
|
75
|
+
raise click.UsageError("--experiments-folder and --data-folder are required.")
|
|
76
|
+
if experiment is not None:
|
|
77
|
+
try:
|
|
78
|
+
result = asyncio.run(
|
|
79
|
+
query_experiment(experiments_folder, data_folder, experiment, sql)
|
|
80
|
+
)
|
|
81
|
+
except (LookupError, RuntimeError) as error:
|
|
82
|
+
raise click.ClickException(str(error)) from error
|
|
83
|
+
click.echo(json.dumps(result, indent=2))
|
|
84
|
+
return
|
|
54
85
|
if as_json:
|
|
55
86
|
experiments = asyncio.run(dump_experiments(experiments_folder, data_folder))
|
|
56
87
|
click.echo(json.dumps(experiments, indent=2))
|
|
@@ -58,6 +89,7 @@ def main(
|
|
|
58
89
|
# Through the environment so --reload and --workers child processes see them.
|
|
59
90
|
os.environ[EXPERIMENTS_FOLDER] = str(experiments_folder.resolve())
|
|
60
91
|
os.environ[DATA_FOLDER] = str(data_folder.resolve())
|
|
92
|
+
os.environ[KERNEL_IDLE_MINUTES] = str(kernel_idle_minutes)
|
|
61
93
|
ctx.invoke(
|
|
62
94
|
uvicorn_main,
|
|
63
95
|
app="labnotes.serve_app:serve_app",
|
|
@@ -0,0 +1,27 @@
|
|
|
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. Pass both on every call, or set `LABNOTES_EXPERIMENTS_FOLDER` and `LABNOTES_DATA_FOLDER`:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
uvx labnotes --experiments-folder experiments --data-folder data --json
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`--json` lists every experiment: its name, study, the design's fixed and varied conditions, the target run count, and how many runs have run and completed, overall and per cell.
|
|
10
|
+
|
|
11
|
+
### One experiment
|
|
12
|
+
|
|
13
|
+
Name an experiment to see what has run for it, as JSON:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
uvx labnotes models-effort --experiments-folder experiments --data-folder data
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add SQL to query its runs. `runs` holds only that experiment's runs, matched on the study in its `design.yaml`; `main.runs` holds every run in the data folder. The query goes through [dirsql](https://dirsql.dev), so it is SQLite and read-only:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
uvx labnotes models-effort "SELECT model, effort, COUNT(*) AS n FROM runs GROUP BY 1, 2" --experiments-folder experiments --data-folder data
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
stdout is one JSON array of row objects. The columns are whatever the data folder's `.dirsql.toml` declares; `SELECT * FROM runs LIMIT 1` shows them. A run counts as completed when the design's `completed_run` SQL expression is true; the one-experiment JSON prints it, so reuse it in a `WHERE`.
|
|
26
|
+
|
|
27
|
+
An unknown experiment, an experiment without a `study`, or bad SQL prints an error to stderr and exits `1`. An experiment named `context` cannot be queried this way.
|
|
@@ -24,6 +24,7 @@ from labnotes.pivot_cells import pivot_cells
|
|
|
24
24
|
from labnotes.python_blocks import python_blocks
|
|
25
25
|
from labnotes.saved_outputs import saved_outputs
|
|
26
26
|
from labnotes.sql_quote import quote_string
|
|
27
|
+
from labnotes.stop_idle_kernels import stop_idle_kernels
|
|
27
28
|
|
|
28
29
|
HERE = Path(__file__).parent
|
|
29
30
|
templates = Jinja2Templates(directory=HERE / "templates")
|
|
@@ -31,7 +32,9 @@ templates.env.filters["value"] = format_value
|
|
|
31
32
|
templates.env.globals["asset"] = asset_url
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def create_app(
|
|
35
|
+
def create_app(
|
|
36
|
+
experiments_folder: Path, data_folder: Path, kernel_idle_minutes: float
|
|
37
|
+
) -> Starlette:
|
|
35
38
|
kernels: dict[tuple[str, str], NotebookKernel] = {}
|
|
36
39
|
|
|
37
40
|
@asynccontextmanager
|
|
@@ -43,6 +46,7 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
|
43
46
|
watchers = [
|
|
44
47
|
asyncio.create_task(drain_watch(app.state.experiments)),
|
|
45
48
|
asyncio.create_task(drain_watch(app.state.runs)),
|
|
49
|
+
asyncio.create_task(stop_idle_kernels(kernels, kernel_idle_minutes)),
|
|
46
50
|
]
|
|
47
51
|
yield
|
|
48
52
|
for watcher in watchers:
|
|
@@ -79,7 +83,7 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
|
79
83
|
{"experiment": loaded, "pivot": pivot_cells(varies, cells)},
|
|
80
84
|
)
|
|
81
85
|
|
|
82
|
-
async def
|
|
86
|
+
async def note_content(request: Request) -> str:
|
|
83
87
|
if request.headers.get("content-type") != "application/json":
|
|
84
88
|
raise HTTPException(415, "Send JSON")
|
|
85
89
|
name = request.path_params["name"]
|
|
@@ -91,19 +95,35 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
|
91
95
|
if not found:
|
|
92
96
|
raise HTTPException(404, "No such note")
|
|
93
97
|
[row] = found
|
|
94
|
-
|
|
98
|
+
return str(row["content"])
|
|
99
|
+
|
|
100
|
+
async def run_block(request: Request) -> Response:
|
|
101
|
+
blocks = python_blocks(await note_content(request))
|
|
102
|
+
key = (request.path_params["name"], request.path_params["note"])
|
|
95
103
|
block = (await request.json()).get("block")
|
|
96
104
|
if not isinstance(block, int) or not 0 <= block < len(blocks):
|
|
97
105
|
raise HTTPException(400, "No such block")
|
|
98
|
-
if
|
|
99
|
-
kernels[
|
|
100
|
-
return HTMLResponse(await kernels[
|
|
106
|
+
if key not in kernels:
|
|
107
|
+
kernels[key] = NotebookKernel(data_folder)
|
|
108
|
+
return HTMLResponse(await kernels[key].run(blocks[block]))
|
|
109
|
+
|
|
110
|
+
async def restart_kernel(request: Request) -> Response:
|
|
111
|
+
await note_content(request)
|
|
112
|
+
kernel = kernels.get((request.path_params["name"], request.path_params["note"]))
|
|
113
|
+
if kernel is not None:
|
|
114
|
+
await kernel.restart()
|
|
115
|
+
return Response(status_code=204)
|
|
101
116
|
|
|
102
117
|
return Starlette(
|
|
103
118
|
routes=[
|
|
104
119
|
Route("/", index),
|
|
105
120
|
Route("/experiments/{name}", experiment),
|
|
106
121
|
Route("/experiments/{name}/notes/{note}/run", run_block, methods=["POST"]),
|
|
122
|
+
Route(
|
|
123
|
+
"/experiments/{name}/notes/{note}/restart",
|
|
124
|
+
restart_kernel,
|
|
125
|
+
methods=["POST"],
|
|
126
|
+
),
|
|
107
127
|
Mount("/static", StaticFiles(directory=HERE / "static"), name="static"),
|
|
108
128
|
],
|
|
109
129
|
lifespan=lifespan,
|
|
@@ -6,6 +6,7 @@ from dirsql import DirSQL
|
|
|
6
6
|
|
|
7
7
|
from labnotes.list_runs import list_runs
|
|
8
8
|
from labnotes.parse_run_view import parse_run_view
|
|
9
|
+
from labnotes.python_blocks import python_blocks
|
|
9
10
|
from labnotes.render_markdown import render_markdown
|
|
10
11
|
from labnotes.run_columns import run_columns
|
|
11
12
|
from labnotes.sort_href import sort_href
|
|
@@ -59,6 +60,7 @@ async def load_experiment(
|
|
|
59
60
|
str(note["content"]),
|
|
60
61
|
outputs_for(str(note["name"]), str(note["content"])),
|
|
61
62
|
),
|
|
63
|
+
"runnable": bool(python_blocks(str(note["content"]))),
|
|
62
64
|
}
|
|
63
65
|
for note in notes
|
|
64
66
|
],
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from time import monotonic
|
|
5
|
+
|
|
6
|
+
from jupyter_client.asynchronous.client import AsyncKernelClient
|
|
7
|
+
from jupyter_client.manager import AsyncKernelManager
|
|
8
|
+
|
|
9
|
+
from labnotes.output_html import output_html
|
|
10
|
+
|
|
11
|
+
QUIET = {"status", "execute_input"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def block_key(code: str) -> str:
|
|
15
|
+
return hashlib.sha256(code.encode()).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotebookKernel:
|
|
19
|
+
"""One live Python kernel per notes file; blocks run only when asked."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, cwd: Path) -> None:
|
|
22
|
+
self.cwd = cwd
|
|
23
|
+
self.outputs: dict[str, str] = {}
|
|
24
|
+
self._lock = asyncio.Lock()
|
|
25
|
+
self._manager: AsyncKernelManager | None = None
|
|
26
|
+
self._client: AsyncKernelClient | None = None
|
|
27
|
+
self.last_used = monotonic()
|
|
28
|
+
|
|
29
|
+
async def _start(self) -> None:
|
|
30
|
+
self._manager = AsyncKernelManager(kernel_name="python3")
|
|
31
|
+
await self._manager.start_kernel(cwd=str(self.cwd))
|
|
32
|
+
self._client = self._manager.client()
|
|
33
|
+
self._client.start_channels()
|
|
34
|
+
await self._client.wait_for_ready(timeout=60)
|
|
35
|
+
|
|
36
|
+
async def run(self, code: str) -> str:
|
|
37
|
+
async with self._lock:
|
|
38
|
+
try:
|
|
39
|
+
if self._client is None:
|
|
40
|
+
await self._start()
|
|
41
|
+
assert self._client is not None
|
|
42
|
+
messages: list[dict[str, object]] = []
|
|
43
|
+
await self._client.execute_interactive(
|
|
44
|
+
code,
|
|
45
|
+
output_hook=lambda message: messages.append(message),
|
|
46
|
+
allow_stdin=False,
|
|
47
|
+
)
|
|
48
|
+
html = output_html(
|
|
49
|
+
[
|
|
50
|
+
{"msg_type": message["msg_type"], "content": message["content"]}
|
|
51
|
+
for message in messages
|
|
52
|
+
if message["msg_type"] not in QUIET
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
self.outputs[block_key(code)] = html
|
|
56
|
+
return html
|
|
57
|
+
finally:
|
|
58
|
+
self.last_used = monotonic()
|
|
59
|
+
|
|
60
|
+
async def stop(self) -> None:
|
|
61
|
+
client, manager = self._client, self._manager
|
|
62
|
+
self._client = self._manager = None
|
|
63
|
+
if client is not None:
|
|
64
|
+
client.stop_channels()
|
|
65
|
+
if manager is not None:
|
|
66
|
+
await manager.shutdown_kernel(now=True)
|
|
67
|
+
|
|
68
|
+
async def restart(self) -> None:
|
|
69
|
+
async with self._lock:
|
|
70
|
+
await self.stop()
|
|
71
|
+
|
|
72
|
+
async def stop_if_idle(self, idle_seconds: float) -> None:
|
|
73
|
+
if not self._lock.locked() and monotonic() - self.last_used > idle_seconds:
|
|
74
|
+
await self.stop()
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
from labnotes.scope_runs import scope_runs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def query_experiment(
|
|
10
|
+
experiments_folder: Path, data_folder: Path, name: str, sql: str | None
|
|
11
|
+
) -> object:
|
|
12
|
+
experiments = open_experiments(experiments_folder)
|
|
13
|
+
runs = open_runs(data_folder)
|
|
14
|
+
await experiments.ready()
|
|
15
|
+
await runs.ready()
|
|
16
|
+
listed = await list_experiments(experiments, runs)
|
|
17
|
+
experiment = next((row for row in listed if row["name"] == name), None)
|
|
18
|
+
if experiment is None:
|
|
19
|
+
raise LookupError(f"no experiment named {name!r}")
|
|
20
|
+
if sql is None:
|
|
21
|
+
return experiment
|
|
22
|
+
if experiment["study"] is None:
|
|
23
|
+
raise LookupError(f"{name!r} has no study in its design.yaml")
|
|
24
|
+
return await runs.query(scope_runs(sql, str(experiment["study"])))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from labnotes.sql_quote import quote_string
|
|
4
|
+
|
|
5
|
+
LEADING_WITH = re.compile(r"\s*WITH(\s+RECURSIVE)?\s+", re.IGNORECASE)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def scope_runs(sql: str, study: str) -> str:
|
|
9
|
+
runs = f"runs AS (SELECT * FROM main.runs WHERE study = {quote_string(study)})"
|
|
10
|
+
leading_with = LEADING_WITH.match(sql)
|
|
11
|
+
if leading_with is None:
|
|
12
|
+
return f"WITH {runs} {sql}"
|
|
13
|
+
recursive = " RECURSIVE" if leading_with.group(1) else ""
|
|
14
|
+
return f"WITH{recursive} {runs}, {sql[leading_with.end() :]}"
|
|
@@ -7,9 +7,12 @@ from labnotes.create_app import create_app
|
|
|
7
7
|
|
|
8
8
|
EXPERIMENTS_FOLDER = "LABNOTES_EXPERIMENTS_FOLDER"
|
|
9
9
|
DATA_FOLDER = "LABNOTES_DATA_FOLDER"
|
|
10
|
+
KERNEL_IDLE_MINUTES = "LABNOTES_KERNEL_IDLE_MINUTES"
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def serve_app() -> Starlette:
|
|
13
14
|
return create_app(
|
|
14
|
-
Path(os.environ[EXPERIMENTS_FOLDER]),
|
|
15
|
+
Path(os.environ[EXPERIMENTS_FOLDER]),
|
|
16
|
+
Path(os.environ[DATA_FOLDER]),
|
|
17
|
+
int(os.environ[KERNEL_IDLE_MINUTES]),
|
|
15
18
|
)
|
|
@@ -414,6 +414,8 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
|
|
|
414
414
|
position: absolute;
|
|
415
415
|
top: 0.5rem;
|
|
416
416
|
right: 0.5rem;
|
|
417
|
+
}
|
|
418
|
+
.cell .run, .note-name .restart {
|
|
417
419
|
font: inherit;
|
|
418
420
|
font-family: var(--sans);
|
|
419
421
|
font-size: 0.75rem;
|
|
@@ -424,8 +426,9 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
|
|
|
424
426
|
color: var(--ink-2);
|
|
425
427
|
cursor: pointer;
|
|
426
428
|
}
|
|
427
|
-
.cell .run:hover { color: var(--accent); border-color: var(--accent); }
|
|
428
|
-
.cell .run:disabled { cursor: progress; opacity: 0.6; }
|
|
429
|
+
.cell .run:hover, .note-name .restart:hover { color: var(--accent); border-color: var(--accent); }
|
|
430
|
+
.cell .run:disabled, .note-name .restart:disabled { cursor: progress; opacity: 0.6; }
|
|
431
|
+
.note-name:has(.restart) { display: flex; align-items: center; justify-content: space-between; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
|
429
432
|
.cell-output { display: block; }
|
|
430
433
|
.cell-output:not(:empty) { margin-top: 0.4rem; padding-left: 0.9rem; border-left: 2px solid var(--accent-soft); overflow-x: auto; }
|
|
431
434
|
.cell-output pre { background: none; border: 0; padding: 0.3rem 0; }
|
|
@@ -21,3 +21,24 @@ for (const note of document.querySelectorAll(".note[data-run-url]")) {
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
for (const note of document.querySelectorAll(".note[data-restart-url]")) {
|
|
26
|
+
const button = note.querySelector(".restart");
|
|
27
|
+
if (!button) continue;
|
|
28
|
+
button.addEventListener("click", async () => {
|
|
29
|
+
button.disabled = true;
|
|
30
|
+
button.textContent = "Restarting";
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(note.dataset.restartUrl, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: "{}",
|
|
36
|
+
});
|
|
37
|
+
button.textContent = response.ok ? "Restarted" : `${response.status} ${response.statusText}`;
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
39
|
+
} finally {
|
|
40
|
+
button.disabled = false;
|
|
41
|
+
button.textContent = "Restart";
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
|
|
4
|
+
from labnotes.notebook_kernel import NotebookKernel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def stop_idle_kernels(
|
|
8
|
+
kernels: Mapping[tuple[str, str], NotebookKernel], idle_minutes: float
|
|
9
|
+
) -> None:
|
|
10
|
+
while True:
|
|
11
|
+
await asyncio.sleep(60)
|
|
12
|
+
for kernel in list(kernels.values()):
|
|
13
|
+
await kernel.stop_if_idle(idle_minutes * 60)
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
<section id="notes" class="block">
|
|
112
112
|
<h2>Notes</h2>
|
|
113
113
|
{% for note in e.notes %}
|
|
114
|
-
<article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}">
|
|
115
|
-
<header class="note-name">{{ note.name }}</header>
|
|
114
|
+
<article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}" data-restart-url="{{ url_for('restart_kernel', name=e.name, note=note.name) }}">
|
|
115
|
+
<header class="note-name">{{ note.name }}{% if note.runnable %}<button type="button" class="restart" title="Stop this note's Python kernel. The next Run starts a fresh one.">Restart</button>{% endif %}</header>
|
|
116
116
|
<div class="prose">{{ note.html | safe }}</div>
|
|
117
117
|
</article>
|
|
118
118
|
{% endfor %}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import hashlib
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from jupyter_client.asynchronous.client import AsyncKernelClient
|
|
6
|
-
from jupyter_client.manager import AsyncKernelManager
|
|
7
|
-
|
|
8
|
-
from labnotes.output_html import output_html
|
|
9
|
-
|
|
10
|
-
QUIET = {"status", "execute_input"}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def block_key(code: str) -> str:
|
|
14
|
-
return hashlib.sha256(code.encode()).hexdigest()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class NotebookKernel:
|
|
18
|
-
"""One live Python kernel per notes file; blocks run only when asked."""
|
|
19
|
-
|
|
20
|
-
def __init__(self, cwd: Path) -> None:
|
|
21
|
-
self.cwd = cwd
|
|
22
|
-
self.outputs: dict[str, str] = {}
|
|
23
|
-
self._lock = asyncio.Lock()
|
|
24
|
-
self._manager: AsyncKernelManager | None = None
|
|
25
|
-
self._client: AsyncKernelClient | None = None
|
|
26
|
-
|
|
27
|
-
async def _start(self) -> None:
|
|
28
|
-
self._manager = AsyncKernelManager(kernel_name="python3")
|
|
29
|
-
await self._manager.start_kernel(cwd=str(self.cwd))
|
|
30
|
-
self._client = self._manager.client()
|
|
31
|
-
self._client.start_channels()
|
|
32
|
-
await self._client.wait_for_ready(timeout=60)
|
|
33
|
-
|
|
34
|
-
async def run(self, code: str) -> str:
|
|
35
|
-
async with self._lock:
|
|
36
|
-
if self._client is None:
|
|
37
|
-
await self._start()
|
|
38
|
-
assert self._client is not None
|
|
39
|
-
messages: list[dict[str, object]] = []
|
|
40
|
-
await self._client.execute_interactive(
|
|
41
|
-
code,
|
|
42
|
-
output_hook=lambda message: messages.append(message),
|
|
43
|
-
allow_stdin=False,
|
|
44
|
-
)
|
|
45
|
-
html = output_html(
|
|
46
|
-
[
|
|
47
|
-
{"msg_type": message["msg_type"], "content": message["content"]}
|
|
48
|
-
for message in messages
|
|
49
|
-
if message["msg_type"] not in QUIET
|
|
50
|
-
]
|
|
51
|
-
)
|
|
52
|
-
self.outputs[block_key(code)] = html
|
|
53
|
-
return html
|
|
54
|
-
|
|
55
|
-
async def stop(self) -> None:
|
|
56
|
-
if self._client is not None:
|
|
57
|
-
self._client.stop_channels()
|
|
58
|
-
if self._manager is not None:
|
|
59
|
-
await self._manager.shutdown_kernel(now=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|