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.
Files changed (112) hide show
  1. {labnotes-0.0.61 → labnotes-0.0.63}/PKG-INFO +2 -2
  2. {labnotes-0.0.61 → labnotes-0.0.63}/README.md +1 -1
  3. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/context.md +1 -1
  4. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/create_app.py +18 -8
  5. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiments.py +1 -7
  6. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/notebook_kernel.py +18 -27
  7. labnotes-0.0.63/src/labnotes/save_conflict_copy.py +16 -0
  8. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/serve_app.py +1 -4
  9. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/server.py +3 -16
  10. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/note-editor.js +36 -8
  11. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/experiment.html +1 -1
  12. labnotes-0.0.61/src/labnotes/stop_idle_kernels.py +0 -13
  13. {labnotes-0.0.61 → labnotes-0.0.63}/.gitignore +0 -0
  14. {labnotes-0.0.61 → labnotes-0.0.63}/CHANGELOG.md +0 -0
  15. {labnotes-0.0.61 → labnotes-0.0.63}/MIGRATIONS.md +0 -0
  16. {labnotes-0.0.61 → labnotes-0.0.63}/pyproject.toml +0 -0
  17. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/__init__.py +0 -0
  18. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/asset_url.py +0 -0
  19. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/cell_progress.py +0 -0
  20. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/check_counts.py +0 -0
  21. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/cli.py +0 -0
  22. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/context_guide.py +0 -0
  23. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/count_runs.py +0 -0
  24. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/create_experiment.py +0 -0
  25. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/create_note.py +0 -0
  26. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/data_html.py +0 -0
  27. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/default_config_text.py +0 -0
  28. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/default_counts.py +0 -0
  29. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/drain_watch.py +0 -0
  30. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiment_notes.py +0 -0
  31. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiment_progress.py +0 -0
  32. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/experiment_runs.py +0 -0
  33. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/file_changes.py +0 -0
  34. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/format_duration.py +0 -0
  35. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/format_value.py +0 -0
  36. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/group_runs.py +0 -0
  37. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/init.py +0 -0
  38. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/invocation_started.py +0 -0
  39. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/is_positive_int.py +0 -0
  40. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/list_experiments.py +0 -0
  41. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/list_runs.py +0 -0
  42. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/load_config.py +0 -0
  43. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/load_experiment.py +0 -0
  44. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/models.py +0 -0
  45. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/modes.py +0 -0
  46. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/nests_runs.py +0 -0
  47. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/output_html.py +0 -0
  48. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/parse_run_view.py +0 -0
  49. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/print_rows.py +0 -0
  50. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/python_blocks.py +0 -0
  51. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/read_config.py +0 -0
  52. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/read_path.py +0 -0
  53. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/render_markdown.py +0 -0
  54. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/__init__.py +0 -0
  55. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/describe_event.py +0 -0
  56. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/execute_run.py +0 -0
  57. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/hold_folder.py +0 -0
  58. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/new_param_run_id.py +0 -0
  59. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/new_run_id.py +0 -0
  60. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  61. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/param_args.py +0 -0
  62. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/param_grid.py +0 -0
  63. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/param_run_name.py +0 -0
  64. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/plan_runs.py +0 -0
  65. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/read_experiment_config.py +0 -0
  66. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/run_experiment.py +0 -0
  67. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/run_folder.py +0 -0
  68. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/run_headless.py +0 -0
  69. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/runs_parent.py +0 -0
  70. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/shell_command.py +0 -0
  71. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/start_experiment.py +0 -0
  72. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/stop_then_kill.py +0 -0
  73. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/wait_for_group.py +0 -0
  74. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/runner/write_run_json.py +0 -0
  75. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/save_note.py +0 -0
  76. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/saved_outputs.py +0 -0
  77. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/sort_href.py +0 -0
  78. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/sql_quote.py +0 -0
  79. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/experiment.js +0 -0
  80. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/labnotes.css +0 -0
  81. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/new-note.js +0 -0
  82. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/note-edit.js +0 -0
  83. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/row-link.js +0 -0
  84. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/run-cell.js +0 -0
  85. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/static/view-toggle.js +0 -0
  86. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/summarize_config.py +0 -0
  87. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/__init__.py +0 -0
  88. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/experiment_rows.py +0 -0
  89. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/file_prefix.py +0 -0
  90. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/iso_time.py +0 -0
  91. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/list_experiment_runs.py +0 -0
  92. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/open_tables.py +0 -0
  93. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/path_glob.py +0 -0
  94. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/path_pattern.py +0 -0
  95. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/read_experiment.py +0 -0
  96. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/read_file.py +0 -0
  97. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/read_run.py +0 -0
  98. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tables/summarize_experiments.py +0 -0
  99. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/base.html +0 -0
  100. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/editor_imports.html +0 -0
  101. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/index.html +0 -0
  102. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/templates/macros.html +0 -0
  103. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/__init__.py +0 -0
  104. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/dim.py +0 -0
  105. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/format_elapsed.py +0 -0
  106. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/run_app.py +0 -0
  107. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/run_tui.py +0 -0
  108. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/tui/runs_table.py +0 -0
  109. {labnotes-0.0.61 → labnotes-0.0.63}/src/labnotes/wait_for_note.py +0 -0
  110. {labnotes-0.0.61 → labnotes-0.0.63}/testing-conventions.toml +0 -0
  111. {labnotes-0.0.61 → labnotes-0.0.63}/tests/integration/conftest.py +0 -0
  112. {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.61
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, or with `--no-tui`, it prints a
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, or with `--no-tui`, it prints a
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, or with `--no-tui`, it prints a
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, kernel_idle_minutes: float) -> Starlette:
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
- watchers = [
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
- for watcher in watchers:
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() and not no_tui
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
- 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()
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, KERNEL_IDLE_MINUTES
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 them.
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
- const absorb = (theirs) => {
138
- setText(view, merge(base, view.state.doc.toString(), theirs));
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
- timer = setTimeout(save, 500);
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&amp;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