labnotes 0.0.61__tar.gz → 0.0.63__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.61 → labnotes-0.0.63}/PKG-INFO +2 -2
- {labnotes-0.0.61 → labnotes-0.0.63}/README.md +1 -1
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/context.md +1 -1
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/create_app.py +18 -8
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiments.py +1 -7
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/notebook_kernel.py +18 -27
- labnotes-0.0.63/src/labnotes/save_conflict_copy.py +16 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/serve_app.py +1 -4
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/server.py +3 -16
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/note-editor.js +36 -8
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/experiment.html +1 -1
- labnotes-0.0.61/src/labnotes/stop_idle_kernels.py +0 -13
- {labnotes-0.0.61 → labnotes-0.0.63}/.gitignore +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/CHANGELOG.md +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/MIGRATIONS.md +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/pyproject.toml +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/cell_progress.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiment_progress.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/init.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/models.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/execute_run.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/run_experiment.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/start_experiment.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/wait_for_group.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/macros.html +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/run_app.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/testing-conventions.toml +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/tests/integration/conftest.py +0 -0
- {labnotes-0.0.61 → labnotes-0.0.63}/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.63
|
|
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>
|
|
@@ -101,7 +101,7 @@ To exclude a run, add `"excluded": true` and an optional `"reason"` to its
|
|
|
101
101
|
replaces it; both show as columns in `runs`.
|
|
102
102
|
|
|
103
103
|
In a terminal, `run` shows a live table of runs (status and time) and stays
|
|
104
|
-
open when they finish; `q` quits. Elsewhere,
|
|
104
|
+
open when they finish; `q` quits. Elsewhere, it prints a
|
|
105
105
|
line per event instead.
|
|
106
106
|
|
|
107
107
|
- `--concurrency N` runs N at a time (default 1).
|
|
@@ -80,7 +80,7 @@ To exclude a run, add `"excluded": true` and an optional `"reason"` to its
|
|
|
80
80
|
replaces it; both show as columns in `runs`.
|
|
81
81
|
|
|
82
82
|
In a terminal, `run` shows a live table of runs (status and time) and stays
|
|
83
|
-
open when they finish; `q` quits. Elsewhere,
|
|
83
|
+
open when they finish; `q` quits. Elsewhere, it prints a
|
|
84
84
|
line per event instead.
|
|
85
85
|
|
|
86
86
|
- `--concurrency N` runs N at a time (default 1).
|
|
@@ -105,7 +105,7 @@ running run under the cursor; `x` again kills it. `+` and
|
|
|
105
105
|
`-` change how many run at once; lowering it lets running runs
|
|
106
106
|
finish. Enter on a run follows its `stdout.log` and `stderr.log` together, as
|
|
107
107
|
they're written, and Escape returns to the table. An `adhoc` experiment that
|
|
108
|
-
plans a single run opens straight on its output. `q` quits. Elsewhere,
|
|
108
|
+
plans a single run opens straight on its output. `q` quits. Elsewhere, it prints a
|
|
109
109
|
line per event instead.
|
|
110
110
|
|
|
111
111
|
- `--concurrency N` runs N at a time to start with. Without it, `concurrency`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from collections.abc import AsyncIterator
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from starlette.applications import Starlette
|
|
@@ -29,9 +30,9 @@ from labnotes.load_experiment import load_experiment
|
|
|
29
30
|
from labnotes.models import LabnotesConfig
|
|
30
31
|
from labnotes.notebook_kernel import NotebookKernel
|
|
31
32
|
from labnotes.python_blocks import python_blocks
|
|
33
|
+
from labnotes.save_conflict_copy import save_conflict_copy
|
|
32
34
|
from labnotes.save_note import save_note
|
|
33
35
|
from labnotes.saved_outputs import saved_outputs
|
|
34
|
-
from labnotes.stop_idle_kernels import stop_idle_kernels
|
|
35
36
|
from labnotes.tables.open_tables import open_tables
|
|
36
37
|
from labnotes.wait_for_note import wait_for_note
|
|
37
38
|
|
|
@@ -42,20 +43,16 @@ templates.env.filters["duration"] = format_duration
|
|
|
42
43
|
templates.env.globals["asset"] = asset_url
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
def create_app(config: LabnotesConfig
|
|
46
|
+
def create_app(config: LabnotesConfig) -> Starlette:
|
|
46
47
|
kernels: dict[tuple[str, str], NotebookKernel] = {}
|
|
47
48
|
|
|
48
49
|
@asynccontextmanager
|
|
49
50
|
async def lifespan(app: Starlette) -> AsyncIterator[None]:
|
|
50
51
|
app.state.tables = open_tables(config)
|
|
51
52
|
await app.state.tables.ready()
|
|
52
|
-
|
|
53
|
-
asyncio.create_task(drain_watch(app.state.tables)),
|
|
54
|
-
asyncio.create_task(stop_idle_kernels(kernels, kernel_idle_minutes)),
|
|
55
|
-
]
|
|
53
|
+
watcher = asyncio.create_task(drain_watch(app.state.tables))
|
|
56
54
|
yield
|
|
57
|
-
|
|
58
|
-
watcher.cancel()
|
|
55
|
+
watcher.cancel()
|
|
59
56
|
for kernel in kernels.values():
|
|
60
57
|
await kernel.stop()
|
|
61
58
|
|
|
@@ -132,6 +129,14 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
|
|
|
132
129
|
return JSONResponse({"content": current}, status_code=409)
|
|
133
130
|
return Response(status_code=204)
|
|
134
131
|
|
|
132
|
+
async def post_conflict(request: Request) -> Response:
|
|
133
|
+
note = await find_note(request)
|
|
134
|
+
content = (await request.json()).get("content")
|
|
135
|
+
if not isinstance(content, str):
|
|
136
|
+
raise HTTPException(400, "Send content as a string")
|
|
137
|
+
copy = save_conflict_copy(Path(note["path"]), content, datetime.now())
|
|
138
|
+
return JSONResponse({"name": copy.name}, status_code=201)
|
|
139
|
+
|
|
135
140
|
async def run_block(request: Request) -> Response:
|
|
136
141
|
blocks = python_blocks((await find_note(request))["content"])
|
|
137
142
|
key = (request.path_params["name"], request.path_params["note"])
|
|
@@ -157,6 +162,11 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
|
|
|
157
162
|
Route("/experiments/{name}/notes", post_note, methods=["POST"]),
|
|
158
163
|
Route("/experiments/{name}/notes/{note:path}", get_note),
|
|
159
164
|
Route("/experiments/{name}/notes/{note:path}", put_note, methods=["PUT"]),
|
|
165
|
+
Route(
|
|
166
|
+
"/experiments/{name}/notes/{note:path}/conflict",
|
|
167
|
+
post_conflict,
|
|
168
|
+
methods=["POST"],
|
|
169
|
+
),
|
|
160
170
|
Route(
|
|
161
171
|
"/experiments/{name}/notes/{note:path}/run",
|
|
162
172
|
run_block,
|
|
@@ -39,11 +39,6 @@ from labnotes.tui.run_tui import run_tui
|
|
|
39
39
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
40
40
|
help="With run: read this file instead of the experiment's experiment.toml.",
|
|
41
41
|
)
|
|
42
|
-
@click.option(
|
|
43
|
-
"--no-tui",
|
|
44
|
-
is_flag=True,
|
|
45
|
-
help="With run: print a line per event instead of the live view.",
|
|
46
|
-
)
|
|
47
42
|
@click.pass_context
|
|
48
43
|
def experiments(
|
|
49
44
|
ctx: click.Context,
|
|
@@ -52,7 +47,6 @@ def experiments(
|
|
|
52
47
|
output_format: str,
|
|
53
48
|
concurrency: int | None,
|
|
54
49
|
experiment_config: Path | None,
|
|
55
|
-
no_tui: bool,
|
|
56
50
|
) -> None:
|
|
57
51
|
config = load_config(ctx)
|
|
58
52
|
if config is None:
|
|
@@ -68,7 +62,7 @@ def experiments(
|
|
|
68
62
|
click.echo(f"Created {path / 'experiment.toml'}")
|
|
69
63
|
return
|
|
70
64
|
case str(), "run":
|
|
71
|
-
live = sys.stdout.isatty() and sys.stderr.isatty()
|
|
65
|
+
live = sys.stdout.isatty() and sys.stderr.isatty()
|
|
72
66
|
drive = run_tui if live else lambda start, title: run_headless(start)
|
|
73
67
|
try:
|
|
74
68
|
results = start_experiment(
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from time import monotonic
|
|
5
4
|
|
|
6
5
|
from jupyter_client.asynchronous.client import AsyncKernelClient
|
|
7
6
|
from jupyter_client.manager import AsyncKernelManager
|
|
@@ -24,7 +23,6 @@ class NotebookKernel:
|
|
|
24
23
|
self._lock = asyncio.Lock()
|
|
25
24
|
self._manager: AsyncKernelManager | None = None
|
|
26
25
|
self._client: AsyncKernelClient | None = None
|
|
27
|
-
self.last_used = monotonic()
|
|
28
26
|
|
|
29
27
|
async def _start(self) -> None:
|
|
30
28
|
self._manager = AsyncKernelManager(kernel_name="python3")
|
|
@@ -35,27 +33,24 @@ class NotebookKernel:
|
|
|
35
33
|
|
|
36
34
|
async def run(self, code: str) -> str:
|
|
37
35
|
async with self._lock:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return html
|
|
57
|
-
finally:
|
|
58
|
-
self.last_used = monotonic()
|
|
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
|
|
59
54
|
|
|
60
55
|
async def stop(self) -> None:
|
|
61
56
|
client, manager = self._client, self._manager
|
|
@@ -68,7 +63,3 @@ class NotebookKernel:
|
|
|
68
63
|
async def restart(self) -> None:
|
|
69
64
|
async with self._lock:
|
|
70
65
|
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,16 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from itertools import chain, count
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def save_conflict_copy(path: Path, content: str, now: datetime) -> Path:
|
|
7
|
+
stamp = f"Conflicted copy {now:%Y%m%d%H%M}"
|
|
8
|
+
tags = chain([stamp], (f"{stamp} {n}" for n in count(2)))
|
|
9
|
+
while True:
|
|
10
|
+
copy = path.with_name(f"{path.stem} ({next(tags)}){path.suffix}")
|
|
11
|
+
try:
|
|
12
|
+
with copy.open("x", encoding="utf-8") as file:
|
|
13
|
+
file.write(content)
|
|
14
|
+
return copy
|
|
15
|
+
except FileExistsError:
|
|
16
|
+
pass
|
|
@@ -7,10 +7,7 @@ from labnotes.create_app import create_app
|
|
|
7
7
|
from labnotes.read_config import read_config
|
|
8
8
|
|
|
9
9
|
CONFIG = "LABNOTES_CONFIG"
|
|
10
|
-
KERNEL_IDLE_MINUTES = "LABNOTES_KERNEL_IDLE_MINUTES"
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
def serve_app() -> Starlette:
|
|
14
|
-
return create_app(
|
|
15
|
-
read_config(Path(os.environ[CONFIG])), int(os.environ[KERNEL_IDLE_MINUTES])
|
|
16
|
-
)
|
|
13
|
+
return create_app(read_config(Path(os.environ[CONFIG])))
|
|
@@ -5,7 +5,7 @@ from uvicorn.main import main as uvicorn_main
|
|
|
5
5
|
|
|
6
6
|
from labnotes.load_config import load_config
|
|
7
7
|
from labnotes.read_config import CONFIG_FILE
|
|
8
|
-
from labnotes.serve_app import CONFIG
|
|
8
|
+
from labnotes.serve_app import CONFIG
|
|
9
9
|
|
|
10
10
|
# labnotes owns these; the rest of uvicorn's options pass straight through.
|
|
11
11
|
OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
|
|
@@ -14,14 +14,6 @@ OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
|
|
|
14
14
|
@click.command(
|
|
15
15
|
help="Serve the experiments, their notes and their runs as a local site."
|
|
16
16
|
)
|
|
17
|
-
@click.option(
|
|
18
|
-
"--kernel-idle-minutes",
|
|
19
|
-
type=click.IntRange(min=1),
|
|
20
|
-
default=30,
|
|
21
|
-
show_default=True,
|
|
22
|
-
envvar=KERNEL_IDLE_MINUTES,
|
|
23
|
-
help="Stop a note's Python kernel after this long without a run.",
|
|
24
|
-
)
|
|
25
17
|
@click.option(
|
|
26
18
|
"-h",
|
|
27
19
|
"--host",
|
|
@@ -31,19 +23,14 @@ OWN_OPTIONS = {"app", "factory", "app_dir", "version", "host", "port"}
|
|
|
31
23
|
)
|
|
32
24
|
@click.option("-p", "--port", type=int, default=8000, show_default=True)
|
|
33
25
|
@click.pass_context
|
|
34
|
-
def server(
|
|
35
|
-
ctx: click.Context,
|
|
36
|
-
kernel_idle_minutes: int,
|
|
37
|
-
**server_options: object,
|
|
38
|
-
) -> None:
|
|
26
|
+
def server(ctx: click.Context, **server_options: object) -> None:
|
|
39
27
|
config = load_config(ctx)
|
|
40
28
|
if config is None:
|
|
41
29
|
raise click.ClickException(
|
|
42
30
|
f"No {CONFIG_FILE} here. Run labnotes init, or pass -c PATH."
|
|
43
31
|
)
|
|
44
|
-
# Through the environment so --reload and --workers child processes see
|
|
32
|
+
# Through the environment so --reload and --workers child processes see it.
|
|
45
33
|
os.environ[CONFIG] = str(config.path.resolve())
|
|
46
|
-
os.environ[KERNEL_IDLE_MINUTES] = str(kernel_idle_minutes)
|
|
47
34
|
ctx.invoke(
|
|
48
35
|
uvicorn_main,
|
|
49
36
|
app="labnotes.serve_app:serve_app",
|
|
@@ -105,9 +105,6 @@ function show(status, message) {
|
|
|
105
105
|
|
|
106
106
|
const dmp = new diff_match_patch();
|
|
107
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
108
|
// Minimal changes, so the cursor and undo history survive.
|
|
112
109
|
function setText(view, text) {
|
|
113
110
|
const changes = [];
|
|
@@ -125,22 +122,52 @@ function setText(view, text) {
|
|
|
125
122
|
export function edit(note, prose, status) {
|
|
126
123
|
let base = JSON.parse(note.querySelector(".note-source").textContent);
|
|
127
124
|
let timer;
|
|
125
|
+
let deadline;
|
|
128
126
|
let queue = Promise.resolve();
|
|
129
127
|
|
|
130
128
|
const host = document.createElement("div");
|
|
131
129
|
host.className = "editor";
|
|
132
130
|
prose.replaceWith(host);
|
|
133
131
|
|
|
132
|
+
const notice = (message) => {
|
|
133
|
+
show(status, message);
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
if (status.textContent === message) status.hidden = true;
|
|
136
|
+
}, 4000);
|
|
137
|
+
};
|
|
138
|
+
|
|
134
139
|
const run = (task) =>
|
|
135
140
|
(queue = queue.then(task).catch((error) => show(status, `Not saved: ${error.message}`)));
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
// Three-way: what changed on disk since base, applied onto the editor's text.
|
|
143
|
+
// A patch that will not apply leaves the editor alone and keeps the disk
|
|
144
|
+
// version in a sibling file, as Obsidian Sync does.
|
|
145
|
+
const absorb = async (theirs) => {
|
|
146
|
+
const ours = view.state.doc.toString();
|
|
147
|
+
const [merged, applied] = dmp.patch_apply(dmp.patch_make(base, theirs), ours);
|
|
148
|
+
const name = note.dataset.note;
|
|
149
|
+
if (applied.every(Boolean)) {
|
|
150
|
+
setText(view, merged);
|
|
151
|
+
if (ours !== base) notice(`${name} was modified externally, merging changes automatically`);
|
|
152
|
+
} else {
|
|
153
|
+
const response = await fetch(note.dataset.conflictUrl, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/json" },
|
|
156
|
+
body: JSON.stringify({ content: theirs }),
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
159
|
+
const copy = (await response.json()).name;
|
|
160
|
+
show(
|
|
161
|
+
status,
|
|
162
|
+
`${name} was modified externally and could not be merged. The outside version is saved as ${copy}.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
139
165
|
base = theirs;
|
|
140
166
|
};
|
|
141
167
|
|
|
142
168
|
const push = async () => {
|
|
143
169
|
clearTimeout(timer);
|
|
170
|
+
deadline = undefined;
|
|
144
171
|
for (;;) {
|
|
145
172
|
const content = view.state.doc.toString();
|
|
146
173
|
if (content === base) return;
|
|
@@ -154,7 +181,7 @@ export function edit(note, prose, status) {
|
|
|
154
181
|
else show(status, `Not saved: ${response.status} ${response.statusText}`);
|
|
155
182
|
return;
|
|
156
183
|
}
|
|
157
|
-
absorb((await response.json()).content);
|
|
184
|
+
await absorb((await response.json()).content);
|
|
158
185
|
}
|
|
159
186
|
};
|
|
160
187
|
|
|
@@ -163,7 +190,7 @@ export function edit(note, prose, status) {
|
|
|
163
190
|
if (!response.ok) return show(status, `Not synced: ${response.status} ${response.statusText}`);
|
|
164
191
|
const theirs = await response.text();
|
|
165
192
|
if (theirs === base) return;
|
|
166
|
-
absorb(theirs);
|
|
193
|
+
await absorb(theirs);
|
|
167
194
|
await push();
|
|
168
195
|
};
|
|
169
196
|
|
|
@@ -194,7 +221,8 @@ export function edit(note, prose, status) {
|
|
|
194
221
|
EditorView.updateListener.of((update) => {
|
|
195
222
|
if (!update.docChanged) return;
|
|
196
223
|
clearTimeout(timer);
|
|
197
|
-
|
|
224
|
+
deadline ??= Date.now() + 2000;
|
|
225
|
+
timer = setTimeout(save, Math.min(500, deadline - Date.now()));
|
|
198
226
|
}),
|
|
199
227
|
],
|
|
200
228
|
}),
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<nav class="tabs sub">
|
|
38
38
|
{% for each in e.notes %}<a href="?tab=notebooks&note={{ each.name | urlencode }}"{% if each.name == note.name %} aria-current="page"{% endif %}>{{ each.name }}</a>{% endfor %}<button type="button" class="new-note" data-url="{{ url_for('post_note', name=e.name) }}" aria-label="New note" title="New note">+</button>
|
|
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') }}" data-changes-url="{{ url_for('changes', name=e.name) }}" data-note="{{ note.name }}">
|
|
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-conflict-url="{{ url_for('post_conflict', 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>
|
|
@@ -1,13 +0,0 @@
|
|
|
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)
|
|
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
|
|
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
|
|
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
|