From 43a071d75f93cd3c9998a0584def97b16665c86a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 May 2025 11:28:09 -0400 Subject: [PATCH 1/3] fix: fix tests and some typing --- examples/basic_scene.py | 2 +- src/scenex/adaptors/__init__.py | 3 +- src/scenex/adaptors/_auto.py | 17 +++++++++- src/scenex/adaptors/_base.py | 11 +++++- src/scenex/adaptors/_pygfx/_image.py | 4 ++- src/scenex/adaptors/_pygfx/_points.py | 4 ++- src/scenex/adaptors/_pygfx/_volume.py | 8 +++-- src/scenex/adaptors/_registry.py | 5 ++- src/scenex/adaptors/_vispy/_image.py | 4 ++- src/scenex/adaptors/_vispy/_points.py | 6 ++-- src/scenex/adaptors/_vispy/_volume.py | 6 ++-- src/scenex/imgui/_controls.py | 4 +-- src/scenex/model/_base.py | 17 ++++++---- src/scenex/model/_canvas.py | 5 +-- src/scenex/model/_view.py | 7 ++-- src/scenex/util.py | 10 ++++-- tests/test_basic_scene.py | 13 ++++++-- tests/test_model.py | 48 --------------------------- 18 files changed, 92 insertions(+), 82 deletions(-) diff --git a/examples/basic_scene.py b/examples/basic_scene.py index 26fc6089..9206ba05 100644 --- a/examples/basic_scene.py +++ b/examples/basic_scene.py @@ -38,7 +38,7 @@ view.scene.add_child(image) # both are optional, just for example -snx.use("pygfx") +# snx.use("pygfx") # snx.use("vispy") snx.show(view) diff --git a/src/scenex/adaptors/__init__.py b/src/scenex/adaptors/__init__.py index 4a81556f..a6595c14 100644 --- a/src/scenex/adaptors/__init__.py +++ b/src/scenex/adaptors/__init__.py @@ -5,7 +5,7 @@ in the model. """ -from ._auto import get_adaptor_registry, run, use +from ._auto import get_adaptor_registry, get_all_adaptors, run, use from ._base import Adaptor from ._registry import AdaptorRegistry @@ -13,6 +13,7 @@ "Adaptor", "AdaptorRegistry", "get_adaptor_registry", + "get_all_adaptors", "run", "use", ] diff --git a/src/scenex/adaptors/_auto.py b/src/scenex/adaptors/_auto.py index 5042c577..6b21084d 100644 --- a/src/scenex/adaptors/_auto.py +++ b/src/scenex/adaptors/_auto.py @@ -2,9 +2,15 @@ import importlib.util import os -from typing import TYPE_CHECKING, Literal, TypeAlias, TypeGuard, get_args +import sys +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeGuard, cast, get_args if TYPE_CHECKING: + from collections.abc import Iterator + + from scenex.adaptors._base import Adaptor + from ._registry import AdaptorRegistry KnownBackend: TypeAlias = Literal["vispy", "pygfx"] @@ -34,6 +40,15 @@ def get_adaptor_registry(backend: KnownBackend | str | None = None) -> AdaptorRe return _pygfx.adaptors +def get_all_adaptors(obj: Any) -> Iterator[Adaptor]: + """Get all adaptors for the given object.""" + for mod_name in ["scenex.adaptors._vispy", "scenex.adaptors._pygfx"]: + if mod := sys.modules.get(mod_name): + reg = cast("AdaptorRegistry", mod.adaptors) + with suppress(KeyError): + yield reg.get_adaptor(obj, create=False) + + def determine_backend(request: KnownBackend | str | None = None) -> KnownBackend: """Get the name of the backend adaptor registry. diff --git a/src/scenex/adaptors/_base.py b/src/scenex/adaptors/_base.py index ba3e99ee..c21542db 100644 --- a/src/scenex/adaptors/_base.py +++ b/src/scenex/adaptors/_base.py @@ -20,6 +20,7 @@ TNode = TypeVar("TNode", bound="model.Node", covariant=True) TCamera = TypeVar("TCamera", bound="model.Camera", covariant=True) TImage = TypeVar("TImage", bound="model.Image", covariant=True) +TVolume = TypeVar("TVolume", bound="model.Volume", covariant=True) TPoints = TypeVar("TPoints", bound="model.Points", covariant=True) TCanvas = TypeVar("TCanvas", bound="model.Canvas", covariant=True) TView = TypeVar("TView", bound="model.View", covariant=True) @@ -47,6 +48,9 @@ def _snx_get_native(self) -> TNative: def handle_event(self, info: EmissionInfo) -> None: """Receive info from psygnal callback and convert to adaptor call.""" signal_name = info.signal.name + if signal_name == "parent": + # Parent change events are handled by the parent adaptor. + return try: name = self.SETTER_METHOD.format(name=signal_name) @@ -138,6 +142,11 @@ def _snx_set_gamma(self, arg: float, /) -> None: ... def _snx_set_interpolation(self, arg: model.InterpolationMode, /) -> None: ... +class VolumeAdaptor(ImageAdaptor[TVolume, TNative]): + @abstractmethod + def _snx_set_render_mode(self, arg: model.RenderMode, /) -> None: ... + + class PointsAdaptor(NodeAdaptor[TPoints, TNative]): """Protocol for a backend Image adaptor object.""" @@ -154,7 +163,7 @@ def _snx_set_edge_width(self, arg: float, /) -> None: ... @abstractmethod def _snx_set_symbol(self, arg: str, /) -> None: ... @abstractmethod - def _snx_set_scaling(self, arg: str, /) -> None: ... + def _snx_set_scaling(self, arg: model.ScalingMode, /) -> None: ... @abstractmethod def _snx_set_antialias(self, arg: float, /) -> None: ... diff --git a/src/scenex/adaptors/_pygfx/_image.py b/src/scenex/adaptors/_pygfx/_image.py index 6249e0a0..cd06f402 100644 --- a/src/scenex/adaptors/_pygfx/_image.py +++ b/src/scenex/adaptors/_pygfx/_image.py @@ -6,6 +6,8 @@ import numpy as np import pygfx +from scenex.adaptors._base import ImageAdaptor + from ._node import Node if TYPE_CHECKING: @@ -17,7 +19,7 @@ logger = logging.getLogger("scenex.adaptors.pygfx") -class Image(Node): +class Image(Node, ImageAdaptor): """pygfx backend adaptor for an Image node.""" _pygfx_node: pygfx.Image diff --git a/src/scenex/adaptors/_pygfx/_points.py b/src/scenex/adaptors/_pygfx/_points.py index fb426385..63489c48 100644 --- a/src/scenex/adaptors/_pygfx/_points.py +++ b/src/scenex/adaptors/_pygfx/_points.py @@ -6,6 +6,8 @@ import numpy as np import pygfx +from scenex.adaptors._base import PointsAdaptor + from ._node import Node if TYPE_CHECKING: @@ -25,7 +27,7 @@ } -class Points(Node): +class Points(Node, PointsAdaptor): """Vispy backend adaptor for an Points node.""" _pygfx_node: pygfx.Points diff --git a/src/scenex/adaptors/_pygfx/_volume.py b/src/scenex/adaptors/_pygfx/_volume.py index 6524b16d..91e7ba79 100644 --- a/src/scenex/adaptors/_pygfx/_volume.py +++ b/src/scenex/adaptors/_pygfx/_volume.py @@ -6,6 +6,8 @@ import numpy as np import pygfx +from scenex.adaptors._base import VolumeAdaptor + from ._node import Node if TYPE_CHECKING: @@ -15,7 +17,7 @@ from scenex import model -class Volume(Node): +class Volume(Node, VolumeAdaptor): """pygfx backend adaptor for a Volume node.""" _pygfx_node: pygfx.Volume @@ -24,7 +26,7 @@ class Volume(Node): def __init__(self, volume: model.Volume, **backend_kwargs: Any) -> None: self._snx_set_data(volume.data) - self._snx_set_rendermode(volume.render_mode, volume.interpolation) + self._snx_set_render_mode(volume.render_mode, volume.interpolation) self._pygfx_node = pygfx.Volume(self._geometry, self._material) def _snx_set_cmap(self, arg: Colormap) -> None: @@ -55,7 +57,7 @@ def _snx_set_data(self, data: ArrayLike) -> None: self._texture = self._create_texture(np.asanyarray(data)) self._geometry = pygfx.Geometry(grid=self._texture) - def _snx_set_rendermode( + def _snx_set_render_mode( self, data: model.RenderMode, interpolation: model.InterpolationMode | None = None, diff --git a/src/scenex/adaptors/_registry.py b/src/scenex/adaptors/_registry.py index 0eed148b..7915efca 100644 --- a/src/scenex/adaptors/_registry.py +++ b/src/scenex/adaptors/_registry.py @@ -89,7 +89,10 @@ def get_adaptor(self, obj: _M, create: bool = True) -> _base.Adaptor[_M, Any]: """Get the adaptor for the given model object, create if `create` is True.""" if obj._model_id.hex not in self._objects: if not create: - raise KeyError(f"No adaptor found for {obj!r}, and create=False") + raise KeyError( + f"{type(self).__name__!r} has no adaptor for {type(obj)} @ " + f"{id(obj):x}, and create=False" + ) logger.debug( "Creating %r Adaptor %-14r id: %s", type(self).__module__, diff --git a/src/scenex/adaptors/_vispy/_image.py b/src/scenex/adaptors/_vispy/_image.py index f2e59aa7..cce126b8 100644 --- a/src/scenex/adaptors/_vispy/_image.py +++ b/src/scenex/adaptors/_vispy/_image.py @@ -5,6 +5,8 @@ import vispy.scene import vispy.visuals +from scenex.adaptors._base import ImageAdaptor + from ._node import Node if TYPE_CHECKING: @@ -14,7 +16,7 @@ from scenex import model -class Image(Node): +class Image(Node, ImageAdaptor): """pygfx backend adaptor for an Image node.""" _vispy_node: vispy.visuals.ImageVisual diff --git a/src/scenex/adaptors/_vispy/_points.py b/src/scenex/adaptors/_vispy/_points.py index 3933a5f3..e5f19d5f 100644 --- a/src/scenex/adaptors/_vispy/_points.py +++ b/src/scenex/adaptors/_vispy/_points.py @@ -7,6 +7,8 @@ import vispy.scene import vispy.visuals +from scenex.adaptors._base import PointsAdaptor + from ._node import Node if TYPE_CHECKING: @@ -26,7 +28,7 @@ } -class Points(Node): +class Points(Node, PointsAdaptor): """Vispy backend adaptor for an Points node.""" _model: model.Points @@ -63,7 +65,7 @@ def _snx_set_edge_width(self, edge_width: float) -> None: def _snx_set_symbol(self, symbol: str) -> None: self._update_vispy_data() - def _snx_set_scaling(self, scaling: str) -> None: + def _snx_set_scaling(self, scaling: model.ScalingMode) -> None: self._update_vispy_data() def _snx_set_antialias(self, antialias: float) -> None: diff --git a/src/scenex/adaptors/_vispy/_volume.py b/src/scenex/adaptors/_vispy/_volume.py index 12009f17..cc426407 100644 --- a/src/scenex/adaptors/_vispy/_volume.py +++ b/src/scenex/adaptors/_vispy/_volume.py @@ -6,6 +6,8 @@ import vispy.scene import vispy.visuals +from scenex.adaptors._base import VolumeAdaptor + from ._node import Node if TYPE_CHECKING: @@ -15,7 +17,7 @@ from scenex import model -class Volume(Node): +class Volume(Node, VolumeAdaptor): """vispy backend adaptor for a Volume node.""" _vispy_node: vispy.visuals.VolumeVisual @@ -41,7 +43,7 @@ def _snx_set_interpolation(self, arg: model.InterpolationMode) -> None: def _snx_set_data(self, data: ArrayLike) -> None: self._vispy_node.set_data(np.asarray(data)) - def _snx_set_rendermode( + def _snx_set_render_mode( self, data: model.RenderMode, interpolation: model.InterpolationMode | None = None, diff --git a/src/scenex/imgui/_controls.py b/src/scenex/imgui/_controls.py index 7df73bda..46d0c066 100644 --- a/src/scenex/imgui/_controls.py +++ b/src/scenex/imgui/_controls.py @@ -37,8 +37,8 @@ def add_imgui_controls(view: View) -> None: """Add imgui controls to the given canvas.""" snx_canvas_model = view.canvas - snx_canvas_adaptor = snx_canvas_model._get_adaptor() - snx_view_adaptor = view._get_adaptor() + snx_canvas_adaptor = snx_canvas_model._get_adaptors(backend="pygfx")[0] + snx_view_adaptor = view._get_adaptors(backend="pygfx")[0] render_canv = snx_canvas_model._get_native() if not ( diff --git a/src/scenex/model/_base.py b/src/scenex/model/_base.py index 764715f6..672f1847 100644 --- a/src/scenex/model/_base.py +++ b/src/scenex/model/_base.py @@ -75,16 +75,19 @@ def __repr_args__(self) -> Iterable[tuple[str | None, Any]]: continue yield key, val - def _get_adaptor( + def _get_adaptors( self, backend: str | None = None, create: bool = False - ) -> "Adaptor": + ) -> list["Adaptor"]: """Get all adaptors for this model.""" - from scenex.adaptors import get_adaptor_registry + from scenex.adaptors import get_adaptor_registry, get_all_adaptors - reg = get_adaptor_registry(backend=backend) - return reg.get_adaptor(self, create=create) + if backend: + reg = get_adaptor_registry(backend=backend) + return [reg.get_adaptor(self, create=create)] + else: + return list(get_all_adaptors(self)) def _get_native(self, backend: str | None = None, create: bool = False) -> Any: """Get the native object for this model.""" - adaptor = self._get_adaptor(backend=backend, create=create) - return adaptor._snx_get_native() + if adaptors := self._get_adaptors(backend=backend, create=create): + return adaptors[0]._snx_get_native() diff --git a/src/scenex/model/_canvas.py b/src/scenex/model/_canvas.py index f6dd4a69..87889459 100644 --- a/src/scenex/model/_canvas.py +++ b/src/scenex/model/_canvas.py @@ -51,5 +51,6 @@ def size(self, value: tuple[int, int]) -> None: def render(self) -> np.ndarray: """Show the canvas.""" - adaptor = cast("CanvasAdaptor", self._get_adaptor()) - return adaptor._snx_render() + if adaptors := self._get_adaptors(): + return cast("CanvasAdaptor", adaptors[0])._snx_render() + raise RuntimeError("No adaptor found for Canvas.") diff --git a/src/scenex/model/_view.py b/src/scenex/model/_view.py index 103ae5a1..09ea9747 100644 --- a/src/scenex/model/_view.py +++ b/src/scenex/model/_view.py @@ -75,6 +75,7 @@ def canvas(self, value: Canvas) -> None: self._canvas.views.append(self) def render(self) -> np.ndarray: - """Show the canvas.""" - adaptor = cast("ViewAdaptor", self._get_adaptor()) - return adaptor._snx_render() + """Render the view to an array.""" + if adaptors := self._get_adaptors(): + return cast("ViewAdaptor", adaptors[0])._snx_render() + raise RuntimeError("No adaptor found for View.") diff --git a/src/scenex/util.py b/src/scenex/util.py index b2779f75..f42051f8 100644 --- a/src/scenex/util.py +++ b/src/scenex/util.py @@ -90,7 +90,9 @@ def _ensure_iterable(obj: object) -> Iterable[Any]: ) -def show(obj: model.Node | model.View | model.Canvas) -> model.Canvas: +def show( + obj: model.Node | model.View | model.Canvas, *, backend: str | None = None +) -> model.Canvas: """Show a scene or view. Parameters @@ -98,9 +100,13 @@ def show(obj: model.Node | model.View | model.Canvas) -> model.Canvas: obj : Node | View | Canvas The scene or view to show. If a Node is provided, it will be wrapped in a Scene and then in a View. + backend : str, optional + The backend to use for rendering. If not specified, the default backend will be + used. Defaults to None. """ from .adaptors import get_adaptor_registry + view = None if isinstance(obj, model.Canvas): canvas = obj else: @@ -115,7 +121,7 @@ def show(obj: model.Node | model.View | model.Canvas) -> model.Canvas: canvas = model.Canvas(views=[view]) # pyright: ignore[reportArgumentType] canvas.visible = True - reg = get_adaptor_registry() + reg = get_adaptor_registry(backend=backend) reg.get_adaptor(canvas, create=True) for view in canvas.views: cam = reg.get_adaptor(view.camera) diff --git a/tests/test_basic_scene.py b/tests/test_basic_scene.py index 88adb178..b13e18ab 100644 --- a/tests/test_basic_scene.py +++ b/tests/test_basic_scene.py @@ -44,8 +44,15 @@ def _child_names(obj: Any) -> list[str]: return [_obj_name(child) for child in obj.children] -def test_basic_view(basic_view: snx.View) -> None: - snx.show(basic_view) +@pytest.mark.parametrize("backend", BACKENDS) +def test_basic_view(basic_view: snx.View, backend: str) -> None: + snx.show(basic_view, backend=backend) + + # make sure we've got the right backend + adaptors = basic_view._get_adaptors(backend=backend, create=False) + assert adaptors + assert backend in type(adaptors[0]).__module__ + assert isinstance(repr(basic_view), str) assert isinstance(basic_view.model_dump(), dict) assert isinstance(basic_view.model_dump_json(), str) @@ -58,7 +65,7 @@ def test_basic_view(basic_view: snx.View) -> None: def test_view_tree_matches_native(basic_view: snx.View, backend: str) -> None: """Test that the structure of the tree generated by the model matches the structure of the tree generated by the native backend.""" - basic_view._get_adaptor(backend=backend, create=True) + basic_view._get_adaptors(backend=backend, create=True) model_tree = snx.util.tree_dict(basic_view.scene, obj_name=_obj_name) native_scene = basic_view.scene._get_native(backend=backend) diff --git a/tests/test_model.py b/tests/test_model.py index 609d0ba5..bc42530f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,13 +1,7 @@ -import sys from unittest.mock import Mock -import pytest -from typing_extensions import TypeAliasType - import scenex as snx -from scenex import model from scenex.adaptors import Adaptor -from scenex.model._base import EventedBase # recursively collect all subclasses of a type @@ -23,53 +17,11 @@ def collect_adaptors(cls: type) -> list[type]: ADAPTORS = collect_adaptors(Adaptor) -def _get_model_type(cls: TypeAliasType) -> type[EventedBase]: - """Get the model class for a given adaptor class.""" - params = cls.__parameters__ - if not (ref := params[0].__bound__): - raise ValueError( - f"Cannot get model class for {cls.__name__}: no bound type found" - ) - if sys.version_info >= (3, 13): - model_type = ref._evaluate( - {"model": model}, None, type_params=None, recursive_guard=frozenset() - ) - else: - model_type = ref._evaluate({"model": model}, None, recursive_guard=frozenset()) - assert issubclass(model_type, EventedBase) - return model_type # type: ignore [no-any-return] - - def test_schema() -> None: assert snx.Canvas.model_json_schema(mode="serialization") assert snx.Canvas.model_json_schema(mode="validation") -@pytest.mark.parametrize("adaptor", ADAPTORS) -def test_events(adaptor: TypeAliasType) -> None: - """Test that models have events corresponding to all _snx_set_* methods.""" - snx_methods = { - name[9:] - for name, method in adaptor.__dict__.items() - if name.startswith("_snx_set_") and callable(method) - } - - model_type = _get_model_type(adaptor) - if not model_type.model_fields: - # no fields, so no events - return - signal_group = model_type.events._create_group(model_type) - signal_names = set(signal_group._psygnal_signals) - # remove the _snx_set_ prefix from the method names - # find the difference between the two sets - missing = snx_methods - signal_names - if missing: - raise AssertionError( - f"Missing events on {model_type.__module__}.{model_type.__name__}: " - f"{', '.join(sorted(missing))}" - ) - - def test_changing_parent_pure_model() -> None: """Test that changing the parent of a model object works, and emits events.""" # create a scene and a view From 724ab1401a3bf1dc460ff529654514762fd68f00 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 May 2025 11:32:31 -0400 Subject: [PATCH 2/3] add imgui bundle to test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0d485d..baccda11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers - name: Install dependencies - run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} + run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} --extra imgui-bundle - name: Test shell: bash From f7d3b918efda9508e55e9b286d1f36800eeab7f3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 May 2025 11:32:51 -0400 Subject: [PATCH 3/3] fix extra name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baccda11..48230072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers - name: Install dependencies - run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} --extra imgui-bundle + run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} --extra imgui - name: Test shell: bash