labnotes 0.0.9__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.9 → labnotes-0.0.10}/PKG-INFO +1 -1
  2. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/cli.py +11 -1
  3. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/create_app.py +26 -6
  4. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/load_experiment.py +2 -0
  5. labnotes-0.0.10/src/labnotes/notebook_kernel.py +74 -0
  6. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/serve_app.py +4 -1
  7. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/static/labnotes.css +5 -2
  8. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/static/run-cell.js +21 -0
  9. labnotes-0.0.10/src/labnotes/stop_idle_kernels.py +13 -0
  10. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/templates/experiment.html +2 -2
  11. labnotes-0.0.9/src/labnotes/notebook_kernel.py +0 -59
  12. {labnotes-0.0.9 → labnotes-0.0.10}/.gitignore +0 -0
  13. {labnotes-0.0.9 → labnotes-0.0.10}/CHANGELOG.md +0 -0
  14. {labnotes-0.0.9 → labnotes-0.0.10}/MIGRATIONS.md +0 -0
  15. {labnotes-0.0.9 → labnotes-0.0.10}/README.md +0 -0
  16. {labnotes-0.0.9 → labnotes-0.0.10}/pyproject.toml +0 -0
  17. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/__init__.py +0 -0
  18. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/asset_url.py +0 -0
  19. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/cell_progress.py +0 -0
  20. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/count_runs.py +0 -0
  21. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/data_html.py +0 -0
  22. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/drain_watch.py +0 -0
  23. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/dump_experiments.py +0 -0
  24. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/format_value.py +0 -0
  25. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/list_experiments.py +0 -0
  26. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/list_runs.py +0 -0
  27. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/models.py +0 -0
  28. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/open_experiments.py +0 -0
  29. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/open_runs.py +0 -0
  30. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/output_html.py +0 -0
  31. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/parse_run_view.py +0 -0
  32. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/pivot_cells.py +0 -0
  33. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/python_blocks.py +0 -0
  34. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/read_design.py +0 -0
  35. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/read_note.py +0 -0
  36. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/render_markdown.py +0 -0
  37. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/run_columns.py +0 -0
  38. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/saved_outputs.py +0 -0
  39. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/sort_href.py +0 -0
  40. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/sql_quote.py +0 -0
  41. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/static/row-link.js +0 -0
  42. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/static/view-toggle.js +0 -0
  43. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/study_progress.py +0 -0
  44. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/summarize_design.py +0 -0
  45. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/templates/base.html +0 -0
  46. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/templates/index.html +0 -0
  47. {labnotes-0.0.9 → labnotes-0.0.10}/src/labnotes/templates/macros.html +0 -0
  48. {labnotes-0.0.9 → labnotes-0.0.10}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.9
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>
@@ -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",
@@ -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(experiments_folder: Path, data_folder: Path) -> Starlette:
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 run_block(request: Request) -> Response:
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
- blocks = python_blocks(str(row["content"]))
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 (name, note) not in kernels:
99
- kernels[(name, note)] = NotebookKernel(data_folder)
100
- return HTMLResponse(await kernels[(name, note)].run(blocks[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)
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()
@@ -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
  )
@@ -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