labnotes 0.0.8__tar.gz → 0.0.10__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 (48) hide show
  1. {labnotes-0.0.8 → labnotes-0.0.10}/PKG-INFO +3 -1
  2. {labnotes-0.0.8 → labnotes-0.0.10}/pyproject.toml +2 -0
  3. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/cli.py +11 -1
  4. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/create_app.py +54 -3
  5. labnotes-0.0.10/src/labnotes/data_html.py +12 -0
  6. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/load_experiment.py +19 -3
  7. labnotes-0.0.10/src/labnotes/notebook_kernel.py +74 -0
  8. labnotes-0.0.10/src/labnotes/output_html.py +25 -0
  9. labnotes-0.0.10/src/labnotes/python_blocks.py +12 -0
  10. labnotes-0.0.10/src/labnotes/render_markdown.py +40 -0
  11. labnotes-0.0.10/src/labnotes/saved_outputs.py +8 -0
  12. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/serve_app.py +4 -1
  13. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/static/labnotes.css +28 -0
  14. labnotes-0.0.10/src/labnotes/static/run-cell.js +44 -0
  15. labnotes-0.0.10/src/labnotes/stop_idle_kernels.py +13 -0
  16. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/templates/experiment.html +3 -2
  17. {labnotes-0.0.8 → labnotes-0.0.10}/uv.lock +511 -0
  18. labnotes-0.0.8/src/labnotes/render_markdown.py +0 -9
  19. {labnotes-0.0.8 → labnotes-0.0.10}/.gitignore +0 -0
  20. {labnotes-0.0.8 → labnotes-0.0.10}/CHANGELOG.md +0 -0
  21. {labnotes-0.0.8 → labnotes-0.0.10}/MIGRATIONS.md +0 -0
  22. {labnotes-0.0.8 → labnotes-0.0.10}/README.md +0 -0
  23. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/__init__.py +0 -0
  24. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/asset_url.py +0 -0
  25. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/cell_progress.py +0 -0
  26. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/count_runs.py +0 -0
  27. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/drain_watch.py +0 -0
  28. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/dump_experiments.py +0 -0
  29. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/format_value.py +0 -0
  30. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/list_experiments.py +0 -0
  31. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/list_runs.py +0 -0
  32. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/models.py +0 -0
  33. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/open_experiments.py +0 -0
  34. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/open_runs.py +0 -0
  35. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/parse_run_view.py +0 -0
  36. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/pivot_cells.py +0 -0
  37. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/read_design.py +0 -0
  38. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/read_note.py +0 -0
  39. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/run_columns.py +0 -0
  40. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/sort_href.py +0 -0
  41. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/sql_quote.py +0 -0
  42. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/static/row-link.js +0 -0
  43. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/static/view-toggle.js +0 -0
  44. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/study_progress.py +0 -0
  45. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/summarize_design.py +0 -0
  46. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/templates/base.html +0 -0
  47. {labnotes-0.0.8 → labnotes-0.0.10}/src/labnotes/templates/index.html +0 -0
  48. {labnotes-0.0.8 → labnotes-0.0.10}/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.8
3
+ Version: 0.0.10
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -8,7 +8,9 @@ License-Expression: MIT
8
8
  Requires-Python: >=3.12
9
9
  Requires-Dist: click>=8.5.0
10
10
  Requires-Dist: dirsql>=0.4.110
11
+ Requires-Dist: ipykernel>=7.4.0
11
12
  Requires-Dist: jinja2>=3.1.6
13
+ Requires-Dist: jupyter-client>=8.10.0
12
14
  Requires-Dist: markdown-it-py>=4.2.0
13
15
  Requires-Dist: pyyaml>=6.0.3
14
16
  Requires-Dist: starlette>=1.8.0
@@ -13,7 +13,9 @@ authors = [{ name = "Kevin Scott", email = "me@thekevinscott.com" }]
13
13
  dependencies = [
14
14
  "click>=8.5.0",
15
15
  "dirsql>=0.4.110",
16
+ "ipykernel>=7.4.0",
16
17
  "jinja2>=3.1.6",
18
+ "jupyter-client>=8.10.0",
17
19
  "markdown-it-py>=4.2.0",
18
20
  "pyyaml>=6.0.3",
19
21
  "starlette>=1.8.0",
@@ -7,7 +7,7 @@ import click
7
7
  from uvicorn.main import main as uvicorn_main
8
8
 
9
9
  from labnotes.dump_experiments import dump_experiments
10
- from labnotes.serve_app import DATA_FOLDER, EXPERIMENTS_FOLDER
10
+ from labnotes.serve_app import DATA_FOLDER, EXPERIMENTS_FOLDER, KERNEL_IDLE_MINUTES
11
11
 
12
12
  # labnotes owns these; the rest of uvicorn's options pass straight through.
13
13
  OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
@@ -29,6 +29,14 @@ OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
29
29
  envvar=DATA_FOLDER,
30
30
  help="Run data, with a .dirsql.toml declaring a runs table that has a study column.",
31
31
  )
32
+ @click.option(
33
+ "--kernel-idle-minutes",
34
+ type=click.IntRange(min=1),
35
+ default=30,
36
+ show_default=True,
37
+ envvar=KERNEL_IDLE_MINUTES,
38
+ help="Stop a note's Python kernel after this long without a run.",
39
+ )
32
40
  @click.option(
33
41
  "--json",
34
42
  "as_json",
@@ -48,6 +56,7 @@ def main(
48
56
  ctx: click.Context,
49
57
  experiments_folder: Path,
50
58
  data_folder: Path,
59
+ kernel_idle_minutes: int,
51
60
  as_json: bool,
52
61
  **server_options: object,
53
62
  ) -> None:
@@ -58,6 +67,7 @@ def main(
58
67
  # Through the environment so --reload and --workers child processes see them.
59
68
  os.environ[EXPERIMENTS_FOLDER] = str(experiments_folder.resolve())
60
69
  os.environ[DATA_FOLDER] = str(data_folder.resolve())
70
+ os.environ[KERNEL_IDLE_MINUTES] = str(kernel_idle_minutes)
61
71
  ctx.invoke(
62
72
  uvicorn_main,
63
73
  app="labnotes.serve_app:serve_app",
@@ -7,7 +7,7 @@ from typing import cast
7
7
  from starlette.applications import Starlette
8
8
  from starlette.exceptions import HTTPException
9
9
  from starlette.requests import Request
10
- from starlette.responses import Response
10
+ from starlette.responses import HTMLResponse, Response
11
11
  from starlette.routing import Mount, Route
12
12
  from starlette.staticfiles import StaticFiles
13
13
  from starlette.templating import Jinja2Templates
@@ -17,9 +17,14 @@ from labnotes.drain_watch import drain_watch
17
17
  from labnotes.format_value import format_value
18
18
  from labnotes.list_experiments import list_experiments
19
19
  from labnotes.load_experiment import load_experiment
20
+ from labnotes.notebook_kernel import NotebookKernel
20
21
  from labnotes.open_experiments import open_experiments
21
22
  from labnotes.open_runs import open_runs
22
23
  from labnotes.pivot_cells import pivot_cells
24
+ from labnotes.python_blocks import python_blocks
25
+ from labnotes.saved_outputs import saved_outputs
26
+ from labnotes.sql_quote import quote_string
27
+ from labnotes.stop_idle_kernels import stop_idle_kernels
23
28
 
24
29
  HERE = Path(__file__).parent
25
30
  templates = Jinja2Templates(directory=HERE / "templates")
@@ -27,7 +32,11 @@ templates.env.filters["value"] = format_value
27
32
  templates.env.globals["asset"] = asset_url
28
33
 
29
34
 
30
- def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
35
+ def create_app(
36
+ experiments_folder: Path, data_folder: Path, kernel_idle_minutes: float
37
+ ) -> Starlette:
38
+ kernels: dict[tuple[str, str], NotebookKernel] = {}
39
+
31
40
  @asynccontextmanager
32
41
  async def lifespan(app: Starlette) -> AsyncIterator[None]:
33
42
  app.state.experiments = open_experiments(experiments_folder)
@@ -37,10 +46,13 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
37
46
  watchers = [
38
47
  asyncio.create_task(drain_watch(app.state.experiments)),
39
48
  asyncio.create_task(drain_watch(app.state.runs)),
49
+ asyncio.create_task(stop_idle_kernels(kernels, kernel_idle_minutes)),
40
50
  ]
41
51
  yield
42
52
  for watcher in watchers:
43
53
  watcher.cancel()
54
+ for kernel in kernels.values():
55
+ await kernel.stop()
44
56
 
45
57
  async def index(request: Request) -> Response:
46
58
  state = request.app.state
@@ -53,11 +65,13 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
53
65
 
54
66
  async def experiment(request: Request) -> Response:
55
67
  state = request.app.state
68
+ name = request.path_params["name"]
56
69
  loaded = await load_experiment(
57
70
  state.experiments,
58
71
  state.runs,
59
- request.path_params["name"],
72
+ name,
60
73
  request.query_params,
74
+ lambda note, content: saved_outputs(kernels.get((name, note)), content),
61
75
  )
62
76
  if loaded is None:
63
77
  raise HTTPException(404, "No such experiment")
@@ -69,10 +83,47 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
69
83
  {"experiment": loaded, "pivot": pivot_cells(varies, cells)},
70
84
  )
71
85
 
86
+ async def note_content(request: Request) -> str:
87
+ if request.headers.get("content-type") != "application/json":
88
+ raise HTTPException(415, "Send JSON")
89
+ name = request.path_params["name"]
90
+ note = request.path_params["note"]
91
+ found = await request.app.state.experiments.query(
92
+ "SELECT content FROM notes"
93
+ f" WHERE experiment = {quote_string(name)} AND name = {quote_string(note)}"
94
+ )
95
+ if not found:
96
+ raise HTTPException(404, "No such note")
97
+ [row] = found
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"])
103
+ block = (await request.json()).get("block")
104
+ if not isinstance(block, int) or not 0 <= block < len(blocks):
105
+ raise HTTPException(400, "No such block")
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)
116
+
72
117
  return Starlette(
73
118
  routes=[
74
119
  Route("/", index),
75
120
  Route("/experiments/{name}", experiment),
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
+ ),
76
127
  Mount("/static", StaticFiles(directory=HERE / "static"), name="static"),
77
128
  ],
78
129
  lifespan=lifespan,
@@ -0,0 +1,12 @@
1
+ from collections.abc import Mapping
2
+ from html import escape
3
+
4
+
5
+ def data_html(data: Mapping[str, str]) -> str:
6
+ # Rich output is trusted: it comes from code in the user's own notes.
7
+ for mime in ("text/html", "image/svg+xml"):
8
+ if mime in data:
9
+ return data[mime]
10
+ if "image/png" in data:
11
+ return f'<img alt="" src="data:image/png;base64,{data["image/png"]}">'
12
+ return f"<pre>{escape(data.get('text/plain', ''))}</pre>"
@@ -1,11 +1,12 @@
1
1
  import json
2
- from collections.abc import Mapping
2
+ from collections.abc import Callable, Mapping, Sequence
3
3
  from typing import cast
4
4
 
5
5
  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
@@ -14,8 +15,16 @@ from labnotes.study_progress import study_progress
14
15
  from labnotes.summarize_design import summarize_design
15
16
 
16
17
 
18
+ def _no_outputs(note: str, content: str) -> Sequence[str]:
19
+ return ()
20
+
21
+
17
22
  async def load_experiment(
18
- experiments: DirSQL, runs: DirSQL, name: str, params: Mapping[str, str]
23
+ experiments: DirSQL,
24
+ runs: DirSQL,
25
+ name: str,
26
+ params: Mapping[str, str],
27
+ outputs_for: Callable[[str, str], Sequence[str]] = _no_outputs,
19
28
  ) -> dict[str, object] | None:
20
29
  found = await experiments.query(
21
30
  f"SELECT name, study, design FROM experiments WHERE name = {quote_string(name)}"
@@ -45,7 +54,14 @@ async def load_experiment(
45
54
  **summary,
46
55
  **progress,
47
56
  "notes": [
48
- {"name": note["name"], "html": render_markdown(str(note["content"]))}
57
+ {
58
+ "name": note["name"],
59
+ "html": render_markdown(
60
+ str(note["content"]),
61
+ outputs_for(str(note["name"]), str(note["content"])),
62
+ ),
63
+ "runnable": bool(python_blocks(str(note["content"]))),
64
+ }
49
65
  for note in notes
50
66
  ],
51
67
  "runs_list": listed,
@@ -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,25 @@
1
+ import re
2
+ from collections.abc import Mapping, Sequence
3
+ from html import escape
4
+
5
+ from labnotes.data_html import data_html
6
+
7
+ ANSI = re.compile(r"\x1b\[[0-9;]*m")
8
+
9
+
10
+ def output_html(outputs: Sequence[Mapping[str, object]]) -> str:
11
+ parts = []
12
+ for output in outputs:
13
+ kind = output["msg_type"]
14
+ content = output["content"]
15
+ assert isinstance(content, Mapping)
16
+ if kind == "stream":
17
+ parts.append(
18
+ f'<pre class="{escape(content["name"])}">{escape(content["text"])}</pre>'
19
+ )
20
+ elif kind in ("execute_result", "display_data"):
21
+ parts.append(data_html(content["data"]))
22
+ elif kind == "error":
23
+ trace = ANSI.sub("", "\n".join(content["traceback"]))
24
+ parts.append(f'<pre class="error">{escape(trace)}</pre>')
25
+ return "".join(parts)
@@ -0,0 +1,12 @@
1
+ from markdown_it import MarkdownIt
2
+ from markdown_it.token import Token
3
+
4
+ _parser = MarkdownIt("commonmark")
5
+
6
+
7
+ def is_python(token: Token) -> bool:
8
+ return token.type == "fence" and token.info.split()[:1] == ["python"]
9
+
10
+
11
+ def python_blocks(text: str) -> list[str]:
12
+ return [token.content for token in _parser.parse(text) if is_python(token)]
@@ -0,0 +1,40 @@
1
+ from collections.abc import MutableMapping, Sequence
2
+ from typing import Any
3
+
4
+ from markdown_it import MarkdownIt
5
+ from markdown_it.renderer import RendererHTML
6
+ from markdown_it.token import Token
7
+
8
+ from labnotes.python_blocks import is_python
9
+
10
+ _markdown = MarkdownIt("commonmark", {"html": False, "typographer": True}).enable(
11
+ ["table", "strikethrough", "replacements", "smartquotes"]
12
+ )
13
+
14
+
15
+ def _fence(
16
+ self: RendererHTML,
17
+ tokens: Sequence[Token],
18
+ idx: int,
19
+ options: Any,
20
+ env: MutableMapping[str, Any],
21
+ ) -> str:
22
+ code = RendererHTML.fence(self, tokens, idx, options, env)
23
+ if not is_python(tokens[idx]):
24
+ return code
25
+ block = env["blocks"]
26
+ env["blocks"] = block + 1
27
+ outputs = env["outputs"]
28
+ shown = outputs[block] if block < len(outputs) else ""
29
+ return (
30
+ f'<div class="cell" data-block="{block}">{code}'
31
+ '<button type="button" class="run">Run</button>'
32
+ f'<output class="cell-output">{shown}</output></div>\n'
33
+ )
34
+
35
+
36
+ _markdown.add_render_rule("fence", _fence)
37
+
38
+
39
+ def render_markdown(text: str, outputs: Sequence[str] = ()) -> str:
40
+ return _markdown.render(text, {"blocks": 0, "outputs": outputs})
@@ -0,0 +1,8 @@
1
+ from labnotes.notebook_kernel import NotebookKernel, block_key
2
+ from labnotes.python_blocks import python_blocks
3
+
4
+
5
+ def saved_outputs(kernel: NotebookKernel | None, content: str) -> list[str]:
6
+ if kernel is None:
7
+ return []
8
+ return [kernel.outputs.get(block_key(code), "") for code in python_blocks(content)]
@@ -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]), Path(os.environ[DATA_FOLDER])
15
+ Path(os.environ[EXPERIMENTS_FOLDER]),
16
+ Path(os.environ[DATA_FOLDER]),
17
+ int(os.environ[KERNEL_IDLE_MINUTES]),
15
18
  )
@@ -406,3 +406,31 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
406
406
  .ledger .design { display: none; }
407
407
  .progress-col { width: auto; }
408
408
  }
409
+
410
+ /* Runnable python blocks in notes */
411
+ .cell { position: relative; margin: 1em 0; }
412
+ .cell pre { margin: 0; }
413
+ .cell .run {
414
+ position: absolute;
415
+ top: 0.5rem;
416
+ right: 0.5rem;
417
+ }
418
+ .cell .run, .note-name .restart {
419
+ font: inherit;
420
+ font-family: var(--sans);
421
+ font-size: 0.75rem;
422
+ padding: 0.15rem 0.6rem;
423
+ border: 1px solid var(--rule-strong);
424
+ border-radius: 5px;
425
+ background: var(--surface);
426
+ color: var(--ink-2);
427
+ cursor: pointer;
428
+ }
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; }
432
+ .cell-output { display: block; }
433
+ .cell-output:not(:empty) { margin-top: 0.4rem; padding-left: 0.9rem; border-left: 2px solid var(--accent-soft); overflow-x: auto; }
434
+ .cell-output pre { background: none; border: 0; padding: 0.3rem 0; }
435
+ .cell-output .stderr, .cell-output .error { color: var(--bad); }
436
+ .cell-output img, .cell-output svg { max-width: 100%; height: auto; }
@@ -0,0 +1,44 @@
1
+ for (const note of document.querySelectorAll(".note[data-run-url]")) {
2
+ for (const cell of note.querySelectorAll(".cell")) {
3
+ const button = cell.querySelector(".run");
4
+ const output = cell.querySelector(".cell-output");
5
+ button.addEventListener("click", async () => {
6
+ button.disabled = true;
7
+ button.textContent = "Running";
8
+ try {
9
+ const response = await fetch(note.dataset.runUrl, {
10
+ method: "POST",
11
+ headers: { "Content-Type": "application/json" },
12
+ body: JSON.stringify({ block: Number(cell.dataset.block) }),
13
+ });
14
+ output.innerHTML = response.ok
15
+ ? await response.text()
16
+ : `<pre class="error">${response.status} ${response.statusText}</pre>`;
17
+ } finally {
18
+ button.disabled = false;
19
+ button.textContent = "Run";
20
+ }
21
+ });
22
+ }
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,12 +111,13 @@
111
111
  <section id="notes" class="block">
112
112
  <h2>Notes</h2>
113
113
  {% for note in e.notes %}
114
- <article class="note">
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 %}
119
119
  </section>
120
+ <script src="{{ asset('run-cell.js') }}" defer></script>
120
121
  {% endif %}
121
122
 
122
123
  {% if e.study %}