hypernote 0.1.0__py3-none-any.whl

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.
hypernote/sdk.py ADDED
@@ -0,0 +1,975 @@
1
+ """Synchronous user-facing Hypernote SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import time
10
+ import urllib.parse
11
+ from dataclasses import asdict, dataclass
12
+ from enum import Enum
13
+ from typing import Any, Iterator
14
+
15
+ import httpx
16
+
17
+ from hypernote.errors import (
18
+ CellNotFoundError,
19
+ ExecutionTimeoutError,
20
+ HypernoteError,
21
+ InputNotExpectedError,
22
+ NotebookNotFoundError,
23
+ RuntimeUnavailableError,
24
+ )
25
+
26
+ SUMMARY_SOURCE_CHARS = 120
27
+ SUMMARY_OUTPUT_TEXT_CHARS = 80
28
+
29
+
30
+ class CellType(str, Enum):
31
+ CODE = "code"
32
+ MARKDOWN = "markdown"
33
+ RAW = "raw"
34
+
35
+
36
+ class RuntimeStatus(str, Enum):
37
+ STARTING = "starting"
38
+ LIVE_ATTACHED = "live-attached"
39
+ LIVE_DETACHED = "live-detached"
40
+ AWAITING_INPUT = "awaiting-input"
41
+ STOPPING = "stopping"
42
+ STOPPED = "stopped"
43
+ FAILED = "failed"
44
+
45
+
46
+ class JobStatus(str, Enum):
47
+ QUEUED = "queued"
48
+ RUNNING = "running"
49
+ AWAITING_INPUT = "awaiting_input"
50
+ SUCCEEDED = "succeeded"
51
+ FAILED = "failed"
52
+ INTERRUPTED = "interrupted"
53
+
54
+
55
+ class ChangeKind(str, Enum):
56
+ ADDED = "added"
57
+ DELETED = "deleted"
58
+ MOVED = "moved"
59
+ SOURCE_EDITED = "source_edited"
60
+ OUTPUT_CHANGED = "output_changed"
61
+ EXECUTION_COUNT = "execution_count"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Snapshot:
66
+ token: str
67
+ timestamp: float
68
+ cell_count: int
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return asdict(self)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class CellStatus:
76
+ id: str
77
+ type: CellType
78
+ changed: bool
79
+ change_kinds: tuple[ChangeKind, ...]
80
+ source: str | None
81
+ outputs: tuple[dict[str, Any], ...] | None
82
+ execution_count: int | None
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return {
86
+ "id": self.id,
87
+ "type": self.type.value,
88
+ "changed": self.changed,
89
+ "change_kinds": [kind.value for kind in self.change_kinds],
90
+ "source": self.source,
91
+ "outputs": list(self.outputs) if self.outputs is not None else None,
92
+ "execution_count": self.execution_count,
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class NotebookStatus:
98
+ notebook_path: str
99
+ baseline: Snapshot | None
100
+ current: Snapshot
101
+ runtime: RuntimeStatus
102
+ cells: tuple[CellStatus, ...]
103
+ summary: str
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "notebook_path": self.notebook_path,
108
+ "baseline": self.baseline.to_dict() if self.baseline is not None else None,
109
+ "current": self.current.to_dict(),
110
+ "runtime": self.runtime.value,
111
+ "cells": [cell.to_dict() for cell in self.cells],
112
+ "summary": self.summary,
113
+ }
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class _SnapshotCell:
118
+ id: str
119
+ type: str
120
+ order: str
121
+ source_hash: str
122
+ outputs_hash: str
123
+ execution_count: int | None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _Config:
128
+ server: str
129
+ token: str | None
130
+ actor_id: str
131
+ actor_type: str
132
+ timeout: float
133
+ transport: httpx.BaseTransport | None = None
134
+
135
+
136
+ def connect(
137
+ path: str,
138
+ create: bool = False,
139
+ *,
140
+ server: str | None = None,
141
+ token: str | None = None,
142
+ actor_id: str = "python-sdk",
143
+ actor_type: str = "human",
144
+ timeout: float = 30.0,
145
+ transport: httpx.BaseTransport | None = None,
146
+ ) -> Notebook:
147
+ """Connect to a notebook path on a Hypernote-enabled Jupyter server."""
148
+ cfg = _Config(
149
+ server=(server or os.environ.get("HYPERNOTE_SERVER", "http://127.0.0.1:8888")).rstrip("/"),
150
+ token=token or os.environ.get("HYPERNOTE_TOKEN"),
151
+ actor_id=actor_id,
152
+ actor_type=actor_type,
153
+ timeout=timeout,
154
+ transport=transport,
155
+ )
156
+ notebook = Notebook(path=path, _config=cfg)
157
+ notebook._ensure_exists(create=create)
158
+ return notebook
159
+
160
+
161
+ class _SDKMixin:
162
+ _config: _Config
163
+
164
+ def _jupyter_headers(self) -> dict[str, str]:
165
+ headers: dict[str, str] = {}
166
+ if self._config.token:
167
+ headers["Authorization"] = f"token {self._config.token}"
168
+ return headers
169
+
170
+ def _hypernote_headers(self) -> dict[str, str]:
171
+ return {
172
+ **self._jupyter_headers(),
173
+ "X-Hypernote-Actor-Id": self._config.actor_id,
174
+ "X-Hypernote-Actor-Type": self._config.actor_type,
175
+ }
176
+
177
+ def _request(
178
+ self,
179
+ method: str,
180
+ path: str,
181
+ *,
182
+ hypernote: bool = False,
183
+ json_body: dict[str, Any] | None = None,
184
+ params: dict[str, Any] | None = None,
185
+ ) -> httpx.Response:
186
+ with httpx.Client(
187
+ base_url=self._config.server,
188
+ headers=self._hypernote_headers() if hypernote else self._jupyter_headers(),
189
+ timeout=self._config.timeout,
190
+ transport=self._config.transport,
191
+ ) as client:
192
+ response = client.request(method, path, json=json_body, params=params)
193
+ return response
194
+
195
+
196
+ class _ControlPlane(_SDKMixin):
197
+ """Internal control-plane helper for CLI/operator commands.
198
+
199
+ This keeps low-level job and diagnostics transport logic in one place
200
+ without expanding the public notebook-first SDK surface.
201
+ """
202
+
203
+ def __init__(self, config: _Config):
204
+ self._config = config
205
+
206
+ def get_job_payload(self, job_id: str) -> dict[str, Any]:
207
+ response = self._request(
208
+ "GET",
209
+ f"/hypernote/api/jobs/{job_id}",
210
+ hypernote=True,
211
+ )
212
+ _raise_response(response)
213
+ return response.json()
214
+
215
+ def get_job(self, job_id: str) -> Job:
216
+ return _job_from_payload(self._config, self.get_job_payload(job_id))
217
+
218
+ def list_jobs(
219
+ self,
220
+ *,
221
+ notebook_id: str | None = None,
222
+ status: str | None = None,
223
+ ) -> dict[str, Any]:
224
+ params: dict[str, Any] = {}
225
+ if notebook_id:
226
+ params["notebook_id"] = notebook_id
227
+ if status:
228
+ params["status"] = status
229
+ response = self._request(
230
+ "GET",
231
+ "/hypernote/api/jobs",
232
+ hypernote=True,
233
+ params=params or None,
234
+ )
235
+ _raise_response(response)
236
+ return response.json()
237
+
238
+ def send_job_stdin(self, job_id: str, value: str) -> dict[str, Any]:
239
+ response = self._request(
240
+ "POST",
241
+ f"/hypernote/api/jobs/{job_id}/stdin",
242
+ hypernote=True,
243
+ json_body={"value": value},
244
+ )
245
+ try:
246
+ _raise_response(response)
247
+ except HypernoteError as exc:
248
+ if response.status_code == 400:
249
+ raise InputNotExpectedError(str(exc)) from exc
250
+ raise
251
+ return response.json()
252
+
253
+
254
+ class Notebook(_SDKMixin):
255
+ """Main user-facing notebook handle."""
256
+
257
+ def __init__(self, path: str, _config: _Config):
258
+ self.path = path
259
+ self._config = _config
260
+ self.cells = CellCollection(self)
261
+ self.runtime = Runtime(self)
262
+
263
+ def _quote_path(self) -> str:
264
+ return urllib.parse.quote(self.path, safe="")
265
+
266
+ def _ensure_exists(self, create: bool) -> None:
267
+ response = self._request(
268
+ "GET",
269
+ f"/hypernote/api/notebooks/{self._quote_path()}/document",
270
+ hypernote=True,
271
+ params={"content": 0},
272
+ )
273
+ if response.status_code == 404 and create:
274
+ model = _new_notebook_model()
275
+ created = self._request(
276
+ "PUT",
277
+ f"/hypernote/api/notebooks/{self._quote_path()}/document",
278
+ hypernote=True,
279
+ json_body=model,
280
+ )
281
+ _raise_notebook_response(created, self.path)
282
+ return
283
+ _raise_notebook_response(response, self.path)
284
+
285
+ def _get_notebook_model(self, *, content: bool = True) -> dict[str, Any]:
286
+ response = self._request(
287
+ "GET",
288
+ f"/hypernote/api/notebooks/{self._quote_path()}/document",
289
+ hypernote=True,
290
+ params={"content": int(content)},
291
+ )
292
+ _raise_notebook_response(response, self.path)
293
+ return response.json()
294
+
295
+ def _save_notebook_model(self, model: dict[str, Any]) -> dict[str, Any]:
296
+ response = self._request(
297
+ "PUT",
298
+ f"/hypernote/api/notebooks/{self._quote_path()}/document",
299
+ hypernote=True,
300
+ json_body=model,
301
+ )
302
+ _raise_notebook_response(response, self.path)
303
+ return response.json()
304
+
305
+ def _cell_model(self, cell_id: str) -> dict[str, Any]:
306
+ quoted_cell_id = urllib.parse.quote(cell_id, safe="")
307
+ response = self._request(
308
+ "GET",
309
+ f"/hypernote/api/notebooks/{self._quote_path()}/cells/{quoted_cell_id}",
310
+ hypernote=True,
311
+ )
312
+ if response.status_code == 404:
313
+ raise CellNotFoundError(cell_id)
314
+ _raise_response(response)
315
+ return response.json()["cell"]
316
+
317
+ def _cell_order(self) -> list[dict[str, Any]]:
318
+ model = self._get_notebook_model(content=True)
319
+ return list(model["content"].get("cells", []))
320
+
321
+ def _kernelspec_name(self) -> str:
322
+ model = self._get_notebook_model(content=True)
323
+ metadata = model["content"].get("metadata", {})
324
+ return metadata.get("kernelspec", {}).get("name", "python3")
325
+
326
+ def _run_cells(self, cell_ids: list[str]) -> Job:
327
+ if not cell_ids:
328
+ raise HypernoteError("No code cells to run")
329
+ response = self._request(
330
+ "POST",
331
+ f"/hypernote/api/notebooks/{self._quote_path()}/execute",
332
+ hypernote=True,
333
+ json_body={"cell_ids": cell_ids},
334
+ )
335
+ _raise_response(response)
336
+ payload = response.json()
337
+ return Job(
338
+ notebook=self,
339
+ id=payload["job_id"],
340
+ status=JobStatus(payload["status"]),
341
+ cell_ids=tuple(cell_ids),
342
+ notebook_path=self.path,
343
+ )
344
+
345
+ def run(self, *cell_ids: str) -> Job:
346
+ normalized: list[str] = []
347
+ for cell_id in cell_ids:
348
+ if isinstance(cell_id, (list, tuple)):
349
+ normalized.extend(str(item) for item in cell_id)
350
+ else:
351
+ normalized.append(str(cell_id))
352
+ return self._run_cells(normalized)
353
+
354
+ def run_all(self) -> Job:
355
+ cells = self._cell_order()
356
+ code_ids = [cell["id"] for cell in cells if cell.get("cell_type") == CellType.CODE.value]
357
+ return self._run_cells(code_ids)
358
+
359
+ def interrupt(self) -> None:
360
+ response = self._request(
361
+ "POST",
362
+ f"/hypernote/api/notebooks/{self._quote_path()}/interrupt",
363
+ hypernote=True,
364
+ json_body={},
365
+ )
366
+ _raise_response(response)
367
+
368
+ def restart(self) -> Runtime:
369
+ self.runtime.stop()
370
+ return self.runtime.ensure()
371
+
372
+ def snapshot(self) -> Snapshot:
373
+ cells = self._cell_order()
374
+ token = _encode_snapshot_token(cells)
375
+ return Snapshot(token=token, timestamp=time.time(), cell_count=len(cells))
376
+
377
+ def status(self, *, full: bool = False) -> NotebookStatus:
378
+ cells = self._cell_order()
379
+ current = Snapshot(
380
+ token=_encode_snapshot_token(cells),
381
+ timestamp=time.time(),
382
+ cell_count=len(cells),
383
+ )
384
+ runtime_status = self.runtime.status
385
+ cell_statuses = tuple(
386
+ _build_cell_status(
387
+ cell,
388
+ full=full,
389
+ changed=False,
390
+ change_kinds=(),
391
+ )
392
+ for cell in cells
393
+ )
394
+ return NotebookStatus(
395
+ notebook_path=self.path,
396
+ baseline=None,
397
+ current=current,
398
+ runtime=runtime_status,
399
+ cells=cell_statuses,
400
+ summary=_build_summary(self.path, runtime_status, cells, diff=False),
401
+ )
402
+
403
+ def diff(self, *, snapshot: Snapshot, full: bool = False) -> NotebookStatus:
404
+ cells = self._cell_order()
405
+ current = Snapshot(
406
+ token=_encode_snapshot_token(cells),
407
+ timestamp=time.time(),
408
+ cell_count=len(cells),
409
+ )
410
+ baseline_data = _decode_snapshot_token(snapshot.token)
411
+ current_data = _snapshot_cells(cells)
412
+
413
+ baseline_by_id = {cell.id: cell for cell in baseline_data}
414
+ current_by_id = {cell.id: cell for cell in current_data}
415
+ changed: list[CellStatus] = []
416
+
417
+ for cell in cells:
418
+ cell_id = cell.get("id")
419
+ current_entry = current_by_id[cell_id]
420
+ baseline_entry = baseline_by_id.get(cell_id)
421
+ change_kinds: list[ChangeKind] = []
422
+ if baseline_entry is None:
423
+ change_kinds.append(ChangeKind.ADDED)
424
+ else:
425
+ if baseline_entry.order != current_entry.order:
426
+ change_kinds.append(ChangeKind.MOVED)
427
+ if baseline_entry.source_hash != current_entry.source_hash:
428
+ change_kinds.append(ChangeKind.SOURCE_EDITED)
429
+ if baseline_entry.outputs_hash != current_entry.outputs_hash:
430
+ change_kinds.append(ChangeKind.OUTPUT_CHANGED)
431
+ if baseline_entry.execution_count != current_entry.execution_count:
432
+ change_kinds.append(ChangeKind.EXECUTION_COUNT)
433
+ if change_kinds:
434
+ changed.append(
435
+ _build_cell_status(
436
+ cell,
437
+ full=full,
438
+ changed=True,
439
+ change_kinds=tuple(change_kinds),
440
+ )
441
+ )
442
+
443
+ for removed_id, removed in baseline_by_id.items():
444
+ if removed_id not in current_by_id:
445
+ changed.append(
446
+ CellStatus(
447
+ id=removed_id,
448
+ type=CellType(removed.type),
449
+ changed=True,
450
+ change_kinds=(ChangeKind.DELETED,),
451
+ source=None,
452
+ outputs=None,
453
+ execution_count=removed.execution_count,
454
+ )
455
+ )
456
+
457
+ runtime_status = self.runtime.status
458
+ return NotebookStatus(
459
+ notebook_path=self.path,
460
+ baseline=snapshot,
461
+ current=current,
462
+ runtime=runtime_status,
463
+ cells=tuple(changed),
464
+ summary=_build_summary(
465
+ self.path,
466
+ runtime_status,
467
+ cells,
468
+ diff=True,
469
+ changed_count=len(changed),
470
+ ),
471
+ )
472
+
473
+ def to_dict(self) -> dict[str, Any]:
474
+ return self.status(full=True).to_dict()
475
+
476
+
477
+ class CellCollection:
478
+ """Lookup, iteration, and insertion surface for notebook cells."""
479
+
480
+ def __init__(self, notebook: Notebook):
481
+ self._notebook = notebook
482
+
483
+ def __getitem__(self, cell_id: str) -> CellHandle:
484
+ _ = self._notebook._cell_model(cell_id)
485
+ return CellHandle(self._notebook, cell_id)
486
+
487
+ def __iter__(self) -> Iterator[CellHandle]:
488
+ for cell in self._notebook._cell_order():
489
+ yield CellHandle(self._notebook, cell["id"])
490
+
491
+ def __len__(self) -> int:
492
+ return len(self._notebook._cell_order())
493
+
494
+ def __contains__(self, cell_id: str) -> bool:
495
+ try:
496
+ self._notebook._cell_model(cell_id)
497
+ return True
498
+ except CellNotFoundError:
499
+ return False
500
+
501
+ def insert_code(
502
+ self,
503
+ source: str,
504
+ *,
505
+ id: str | None = None,
506
+ before: str | None = None,
507
+ after: str | None = None,
508
+ ) -> CellHandle:
509
+ return self._insert(CellType.CODE, source, id=id, before=before, after=after)
510
+
511
+ def insert_markdown(
512
+ self,
513
+ source: str,
514
+ *,
515
+ id: str | None = None,
516
+ before: str | None = None,
517
+ after: str | None = None,
518
+ ) -> CellHandle:
519
+ return self._insert(CellType.MARKDOWN, source, id=id, before=before, after=after)
520
+
521
+ def _insert(
522
+ self,
523
+ cell_type: CellType,
524
+ source: str,
525
+ *,
526
+ id: str | None,
527
+ before: str | None,
528
+ after: str | None,
529
+ ) -> CellHandle:
530
+ _validate_position(before=before, after=after)
531
+ cell_id = id or _generated_cell_id()
532
+ response = self._notebook._request(
533
+ "POST",
534
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/cells",
535
+ hypernote=True,
536
+ json_body={
537
+ "id": cell_id,
538
+ "cell_type": cell_type.value,
539
+ "source": source,
540
+ "before": before,
541
+ "after": after,
542
+ },
543
+ )
544
+ _raise_response(response)
545
+ return CellHandle(self._notebook, cell_id)
546
+
547
+
548
+ class CellHandle:
549
+ """Live notebook-bound handle for a specific cell."""
550
+
551
+ def __init__(self, notebook: Notebook, cell_id: str):
552
+ self._notebook = notebook
553
+ self.id = cell_id
554
+
555
+ @property
556
+ def _cell(self) -> dict[str, Any]:
557
+ return self._notebook._cell_model(self.id)
558
+
559
+ @property
560
+ def type(self) -> CellType:
561
+ return CellType(self._cell.get("cell_type", CellType.CODE.value))
562
+
563
+ @property
564
+ def source(self) -> str:
565
+ return _cell_source(self._cell)
566
+
567
+ @property
568
+ def outputs(self) -> tuple[dict[str, Any], ...]:
569
+ return tuple(self._cell.get("outputs", []))
570
+
571
+ @property
572
+ def execution_count(self) -> int | None:
573
+ return self._cell.get("execution_count")
574
+
575
+ def replace(self, source: str) -> CellHandle:
576
+ quoted_cell_id = urllib.parse.quote(self.id, safe="")
577
+ response = self._notebook._request(
578
+ "PATCH",
579
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/cells/{quoted_cell_id}",
580
+ hypernote=True,
581
+ json_body={"source": source},
582
+ )
583
+ _raise_response(response)
584
+ return self
585
+
586
+ def delete(self) -> None:
587
+ quoted_cell_id = urllib.parse.quote(self.id, safe="")
588
+ response = self._notebook._request(
589
+ "DELETE",
590
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/cells/{quoted_cell_id}",
591
+ hypernote=True,
592
+ )
593
+ _raise_response(response)
594
+
595
+ def move(self, *, before: str | None = None, after: str | None = None) -> None:
596
+ _validate_position(before=before, after=after)
597
+ quoted_cell_id = urllib.parse.quote(self.id, safe="")
598
+ response = self._notebook._request(
599
+ "POST",
600
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/cells/{quoted_cell_id}/move",
601
+ hypernote=True,
602
+ json_body={"before": before, "after": after},
603
+ )
604
+ _raise_response(response)
605
+
606
+ def clear_outputs(self) -> CellHandle:
607
+ quoted_cell_id = urllib.parse.quote(self.id, safe="")
608
+ response = self._notebook._request(
609
+ "POST",
610
+ (
611
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/cells/"
612
+ f"{quoted_cell_id}/clear-outputs"
613
+ ),
614
+ hypernote=True,
615
+ json_body={},
616
+ )
617
+ _raise_response(response)
618
+ return self
619
+
620
+ def run(self) -> Job:
621
+ if self.type != CellType.CODE:
622
+ raise HypernoteError(f"Cell {self.id} is {self.type.value}, not code")
623
+ return self._notebook._run_cells([self.id])
624
+
625
+ def to_dict(self) -> dict[str, Any]:
626
+ return {
627
+ "id": self.id,
628
+ "type": self.type.value,
629
+ "source": self.source,
630
+ "outputs": list(self.outputs),
631
+ "execution_count": self.execution_count,
632
+ }
633
+
634
+
635
+ class Runtime:
636
+ """Live execution state for a notebook."""
637
+
638
+ def __init__(self, notebook: Notebook):
639
+ self._notebook = notebook
640
+
641
+ def _payload(self) -> dict[str, Any]:
642
+ response = self._notebook._request(
643
+ "GET",
644
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/runtime",
645
+ hypernote=True,
646
+ )
647
+ _raise_response(response)
648
+ return response.json()
649
+
650
+ @property
651
+ def status(self) -> RuntimeStatus:
652
+ return RuntimeStatus(self._payload()["state"])
653
+
654
+ @property
655
+ def recoverable(self) -> bool:
656
+ return bool(self._payload().get("recoverable", False))
657
+
658
+ @property
659
+ def session_id(self) -> str | None:
660
+ return self._payload().get("session_id")
661
+
662
+ @property
663
+ def kernel_id(self) -> str | None:
664
+ return self._payload().get("kernel_id")
665
+
666
+ @property
667
+ def kernel_name(self) -> str | None:
668
+ return self._payload().get("kernel_name")
669
+
670
+ def ensure(self) -> Runtime:
671
+ response = self._notebook._request(
672
+ "POST",
673
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/runtime/open",
674
+ hypernote=True,
675
+ json_body={
676
+ "client_id": self._notebook._config.actor_id,
677
+ "kernel_name": self._notebook._kernelspec_name(),
678
+ },
679
+ )
680
+ if response.status_code == 400:
681
+ raise RuntimeUnavailableError(response.text or "Runtime unavailable")
682
+ _raise_response(response)
683
+ return self
684
+
685
+ def stop(self) -> Runtime:
686
+ response = self._notebook._request(
687
+ "POST",
688
+ f"/hypernote/api/notebooks/{self._notebook._quote_path()}/runtime/stop",
689
+ hypernote=True,
690
+ json_body={},
691
+ )
692
+ if response.status_code == 400:
693
+ raise RuntimeUnavailableError(response.text or "Runtime unavailable")
694
+ _raise_response(response)
695
+ return self
696
+
697
+ def to_dict(self) -> dict[str, Any]:
698
+ payload = self._payload()
699
+ payload["status"] = payload.pop("state")
700
+ return payload
701
+
702
+
703
+ @dataclass
704
+ class Job:
705
+ notebook: Notebook
706
+ id: str
707
+ status: JobStatus
708
+ cell_ids: tuple[str, ...]
709
+ notebook_path: str
710
+
711
+ def refresh(self) -> Job:
712
+ response = self.notebook._request(
713
+ "GET",
714
+ f"/hypernote/api/jobs/{self.id}",
715
+ hypernote=True,
716
+ )
717
+ _raise_response(response)
718
+ payload = response.json()
719
+ self.status = JobStatus(payload["status"])
720
+ if payload.get("target_cells"):
721
+ self.cell_ids = tuple(json.loads(payload["target_cells"]))
722
+ return self
723
+
724
+ def wait(self, timeout: float | None = None) -> Job:
725
+ deadline = None if timeout is None else time.monotonic() + timeout
726
+ while True:
727
+ self.refresh()
728
+ if self.status in {
729
+ JobStatus.SUCCEEDED,
730
+ JobStatus.FAILED,
731
+ JobStatus.INTERRUPTED,
732
+ JobStatus.AWAITING_INPUT,
733
+ }:
734
+ return self
735
+ if deadline is not None and time.monotonic() >= deadline:
736
+ raise ExecutionTimeoutError(f"Timed out waiting for job {self.id}")
737
+ time.sleep(0.25)
738
+
739
+ def send_stdin(self, value: str) -> None:
740
+ response = self.notebook._request(
741
+ "POST",
742
+ f"/hypernote/api/jobs/{self.id}/stdin",
743
+ hypernote=True,
744
+ json_body={"value": value},
745
+ )
746
+ try:
747
+ _raise_response(response)
748
+ except HypernoteError as exc:
749
+ if response.status_code == 400:
750
+ raise InputNotExpectedError(str(exc)) from exc
751
+ raise
752
+
753
+ def to_dict(self) -> dict[str, Any]:
754
+ return {
755
+ "id": self.id,
756
+ "status": self.status.value,
757
+ "cell_ids": list(self.cell_ids),
758
+ "notebook_path": self.notebook_path,
759
+ }
760
+
761
+
762
+ def _new_notebook_model() -> dict[str, Any]:
763
+ return {
764
+ "type": "notebook",
765
+ "format": "json",
766
+ "content": {
767
+ "cells": [],
768
+ "metadata": {
769
+ "kernelspec": {"display_name": "Python 3", "name": "python3"},
770
+ "language_info": {"name": "python"},
771
+ },
772
+ "nbformat": 4,
773
+ "nbformat_minor": 5,
774
+ },
775
+ }
776
+
777
+
778
+ def _generated_cell_id() -> str:
779
+ return hashlib.sha1(f"{time.time_ns()}".encode()).hexdigest()[:12]
780
+
781
+
782
+ def _control_plane(config: _Config) -> _ControlPlane:
783
+ return _ControlPlane(config)
784
+
785
+
786
+ def _job_from_payload(config: _Config, payload: dict[str, Any]) -> Job:
787
+ target_cells: tuple[str, ...] = ()
788
+ if payload.get("target_cells"):
789
+ target_cells = tuple(json.loads(payload["target_cells"]))
790
+ notebook_path = payload["notebook_id"]
791
+ notebook = Notebook(path=notebook_path, _config=config)
792
+ return Job(
793
+ notebook=notebook,
794
+ id=payload["job_id"],
795
+ status=JobStatus(payload["status"]),
796
+ cell_ids=target_cells,
797
+ notebook_path=notebook_path,
798
+ )
799
+
800
+
801
+ def _validate_position(*, before: str | None, after: str | None) -> None:
802
+ if before is not None and after is not None:
803
+ raise HypernoteError("Specify only one of before= or after=")
804
+
805
+
806
+ def _resolve_insert_index(
807
+ cells: list[dict[str, Any]],
808
+ *,
809
+ before: str | None,
810
+ after: str | None,
811
+ ) -> int:
812
+ if before is None and after is None:
813
+ return len(cells)
814
+ if before is not None:
815
+ return _find_cell_index(cells, before)
816
+ return _find_cell_index(cells, after) + 1
817
+
818
+
819
+ def _find_cell_index(cells: list[dict[str, Any]], cell_id: str) -> int:
820
+ for index, cell in enumerate(cells):
821
+ if cell.get("id") == cell_id:
822
+ return index
823
+ raise CellNotFoundError(cell_id)
824
+
825
+
826
+ def _assign_position_keys(cells: list[dict[str, Any]]) -> list[dict[str, Any]]:
827
+ for index, cell in enumerate(cells):
828
+ metadata = dict(cell.get("metadata") or {})
829
+ hypernote_meta = dict(metadata.get("hypernote") or {})
830
+ hypernote_meta["position_key"] = f"{index:09d}"
831
+ metadata["hypernote"] = hypernote_meta
832
+ cell["metadata"] = metadata
833
+ return cells
834
+
835
+
836
+ def _cell_source(cell: dict[str, Any]) -> str:
837
+ source = cell.get("source", "")
838
+ if isinstance(source, list):
839
+ return "".join(source)
840
+ return str(source)
841
+
842
+
843
+ def _snapshot_cells(cells: list[dict[str, Any]]) -> tuple[_SnapshotCell, ...]:
844
+ entries = []
845
+ for index, cell in enumerate(cells):
846
+ metadata = cell.get("metadata") or {}
847
+ hypernote_meta = metadata.get("hypernote") or {}
848
+ order = hypernote_meta.get("position_key", f"{index:09d}")
849
+ entries.append(
850
+ _SnapshotCell(
851
+ id=cell["id"],
852
+ type=cell.get("cell_type", CellType.CODE.value),
853
+ order=order,
854
+ source_hash=_sha256(_cell_source(cell)),
855
+ outputs_hash=_sha256(
856
+ json.dumps(
857
+ cell.get("outputs", []),
858
+ sort_keys=True,
859
+ default=str,
860
+ )
861
+ ),
862
+ execution_count=cell.get("execution_count"),
863
+ )
864
+ )
865
+ return tuple(entries)
866
+
867
+
868
+ def _encode_snapshot_token(cells: list[dict[str, Any]]) -> str:
869
+ payload = {"cells": [asdict(entry) for entry in _snapshot_cells(cells)]}
870
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
871
+ return base64.urlsafe_b64encode(raw).decode()
872
+
873
+
874
+ def _decode_snapshot_token(token: str) -> tuple[_SnapshotCell, ...]:
875
+ try:
876
+ raw = base64.urlsafe_b64decode(token.encode())
877
+ payload = json.loads(raw.decode())
878
+ return tuple(_SnapshotCell(**entry) for entry in payload["cells"])
879
+ except Exception as exc: # pragma: no cover - defensive
880
+ raise HypernoteError("Invalid snapshot token") from exc
881
+
882
+
883
+ def _sha256(value: str) -> str:
884
+ return hashlib.sha256(value.encode()).hexdigest()
885
+
886
+
887
+ def _build_cell_status(
888
+ cell: dict[str, Any],
889
+ *,
890
+ full: bool,
891
+ changed: bool,
892
+ change_kinds: tuple[ChangeKind, ...],
893
+ ) -> CellStatus:
894
+ source = _cell_source(cell)
895
+ outputs = cell.get("outputs", [])
896
+ return CellStatus(
897
+ id=cell["id"],
898
+ type=CellType(cell.get("cell_type", CellType.CODE.value)),
899
+ changed=changed,
900
+ change_kinds=change_kinds,
901
+ source=source if full else _truncate(source, SUMMARY_SOURCE_CHARS),
902
+ outputs=tuple(outputs) if full else tuple(_summarize_output(output) for output in outputs),
903
+ execution_count=cell.get("execution_count"),
904
+ )
905
+
906
+
907
+ def _summarize_output(output: dict[str, Any]) -> dict[str, Any]:
908
+ output_type = output.get("output_type", "unknown")
909
+ if output_type == "stream":
910
+ return {
911
+ "output_type": output_type,
912
+ "name": output.get("name"),
913
+ "text": _truncate(str(output.get("text", "")), SUMMARY_OUTPUT_TEXT_CHARS),
914
+ }
915
+ if output_type in {"display_data", "execute_result"}:
916
+ data = output.get("data", {})
917
+ return {
918
+ "output_type": output_type,
919
+ "data_keys": sorted(data.keys()),
920
+ "text": _truncate(str(data.get("text/plain", "")), SUMMARY_OUTPUT_TEXT_CHARS),
921
+ }
922
+ if output_type == "error":
923
+ return {
924
+ "output_type": output_type,
925
+ "ename": output.get("ename"),
926
+ "evalue": output.get("evalue"),
927
+ }
928
+ return {"output_type": output_type}
929
+
930
+
931
+ def _truncate(value: str, limit: int) -> str:
932
+ if len(value) <= limit:
933
+ return value
934
+ return value[: limit - 1] + "…"
935
+
936
+
937
+ def _build_summary(
938
+ notebook_path: str,
939
+ runtime_status: RuntimeStatus,
940
+ cells: list[dict[str, Any]],
941
+ *,
942
+ diff: bool,
943
+ changed_count: int = 0,
944
+ ) -> str:
945
+ code_cells = sum(1 for cell in cells if cell.get("cell_type") == CellType.CODE.value)
946
+ markdown_cells = sum(1 for cell in cells if cell.get("cell_type") == CellType.MARKDOWN.value)
947
+ executed = sum(1 for cell in cells if cell.get("execution_count") is not None)
948
+ if diff:
949
+ return (
950
+ f"{os.path.basename(notebook_path)} · {changed_count} changed cells · "
951
+ f"runtime {runtime_status.value}"
952
+ )
953
+ return (
954
+ f"{os.path.basename(notebook_path)} · {len(cells)} cells "
955
+ f"({code_cells} code, {markdown_cells} markdown) · "
956
+ f"{executed} executed · runtime {runtime_status.value}"
957
+ )
958
+
959
+
960
+ def _raise_notebook_response(response: httpx.Response, notebook_path: str) -> None:
961
+ if response.is_success:
962
+ return
963
+ if response.status_code == 404:
964
+ raise NotebookNotFoundError(notebook_path)
965
+ _raise_response(response)
966
+
967
+
968
+ def _raise_response(response: httpx.Response) -> None:
969
+ if response.is_success:
970
+ return
971
+ if response.status_code == 404:
972
+ raise HypernoteError("Resource not found")
973
+ if response.status_code == 400:
974
+ raise HypernoteError(response.text or "Bad request")
975
+ raise HypernoteError(f"{response.status_code}: {response.text}")