pyviewarr 0.3.4__tar.gz → 0.4.0__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.
@@ -15,3 +15,6 @@ viewarr/pkg
15
15
 
16
16
  # playwright test artifacts
17
17
  test-results/
18
+ tests/e2e/screenshots/
19
+ # notebooks the e2e suite creates via Jupyter "New Notebook"
20
+ Untitled*.ipynb
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pyviewarr
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  License-File: LICENSE
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: anywidget<0.10,>=0.9
@@ -1,5 +1,6 @@
1
1
  import importlib.metadata
2
2
  import pathlib
3
+ import warnings
3
4
  from IPython.display import display
4
5
  from dataclasses import asdict, dataclass
5
6
  from typing import Any, Callable, Dict, Literal, Optional, Tuple, TYPE_CHECKING, Union
@@ -310,18 +311,12 @@ class ViewArrWidget(anywidget.AnyWidget):
310
311
  _esm = pathlib.Path(__file__).parent / "static" / "widget.js"
311
312
  _css = pathlib.Path(__file__).parent / "static" / "widget.css"
312
313
 
313
- # Binary image data (synced as DataView in JavaScript)
314
- data = traitlets.Bytes(b"").tag(sync=True)
315
-
316
- # Image dimensions
314
+ # Image dimensions of the currently shown slice (informational / introspection;
315
+ # the actual pixels are sent on demand over the custom message channel).
317
316
  image_width = traitlets.Int(0).tag(sync=True)
318
317
  image_height = traitlets.Int(0).tag(sync=True)
319
318
 
320
- # Monotonic token that increments whenever image payload traits are updated.
321
- # Frontend applies setImageData when it observes a new token value.
322
- _image_update_token = traitlets.Int(0).tag(sync=True)
323
-
324
- # Data type string for viewarr (e.g., "f32", "u16")
319
+ # Data type string for viewarr (e.g., "f32", "u16") of the current slice.
325
320
  dtype = traitlets.Unicode("f64").tag(sync=True)
326
321
 
327
322
  # Widget display dimensions (CSS pixels)
@@ -331,7 +326,8 @@ class ViewArrWidget(anywidget.AnyWidget):
331
326
  # Array shape (list of dimensions)
332
327
  shape = traitlets.List(traitlets.Int()).tag(sync=True)
333
328
 
334
- # Current slice indices for leading axes (empty for 2D arrays)
329
+ # Current slice indices for leading axes (empty for 2D arrays). Informational:
330
+ # navigation is driven by the frontend via the "request_slice" message.
335
331
  current_slice_indices = traitlets.List(traitlets.Int()).tag(sync=True)
336
332
 
337
333
  # Optional initial viewer state object (mapped to JS state keys)
@@ -426,8 +422,11 @@ class ViewArrWidget(anywidget.AnyWidget):
426
422
  super().__init__(**kwargs)
427
423
  self._on_shift_click = shift_click_callback
428
424
  self._array = None
429
- self.observe(self._on_slice_indices_changed, names=["current_slice_indices"])
430
425
  self.observe(self._on_shift_click_event, names=["_shift_click_event"])
426
+ # Slices are served on demand over a custom message channel rather than
427
+ # via synced traits, so every request produces a fresh slice (synced List
428
+ # traits would dedup an unchanged value and stall a play-mode prefetch).
429
+ self.on_msg(self._handle_frontend_msg)
431
430
 
432
431
  def set_shift_click_callback(
433
432
  self, callback: Optional[ShiftClickCallback]
@@ -447,33 +446,84 @@ class ViewArrWidget(anywidget.AnyWidget):
447
446
  if isinstance(x, (int, float)) and isinstance(y, (int, float)):
448
447
  callback(float(x), float(y))
449
448
 
450
- def _on_slice_indices_changed(self, change):
451
- """Update the displayed slice when slice indices change."""
452
- self._update_slice()
453
-
454
- def _update_slice(self):
455
- """Compute the current 2D slice and update traits."""
456
- if self._array is None:
449
+ def _handle_frontend_msg(self, _widget, content, _buffers):
450
+ """Handle a custom message from the frontend (e.g. a slice request)."""
451
+ if not isinstance(content, dict):
457
452
  return
458
-
453
+ if content.get("type") == "request_slice":
454
+ try:
455
+ indices = [int(i) for i in content.get("indices", [])]
456
+ except (TypeError, ValueError):
457
+ return
458
+ # Every request must get an answer. The viewer only has one slice
459
+ # request in flight at a time, so a request that goes unanswered --
460
+ # because the array is gone, the indices are stale, or reading the
461
+ # slice raised -- would leave its scrubber dead for the rest of the
462
+ # session. Report the failure instead of letting it escape here.
463
+ try:
464
+ self._send_slice(indices)
465
+ except Exception as exc: # noqa: BLE001 - must not wedge the viewer
466
+ self._send_slice_error(indices, exc)
467
+
468
+ def _compute_slice(self, indices):
469
+ """Return the 2D slice at ``indices`` as a contiguous little-endian array."""
459
470
  arr = self._array
460
- indices = self.current_slice_indices
461
-
462
- # Slice the array: leading axes use indices, last two are full slices
463
471
  if len(indices) > 0:
464
472
  slice_obj = tuple(indices) + (slice(None), slice(None))
465
473
  slice_arr = arr[slice_obj]
466
474
  else:
467
475
  slice_arr = arr
468
-
469
- # Ensure slice is contiguous and little-endian
470
476
  slice_arr = np.ascontiguousarray(slice_arr)
471
- slice_arr = slice_arr.astype(slice_arr.dtype.newbyteorder("<"))
477
+ return slice_arr.astype(slice_arr.dtype.newbyteorder("<"))
472
478
 
473
- self.dtype = _numpy_dtype_to_viewarr(slice_arr.dtype)
479
+ def _refresh_slice_state(self, indices):
480
+ """Compute the slice at ``indices`` and update the informational traits."""
481
+ slice_arr = self._compute_slice(indices)
482
+ self.current_slice_indices = list(indices)
474
483
  self.image_height, self.image_width = slice_arr.shape
475
- self.data = slice_arr.tobytes()
476
- self._image_update_token += 1
484
+ self.dtype = _numpy_dtype_to_viewarr(slice_arr.dtype)
485
+ return slice_arr
486
+
487
+ def _send_slice(self, indices):
488
+ """Serve a slice to the frontend over the custom message channel.
489
+
490
+ Always produces a slice for the requested indices (no dedup), so a
491
+ play-mode prefetch that re-requests the slice the kernel already holds
492
+ still completes.
493
+ """
494
+ if self._array is None:
495
+ raise RuntimeError("no array is set on this viewer")
496
+ slice_arr = self._refresh_slice_state(indices)
497
+ self.send(
498
+ {
499
+ "type": "slice",
500
+ "indices": list(indices),
501
+ "width": int(self.image_width),
502
+ "height": int(self.image_height),
503
+ "dtype": self.dtype,
504
+ },
505
+ buffers=[slice_arr.tobytes()],
506
+ )
507
+
508
+ def _send_slice_error(self, indices, exc):
509
+ """Tell the frontend a slice request could not be served.
510
+
511
+ The frontend hands this to the viewer so it stops waiting on the reply
512
+ and the slider keeps working; without it one failed read disables
513
+ slice navigation until the widget is recreated.
514
+ """
515
+ warnings.warn(
516
+ f"pyviewarr could not serve slice {list(indices)}: {exc}",
517
+ RuntimeWarning,
518
+ stacklevel=2,
519
+ )
520
+ self.send(
521
+ {
522
+ "type": "slice_error",
523
+ "indices": list(indices),
524
+ "message": f"{type(exc).__name__}: {exc}",
525
+ }
526
+ )
477
527
 
478
528
  def set_array(self, arr: np.ndarray) -> None:
479
529
  """Set the array data to display.
@@ -493,12 +543,11 @@ class ViewArrWidget(anywidget.AnyWidget):
493
543
  # Set shape
494
544
  self.shape = list(arr.shape)
495
545
 
496
- # Initialize slice indices for leading axes
546
+ # Initialize on the first slice. We only refresh the informational traits
547
+ # here; the frontend pulls the actual pixels on demand (on viewer-ready
548
+ # and whenever the shape changes) via the "request_slice" message.
497
549
  num_leading_axes = arr.ndim - 2
498
- self.current_slice_indices = [0] * num_leading_axes
499
-
500
- # Update the displayed slice
501
- self._update_slice()
550
+ self._refresh_slice_state([0] * num_leading_axes)
502
551
 
503
552
  def get_current_slice(self) -> np.ndarray:
504
553
  """Get the current 2D slice being displayed.
@@ -0,0 +1 @@
1
+ .pyviewarr{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.pyviewarr-container{background-color:transparent;border:1px solid var(--jp-border-color1, var(--vscode-panel-border, #333));overflow:hidden}.pyviewarr-container:focus-within{border-color:var(--jp-brand-color1, var(--vscode-focusBorder, #2196f3));box-shadow:inset 0 0 0 1px var(--jp-brand-color1, var(--vscode-focusBorder, #2196f3))}.pyviewarr-container canvas:focus{outline:none}