labnotes 0.0.55__tar.gz → 0.0.56__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 (103) hide show
  1. {labnotes-0.0.55 → labnotes-0.0.56}/PKG-INFO +1 -1
  2. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/create_app.py +26 -4
  3. labnotes-0.0.56/src/labnotes/file_changes.py +11 -0
  4. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/note-edit.js +10 -2
  5. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/note-editor.js +58 -23
  6. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/templates/editor_imports.html +1 -0
  7. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/templates/experiment.html +1 -1
  8. {labnotes-0.0.55 → labnotes-0.0.56}/.gitignore +0 -0
  9. {labnotes-0.0.55 → labnotes-0.0.56}/CHANGELOG.md +0 -0
  10. {labnotes-0.0.55 → labnotes-0.0.56}/MIGRATIONS.md +0 -0
  11. {labnotes-0.0.55 → labnotes-0.0.56}/README.md +0 -0
  12. {labnotes-0.0.55 → labnotes-0.0.56}/pyproject.toml +0 -0
  13. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/__init__.py +0 -0
  14. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/asset_url.py +0 -0
  15. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/cell_progress.py +0 -0
  16. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/check_counts.py +0 -0
  17. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/cli.py +0 -0
  18. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/context.md +0 -0
  19. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/context_guide.py +0 -0
  20. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/count_runs.py +0 -0
  21. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/create_experiment.py +0 -0
  22. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/data_html.py +0 -0
  23. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/default_config_text.py +0 -0
  24. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/default_counts.py +0 -0
  25. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/drain_watch.py +0 -0
  26. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/experiment_notes.py +0 -0
  27. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/experiment_progress.py +0 -0
  28. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/experiment_runs.py +0 -0
  29. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/experiments.py +0 -0
  30. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/format_duration.py +0 -0
  31. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/format_value.py +0 -0
  32. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/init.py +0 -0
  33. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/is_positive_int.py +0 -0
  34. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/list_experiments.py +0 -0
  35. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/list_runs.py +0 -0
  36. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/load_config.py +0 -0
  37. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/load_experiment.py +0 -0
  38. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/models.py +0 -0
  39. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/modes.py +0 -0
  40. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/notebook_kernel.py +0 -0
  41. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/output_html.py +0 -0
  42. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/parse_run_view.py +0 -0
  43. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/print_rows.py +0 -0
  44. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/python_blocks.py +0 -0
  45. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/read_config.py +0 -0
  46. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/read_path.py +0 -0
  47. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/render_markdown.py +0 -0
  48. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/__init__.py +0 -0
  49. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/describe_event.py +0 -0
  50. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/execute_run.py +0 -0
  51. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/hold_folder.py +0 -0
  52. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/new_run_id.py +0 -0
  53. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  54. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/param_args.py +0 -0
  55. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/param_grid.py +0 -0
  56. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/plan_runs.py +0 -0
  57. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/read_experiment_config.py +0 -0
  58. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/run_experiment.py +0 -0
  59. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/run_folder.py +0 -0
  60. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/run_headless.py +0 -0
  61. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/runs_parent.py +0 -0
  62. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/shell_command.py +0 -0
  63. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/start_experiment.py +0 -0
  64. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/stop_then_kill.py +0 -0
  65. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/wait_for_group.py +0 -0
  66. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/runner/write_run_json.py +0 -0
  67. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/save_note.py +0 -0
  68. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/saved_outputs.py +0 -0
  69. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/serve_app.py +0 -0
  70. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/server.py +0 -0
  71. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/sort_href.py +0 -0
  72. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/sql_quote.py +0 -0
  73. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/experiment.js +0 -0
  74. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/labnotes.css +0 -0
  75. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/row-link.js +0 -0
  76. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/run-cell.js +0 -0
  77. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/static/view-toggle.js +0 -0
  78. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/stop_idle_kernels.py +0 -0
  79. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/summarize_config.py +0 -0
  80. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/__init__.py +0 -0
  81. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/experiment_rows.py +0 -0
  82. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/file_prefix.py +0 -0
  83. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/iso_time.py +0 -0
  84. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/list_experiment_runs.py +0 -0
  85. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/open_tables.py +0 -0
  86. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/path_glob.py +0 -0
  87. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/path_pattern.py +0 -0
  88. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/read_experiment.py +0 -0
  89. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/read_file.py +0 -0
  90. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/read_run.py +0 -0
  91. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tables/summarize_experiments.py +0 -0
  92. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/templates/base.html +0 -0
  93. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/templates/index.html +0 -0
  94. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/templates/macros.html +0 -0
  95. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tui/__init__.py +0 -0
  96. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tui/dim.py +0 -0
  97. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tui/format_elapsed.py +0 -0
  98. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tui/run_app.py +0 -0
  99. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tui/run_tui.py +0 -0
  100. {labnotes-0.0.55 → labnotes-0.0.56}/src/labnotes/tui/runs_table.py +0 -0
  101. {labnotes-0.0.55 → labnotes-0.0.56}/testing-conventions.toml +0 -0
  102. {labnotes-0.0.55 → labnotes-0.0.56}/tests/integration/conftest.py +0 -0
  103. {labnotes-0.0.55 → labnotes-0.0.56}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.55
3
+ Version: 0.0.56
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>
@@ -6,7 +6,13 @@ from pathlib import Path
6
6
  from starlette.applications import Starlette
7
7
  from starlette.exceptions import HTTPException
8
8
  from starlette.requests import Request
9
- from starlette.responses import HTMLResponse, JSONResponse, Response
9
+ from starlette.responses import (
10
+ HTMLResponse,
11
+ JSONResponse,
12
+ PlainTextResponse,
13
+ Response,
14
+ StreamingResponse,
15
+ )
10
16
  from starlette.routing import Mount, Route
11
17
  from starlette.staticfiles import StaticFiles
12
18
  from starlette.templating import Jinja2Templates
@@ -14,6 +20,7 @@ from starlette.templating import Jinja2Templates
14
20
  from labnotes.asset_url import asset_url
15
21
  from labnotes.drain_watch import drain_watch
16
22
  from labnotes.experiment_notes import experiment_notes
23
+ from labnotes.file_changes import file_changes
17
24
  from labnotes.format_duration import format_duration
18
25
  from labnotes.format_value import format_value
19
26
  from labnotes.list_experiments import list_experiments
@@ -74,9 +81,7 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
74
81
  request, "experiment.html", {"experiment": loaded}
75
82
  )
76
83
 
77
- async def find_note(request: Request) -> dict[str, str]:
78
- if request.headers.get("content-type") != "application/json":
79
- raise HTTPException(415, "Send JSON")
84
+ async def read_note(request: Request) -> dict[str, str]:
80
85
  name = request.path_params["name"]
81
86
  note = request.path_params["note"]
82
87
  found = await experiment_notes(
@@ -87,6 +92,21 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
87
92
  [note] = found
88
93
  return note
89
94
 
95
+ async def find_note(request: Request) -> dict[str, str]:
96
+ if request.headers.get("content-type") != "application/json":
97
+ raise HTTPException(415, "Send JSON")
98
+ return await read_note(request)
99
+
100
+ async def get_note(request: Request) -> Response:
101
+ note = await read_note(request)
102
+ return PlainTextResponse(note["content"], media_type="text/markdown")
103
+
104
+ async def changes(request: Request) -> Response:
105
+ return StreamingResponse(
106
+ file_changes(request.app.state.tables, request.path_params["name"]),
107
+ media_type="text/event-stream",
108
+ )
109
+
90
110
  async def put_note(request: Request) -> Response:
91
111
  note = await find_note(request)
92
112
  body = await request.json()
@@ -119,6 +139,8 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
119
139
  routes=[
120
140
  Route("/", index),
121
141
  Route("/experiments/{name}", experiment),
142
+ Route("/experiments/{name}/changes", changes),
143
+ Route("/experiments/{name}/notes/{note:path}", get_note),
122
144
  Route("/experiments/{name}/notes/{note:path}", put_note, methods=["PUT"]),
123
145
  Route(
124
146
  "/experiments/{name}/notes/{note:path}/run",
@@ -0,0 +1,11 @@
1
+ import json
2
+ from collections.abc import AsyncIterator
3
+
4
+ from dirsql import DirSQL
5
+
6
+
7
+ async def file_changes(tables: DirSQL, experiment: str) -> AsyncIterator[str]:
8
+ async for event in tables.watch():
9
+ row = event.row or event.old_row
10
+ if event.table == "files" and row and row["experiment"] == experiment:
11
+ yield f"data: {json.dumps(row['filename'])}\n\n"
@@ -4,6 +4,8 @@ if (note) {
4
4
  const prose = note.querySelector(".prose");
5
5
  const status = note.querySelector(".edit-status");
6
6
  const editor = import(note.dataset.editor);
7
+ let editing = null;
8
+
7
9
  editor.then(
8
10
  () => prose.classList.add("editable"),
9
11
  () => {
@@ -15,9 +17,15 @@ if (note) {
15
17
  const open = async (event) => {
16
18
  if (event.target.closest("a, button, output") || getSelection().toString()) return;
17
19
  const { edit } = await editor;
18
- if (!prose.isConnected) return;
20
+ if (editing) return;
19
21
  prose.removeEventListener("click", open);
20
- edit(note, prose, status);
22
+ editing = edit(note, prose, status);
21
23
  };
22
24
  prose.addEventListener("click", open);
25
+
26
+ new EventSource(note.dataset.changesUrl).addEventListener("message", (event) => {
27
+ if (JSON.parse(event.data) !== note.dataset.note) return;
28
+ if (editing) editing.pull();
29
+ else location.reload();
30
+ });
23
31
  }
@@ -12,6 +12,7 @@ import { EditorState, RangeSetBuilder } from "@codemirror/state";
12
12
  import { Decoration, EditorView, ViewPlugin, keymap } from "@codemirror/view";
13
13
  import { tags } from "@lezer/highlight";
14
14
  import { GFM, parseCode, parser } from "@lezer/markdown";
15
+ import { DIFF_DELETE, DIFF_EQUAL, diff_match_patch } from "diff-match-patch";
15
16
 
16
17
  // Built from @lezer/markdown rather than @codemirror/lang-markdown, which
17
18
  // imports HTML, CSS and JavaScript support that notes never use.
@@ -102,9 +103,27 @@ function show(status, message) {
102
103
  status.hidden = false;
103
104
  }
104
105
 
106
+ const dmp = new diff_match_patch();
107
+
108
+ // Three-way: what changed on disk since base, applied onto the editor's text.
109
+ const merge = (base, ours, theirs) => dmp.patch_apply(dmp.patch_make(base, theirs), ours)[0];
110
+
111
+ // Minimal changes, so the cursor and undo history survive.
112
+ function setText(view, text) {
113
+ const changes = [];
114
+ let at = 0;
115
+ for (const [op, chunk] of dmp.diff_main(view.state.doc.toString(), text)) {
116
+ if (op === DIFF_EQUAL) at += chunk.length;
117
+ else if (op === DIFF_DELETE) {
118
+ changes.push({ from: at, to: at + chunk.length });
119
+ at += chunk.length;
120
+ } else changes.push({ from: at, insert: chunk });
121
+ }
122
+ if (changes.length) view.dispatch({ changes });
123
+ }
124
+
105
125
  export function edit(note, prose, status) {
106
126
  let base = JSON.parse(note.querySelector(".note-source").textContent);
107
- let stale = false;
108
127
  let timer;
109
128
  let queue = Promise.resolve();
110
129
 
@@ -112,31 +131,46 @@ export function edit(note, prose, status) {
112
131
  host.className = "editor";
113
132
  prose.replaceWith(host);
114
133
 
115
- const save = () =>
116
- (queue = queue
117
- .then(async () => {
118
- clearTimeout(timer);
119
- const content = view.state.doc.toString();
120
- if (stale || content === base) return;
121
- const response = await fetch(note.dataset.saveUrl, {
122
- method: "PUT",
123
- headers: { "Content-Type": "application/json" },
124
- body: JSON.stringify({ base, content }),
125
- });
126
- if (response.status === 409) {
127
- stale = true;
128
- show(status, "This note changed on disk, so your edits are not saved. Copy them, then reload.");
129
- } else if (response.ok) {
130
- base = content;
131
- } else {
132
- show(status, `Not saved: ${response.status} ${response.statusText}`);
133
- }
134
- })
135
- .catch((error) => show(status, `Not saved: ${error.message}`)));
134
+ const run = (task) =>
135
+ (queue = queue.then(task).catch((error) => show(status, `Not saved: ${error.message}`)));
136
+
137
+ const absorb = (theirs) => {
138
+ setText(view, merge(base, view.state.doc.toString(), theirs));
139
+ base = theirs;
140
+ };
141
+
142
+ const push = async () => {
143
+ clearTimeout(timer);
144
+ for (;;) {
145
+ const content = view.state.doc.toString();
146
+ if (content === base) return;
147
+ const response = await fetch(note.dataset.saveUrl, {
148
+ method: "PUT",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({ base, content }),
151
+ });
152
+ if (response.status !== 409) {
153
+ if (response.ok) base = content;
154
+ else show(status, `Not saved: ${response.status} ${response.statusText}`);
155
+ return;
156
+ }
157
+ absorb((await response.json()).content);
158
+ }
159
+ };
160
+
161
+ const pull = async () => {
162
+ const response = await fetch(note.dataset.saveUrl, { cache: "no-store" });
163
+ if (!response.ok) return show(status, `Not synced: ${response.status} ${response.statusText}`);
164
+ const theirs = await response.text();
165
+ if (theirs === base) return;
166
+ absorb(theirs);
167
+ await push();
168
+ };
169
+
170
+ const save = () => run(push);
136
171
 
137
172
  const close = async () => {
138
173
  await save();
139
- if (stale) return;
140
174
  document.removeEventListener("mousedown", outside);
141
175
  location.reload();
142
176
  };
@@ -166,4 +200,5 @@ export function edit(note, prose, status) {
166
200
  }),
167
201
  });
168
202
  view.focus();
203
+ return { pull: () => run(pull) };
169
204
  }
@@ -16,6 +16,7 @@
16
16
  "@lezer/python": "https://esm.sh/*@lezer/python@1.1.19",
17
17
  "@marijn/find-cluster-break": "https://esm.sh/*@marijn/find-cluster-break@1.0.4",
18
18
  "crelt": "https://esm.sh/*crelt@1.0.7",
19
+ "diff-match-patch": "https://esm.sh/*diff-match-patch@1.0.5",
19
20
  "style-mod": "https://esm.sh/*style-mod@4.1.4",
20
21
  "w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8"
21
22
  }
@@ -37,7 +37,7 @@
37
37
  <nav class="tabs sub">
38
38
  {% for each in e.notes %}<a href="?tab=notebooks&amp;note={{ each.name | urlencode }}"{% if each.name == note.name %} aria-current="page"{% endif %}>{{ each.name }}</a>{% endfor %}
39
39
  </nav>
40
- <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) }}" data-save-url="{{ url_for('put_note', name=e.name, note=note.name) }}" data-editor="{{ asset('note-editor.js') }}">
40
+ <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) }}" data-save-url="{{ url_for('put_note', name=e.name, note=note.name) }}" data-editor="{{ asset('note-editor.js') }}" data-changes-url="{{ url_for('changes', name=e.name) }}" data-note="{{ note.name }}">
41
41
  {% if note.runnable %}<header class="note-name"><button type="button" class="restart" title="Stop this note's Python kernel. The next Run starts a fresh one.">Restart</button></header>{% endif %}
42
42
  <p class="edit-status" role="status" hidden></p>
43
43
  <div class="prose">{{ note.html | safe }}</div>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes