From 6087d69d8bfc818630fd73499260f52769fb8b05 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 30 Apr 2025 16:38:15 +0800 Subject: [PATCH 001/123] Add ColormapSet for color blending --- carta/constants.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/carta/constants.py b/carta/constants.py index 12e72d0..14391af 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -23,6 +23,13 @@ class ComplexComponent(StrEnum): Colormap.__doc__ = """All available colormaps.""" +class ColormapSet(StrEnum): + """Colormap sets for color blending.""" + RGB = "RGB" + CMY = "CMY" + Rainbow = "Rainbow" + + Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) Scaling.__doc__ = """Colormap scaling types.""" From d6961b202eaaaacf68181bc6e0a5f6af56a8153c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 30 Apr 2025 18:13:07 +0800 Subject: [PATCH 002/123] Update Image.make_active to support CARTA 5.0.0+ API changes --- carta/image.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/carta/image.py b/carta/image.py index 0567520..7038701 100644 --- a/carta/image.py +++ b/carta/image.py @@ -3,14 +3,15 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ -from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin -from .units import AngularSize, WorldCoordinate -from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr +from .constants import (Polarization, SpatialAxis, SpectralSystem, + SpectralType, SpectralUnit) +from .contours import Contours from .metadata import parse_header - from .raster import Raster -from .contours import Contours +from .units import AngularSize, WorldCoordinate +from .util import BasePathMixin, CartaActionFailed, Macro, cached +from .validation import (Attr, Attrs, Boolean, Constant, Coordinate, Evaluate, + NoneOr, Number, OneOf, Size, validate) from .vector_overlay import VectorOverlay from .wcs_overlay import ImageWCSOverlay @@ -251,7 +252,12 @@ def polarizations(self): def make_active(self): """Make this the active image.""" - self.session.call_action("setActiveFrameById", self.image_id) + try: + # Before CARTA 5.0.0 + self.session.call_action("setActiveFrameById", self.image_id) + except CartaActionFailed: + # After CARTA 5.0.0 (inclusive) + self.session.call_action("setActiveImageByFileId", self.image_id) def make_spatial_reference(self): """Make this image the spatial reference.""" From 9fa89de21cebe0d2ca63f2a65d347535368e35f4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 5 May 2025 12:07:19 +0800 Subject: [PATCH 003/123] Add ColorBlending class with layer management and color blending functionality --- carta/colorblending.py | 414 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 carta/colorblending.py diff --git a/carta/colorblending.py b/carta/colorblending.py new file mode 100644 index 0000000..bf2ff16 --- /dev/null +++ b/carta/colorblending.py @@ -0,0 +1,414 @@ +from .constants import Colormap, ColormapSet +from .image import Image +from .util import BasePathMixin, CartaActionFailed, Macro, cached +from .validation import (Boolean, Constant, Coordinate, InstanceOf, IterableOf, + Number, validate) + + +class Layer(BasePathMixin): + """This object represents a single layer in a color blending object. +` + Parameters + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + The layer ID. + + Attributes + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + The layer ID. + session : :obj:`carta.session.Session` + The session object associated with this layer. + """ + def __init__(self, colorblending, layer_id): + self.colorblending = colorblending + self.layer_id = layer_id + self.session = colorblending.session + + self._base_path = f"{self.colorblending._base_path}.frames[{layer_id}]" + self._frame = Macro("", self._base_path) + + @classmethod + def from_list(cls, colorblending, layer_ids): + """ + Create a list of Layer objects from a list of layer IDs. + + Parameters + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_ids : list of int + The layer IDs. + + Returns + ------- + list of :obj:`carta.colorblending.Layer` + A list of new Layer objects. + """ + return [cls(colorblending, layer_id) for layer_id in layer_ids] + + def __repr__(self): + """A human-readable representation of this object.""" + session_id = self.session.session_id + cb_id = self.colorblending.imageview_id + cb_name = self.colorblending.file_name + repr_content = [ + f"{session_id}:{cb_id}:{cb_name}", + f"{self.layer_id}:{self.file_name}" + ] + return ":".join(repr_content) + + @property + @cached + def file_name(self): + """The name of the image. + + Returns + ------- + string + The image name. + """ + return self.get_value("frameInfo.fileInfo.name") + + @property + @cached + def image_id(self): + """The ID of the image. + + Returns + ------- + int + The image ID. + """ + return self.get_value("frameInfo.fileId") + + @validate(Number(0, 1)) + def set_alpha(self, alpha): + """Set the alpha value for the layer in the color blending. + + Parameters + ---------- + alpha : float + The alpha value. + """ + self.colorblending.call_action("setAlpha", self.layer_id, alpha) + + @validate(Constant(Colormap), Boolean()) + def set_colormap(self, colormap, invert=False): + """Set the colormap for the layer in the color blending. + + Parameters + ---------- + colormap : :obj:`carta.constants.Colormap` + The colormap. + invert : bool + Whether the colormap should be inverted. This is false by default. + """ + self.call_action( + "renderConfig.setColorMap", colormap) + self.call_action( + "renderConfig.setInverted", invert) + + +class ColorBlending(BasePathMixin): + """This object represents a color blending image in a session. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object associated with this color blending. + image_id : int + The image ID. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this color blending. + image_id : int + The image ID. + """ + def __init__(self, session, image_id): + self.session = session + self.image_id = image_id + + path = "imageViewConfigStore.colorBlendingImages" + self._base_path = f"{path}[{self.image_id}]" + self._frame = Macro("", self._base_path) + + self.base_frame = Image( + self.session, self.layer_list()[0].image_id) + + @classmethod + def from_images(cls, session, images): + """Create a color blending object from a list of images. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + images : list of :obj:`carta.image.Image` + The images to be blended. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + """ + # Set the first image as the spatial reference + session.call_action("setSpatialReference", images[0]._frame, False) + # Align the other images to the spatial reference + for image in images[1:]: + success = image.call_action( + "setSpatialReference", images[0]._frame) + if not success: + name = image.file_name + raise CartaActionFailed( + f"Failed to set spatial reference for image {name}.") + + command = "imageViewConfigStore.createColorBlending" + image_id = session.call_action(command, return_path="id") + return cls(session, image_id) + + def __repr__(self): + """A human-readable representation of this color blending object.""" + session_id = self.session.session_id + return f"{session_id}:{self.imageview_id}:{self.file_name}" + + @property + @cached + def file_name(self): + """The name of the image. + + Returns + ------- + string + The image name. + """ + return self.get_value("filename") + + @property + @cached + def imageview_id(self): + """The ID of the image in imageView. + + Returns + ------- + integer + The image ID. + """ + imageview_names = self.session.get_value( + "imageViewConfigStore.imageNames") + return imageview_names.index(self.file_name) + + @property + def alpha(self): + """The alpha value list for the color blending layers. + + Returns + ------- + list of float + The alpha values. + """ + return self.get_value("alpha") + + def make_active(self): + """Make this the active image.""" + self.session.call_action("setActiveImageByIndex", self.imageview_id) + + def layer_list(self): + """ + Returns a list of Layer objects, each representing a layer in + this color blending object. + + Returns + ------- + list of :obj:`carta.colorblending.Layer` + A list of Layer objects. + """ + def count_layers(): + idx = 0 + while True: + try: + self.get_value(f"frames[{idx}].frameInfo.fileId") + idx += 1 + except CartaActionFailed: + break + return idx + return [Layer(self, i) for i in range(count_layers())] + + def add_layer(self, image): + """Add a new layer to the color blending. + + Parameters + ---------- + image : :obj:`carta.image.Image` + The image to add. + """ + self.call_action("addSelectedFrame", image._frame) + + @validate(Number(1, None)) + def delete_layer(self, layer_index): + """Delete a layer from the color blending. + + Parameters + ---------- + layer_index : int + The layer index. The base layer (layer_index = 0) cannot + be deleted. + """ + self.call_action("deleteSelectedFrame", layer_index - 1) + + @validate(InstanceOf(Image), Number(1, None)) + def set_layer(self, image, layer_index): + """Set a layer at a specified index in the color blending. + + Parameters + ---------- + image : :obj:`carta.image.Image` + The image to set. + layer_index : int + The layer index. The base layer (layer_index = 0) cannot + be set. + """ + self.call_action("setSelectedFrame", layer_index - 1, image._frame) + + @validate(IterableOf(Number(1, None), min_size=2)) + def reorder_layers(self, order_list): + """Reorder the layers in the color blending. + + Parameters + ---------- + order_list : list of int + The list of layer indices in the desired order. The list must not + contain the base layer (index = 0). + """ + layers = self.layer_list() + image_ids = [layer.image_id for layer in layers] + # Delete all layers except the base layer + for _ in layers[1:]: + # Delete the first layer + # The previous second layer becomes the first layer + self.delete_layer(1) + for idx in order_list: + image = Image(self.session, image_ids[idx]) + self.add_layer(image) + + @validate(Coordinate(), Coordinate()) + def set_center(self, x, y): + """Set the center position, in image or world coordinates. + + World coordinates are interpreted according to the session's globally + set coordinate system and any custom number formats. These can be + changed using :obj:`carta.session.set_coordinate_system` and + :obj:`set_custom_number_format`. + + Coordinates must either both be image coordinates or match the current + number formats. Numbers are interpreted as image coordinates, and + numeric strings with no units are interpreted as degrees. + + Parameters + ---------- + x : {0} + The X position. + y : {1} + The Y position. + + Raises + ------ + ValueError + If a mix of image and world coordinates is provided, if world + coordinates are provided and the image has no valid WCS + information, or if world coordinates do not match the session-wide + number formats. + """ + self.base_frame.set_center(x, y) + + @validate(Number(), Boolean()) + def set_zoom_level(self, zoom, absolute=True): + """Set the zoom level. + + TODO: explain this more rigorously. + + Parameters + ---------- + zoom : {0} + The zoom level. + absolute : {1} + Whether the zoom level should be treated as absolute. By default + it is adjusted by a scaling factor. + """ + self.base_frame.set_zoom_level(zoom, absolute) + + @validate(Constant(ColormapSet)) + def set_colormap_set(self, colormap_set): + """Set the colormap set for the color blending. + + Parameters + ---------- + colormap_set : :obj:`carta.constants.ColormapSet` + The colormap set. + """ + self.call_action("applyColormapSet", colormap_set) + for layer in self.layer_list(): + layer.call_action("renderConfig.setInverted", False) + + @validate(IterableOf(Number(0, 1))) + def set_alpha(self, alpha_list): + """Set the alpha value for the color blending layers. + + Parameters + ---------- + alpha_list : list of float + The alpha values. + """ + layer_list = self.layer_list() + for alpha, layer in zip(alpha_list, layer_list): + layer.set_alpha(alpha) + + @validate(Boolean()) + def set_raster_visible(self, state): + """Set the raster component visibility. + + Parameters + ---------- + state : bool + The desired visibility state. + """ + is_visible = self.get_value("rasterVisible") + if is_visible != state: + self.call_action("toggleRasterVisible") + + @validate(Boolean()) + def set_contour_visible(self, state): + """Set the contour component visibility. + + Parameters + ---------- + state : bool + The desired visibility state. + """ + is_visible = self.get_value("contourVisible") + if is_visible != state: + self.call_action("toggleContourVisible") + + @validate(Boolean()) + def set_vectoroverlay_visible(self, state): + """Set the vector overlay visibility. + + Parameters + ---------- + state : bool + The desired visibility state. + """ + is_visible = self.get_value("vectorOverlayVisible") + if is_visible != state: + self.call_action("toggleVectorOverlayVisible") + + def close(self): + """Close this color blending object.""" + self.session.call_action( + "imageViewConfigStore.removeColorBlending", self._frame) From 85b3528714f994fdb80ee88e07fd4ed53c63439b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 5 May 2025 12:07:28 +0800 Subject: [PATCH 004/123] Add color blending documentation and update image handling examples --- docs/source/quickstart.rst | 78 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ac8bbec..98d53ec 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -171,8 +171,9 @@ Helper methods on the session object open images in the frontend and return imag .. code-block:: python # Open or append images - img1 = session.open_image("data/hdf5/first_file.hdf5") - img2 = session.open_image("data/fits/second_file.fits", append=True) + img0 = session.open_image("data/hdf5/first_file.hdf5") + img1 = session.open_image("data/fits/second_file.fits", append=True) + img2 = session.open_image("data/fits/third_file.fits", append=True) Changing image properties ------------------------- @@ -192,7 +193,7 @@ Properties specific to individual images can be accessed through image objects: # pan and zoom y, x = img.shape[-2:] img.set_center(x/2, y/2) - img.set_zoom(4) + img.set_zoom_level(4) # change colormap img.raster.set_colormap(Colormap.VIRIDIS) @@ -225,7 +226,76 @@ Properties which affect the whole session can be set through the session object: session.wcs.global_.set_color(PaletteColor.RED) session.wcs.ticks.set_color(PaletteColor.VIOLET) session.wcs.title.show() - + +Making color blended image +-------------------------- + +Create a color blending object from a list of images. + +.. code-block:: python + + from carta.colorblending import ColorBlending + from carta.constants import Colormap, ColormapSet + + # Make a color blending object + # Warning: This will break the current spatial matching and + # use the first image as the spatial reference + # Note: The base layer (id = 0) cannot be deleted or reordered. + cb = ColorBlending.from_images(session, [img0, img1, img2]) + + # Get layer objects + layers = cb.layer_list() + + # Set colormap for individual layers + layers[0].set_colormap(Colormap.REDS) + layers[1].set_colormap(Colormap.GREENS) + layers[2].set_colormap(Colormap.BLUES) + + # Or apply an existing colormap set + cb.set_colormap_set(ColormapSet.RGB) + + # Print the current alpha values of all layers + print(cb.alpha) + + # Set alpha for individual layers + layers[0].set_alpha(0.7) + layers[1].set_alpha(0.8) + layers[2].set_alpha(0.9) + + # Or set alpha for all layers at once + cb.set_alpha([0.7, 0.8, 0.9]) + + # Reorder layers (except the base layer) + # Since the base layer (id = 0) cannot be reordered, + # the layers will be reordered as [img0, img2, img1] + cb.reorder_layers([2, 1]) + + # Remove the last layer (id = 2) + cb.delete_layer(2) + + # Add a new layer + # The layer to be added cannot be one of the current layers + cb.add_layer(img1) + + # Set center + cb.set_center(100, 100) + + # Set zoom level + cb.set_zoom_level(2) + + # Set the color blending object as the active frame + cb.make_active() + + # Set contour visibility + # This will hide the contours (if any) + cb.set_contour_visible(False) + + # Close the color blending object + cb.close() + +.. note:: + When you would like to reorder the layers, especially when the base layer (id = 0) is involved, it is more recommended to close the current color blending object and create a new one. + Saving or displaying an image ----------------------------- From 06445bc935cb402bc162b0df36f673887493b9ee Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 10 Sep 2025 16:00:56 +0800 Subject: [PATCH 005/123] Add ColorBlending.from_files method to create blended images directly from file paths --- carta/colorblending.py | 91 ++++++++++++++++++++++++++------------ docs/source/quickstart.rst | 21 +++++++++ 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index bf2ff16..e9cfd45 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,29 +1,37 @@ from .constants import Colormap, ColormapSet from .image import Image from .util import BasePathMixin, CartaActionFailed, Macro, cached -from .validation import (Boolean, Constant, Coordinate, InstanceOf, IterableOf, - Number, validate) +from .validation import ( + Boolean, + Constant, + Coordinate, + InstanceOf, + IterableOf, + Number, + validate, +) class Layer(BasePathMixin): """This object represents a single layer in a color blending object. -` - Parameters - ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` - The color blending object. - layer_id : int - The layer ID. + ` + Parameters + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + The layer ID. - Attributes - ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` - The color blending object. - layer_id : int - The layer ID. - session : :obj:`carta.session.Session` - The session object associated with this layer. + Attributes + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + The layer ID. + session : :obj:`carta.session.Session` + The session object associated with this layer. """ + def __init__(self, colorblending, layer_id): self.colorblending = colorblending self.layer_id = layer_id @@ -58,7 +66,7 @@ def __repr__(self): cb_name = self.colorblending.file_name repr_content = [ f"{session_id}:{cb_id}:{cb_name}", - f"{self.layer_id}:{self.file_name}" + f"{self.layer_id}:{self.file_name}", ] return ":".join(repr_content) @@ -108,10 +116,8 @@ def set_colormap(self, colormap, invert=False): invert : bool Whether the colormap should be inverted. This is false by default. """ - self.call_action( - "renderConfig.setColorMap", colormap) - self.call_action( - "renderConfig.setInverted", invert) + self.call_action("renderConfig.setColorMap", colormap) + self.call_action("renderConfig.setInverted", invert) class ColorBlending(BasePathMixin): @@ -131,6 +137,7 @@ class ColorBlending(BasePathMixin): image_id : int The image ID. """ + def __init__(self, session, image_id): self.session = session self.image_id = image_id @@ -139,8 +146,7 @@ def __init__(self, session, image_id): self._base_path = f"{path}[{self.image_id}]" self._frame = Macro("", self._base_path) - self.base_frame = Image( - self.session, self.layer_list()[0].image_id) + self.base_frame = Image(self.session, self.layer_list()[0].image_id) @classmethod def from_images(cls, session, images): @@ -163,16 +169,41 @@ def from_images(cls, session, images): # Align the other images to the spatial reference for image in images[1:]: success = image.call_action( - "setSpatialReference", images[0]._frame) + "setSpatialReference", images[0]._frame + ) if not success: name = image.file_name raise CartaActionFailed( - f"Failed to set spatial reference for image {name}.") + f"Failed to set spatial reference for image {name}." + ) command = "imageViewConfigStore.createColorBlending" image_id = session.call_action(command, return_path="id") return cls(session, image_id) + @classmethod + def from_files(cls, session, files, append=False): + """Create a color blending object from a list of files. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + files : list of string + The files to be blended. + append : bool + Whether the images should be appended to existing images. + By default this is ``False`` and any existing open images + are closed. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + """ + images = session.open_images(files, append=append) + return cls.from_images(session, images) + def __repr__(self): """A human-readable representation of this color blending object.""" session_id = self.session.session_id @@ -201,7 +232,8 @@ def imageview_id(self): The image ID. """ imageview_names = self.session.get_value( - "imageViewConfigStore.imageNames") + "imageViewConfigStore.imageNames" + ) return imageview_names.index(self.file_name) @property @@ -229,6 +261,7 @@ def layer_list(self): list of :obj:`carta.colorblending.Layer` A list of Layer objects. """ + def count_layers(): idx = 0 while True: @@ -238,6 +271,7 @@ def count_layers(): except CartaActionFailed: break return idx + return [Layer(self, i) for i in range(count_layers())] def add_layer(self, image): @@ -411,4 +445,5 @@ def set_vectoroverlay_visible(self, state): def close(self): """Close this color blending object.""" self.session.call_action( - "imageViewConfigStore.removeColorBlending", self._frame) + "imageViewConfigStore.removeColorBlending", self._frame + ) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 98d53ec..dbb01c8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -230,6 +230,23 @@ Properties which affect the whole session can be set through the session object: Making color blended image -------------------------- +Create a color blending object from a list of files. + +.. code-block:: python + + from carta.colorblending import ColorBlending + from carta.constants import Colormap, ColormapSet + + # Make a color blending object + # Warning: setting `append=False` will close any existing images + # Note: The base layer (id = 0) cannot be deleted or reordered. + files = [ + "data/hdf5/first_file.hdf5", + "data/fits/second_file.fits", + "data/fits/third_file.fits", + ] + cb = ColorBlending.from_files(session, files, append=False) + Create a color blending object from a list of images. .. code-block:: python @@ -243,6 +260,10 @@ Create a color blending object from a list of images. # Note: The base layer (id = 0) cannot be deleted or reordered. cb = ColorBlending.from_images(session, [img0, img1, img2]) +Manipulate properties of the color blending object and the underlying images. + +.. code-block:: python + # Get layer objects layers = cb.layer_list() From 266f1c1f446555652769b60a0716b6b1385398a0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 10 Sep 2025 16:15:58 +0800 Subject: [PATCH 006/123] Add tests for ColorBlending and Layer classes --- tests/test_colorblending.py | 320 ++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/test_colorblending.py diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py new file mode 100644 index 0000000..82efff3 --- /dev/null +++ b/tests/test_colorblending.py @@ -0,0 +1,320 @@ +import pytest + +from carta.colorblending import ColorBlending, Layer +from carta.image import Image +from carta.util import Macro, CartaActionFailed, CartaValidationFailed +from carta.constants import Colormap as CM, ColormapSet as CMS + + +# FIXTURES + + +@pytest.fixture +def colorblending(session, mocker): + # Avoid hitting real layer_list logic during __init__ + class _Dummy: + def __init__(self, image_id): + self.image_id = image_id + + mocker.patch.object(ColorBlending, "layer_list", return_value=[_Dummy(42)]) + return ColorBlending(session, 0) + + +@pytest.fixture +def layer(colorblending): + return Layer(colorblending, 1) + + +@pytest.fixture +def cb_get_value(colorblending, mock_get_value): + return mock_get_value(colorblending) + + +@pytest.fixture +def cb_call_action(colorblending, mock_call_action): + return mock_call_action(colorblending) + + +@pytest.fixture +def layer_get_value(layer, mock_get_value): + return mock_get_value(layer) + + +@pytest.fixture +def layer_call_action(layer, mock_call_action): + return mock_call_action(layer) + + +@pytest.fixture +def session_call_action(session, mock_call_action): + return mock_call_action(session) + + +@pytest.fixture +def session_get_value(session, mock_get_value): + return mock_get_value(session) + + +@pytest.fixture +def cb_property(mock_property): + return mock_property("carta.colorblending.ColorBlending") + + +@pytest.fixture +def layer_property(mock_property): + return mock_property("carta.colorblending.Layer") + + +# TESTS — Layer + + +def test_layer_from_list(colorblending): + layers = Layer.from_list(colorblending, [5, 6, 7]) + assert [ly.layer_id for ly in layers] == [5, 6, 7] + assert all(ly.colorblending is colorblending for ly in layers) + + +def test_layer_repr(session, colorblending, cb_property, layer_property): + cb_property("imageview_id", 11) + cb_property("file_name", "blend.fits") + layer_property("file_name", "layer1.fits") + r = repr(Layer(colorblending, 3)) + # session id is 0 (from conftest) + assert r == "0:11:blend.fits:3:layer1.fits" + + +def test_layer_file_name_property(layer, layer_get_value): + layer.file_name + layer_get_value.assert_called_with("frameInfo.fileInfo.name") + + +def test_layer_image_id_property(layer, layer_get_value): + layer.image_id + layer_get_value.assert_called_with("frameInfo.fileId") + + +@pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) +def test_layer_set_alpha_valid(colorblending, alpha, cb_call_action): + Layer(colorblending, 2).set_alpha(alpha) + cb_call_action.assert_called_with("setAlpha", 2, alpha) + + +@pytest.mark.parametrize("alpha", [-0.1, 1.1]) +def test_layer_set_alpha_invalid(colorblending, alpha): + with pytest.raises(CartaValidationFailed): + Layer(colorblending, 2).set_alpha(alpha) + + +@pytest.mark.parametrize("invert", [True, False]) +def test_layer_set_colormap(layer, layer_call_action, invert): + layer.set_colormap(CM.VIRIDIS, invert) + layer_call_action.assert_any_call("renderConfig.setColorMap", CM.VIRIDIS) + layer_call_action.assert_any_call("renderConfig.setInverted", invert) + + +# TESTS — ColorBlending basics + + +def test_colorblending_repr(session, colorblending, cb_property): + cb_property("imageview_id", 3) + cb_property("file_name", "blend.fits") + assert repr(colorblending) == "0:3:blend.fits" + + +def test_colorblending_file_name(colorblending, cb_get_value): + colorblending.file_name + cb_get_value.assert_called_with("filename") + + +def test_colorblending_imageview_id(session, colorblending, session_get_value, cb_property): + cb_property("file_name", "imgC") + session_get_value.side_effect = [["imgA", "imgB", "imgC", "imgD"]] + assert colorblending.imageview_id == 2 + session_get_value.assert_called_with("imageViewConfigStore.imageNames") + + +def test_colorblending_alpha(colorblending, cb_get_value): + colorblending.alpha + cb_get_value.assert_called_with("alpha") + + +def test_colorblending_make_active(session, colorblending, cb_property, session_call_action): + cb_property("imageview_id", 9) + colorblending.make_active() + session_call_action.assert_called_with("setActiveImageByIndex", 9) + + +def test_colorblending_layer_list_derived(session, mocker): + # Construct without running __init__ to avoid base_frame wiring + cb = object.__new__(ColorBlending) + cb.session = session + cb.image_id = 0 + cb._base_path = f"imageViewConfigStore.colorBlendingImages[{cb.image_id}]" + cb._frame = Macro("", cb._base_path) + + # Simulate two layers and then failure for third + gv = mocker.patch.object(cb, "get_value") + gv.side_effect = [1, 2, CartaActionFailed("stop")] # fileIds for idx 0,1 then fail + + layers = cb.layer_list() + assert [ly.layer_id for ly in layers] == [0, 1] + + +def test_colorblending_add_layer(colorblending, cb_call_action, image): + colorblending.add_layer(image) + cb_call_action.assert_called_with("addSelectedFrame", image._frame) + + +@pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) +def test_colorblending_delete_layer(colorblending, cb_call_action, idx, expected_param): + colorblending.delete_layer(idx) + cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) + + +@pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) +def test_colorblending_set_layer(colorblending, cb_call_action, image, idx, expected_param): + colorblending.set_layer(image, idx) + cb_call_action.assert_called_with("setSelectedFrame", expected_param, image._frame) + + +def test_colorblending_reorder_layers(session, colorblending, mocker): + # Prepare three existing layers with image_ids 10, 20, 30 + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object(ColorBlending, "layer_list", return_value=[_L(0, 10), _L(1, 20), _L(2, 30)]) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + + colorblending.reorder_layers([2, 1]) + + # Deletes all non-base layers (twice) then adds layers in specified order + assert del_layer.call_count == 2 + add_args = [call.args[0] for call in add_layer.call_args_list] + assert [img.image_id for img in add_args] == [30, 20] + + +def test_colorblending_set_center(colorblending, mocker): + set_center = mocker.patch.object(colorblending.base_frame, "set_center") + colorblending.set_center(1, 2) + set_center.assert_called_with(1, 2) + + +@pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) +def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): + set_zoom = mocker.patch.object(colorblending.base_frame, "set_zoom_level") + colorblending.set_zoom_level(zoom, absolute) + set_zoom.assert_called_with(zoom, absolute) + + +def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): + # Two layers; verify setInverted(False) called on each + ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) + mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + + colorblending.set_colormap_set(CMS.Rainbow) + cb_call_action.assert_called_with("applyColormapSet", CMS.Rainbow) + ly1.call_action.assert_called_with("renderConfig.setInverted", False) + ly2.call_action.assert_called_with("renderConfig.setInverted", False) + + +def test_colorblending_set_alpha_valid(colorblending, mocker): + ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) + mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + + colorblending.set_alpha([0.2, 0.8]) + ly1.set_alpha.assert_called_with(0.2) + ly2.set_alpha.assert_called_with(0.8) + + +@pytest.mark.parametrize("vals", [[-0.1, 0.5], [1.2], [0.1, 2.0, 0.3]]) +def test_colorblending_set_alpha_invalid(colorblending, vals): + with pytest.raises(CartaValidationFailed): + colorblending.set_alpha(vals) + + +@pytest.mark.parametrize( + "getter,method,action,state", + [ + ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ("contourVisible", "set_contour_visible", "toggleContourVisible", True), + ("vectorOverlayVisible", "set_vectoroverlay_visible", "toggleVectorOverlayVisible", False), + ], +) +def test_colorblending_toggle_visibility_when_needed(colorblending, cb_get_value, cb_call_action, getter, method, action, state): + # Current state opposite to desired -> should toggle + cb_get_value.side_effect = [not state] + getattr(colorblending, method)(state) + cb_call_action.assert_called_with(action) + + +@pytest.mark.parametrize( + "getter,method,action,state", + [ + ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ("contourVisible", "set_contour_visible", "toggleContourVisible", False), + ("vectorOverlayVisible", "set_vectoroverlay_visible", "toggleVectorOverlayVisible", True), + ], +) +def test_colorblending_toggle_visibility_noop(colorblending, cb_get_value, cb_call_action, getter, method, action, state): + # Current state equals desired -> no toggle + cb_get_value.side_effect = [state] + getattr(colorblending, method)(state) + cb_call_action.assert_not_called() + + +def test_colorblending_close(session, colorblending, session_call_action): + colorblending.close() + session_call_action.assert_called_with( + "imageViewConfigStore.removeColorBlending", colorblending._frame + ) + + +# CREATION HELPERS + + +def test_colorblending_from_images_success(session, mocker): + # Prepare two images to blend + img0 = Image(session, 100) + img1 = Image(session, 200) + + # setSpatialReference alignment returns True for img1 + mocker.patch.object(session, "call_action") + mocker.patch.object(img1, "call_action", return_value=True) + + # Create ID for new color blending + session.call_action.side_effect = [None, 123] + + # Avoid __init__ side effects; just ensure returned instance + mocker.patch.object(ColorBlending, "__init__", return_value=None) + cb = ColorBlending.from_images(session, [img0, img1]) + assert isinstance(cb, ColorBlending) + session.call_action.assert_any_call("setSpatialReference", img0._frame, False) + img1.call_action.assert_called_with("setSpatialReference", img0._frame) + session.call_action.assert_called_with("imageViewConfigStore.createColorBlending", return_path="id") + + +def test_colorblending_from_images_alignment_failure(session, mocker): + img0 = Image(session, 100) + img1 = Image(session, 200) + + mocker.patch.object(session, "call_action") + mocker.patch.object(type(img1), "file_name", new_callable=mocker.PropertyMock, return_value="bad.fits") + mocker.patch.object(img1, "call_action", return_value=False) + + with pytest.raises(CartaActionFailed) as e: + ColorBlending.from_images(session, [img0, img1]) + assert "Failed to set spatial reference for image bad.fits." in str(e.value) + + +def test_colorblending_from_files(session, mocker): + mock_open_images = mocker.patch.object(session, "open_images", return_value=[Image(session, 1), Image(session, 2)]) + mock_from_images = mocker.patch.object(ColorBlending, "from_images", return_value="CB") + out = ColorBlending.from_files(session, ["a.fits", "b.fits"], append=True) + mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) + mock_from_images.assert_called() + assert out == "CB" From 606c9f3a1030473f80e5d15336b6cc4d6b60ad24 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 11 Sep 2025 14:00:57 +0800 Subject: [PATCH 007/123] Improve mock consistency --- tests/test_colorblending.py | 102 ++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 82efff3..9cb78c1 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -1,10 +1,10 @@ import pytest from carta.colorblending import ColorBlending, Layer +from carta.constants import Colormap as CM +from carta.constants import ColormapSet as CMS from carta.image import Image -from carta.util import Macro, CartaActionFailed, CartaValidationFailed -from carta.constants import Colormap as CM, ColormapSet as CMS - +from carta.util import CartaActionFailed, CartaValidationFailed, Macro # FIXTURES @@ -126,7 +126,9 @@ def test_colorblending_file_name(colorblending, cb_get_value): cb_get_value.assert_called_with("filename") -def test_colorblending_imageview_id(session, colorblending, session_get_value, cb_property): +def test_colorblending_imageview_id( + session, colorblending, session_get_value, cb_property +): cb_property("file_name", "imgC") session_get_value.side_effect = [["imgA", "imgB", "imgC", "imgD"]] assert colorblending.imageview_id == 2 @@ -138,7 +140,9 @@ def test_colorblending_alpha(colorblending, cb_get_value): cb_get_value.assert_called_with("alpha") -def test_colorblending_make_active(session, colorblending, cb_property, session_call_action): +def test_colorblending_make_active( + session, colorblending, cb_property, session_call_action +): cb_property("imageview_id", 9) colorblending.make_active() session_call_action.assert_called_with("setActiveImageByIndex", 9) @@ -154,7 +158,11 @@ def test_colorblending_layer_list_derived(session, mocker): # Simulate two layers and then failure for third gv = mocker.patch.object(cb, "get_value") - gv.side_effect = [1, 2, CartaActionFailed("stop")] # fileIds for idx 0,1 then fail + gv.side_effect = [ + 1, + 2, + CartaActionFailed("stop"), + ] # fileIds for idx 0,1 then fail layers = cb.layer_list() assert [ly.layer_id for ly in layers] == [0, 1] @@ -166,15 +174,21 @@ def test_colorblending_add_layer(colorblending, cb_call_action, image): @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) -def test_colorblending_delete_layer(colorblending, cb_call_action, idx, expected_param): +def test_colorblending_delete_layer( + colorblending, cb_call_action, idx, expected_param +): colorblending.delete_layer(idx) cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) -def test_colorblending_set_layer(colorblending, cb_call_action, image, idx, expected_param): +def test_colorblending_set_layer( + colorblending, cb_call_action, image, idx, expected_param +): colorblending.set_layer(image, idx) - cb_call_action.assert_called_with("setSelectedFrame", expected_param, image._frame) + cb_call_action.assert_called_with( + "setSelectedFrame", expected_param, image._frame + ) def test_colorblending_reorder_layers(session, colorblending, mocker): @@ -184,7 +198,11 @@ def __init__(self, lid, iid): self.layer_id = lid self.image_id = iid - mocker.patch.object(ColorBlending, "layer_list", return_value=[_L(0, 10), _L(1, 20), _L(2, 30)]) + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) del_layer = mocker.patch.object(colorblending, "delete_layer") add_layer = mocker.patch.object(colorblending, "add_layer") @@ -241,11 +259,23 @@ def test_colorblending_set_alpha_invalid(colorblending, vals): "getter,method,action,state", [ ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), - ("contourVisible", "set_contour_visible", "toggleContourVisible", True), - ("vectorOverlayVisible", "set_vectoroverlay_visible", "toggleVectorOverlayVisible", False), + ( + "contourVisible", + "set_contour_visible", + "toggleContourVisible", + True, + ), + ( + "vectorOverlayVisible", + "set_vectoroverlay_visible", + "toggleVectorOverlayVisible", + False, + ), ], ) -def test_colorblending_toggle_visibility_when_needed(colorblending, cb_get_value, cb_call_action, getter, method, action, state): +def test_colorblending_toggle_visibility_when_needed( + colorblending, cb_get_value, cb_call_action, getter, method, action, state +): # Current state opposite to desired -> should toggle cb_get_value.side_effect = [not state] getattr(colorblending, method)(state) @@ -256,11 +286,23 @@ def test_colorblending_toggle_visibility_when_needed(colorblending, cb_get_value "getter,method,action,state", [ ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), - ("contourVisible", "set_contour_visible", "toggleContourVisible", False), - ("vectorOverlayVisible", "set_vectoroverlay_visible", "toggleVectorOverlayVisible", True), + ( + "contourVisible", + "set_contour_visible", + "toggleContourVisible", + False, + ), + ( + "vectorOverlayVisible", + "set_vectoroverlay_visible", + "toggleVectorOverlayVisible", + True, + ), ], ) -def test_colorblending_toggle_visibility_noop(colorblending, cb_get_value, cb_call_action, getter, method, action, state): +def test_colorblending_toggle_visibility_noop( + colorblending, cb_get_value, cb_call_action, getter, method, action, state +): # Current state equals desired -> no toggle cb_get_value.side_effect = [state] getattr(colorblending, method)(state) @@ -293,27 +335,41 @@ def test_colorblending_from_images_success(session, mocker): mocker.patch.object(ColorBlending, "__init__", return_value=None) cb = ColorBlending.from_images(session, [img0, img1]) assert isinstance(cb, ColorBlending) - session.call_action.assert_any_call("setSpatialReference", img0._frame, False) + session.call_action.assert_any_call( + "setSpatialReference", img0._frame, False + ) img1.call_action.assert_called_with("setSpatialReference", img0._frame) - session.call_action.assert_called_with("imageViewConfigStore.createColorBlending", return_path="id") + session.call_action.assert_called_with( + "imageViewConfigStore.createColorBlending", return_path="id" + ) -def test_colorblending_from_images_alignment_failure(session, mocker): +def test_colorblending_from_images_alignment_failure( + session, mocker, mock_property +): img0 = Image(session, 100) img1 = Image(session, 200) mocker.patch.object(session, "call_action") - mocker.patch.object(type(img1), "file_name", new_callable=mocker.PropertyMock, return_value="bad.fits") + mock_property("carta.image.Image")("file_name", "bad.fits") mocker.patch.object(img1, "call_action", return_value=False) with pytest.raises(CartaActionFailed) as e: ColorBlending.from_images(session, [img0, img1]) - assert "Failed to set spatial reference for image bad.fits." in str(e.value) + assert "Failed to set spatial reference for image bad.fits." in str( + e.value + ) def test_colorblending_from_files(session, mocker): - mock_open_images = mocker.patch.object(session, "open_images", return_value=[Image(session, 1), Image(session, 2)]) - mock_from_images = mocker.patch.object(ColorBlending, "from_images", return_value="CB") + mock_open_images = mocker.patch.object( + session, + "open_images", + return_value=[Image(session, 1), Image(session, 2)], + ) + mock_from_images = mocker.patch.object( + ColorBlending, "from_images", return_value="CB" + ) out = ColorBlending.from_files(session, ["a.fits", "b.fits"], append=True) mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) mock_from_images.assert_called() From 34bf41b18aeb81986a6c4c1c2f79f441d6e82a9f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 15:29:48 +0800 Subject: [PATCH 008/123] Fix duplicate imports and docstring formatting in colorblending and image modules --- carta/colorblending.py | 2 +- carta/image.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index e9cfd45..558e829 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -14,7 +14,7 @@ class Layer(BasePathMixin): """This object represents a single layer in a color blending object. - ` + Parameters ---------- colorblending : :obj:`carta.colorblending.ColorBlending` diff --git a/carta/image.py b/carta/image.py index e111cee..12fef67 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,15 +5,12 @@ from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin, Point as Pt +from .util import Macro, cached, BasePathMixin, CartaActionFailed, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point from .metadata import parse_header from .raster import Raster -from .units import AngularSize, WorldCoordinate -from .util import BasePathMixin, CartaActionFailed, Macro, cached -from .validation import (Attr, Attrs, Boolean, Constant, Coordinate, Evaluate, - NoneOr, Number, OneOf, Size, validate) +from .contours import Contours from .vector_overlay import VectorOverlay from .wcs_overlay import ImageWCSOverlay from .region import RegionSet From 5d4fca4e01fe17c16323612f288f130eeb0c598d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 15:30:29 +0800 Subject: [PATCH 009/123] Add colorblending module to API documentation --- docs/source/carta.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 54998fe..ed393e5 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -17,6 +17,14 @@ carta.browser module :undoc-members: :show-inheritance: +carta.colorblending module +-------------------------- + +.. automodule:: carta.colorblending + :members: + :undoc-members: + :show-inheritance: + carta.constants module ---------------------- From 032949d0d416e6d28debd8196069b6bc1484e5e8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 16:08:57 +0800 Subject: [PATCH 010/123] Refactor layers property to use Layer.from_list method --- carta/colorblending.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 558e829..58e3020 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -272,7 +272,7 @@ def count_layers(): break return idx - return [Layer(self, i) for i in range(count_layers())] + return Layer.from_list(self, list(range(count_layers()))) def add_layer(self, image): """Add a new layer to the color blending. From 2e0abceb1c2363bbef63e6c7b90e3b62f9d4222f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 16:09:56 +0800 Subject: [PATCH 011/123] Fix indentation in Layer class docstring --- carta/colorblending.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 58e3020..ae2eaa3 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -15,21 +15,21 @@ class Layer(BasePathMixin): """This object represents a single layer in a color blending object. - Parameters - ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` - The color blending object. - layer_id : int - The layer ID. + Parameters + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + The layer ID. - Attributes - ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` - The color blending object. - layer_id : int - The layer ID. - session : :obj:`carta.session.Session` - The session object associated with this layer. + Attributes + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + The layer ID. + session : :obj:`carta.session.Session` + The session object associated with this layer. """ def __init__(self, colorblending, layer_id): From 5c52466a0dd01e646b0e2a4df872cb7ba5e4e7fd Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:30:05 +0800 Subject: [PATCH 012/123] Rename reorder_layers to set_layer_sequence and add support for layer subsetting and alpha preservation --- carta/colorblending.py | 63 ++++++++++++++++----- docs/source/quickstart.rst | 7 ++- tests/test_colorblending.py | 106 +++++++++++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 18 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index ae2eaa3..6bdb46b 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -310,26 +310,63 @@ def set_layer(self, image, layer_index): """ self.call_action("setSelectedFrame", layer_index - 1, image._frame) - @validate(IterableOf(Number(1, None), min_size=2)) - def reorder_layers(self, order_list): - """Reorder the layers in the color blending. + @validate(IterableOf(Number(0, None), min_size=1)) + def set_layer_sequence(self, layer_indices): + """Set which layers are included in the color blending and in what + order. Parameters ---------- - order_list : list of int - The list of layer indices in the desired order. The list must not - contain the base layer (index = 0). + layer_indices : list of int + The layer indices to keep, in the desired order. The first index + must be the base layer (index = 0). Existing alpha values are + preserved. """ - layers = self.layer_list() - image_ids = [layer.image_id for layer in layers] + current_layers = self.layer_list() + max_current_layer_index = len(current_layers) - 1 + if any( + layer_index > max_current_layer_index + for layer_index in layer_indices + ): + raise ValueError( + "layer_indices contains a layer index which does not exist." + ) + + if layer_indices[0] != 0: + raise ValueError( + "layer_indices must start with the base layer index 0." + ) + + if 0 in layer_indices[1:]: + raise ValueError( + "layer_indices must contain the base layer index 0 only once, " + "as the first index." + ) + + current_layer_indices = list(range(len(current_layers))) + if layer_indices == current_layer_indices: + return + + current_alpha_values = self.alpha + target_layer_states = [ + ( + Image(self.session, current_layers[layer_index].image_id), + current_alpha_values[layer_index], + ) + for layer_index in layer_indices[1:] + ] + # Delete all layers except the base layer - for _ in layers[1:]: - # Delete the first layer - # The previous second layer becomes the first layer + for _ in current_layers[1:]: + # Delete layer at index 1 (the first non-base layer); + # after deletion, the previous layer at index 2 shifts to index 1 self.delete_layer(1) - for idx in order_list: - image = Image(self.session, image_ids[idx]) + + for target_layer_index, (image, alpha) in enumerate( + target_layer_states, start=1 + ): self.add_layer(image) + Layer(self, target_layer_index).set_alpha(alpha) @validate(Coordinate(), Coordinate()) def set_center(self, x, y): diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index dbb01c8..6b6b1a8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -286,10 +286,11 @@ Manipulate properties of the color blending object and the underlying images. # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) - # Reorder layers (except the base layer) - # Since the base layer (id = 0) cannot be reordered, + # Set which layers to keep, and in what order + # The first layer index must be the base layer (id = 0) + # Since the base layer cannot be reordered, # the layers will be reordered as [img0, img2, img1] - cb.reorder_layers([2, 1]) + cb.set_layer_sequence([0, 2, 1]) # Remove the last layer (id = 2) cb.delete_layer(2) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 9cb78c1..a39a4f9 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -191,7 +191,7 @@ def test_colorblending_set_layer( ) -def test_colorblending_reorder_layers(session, colorblending, mocker): +def test_colorblending_set_layer_sequence(session, colorblending, mocker): # Prepare three existing layers with image_ids 10, 20, 30 class _L: def __init__(self, lid, iid): @@ -203,15 +203,117 @@ def __init__(self, lid, iid): "layer_list", return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + return_value=[1.0, 0.2, 0.8], + ) del_layer = mocker.patch.object(colorblending, "delete_layer") add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.reorder_layers([2, 1]) + colorblending.set_layer_sequence([0, 2, 1]) # Deletes all non-base layers (twice) then adds layers in specified order assert del_layer.call_count == 2 add_args = [call.args[0] for call in add_layer.call_args_list] assert [img.image_id for img in add_args] == [30, 20] + assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] + + +def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], + ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + return_value=[1.0, 0.2, 0.8, 0.4], + ) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) + + colorblending.set_layer_sequence([0, 3, 1]) + + assert del_layer.call_count == 3 + assert [call.args[0].image_id for call in add_layer.call_args_list] == [40, 20] + assert [call.args[1] for call in set_alpha.call_args_list] == [0.4, 0.2] + + +def test_colorblending_set_layer_sequence_rejects_missing_layer_index( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], + ) + + with pytest.raises( + ValueError, + match="layer_indices contains a layer index which does not exist.", + ): + colorblending.set_layer_sequence([0, 4, 1]) + + +def test_colorblending_set_layer_sequence_requires_base_layer_first( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match="layer_indices must start with the base layer index 0.", + ): + colorblending.set_layer_sequence([2, 1]) + + +def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match=( + "layer_indices must contain the base layer index 0 only once, " + "as the first index." + ), + ): + colorblending.set_layer_sequence([0, 2, 0]) def test_colorblending_set_center(colorblending, mocker): From 06fff60193e12dcb36e42e777bbd38a3fdf393f8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:43:29 +0800 Subject: [PATCH 013/123] Simplify layer_list implementation by using frames.length instead of iterative probing --- carta/colorblending.py | 14 ++------------ tests/test_colorblending.py | 9 +++------ 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 6bdb46b..e7e58bd 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -261,18 +261,8 @@ def layer_list(self): list of :obj:`carta.colorblending.Layer` A list of Layer objects. """ - - def count_layers(): - idx = 0 - while True: - try: - self.get_value(f"frames[{idx}].frameInfo.fileId") - idx += 1 - except CartaActionFailed: - break - return idx - - return Layer.from_list(self, list(range(count_layers()))) + layer_count = self.get_value("frames.length") + return Layer.from_list(self, list(range(layer_count))) def add_layer(self, image): """Add a new layer to the color blending. diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index a39a4f9..44e3bdf 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -156,16 +156,13 @@ def test_colorblending_layer_list_derived(session, mocker): cb._base_path = f"imageViewConfigStore.colorBlendingImages[{cb.image_id}]" cb._frame = Macro("", cb._base_path) - # Simulate two layers and then failure for third + # Simulate two layers from the frontend's computed frames array length. gv = mocker.patch.object(cb, "get_value") - gv.side_effect = [ - 1, - 2, - CartaActionFailed("stop"), - ] # fileIds for idx 0,1 then fail + gv.return_value = 2 layers = cb.layer_list() assert [ly.layer_id for ly in layers] == [0, 1] + gv.assert_called_once_with("frames.length") def test_colorblending_add_layer(colorblending, cb_call_action, image): From b7af8dc4fcd4a124dbbffaac1adff720bac85695 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:49:54 +0800 Subject: [PATCH 014/123] Add explicit validation to prevent deletion of base layer in ColorBlending --- carta/colorblending.py | 4 +++- tests/test_colorblending.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index e7e58bd..b18cf40 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -274,7 +274,7 @@ def add_layer(self, image): """ self.call_action("addSelectedFrame", image._frame) - @validate(Number(1, None)) + @validate(Number(0, None)) def delete_layer(self, layer_index): """Delete a layer from the color blending. @@ -284,6 +284,8 @@ def delete_layer(self, layer_index): The layer index. The base layer (layer_index = 0) cannot be deleted. """ + if layer_index == 0: + raise ValueError("The base layer cannot be deleted.") self.call_action("deleteSelectedFrame", layer_index - 1) @validate(InstanceOf(Image), Number(1, None)) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 44e3bdf..be893ef 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -178,6 +178,15 @@ def test_colorblending_delete_layer( cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) +def test_colorblending_delete_layer_rejects_base_layer( + colorblending, cb_call_action +): + with pytest.raises(ValueError, match="The base layer cannot be deleted."): + colorblending.delete_layer(0) + + cb_call_action.assert_not_called() + + @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) def test_colorblending_set_layer( colorblending, cb_call_action, image, idx, expected_param From fbcf72f3e772a6c3ea7ea3dea4423c39d0d5d4de Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:55:17 +0800 Subject: [PATCH 015/123] Add validation to prevent alpha list length mismatch in set_alpha method --- carta/colorblending.py | 5 +++++ tests/test_colorblending.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index b18cf40..243440e 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -429,6 +429,11 @@ def set_alpha(self, alpha_list): The alpha values. """ layer_list = self.layer_list() + if len(alpha_list) != len(layer_list): + raise ValueError( + f"alpha_list length ({len(alpha_list)}) does not match " + f"the number of layers ({len(layer_list)})." + ) for alpha, layer in zip(alpha_list, layer_list): layer.set_alpha(alpha) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index be893ef..a257c98 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -363,6 +363,16 @@ def test_colorblending_set_alpha_invalid(colorblending, vals): colorblending.set_alpha(vals) +@pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) +def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): + ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) + mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + + with pytest.raises(ValueError, match="does not match"): + colorblending.set_alpha(vals) + + @pytest.mark.parametrize( "getter,method,action,state", [ From 6596ab364d52976495f0d158a0a34e24c024871f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 18:22:09 +0800 Subject: [PATCH 016/123] Refactor ColorBlending to use store_id instead of image_id and add from_imageview_id class method --- carta/colorblending.py | 83 +++++++++++++++++++++------------ carta/constants.py | 7 +++ tests/test_colorblending.py | 93 ++++++++++++++++++++++++++++++------- 3 files changed, 136 insertions(+), 47 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 243440e..978256a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,6 +1,6 @@ -from .constants import Colormap, ColormapSet +from .constants import Colormap, ColormapSet, ImageType from .image import Image -from .util import BasePathMixin, CartaActionFailed, Macro, cached +from .util import BasePathMixin, CartaActionFailed, Macro from .validation import ( Boolean, Constant, @@ -19,14 +19,14 @@ class Layer(BasePathMixin): ---------- colorblending : :obj:`carta.colorblending.ColorBlending` The color blending object. - layer_id : int + layer_id : integer The layer ID. Attributes ---------- colorblending : :obj:`carta.colorblending.ColorBlending` The color blending object. - layer_id : int + layer_id : integer The layer ID. session : :obj:`carta.session.Session` The session object associated with this layer. @@ -49,7 +49,7 @@ def from_list(cls, colorblending, layer_ids): ---------- colorblending : :obj:`carta.colorblending.ColorBlending` The color blending object. - layer_ids : list of int + layer_ids : list of integer The layer IDs. Returns @@ -62,16 +62,15 @@ def from_list(cls, colorblending, layer_ids): def __repr__(self): """A human-readable representation of this object.""" session_id = self.session.session_id - cb_id = self.colorblending.imageview_id + cb_imageview_id = self.colorblending.imageview_id cb_name = self.colorblending.file_name repr_content = [ - f"{session_id}:{cb_id}:{cb_name}", + f"{session_id}:{cb_imageview_id}:{cb_name}", f"{self.layer_id}:{self.file_name}", ] return ":".join(repr_content) @property - @cached def file_name(self): """The name of the image. @@ -83,13 +82,12 @@ def file_name(self): return self.get_value("frameInfo.fileInfo.name") @property - @cached def image_id(self): """The ID of the image. Returns ------- - int + integer The image ID. """ return self.get_value("frameInfo.fileId") @@ -127,26 +125,51 @@ class ColorBlending(BasePathMixin): ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - image_id : int - The image ID. + store_id : integer + The color blending store ID of the color blending image. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - image_id : int - The image ID. + store_id : integer + The color blending store ID of the color blending image. """ - def __init__(self, session, image_id): + def __init__(self, session, store_id): self.session = session - self.image_id = image_id + self.store_id = store_id path = "imageViewConfigStore.colorBlendingImages" - self._base_path = f"{path}[{self.image_id}]" + self._base_path = f"{path}[{self.store_id}]" self._frame = Macro("", self._base_path) - self.base_frame = Image(self.session, self.layer_list()[0].image_id) + @classmethod + def from_imageview_id(cls, session, imageview_id): + """Create a color blending object from an image view ID. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + imageview_id : integer + The image view ID, the index of the image within the list of + currently open images, of the color blending image. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + """ + # Find the store ID for the given image view ID + path = f"imageViewConfigStore.imageList[{imageview_id}]" + image_type = session.get_value(f"{path}.type") + if image_type != ImageType.COLOR_BLENDING: + raise ValueError( + "imageview_id does not refer to a color blending image." + ) + store_id = session.get_value(f"{path}.store.id") + return cls(session, store_id) @classmethod def from_images(cls, session, images): @@ -178,8 +201,8 @@ def from_images(cls, session, images): ) command = "imageViewConfigStore.createColorBlending" - image_id = session.call_action(command, return_path="id") - return cls(session, image_id) + store_id = session.call_action(command, return_path="id") + return cls(session, store_id) @classmethod def from_files(cls, session, files, append=False): @@ -210,7 +233,10 @@ def __repr__(self): return f"{session_id}:{self.imageview_id}:{self.file_name}" @property - @cached + def _base_frame(self): + return Image(self.session, self.get_value("frames[0].id")) + + @property def file_name(self): """The name of the image. @@ -222,14 +248,13 @@ def file_name(self): return self.get_value("filename") @property - @cached def imageview_id(self): - """The ID of the image in imageView. + """The image view ID of the color blending image. Returns ------- integer - The image ID. + The image view ID. """ imageview_names = self.session.get_value( "imageViewConfigStore.imageNames" @@ -280,7 +305,7 @@ def delete_layer(self, layer_index): Parameters ---------- - layer_index : int + layer_index : integer The layer index. The base layer (layer_index = 0) cannot be deleted. """ @@ -296,7 +321,7 @@ def set_layer(self, image, layer_index): ---------- image : :obj:`carta.image.Image` The image to set. - layer_index : int + layer_index : integer The layer index. The base layer (layer_index = 0) cannot be set. """ @@ -309,7 +334,7 @@ def set_layer_sequence(self, layer_indices): Parameters ---------- - layer_indices : list of int + layer_indices : list of integer The layer indices to keep, in the desired order. The first index must be the base layer (index = 0). Existing alpha values are preserved. @@ -388,7 +413,7 @@ def set_center(self, x, y): information, or if world coordinates do not match the session-wide number formats. """ - self.base_frame.set_center(x, y) + self._base_frame.set_center(x, y) @validate(Number(), Boolean()) def set_zoom_level(self, zoom, absolute=True): @@ -404,7 +429,7 @@ def set_zoom_level(self, zoom, absolute=True): Whether the zoom level should be treated as absolute. By default it is adjusted by a scaling factor. """ - self.base_frame.set_zoom_level(zoom, absolute) + self._base_frame.set_zoom_level(zoom, absolute) @validate(Constant(ColormapSet)) def set_colormap_set(self, colormap_set): diff --git a/carta/constants.py b/carta/constants.py index dd3576d..b862cc6 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -30,6 +30,13 @@ class ColormapSet(StrEnum): Rainbow = "Rainbow" +class ImageType(IntEnum): + """Image view item types, corresponding to the frontend ImageType enum.""" + FRAME = 0 + COLOR_BLENDING = 1 + PV_PREVIEW = 2 + + Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) Scaling.__doc__ = """Colormap scaling types.""" diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index a257c98..1e91e9d 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -3,6 +3,7 @@ from carta.colorblending import ColorBlending, Layer from carta.constants import Colormap as CM from carta.constants import ColormapSet as CMS +from carta.constants import ImageType from carta.image import Image from carta.util import CartaActionFailed, CartaValidationFailed, Macro @@ -10,13 +11,7 @@ @pytest.fixture -def colorblending(session, mocker): - # Avoid hitting real layer_list logic during __init__ - class _Dummy: - def __init__(self, image_id): - self.image_id = image_id - - mocker.patch.object(ColorBlending, "layer_list", return_value=[_Dummy(42)]) +def colorblending(session): return ColorBlending(session, 0) @@ -115,6 +110,18 @@ def test_layer_set_colormap(layer, layer_call_action, invert): # TESTS — ColorBlending basics +def test_colorblending_init(session): + colorblending = ColorBlending(session, 3) + assert colorblending.store_id == 3 + assert ( + colorblending._base_path + == "imageViewConfigStore.colorBlendingImages[3]" + ) + assert colorblending._frame == Macro( + "", "imageViewConfigStore.colorBlendingImages[3]" + ) + + def test_colorblending_repr(session, colorblending, cb_property): cb_property("imageview_id", 3) cb_property("file_name", "blend.fits") @@ -140,6 +147,16 @@ def test_colorblending_alpha(colorblending, cb_get_value): cb_get_value.assert_called_with("alpha") +def test_colorblending_base_frame(colorblending, cb_get_value): + cb_get_value.return_value = 42 + base_frame = colorblending._base_frame + + cb_get_value.assert_called_once_with("frames[0].id") + assert isinstance(base_frame, Image) + assert base_frame.session is colorblending.session + assert base_frame.image_id == 42 + + def test_colorblending_make_active( session, colorblending, cb_property, session_call_action ): @@ -149,12 +166,7 @@ def test_colorblending_make_active( def test_colorblending_layer_list_derived(session, mocker): - # Construct without running __init__ to avoid base_frame wiring - cb = object.__new__(ColorBlending) - cb.session = session - cb.image_id = 0 - cb._base_path = f"imageViewConfigStore.colorBlendingImages[{cb.image_id}]" - cb._frame = Macro("", cb._base_path) + cb = ColorBlending(session, 3) # Simulate two layers from the frontend's computed frames array length. gv = mocker.patch.object(cb, "get_value") @@ -323,16 +335,28 @@ def __init__(self, lid, iid): def test_colorblending_set_center(colorblending, mocker): - set_center = mocker.patch.object(colorblending.base_frame, "set_center") + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + colorblending.set_center(1, 2) - set_center.assert_called_with(1, 2) + base_frame.set_center.assert_called_once_with(1, 2) @pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): - set_zoom = mocker.patch.object(colorblending.base_frame, "set_zoom_level") + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + colorblending.set_zoom_level(zoom, absolute) - set_zoom.assert_called_with(zoom, absolute) + base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): @@ -437,6 +461,38 @@ def test_colorblending_close(session, colorblending, session_call_action): # CREATION HELPERS +def test_colorblending_from_imageview_id(session, session_get_value, mocker): + session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] + init = mocker.patch.object(ColorBlending, "__init__", return_value=None) + + cb = ColorBlending.from_imageview_id(session, 5) + + assert isinstance(cb, ColorBlending) + assert [call.args for call in session_get_value.call_args_list] == [ + ("imageViewConfigStore.imageList[5].type",), + ("imageViewConfigStore.imageList[5].store.id",), + ] + init.assert_called_once_with(session, 17) + + +def test_colorblending_from_imageview_id_rejects_non_color_blending( + session, session_get_value, mocker +): + session_get_value.return_value = ImageType.FRAME + init = mocker.patch.object(ColorBlending, "__init__", return_value=None) + + with pytest.raises( + ValueError, + match="imageview_id does not refer to a color blending image.", + ): + ColorBlending.from_imageview_id(session, 5) + + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageList[5].type" + ) + init.assert_not_called() + + def test_colorblending_from_images_success(session, mocker): # Prepare two images to blend img0 = Image(session, 100) @@ -450,7 +506,7 @@ def test_colorblending_from_images_success(session, mocker): session.call_action.side_effect = [None, 123] # Avoid __init__ side effects; just ensure returned instance - mocker.patch.object(ColorBlending, "__init__", return_value=None) + init = mocker.patch.object(ColorBlending, "__init__", return_value=None) cb = ColorBlending.from_images(session, [img0, img1]) assert isinstance(cb, ColorBlending) session.call_action.assert_any_call( @@ -460,6 +516,7 @@ def test_colorblending_from_images_success(session, mocker): session.call_action.assert_called_with( "imageViewConfigStore.createColorBlending", return_path="id" ) + init.assert_called_once_with(session, 123) def test_colorblending_from_images_alignment_failure( From 9a20cfaabcad7002acbb9f1601c2575a99ab5dd3 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 18:31:38 +0800 Subject: [PATCH 017/123] Add color_blending_list method to Session class for retrieving open color blending objects --- carta/session.py | 14 ++++++++++++++ tests/test_session.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/carta/session.py b/carta/session.py index fc0ddc8..e924ccd 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,6 +10,7 @@ import posixpath from .image import Image +from .colorblending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, Polarization from .backend import Backend from .protocol import Protocol @@ -520,6 +521,19 @@ def image_list(self): """ return Image.from_list(self, self.get_value("frameNames")) + def color_blending_list(self): + """Return the list of currently open color blending objects. + + Returns + ------- + list of :obj:`carta.colorblending.ColorBlending` objects + The list of color blending objects open in this session. + """ + path = "imageViewConfigStore.colorBlendingImages" + length = self.get_value(f"{path}.length") + store_ids = [self.get_value(f"{path}[{idx}].id") for idx in range(length)] + return [ColorBlending(self, store_id) for store_id in store_ids] + def active_frame(self): """Return the currently active image. diff --git a/tests/test_session.py b/tests/test_session.py index cf79849..108a697 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,6 +1,7 @@ import pytest from carta.image import Image +from carta.colorblending import ColorBlending from carta.util import Macro from carta.constants import ComplexComponent as CC, Polarization as Pol @@ -69,6 +70,29 @@ def test_cd(session, method, call_action): session.cd("original/path") call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") + +def test_color_blending_list(session, get_value): + get_value.side_effect = [2, 3, 8] + + color_blendings = session.color_blending_list() + + assert len(color_blendings) == 2 + assert all(isinstance(cb, ColorBlending) for cb in color_blendings) + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages.length") + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[0].id") + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") + assert [cb.session for cb in color_blendings] == [session, session] + assert [cb.store_id for cb in color_blendings] == [3, 8] + + +def test_color_blending_list_empty(session, get_value): + get_value.return_value = 0 + + assert session.color_blending_list() == [] + get_value.assert_called_once_with( + "imageViewConfigStore.colorBlendingImages.length" + ) + # OPENING IMAGES From d9bcab4925b6e6017a0a17718d1e34c5e047beb8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:02:11 +0800 Subject: [PATCH 018/123] Update quickstart documentation to clarify ColorBlending layer terminology and usage patterns --- docs/source/quickstart.rst | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 6b6b1a8..538ca02 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -239,7 +239,7 @@ Create a color blending object from a list of files. # Make a color blending object # Warning: setting `append=False` will close any existing images - # Note: The base layer (id = 0) cannot be deleted or reordered. + # Note: The base layer (index = 0) cannot be deleted or moved. files = [ "data/hdf5/first_file.hdf5", "data/fits/second_file.fits", @@ -257,9 +257,27 @@ Create a color blending object from a list of images. # Make a color blending object # Warning: This will break the current spatial matching and # use the first image as the spatial reference - # Note: The base layer (id = 0) cannot be deleted or reordered. + # Note: The base layer (index = 0) cannot be deleted or moved. cb = ColorBlending.from_images(session, [img0, img1, img2]) +To work with color blending images that are already open in a session, use +the session helper. + +.. code-block:: python + + # Get all open color blending objects in this session + color_blendings = session.color_blending_list() + cb = color_blendings[0] + + # Or get a color blending object by its image view index + cb = ColorBlending.from_imageview_id(session, 3) + +.. note:: + The ``ColorBlending`` constructor takes the internal color blending store ID, + not the image view index. Use ``ColorBlending.from_files``, + ``ColorBlending.from_images``, ``ColorBlending.from_imageview_id`` or + ``session.color_blending_list`` in scripts. + Manipulate properties of the color blending object and the underlying images. .. code-block:: python @@ -287,12 +305,12 @@ Manipulate properties of the color blending object and the underlying images. cb.set_alpha([0.7, 0.8, 0.9]) # Set which layers to keep, and in what order - # The first layer index must be the base layer (id = 0) - # Since the base layer cannot be reordered, + # The first layer index must be the base layer (index = 0) + # Since the base layer cannot be moved, # the layers will be reordered as [img0, img2, img1] cb.set_layer_sequence([0, 2, 1]) - # Remove the last layer (id = 2) + # Remove the last layer (index = 2) cb.delete_layer(2) # Add a new layer @@ -316,7 +334,8 @@ Manipulate properties of the color blending object and the underlying images. cb.close() .. note:: - When you would like to reorder the layers, especially when the base layer (id = 0) is involved, it is more recommended to close the current color blending object and create a new one. + If you need to change the layer order involving the base layer (index = 0), + close the current color blending object and create a new one. Saving or displaying an image ----------------------------- From a34e64649b9376c9b20e5ca8883a9e5c0c941a10 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:12:05 +0800 Subject: [PATCH 019/123] Drop support for carta version < 5.0 for make_active --- carta/image.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/carta/image.py b/carta/image.py index 12fef67..6292415 100644 --- a/carta/image.py +++ b/carta/image.py @@ -255,12 +255,7 @@ def polarizations(self): def make_active(self): """Make this the active image.""" - try: - # Before CARTA 5.0.0 - self.session.call_action("setActiveFrameById", self.image_id) - except CartaActionFailed: - # After CARTA 5.0.0 (inclusive) - self.session.call_action("setActiveImageByFileId", self.image_id) + self.session.call_action("setActiveImageByFileId", self.image_id) def make_spatial_reference(self): """Make this image the spatial reference.""" From 4ce4429e665ac9abcd582de25f318250741cef6e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:14:52 +0800 Subject: [PATCH 020/123] Update documentation references to use new wcs_overlay module paths for coordinate system and number format methods --- carta/colorblending.py | 5 +++-- carta/image.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 978256a..8b3140a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -391,8 +391,9 @@ def set_center(self, x, y): World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be - changed using :obj:`carta.session.set_coordinate_system` and - :obj:`set_custom_number_format`. + changed using + :obj:`carta.wcs_overlay.Global.set_coordinate_system` and + :obj:`carta.wcs_overlay.Numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and diff --git a/carta/image.py b/carta/image.py index 6292415..95a9e9f 100644 --- a/carta/image.py +++ b/carta/image.py @@ -361,7 +361,7 @@ def valid_wcs(self): def set_center(self, x, y): """Set the center position, in image or world coordinates. - World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.session.set_coordinate_system` and :obj:`set_custom_number_format`. + World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.wcs_overlay.Global.set_coordinate_system` and :obj:`carta.wcs_overlay.Numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and numeric strings with no units are interpreted as degrees. From 25e0baa117f7658d253c1df2b56d4d931859b550 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:16:04 +0800 Subject: [PATCH 021/123] Add validation test for invalid colormap name in Layer.set_colormap method --- tests/test_colorblending.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 1e91e9d..04ebc71 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -107,6 +107,13 @@ def test_layer_set_colormap(layer, layer_call_action, invert): layer_call_action.assert_any_call("renderConfig.setInverted", invert) +def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): + with pytest.raises(CartaValidationFailed): + layer.set_colormap("not-a-colormap") + + layer_call_action.assert_not_called() + + # TESTS — ColorBlending basics From cb22ed59f783fb3db10611fc557bd2f1fb728fb7 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:17:40 +0800 Subject: [PATCH 022/123] Fix indentation in Layer.from_layer_ids docstring return section --- carta/colorblending.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8b3140a..15566ac 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -55,7 +55,7 @@ def from_list(cls, colorblending, layer_ids): Returns ------- list of :obj:`carta.colorblending.Layer` - A list of new Layer objects. + A list of new Layer objects. """ return [cls(colorblending, layer_id) for layer_id in layer_ids] From 91e952dace306fd004e3446a340330764b53d8ce Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:40:54 +0800 Subject: [PATCH 023/123] Add validation to prevent exceeding maximum initial layer count in ColorBlending initialization --- carta/colorblending.py | 21 +++++++++++++++++++ tests/test_colorblending.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index 15566ac..9c702aa 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -136,6 +136,9 @@ class ColorBlending(BasePathMixin): The color blending store ID of the color blending image. """ + # Mirrors ColorBlendingStore.DEFAULT_LAYER_LIMIT in carta-frontend. + MAX_INITIAL_LAYERS = 10 + def __init__(self, session, store_id): self.session = session self.store_id = store_id @@ -144,6 +147,15 @@ def __init__(self, session, store_id): self._base_path = f"{path}[{self.store_id}]" self._frame = Macro("", self._base_path) + @classmethod + def _validate_initial_layer_count(cls, layer_count): + if layer_count > cls.MAX_INITIAL_LAYERS: + raise ValueError( + "Color blending initialization supports at most " + f"{cls.MAX_INITIAL_LAYERS} images (the base layer plus " + f"{cls.MAX_INITIAL_LAYERS - 1} matched images)." + ) + @classmethod def from_imageview_id(cls, session, imageview_id): """Create a color blending object from an image view ID. @@ -186,7 +198,15 @@ def from_images(cls, session, images): ------- :obj:`carta.colorblending.ColorBlending` A new color blending object. + + Raises + ------ + ValueError + If more images are provided than the frontend can include when + initializing the color blending layers. """ + cls._validate_initial_layer_count(len(images)) + # Set the first image as the spatial reference session.call_action("setSpatialReference", images[0]._frame, False) # Align the other images to the spatial reference @@ -224,6 +244,7 @@ def from_files(cls, session, files, append=False): :obj:`carta.colorblending.ColorBlending` A new color blending object. """ + cls._validate_initial_layer_count(len(files)) images = session.open_images(files, append=append) return cls.from_images(session, images) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 04ebc71..b0fd26d 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -543,6 +543,27 @@ def test_colorblending_from_images_alignment_failure( ) +def test_colorblending_from_images_rejects_more_than_initial_layer_limit( + session, mocker +): + images = [ + Image(session, image_id) + for image_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) + ] + session_call_action = mocker.patch.object(session, "call_action") + + with pytest.raises( + ValueError, + match=( + "Color blending initialization supports at most 10 images " + r"\(the base layer plus 9 matched images\)." + ), + ): + ColorBlending.from_images(session, images) + + session_call_action.assert_not_called() + + def test_colorblending_from_files(session, mocker): mock_open_images = mocker.patch.object( session, @@ -556,3 +577,24 @@ def test_colorblending_from_files(session, mocker): mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) mock_from_images.assert_called() assert out == "CB" + + +def test_colorblending_from_files_rejects_more_than_initial_layer_limit( + session, mocker +): + files = [ + f"image-{file_id}.fits" + for file_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) + ] + mock_open_images = mocker.patch.object(session, "open_images") + + with pytest.raises( + ValueError, + match=( + "Color blending initialization supports at most 10 images " + r"\(the base layer plus 9 matched images\)." + ), + ): + ColorBlending.from_files(session, files) + + mock_open_images.assert_not_called() From 3f1c4fe04c0118a582cd3ca17d26a5ced5fb9a95 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:40:43 +0800 Subject: [PATCH 024/123] Rename set_vectoroverlay_visible method to set_vector_overlay_visible for consistency with naming conventions --- carta/colorblending.py | 2 +- tests/test_colorblending.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 9c702aa..9f0c886 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -511,7 +511,7 @@ def set_contour_visible(self, state): self.call_action("toggleContourVisible") @validate(Boolean()) - def set_vectoroverlay_visible(self, state): + def set_vector_overlay_visible(self, state): """Set the vector overlay visibility. Parameters diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index b0fd26d..9be2bc3 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -416,7 +416,7 @@ def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): ), ( "vectorOverlayVisible", - "set_vectoroverlay_visible", + "set_vector_overlay_visible", "toggleVectorOverlayVisible", False, ), @@ -443,7 +443,7 @@ def test_colorblending_toggle_visibility_when_needed( ), ( "vectorOverlayVisible", - "set_vectoroverlay_visible", + "set_vector_overlay_visible", "toggleVectorOverlayVisible", True, ), From d3377594f6c0bb39d27947d9952e2cbebc6a91b0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:42:20 +0800 Subject: [PATCH 025/123] Rename ColormapSet.Rainbow to RAINBOW for consistent uppercase enum naming convention --- carta/constants.py | 2 +- tests/test_colorblending.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index b862cc6..0cdf183 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -27,7 +27,7 @@ class ColormapSet(StrEnum): """Colormap sets for color blending.""" RGB = "RGB" CMY = "CMY" - Rainbow = "Rainbow" + RAINBOW = "Rainbow" class ImageType(IntEnum): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 9be2bc3..01958b2 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -372,8 +372,8 @@ def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) - colorblending.set_colormap_set(CMS.Rainbow) - cb_call_action.assert_called_with("applyColormapSet", CMS.Rainbow) + colorblending.set_colormap_set(CMS.RAINBOW) + cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) ly1.call_action.assert_called_with("renderConfig.setInverted", False) ly2.call_action.assert_called_with("renderConfig.setInverted", False) From 345c17dc4ae050d674c1fd527fe895a073a41bf8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:45:21 +0800 Subject: [PATCH 026/123] Add module docstring to colorblending.py describing color blending functionality --- carta/colorblending.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index 9f0c886..6d70048 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,3 +1,5 @@ +"""This module contains functionality for interacting with color blending images and their layers.""" + from .constants import Colormap, ColormapSet, ImageType from .image import Image from .util import BasePathMixin, CartaActionFailed, Macro From 2bf9d1f052183df481d91b1fee3424b666d55baa Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:50:14 +0800 Subject: [PATCH 027/123] Replace explicit type annotations with placeholder format strings in ColorBlending and Layer method docstrings --- carta/colorblending.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 6d70048..2a79752 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -100,7 +100,7 @@ def set_alpha(self, alpha): Parameters ---------- - alpha : float + alpha : {0} The alpha value. """ self.colorblending.call_action("setAlpha", self.layer_id, alpha) @@ -111,9 +111,9 @@ def set_colormap(self, colormap, invert=False): Parameters ---------- - colormap : :obj:`carta.constants.Colormap` + colormap : {0} The colormap. - invert : bool + invert : {1} Whether the colormap should be inverted. This is false by default. """ self.call_action("renderConfig.setColorMap", colormap) @@ -328,7 +328,7 @@ def delete_layer(self, layer_index): Parameters ---------- - layer_index : integer + layer_index : {0} The layer index. The base layer (layer_index = 0) cannot be deleted. """ @@ -342,9 +342,9 @@ def set_layer(self, image, layer_index): Parameters ---------- - image : :obj:`carta.image.Image` + image : {0} The image to set. - layer_index : integer + layer_index : {1} The layer index. The base layer (layer_index = 0) cannot be set. """ @@ -357,7 +357,7 @@ def set_layer_sequence(self, layer_indices): Parameters ---------- - layer_indices : list of integer + layer_indices : {0} The layer indices to keep, in the desired order. The first index must be the base layer (index = 0). Existing alpha values are preserved. @@ -461,7 +461,7 @@ def set_colormap_set(self, colormap_set): Parameters ---------- - colormap_set : :obj:`carta.constants.ColormapSet` + colormap_set : {0} The colormap set. """ self.call_action("applyColormapSet", colormap_set) @@ -474,7 +474,7 @@ def set_alpha(self, alpha_list): Parameters ---------- - alpha_list : list of float + alpha_list : {0} The alpha values. """ layer_list = self.layer_list() @@ -492,7 +492,7 @@ def set_raster_visible(self, state): Parameters ---------- - state : bool + state : {0} The desired visibility state. """ is_visible = self.get_value("rasterVisible") @@ -505,7 +505,7 @@ def set_contour_visible(self, state): Parameters ---------- - state : bool + state : {0} The desired visibility state. """ is_visible = self.get_value("contourVisible") @@ -518,7 +518,7 @@ def set_vector_overlay_visible(self, state): Parameters ---------- - state : bool + state : {0} The desired visibility state. """ is_visible = self.get_value("vectorOverlayVisible") From e16c9f43055eddaae9e8a1a82028b7670f6b6ecc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:52:46 +0800 Subject: [PATCH 028/123] Change periods to colons in ColorBlending section introductory sentences for consistency with documentation style --- docs/source/quickstart.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 538ca02..d6b6874 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -230,7 +230,7 @@ Properties which affect the whole session can be set through the session object: Making color blended image -------------------------- -Create a color blending object from a list of files. +Create a color blending object from a list of files: .. code-block:: python @@ -247,7 +247,7 @@ Create a color blending object from a list of files. ] cb = ColorBlending.from_files(session, files, append=False) -Create a color blending object from a list of images. +Create a color blending object from a list of images: .. code-block:: python @@ -261,7 +261,7 @@ Create a color blending object from a list of images. cb = ColorBlending.from_images(session, [img0, img1, img2]) To work with color blending images that are already open in a session, use -the session helper. +the session helper: .. code-block:: python @@ -278,7 +278,7 @@ the session helper. ``ColorBlending.from_images``, ``ColorBlending.from_imageview_id`` or ``session.color_blending_list`` in scripts. -Manipulate properties of the color blending object and the underlying images. +Manipulate properties of the color blending object and the underlying images: .. code-block:: python From 2812ffaca3c6ca6620740d4cd1fb5ca4bca3de59 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 22:17:56 +0800 Subject: [PATCH 029/123] Update test_make_active to use setActiveImageByFileId action instead of setActiveFrameById --- carta/image.py | 2 +- tests/test_image.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/carta/image.py b/carta/image.py index 95a9e9f..ea3945f 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,7 +5,7 @@ from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin, CartaActionFailed, Point as Pt +from .util import Macro, cached, BasePathMixin, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point from .metadata import parse_header diff --git a/tests/test_image.py b/tests/test_image.py index 9a1c275..e8bf392 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -110,7 +110,7 @@ def test_simple_properties(image, property_name, expected_path, get_value): def test_make_active(image, session_call_action): image.make_active() - session_call_action.assert_called_with("setActiveFrameById", 0) + session_call_action.assert_called_with("setActiveImageByFileId", 0) @pytest.mark.parametrize("channel", [0, 10, 19]) From 509d4e2d2befb6ca0bfdb7335ea5b63bd35df90a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 12:37:00 +0800 Subject: [PATCH 030/123] Fix ColorBlending initialization to rebuild layers from requested images and update base path to colorBlendingImageMap --- carta/colorblending.py | 16 ++++++++++++-- tests/test_colorblending.py | 42 ++++++++++++++++++++++++++----------- tests/test_session.py | 4 ++++ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 2a79752..3832851 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -145,7 +145,7 @@ def __init__(self, session, store_id): self.session = session self.store_id = store_id - path = "imageViewConfigStore.colorBlendingImages" + path = "imageViewConfigStore.colorBlendingImageMap" self._base_path = f"{path}[{self.store_id}]" self._frame = Macro("", self._base_path) @@ -224,7 +224,19 @@ def from_images(cls, session, images): command = "imageViewConfigStore.createColorBlending" store_id = session.call_action(command, return_path="id") - return cls(session, store_id) + colorblending = cls(session, store_id) + + # The frontend initializes color blending from the current spatial + # reference's secondarySpatialImages, which can include frames matched + # before this helper was called. Rebuild the non-base layers so the + # blend contains exactly the images requested here without clearing the + # session-wide spatial matching state. + for _ in colorblending.layer_list()[1:]: + colorblending.delete_layer(1) + for image in images[1:]: + colorblending.add_layer(image) + + return colorblending @classmethod def from_files(cls, session, files, append=False): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 01958b2..313e952 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -122,10 +122,10 @@ def test_colorblending_init(session): assert colorblending.store_id == 3 assert ( colorblending._base_path - == "imageViewConfigStore.colorBlendingImages[3]" + == "imageViewConfigStore.colorBlendingImageMap[3]" ) assert colorblending._frame == Macro( - "", "imageViewConfigStore.colorBlendingImages[3]" + "", "imageViewConfigStore.colorBlendingImageMap[3]" ) @@ -468,18 +468,21 @@ def test_colorblending_close(session, colorblending, session_call_action): # CREATION HELPERS -def test_colorblending_from_imageview_id(session, session_get_value, mocker): +def test_colorblending_from_imageview_id(session, session_get_value): session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] - init = mocker.patch.object(ColorBlending, "__init__", return_value=None) cb = ColorBlending.from_imageview_id(session, 5) assert isinstance(cb, ColorBlending) + assert cb.store_id == 17 + assert ( + cb._base_path + == "imageViewConfigStore.colorBlendingImageMap[17]" + ) assert [call.args for call in session_get_value.call_args_list] == [ ("imageViewConfigStore.imageList[5].type",), ("imageViewConfigStore.imageList[5].store.id",), ] - init.assert_called_once_with(session, 17) def test_colorblending_from_imageview_id_rejects_non_color_blending( @@ -501,29 +504,44 @@ def test_colorblending_from_imageview_id_rejects_non_color_blending( def test_colorblending_from_images_success(session, mocker): - # Prepare two images to blend img0 = Image(session, 100) img1 = Image(session, 200) + img2 = Image(session, 300) - # setSpatialReference alignment returns True for img1 mocker.patch.object(session, "call_action") mocker.patch.object(img1, "call_action", return_value=True) + mocker.patch.object(img2, "call_action", return_value=True) + layer_list = mocker.patch.object( + ColorBlending, + "layer_list", + autospec=True, + return_value=[object(), object(), object(), object()], + ) + delete_layer = mocker.patch.object( + ColorBlending, "delete_layer", autospec=True + ) + add_layer = mocker.patch.object(ColorBlending, "add_layer", autospec=True) - # Create ID for new color blending session.call_action.side_effect = [None, 123] - # Avoid __init__ side effects; just ensure returned instance - init = mocker.patch.object(ColorBlending, "__init__", return_value=None) - cb = ColorBlending.from_images(session, [img0, img1]) + cb = ColorBlending.from_images(session, [img0, img1, img2]) + assert isinstance(cb, ColorBlending) + assert cb.store_id == 123 + assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" session.call_action.assert_any_call( "setSpatialReference", img0._frame, False ) img1.call_action.assert_called_with("setSpatialReference", img0._frame) + img2.call_action.assert_called_with("setSpatialReference", img0._frame) session.call_action.assert_called_with( "imageViewConfigStore.createColorBlending", return_path="id" ) - init.assert_called_once_with(session, 123) + layer_list.assert_called_once_with(cb) + delete_layer.assert_has_calls( + [mocker.call(cb, 1), mocker.call(cb, 1), mocker.call(cb, 1)] + ) + add_layer.assert_has_calls([mocker.call(cb, img1), mocker.call(cb, img2)]) def test_colorblending_from_images_alignment_failure( diff --git a/tests/test_session.py b/tests/test_session.py index 108a697..3fb07db 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -83,6 +83,10 @@ def test_color_blending_list(session, get_value): get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") assert [cb.session for cb in color_blendings] == [session, session] assert [cb.store_id for cb in color_blendings] == [3, 8] + assert [cb._base_path for cb in color_blendings] == [ + "imageViewConfigStore.colorBlendingImageMap[3]", + "imageViewConfigStore.colorBlendingImageMap[8]", + ] def test_color_blending_list_empty(session, get_value): From af48cf33cdf4e1896e44114bf98c1f06ef892ebc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 12:39:27 +0800 Subject: [PATCH 031/123] Add validation to reject duplicate layer indices in ColorBlending.set_layer_sequence method --- carta/colorblending.py | 5 +++++ tests/test_colorblending.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index 3832851..8020bba 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -395,6 +395,11 @@ def set_layer_sequence(self, layer_indices): "as the first index." ) + if len(layer_indices) != len(set(layer_indices)): + raise ValueError( + "layer_indices must not contain duplicate layer indices." + ) + current_layer_indices = list(range(len(current_layers))) if layer_indices == current_layer_indices: return diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 313e952..ce76325 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -341,6 +341,27 @@ def __init__(self, lid, iid): colorblending.set_layer_sequence([0, 2, 0]) +def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match="layer_indices must not contain duplicate layer indices.", + ): + colorblending.set_layer_sequence([0, 1, 1]) + + def test_colorblending_set_center(colorblending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( From 2f7d76bc6ff72e25fc797e0a2d5847a39e82a49c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 12:46:09 +0800 Subject: [PATCH 032/123] Remove automatic inversion reset when applying colormap sets in ColorBlending.set_colormap_set method --- carta/colorblending.py | 2 -- tests/test_colorblending.py | 9 +-------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8020bba..8270cf9 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -482,8 +482,6 @@ def set_colormap_set(self, colormap_set): The colormap set. """ self.call_action("applyColormapSet", colormap_set) - for layer in self.layer_list(): - layer.call_action("renderConfig.setInverted", False) @validate(IterableOf(Number(0, 1))) def set_alpha(self, alpha_list): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index ce76325..2c099d1 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -387,16 +387,9 @@ def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) -def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): - # Two layers; verify setInverted(False) called on each - ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) - ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) - mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) - +def test_colorblending_set_colormap_set(colorblending, cb_call_action): colorblending.set_colormap_set(CMS.RAINBOW) cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) - ly1.call_action.assert_called_with("renderConfig.setInverted", False) - ly2.call_action.assert_called_with("renderConfig.setInverted", False) def test_colorblending_set_alpha_valid(colorblending, mocker): From 56e019df8e68c15c7d287b9d8b3da4312d03e394 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 13:00:56 +0800 Subject: [PATCH 033/123] Fix imageview_id property to search imageList by store_id instead of matching file_name in imageNames array --- carta/colorblending.py | 16 +++++++++++++--- tests/test_colorblending.py | 16 ++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8270cf9..c1080fc 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -291,10 +291,20 @@ def imageview_id(self): integer The image view ID. """ - imageview_names = self.session.get_value( - "imageViewConfigStore.imageNames" + path = "imageViewConfigStore.imageList" + length = self.session.get_value(f"{path}.length") + for idx in range(length): + entry = f"{path}[{idx}]" + if ( + self.session.get_value(f"{entry}.type") + == ImageType.COLOR_BLENDING + and self.session.get_value(f"{entry}.store.id") + == self.store_id + ): + return idx + raise RuntimeError( + "Could not find this color blending image in the image list." ) - return imageview_names.index(self.file_name) @property def alpha(self): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 2c099d1..c7a147a 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -140,13 +140,17 @@ def test_colorblending_file_name(colorblending, cb_get_value): cb_get_value.assert_called_with("filename") -def test_colorblending_imageview_id( - session, colorblending, session_get_value, cb_property -): - cb_property("file_name", "imgC") - session_get_value.side_effect = [["imgA", "imgB", "imgC", "imgD"]] +def test_colorblending_imageview_id(session, colorblending, session_get_value): + # imageList has 3 entries; the color blending with store_id=0 is at index 2 + session_get_value.side_effect = [ + 3, # imageList.length + ImageType.FRAME, # [0].type — skip + ImageType.COLOR_BLENDING, # [1].type — match type… + 99, # [1].store.id — wrong store_id + ImageType.COLOR_BLENDING, # [2].type — match type… + 0, # [2].store.id — matches store_id=0 + ] assert colorblending.imageview_id == 2 - session_get_value.assert_called_with("imageViewConfigStore.imageNames") def test_colorblending_alpha(colorblending, cb_get_value): From 6e02ba28f32a5e6c568362f9885ca97b42d1f383 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 13:03:52 +0800 Subject: [PATCH 034/123] Convert layer_indices to list before comparing with current_layer_indices in set_layer_sequence method --- carta/colorblending.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index c1080fc..96191b1 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -411,7 +411,7 @@ def set_layer_sequence(self, layer_indices): ) current_layer_indices = list(range(len(current_layers))) - if layer_indices == current_layer_indices: + if list(layer_indices) == current_layer_indices: return current_alpha_values = self.alpha From 9ea1a6644fe40317719c1422b940f922a852f32c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 13:31:56 +0800 Subject: [PATCH 035/123] Style check --- carta/colorblending.py | 11 +++++------ tests/test_colorblending.py | 12 ++++-------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 96191b1..c6d5bc0 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -295,12 +295,11 @@ def imageview_id(self): length = self.session.get_value(f"{path}.length") for idx in range(length): entry = f"{path}[{idx}]" - if ( - self.session.get_value(f"{entry}.type") - == ImageType.COLOR_BLENDING - and self.session.get_value(f"{entry}.store.id") - == self.store_id - ): + entry_type = self.session.get_value(f"{entry}.type") + if entry_type != ImageType.COLOR_BLENDING: + continue + entry_id = self.session.get_value(f"{entry}.store.id") + if entry_id == self.store_id: return idx raise RuntimeError( "Could not find this color blending image in the image list." diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index c7a147a..61f8ef3 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -120,10 +120,8 @@ def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): def test_colorblending_init(session): colorblending = ColorBlending(session, 3) assert colorblending.store_id == 3 - assert ( - colorblending._base_path - == "imageViewConfigStore.colorBlendingImageMap[3]" - ) + expected = "imageViewConfigStore.colorBlendingImageMap[3]" + assert colorblending._base_path == expected assert colorblending._frame == Macro( "", "imageViewConfigStore.colorBlendingImageMap[3]" ) @@ -493,10 +491,8 @@ def test_colorblending_from_imageview_id(session, session_get_value): assert isinstance(cb, ColorBlending) assert cb.store_id == 17 - assert ( - cb._base_path - == "imageViewConfigStore.colorBlendingImageMap[17]" - ) + expected = "imageViewConfigStore.colorBlendingImageMap[17]" + assert cb._base_path == expected assert [call.args for call in session_get_value.call_args_list] == [ ("imageViewConfigStore.imageList[5].type",), ("imageViewConfigStore.imageList[5].store.id",), From 50a21bc1b2085035594536fe117410a4566ebd61 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 16:34:27 +0800 Subject: [PATCH 036/123] Implement ImageBase class --- carta/image.py | 113 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/carta/image.py b/carta/image.py index ea3945f..0fc0506 100644 --- a/carta/image.py +++ b/carta/image.py @@ -1,11 +1,11 @@ -"""This module contains an image class which represents a single image open in the session. +"""This module contains the image classes representing image-view items open in the session. Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ -from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin, Point as Pt +from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit +from .util import Macro, cached, BasePathMixin, CartaScriptingException, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point from .metadata import parse_header @@ -16,8 +16,41 @@ from .region import RegionSet -class Image(BasePathMixin): - """This object corresponds to an image open in a CARTA frontend session. +class ImageBase: + """Base class for image-view items (file-based images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this image-view item. + """ + + _image_type: ImageType = None + + def __init__(self, session): + self.session = session + + @property + def _stable_id(self): + """The stable identifier of this image-view item.""" + raise NotImplementedError + + @property + def image_view_order(self): + """The index of this item in image list.""" + raise NotImplementedError + + def make_active(self): + """Make this the active image-view item.""" + self.session.call_action( + "setActiveImageById", self._image_type, self._stable_id + ) + + +class Image(ImageBase, BasePathMixin): + """This object corresponds to a file-based image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. @@ -25,15 +58,15 @@ class Image(BasePathMixin): ---------- session : :obj:`carta.session.Session` The session object associated with this image. - image_id : integer - The ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. + file_id : integer + The frontend file id identifying this image. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. - image_id : integer - The ID identifying this image within the session. + file_id : integer + The frontend file id identifying this image. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` @@ -46,11 +79,13 @@ class Image(BasePathMixin): Functions for manipulating regions associated with this image. """ - def __init__(self, session, image_id): + _image_type = ImageType.FRAME + + def __init__(self, session, file_id): self.session = session - self.image_id = image_id + self.file_id = file_id - self._base_path = f"frameMap[{image_id}]" + self._base_path = f"frameMap[{file_id}]" self._frame = Macro("", self._base_path) # Sub-objects grouping related functions @@ -60,6 +95,10 @@ def __init__(self, session, image_id): self.wcs = ImageWCSOverlay(self) self.regions = RegionSet(self) + @property + def _stable_id(self): + return self.file_id + @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): """Open or append a new image in the session and return an image object associated with it. @@ -98,32 +137,42 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(make_active) params.append(update_directory) - image_id = session.call_action(command, *params, return_path="frameInfo.fileId") - return cls(session, image_id) - - @classmethod - def from_list(cls, session, image_list): - """Create a list of image objects from a list of open images retrieved from the frontend. + file_id = session.call_action(command, *params, return_path="frameInfo.fileId") + return cls(session, file_id) - This method should not be used directly. It is wrapped by :obj:`carta.session.Session.image_list`. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - image_list : list of dicts - The JSON object representing frame names retrieved from the frontend. + @property + def image_view_order(self): + """The current index of this image in image list. Returns ------- - list of :obj:`carta.image.Image` - A list of new image objects. + integer + The image view order. + + Raises + ------ + RuntimeError + If no matching frame entry exists in the image list. """ - return [cls(session, f["value"]) for f in image_list] + return self.session._find_image_view_order(ImageType.FRAME, self.file_id) def __repr__(self): """A human-readable representation of this image object.""" - return f"{self.session.session_id}:{self.image_id}:{self.file_name}" + cls = type(self).__name__ + cached_name = getattr(self, "_cache", {}).get("file_name") + name_part = f", file_name={cached_name!r}" if cached_name is not None else "" + + try: + order = self.image_view_order + except (CartaScriptingException, RuntimeError): + return f"[Invalid] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" + + try: + name = self.file_name + except CartaScriptingException: + return f"[Invalid] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" + + return f"{cls}(image_view_order={order}, file_name={name!r}, file_id={self.file_id})" # METADATA @@ -253,10 +302,6 @@ def polarizations(self): # SELECTION - def make_active(self): - """Make this the active image.""" - self.session.call_action("setActiveImageByFileId", self.image_id) - def make_spatial_reference(self): """Make this image the spatial reference.""" self.session.call_action("setSpatialReference", self._frame) From 6c2d9a15ea886bec0be576476275aa04c859a57a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 16:43:18 +0800 Subject: [PATCH 037/123] Update Image.__repr__ to use [Closed] label instead of [Invalid] for inaccessible images --- carta/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/carta/image.py b/carta/image.py index 0fc0506..98f7ed3 100644 --- a/carta/image.py +++ b/carta/image.py @@ -165,12 +165,12 @@ def __repr__(self): try: order = self.image_view_order except (CartaScriptingException, RuntimeError): - return f"[Invalid] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" try: name = self.file_name except CartaScriptingException: - return f"[Invalid] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" return f"{cls}(image_view_order={order}, file_name={name!r}, file_id={self.file_id})" From ace85cbb7bed841460eead932597b5f99c05ee17 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 17:08:22 +0800 Subject: [PATCH 038/123] Update ColorBlending and Layer to use color_blending_id and improve __repr__ methods --- carta/colorblending.py | 223 ++++++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 101 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index c6d5bc0..a395e46 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,9 +1,10 @@ """This module contains functionality for interacting with color blending images and their layers.""" from .constants import Colormap, ColormapSet, ImageType -from .image import Image -from .util import BasePathMixin, CartaActionFailed, Macro +from .image import Image, ImageBase +from .util import BasePathMixin, CartaActionFailed, CartaScriptingException, Macro from .validation import ( + Any, Boolean, Constant, Coordinate, @@ -62,15 +63,30 @@ def from_list(cls, colorblending, layer_ids): return [cls(colorblending, layer_id) for layer_id in layer_ids] def __repr__(self): - """A human-readable representation of this object.""" - session_id = self.session.session_id - cb_imageview_id = self.colorblending.imageview_id - cb_name = self.colorblending.file_name - repr_content = [ - f"{session_id}:{cb_imageview_id}:{cb_name}", - f"{self.layer_id}:{self.file_name}", - ] - return ":".join(repr_content) + """A human-readable representation of this layer.""" + cls = type(self).__name__ + cb_id = self.colorblending.color_blending_id + + try: + order = self.colorblending.image_view_order + except (CartaScriptingException, RuntimeError): + return ( + f"[Closed] {cls}(image_view_order=None, " + f"color_blending_id={cb_id}, layer_id={self.layer_id})" + ) + + try: + name = self.file_name + except CartaScriptingException: + return ( + f"[Closed] {cls}(image_view_order={order}, " + f"color_blending_id={cb_id}, layer_id={self.layer_id})" + ) + + return ( + f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " + f"layer_id={self.layer_id}, file_name={name!r})" + ) @property def file_name(self): @@ -84,13 +100,13 @@ def file_name(self): return self.get_value("frameInfo.fileInfo.name") @property - def image_id(self): - """The ID of the image. + def file_id(self): + """The frontend file id of the layer's underlying image. Returns ------- integer - The image ID. + The file id. """ return self.get_value("frameInfo.fileId") @@ -120,81 +136,90 @@ def set_colormap(self, colormap, invert=False): self.call_action("renderConfig.setInverted", invert) -class ColorBlending(BasePathMixin): +class ColorBlending(ImageBase, BasePathMixin): """This object represents a color blending image in a session. Parameters ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - store_id : integer - The color blending store ID of the color blending image. + color_blending_id : integer + The id of the backing ``ColorBlendingStore`` on the frontend. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - store_id : integer - The color blending store ID of the color blending image. + color_blending_id : integer + The id of the backing ``ColorBlendingStore`` on the frontend. """ - # Mirrors ColorBlendingStore.DEFAULT_LAYER_LIMIT in carta-frontend. - MAX_INITIAL_LAYERS = 10 + _image_type = ImageType.COLOR_BLENDING - def __init__(self, session, store_id): + def __init__(self, session, color_blending_id): self.session = session - self.store_id = store_id + self.color_blending_id = color_blending_id path = "imageViewConfigStore.colorBlendingImageMap" - self._base_path = f"{path}[{self.store_id}]" + self._base_path = f"{path}[{self.color_blending_id}]" self._frame = Macro("", self._base_path) - @classmethod - def _validate_initial_layer_count(cls, layer_count): - if layer_count > cls.MAX_INITIAL_LAYERS: - raise ValueError( - "Color blending initialization supports at most " - f"{cls.MAX_INITIAL_LAYERS} images (the base layer plus " - f"{cls.MAX_INITIAL_LAYERS - 1} matched images)." - ) + @property + def _stable_id(self): + return self.color_blending_id @classmethod - def from_imageview_id(cls, session, imageview_id): - """Create a color blending object from an image view ID. + def from_image_view_order(cls, session, image_view_order): + """Create a color blending object from an image view order. Parameters ---------- session : :obj:`carta.session.Session` The session object. - imageview_id : integer - The image view ID, the index of the image within the list of - currently open images, of the color blending image. + image_view_order : integer + The image-view order of the color blending image. Returns ------- :obj:`carta.colorblending.ColorBlending` A new color blending object. + + Raises + ------ + ValueError + If the entry at the given image-view order is not a color blending image. + IndexError + If ``image_view_order`` is out of range. """ - # Find the store ID for the given image view ID - path = f"imageViewConfigStore.imageList[{imageview_id}]" - image_type = session.get_value(f"{path}.type") - if image_type != ImageType.COLOR_BLENDING: + summary = session.get_value("imageViewConfigStore.imageListSummary") + if image_view_order < 0 or image_view_order >= len(summary): + raise IndexError( + f"image_view_order {image_view_order} is out of range for " + f"an image list of length {len(summary)}." + ) + entry = summary[image_view_order] + if entry["type"] != ImageType.COLOR_BLENDING: raise ValueError( - "imageview_id does not refer to a color blending image." + "image_view_order does not refer to a color blending image." ) - store_id = session.get_value(f"{path}.store.id") - return cls(session, store_id) + return cls(session, entry["id"]) @classmethod + @validate(Any(), IterableOf(InstanceOf(Image), min_size=1)) def from_images(cls, session, images): """Create a color blending object from a list of images. + Side effect: this overwrites the session-wide spatial reference + to ``images[0]`` and spatially matches each of ``images[1:]`` to + it. + Parameters ---------- session : :obj:`carta.session.Session` The session object. - images : list of :obj:`carta.image.Image` - The images to be blended. + images : {1} + The images to be blended. Must be non-empty. The first entry + becomes the base layer. Returns ------- @@ -203,40 +228,25 @@ def from_images(cls, session, images): Raises ------ - ValueError - If more images are provided than the frontend can include when - initializing the color blending layers. + CartaValidationFailed + If ``images`` is empty or contains a non-:obj:`carta.image.Image` + value. + CartaActionFailed + If the atomic frontend action fails. In practice this + happens when the input contains a stale/closed frame or + exceeds the frontend's layer-count limit. """ - cls._validate_initial_layer_count(len(images)) - - # Set the first image as the spatial reference - session.call_action("setSpatialReference", images[0]._frame, False) - # Align the other images to the spatial reference - for image in images[1:]: - success = image.call_action( - "setSpatialReference", images[0]._frame + result = session.call_action( + "imageViewConfigStore.createColorBlendingFromFrames", + [image._frame for image in images], + ) + if result is None: + raise CartaActionFailed( + "Failed to create color blending: the frontend returned " + "null. This indicates a stale frame or a layer-count " + "limit exceeded." ) - if not success: - name = image.file_name - raise CartaActionFailed( - f"Failed to set spatial reference for image {name}." - ) - - command = "imageViewConfigStore.createColorBlending" - store_id = session.call_action(command, return_path="id") - colorblending = cls(session, store_id) - - # The frontend initializes color blending from the current spatial - # reference's secondarySpatialImages, which can include frames matched - # before this helper was called. Rebuild the non-base layers so the - # blend contains exactly the images requested here without clearing the - # session-wide spatial matching state. - for _ in colorblending.layer_list()[1:]: - colorblending.delete_layer(1) - for image in images[1:]: - colorblending.add_layer(image) - - return colorblending + return cls(session, result["id"]) @classmethod def from_files(cls, session, files, append=False): @@ -258,14 +268,34 @@ def from_files(cls, session, files, append=False): :obj:`carta.colorblending.ColorBlending` A new color blending object. """ - cls._validate_initial_layer_count(len(files)) images = session.open_images(files, append=append) return cls.from_images(session, images) def __repr__(self): """A human-readable representation of this color blending object.""" - session_id = self.session.session_id - return f"{session_id}:{self.imageview_id}:{self.file_name}" + cls = type(self).__name__ + + try: + order = self.image_view_order + except (CartaScriptingException, RuntimeError): + return ( + f"[Closed] {cls}(image_view_order=None, " + f"color_blending_id={self.color_blending_id})" + ) + + try: + name = self.file_name + except CartaScriptingException: + return ( + f"[Closed] {cls}(image_view_order={order}, " + f"color_blending_id={self.color_blending_id})" + ) + + return ( + f"{cls}(image_view_order={order}, " + f"color_blending_id={self.color_blending_id}, " + f"file_name={name!r})" + ) @property def _base_frame(self): @@ -283,26 +313,21 @@ def file_name(self): return self.get_value("filename") @property - def imageview_id(self): - """The image view ID of the color blending image. + def image_view_order(self): + """The current index of this color blending in image list. Returns ------- integer - The image view ID. + The image view order. + + Raises + ------ + RuntimeError + If no matching color blending entry exists in the image list. """ - path = "imageViewConfigStore.imageList" - length = self.session.get_value(f"{path}.length") - for idx in range(length): - entry = f"{path}[{idx}]" - entry_type = self.session.get_value(f"{entry}.type") - if entry_type != ImageType.COLOR_BLENDING: - continue - entry_id = self.session.get_value(f"{entry}.store.id") - if entry_id == self.store_id: - return idx - raise RuntimeError( - "Could not find this color blending image in the image list." + return self.session._find_image_view_order( + ImageType.COLOR_BLENDING, self.color_blending_id ) @property @@ -316,10 +341,6 @@ def alpha(self): """ return self.get_value("alpha") - def make_active(self): - """Make this the active image.""" - self.session.call_action("setActiveImageByIndex", self.imageview_id) - def layer_list(self): """ Returns a list of Layer objects, each representing a layer in @@ -416,7 +437,7 @@ def set_layer_sequence(self, layer_indices): current_alpha_values = self.alpha target_layer_states = [ ( - Image(self.session, current_layers[layer_index].image_id), + Image(self.session, current_layers[layer_index].file_id), current_alpha_values[layer_index], ) for layer_index in layer_indices[1:] From 566dfb0e72047fe8f388c7b0428ef97d2f17c07b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 17:20:47 +0800 Subject: [PATCH 039/123] Add get_image method and refactor image list handling to support heterogeneous image-view items --- carta/session.py | 187 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 170 insertions(+), 17 deletions(-) diff --git a/carta/session.py b/carta/session.py index e924ccd..312b060 100644 --- a/carta/session.py +++ b/carta/session.py @@ -11,7 +11,7 @@ from .image import Image from .colorblending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, Polarization +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt @@ -508,31 +508,184 @@ def open_hypercube(self, image_paths, append=False): output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" - image_id = self.call_action(command, stokes_images, output_directory, output_hdu) - return Image(self, image_id) + file_id = self.call_action(command, stokes_images, output_directory, output_hdu) + return Image(self, file_id) def image_list(self): - """Return the list of currently open images. + """Return the list of currently open image-view items. Returns ------- - list of :obj:`carta.image.Image` objects - The list of images open in this session. + list of :obj:`carta.image.ImageBase` + The heterogeneous list of image-view items open in this session. + """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + result = [] + for order, entry in enumerate(summary): + entry_type = entry["type"] + if entry_type == ImageType.FRAME: + result.append(Image(self, entry["id"])) + elif entry_type == ImageType.COLOR_BLENDING: + result.append(ColorBlending(self, entry["id"])) + else: + raise NotImplementedError( + f"image_list encountered an unsupported image-view " + f"entry at order {order} with type {entry_type!r}; " + "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " + "entries are currently wrapped." + ) + return result + + def _find_image_view_order(self, image_type, stable_id): + """Return the image-view order of an item identified by a stable id. + + Parameters + ---------- + image_type : :obj:`carta.constants.ImageType` + The image-view item type. + stable_id : integer + The stable id for that type (``file_id`` for frames, + ``color_blending_id`` for color blendings). + + Returns + ------- + integer + The image-view order of the first matching entry. + + Raises + ------ + RuntimeError + If no matching entry exists in the image list. + """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + for idx, entry in enumerate(summary): + if entry["type"] == image_type and entry["id"] == stable_id: + return idx + raise RuntimeError( + f"Could not find an image of type {image_type!r} with id " + f"{stable_id} in the image list." + ) + + def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=None): + """Return the image-view item identified by exactly one of the supported identifiers. + + Parameters + ---------- + image_view_order : integer, optional + The index of the item in the image list. + Returns whichever concrete wrapper (:obj:`carta.image.Image` + or :obj:`carta.colorblending.ColorBlending`) matches the + entry type at that position. Raises :obj:`NotImplementedError` + for any future entry type that is not yet wrapped on the + Python side. + file_id : integer, optional + The stable frontend file id of a normal frame-backed image. + color_blending_id : integer, optional + The stable id of a color blending. + + Returns + ------- + :obj:`carta.image.ImageBase` + The matching image-view item. + + Raises + ------ + ValueError + If zero or more than one of the keyword arguments is provided. + The error message lists the three accepted keyword names so + the API is discoverable from the exception alone. + IndexError + If ``image_view_order`` is out of range. + RuntimeError + If no matching entry exists for the given ``file_id`` or + ``color_blending_id``. There is no cross-type fallback. """ - return Image.from_list(self, self.get_value("frameNames")) + provided = { + "image_view_order": image_view_order, + "file_id": file_id, + "color_blending_id": color_blending_id, + } + given = [key for key, val in provided.items() if val is not None] + if len(given) != 1: + raise ValueError( + "get_image requires exactly one of the keyword arguments " + "`image_view_order`, `file_id`, or `color_blending_id`; " + f"got {len(given)}." + ) + + summary = self.get_value("imageViewConfigStore.imageListSummary") + + if image_view_order is not None: + if image_view_order < 0 or image_view_order >= len(summary): + raise IndexError( + f"image_view_order {image_view_order} is out of range " + f"for an image list of length {len(summary)}." + ) + entry = summary[image_view_order] + entry_type = entry["type"] + entry_id = entry["id"] + if entry_type == ImageType.FRAME: + return Image(self, entry_id) + if entry_type == ImageType.COLOR_BLENDING: + return ColorBlending(self, entry_id) + raise NotImplementedError( + f"get_image encountered an unsupported image-view entry " + f"at order {image_view_order} with type {entry_type!r}; " + "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " + "entries are currently wrapped." + ) + + if file_id is not None: + for entry in summary: + if entry["type"] == ImageType.FRAME and entry["id"] == file_id: + return Image(self, file_id) + raise RuntimeError( + f"No file-based image with file_id={file_id} is open." + ) + + # color_blending_id is not None + for entry in summary: + if ( + entry["type"] == ImageType.COLOR_BLENDING + and entry["id"] == color_blending_id + ): + return ColorBlending(self, color_blending_id) + raise RuntimeError( + f"No color blending with color_blending_id={color_blending_id} is open." + ) + + @validate(IterableOf(String()), Boolean()) + def open_as_color_blending(self, files, append=False): + """Open files and combine them into a new color blending image. - def color_blending_list(self): - """Return the list of currently open color blending objects. + Parameters + ---------- + files : {0} + The files to be blended. + append : {1} + Whether the images should be appended to existing images. By default this is ``False`` and any existing open images are closed. Returns ------- - list of :obj:`carta.colorblending.ColorBlending` objects - The list of color blending objects open in this session. + :obj:`carta.colorblending.ColorBlending` + The new color blending object. """ - path = "imageViewConfigStore.colorBlendingImages" - length = self.get_value(f"{path}.length") - store_ids = [self.get_value(f"{path}[{idx}].id") for idx in range(length)] - return [ColorBlending(self, store_id) for store_id in store_ids] + return ColorBlending.from_files(self, files, append=append) + + def create_color_blending(self, images): + """Combine already-open images into a new color blending image. + + Parameters + ---------- + images : list of :obj:`carta.image.Image` + The images to be blended. The first entry becomes the base layer. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + The new color blending object. + """ + return ColorBlending.from_images(self, images) def active_frame(self): """Return the currently active image. @@ -542,8 +695,8 @@ def active_frame(self): :obj:`carta.image.Image` The currently active image. """ - image_id = self.get_value("activeFrame.frameInfo.fileId") - return Image(self, image_id) + file_id = self.get_value("activeFrame.frameInfo.fileId") + return Image(self, file_id) def image_by_id(self, image_id): """Return an image object with the specified ID. From 07353f199ca57a38dc3b67ac5bb39eaee7894cc0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 17:34:46 +0800 Subject: [PATCH 040/123] Filter image_list to return only Image instances in _images helper method --- carta/wcs_overlay.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index a82a4f7..6d26829 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -437,8 +437,9 @@ class ImageWCSConnector: def _images(self, image_ids=None): """Internal helper function for fetching image objects.""" + from .image import Image if image_ids is None: - return self.session.image_list() + return [img for img in self.session.image_list() if isinstance(img, Image)] return [self.session.image_by_id(image_id) for image_id in image_ids] def _get_image_wcs_properties(self, image_ids, property_path): From 4302da5e4351d85873a3779b42f21f2dd42c8635 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 18:19:20 +0800 Subject: [PATCH 041/123] Update tests --- tests/test_colorblending.py | 331 +++++++++++++++++++----------------- tests/test_image.py | 132 +++++++++++++- tests/test_session.py | 179 ++++++++++++++++--- 3 files changed, 462 insertions(+), 180 deletions(-) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 61f8ef3..3c569e0 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -69,13 +69,41 @@ def test_layer_from_list(colorblending): assert all(ly.colorblending is colorblending for ly in layers) -def test_layer_repr(session, colorblending, cb_property, layer_property): - cb_property("imageview_id", 11) - cb_property("file_name", "blend.fits") +def test_layer_repr_healthy(session, colorblending, layer_property, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_name", "layer1.fits") r = repr(Layer(colorblending, 3)) - # session id is 0 (from conftest) - assert r == "0:11:blend.fits:3:layer1.fits" + assert r == ( + "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " + "file_name='layer1.fits')" + ) + + +def test_layer_repr_closed_when_parent_missing(session, colorblending, mocker): + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + r = repr(Layer(colorblending, 3)) + assert r == ( + "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch( + "carta.colorblending.Layer.file_name", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("frame is gone"), + ) + r = repr(Layer(colorblending, 3)) + assert r == ( + "[Closed] Layer(image_view_order=2, color_blending_id=0, " + "layer_id=3)" + ) def test_layer_file_name_property(layer, layer_get_value): @@ -83,8 +111,8 @@ def test_layer_file_name_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileInfo.name") -def test_layer_image_id_property(layer, layer_get_value): - layer.image_id +def test_layer_file_id_property(layer, layer_get_value): + layer.file_id layer_get_value.assert_called_with("frameInfo.fileId") @@ -119,7 +147,7 @@ def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): def test_colorblending_init(session): colorblending = ColorBlending(session, 3) - assert colorblending.store_id == 3 + assert colorblending.color_blending_id == 3 expected = "imageViewConfigStore.colorBlendingImageMap[3]" assert colorblending._base_path == expected assert colorblending._frame == Macro( @@ -127,10 +155,43 @@ def test_colorblending_init(session): ) -def test_colorblending_repr(session, colorblending, cb_property): - cb_property("imageview_id", 3) - cb_property("file_name", "blend.fits") - assert repr(colorblending) == "0:3:blend.fits" +def test_colorblending_repr_healthy(session, colorblending, cb_property, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=2) + cb_property("file_name", "Color Blending 1") + r = repr(colorblending) + assert r == ( + "ColorBlending(image_view_order=2, color_blending_id=0, " + "file_name='Color Blending 1')" + ) + + +def test_colorblending_repr_closed_when_not_in_image_list( + session, colorblending, mocker +): + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + r = repr(colorblending) + assert r == ( + "[Closed] ColorBlending(image_view_order=None, color_blending_id=0)" + ) + + +def test_colorblending_repr_closed_when_backing_entry_is_gone( + session, colorblending, mocker +): + mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch( + "carta.colorblending.ColorBlending.file_name", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("color blending is gone"), + ) + r = repr(colorblending) + assert r == ( + "[Closed] ColorBlending(image_view_order=2, color_blending_id=0)" + ) def test_colorblending_file_name(colorblending, cb_get_value): @@ -138,17 +199,38 @@ def test_colorblending_file_name(colorblending, cb_get_value): cb_get_value.assert_called_with("filename") -def test_colorblending_imageview_id(session, colorblending, session_get_value): - # imageList has 3 entries; the color blending with store_id=0 is at index 2 - session_get_value.side_effect = [ - 3, # imageList.length - ImageType.FRAME, # [0].type — skip - ImageType.COLOR_BLENDING, # [1].type — match type… - 99, # [1].store.id — wrong store_id - ImageType.COLOR_BLENDING, # [2].type — match type… - 0, # [2].store.id — matches store_id=0 +def test_colorblending_image_view_order( + session, colorblending, session_get_value +): + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 99}, + {"type": ImageType.COLOR_BLENDING, "id": 0}, + ] + assert colorblending.image_view_order == 2 + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_colorblending_image_view_order_ignores_non_color_blending( + session, colorblending, session_get_value +): + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 0}, + {"type": ImageType.COLOR_BLENDING, "id": 0}, ] - assert colorblending.imageview_id == 2 + assert colorblending.image_view_order == 1 + + +def test_colorblending_image_view_order_raises_when_missing( + session, colorblending, session_get_value +): + session_get_value.return_value = [ + {"type": ImageType.COLOR_BLENDING, "id": 99}, + ] + with pytest.raises(RuntimeError): + colorblending.image_view_order def test_colorblending_alpha(colorblending, cb_get_value): @@ -163,15 +245,24 @@ def test_colorblending_base_frame(colorblending, cb_get_value): cb_get_value.assert_called_once_with("frames[0].id") assert isinstance(base_frame, Image) assert base_frame.session is colorblending.session - assert base_frame.image_id == 42 + assert base_frame.file_id == 42 + + +def test_colorblending_make_active(session, colorblending, session_call_action): + # make_active must be driven by color_blending_id via setActiveImageById. + # It must not depend on image_view_order (which is volatile). + colorblending.make_active() + session_call_action.assert_called_with( + "setActiveImageById", ImageType.COLOR_BLENDING, 0 + ) -def test_colorblending_make_active( - session, colorblending, cb_property, session_call_action +def test_colorblending_make_active_does_not_read_image_view_order( + session, colorblending, session_call_action, session_get_value ): - cb_property("imageview_id", 9) colorblending.make_active() - session_call_action.assert_called_with("setActiveImageByIndex", 9) + for call in session_get_value.call_args_list: + assert call.args != ("imageViewConfigStore.imageListSummary",) def test_colorblending_layer_list_derived(session, mocker): @@ -218,13 +309,14 @@ def test_colorblending_set_layer( ) -def test_colorblending_set_layer_sequence(session, colorblending, mocker): - # Prepare three existing layers with image_ids 10, 20, 30 - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid +class _L: + def __init__(self, lid, fid): + self.layer_id = lid + self.file_id = fid + +def test_colorblending_set_layer_sequence(session, colorblending, mocker): + # Prepare three existing layers with file_ids 10, 20, 30 mocker.patch.object( ColorBlending, "layer_list", @@ -244,18 +336,13 @@ def __init__(self, lid, iid): # Deletes all non-base layers (twice) then adds layers in specified order assert del_layer.call_count == 2 add_args = [call.args[0] for call in add_layer.call_args_list] - assert [img.image_id for img in add_args] == [30, 20] + assert [img.file_id for img in add_args] == [30, 20] assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( session, colorblending, mocker ): - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid - mocker.patch.object( ColorBlending, "layer_list", @@ -273,18 +360,13 @@ def __init__(self, lid, iid): colorblending.set_layer_sequence([0, 3, 1]) assert del_layer.call_count == 3 - assert [call.args[0].image_id for call in add_layer.call_args_list] == [40, 20] + assert [call.args[0].file_id for call in add_layer.call_args_list] == [40, 20] assert [call.args[1] for call in set_alpha.call_args_list] == [0.4, 0.2] def test_colorblending_set_layer_sequence_rejects_missing_layer_index( session, colorblending, mocker ): - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid - mocker.patch.object( ColorBlending, "layer_list", @@ -301,11 +383,6 @@ def __init__(self, lid, iid): def test_colorblending_set_layer_sequence_requires_base_layer_first( session, colorblending, mocker ): - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid - mocker.patch.object( ColorBlending, "layer_list", @@ -322,11 +399,6 @@ def __init__(self, lid, iid): def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( session, colorblending, mocker ): - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid - mocker.patch.object( ColorBlending, "layer_list", @@ -346,11 +418,6 @@ def __init__(self, lid, iid): def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( session, colorblending, mocker ): - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid - mocker.patch.object( ColorBlending, "layer_list", @@ -484,37 +551,44 @@ def test_colorblending_close(session, colorblending, session_call_action): # CREATION HELPERS -def test_colorblending_from_imageview_id(session, session_get_value): - session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] +def test_colorblending_from_image_view_order(session, session_get_value): + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.FRAME, "id": 20}, + {"type": ImageType.COLOR_BLENDING, "id": 17}, + ] - cb = ColorBlending.from_imageview_id(session, 5) + cb = ColorBlending.from_image_view_order(session, 2) assert isinstance(cb, ColorBlending) - assert cb.store_id == 17 + assert cb.color_blending_id == 17 expected = "imageViewConfigStore.colorBlendingImageMap[17]" assert cb._base_path == expected - assert [call.args for call in session_get_value.call_args_list] == [ - ("imageViewConfigStore.imageList[5].type",), - ("imageViewConfigStore.imageList[5].store.id",), - ] + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) -def test_colorblending_from_imageview_id_rejects_non_color_blending( +def test_colorblending_from_image_view_order_rejects_non_color_blending( session, session_get_value, mocker ): - session_get_value.return_value = ImageType.FRAME - init = mocker.patch.object(ColorBlending, "__init__", return_value=None) + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + ] with pytest.raises( ValueError, - match="imageview_id does not refer to a color blending image.", + match="image_view_order does not refer to a color blending image.", ): - ColorBlending.from_imageview_id(session, 5) + ColorBlending.from_image_view_order(session, 0) - session_get_value.assert_called_once_with( - "imageViewConfigStore.imageList[5].type" - ) - init.assert_not_called() + +def test_colorblending_from_image_view_order_out_of_range( + session, session_get_value +): + session_get_value.return_value = [] + with pytest.raises(IndexError): + ColorBlending.from_image_view_order(session, 0) def test_colorblending_from_images_success(session, mocker): @@ -522,78 +596,46 @@ def test_colorblending_from_images_success(session, mocker): img1 = Image(session, 200) img2 = Image(session, 300) - mocker.patch.object(session, "call_action") - mocker.patch.object(img1, "call_action", return_value=True) - mocker.patch.object(img2, "call_action", return_value=True) - layer_list = mocker.patch.object( - ColorBlending, - "layer_list", - autospec=True, - return_value=[object(), object(), object(), object()], - ) - delete_layer = mocker.patch.object( - ColorBlending, "delete_layer", autospec=True - ) - add_layer = mocker.patch.object(ColorBlending, "add_layer", autospec=True) - - session.call_action.side_effect = [None, 123] + call_action = mocker.patch.object(session, "call_action", return_value={"id": 123}) cb = ColorBlending.from_images(session, [img0, img1, img2]) assert isinstance(cb, ColorBlending) - assert cb.store_id == 123 + assert cb.color_blending_id == 123 assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" - session.call_action.assert_any_call( - "setSpatialReference", img0._frame, False - ) - img1.call_action.assert_called_with("setSpatialReference", img0._frame) - img2.call_action.assert_called_with("setSpatialReference", img0._frame) - session.call_action.assert_called_with( - "imageViewConfigStore.createColorBlending", return_path="id" - ) - layer_list.assert_called_once_with(cb) - delete_layer.assert_has_calls( - [mocker.call(cb, 1), mocker.call(cb, 1), mocker.call(cb, 1)] + + call_action.assert_called_once_with( + "imageViewConfigStore.createColorBlendingFromFrames", + [img0._frame, img1._frame, img2._frame], ) - add_layer.assert_has_calls([mocker.call(cb, img1), mocker.call(cb, img2)]) -def test_colorblending_from_images_alignment_failure( - session, mocker, mock_property +def test_colorblending_from_images_null_return_raises_action_failed( + session, mocker ): img0 = Image(session, 100) - img1 = Image(session, 200) + mocker.patch.object(session, "call_action", return_value=None) - mocker.patch.object(session, "call_action") - mock_property("carta.image.Image")("file_name", "bad.fits") - mocker.patch.object(img1, "call_action", return_value=False) + with pytest.raises(CartaActionFailed): + ColorBlending.from_images(session, [img0]) - with pytest.raises(CartaActionFailed) as e: - ColorBlending.from_images(session, [img0, img1]) - assert "Failed to set spatial reference for image bad.fits." in str( - e.value - ) +def test_colorblending_from_images_rejects_empty_list(session, mocker): + call_action = mocker.patch.object(session, "call_action") -def test_colorblending_from_images_rejects_more_than_initial_layer_limit( - session, mocker -): - images = [ - Image(session, image_id) - for image_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) - ] - session_call_action = mocker.patch.object(session, "call_action") + with pytest.raises(CartaValidationFailed): + ColorBlending.from_images(session, []) - with pytest.raises( - ValueError, - match=( - "Color blending initialization supports at most 10 images " - r"\(the base layer plus 9 matched images\)." - ), - ): - ColorBlending.from_images(session, images) + call_action.assert_not_called() + + +def test_colorblending_from_images_rejects_non_image_element(session, mocker): + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed): + ColorBlending.from_images(session, ["not-an-image"]) - session_call_action.assert_not_called() + call_action.assert_not_called() def test_colorblending_from_files(session, mocker): @@ -607,26 +649,5 @@ def test_colorblending_from_files(session, mocker): ) out = ColorBlending.from_files(session, ["a.fits", "b.fits"], append=True) mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) - mock_from_images.assert_called() + mock_from_images.assert_called_once() assert out == "CB" - - -def test_colorblending_from_files_rejects_more_than_initial_layer_limit( - session, mocker -): - files = [ - f"image-{file_id}.fits" - for file_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) - ] - mock_open_images = mocker.patch.object(session, "open_images") - - with pytest.raises( - ValueError, - match=( - "Color blending initialization supports at most 10 images " - r"\(the base layer plus 9 matched images\)." - ), - ): - ColorBlending.from_files(session, files) - - mock_open_images.assert_not_called() diff --git a/tests/test_image.py b/tests/test_image.py index e8bf392..589e71b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,8 +1,8 @@ import pytest -from carta.image import Image -from carta.util import CartaValidationFailed, Point as Pt -from carta.constants import NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU +from carta.image import Image, ImageBase +from carta.util import CartaActionFailed, CartaValidationFailed, Point as Pt +from carta.constants import ImageType, NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU # FIXTURES @@ -77,7 +77,7 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte assert type(image_object) is Image assert image_object.session == session - assert image_object.image_id == 123 + assert image_object.file_id == 123 # SUBOBJECTS @@ -110,7 +110,129 @@ def test_simple_properties(image, property_name, expected_path, get_value): def test_make_active(image, session_call_action): image.make_active() - session_call_action.assert_called_with("setActiveImageByFileId", 0) + session_call_action.assert_called_with( + "setActiveImageById", ImageType.FRAME, 0 + ) + + +def test_image_base_image_view_order_not_implemented(session): + base = ImageBase(session) + with pytest.raises(NotImplementedError): + base.image_view_order + + +def test_image_base_stable_id_not_implemented(session): + base = ImageBase(session) + with pytest.raises(NotImplementedError): + base._stable_id + + +def test_image_base_make_active_uses_subclass_ids(session, session_call_action): + # Verify the shared ImageBase.make_active dispatches setActiveImageById + # with the subclass's _image_type and _stable_id exactly once. + class Dummy(ImageBase): + _image_type = ImageType.FRAME + + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + Dummy(session, 42).make_active() + session_call_action.assert_called_once_with( + "setActiveImageById", ImageType.FRAME, 42 + ) + + +def test_image_view_order_uses_summary_once(session, mocker, image): + find = mocker.patch.object( + session, "_find_image_view_order", return_value=3 + ) + # Frame with file_id=0 at viewer order 3. + assert image.image_view_order == 3 + find.assert_called_once_with(ImageType.FRAME, 0) + + +def test_image_view_order_ignores_non_frame_entries(session, mocker): + get_value = mocker.patch.object( + session, + "get_value", + return_value=[ + {"type": ImageType.COLOR_BLENDING, "id": 0}, + {"type": ImageType.FRAME, "id": 7}, + {"type": ImageType.FRAME, "id": 3}, + ], + ) + img = Image(session, 3) + assert img.image_view_order == 2 + get_value.assert_called_once_with("imageViewConfigStore.imageListSummary") + + +def test_image_view_order_raises_when_missing(session, mocker): + mocker.patch.object( + session, + "get_value", + return_value=[{"type": ImageType.FRAME, "id": 99}], + ) + img = Image(session, 3) + with pytest.raises(RuntimeError): + img.image_view_order + + +def test_image_repr_cached_name_resolves_only_image_view_order(session, image, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=3) + get_value = mocker.patch.object(image, "get_value") + image._cache = {"file_name": "cube.fits"} + r = repr(image) + assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + get_value.assert_not_called() + + +def test_image_repr_resolves_image_view_order_and_file_name(session, image, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=3) + mocker.patch.object(image, "get_value", return_value="cube.fits") + r = repr(image) + assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + + +def test_image_repr_closed_when_image_view_order_missing(session, image, mocker): + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + r = repr(image) + assert r == "[Closed] Image(image_view_order=None, file_id=0)" + + +def test_image_repr_closed_shows_cached_file_name(session, image, mocker): + # When the image-view-order lookup fails but file_name was previously + # cached, the closed repr still surfaces the cached name without + # triggering any fresh round-trip. + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + get_value = mocker.patch.object(image, "get_value") + image._cache = {"file_name": "cube.fits"} + r = repr(image) + assert r == "[Closed] Image(image_view_order=None, file_name='cube.fits', file_id=0)" + get_value.assert_not_called() + + +def test_image_repr_closed_when_frame_is_gone(session, image, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=3) + mocker.patch.object( + image, + "get_value", + side_effect=CartaActionFailed("frameMap entry is missing"), + ) + r = repr(image) + assert r == "[Closed] Image(image_view_order=3, file_id=0)" @pytest.mark.parametrize("channel", [0, 10, 19]) diff --git a/tests/test_session.py b/tests/test_session.py index 3fb07db..ff016f9 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,7 +3,7 @@ from carta.image import Image from carta.colorblending import ColorBlending from carta.util import Macro -from carta.constants import ComplexComponent as CC, Polarization as Pol +from carta.constants import ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -71,31 +71,170 @@ def test_cd(session, method, call_action): call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") -def test_color_blending_list(session, get_value): - get_value.side_effect = [2, 3, 8] +# IMAGE LIST / GET_IMAGE / COLOR-BLENDING HELPERS - color_blendings = session.color_blending_list() - assert len(color_blendings) == 2 - assert all(isinstance(cb, ColorBlending) for cb in color_blendings) - get_value.assert_any_call("imageViewConfigStore.colorBlendingImages.length") - get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[0].id") - get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") - assert [cb.session for cb in color_blendings] == [session, session] - assert [cb.store_id for cb in color_blendings] == [3, 8] - assert [cb._base_path for cb in color_blendings] == [ - "imageViewConfigStore.colorBlendingImageMap[3]", - "imageViewConfigStore.colorBlendingImageMap[8]", +def test_image_list_heterogeneous(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.FRAME, "id": 20}, ] + images = session.image_list() + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + assert len(images) == 3 + assert isinstance(images[0], Image) and images[0].file_id == 10 + assert isinstance(images[1], ColorBlending) and images[1].color_blending_id == 3 + assert isinstance(images[2], Image) and images[2].file_id == 20 + + +def test_image_list_raises_on_pv_preview(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.PV_PREVIEW, "id": -2}, + ] + with pytest.raises(NotImplementedError): + session.image_list() -def test_color_blending_list_empty(session, get_value): - get_value.return_value = 0 - assert session.color_blending_list() == [] +def test_image_list_empty(session, get_value): + get_value.return_value = [] + assert session.image_list() == [] get_value.assert_called_once_with( - "imageViewConfigStore.colorBlendingImages.length" + "imageViewConfigStore.imageListSummary" + ) + + +def test_find_image_view_order_single_round_trip(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 7}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + {"type": ImageType.FRAME, "id": 3}, + ] + + assert session._find_image_view_order(ImageType.FRAME, 3) == 2 + assert session._find_image_view_order(ImageType.COLOR_BLENDING, 7) == 1 + assert get_value.call_count == 2 + for call in get_value.call_args_list: + assert call.args == ("imageViewConfigStore.imageListSummary",) + + +def test_find_image_view_order_raises_when_missing(session, get_value): + get_value.return_value = [{"type": ImageType.FRAME, "id": 1}] + with pytest.raises(RuntimeError): + session._find_image_view_order(ImageType.FRAME, 99) + + +# session.get_image + + +@pytest.fixture +def summary(get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + {"type": ImageType.FRAME, "id": 20}, + ] + return get_value + + +def test_get_image_requires_exactly_one_keyword(session, get_value): + # Zero keywords -> ValueError with all three names listed. + with pytest.raises(ValueError) as e: + session.get_image() + for name in ("image_view_order", "file_id", "color_blending_id"): + assert name in str(e.value) + # Multiple keywords -> ValueError. + with pytest.raises(ValueError): + session.get_image(file_id=1, color_blending_id=2) + + +def test_get_image_rejects_positional(session): + with pytest.raises(TypeError): + session.get_image(0) + + +def test_get_image_by_image_view_order(session, summary): + img = session.get_image(image_view_order=0) + assert isinstance(img, Image) + assert img.file_id == 10 + + cb = session.get_image(image_view_order=1) + assert isinstance(cb, ColorBlending) + assert cb.color_blending_id == 7 + + img2 = session.get_image(image_view_order=2) + assert isinstance(img2, Image) + assert img2.file_id == 20 + + +def test_get_image_by_image_view_order_out_of_range(session, summary): + with pytest.raises(IndexError): + session.get_image(image_view_order=99) + + +def test_get_image_by_file_id(session, summary): + img = session.get_image(file_id=20) + assert isinstance(img, Image) + assert img.file_id == 20 + + +def test_get_image_by_file_id_no_cross_type_fallback(session, summary): + # The summary contains a COLOR_BLENDING entry with id=7, but no FRAME + # with that id, so get_image(file_id=7) must raise. + with pytest.raises(RuntimeError): + session.get_image(file_id=7) + + +def test_get_image_by_color_blending_id(session, summary): + cb = session.get_image(color_blending_id=7) + assert isinstance(cb, ColorBlending) + assert cb.color_blending_id == 7 + + +def test_get_image_by_color_blending_id_no_cross_type_fallback(session, summary): + # The summary contains a FRAME with id=10, but no COLOR_BLENDING with + # that id, so get_image(color_blending_id=10) must raise. + with pytest.raises(RuntimeError): + session.get_image(color_blending_id=10) + + +def test_get_image_single_round_trip(session, summary): + session.get_image(image_view_order=0) + session.get_image(file_id=10) + session.get_image(color_blending_id=7) + assert summary.call_count == 3 + for call in summary.call_args_list: + assert call.args == ("imageViewConfigStore.imageListSummary",) + + +# open_as_color_blending / create_color_blending + + +def test_open_as_color_blending_delegates_to_from_files(session, mocker): + mock_from_files = mocker.patch.object( + ColorBlending, "from_files", return_value="CB" + ) + result = session.open_as_color_blending(["a.fits", "b.fits"], append=True) + mock_from_files.assert_called_once_with( + session, ["a.fits", "b.fits"], append=True + ) + assert result == "CB" + + +def test_create_color_blending_delegates_to_from_images(session, mocker): + img0 = Image(session, 100) + img1 = Image(session, 200) + mock_from_images = mocker.patch.object( + ColorBlending, "from_images", return_value="CB" ) + result = session.create_color_blending([img0, img1]) + mock_from_images.assert_called_once_with(session, [img0, img1]) + assert result == "CB" # OPENING IMAGES @@ -206,7 +345,7 @@ def test_open_hypercube_guess_polarization(mocker, session, call_action, method, assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.image_id == 123 + assert hypercube.file_id == 123 @pytest.mark.parametrize("paths,expected_calls,mocked_side_effect,expected_error", [ @@ -260,7 +399,7 @@ def test_open_hypercube_explicit_polarization(mocker, session, call_action, meth assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.image_id == 123 + assert hypercube.file_id == 123 @pytest.mark.parametrize("paths,expected_error", [ From d424e901dc5ff37bf1691854db5d2a489a472970 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 19:30:05 +0800 Subject: [PATCH 042/123] Update terminology from "file-based" to "frame-backed" in image docstring and error messages --- carta/image.py | 4 ++-- carta/session.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/image.py b/carta/image.py index 98f7ed3..4945eb0 100644 --- a/carta/image.py +++ b/carta/image.py @@ -17,7 +17,7 @@ class ImageBase: - """Base class for image-view items (file-based images and color blendings). + """Base class for image-view items (frame-backed images and color blendings). This class is not intended to be instantiated directly. @@ -50,7 +50,7 @@ def make_active(self): class Image(ImageBase, BasePathMixin): - """This object corresponds to a file-based image open in a CARTA frontend session. + """This object corresponds to a frame-backed image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. diff --git a/carta/session.py b/carta/session.py index 312b060..5a8c17e 100644 --- a/carta/session.py +++ b/carta/session.py @@ -640,7 +640,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No if entry["type"] == ImageType.FRAME and entry["id"] == file_id: return Image(self, file_id) raise RuntimeError( - f"No file-based image with file_id={file_id} is open." + f"No frame-backed image with file_id={file_id} is open." ) # color_blending_id is not None From 5c9e334a65d35a4136bcf174b99709edc3e335bc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 20:01:13 +0800 Subject: [PATCH 043/123] Set default colormap set based on number of files for open_as_color_blending --- carta/session.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index 5a8c17e..86f18b0 100644 --- a/carta/session.py +++ b/carta/session.py @@ -11,7 +11,7 @@ from .image import Image from .colorblending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt @@ -670,7 +670,12 @@ def open_as_color_blending(self, files, append=False): :obj:`carta.colorblending.ColorBlending` The new color blending object. """ - return ColorBlending.from_files(self, files, append=append) + cb = ColorBlending.from_files(self, files, append=append) + if len(files) <= 3: + cb.set_colormap_set(ColormapSet.RGB) + else: + cb.set_colormap_set(ColormapSet.RAINBOW) + return cb def create_color_blending(self, images): """Combine already-open images into a new color blending image. From ac58b5f9fa61863758c7d77a34e472ca10014044 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 20:51:46 +0800 Subject: [PATCH 044/123] Extract ImageBase class into separate module --- carta/colorblending.py | 3 ++- carta/image.py | 34 +--------------------------------- carta/image_base.py | 40 ++++++++++++++++++++++++++++++++++++++++ carta/session.py | 4 ++-- docs/source/carta.rst | 8 ++++++++ 5 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 carta/image_base.py diff --git a/carta/colorblending.py b/carta/colorblending.py index a395e46..f5d233f 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,7 +1,8 @@ """This module contains functionality for interacting with color blending images and their layers.""" from .constants import Colormap, ColormapSet, ImageType -from .image import Image, ImageBase +from .image import Image +from .image_base import ImageBase from .util import BasePathMixin, CartaActionFailed, CartaScriptingException, Macro from .validation import ( Any, diff --git a/carta/image.py b/carta/image.py index 4945eb0..4bbca67 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,6 +5,7 @@ from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit +from .image_base import ImageBase from .util import Macro, cached, BasePathMixin, CartaScriptingException, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point @@ -16,39 +17,6 @@ from .region import RegionSet -class ImageBase: - """Base class for image-view items (frame-backed images and color blendings). - - This class is not intended to be instantiated directly. - - Attributes - ---------- - session : :obj:`carta.session.Session` - The session object associated with this image-view item. - """ - - _image_type: ImageType = None - - def __init__(self, session): - self.session = session - - @property - def _stable_id(self): - """The stable identifier of this image-view item.""" - raise NotImplementedError - - @property - def image_view_order(self): - """The index of this item in image list.""" - raise NotImplementedError - - def make_active(self): - """Make this the active image-view item.""" - self.session.call_action( - "setActiveImageById", self._image_type, self._stable_id - ) - - class Image(ImageBase, BasePathMixin): """This object corresponds to a frame-backed image open in a CARTA frontend session. diff --git a/carta/image_base.py b/carta/image_base.py new file mode 100644 index 0000000..bd0e8bf --- /dev/null +++ b/carta/image_base.py @@ -0,0 +1,40 @@ +"""This module contains the shared base class for image-view items (frame-backed images and color blendings). + +The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.colorblending.ColorBlending` can share a common protocol without one having to import the other. +""" + + +from .constants import ImageType + + +class ImageBase: + """Base class for image-view items (frame-backed images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this image-view item. + """ + + _image_type: ImageType = None + + def __init__(self, session): + self.session = session + + @property + def _stable_id(self): + """The stable identifier of this image-view item.""" + raise NotImplementedError + + @property + def image_view_order(self): + """The index of this item in image list.""" + raise NotImplementedError + + def make_active(self): + """Make this the active image-view item.""" + self.session.call_action( + "setActiveImageById", self._image_type, self._stable_id + ) diff --git a/carta/session.py b/carta/session.py index 86f18b0..1be401b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -516,7 +516,7 @@ def image_list(self): Returns ------- - list of :obj:`carta.image.ImageBase` + list of :obj:`carta.image_base.ImageBase` The heterogeneous list of image-view items open in this session. """ summary = self.get_value("imageViewConfigStore.imageListSummary") @@ -585,7 +585,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No Returns ------- - :obj:`carta.image.ImageBase` + :obj:`carta.image_base.ImageBase` The matching image-view item. Raises diff --git a/docs/source/carta.rst b/docs/source/carta.rst index ed393e5..f1e13f5 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -49,6 +49,14 @@ carta.image module :undoc-members: :show-inheritance: +carta.image\_base module +------------------------ + +.. automodule:: carta.image_base + :members: + :undoc-members: + :show-inheritance: + carta.metadata module --------------------- From 7be1e6533591563391d6bd3f58d1801a878cf24e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 20:54:50 +0800 Subject: [PATCH 045/123] Add parametrized test for default colormap set selection in open_as_color_blending --- tests/test_session.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_session.py b/tests/test_session.py index ff016f9..3baaf05 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,7 +3,7 @@ from carta.image import Image from carta.colorblending import ColorBlending from carta.util import Macro -from carta.constants import ComplexComponent as CC, ImageType, Polarization as Pol +from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -215,15 +215,23 @@ def test_get_image_single_round_trip(session, summary): # open_as_color_blending / create_color_blending -def test_open_as_color_blending_delegates_to_from_files(session, mocker): +@pytest.mark.parametrize("files,expected_colormap_set", [ + # <= 3 files -> RGB + (["a.fits"], ColormapSet.RGB), + (["a.fits", "b.fits"], ColormapSet.RGB), + (["a.fits", "b.fits", "c.fits"], ColormapSet.RGB), + # > 3 files -> RAINBOW + (["a.fits", "b.fits", "c.fits", "d.fits"], ColormapSet.RAINBOW), +]) +def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, expected_colormap_set): + fake_cb = mocker.MagicMock(name="ColorBlending") mock_from_files = mocker.patch.object( - ColorBlending, "from_files", return_value="CB" - ) - result = session.open_as_color_blending(["a.fits", "b.fits"], append=True) - mock_from_files.assert_called_once_with( - session, ["a.fits", "b.fits"], append=True + ColorBlending, "from_files", return_value=fake_cb ) - assert result == "CB" + result = session.open_as_color_blending(files, append=True) + mock_from_files.assert_called_once_with(session, files, append=True) + fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) + assert result is fake_cb def test_create_color_blending_delegates_to_from_images(session, mocker): From 4a06836067841244c07bcd42bd7a848109ddc1e3 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 21:01:31 +0800 Subject: [PATCH 046/123] Reorganize ColorBlending methods with section comments for better code structure --- carta/colorblending.py | 82 ++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index f5d233f..d27a03a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -169,6 +169,8 @@ def __init__(self, session, color_blending_id): def _stable_id(self): return self.color_blending_id + # FACTORIES + @classmethod def from_image_view_order(cls, session, image_view_order): """Create a color blending object from an image view order. @@ -272,6 +274,24 @@ def from_files(cls, session, files, append=False): images = session.open_images(files, append=append) return cls.from_images(session, images) + @property + def image_view_order(self): + """The current index of this color blending in image list. + + Returns + ------- + integer + The image view order. + + Raises + ------ + RuntimeError + If no matching color blending entry exists in the image list. + """ + return self.session._find_image_view_order( + ImageType.COLOR_BLENDING, self.color_blending_id + ) + def __repr__(self): """A human-readable representation of this color blending object.""" cls = type(self).__name__ @@ -298,6 +318,8 @@ def __repr__(self): f"file_name={name!r})" ) + # METADATA + @property def _base_frame(self): return Image(self.session, self.get_value("frames[0].id")) @@ -313,23 +335,7 @@ def file_name(self): """ return self.get_value("filename") - @property - def image_view_order(self): - """The current index of this color blending in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching color blending entry exists in the image list. - """ - return self.session._find_image_view_order( - ImageType.COLOR_BLENDING, self.color_blending_id - ) + # LAYERS @property def alpha(self): @@ -342,6 +348,24 @@ def alpha(self): """ return self.get_value("alpha") + @validate(IterableOf(Number(0, 1))) + def set_alpha(self, alpha_list): + """Set the alpha value for the color blending layers. + + Parameters + ---------- + alpha_list : {0} + The alpha values. + """ + layer_list = self.layer_list() + if len(alpha_list) != len(layer_list): + raise ValueError( + f"alpha_list length ({len(alpha_list)}) does not match " + f"the number of layers ({len(layer_list)})." + ) + for alpha, layer in zip(alpha_list, layer_list): + layer.set_alpha(alpha) + def layer_list(self): """ Returns a list of Layer objects, each representing a layer in @@ -456,6 +480,8 @@ def set_layer_sequence(self, layer_indices): self.add_layer(image) Layer(self, target_layer_index).set_alpha(alpha) + # NAVIGATION + @validate(Coordinate(), Coordinate()) def set_center(self, x, y): """Set the center position, in image or world coordinates. @@ -503,6 +529,8 @@ def set_zoom_level(self, zoom, absolute=True): """ self._base_frame.set_zoom_level(zoom, absolute) + # RENDERING + @validate(Constant(ColormapSet)) def set_colormap_set(self, colormap_set): """Set the colormap set for the color blending. @@ -514,23 +542,7 @@ def set_colormap_set(self, colormap_set): """ self.call_action("applyColormapSet", colormap_set) - @validate(IterableOf(Number(0, 1))) - def set_alpha(self, alpha_list): - """Set the alpha value for the color blending layers. - - Parameters - ---------- - alpha_list : {0} - The alpha values. - """ - layer_list = self.layer_list() - if len(alpha_list) != len(layer_list): - raise ValueError( - f"alpha_list length ({len(alpha_list)}) does not match " - f"the number of layers ({len(layer_list)})." - ) - for alpha, layer in zip(alpha_list, layer_list): - layer.set_alpha(alpha) + # VISIBILITY @validate(Boolean()) def set_raster_visible(self, state): @@ -571,6 +583,8 @@ def set_vector_overlay_visible(self, state): if is_visible != state: self.call_action("toggleVectorOverlayVisible") + # CLOSE + def close(self): """Close this color blending object.""" self.session.call_action( From ec1501adc484edd8eaad155d7f151c614f1d7c48 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 21:14:47 +0800 Subject: [PATCH 047/123] Add image_view_order property to Layer class and update __repr__ to use it --- carta/colorblending.py | 25 +++++++++++++++++++- tests/test_colorblending.py | 47 ++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index d27a03a..0647f2a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -63,13 +63,36 @@ def from_list(cls, colorblending, layer_ids): """ return [cls(colorblending, layer_id) for layer_id in layer_ids] + @property + def image_view_order(self): + """The image-view order of this layer's underlying frame. + + This is the position of the underlying frame in the session's image + list. A layer does not occupy its own position in the image list; + its parent color blending does (see + :obj:`carta.colorblending.ColorBlending.image_view_order`). + + Returns + ------- + integer + The image-view order of the underlying frame. + + Raises + ------ + RuntimeError + If no matching frame entry exists in the image list. + """ + return self.session._find_image_view_order( + ImageType.FRAME, self.file_id + ) + def __repr__(self): """A human-readable representation of this layer.""" cls = type(self).__name__ cb_id = self.colorblending.color_blending_id try: - order = self.colorblending.image_view_order + order = self.image_view_order except (CartaScriptingException, RuntimeError): return ( f"[Closed] {cls}(image_view_order=None, " diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 3c569e0..5e01400 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -70,16 +70,21 @@ def test_layer_from_list(colorblending): def test_layer_repr_healthy(session, colorblending, layer_property, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=2) + find = mocker.patch.object(session, "_find_image_view_order", return_value=2) + layer_property("file_id", 42) layer_property("file_name", "layer1.fits") r = repr(Layer(colorblending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " "file_name='layer1.fits')" ) + find.assert_called_once_with(ImageType.FRAME, 42) -def test_layer_repr_closed_when_parent_missing(session, colorblending, mocker): +def test_layer_repr_closed_when_frame_not_in_image_list( + session, colorblending, layer_property, mocker +): + layer_property("file_id", 42) mocker.patch.object( session, "_find_image_view_order", @@ -93,11 +98,27 @@ def test_layer_repr_closed_when_parent_missing(session, colorblending, mocker): def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): + mocker.patch( + "carta.colorblending.Layer.file_id", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("frame is gone"), + ) + r = repr(Layer(colorblending, 3)) + assert r == ( + "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_repr_closed_when_file_name_read_fails( + session, colorblending, layer_property, mocker +): mocker.patch.object(session, "_find_image_view_order", return_value=2) + layer_property("file_id", 42) mocker.patch( "carta.colorblending.Layer.file_name", new_callable=mocker.PropertyMock, - side_effect=CartaActionFailed("frame is gone"), + side_effect=CartaActionFailed("file_name read failed"), ) r = repr(Layer(colorblending, 3)) assert r == ( @@ -116,6 +137,26 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") +def test_layer_image_view_order(session, colorblending, layer_property, mocker): + find = mocker.patch.object(session, "_find_image_view_order", return_value=7) + layer_property("file_id", 42) + assert Layer(colorblending, 3).image_view_order == 7 + find.assert_called_once_with(ImageType.FRAME, 42) + + +def test_layer_image_view_order_raises_when_frame_not_in_image_list( + session, colorblending, layer_property, mocker +): + layer_property("file_id", 42) + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + with pytest.raises(RuntimeError): + Layer(colorblending, 3).image_view_order + + @pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) def test_layer_set_alpha_valid(colorblending, alpha, cb_call_action): Layer(colorblending, 2).set_alpha(alpha) From 68f6b271adbd33e6894663fc7b0e4fe792f1dd36 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 22:19:09 +0800 Subject: [PATCH 048/123] Set default colormap set based on number of images for create_color_blending --- carta/session.py | 7 ++++++- tests/test_session.py | 23 ++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/carta/session.py b/carta/session.py index 1be401b..3b7c64b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -690,7 +690,12 @@ def create_color_blending(self, images): :obj:`carta.colorblending.ColorBlending` The new color blending object. """ - return ColorBlending.from_images(self, images) + cb = ColorBlending.from_images(self, images) + if len(images) <= 3: + cb.set_colormap_set(ColormapSet.RGB) + else: + cb.set_colormap_set(ColormapSet.RAINBOW) + return cb def active_frame(self): """Return the currently active image. diff --git a/tests/test_session.py b/tests/test_session.py index 3baaf05..99b759e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -234,15 +234,24 @@ def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, assert result is fake_cb -def test_create_color_blending_delegates_to_from_images(session, mocker): - img0 = Image(session, 100) - img1 = Image(session, 200) +@pytest.mark.parametrize("image_count,expected_colormap_set", [ + # <= 3 images -> RGB + (1, ColormapSet.RGB), + (2, ColormapSet.RGB), + (3, ColormapSet.RGB), + # > 3 images -> RAINBOW + (4, ColormapSet.RAINBOW), +]) +def test_create_color_blending_delegates_to_from_images(session, mocker, image_count, expected_colormap_set): + images = [Image(session, 100 + i) for i in range(image_count)] + fake_cb = mocker.MagicMock(name="ColorBlending") mock_from_images = mocker.patch.object( - ColorBlending, "from_images", return_value="CB" + ColorBlending, "from_images", return_value=fake_cb ) - result = session.create_color_blending([img0, img1]) - mock_from_images.assert_called_once_with(session, [img0, img1]) - assert result == "CB" + result = session.create_color_blending(images) + mock_from_images.assert_called_once_with(session, images) + fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) + assert result is fake_cb # OPENING IMAGES From 57bdb556d4fa0dd5e6cc2abef4f5131ed8d7e6ae Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 22:33:26 +0800 Subject: [PATCH 049/123] Add documentation for image list inspection and update color blending workflow examples --- docs/source/images/image_list.jpg | Bin 0 -> 72264 bytes docs/source/quickstart.rst | 98 +++++++++++++++++------------- 2 files changed, 55 insertions(+), 43 deletions(-) create mode 100644 docs/source/images/image_list.jpg diff --git a/docs/source/images/image_list.jpg b/docs/source/images/image_list.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4a12a2bcea92d7e90a96ad0b5a27fd1a7569024d GIT binary patch literal 72264 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 literal 0 HcmV?d00001 diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 52d5eb3..1cf50e8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -178,6 +178,43 @@ Helper methods on the session object open images in the frontend and return imag img0 = session.open_image("data/hdf5/first_file.hdf5") img1 = session.open_image("data/fits/second_file.fits", append=True) img2 = session.open_image("data/fits/third_file.fits", append=True) + + # Open multiple images in one call + img0, img1, img2 = session.open_images([ + "data/hdf5/first_file.hdf5", + "data/fits/second_file.fits", + "data/fits/third_file.fits", + ]) + +Inspecting the list of open images +---------------------------------- + +The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.colorblending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. + +.. figure:: images/image_list.jpg + :alt: CARTA frontend image list panel showing frame-backed images and a color blending entry. + :align: center + + The frontend image list panel. Each row corresponds to an item returned by :obj:`carta.session.Session.image_list`, and its position in the list is the item's ``image_view_order``. + +.. code-block:: python + + from carta.image import Image + from carta.colorblending import ColorBlending + + # All open image-view items, in display order + items = session.image_list() + + # Filter by type if needed + images = [i for i in items if isinstance(i, Image)] + color_blendings = [i for i in items if isinstance(i, ColorBlending)] + + # Every image-view item exposes its current image-view order + print(img0.image_view_order) + + # Retrieve a specific item by image view order + img = session.get_image(image_view_order=0) + cb = session.get_image(image_view_order=1) Changing image properties ------------------------- @@ -234,55 +271,26 @@ Properties which affect the whole session can be set through the session object: Making color blended image -------------------------- -Create a color blending object from a list of files: +The session object provides two convenience methods which create a color blending image and apply a sensible default colormap set (``RGB`` for three layers or fewer, ``RAINBOW`` for more): .. code-block:: python - from carta.colorblending import ColorBlending - from carta.constants import Colormap, ColormapSet - - # Make a color blending object - # Warning: setting `append=False` will close any existing images - # Note: The base layer (index = 0) cannot be deleted or moved. files = [ "data/hdf5/first_file.hdf5", "data/fits/second_file.fits", "data/fits/third_file.fits", ] - cb = ColorBlending.from_files(session, files, append=False) - -Create a color blending object from a list of images: -.. code-block:: python - - from carta.colorblending import ColorBlending - from carta.constants import Colormap, ColormapSet + # Open the files and combine them into a new color blending image + # Warning: setting `append=False` will close any existing images + cb = session.open_as_color_blending(files, append=False) - # Make a color blending object - # Warning: This will break the current spatial matching and + # Combine already-open images into a new color blending image + # Warning: this will break the current spatial matching and # use the first image as the spatial reference - # Note: The base layer (index = 0) cannot be deleted or moved. - cb = ColorBlending.from_images(session, [img0, img1, img2]) + cb = session.create_color_blending([img0, img1, img2]) -To work with color blending images that are already open in a session, use -the session helper: - -.. code-block:: python - - # Get all open color blending objects in this session - color_blendings = session.color_blending_list() - cb = color_blendings[0] - - # Or get a color blending object by its image view index - cb = ColorBlending.from_imageview_id(session, 3) - -.. note:: - The ``ColorBlending`` constructor takes the internal color blending store ID, - not the image view index. Use ``ColorBlending.from_files``, - ``ColorBlending.from_images``, ``ColorBlending.from_imageview_id`` or - ``session.color_blending_list`` in scripts. - -Manipulate properties of the color blending object and the underlying images: +Manipulate properties of the color blending object and the underlying layers: .. code-block:: python @@ -327,7 +335,10 @@ Manipulate properties of the color blending object and the underlying images: # Set zoom level cb.set_zoom_level(2) - # Set the color blending object as the active frame + # Get the current image-view order of the color blending image + print(cb.image_view_order) + + # Set the color blending object as the active image-view item cb.make_active() # Set contour visibility @@ -338,8 +349,9 @@ Manipulate properties of the color blending object and the underlying images: cb.close() .. note:: - If you need to change the layer order involving the base layer (index = 0), - close the current color blending object and create a new one. + The base layer (index = 0) cannot be deleted or moved. If you need to + change the layer order involving the base layer, close the current color + blending object and create a new one. Saving or displaying an image ----------------------------- @@ -369,9 +381,9 @@ Closing images .. code-block:: python - # Close all images open in the session - for img in session.image_list(): - img.close() + # Close all image-view items open in the session + for item in session.image_list(): + item.close() Closing the session ------------------- From e7a8370dcbc879632828eb83e8b610e4d065c052 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 21 Apr 2026 21:37:45 +0800 Subject: [PATCH 050/123] Add validation to prevent rebasing existing color blendings when creating new ones --- carta/colorblending.py | 27 ++++++++++------ carta/session.py | 58 +++++++++++++++++++++++++++++++--- docs/source/quickstart.rst | 21 ++++++++++--- tests/test_colorblending.py | 26 +++++++++++++-- tests/test_session.py | 63 +++++++++++++++++++++++++++++++++++-- 5 files changed, 172 insertions(+), 23 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 0647f2a..b652d1e 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -235,9 +235,13 @@ def from_image_view_order(cls, session, image_view_order): def from_images(cls, session, images): """Create a color blending object from a list of images. - Side effect: this overwrites the session-wide spatial reference - to ``images[0]`` and spatially matches each of ``images[1:]`` to - it. + If color blending images are already open, ``images[0]`` must + already be the current spatial reference. Rebasing existing + color blendings is rejected. + + Side effect: on success, this ensures that ``images[0]`` is the + current spatial reference and that each of ``images[1:]`` is + spatially matched to it. Parameters ---------- @@ -257,11 +261,15 @@ def from_images(cls, session, images): CartaValidationFailed If ``images`` is empty or contains a non-:obj:`carta.image.Image` value. + ValueError + If color blendings are already open and ``images[0]`` is not the + current spatial reference. CartaActionFailed If the atomic frontend action fails. In practice this happens when the input contains a stale/closed frame or exceeds the frontend's layer-count limit. """ + session._validate_color_blending_base(images[0].file_id) result = session.call_action( "imageViewConfigStore.createColorBlendingFromFrames", [image._frame for image in images], @@ -275,26 +283,27 @@ def from_images(cls, session, images): return cls(session, result["id"]) @classmethod - def from_files(cls, session, files, append=False): + def from_files(cls, session, files): """Create a color blending object from a list of files. + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``, + because the frontend does not support creating a color blending + in append mode. + Parameters ---------- session : :obj:`carta.session.Session` The session object. files : list of string The files to be blended. - append : bool - Whether the images should be appended to existing images. - By default this is ``False`` and any existing open images - are closed. Returns ------- :obj:`carta.colorblending.ColorBlending` A new color blending object. """ - images = session.open_images(files, append=append) + images = session.open_images(files, append=False) return cls.from_images(session, images) @property diff --git a/carta/session.py b/carta/session.py index 3b7c64b..e5395cc 100644 --- a/carta/session.py +++ b/carta/session.py @@ -566,6 +566,38 @@ def _find_image_view_order(self, image_type, stable_id): f"{stable_id} in the image list." ) + def _validate_color_blending_base(self, base_file_id): + """Reject color-blending creation that would rebase existing blendings. + + Parameters + ---------- + base_file_id : integer + The file id of the requested base image. + + Raises + ------ + ValueError + If one or more color blending images are already open and the + requested base image is not the current spatial reference. + """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + has_open_color_blending = any( + entry["type"] == ImageType.COLOR_BLENDING for entry in summary + ) + if not has_open_color_blending: + return + + current_spatial_reference_file_id = self.get_value("spatialReference.id") + if current_spatial_reference_file_id != base_file_id: + raise ValueError( + "Cannot create a color blending with a different base image " + "while color blendings are already open. images[0] must be " + "the current spatial reference. Call " + "images[0].make_spatial_reference() and retry " + f"(requested base file_id={base_file_id}, current spatial " + f"reference file_id={current_spatial_reference_file_id})." + ) + def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. @@ -654,23 +686,33 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No f"No color blending with color_blending_id={color_blending_id} is open." ) - @validate(IterableOf(String()), Boolean()) - def open_as_color_blending(self, files, append=False): + @validate(IterableOf(String())) + def open_as_color_blending(self, files): """Open files and combine them into a new color blending image. + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``, + because the frontend does not support creating a color blending + in append mode. + Parameters ---------- files : {0} The files to be blended. - append : {1} - Whether the images should be appended to existing images. By default this is ``False`` and any existing open images are closed. Returns ------- :obj:`carta.colorblending.ColorBlending` The new color blending object. + + Raises + ------ + ValueError + If color blendings are already open and the first opened file does + not become the current spatial reference. This validation happens + after the files are opened. """ - cb = ColorBlending.from_files(self, files, append=append) + cb = ColorBlending.from_files(self, files) if len(files) <= 3: cb.set_colormap_set(ColormapSet.RGB) else: @@ -689,6 +731,12 @@ def create_color_blending(self, images): ------- :obj:`carta.colorblending.ColorBlending` The new color blending object. + + Raises + ------ + ValueError + If color blendings are already open and ``images[0]`` is not the + current spatial reference. """ cb = ColorBlending.from_images(self, images) if len(images) <= 3: diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 1cf50e8..8fb9d1e 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -282,14 +282,27 @@ The session object provides two convenience methods which create a color blendin ] # Open the files and combine them into a new color blending image - # Warning: setting `append=False` will close any existing images - cb = session.open_as_color_blending(files, append=False) + # Warning: this always opens files with append=False, + # so any existing images will be closed first + cb = session.open_as_color_blending(files) # Combine already-open images into a new color blending image - # Warning: this will break the current spatial matching and - # use the first image as the spatial reference + # Set the first image as the base layer. + # If color blendings are already open, img0 must already be the + # current spatial reference; otherwise make it the reference first. + img0.make_spatial_reference() cb = session.create_color_blending([img0, img1, img2]) +.. note:: + ``session.open_as_color_blending(files)`` always closes any currently + open images before opening ``files``. + + ``session.create_color_blending(images)`` treats ``images[0]`` as the + base layer. If color blending images are already open, ``images[0]`` + must already be the current spatial reference. To switch to a new base + image first, call ``images[0].make_spatial_reference()`` before creating + the color blending. + Manipulate properties of the color blending object and the underlying layers: .. code-block:: python diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 5e01400..8ed9115 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -637,6 +637,7 @@ def test_colorblending_from_images_success(session, mocker): img1 = Image(session, 200) img2 = Image(session, 300) + validate = mocker.patch.object(session, "_validate_color_blending_base") call_action = mocker.patch.object(session, "call_action", return_value={"id": 123}) cb = ColorBlending.from_images(session, [img0, img1, img2]) @@ -645,6 +646,7 @@ def test_colorblending_from_images_success(session, mocker): assert cb.color_blending_id == 123 assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" + validate.assert_called_once_with(100) call_action.assert_called_once_with( "imageViewConfigStore.createColorBlendingFromFrames", [img0._frame, img1._frame, img2._frame], @@ -655,11 +657,31 @@ def test_colorblending_from_images_null_return_raises_action_failed( session, mocker ): img0 = Image(session, 100) + validate = mocker.patch.object(session, "_validate_color_blending_base") mocker.patch.object(session, "call_action", return_value=None) with pytest.raises(CartaActionFailed): ColorBlending.from_images(session, [img0]) + validate.assert_called_once_with(100) + + +def test_colorblending_from_images_propagates_base_validation_error( + session, mocker +): + img0 = Image(session, 100) + call_action = mocker.patch.object(session, "call_action") + mocker.patch.object( + session, + "_validate_color_blending_base", + side_effect=ValueError(), + ) + + with pytest.raises(ValueError): + ColorBlending.from_images(session, [img0]) + + call_action.assert_not_called() + def test_colorblending_from_images_rejects_empty_list(session, mocker): call_action = mocker.patch.object(session, "call_action") @@ -688,7 +710,7 @@ def test_colorblending_from_files(session, mocker): mock_from_images = mocker.patch.object( ColorBlending, "from_images", return_value="CB" ) - out = ColorBlending.from_files(session, ["a.fits", "b.fits"], append=True) - mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) + out = ColorBlending.from_files(session, ["a.fits", "b.fits"]) + mock_open_images.assert_called_with(["a.fits", "b.fits"], append=False) mock_from_images.assert_called_once() assert out == "CB" diff --git a/tests/test_session.py b/tests/test_session.py index 99b759e..6cc2fe6 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import Macro +from carta.util import CartaValidationFailed, Macro from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -212,6 +212,63 @@ def test_get_image_single_round_trip(session, summary): assert call.args == ("imageViewConfigStore.imageListSummary",) +# session._validate_color_blending_base + + +def test_validate_color_blending_base_allows_when_no_open_color_blendings( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.FRAME, "id": 20}, + ] + + session._validate_color_blending_base(10) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_validate_color_blending_base_allows_current_spatial_reference( + session, get_value +): + get_value.side_effect = [ + [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ], + 10, + ] + + session._validate_color_blending_base(10) + + assert [call.args for call in get_value.call_args_list] == [ + ("imageViewConfigStore.imageListSummary",), + ("spatialReference.id",), + ] + + +def test_validate_color_blending_base_rejects_rebasing_existing_color_blendings( + session, get_value +): + get_value.side_effect = [ + [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ], + 20, + ] + + with pytest.raises(ValueError): + session._validate_color_blending_base(10) + + assert [call.args for call in get_value.call_args_list] == [ + ("imageViewConfigStore.imageListSummary",), + ("spatialReference.id",), + ] + + # open_as_color_blending / create_color_blending @@ -228,8 +285,8 @@ def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, mock_from_files = mocker.patch.object( ColorBlending, "from_files", return_value=fake_cb ) - result = session.open_as_color_blending(files, append=True) - mock_from_files.assert_called_once_with(session, files, append=True) + result = session.open_as_color_blending(files) + mock_from_files.assert_called_once_with(session, files) fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) assert result is fake_cb From e13fd3af80ffe88e931b52ad067d5c0bb2184a03 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 12:19:43 +0800 Subject: [PATCH 051/123] Rename active_frame to active_image and add support for ColorBlending type --- carta/session.py | 34 ++++++++++++++++++++++++++-------- tests/test_session.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/carta/session.py b/carta/session.py index e5395cc..761e823 100644 --- a/carta/session.py +++ b/carta/session.py @@ -745,16 +745,34 @@ def create_color_blending(self, images): cb.set_colormap_set(ColormapSet.RAINBOW) return cb - def active_frame(self): - """Return the currently active image. + def active_image(self): + """Return the currently active image-view item. + + This is the frame-backed image or color blending image that is + currently active in the viewer. Returns ------- - :obj:`carta.image.Image` - The currently active image. - """ - file_id = self.get_value("activeFrame.frameInfo.fileId") - return Image(self, file_id) + :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` + The currently active image-view item. + + Raises + ------ + NotImplementedError + If the active image is of a type that is not yet wrapped on + the Python side. + """ + active_type = self.get_value("activeImage.type") + active_id = self.get_value("activeImage.store.id") + if active_type == ImageType.FRAME: + return Image(self, active_id) + if active_type == ImageType.COLOR_BLENDING: + return ColorBlending(self, active_id) + raise NotImplementedError( + f"active_image encountered an unsupported image-view type " + f"{active_type!r}; only Image (FRAME) and ColorBlending " + "(COLOR_BLENDING) entries are currently wrapped." + ) def image_by_id(self, image_id): """Return an image object with the specified ID. @@ -845,7 +863,7 @@ def set_cursor(self, x, y): The Y position. """ - self.active_frame().regions.call_action("updateCursorRegionPosition", Pt(x, y)) + self.call_action("activeFrame.regionSet.updateCursorRegionPosition", Pt(x, y)) # SAVE IMAGE diff --git a/tests/test_session.py b/tests/test_session.py index 6cc2fe6..07f88cf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -212,6 +212,39 @@ def test_get_image_single_round_trip(session, summary): assert call.args == ("imageViewConfigStore.imageListSummary",) +# session.active_image + + +def test_active_image_returns_image_when_frame_active(session, get_value): + get_value.side_effect = [ImageType.FRAME, 12] + active = session.active_image() + assert isinstance(active, Image) + assert active.file_id == 12 + assert [call.args for call in get_value.call_args_list] == [ + ("activeImage.type",), + ("activeImage.store.id",), + ] + + +def test_active_image_returns_color_blending_when_color_blending_active( + session, get_value +): + get_value.side_effect = [ImageType.COLOR_BLENDING, 3] + active = session.active_image() + assert isinstance(active, ColorBlending) + assert active.color_blending_id == 3 + assert [call.args for call in get_value.call_args_list] == [ + ("activeImage.type",), + ("activeImage.store.id",), + ] + + +def test_active_image_raises_on_unsupported_type(session, get_value): + get_value.side_effect = [ImageType.PV_PREVIEW, -2] + with pytest.raises(NotImplementedError): + session.active_image() + + # session._validate_color_blending_base From b47334b260a9579a7b71c3dd0ea2d34448c83a65 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 12:26:08 +0800 Subject: [PATCH 052/123] Rename get_image to image_by_id and update signature to use keyword arguments for image identification --- carta/session.py | 23 +++-------------- carta/wcs_overlay.py | 2 +- docs/source/quickstart.rst | 4 +-- tests/test_session.py | 52 +++++++++++++++++++------------------- 4 files changed, 32 insertions(+), 49 deletions(-) diff --git a/carta/session.py b/carta/session.py index 761e823..ea7b442 100644 --- a/carta/session.py +++ b/carta/session.py @@ -598,7 +598,7 @@ def _validate_color_blending_base(self, base_file_id): f"reference file_id={current_spatial_reference_file_id})." ) - def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=None): + def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. Parameters @@ -640,7 +640,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No given = [key for key, val in provided.items() if val is not None] if len(given) != 1: raise ValueError( - "get_image requires exactly one of the keyword arguments " + "image_by_id requires exactly one of the keyword arguments " "`image_view_order`, `file_id`, or `color_blending_id`; " f"got {len(given)}." ) @@ -661,7 +661,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No if entry_type == ImageType.COLOR_BLENDING: return ColorBlending(self, entry_id) raise NotImplementedError( - f"get_image encountered an unsupported image-view entry " + f"image_by_id encountered an unsupported image-view entry " f"at order {image_view_order} with type {entry_type!r}; " "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " "entries are currently wrapped." @@ -774,23 +774,6 @@ def active_image(self): "(COLOR_BLENDING) entries are currently wrapped." ) - def image_by_id(self, image_id): - """Return an image object with the specified ID. - - This is a helper function which constructs a :obj:`carta.image.Image` object with the specified ID, without checking whether an image with that ID is currently open. It is the caller's responsibility to ensure this. - - Parameters - ---------- - image_id : integer - The ID of the image to return. - - Returns - ------- - :obj:`carta.image.Image` - The image with the specified ID. - """ - return Image(self, image_id) - def clear_spatial_reference(self): """Clear the spatial reference.""" self.call_action("clearSpatialReference") diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index 6d26829..a46a440 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -440,7 +440,7 @@ def _images(self, image_ids=None): from .image import Image if image_ids is None: return [img for img in self.session.image_list() if isinstance(img, Image)] - return [self.session.image_by_id(image_id) for image_id in image_ids] + return [self.session.image_by_id(file_id=image_id) for image_id in image_ids] def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 8fb9d1e..1f54299 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -213,8 +213,8 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba print(img0.image_view_order) # Retrieve a specific item by image view order - img = session.get_image(image_view_order=0) - cb = session.get_image(image_view_order=1) + img = session.image_by_id(image_view_order=0) + cb = session.image_by_id(image_view_order=1) Changing image properties ------------------------- diff --git a/tests/test_session.py b/tests/test_session.py index 07f88cf..8ecbcbe 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -129,7 +129,7 @@ def test_find_image_view_order_raises_when_missing(session, get_value): session._find_image_view_order(ImageType.FRAME, 99) -# session.get_image +# session.image_by_id @pytest.fixture @@ -142,71 +142,71 @@ def summary(get_value): return get_value -def test_get_image_requires_exactly_one_keyword(session, get_value): +def test_image_by_id_requires_exactly_one_keyword(session, get_value): # Zero keywords -> ValueError with all three names listed. with pytest.raises(ValueError) as e: - session.get_image() + session.image_by_id() for name in ("image_view_order", "file_id", "color_blending_id"): assert name in str(e.value) # Multiple keywords -> ValueError. with pytest.raises(ValueError): - session.get_image(file_id=1, color_blending_id=2) + session.image_by_id(file_id=1, color_blending_id=2) -def test_get_image_rejects_positional(session): +def test_image_by_id_rejects_positional(session): with pytest.raises(TypeError): - session.get_image(0) + session.image_by_id(0) -def test_get_image_by_image_view_order(session, summary): - img = session.get_image(image_view_order=0) +def test_image_by_id_by_image_view_order(session, summary): + img = session.image_by_id(image_view_order=0) assert isinstance(img, Image) assert img.file_id == 10 - cb = session.get_image(image_view_order=1) + cb = session.image_by_id(image_view_order=1) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 - img2 = session.get_image(image_view_order=2) + img2 = session.image_by_id(image_view_order=2) assert isinstance(img2, Image) assert img2.file_id == 20 -def test_get_image_by_image_view_order_out_of_range(session, summary): +def test_image_by_id_by_image_view_order_out_of_range(session, summary): with pytest.raises(IndexError): - session.get_image(image_view_order=99) + session.image_by_id(image_view_order=99) -def test_get_image_by_file_id(session, summary): - img = session.get_image(file_id=20) +def test_image_by_id_by_file_id(session, summary): + img = session.image_by_id(file_id=20) assert isinstance(img, Image) assert img.file_id == 20 -def test_get_image_by_file_id_no_cross_type_fallback(session, summary): +def test_image_by_id_by_file_id_no_cross_type_fallback(session, summary): # The summary contains a COLOR_BLENDING entry with id=7, but no FRAME - # with that id, so get_image(file_id=7) must raise. + # with that id, so image_by_id(file_id=7) must raise. with pytest.raises(RuntimeError): - session.get_image(file_id=7) + session.image_by_id(file_id=7) -def test_get_image_by_color_blending_id(session, summary): - cb = session.get_image(color_blending_id=7) +def test_image_by_id_by_color_blending_id(session, summary): + cb = session.image_by_id(color_blending_id=7) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 -def test_get_image_by_color_blending_id_no_cross_type_fallback(session, summary): +def test_image_by_id_by_color_blending_id_no_cross_type_fallback(session, summary): # The summary contains a FRAME with id=10, but no COLOR_BLENDING with - # that id, so get_image(color_blending_id=10) must raise. + # that id, so image_by_id(color_blending_id=10) must raise. with pytest.raises(RuntimeError): - session.get_image(color_blending_id=10) + session.image_by_id(color_blending_id=10) -def test_get_image_single_round_trip(session, summary): - session.get_image(image_view_order=0) - session.get_image(file_id=10) - session.get_image(color_blending_id=7) +def test_image_by_id_single_round_trip(session, summary): + session.image_by_id(image_view_order=0) + session.image_by_id(file_id=10) + session.image_by_id(color_blending_id=7) assert summary.call_count == 3 for call in summary.call_args_list: assert call.args == ("imageViewConfigStore.imageListSummary",) From a4630debc232fe50ab6c9ec8906371e0e603cb7d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 15:15:08 +0800 Subject: [PATCH 053/123] Update set_cursor to use activeFrame.setCursorPosition action and remove outdated TODO comment --- carta/session.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/carta/session.py b/carta/session.py index ea7b442..2df7d27 100644 --- a/carta/session.py +++ b/carta/session.py @@ -836,8 +836,6 @@ def set_viewer_grid(self, rows, columns, grid_mode=GridMode.FIXED): def set_cursor(self, x, y): """Set the curson position. - TODO: this is a precursor to making z-profiles available, but currently the relevant functionality is not exposed by the frontend. There is also a frontend issue which is preventing the cursor from being updated correctly (it is updated only in the profiles). - Parameters ---------- x : {0} @@ -846,7 +844,7 @@ def set_cursor(self, x, y): The Y position. """ - self.call_action("activeFrame.regionSet.updateCursorRegionPosition", Pt(x, y)) + self.call_action("activeFrame.setCursorPosition", Pt(x, y)) # SAVE IMAGE From c6e8a54e78cb0fc546b4813f902112c88c4a2baa Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 16:36:48 +0800 Subject: [PATCH 054/123] Remove ColorBlending factory methods and simplify create_color_blending to use frontend's createColorBlending action --- carta/colorblending.py | 117 +---------------------------- carta/session.py | 87 +++++++--------------- docs/source/quickstart.rst | 29 ++++---- tests/test_colorblending.py | 127 -------------------------------- tests/test_session.py | 143 +++++++++++++++--------------------- 5 files changed, 102 insertions(+), 401 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index b652d1e..8af7ec7 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -3,9 +3,8 @@ from .constants import Colormap, ColormapSet, ImageType from .image import Image from .image_base import ImageBase -from .util import BasePathMixin, CartaActionFailed, CartaScriptingException, Macro +from .util import BasePathMixin, CartaScriptingException, Macro from .validation import ( - Any, Boolean, Constant, Coordinate, @@ -192,120 +191,6 @@ def __init__(self, session, color_blending_id): def _stable_id(self): return self.color_blending_id - # FACTORIES - - @classmethod - def from_image_view_order(cls, session, image_view_order): - """Create a color blending object from an image view order. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - image_view_order : integer - The image-view order of the color blending image. - - Returns - ------- - :obj:`carta.colorblending.ColorBlending` - A new color blending object. - - Raises - ------ - ValueError - If the entry at the given image-view order is not a color blending image. - IndexError - If ``image_view_order`` is out of range. - """ - summary = session.get_value("imageViewConfigStore.imageListSummary") - if image_view_order < 0 or image_view_order >= len(summary): - raise IndexError( - f"image_view_order {image_view_order} is out of range for " - f"an image list of length {len(summary)}." - ) - entry = summary[image_view_order] - if entry["type"] != ImageType.COLOR_BLENDING: - raise ValueError( - "image_view_order does not refer to a color blending image." - ) - return cls(session, entry["id"]) - - @classmethod - @validate(Any(), IterableOf(InstanceOf(Image), min_size=1)) - def from_images(cls, session, images): - """Create a color blending object from a list of images. - - If color blending images are already open, ``images[0]`` must - already be the current spatial reference. Rebasing existing - color blendings is rejected. - - Side effect: on success, this ensures that ``images[0]`` is the - current spatial reference and that each of ``images[1:]`` is - spatially matched to it. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - images : {1} - The images to be blended. Must be non-empty. The first entry - becomes the base layer. - - Returns - ------- - :obj:`carta.colorblending.ColorBlending` - A new color blending object. - - Raises - ------ - CartaValidationFailed - If ``images`` is empty or contains a non-:obj:`carta.image.Image` - value. - ValueError - If color blendings are already open and ``images[0]`` is not the - current spatial reference. - CartaActionFailed - If the atomic frontend action fails. In practice this - happens when the input contains a stale/closed frame or - exceeds the frontend's layer-count limit. - """ - session._validate_color_blending_base(images[0].file_id) - result = session.call_action( - "imageViewConfigStore.createColorBlendingFromFrames", - [image._frame for image in images], - ) - if result is None: - raise CartaActionFailed( - "Failed to create color blending: the frontend returned " - "null. This indicates a stale frame or a layer-count " - "limit exceeded." - ) - return cls(session, result["id"]) - - @classmethod - def from_files(cls, session, files): - """Create a color blending object from a list of files. - - This helper always opens the files with ``append=False``, which - closes any currently open images before opening ``files``, - because the frontend does not support creating a color blending - in append mode. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - files : list of string - The files to be blended. - - Returns - ------- - :obj:`carta.colorblending.ColorBlending` - A new color blending object. - """ - images = session.open_images(files, append=False) - return cls.from_images(session, images) - @property def image_view_order(self): """The current index of this color blending in image list. diff --git a/carta/session.py b/carta/session.py index 2df7d27..a938f23 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -566,38 +566,6 @@ def _find_image_view_order(self, image_type, stable_id): f"{stable_id} in the image list." ) - def _validate_color_blending_base(self, base_file_id): - """Reject color-blending creation that would rebase existing blendings. - - Parameters - ---------- - base_file_id : integer - The file id of the requested base image. - - Raises - ------ - ValueError - If one or more color blending images are already open and the - requested base image is not the current spatial reference. - """ - summary = self.get_value("imageViewConfigStore.imageListSummary") - has_open_color_blending = any( - entry["type"] == ImageType.COLOR_BLENDING for entry in summary - ) - if not has_open_color_blending: - return - - current_spatial_reference_file_id = self.get_value("spatialReference.id") - if current_spatial_reference_file_id != base_file_id: - raise ValueError( - "Cannot create a color blending with a different base image " - "while color blendings are already open. images[0] must be " - "the current spatial reference. Call " - "images[0].make_spatial_reference() and retry " - f"(requested base file_id={base_file_id}, current spatial " - f"reference file_id={current_spatial_reference_file_id})." - ) - def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. @@ -691,9 +659,10 @@ def open_as_color_blending(self, files): """Open files and combine them into a new color blending image. This helper always opens the files with ``append=False``, which - closes any currently open images before opening ``files``, - because the frontend does not support creating a color blending - in append mode. + closes any currently open images before opening ``files``. It + then makes the first opened image the spatial reference, enables + spatial matching for the remaining opened images, and calls + :obj:`create_color_blending`. Parameters ---------- @@ -704,28 +673,17 @@ def open_as_color_blending(self, files): ------- :obj:`carta.colorblending.ColorBlending` The new color blending object. - - Raises - ------ - ValueError - If color blendings are already open and the first opened file does - not become the current spatial reference. This validation happens - after the files are opened. """ - cb = ColorBlending.from_files(self, files) - if len(files) <= 3: - cb.set_colormap_set(ColormapSet.RGB) - else: - cb.set_colormap_set(ColormapSet.RAINBOW) + images = self.open_images(files, append=False) + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + cb = self.create_color_blending() return cb - def create_color_blending(self, images): - """Combine already-open images into a new color blending image. - - Parameters - ---------- - images : list of :obj:`carta.image.Image` - The images to be blended. The first entry becomes the base layer. + def create_color_blending(self): + """Create a new color blending from the current spatial reference + and its currently spatially matched frames. Returns ------- @@ -734,12 +692,21 @@ def create_color_blending(self, images): Raises ------ - ValueError - If color blendings are already open and ``images[0]`` is not the - current spatial reference. + CartaActionFailed + If no frames are open or the frontend could not create the + color blending image. """ - cb = ColorBlending.from_images(self, images) - if len(images) <= 3: + frame_length = self.get_value("frames.length") + if frame_length <= 0: + raise CartaActionFailed("No frames are open") + + color_blending_id = self.call_action( + "imageViewConfigStore.createColorBlending", + return_path="id", + ) + cb = ColorBlending(self, color_blending_id) + + if frame_length <= 3: cb.set_colormap_set(ColormapSet.RGB) else: cb.set_colormap_set(ColormapSet.RAINBOW) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 1f54299..ba6ff36 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -281,27 +281,26 @@ The session object provides two convenience methods which create a color blendin "data/fits/third_file.fits", ] - # Open the files and combine them into a new color blending image - # Warning: this always opens files with append=False, - # so any existing images will be closed first + # Open the files and combine them into a new color blending image. + # This closes any currently open images, makes the first opened + # image the spatial reference, and spatially matches the remaining + # opened images to it. cb = session.open_as_color_blending(files) - # Combine already-open images into a new color blending image - # Set the first image as the base layer. - # If color blendings are already open, img0 must already be the - # current spatial reference; otherwise make it the reference first. + # Create a new color blending from the current spatial reference + # and its currently spatially matched frames. + # Set the desired base image as the current spatial reference and + # enable spatial matching for the other layers first. img0.make_spatial_reference() - cb = session.create_color_blending([img0, img1, img2]) + img1.set_spatial_matching(True) + img2.set_spatial_matching(True) + cb = session.create_color_blending() .. note:: ``session.open_as_color_blending(files)`` always closes any currently - open images before opening ``files``. - - ``session.create_color_blending(images)`` treats ``images[0]`` as the - base layer. If color blending images are already open, ``images[0]`` - must already be the current spatial reference. To switch to a new base - image first, call ``images[0].make_spatial_reference()`` before creating - the color blending. + open images before opening ``files``. It then makes the first opened + image the spatial reference and spatially matches the remaining + opened images to it before creating the color blending. Manipulate properties of the color blending object and the underlying layers: diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 8ed9115..24972f5 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -587,130 +587,3 @@ def test_colorblending_close(session, colorblending, session_call_action): session_call_action.assert_called_with( "imageViewConfigStore.removeColorBlending", colorblending._frame ) - - -# CREATION HELPERS - - -def test_colorblending_from_image_view_order(session, session_get_value): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.FRAME, "id": 20}, - {"type": ImageType.COLOR_BLENDING, "id": 17}, - ] - - cb = ColorBlending.from_image_view_order(session, 2) - - assert isinstance(cb, ColorBlending) - assert cb.color_blending_id == 17 - expected = "imageViewConfigStore.colorBlendingImageMap[17]" - assert cb._base_path == expected - session_get_value.assert_called_once_with( - "imageViewConfigStore.imageListSummary" - ) - - -def test_colorblending_from_image_view_order_rejects_non_color_blending( - session, session_get_value, mocker -): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - ] - - with pytest.raises( - ValueError, - match="image_view_order does not refer to a color blending image.", - ): - ColorBlending.from_image_view_order(session, 0) - - -def test_colorblending_from_image_view_order_out_of_range( - session, session_get_value -): - session_get_value.return_value = [] - with pytest.raises(IndexError): - ColorBlending.from_image_view_order(session, 0) - - -def test_colorblending_from_images_success(session, mocker): - img0 = Image(session, 100) - img1 = Image(session, 200) - img2 = Image(session, 300) - - validate = mocker.patch.object(session, "_validate_color_blending_base") - call_action = mocker.patch.object(session, "call_action", return_value={"id": 123}) - - cb = ColorBlending.from_images(session, [img0, img1, img2]) - - assert isinstance(cb, ColorBlending) - assert cb.color_blending_id == 123 - assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" - - validate.assert_called_once_with(100) - call_action.assert_called_once_with( - "imageViewConfigStore.createColorBlendingFromFrames", - [img0._frame, img1._frame, img2._frame], - ) - - -def test_colorblending_from_images_null_return_raises_action_failed( - session, mocker -): - img0 = Image(session, 100) - validate = mocker.patch.object(session, "_validate_color_blending_base") - mocker.patch.object(session, "call_action", return_value=None) - - with pytest.raises(CartaActionFailed): - ColorBlending.from_images(session, [img0]) - - validate.assert_called_once_with(100) - - -def test_colorblending_from_images_propagates_base_validation_error( - session, mocker -): - img0 = Image(session, 100) - call_action = mocker.patch.object(session, "call_action") - mocker.patch.object( - session, - "_validate_color_blending_base", - side_effect=ValueError(), - ) - - with pytest.raises(ValueError): - ColorBlending.from_images(session, [img0]) - - call_action.assert_not_called() - - -def test_colorblending_from_images_rejects_empty_list(session, mocker): - call_action = mocker.patch.object(session, "call_action") - - with pytest.raises(CartaValidationFailed): - ColorBlending.from_images(session, []) - - call_action.assert_not_called() - - -def test_colorblending_from_images_rejects_non_image_element(session, mocker): - call_action = mocker.patch.object(session, "call_action") - - with pytest.raises(CartaValidationFailed): - ColorBlending.from_images(session, ["not-an-image"]) - - call_action.assert_not_called() - - -def test_colorblending_from_files(session, mocker): - mock_open_images = mocker.patch.object( - session, - "open_images", - return_value=[Image(session, 1), Image(session, 2)], - ) - mock_from_images = mocker.patch.object( - ColorBlending, "from_images", return_value="CB" - ) - out = ColorBlending.from_files(session, ["a.fits", "b.fits"]) - mock_open_images.assert_called_with(["a.fits", "b.fits"], append=False) - mock_from_images.assert_called_once() - assert out == "CB" diff --git a/tests/test_session.py b/tests/test_session.py index 8ecbcbe..492e30d 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaValidationFailed, Macro +from carta.util import CartaActionFailed, Macro from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -244,104 +244,81 @@ def test_active_image_raises_on_unsupported_type(session, get_value): with pytest.raises(NotImplementedError): session.active_image() - -# session._validate_color_blending_base - - -def test_validate_color_blending_base_allows_when_no_open_color_blendings( - session, get_value -): - get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.FRAME, "id": 20}, - ] - - session._validate_color_blending_base(10) - - get_value.assert_called_once_with( - "imageViewConfigStore.imageListSummary" - ) - - -def test_validate_color_blending_base_allows_current_spatial_reference( - session, get_value -): - get_value.side_effect = [ - [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - ], - 10, - ] - - session._validate_color_blending_base(10) - - assert [call.args for call in get_value.call_args_list] == [ - ("imageViewConfigStore.imageListSummary",), - ("spatialReference.id",), - ] - - -def test_validate_color_blending_base_rejects_rebasing_existing_color_blendings( - session, get_value -): - get_value.side_effect = [ - [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - ], - 20, - ] - - with pytest.raises(ValueError): - session._validate_color_blending_base(10) - - assert [call.args for call in get_value.call_args_list] == [ - ("imageViewConfigStore.imageListSummary",), - ("spatialReference.id",), - ] - - # open_as_color_blending / create_color_blending -@pytest.mark.parametrize("files,expected_colormap_set", [ - # <= 3 files -> RGB - (["a.fits"], ColormapSet.RGB), - (["a.fits", "b.fits"], ColormapSet.RGB), - (["a.fits", "b.fits", "c.fits"], ColormapSet.RGB), - # > 3 files -> RAINBOW - (["a.fits", "b.fits", "c.fits", "d.fits"], ColormapSet.RAINBOW), +@pytest.mark.parametrize("files", [ + ["a.fits"], + ["a.fits", "b.fits"], + ["a.fits", "b.fits", "c.fits"], + ["a.fits", "b.fits", "c.fits", "d.fits"], ]) -def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, expected_colormap_set): +def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( + session, mocker, files +): + images = [mocker.MagicMock(name=f"image{i}") for i in range(len(files))] + open_images = mocker.patch.object( + session, + "open_images", + return_value=images, + ) fake_cb = mocker.MagicMock(name="ColorBlending") - mock_from_files = mocker.patch.object( - ColorBlending, "from_files", return_value=fake_cb + create_color_blending = mocker.patch.object( + session, + "create_color_blending", + return_value=fake_cb, ) + result = session.open_as_color_blending(files) - mock_from_files.assert_called_once_with(session, files) - fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) + open_images.assert_called_once_with(files, append=False) + images[0].make_spatial_reference.assert_called_once_with() + images[0].set_spatial_matching.assert_not_called() + for image in images[1:]: + image.set_spatial_matching.assert_called_once_with(True) + image.make_spatial_reference.assert_not_called() + create_color_blending.assert_called_once_with() assert result is fake_cb -@pytest.mark.parametrize("image_count,expected_colormap_set", [ - # <= 3 images -> RGB +@pytest.mark.parametrize("frame_count,expected_colormap_set", [ + # <= 3 open frames -> RGB (1, ColormapSet.RGB), (2, ColormapSet.RGB), (3, ColormapSet.RGB), - # > 3 images -> RAINBOW + # > 3 open frames -> RAINBOW (4, ColormapSet.RAINBOW), ]) -def test_create_color_blending_delegates_to_from_images(session, mocker, image_count, expected_colormap_set): - images = [Image(session, 100 + i) for i in range(image_count)] - fake_cb = mocker.MagicMock(name="ColorBlending") - mock_from_images = mocker.patch.object( - ColorBlending, "from_images", return_value=fake_cb +def test_create_color_blending_calls_frontend_create_action(session, mocker, frame_count, expected_colormap_set): + get_value = mocker.patch.object(session, "get_value", return_value=frame_count) + call_action = mocker.patch.object( + session, + "call_action", + return_value=123, ) - result = session.create_color_blending(images) - mock_from_images.assert_called_once_with(session, images) - fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) - assert result is fake_cb + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + result = session.create_color_blending() + get_value.assert_called_once_with("frames.length") + call_action.assert_called_once_with( + "imageViewConfigStore.createColorBlending", + return_path="id", + ) + mock_set_colormap.assert_called_once_with(expected_colormap_set) + assert isinstance(result, ColorBlending) + assert result.color_blending_id == 123 + + +def test_create_color_blending_raises_when_no_frames_are_open(session, mocker): + get_value = mocker.patch.object(session, "get_value", return_value=0) + call_action = mocker.patch.object(session, "call_action") + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + with pytest.raises(CartaActionFailed, match="No frames are open"): + session.create_color_blending() + + get_value.assert_called_once_with("frames.length") + call_action.assert_not_called() + mock_set_colormap.assert_not_called() # OPENING IMAGES From d6a479e9320f3be0220c45b3a84151e2f3c27e2e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 16:54:35 +0800 Subject: [PATCH 055/123] Reorganize Session methods with section comments for color blending and image-view items --- carta/session.py | 104 ++++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/carta/session.py b/carta/session.py index a938f23..a3cbf4e 100644 --- a/carta/session.py +++ b/carta/session.py @@ -511,6 +511,35 @@ def open_hypercube(self, image_paths, append=False): file_id = self.call_action(command, stokes_images, output_directory, output_hdu) return Image(self, file_id) + @validate(IterableOf(String())) + def open_as_color_blending(self, files): + """Open files and combine them into a new color blending image. + + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``. It + then makes the first opened image the spatial reference, enables + spatial matching for the remaining opened images, and calls + :obj:`create_color_blending`. + + Parameters + ---------- + files : {0} + The files to be blended. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + The new color blending object. + """ + images = self.open_images(files, append=False) + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + cb = self.create_color_blending() + return cb + + # IMAGE-VIEW ITEMS + def image_list(self): """Return the list of currently open image-view items. @@ -654,32 +683,36 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= f"No color blending with color_blending_id={color_blending_id} is open." ) - @validate(IterableOf(String())) - def open_as_color_blending(self, files): - """Open files and combine them into a new color blending image. - - This helper always opens the files with ``append=False``, which - closes any currently open images before opening ``files``. It - then makes the first opened image the spatial reference, enables - spatial matching for the remaining opened images, and calls - :obj:`create_color_blending`. + def active_image(self): + """Return the currently active image-view item. - Parameters - ---------- - files : {0} - The files to be blended. + This is the frame-backed image or color blending image that is + currently active in the viewer. Returns ------- - :obj:`carta.colorblending.ColorBlending` - The new color blending object. + :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` + The currently active image-view item. + + Raises + ------ + NotImplementedError + If the active image is of a type that is not yet wrapped on + the Python side. """ - images = self.open_images(files, append=False) - images[0].make_spatial_reference() - for image in images[1:]: - image.set_spatial_matching(True) - cb = self.create_color_blending() - return cb + active_type = self.get_value("activeImage.type") + active_id = self.get_value("activeImage.store.id") + if active_type == ImageType.FRAME: + return Image(self, active_id) + if active_type == ImageType.COLOR_BLENDING: + return ColorBlending(self, active_id) + raise NotImplementedError( + f"active_image encountered an unsupported image-view type " + f"{active_type!r}; only Image (FRAME) and ColorBlending " + "(COLOR_BLENDING) entries are currently wrapped." + ) + + # COLOR BLENDING def create_color_blending(self): """Create a new color blending from the current spatial reference @@ -712,35 +745,6 @@ def create_color_blending(self): cb.set_colormap_set(ColormapSet.RAINBOW) return cb - def active_image(self): - """Return the currently active image-view item. - - This is the frame-backed image or color blending image that is - currently active in the viewer. - - Returns - ------- - :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` - The currently active image-view item. - - Raises - ------ - NotImplementedError - If the active image is of a type that is not yet wrapped on - the Python side. - """ - active_type = self.get_value("activeImage.type") - active_id = self.get_value("activeImage.store.id") - if active_type == ImageType.FRAME: - return Image(self, active_id) - if active_type == ImageType.COLOR_BLENDING: - return ColorBlending(self, active_id) - raise NotImplementedError( - f"active_image encountered an unsupported image-view type " - f"{active_type!r}; only Image (FRAME) and ColorBlending " - "(COLOR_BLENDING) entries are currently wrapped." - ) - def clear_spatial_reference(self): """Clear the spatial reference.""" self.call_action("clearSpatialReference") From 9d866163f133571a73b5ccf6d7aebdaa59f1ce6a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 17:25:11 +0800 Subject: [PATCH 056/123] Fix default colormap set selection to use layer count instead of total frame count in create_color_blending --- carta/session.py | 9 +++++---- tests/test_session.py | 31 +++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/carta/session.py b/carta/session.py index a3cbf4e..754b9bc 100644 --- a/carta/session.py +++ b/carta/session.py @@ -729,9 +729,9 @@ def create_color_blending(self): If no frames are open or the frontend could not create the color blending image. """ - frame_length = self.get_value("frames.length") - if frame_length <= 0: - raise CartaActionFailed("No frames are open") + frame_count = self.get_value("frames.length") + if frame_count <= 0: + raise CartaActionFailed("No frames are open.") color_blending_id = self.call_action( "imageViewConfigStore.createColorBlending", @@ -739,7 +739,8 @@ def create_color_blending(self): ) cb = ColorBlending(self, color_blending_id) - if frame_length <= 3: + layer_count = cb.get_value("frames.length") + if layer_count <= 3: cb.set_colormap_set(ColormapSet.RGB) else: cb.set_colormap_set(ColormapSet.RAINBOW) diff --git a/tests/test_session.py b/tests/test_session.py index 492e30d..fbe7812 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -280,16 +280,21 @@ def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( assert result is fake_cb -@pytest.mark.parametrize("frame_count,expected_colormap_set", [ - # <= 3 open frames -> RGB - (1, ColormapSet.RGB), - (2, ColormapSet.RGB), - (3, ColormapSet.RGB), - # > 3 open frames -> RAINBOW - (4, ColormapSet.RAINBOW), +@pytest.mark.parametrize("open_frame_count,layer_count,expected_colormap_set", [ + (1, 1, ColormapSet.RGB), + (3, 3, ColormapSet.RGB), + (5, 3, ColormapSet.RGB), + (5, 4, ColormapSet.RAINBOW), ]) -def test_create_color_blending_calls_frontend_create_action(session, mocker, frame_count, expected_colormap_set): - get_value = mocker.patch.object(session, "get_value", return_value=frame_count) +def test_create_color_blending_calls_frontend_create_action(session, mocker, open_frame_count, layer_count, expected_colormap_set): + get_value = mocker.patch.object( + session, + "get_value", + side_effect=[ + open_frame_count, + layer_count, + ], + ) call_action = mocker.patch.object( session, "call_action", @@ -298,7 +303,13 @@ def test_create_color_blending_calls_frontend_create_action(session, mocker, fra mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") result = session.create_color_blending() - get_value.assert_called_once_with("frames.length") + assert get_value.call_args_list == [ + mocker.call("frames.length"), + mocker.call( + "imageViewConfigStore.colorBlendingImageMap[123].frames.length", + return_path=None, + ), + ] call_action.assert_called_once_with( "imageViewConfigStore.createColorBlending", return_path="id", From bebe9e4893e4432a1cc19a6fe2c17f15da04b48d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 18:12:02 +0800 Subject: [PATCH 057/123] Add carta_version property to Session and include it in __repr__ when available --- carta/session.py | 34 ++++++++++++++++++++++++++++++++-- tests/test_session.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index 754b9bc..66fce41 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, cached, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -51,6 +51,8 @@ class Session: ---------- session_id : integer The ID of the CARTA frontend session associated with this object. + carta_version : string + The CARTA version string reported by the frontend. wcs : :obj:`carta.wcs_overlay.SessionWCSOverlay` Sub-object with functions related to the WCS overlay. raster : :obj:`carta.raster.SessionRaster` @@ -233,7 +235,35 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa def __repr__(self): """A human-readable representation of this session object.""" - return f"Session(session_id={self.session_id}, uri={self._protocol.frontend_url if self._protocol else None})" + uri = self._protocol.frontend_url if self._protocol else None + cache = getattr(self, "_cache", {}) + + if "carta_version" in cache: + version = cache["carta_version"] + else: + try: + version = self.carta_version + except (AttributeError, CartaScriptingException): + return f"Session(session_id={self.session_id}, uri={uri!r})" + + return ( + f"Session(session_id={self.session_id}, uri={uri!r}, " + f"carta_version={version!r})" + ) + + # METADATA + + @property + @cached + def carta_version(self): + """The CARTA version. + + Returns + ------- + string + The version string reported by the frontend. + """ + return self.get_value("frontendVersion") def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. diff --git a/tests/test_session.py b/tests/test_session.py index fbe7812..5a984c7 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -33,6 +33,36 @@ def method(session, mock_method): def test_subobjects(session, name, classname): assert getattr(session, name).__class__.__name__ == classname + +def test_carta_version_property(session, get_value): + get_value.return_value = "6.0.0" + + assert session.carta_version == "6.0.0" + assert session.carta_version == "6.0.0" + + get_value.assert_called_once_with("frontendVersion") + + +def test_session_repr_includes_carta_version(session, get_value, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + get_value.return_value = "6.0.0" + + assert ( + repr(session) + == "Session(session_id=0, uri='http://localhost:3000', carta_version='6.0.0')" + ) + + +def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch.object( + session, + "get_value", + side_effect=CartaActionFailed("frontendVersion unavailable"), + ) + + assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" + # PATHS From 76a6c37136beca68b4c33fbc6760b867dbb4cc3b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 19:29:34 +0800 Subject: [PATCH 058/123] Update documentation references to use new wcs module paths and improve file_id description clarity --- carta/colorblending.py | 4 ++-- carta/image.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8af7ec7..2d00a2b 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -406,8 +406,8 @@ def set_center(self, x, y): World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using - :obj:`carta.wcs_overlay.Global.set_coordinate_system` and - :obj:`carta.wcs_overlay.Numbers.set_format`. + :obj:`carta.session.wcs.global_.set_coordinate_system` and + :obj:`carta.session.wcs.numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and diff --git a/carta/image.py b/carta/image.py index 4bbca67..f966b2d 100644 --- a/carta/image.py +++ b/carta/image.py @@ -27,14 +27,14 @@ class Image(ImageBase, BasePathMixin): session : :obj:`carta.session.Session` The session object associated with this image. file_id : integer - The frontend file id identifying this image. This is a unique number which is not reused, not the index of the image within the list of currently open images. + The frontend file ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. file_id : integer - The frontend file id identifying this image. + The frontend file ID identifying this image within the session. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` @@ -374,7 +374,7 @@ def valid_wcs(self): def set_center(self, x, y): """Set the center position, in image or world coordinates. - World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.wcs_overlay.Global.set_coordinate_system` and :obj:`carta.wcs_overlay.Numbers.set_format`. + World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.session.wcs.global_.set_coordinate_system` and :obj:`carta.session.wcs.numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and numeric strings with no units are interpreted as degrees. From 49e4e78cfcaaf1a6f1bea873d4128a96935e18f0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 19:34:09 +0800 Subject: [PATCH 059/123] Add zoom_to_size method to ColorBlending class with validation and delegate to base frame implementation --- carta/colorblending.py | 23 ++++++++++++++++++++++- tests/test_colorblending.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 2d00a2b..95241ec 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,6 +1,6 @@ """This module contains functionality for interacting with color blending images and their layers.""" -from .constants import Colormap, ColormapSet, ImageType +from .constants import Colormap, ColormapSet, ImageType, SpatialAxis from .image import Image from .image_base import ImageBase from .util import BasePathMixin, CartaScriptingException, Macro @@ -11,6 +11,7 @@ InstanceOf, IterableOf, Number, + Size, validate, ) @@ -430,6 +431,26 @@ def set_center(self, x, y): """ self._base_frame.set_center(x, y) + @validate(Size(), Constant(SpatialAxis)) + def zoom_to_size(self, size, axis): + """Zoom to the given size along the specified axis. + + Numbers are interpreted as pixel sizes. Numeric strings with no units are interpreted as arcseconds. + + Parameters + ---------- + size : {0} + The size to zoom to. + axis : {1} + The spatial axis to use. + + Raises + ------ + ValueError + If an angular size is provided and the image has no valid WCS information. + """ + self._base_frame.zoom_to_size(size, axis) + @validate(Number(), Boolean()) def set_zoom_level(self, zoom, absolute=True): """Set the zoom level. diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 24972f5..d1e891a 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -4,6 +4,7 @@ from carta.constants import Colormap as CM from carta.constants import ColormapSet as CMS from carta.constants import ImageType +from carta.constants import SpatialAxis as SA from carta.image import Image from carta.util import CartaActionFailed, CartaValidationFailed, Macro @@ -484,6 +485,34 @@ def test_colorblending_set_center(colorblending, mocker): base_frame.set_center.assert_called_once_with(1, 2) +@pytest.mark.parametrize("size,axis", [(123, SA.X), ("123arcsec", SA.Y)]) +def test_colorblending_zoom_to_size(colorblending, mocker, size, axis): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + colorblending.zoom_to_size(size, axis) + base_frame.zoom_to_size.assert_called_once_with(size, axis) + + +@pytest.mark.parametrize("size,axis", [("123px", SA.X), (123, "z")]) +def test_colorblending_zoom_to_size_invalid(colorblending, mocker, size, axis): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + with pytest.raises(CartaValidationFailed): + colorblending.zoom_to_size(size, axis) + + base_frame.zoom_to_size.assert_not_called() + + @pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): base_frame = mocker.create_autospec(Image, instance=True) From 21b22f026673ae0b720eaac2abc943ca62cf6bc4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 19:50:27 +0800 Subject: [PATCH 060/123] Refactor ImageBase to use ABC pattern, move image_view_order implementation to base class, and consolidate session initialization via super().__init__ --- carta/colorblending.py | 20 +----------- carta/image.py | 18 +---------- carta/image_base.py | 30 ++++++++++++++++-- tests/test_image.py | 69 ++++++++++++++++++++++++++++++++++++------ 4 files changed, 88 insertions(+), 49 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 95241ec..ebe2b9e 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -181,7 +181,7 @@ class ColorBlending(ImageBase, BasePathMixin): _image_type = ImageType.COLOR_BLENDING def __init__(self, session, color_blending_id): - self.session = session + super().__init__(session) self.color_blending_id = color_blending_id path = "imageViewConfigStore.colorBlendingImageMap" @@ -192,24 +192,6 @@ def __init__(self, session, color_blending_id): def _stable_id(self): return self.color_blending_id - @property - def image_view_order(self): - """The current index of this color blending in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching color blending entry exists in the image list. - """ - return self.session._find_image_view_order( - ImageType.COLOR_BLENDING, self.color_blending_id - ) - def __repr__(self): """A human-readable representation of this color blending object.""" cls = type(self).__name__ diff --git a/carta/image.py b/carta/image.py index f966b2d..d8deb17 100644 --- a/carta/image.py +++ b/carta/image.py @@ -50,7 +50,7 @@ class Image(ImageBase, BasePathMixin): _image_type = ImageType.FRAME def __init__(self, session, file_id): - self.session = session + super().__init__(session) self.file_id = file_id self._base_path = f"frameMap[{file_id}]" @@ -108,22 +108,6 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ file_id = session.call_action(command, *params, return_path="frameInfo.fileId") return cls(session, file_id) - @property - def image_view_order(self): - """The current index of this image in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching frame entry exists in the image list. - """ - return self.session._find_image_view_order(ImageType.FRAME, self.file_id) - def __repr__(self): """A human-readable representation of this image object.""" cls = type(self).__name__ diff --git a/carta/image_base.py b/carta/image_base.py index bd0e8bf..e46446c 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -4,10 +4,12 @@ """ +from abc import ABC, abstractmethod + from .constants import ImageType -class ImageBase: +class ImageBase(ABC): """Base class for image-view items (frame-backed images and color blendings). This class is not intended to be instantiated directly. @@ -24,17 +26,39 @@ def __init__(self, session): self.session = session @property + @abstractmethod def _stable_id(self): """The stable identifier of this image-view item.""" raise NotImplementedError + def _require_image_type(self): + if self._image_type is None: + raise NotImplementedError( + "Subclasses must define _image_type." + ) + @property def image_view_order(self): - """The index of this item in image list.""" - raise NotImplementedError + """The current index of this item in image list. + + Returns + ------- + integer + The image view order. + + Raises + ------ + RuntimeError + If no matching entry exists in the image list. + """ + self._require_image_type() + return self.session._find_image_view_order( + self._image_type, self._stable_id + ) def make_active(self): """Make this the active image-view item.""" + self._require_image_type() self.session.call_action( "setActiveImageById", self._image_type, self._stable_id ) diff --git a/tests/test_image.py b/tests/test_image.py index 589e71b..2723c50 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -115,16 +115,9 @@ def test_make_active(image, session_call_action): ) -def test_image_base_image_view_order_not_implemented(session): - base = ImageBase(session) - with pytest.raises(NotImplementedError): - base.image_view_order - - -def test_image_base_stable_id_not_implemented(session): - base = ImageBase(session) - with pytest.raises(NotImplementedError): - base._stable_id +def test_image_base_is_abstract(session): + with pytest.raises(TypeError, match="abstract method _stable_id"): + ImageBase(session) def test_image_base_make_active_uses_subclass_ids(session, session_call_action): @@ -147,6 +140,62 @@ def _stable_id(self): ) +def test_image_base_image_view_order_uses_subclass_ids(session, mocker): + class Dummy(ImageBase): + _image_type = ImageType.COLOR_BLENDING + + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + find = mocker.patch.object(session, "_find_image_view_order", return_value=5) + assert Dummy(session, 42).image_view_order == 5 + find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) + + +def test_image_base_image_type_required_for_make_active( + session, session_call_action +): + class Dummy(ImageBase): + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + with pytest.raises( + NotImplementedError, match="Subclasses must define _image_type" + ): + Dummy(session, 42).make_active() + + session_call_action.assert_not_called() + + +def test_image_base_image_type_required_for_image_view_order(session, mocker): + class Dummy(ImageBase): + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + find = mocker.patch.object(session, "_find_image_view_order") + with pytest.raises( + NotImplementedError, match="Subclasses must define _image_type" + ): + Dummy(session, 42).image_view_order + + find.assert_not_called() + + def test_image_view_order_uses_summary_once(session, mocker, image): find = mocker.patch.object( session, "_find_image_view_order", return_value=3 From 1c5b8ab69dbb00b0f7919e81092c21df4119e7f7 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:06:18 +0800 Subject: [PATCH 061/123] Update quickstart documentation to use unpacked layer variables instead of indexed access for improved readability --- docs/source/quickstart.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ba6ff36..42820f5 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -307,12 +307,12 @@ Manipulate properties of the color blending object and the underlying layers: .. code-block:: python # Get layer objects - layers = cb.layer_list() + red, green, blue = cb.layer_list() # Set colormap for individual layers - layers[0].set_colormap(Colormap.REDS) - layers[1].set_colormap(Colormap.GREENS) - layers[2].set_colormap(Colormap.BLUES) + red.set_colormap(Colormap.REDS) + green.set_colormap(Colormap.GREENS) + blue.set_colormap(Colormap.BLUES) # Or apply an existing colormap set cb.set_colormap_set(ColormapSet.RGB) @@ -321,9 +321,9 @@ Manipulate properties of the color blending object and the underlying layers: print(cb.alpha) # Set alpha for individual layers - layers[0].set_alpha(0.7) - layers[1].set_alpha(0.8) - layers[2].set_alpha(0.9) + red.set_alpha(0.7) + green.set_alpha(0.8) + blue.set_alpha(0.9) # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) From 8940ca36d04bdc26d6a5ba6416110c94a55fe159 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:09:52 +0800 Subject: [PATCH 062/123] Update test regex pattern to match abstract method error messages more flexibly --- tests/test_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_image.py b/tests/test_image.py index 2723c50..24f87dd 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -116,7 +116,7 @@ def test_make_active(image, session_call_action): def test_image_base_is_abstract(session): - with pytest.raises(TypeError, match="abstract method _stable_id"): + with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): ImageBase(session) From f0b86ae496c8518aaaad96d74ccf9b1602bb7900 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:27:57 +0800 Subject: [PATCH 063/123] Fix a test warning --- carta/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/util.py b/carta/util.py index 6df380c..5b4ebb4 100644 --- a/carta/util.py +++ b/carta/util.py @@ -130,7 +130,7 @@ def newfunc(self, *args): return self._cache[func.__name__] if newfunc.__doc__ is not None: - newfunc.__doc__ = re.sub(r"($|\n)", r" This value is transparently cached on the parent object.\1", newfunc.__doc__, 1) + newfunc.__doc__ = re.sub(r"($|\n)", r" This value is transparently cached on the parent object.\1", newfunc.__doc__, count=1) return newfunc From 1e4d2fe56675403d3f88568182ab63bea6c33aef Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:28:13 +0800 Subject: [PATCH 064/123] Add test coverage --- carta/image_base.py | 2 +- tests/test_colorblending.py | 24 ++++++++++++++++++++++++ tests/test_session.py | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/carta/image_base.py b/carta/image_base.py index e46446c..05ed39b 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -29,7 +29,7 @@ def __init__(self, session): @abstractmethod def _stable_id(self): """The stable identifier of this image-view item.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover def _require_image_type(self): if self._image_type is None: diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index d1e891a..663e86c 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -382,6 +382,30 @@ def test_colorblending_set_layer_sequence(session, colorblending, mocker): assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] +def test_colorblending_set_layer_sequence_noop_when_order_is_unchanged( + colorblending, mocker +): + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + side_effect=AssertionError("alpha should not be read"), + ) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) + + colorblending.set_layer_sequence([0, 1, 2]) + + del_layer.assert_not_called() + add_layer.assert_not_called() + set_alpha.assert_not_called() + + def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( session, colorblending, mocker ): diff --git a/tests/test_session.py b/tests/test_session.py index 5a984c7..081121a 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaActionFailed, Macro +from carta.util import CartaActionFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -53,6 +53,20 @@ def test_session_repr_includes_carta_version(session, get_value, mocker): ) +def test_session_repr_uses_cached_carta_version_without_lookup( + session, mocker +): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + session._cache = {"carta_version": "6.0.0"} + get_value = mocker.patch.object(session, "get_value") + + assert ( + repr(session) + == "Session(session_id=0, uri='http://localhost:3000', carta_version='6.0.0')" + ) + get_value.assert_not_called() + + def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): session._protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch.object( @@ -207,6 +221,17 @@ def test_image_by_id_by_image_view_order_out_of_range(session, summary): session.image_by_id(image_view_order=99) +def test_image_by_id_by_image_view_order_raises_on_unsupported_type( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.PV_PREVIEW, "id": -2}, + ] + + with pytest.raises(NotImplementedError): + session.image_by_id(image_view_order=0) + + def test_image_by_id_by_file_id(session, summary): img = session.image_by_id(file_id=20) assert isinstance(img, Image) @@ -361,6 +386,13 @@ def test_create_color_blending_raises_when_no_frames_are_open(session, mocker): call_action.assert_not_called() mock_set_colormap.assert_not_called() + +def test_set_cursor(session, call_action): + session.set_cursor(1, 2) + call_action.assert_called_once_with( + "activeFrame.setCursorPosition", Pt(1, 2) + ) + # OPENING IMAGES From 8620e85f2cd090f882fbd2b78f1724605dd06557 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:41:43 +0800 Subject: [PATCH 065/123] Improve error messages to include actual values and update tests to verify detailed error content --- carta/colorblending.py | 30 ++++++++++++++++++++++-------- carta/session.py | 5 ++++- tests/test_colorblending.py | 33 ++++++++++++++------------------- tests/test_session.py | 5 ++++- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index ebe2b9e..aab8f84 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -329,30 +329,44 @@ def set_layer_sequence(self, layer_indices): must be the base layer (index = 0). Existing alpha values are preserved. """ + layer_indices = list(layer_indices) current_layers = self.layer_list() max_current_layer_index = len(current_layers) - 1 - if any( - layer_index > max_current_layer_index + invalid_layer_indices = [ + layer_index for layer_index in layer_indices - ): + if layer_index > max_current_layer_index + ] + if invalid_layer_indices: raise ValueError( - "layer_indices contains a layer index which does not exist." + f"layer_indices {layer_indices!r} contains non-existent layer " + f"indices {invalid_layer_indices!r}; available layer indices " + f"are 0..{max_current_layer_index}." ) if layer_indices[0] != 0: raise ValueError( - "layer_indices must start with the base layer index 0." + f"layer_indices {layer_indices!r} must start with the base " + "layer index 0." ) if 0 in layer_indices[1:]: raise ValueError( - "layer_indices must contain the base layer index 0 only once, " - "as the first index." + f"layer_indices {layer_indices!r} must contain the base layer " + "index 0 only once, as the first index." ) if len(layer_indices) != len(set(layer_indices)): + duplicate_layer_indices = sorted( + { + layer_index + for layer_index in layer_indices + if layer_indices.count(layer_index) > 1 + } + ) raise ValueError( - "layer_indices must not contain duplicate layer indices." + f"layer_indices {layer_indices!r} must not contain duplicate " + f"layer indices; duplicates were {duplicate_layer_indices!r}." ) current_layer_indices = list(range(len(current_layers))) diff --git a/carta/session.py b/carta/session.py index 66fce41..c3579b7 100644 --- a/carta/session.py +++ b/carta/session.py @@ -666,10 +666,13 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= } given = [key for key, val in provided.items() if val is not None] if len(given) != 1: + given_values = { + key: val for key, val in provided.items() if val is not None + } raise ValueError( "image_by_id requires exactly one of the keyword arguments " "`image_view_order`, `file_id`, or `color_blending_id`; " - f"got {len(given)}." + f"got {len(given)} with values {given_values!r}." ) summary = self.get_value("imageViewConfigStore.imageListSummary") diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 663e86c..c14cdb1 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -439,11 +439,11 @@ def test_colorblending_set_layer_sequence_rejects_missing_layer_index( return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], ) - with pytest.raises( - ValueError, - match="layer_indices contains a layer index which does not exist.", - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([0, 4, 1]) + assert "layer_indices [0, 4, 1]" in str(e.value) + assert "[4]" in str(e.value) + assert "0..3" in str(e.value) def test_colorblending_set_layer_sequence_requires_base_layer_first( @@ -455,11 +455,10 @@ def test_colorblending_set_layer_sequence_requires_base_layer_first( return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) - with pytest.raises( - ValueError, - match="layer_indices must start with the base layer index 0.", - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([2, 1]) + assert "layer_indices [2, 1]" in str(e.value) + assert "must start with the base layer index 0" in str(e.value) def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( @@ -471,14 +470,10 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) - with pytest.raises( - ValueError, - match=( - "layer_indices must contain the base layer index 0 only once, " - "as the first index." - ), - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([0, 2, 0]) + assert "layer_indices [0, 2, 0]" in str(e.value) + assert "must contain the base layer index 0 only once" in str(e.value) def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( @@ -490,11 +485,11 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) - with pytest.raises( - ValueError, - match="layer_indices must not contain duplicate layer indices.", - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([0, 1, 1]) + assert "layer_indices [0, 1, 1]" in str(e.value) + assert "must not contain duplicate layer indices" in str(e.value) + assert "[1]" in str(e.value) def test_colorblending_set_center(colorblending, mocker): diff --git a/tests/test_session.py b/tests/test_session.py index 081121a..54c2f0b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -192,9 +192,12 @@ def test_image_by_id_requires_exactly_one_keyword(session, get_value): session.image_by_id() for name in ("image_view_order", "file_id", "color_blending_id"): assert name in str(e.value) + assert "got 0 with values {}" in str(e.value) # Multiple keywords -> ValueError. - with pytest.raises(ValueError): + with pytest.raises(ValueError) as e: session.image_by_id(file_id=1, color_blending_id=2) + assert "'file_id': 1" in str(e.value) + assert "'color_blending_id': 2" in str(e.value) def test_image_by_id_rejects_positional(session): From c982c8916cff49bcc1cbdbe12417cb73cdba8db8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:43:01 +0800 Subject: [PATCH 066/123] Add session.clear_spatial_reference() call before setting new spatial reference in color blending example --- docs/source/quickstart.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 42820f5..00315b8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -291,6 +291,7 @@ The session object provides two convenience methods which create a color blendin # and its currently spatially matched frames. # Set the desired base image as the current spatial reference and # enable spatial matching for the other layers first. + session.clear_spatial_reference() img0.make_spatial_reference() img1.set_spatial_matching(True) img2.set_spatial_matching(True) From ab1753d2171c04f85a07f0682f24f7358f867e85 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 21:17:19 +0800 Subject: [PATCH 067/123] Add validation to reject empty file list in open_as_color_blending method and verify error message includes minimum size requirement --- carta/session.py | 2 +- tests/test_session.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index c3579b7..7f14db9 100644 --- a/carta/session.py +++ b/carta/session.py @@ -541,7 +541,7 @@ def open_hypercube(self, image_paths, append=False): file_id = self.call_action(command, stokes_images, output_directory, output_hdu) return Image(self, file_id) - @validate(IterableOf(String())) + @validate(IterableOf(String(), min_size=1)) def open_as_color_blending(self, files): """Open files and combine them into a new color blending image. diff --git a/tests/test_session.py b/tests/test_session.py index 54c2f0b..12841db 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaActionFailed, Macro, Point as Pt +from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -338,6 +338,20 @@ def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( assert result is fake_cb +def test_open_as_color_blending_rejects_empty_file_list(session, mocker): + open_images = mocker.patch.object(session, "open_images") + create_color_blending = mocker.patch.object( + session, "create_color_blending" + ) + + with pytest.raises(CartaValidationFailed) as e: + session.open_as_color_blending([]) + + assert "at least 1" in str(e.value) + open_images.assert_not_called() + create_color_blending.assert_not_called() + + @pytest.mark.parametrize("open_frame_count,layer_count,expected_colormap_set", [ (1, 1, ColormapSet.RGB), (3, 3, ColormapSet.RGB), From 8f6ab9ea03b4cd5bd4c9f7e03b6c1410cc758403 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 21:56:02 +0800 Subject: [PATCH 068/123] Optimize active_image method to retrieve activeImage object once instead of making separate calls for type and store.id properties --- carta/session.py | 5 +++-- tests/test_session.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/carta/session.py b/carta/session.py index 7f14db9..25a6cc5 100644 --- a/carta/session.py +++ b/carta/session.py @@ -733,8 +733,9 @@ def active_image(self): If the active image is of a type that is not yet wrapped on the Python side. """ - active_type = self.get_value("activeImage.type") - active_id = self.get_value("activeImage.store.id") + active = self.get_value("activeImage") + active_type = active["type"] + active_id = active["store"]["id"] if active_type == ImageType.FRAME: return Image(self, active_id) if active_type == ImageType.COLOR_BLENDING: diff --git a/tests/test_session.py b/tests/test_session.py index 12841db..42690f1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -274,31 +274,35 @@ def test_image_by_id_single_round_trip(session, summary): def test_active_image_returns_image_when_frame_active(session, get_value): - get_value.side_effect = [ImageType.FRAME, 12] + get_value.side_effect = [ + {"type": ImageType.FRAME, "store": {"id": 12}}, + ] active = session.active_image() assert isinstance(active, Image) assert active.file_id == 12 assert [call.args for call in get_value.call_args_list] == [ - ("activeImage.type",), - ("activeImage.store.id",), + ("activeImage",), ] def test_active_image_returns_color_blending_when_color_blending_active( session, get_value ): - get_value.side_effect = [ImageType.COLOR_BLENDING, 3] + get_value.side_effect = [ + {"type": ImageType.COLOR_BLENDING, "store": {"id": 3}}, + ] active = session.active_image() assert isinstance(active, ColorBlending) assert active.color_blending_id == 3 assert [call.args for call in get_value.call_args_list] == [ - ("activeImage.type",), - ("activeImage.store.id",), + ("activeImage",), ] def test_active_image_raises_on_unsupported_type(session, get_value): - get_value.side_effect = [ImageType.PV_PREVIEW, -2] + get_value.side_effect = [ + {"type": ImageType.PV_PREVIEW, "store": {"id": -2}}, + ] with pytest.raises(NotImplementedError): session.active_image() From 65eacedd8d1663c9af6f1622ba1225c021a9a567 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 23 Apr 2026 13:56:18 +0800 Subject: [PATCH 069/123] Refactor _find_image_view_order to use imageViewConfigStore.getImageListIndex action instead of manually iterating through imageListSummary --- carta/session.py | 20 ++++++++++++-------- tests/test_colorblending.py | 37 ++++++++++++------------------------- tests/test_image.py | 21 ++------------------- tests/test_session.py | 31 ++++++++++++++++++++----------- 4 files changed, 46 insertions(+), 63 deletions(-) diff --git a/carta/session.py b/carta/session.py index 25a6cc5..c037bd1 100644 --- a/carta/session.py +++ b/carta/session.py @@ -609,21 +609,25 @@ def _find_image_view_order(self, image_type, stable_id): Returns ------- integer - The image-view order of the first matching entry. + The image-view order of the matching entry. Raises ------ RuntimeError If no matching entry exists in the image list. """ - summary = self.get_value("imageViewConfigStore.imageListSummary") - for idx, entry in enumerate(summary): - if entry["type"] == image_type and entry["id"] == stable_id: - return idx - raise RuntimeError( - f"Could not find an image of type {image_type!r} with id " - f"{stable_id} in the image list." + image_view_order = self.call_action( + "imageViewConfigStore.getImageListIndex", + image_type, + stable_id, + response_expected=True, ) + if image_view_order == -1: + raise RuntimeError( + f"Could not find an image of type {image_type!r} with id " + f"{stable_id} in the image list." + ) + return image_view_order def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index c14cdb1..f34dd4c 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -198,7 +198,7 @@ def test_colorblending_init(session): def test_colorblending_repr_healthy(session, colorblending, cb_property, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch.object(session, "call_action", return_value=2) cb_property("file_name", "Color Blending 1") r = repr(colorblending) assert r == ( @@ -212,7 +212,7 @@ def test_colorblending_repr_closed_when_not_in_image_list( ): mocker.patch.object( session, - "_find_image_view_order", + "call_action", side_effect=RuntimeError("not in image list"), ) r = repr(colorblending) @@ -224,7 +224,7 @@ def test_colorblending_repr_closed_when_not_in_image_list( def test_colorblending_repr_closed_when_backing_entry_is_gone( session, colorblending, mocker ): - mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch.object(session, "call_action", return_value=2) mocker.patch( "carta.colorblending.ColorBlending.file_name", new_callable=mocker.PropertyMock, @@ -242,35 +242,22 @@ def test_colorblending_file_name(colorblending, cb_get_value): def test_colorblending_image_view_order( - session, colorblending, session_get_value + session, colorblending, session_call_action ): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 99}, - {"type": ImageType.COLOR_BLENDING, "id": 0}, - ] + session_call_action.return_value = 2 assert colorblending.image_view_order == 2 - session_get_value.assert_called_once_with( - "imageViewConfigStore.imageListSummary" + session_call_action.assert_called_once_with( + "imageViewConfigStore.getImageListIndex", + ImageType.COLOR_BLENDING, + 0, + response_expected=True, ) -def test_colorblending_image_view_order_ignores_non_color_blending( - session, colorblending, session_get_value -): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 0}, - {"type": ImageType.COLOR_BLENDING, "id": 0}, - ] - assert colorblending.image_view_order == 1 - - def test_colorblending_image_view_order_raises_when_missing( - session, colorblending, session_get_value + session, colorblending, session_call_action ): - session_get_value.return_value = [ - {"type": ImageType.COLOR_BLENDING, "id": 99}, - ] + session_call_action.return_value = -1 with pytest.raises(RuntimeError): colorblending.image_view_order diff --git a/tests/test_image.py b/tests/test_image.py index 24f87dd..7f2a969 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -196,7 +196,7 @@ def _stable_id(self): find.assert_not_called() -def test_image_view_order_uses_summary_once(session, mocker, image): +def test_image_view_order_uses_find_image_view_order(session, mocker, image): find = mocker.patch.object( session, "_find_image_view_order", return_value=3 ) @@ -205,26 +205,9 @@ def test_image_view_order_uses_summary_once(session, mocker, image): find.assert_called_once_with(ImageType.FRAME, 0) -def test_image_view_order_ignores_non_frame_entries(session, mocker): - get_value = mocker.patch.object( - session, - "get_value", - return_value=[ - {"type": ImageType.COLOR_BLENDING, "id": 0}, - {"type": ImageType.FRAME, "id": 7}, - {"type": ImageType.FRAME, "id": 3}, - ], - ) - img = Image(session, 3) - assert img.image_view_order == 2 - get_value.assert_called_once_with("imageViewConfigStore.imageListSummary") - - def test_image_view_order_raises_when_missing(session, mocker): mocker.patch.object( - session, - "get_value", - return_value=[{"type": ImageType.FRAME, "id": 99}], + session, "_find_image_view_order", side_effect=RuntimeError ) img = Image(session, 3) with pytest.raises(RuntimeError): diff --git a/tests/test_session.py b/tests/test_session.py index 42690f1..d9a70a4 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,3 +1,5 @@ +from unittest.mock import call + import pytest from carta.image import Image @@ -153,22 +155,29 @@ def test_image_list_empty(session, get_value): ) -def test_find_image_view_order_single_round_trip(session, get_value): - get_value.return_value = [ - {"type": ImageType.FRAME, "id": 7}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - {"type": ImageType.FRAME, "id": 3}, - ] +def test_find_image_view_order_single_round_trip(session, call_action): + call_action.side_effect = [2, 1] assert session._find_image_view_order(ImageType.FRAME, 3) == 2 assert session._find_image_view_order(ImageType.COLOR_BLENDING, 7) == 1 - assert get_value.call_count == 2 - for call in get_value.call_args_list: - assert call.args == ("imageViewConfigStore.imageListSummary",) + assert call_action.call_args_list == [ + call( + "imageViewConfigStore.getImageListIndex", + ImageType.FRAME, + 3, + response_expected=True, + ), + call( + "imageViewConfigStore.getImageListIndex", + ImageType.COLOR_BLENDING, + 7, + response_expected=True, + ), + ] -def test_find_image_view_order_raises_when_missing(session, get_value): - get_value.return_value = [{"type": ImageType.FRAME, "id": 1}] +def test_find_image_view_order_raises_when_missing(session, call_action): + call_action.return_value = -1 with pytest.raises(RuntimeError): session._find_image_view_order(ImageType.FRAME, 99) From 298043ca1d9c02b087bc1274f7dcb45f42f5ab71 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 23 Apr 2026 14:03:21 +0800 Subject: [PATCH 070/123] Rename loop variable from 'call' to 'call_' to avoid shadowing built-in name --- tests/test_session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_session.py b/tests/test_session.py index d9a70a4..91e6a96 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -275,8 +275,8 @@ def test_image_by_id_single_round_trip(session, summary): session.image_by_id(file_id=10) session.image_by_id(color_blending_id=7) assert summary.call_count == 3 - for call in summary.call_args_list: - assert call.args == ("imageViewConfigStore.imageListSummary",) + for call_ in summary.call_args_list: + assert call_.args == ("imageViewConfigStore.imageListSummary",) # session.active_image From 9693fa564069ec9e79b508ed1e92b022327843fb Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 28 Apr 2026 12:56:40 +0800 Subject: [PATCH 071/123] Add connection validation during session creation with minimum CARTA version check --- carta/browser.py | 56 +++++++++- carta/session.py | 174 ++++++++++++++++++++++++++++-- carta/util.py | 58 ++++++++++ docs/source/quickstart.rst | 14 ++- tests/test_browser.py | 166 +++++++++++++++++++++++++++++ tests/test_session.py | 213 ++++++++++++++++++++++++++++++++++++- 6 files changed, 665 insertions(+), 16 deletions(-) create mode 100644 tests/test_browser.py diff --git a/carta/browser.py b/carta/browser.py index 4ae5d89..741f8dd 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -34,7 +34,7 @@ class Browser: def __init__(self, driver_class, **kwargs): self.driver = driver_class(**kwargs) - def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False): + def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10): """Create a new session by connecting to an existing backend. You can use :obj:`carta.session.Session.create`, which wraps this method. @@ -51,6 +51,13 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 The number of seconds to spend parsing the frontend for connection information. 10 seconds by default. debug_no_auth : boolean Disable authentication. This should be set if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. + check_connection : boolean, optional + Whether to validate the session by reading the CARTA frontend + version through the scripting interface before returning. Default: + ``True``. Set to ``False`` to skip startup scripting calls. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. Returns ------- @@ -65,6 +72,9 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the connected CARTA frontend reports a version below the + minimum supported CARTA version. """ protocol = Protocol(frontend_url, token, debug_no_auth=debug_no_auth) @@ -97,9 +107,16 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 if not session_id: self.exit(f"Could not parse session ID from frontend. Last error: {last_error}") - return Session(session_id, protocol, browser=self, backend=backend) + session = Session(session_id, protocol, browser=self, backend=backend) + if check_connection: + try: + session._check_connection(timeout=connection_check_timeout) + except Exception: + self._close_after_error() + raise + return session - def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10): + def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10): """Create a new session after launching a new backend process. You can use :obj:`carta.session.Session.start_and_create`, which wraps this method. This method starts a backend process, parses the frontend URL from the output, and calls :obj:`carta.browser.Browser.new_session_from_url`. @@ -118,6 +135,13 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa The security token to use. Parsed from the backend output by default. frontend_url_timeout : integer How long to keep checking the backend output for the frontend URL. Default: 10 seconds. + check_connection : boolean, optional + Whether to validate the session by reading the CARTA frontend + version through the scripting interface before returning. Default: + ``True``. Set to ``False`` to skip startup scripting calls. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. Returns ------- @@ -132,6 +156,9 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the connected CARTA frontend reports a version below the + minimum supported CARTA version. """ backend = Backend(("--no_browser", "--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout=frontend_url_timeout, session_creation_timeout=0) @@ -139,19 +166,38 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa self.exit(f"CARTA backend exited unexpectedly:\n{''.join(backend.errors)}") if backend.frontend_url is None: + backend.stop() self.exit("Could not parse CARTA frontend URL from backend output.") - return self.new_session_from_url(backend.frontend_url, backend.token, backend=backend, timeout=timeout, debug_no_auth=backend.debug_no_auth) + try: + return self.new_session_from_url( + backend.frontend_url, + backend.token, + backend=backend, + timeout=timeout, + debug_no_auth=backend.debug_no_auth, + check_connection=check_connection, + connection_check_timeout=connection_check_timeout, + ) + except Exception: + backend.stop() + raise def exit(self, msg): """Exit the browser with an error.""" - self.close() + self._close_after_error() raise CartaBadSession(msg) def close(self): """Shut down the browser driver.""" self.driver.quit() + def _close_after_error(self): + try: + self.close() + except Exception: + pass + class Chrome(Browser): """Chrome or Chromium, optionally headless. diff --git a/carta/session.py b/carta/session.py index c037bd1..26e503c 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, cached, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaUnsupportedVersion, cached, carta_version_meets_minimum, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -22,6 +22,9 @@ from .preferences import Preferences +MINIMUM_CARTA_VERSION = "6.0.0" + + class Session: """This object corresponds to a CARTA frontend session. @@ -75,7 +78,7 @@ def __del__(self): self.close() @classmethod - def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None): + def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, check_connection=True, connection_check_timeout=10): """Interact with an existing CARTA frontend session. Parameters @@ -90,6 +93,13 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac Disable authentication. Set this if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. backend : :obj:`carta.backend.Backend` The backend object associated with this session, if any. This is set if this method is called from :obj:`carta.session.Session.start_and_interact`. + check_connection : boolean, optional + Whether to validate the session by reading the CARTA frontend + version through the scripting interface before returning. + Default: ``True``. Set to ``False`` to skip startup scripting calls. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. Returns ------- @@ -106,16 +116,26 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the connected CARTA frontend reports a version below the + minimum supported CARTA version. """ try: session_id = int(session_id) except ValueError: raise CartaBadID(f"Session ID '{session_id}' is not a number.") - return cls(session_id, Protocol(frontend_url, token, debug_no_auth=debug_no_auth), backend=backend) + session = cls( + session_id, + Protocol(frontend_url, token, debug_no_auth=debug_no_auth), + backend=backend, + ) + if check_connection: + session._check_connection(timeout=connection_check_timeout) + return session @classmethod - def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10): + def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, check_connection=True, connection_check_timeout=10): """Start a new CARTA backend instance and interact with the default CARTA frontend session which is created automatically in the user's browser. This method cannot be used with a CARTA controller instance. Parameters @@ -132,6 +152,13 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu How long to keep checking the backend output for the frontend URL. Default: 10 seconds. session_creation_timeout : integer How long to keep checking the output for a default session ID. Default: 10 seconds. + check_connection : boolean, optional + Whether to validate the session by reading the CARTA frontend + version through the scripting interface before returning. + Default: ``True``. Set to ``False`` to skip startup scripting calls. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. Returns ------- @@ -148,6 +175,9 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu If a valid URL cannot be obtained from the backend process output. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the connected CARTA frontend reports a version below the + minimum supported CARTA version. """ backend = Backend(("--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout, session_creation_timeout) if not backend.start(): @@ -161,10 +191,22 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu backend.stop() raise CartaBadID("Could not parse default CARTA session ID from backend output.") - return cls.interact(backend.frontend_url, backend.last_session_id, token, backend.debug_no_auth, backend) + try: + return cls.interact( + backend.frontend_url, + backend.last_session_id, + token, + backend.debug_no_auth, + backend, + check_connection=check_connection, + connection_check_timeout=connection_check_timeout, + ) + except Exception: + backend.stop() + raise @classmethod - def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False): + def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10): """Connect to an existing CARTA backend or CARTA controller instance and create a new session. Parameters @@ -179,6 +221,13 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal The number of seconds to spend retrying parsing connection information from the frontend (default: 10). debug_no_auth : boolean, optional Disable authentication. Set this if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. + check_connection : boolean, optional + Whether to validate the session by reading the CARTA frontend + version through the scripting interface before returning. + Default: ``True``. Set to ``False`` to skip startup scripting calls. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. Returns ------- @@ -193,11 +242,22 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the connected CARTA frontend reports a version below the + minimum supported CARTA version. """ - return browser.new_session_from_url(frontend_url, token, backend=None, timeout=timeout, debug_no_auth=debug_no_auth) + return browser.new_session_from_url( + frontend_url, + token, + backend=None, + timeout=timeout, + debug_no_auth=debug_no_auth, + check_connection=check_connection, + connection_check_timeout=connection_check_timeout, + ) @classmethod - def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10): + def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10): """Start a new CARTA backend instance and create a new session. This method cannot be used with a CARTA controller instance (which already starts and stops backend instances for the user on demand). Parameters @@ -216,6 +276,13 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa The security token to use. Parsed from the backend output by default. frontend_url_timeout : integer How long to keep checking the backend output for the frontend URL. Default: 10 seconds. + check_connection : boolean, optional + Whether to validate the session by reading the CARTA frontend + version through the scripting interface before returning. + Default: ``True``. Set to ``False`` to skip startup scripting calls. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. Returns ------- @@ -230,8 +297,20 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the connected CARTA frontend reports a version below the + minimum supported CARTA version. """ - return browser.new_session_with_backend(executable_path, remote_host, params, timeout, token, frontend_url_timeout) + return browser.new_session_with_backend( + executable_path, + remote_host, + params, + timeout, + token, + frontend_url_timeout, + check_connection=check_connection, + connection_check_timeout=connection_check_timeout, + ) def __repr__(self): """A human-readable representation of this session object.""" @@ -265,6 +344,83 @@ def carta_version(self): """ return self.get_value("frontendVersion") + def require_carta_version(self, minimum_version, feature=None): + """Require this session to be connected to a supported CARTA version. + + Parameters + ---------- + minimum_version : string + The minimum CARTA release version required. + feature : string, optional + The feature requiring this version. Used in error messages. + + Returns + ------- + string + The detected CARTA version. + + Raises + ------ + CartaUnsupportedVersion + If the detected CARTA version is below the minimum or cannot be + parsed. + """ + version = self.carta_version + if carta_version_meets_minimum(version, minimum_version): + return version + + feature_text = f" for {feature}" if feature else "" + raise CartaUnsupportedVersion( + f"CARTA version {version!r} is not supported{feature_text}. " + f"Required minimum version is {minimum_version!r}." + ) + + def _fetch_frontend_version(self, timeout=None): + kwargs = {"response_expected": True} + if timeout is not None: + kwargs["timeout"] = timeout + + return self.call_action( + "fetchParameter", + Macro("", "frontendVersion"), + **kwargs, + ) + + def _cache_carta_version(self, version): + if not hasattr(self, "_cache"): + self._cache = {} + self._cache["carta_version"] = version + + def _check_connection(self, timeout=10): + try: + version = self._fetch_frontend_version(timeout=timeout) + except CartaScriptingException as e: + raise CartaBadSession( + self._connection_check_error_message(e, timeout) + ) from e + + self._cache_carta_version(version) + self.require_carta_version( + MINIMUM_CARTA_VERSION, + feature="carta-python session startup", + ) + return version + + def _connection_check_error_message(self, error, timeout): + uri = self._protocol.frontend_url if self._protocol else None + return ( + f"Could not validate CARTA session {self.session_id} at {uri!r} " + f"within {timeout} seconds. " + f"Original error ({error.__class__.__name__}): {error}\n" + "Possible causes:\n" + "- CARTA backend/frontend is not running or not reachable.\n" + "- The session ID is wrong or the session has closed.\n" + "- The backend was not started with --enable_scripting.\n" + "- The token is missing, invalid, or expired.\n" + "- The CARTA frontend does not expose frontendVersion, which " + "can happen with unsupported versions such as 6.0.0-beta.1." + ) + def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. diff --git a/carta/util.py b/carta/util.py index 5b4ebb4..89b6382 100644 --- a/carta/util.py +++ b/carta/util.py @@ -57,6 +57,11 @@ class CartaActionFailed(CartaScriptingException): pass +class CartaUnsupportedVersion(CartaScriptingException): + """The connected CARTA version is not supported.""" + pass + + class CartaBadResponse(CartaScriptingException): """An action request received an unexpected response from the CARTA frontend.""" pass @@ -142,6 +147,59 @@ def split_action_path(path): return '.'.join(parts[:-1]), parts[-1] +def parse_carta_version(version): + """Parse a CARTA version string into a ``(major, minor, patch)`` tuple. + + Any prerelease suffix (e.g. ``"-dev"``, ``"-beta.1"``) is ignored. + + Parameters + ---------- + version : string + A version string of the form ``"MAJOR.MINOR.PATCH"`` with an optional + ``"-SUFFIX"`` (e.g. ``"6.0.0"`` or ``"6.0.0-dev"``). + + Returns + ------- + tuple or None + ``(major, minor, patch)`` or ``None`` if the version string cannot be + parsed. + """ + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-[A-Za-z0-9.]+)?", str(version)) + if not match: + return None + + major, minor, patch = match.groups() + return (int(major), int(minor), int(patch)) + + +def carta_version_meets_minimum(version, minimum_version): + """Check whether a CARTA version meets a minimum version requirement. + + Only the ``MAJOR.MINOR.PATCH`` components are compared; any prerelease + suffix is ignored. + + Parameters + ---------- + version : string + The CARTA version string to check. + minimum_version : string + The minimum CARTA version string required. + + Returns + ------- + boolean + ``True`` if ``version`` meets the minimum, ``False`` otherwise + (including when either argument cannot be parsed). + """ + version_base = parse_carta_version(version) + minimum_base = parse_carta_version(minimum_version) + + if version_base is None or minimum_base is None: + return False + + return version_base >= minimum_base + + class BasePathMixin: """A mixin which provides ``call_action`` and ``get_value`` methods which prepend the object's base path to the path before calling the corresponding :obj:`carta.session.Session` methods. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 00315b8..8b54a3f 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -23,7 +23,19 @@ The required Python library dependencies should be installed automatically. To c You need access either to a CARTA backend executable, on the local host or on a remote host which you can access through SSH, or to a CARTA controller instance (a multi-user system with web-based authentication). You must be able to access the frontend served by this CARTA instance. If you are using your own backend executable, you must start it with the ``--enable_scripting`` commandline parameter to enable the scripting interface. .. note:: - This version of the wrapper requires at least the 4.0 release versions of the CARTA backend, frontend and (optionally) controller. Older versions of these components may not work correctly and are not supported. + This version of the wrapper requires CARTA backend, frontend and + (optionally) controller versions compatible with CARTA 6.0.0 or later. + Session creation validates the frontend by reading ``frontendVersion`` + through the scripting interface. Only the ``MAJOR.MINOR.PATCH`` + components are compared, so any prerelease suffix (e.g. ``-dev``) is + accepted as long as the base version meets the minimum. Older + prerelease builds such as ``6.0.0-beta.1`` do not expose + ``frontendVersion`` and therefore fail this validation. + +If session creation fails during startup validation, check that the frontend +URL is reachable, the session ID is correct, the token is valid, and the +backend was started with ``--enable_scripting``. A failure while reading +``frontendVersion`` can also indicate an unsupported CARTA frontend version. If you want to create browser sessions from the wrapper, you also need to make sure that your desired browser is installed, together with a corresponding web driver. At present only Chrome (or Chromium) can be used for headless sessions. diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..209fe47 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,166 @@ +import pytest + + +class FakeElement: + def __init__(self, text): + self.text = text + + def get_attribute(self, name): + assert name == "textContent" + return self.text + + +class FakeDriver: + def __init__(self, session_id="123", close_error=None): + self.session_id = session_id + self.close_error = close_error + self.urls = [] + self.closed = False + self.cookies = [] + + def get(self, url): + self.urls.append(url) + + def add_cookie(self, cookie): + self.cookies.append(cookie) + + def find_element(self, by, value): + return FakeElement(self.session_id) + + def quit(self): + if self.close_error: + raise self.close_error + self.closed = True + + +@pytest.fixture +def browser_module(): + pytest.importorskip("selenium") + import carta.browser + + return carta.browser + + +@pytest.fixture +def util_module(): + pytest.importorskip("selenium") + import carta.util + + return carta.util + + +def make_browser(browser_class, session_id="123", close_error=None): + browser = browser_class.__new__(browser_class) + browser.driver = FakeDriver(session_id=session_id, close_error=close_error) + return browser + + +def test_new_session_from_url_runs_connection_check_after_parsing_session_id( + mocker, browser_module +): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + session_class = mocker.patch("carta.browser.Session", return_value=session) + + result = browser.new_session_from_url( + "http://localhost:3000?token=x", + check_connection=True, + connection_check_timeout=5, + ) + + assert result is session + session_class.assert_called_once_with(123, protocol, browser=browser, backend=None) + session._check_connection.assert_called_once_with(timeout=5) + assert browser.driver.closed is False + + +def test_new_session_from_url_skips_connection_check(mocker, browser_module): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + mocker.patch("carta.browser.Session", return_value=session) + + browser.new_session_from_url( + "http://localhost:3000?token=x", + check_connection=False, + ) + + session._check_connection.assert_not_called() + + +def test_new_session_from_url_closes_browser_and_preserves_validation_error( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + session._check_connection.side_effect = util_module.CartaUnsupportedVersion("bad version") + mocker.patch("carta.browser.Session", return_value=session) + + with pytest.raises(util_module.CartaUnsupportedVersion): + browser.new_session_from_url( + "http://localhost:3000?token=x", + check_connection=True, + ) + + assert browser.driver.closed is True + + +def test_new_session_from_url_preserves_validation_error_when_close_fails( + mocker, browser_module, util_module +): + close_error = RuntimeError("close failed") + browser = make_browser(browser_module.Browser, close_error=close_error) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + session._check_connection.side_effect = util_module.CartaUnsupportedVersion("bad version") + mocker.patch("carta.browser.Session", return_value=session) + + with pytest.raises(util_module.CartaUnsupportedVersion): + browser.new_session_from_url( + "http://localhost:3000?token=x", + check_connection=True, + ) + + +def test_new_session_with_backend_stops_backend_when_session_creation_fails( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock( + frontend_url="http://localhost:3000", + token="token", + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + mocker.patch("carta.browser.Backend", return_value=backend) + mocker.patch.object( + browser, + "new_session_from_url", + side_effect=util_module.CartaBadSession("startup failed"), + ) + + with pytest.raises(util_module.CartaBadSession): + browser.new_session_with_backend(check_connection=True) + + backend.stop.assert_called_once_with() + + +def test_new_session_with_backend_stops_backend_when_frontend_url_missing( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock(frontend_url=None, errors=[]) + backend.start.return_value = True + mocker.patch("carta.browser.Backend", return_value=backend) + + with pytest.raises(util_module.CartaBadSession): + browser.new_session_with_backend() + + backend.stop.assert_called_once_with() diff --git a/tests/test_session.py b/tests/test_session.py index 91e6a96..3096a13 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,16 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt +from carta.session import Session +from carta.util import ( + CartaActionFailed, + CartaBadSession, + CartaUnsupportedVersion, + CartaValidationFailed, + Macro, + Point as Pt, + carta_version_meets_minimum, +) from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -33,6 +42,7 @@ def method(session, mock_method): ("wcs", "SessionWCSOverlay"), ]) def test_subobjects(session, name, classname): + assert isinstance(session, Session) assert getattr(session, name).__class__.__name__ == classname @@ -79,6 +89,207 @@ def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" + +@pytest.mark.parametrize("version", [ + "6.0.0", + "6.1.0", + "7.0.0", + "6.0.0-dev", + "6.1.0-dev", + "7.0.0-dev", + "6.0.0-beta.1", + "6.0.0-rc.1", +]) +def test_carta_version_meets_minimum_accepts_supported_versions(version): + assert carta_version_meets_minimum(version, "6.0.0") + + +@pytest.mark.parametrize("version", [ + "6.9.9", + "6.9.9-dev", + "6.9.9-beta.1", + "bad.version", +]) +def test_carta_version_meets_minimum_rejects_unsupported_versions(version): + assert not carta_version_meets_minimum(version, "7.0.0") + + +def test_require_carta_version_returns_detected_version(session, get_value): + get_value.return_value = "6.1.0-dev" + + assert session.require_carta_version("6.0.0", feature="test feature") == "6.1.0-dev" + + +def test_require_carta_version_raises_for_unsupported_version(session, get_value): + get_value.return_value = "6.9.9" + + with pytest.raises(CartaUnsupportedVersion) as e: + session.require_carta_version("7.0.0", feature="test feature") + + message = str(e.value) + assert "test feature" in message + assert "6.9.9" in message + assert "7.0.0" in message + + +def test_direct_session_construction_does_not_check_connection(mocker): + check_connection = mocker.patch.object(Session, "_check_connection") + + session = Session(0, None) + + assert session.session_id == 0 + check_connection.assert_not_called() + + +def test_check_connection_fetches_frontend_version_with_timeout(session, call_action): + call_action.return_value = "6.0.0-dev" + + assert session._check_connection(timeout=3) == "6.0.0-dev" + + call_action.assert_called_once_with( + "fetchParameter", + Macro("", "frontendVersion"), + response_expected=True, + timeout=3, + ) + assert session.carta_version == "6.0.0-dev" + + +def test_check_connection_wraps_frontend_version_failure(session, call_action, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + call_action.side_effect = CartaActionFailed("frontendVersion unavailable") + + with pytest.raises(CartaBadSession) as e: + session._check_connection(timeout=2) + + message = str(e.value) + assert "Could not validate CARTA session 0" in message + assert "http://localhost:3000" in message + assert "2" in message + assert "CartaActionFailed" in message + assert "frontendVersion unavailable" in message + assert "--enable_scripting" in message + assert "6.0.0-beta.1" in message + + +def test_interact_checks_connection_by_default(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + check_connection = mocker.patch.object(Session, "_check_connection") + + session = Session.interact("http://localhost:3000?token=x", "123") + + assert session.session_id == 123 + assert session._protocol is protocol + check_connection.assert_called_once_with(timeout=10) + + +def test_interact_passes_connection_check_timeout(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + check_connection = mocker.patch.object(Session, "_check_connection") + + Session.interact( + "http://localhost:3000?token=x", + 123, + connection_check_timeout=4, + ) + + check_connection.assert_called_once_with(timeout=4) + + +def test_interact_can_skip_connection_check(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + check_connection = mocker.patch.object(Session, "_check_connection") + + Session.interact( + "http://localhost:3000?token=x", + 123, + check_connection=False, + ) + + check_connection.assert_not_called() + + +def test_start_and_interact_stops_backend_when_connection_check_fails(mocker): + backend = mocker.Mock( + frontend_url="http://localhost:3000", + last_session_id=123, + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + mocker.patch("carta.session.Backend", return_value=backend) + mocker.patch.object( + Session, + "interact", + side_effect=CartaBadSession("startup check failed"), + ) + + with pytest.raises(CartaBadSession): + Session.start_and_interact() + + backend.stop.assert_called_once_with() + + +def test_create_passes_connection_check_options_to_browser(mocker): + browser = mocker.Mock() + expected_session = mocker.Mock() + browser.new_session_from_url.return_value = expected_session + + result = Session.create( + browser, + "http://localhost:3000?token=x", + token="token", + timeout=7, + debug_no_auth=True, + check_connection=False, + connection_check_timeout=4, + ) + + assert result is expected_session + browser.new_session_from_url.assert_called_once_with( + "http://localhost:3000?token=x", + "token", + backend=None, + timeout=7, + debug_no_auth=True, + check_connection=False, + connection_check_timeout=4, + ) + + +def test_start_and_create_passes_connection_check_options_to_browser(mocker): + browser = mocker.Mock() + expected_session = mocker.Mock() + browser.new_session_with_backend.return_value = expected_session + + result = Session.start_and_create( + browser, + executable_path="carta-custom", + remote_host="remote", + params=("--verbosity", "5"), + timeout=7, + token="token", + frontend_url_timeout=8, + check_connection=False, + connection_check_timeout=4, + ) + + assert result is expected_session + browser.new_session_with_backend.assert_called_once_with( + "carta-custom", + "remote", + ("--verbosity", "5"), + 7, + "token", + 8, + check_connection=False, + connection_check_timeout=4, + ) + + # PATHS From 5a7a92453c107b1da003075ea862dcdd6b9e253d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 28 Apr 2026 12:56:46 +0800 Subject: [PATCH 072/123] Update uv sync command to include all extras in contributor setup instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db279f4..e20036c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Alternative contributor setup with uv If you prefer a synced local development environment, `uv` is also supported: ``` -uv sync +uv sync --all-extras ``` This creates a local virtual environment, installs the package in editable mode, and syncs the default development groups. From d421fe91023962b2121f85733cd45b277fd4a750 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 28 Apr 2026 13:22:33 +0800 Subject: [PATCH 073/123] Simplify __repr__ method by removing manual cache checking and relying on carta_version property's built-in caching --- carta/session.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/carta/session.py b/carta/session.py index c037bd1..e10a50f 100644 --- a/carta/session.py +++ b/carta/session.py @@ -236,15 +236,10 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa def __repr__(self): """A human-readable representation of this session object.""" uri = self._protocol.frontend_url if self._protocol else None - cache = getattr(self, "_cache", {}) - - if "carta_version" in cache: - version = cache["carta_version"] - else: - try: - version = self.carta_version - except (AttributeError, CartaScriptingException): - return f"Session(session_id={self.session_id}, uri={uri!r})" + try: + version = self.carta_version + except (AttributeError, CartaScriptingException): + return f"Session(session_id={self.session_id}, uri={uri!r})" return ( f"Session(session_id={self.session_id}, uri={uri!r}, " From 9b3b423c3a8d1e2b63c174aa474a3ff0f5dd67f9 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 30 Apr 2026 15:48:11 +0800 Subject: [PATCH 074/123] Add carta_version_requirement parameter to session creation methods to support user-specified CARTA version validation with flexible comparison operators --- carta/browser.py | 40 +++++++++--- carta/session.py | 107 +++++++++++++++++++++--------- carta/util.py | 76 ++++++++++++++++++---- docs/source/quickstart.rst | 19 ++++++ tests/test_browser.py | 76 ++++++++++++++++++++-- tests/test_session.py | 129 +++++++++++++++++++++++++------------ tests/test_util.py | 64 +++++++++++++++++- 7 files changed, 414 insertions(+), 97 deletions(-) diff --git a/carta/browser.py b/carta/browser.py index 741f8dd..7771de2 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -34,7 +34,7 @@ class Browser: def __init__(self, driver_class, **kwargs): self.driver = driver_class(**kwargs) - def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10): + def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): """Create a new session by connecting to an existing backend. You can use :obj:`carta.session.Session.create`, which wraps this method. @@ -54,10 +54,16 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 check_connection : boolean, optional Whether to validate the session by reading the CARTA frontend version through the scripting interface before returning. Default: - ``True``. Set to ``False`` to skip startup scripting calls. + ``True``. Set to ``False`` to skip startup scripting calls unless + ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. + carta_version_requirement : string, optional + A CARTA version requirement string. If provided, the session + frontend version is checked before this method returns, even when + ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- @@ -73,8 +79,10 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend reports a version below the - minimum supported CARTA version. + If the connected CARTA frontend does not satisfy the internal + minimum version requirement or the user-specified requirement. + CartaValidationFailed + If the user-specified requirement string cannot be parsed. """ protocol = Protocol(frontend_url, token, debug_no_auth=debug_no_auth) @@ -108,15 +116,18 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 self.exit(f"Could not parse session ID from frontend. Last error: {last_error}") session = Session(session_id, protocol, browser=self, backend=backend) - if check_connection: + if check_connection or carta_version_requirement is not None: try: - session._check_connection(timeout=connection_check_timeout) + session._validate_session( + timeout=connection_check_timeout, + carta_version_requirement=carta_version_requirement, + ) except Exception: self._close_after_error() raise return session - def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10): + def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): """Create a new session after launching a new backend process. You can use :obj:`carta.session.Session.start_and_create`, which wraps this method. This method starts a backend process, parses the frontend URL from the output, and calls :obj:`carta.browser.Browser.new_session_from_url`. @@ -138,10 +149,16 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa check_connection : boolean, optional Whether to validate the session by reading the CARTA frontend version through the scripting interface before returning. Default: - ``True``. Set to ``False`` to skip startup scripting calls. + ``True``. Set to ``False`` to skip startup scripting calls unless + ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. + carta_version_requirement : string, optional + A CARTA version requirement string. If provided, the session + frontend version is checked before this method returns, even when + ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- @@ -157,8 +174,10 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend reports a version below the - minimum supported CARTA version. + If the connected CARTA frontend does not satisfy the internal + minimum version requirement or the user-specified requirement. + CartaValidationFailed + If the user-specified requirement string cannot be parsed. """ backend = Backend(("--no_browser", "--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout=frontend_url_timeout, session_creation_timeout=0) @@ -178,6 +197,7 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa debug_no_auth=backend.debug_no_auth, check_connection=check_connection, connection_check_timeout=connection_check_timeout, + carta_version_requirement=carta_version_requirement, ) except Exception: backend.stop() diff --git a/carta/session.py b/carta/session.py index 6d5cc7e..20dea0b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaUnsupportedVersion, cached, carta_version_meets_minimum, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaUnsupportedVersion, cached, carta_version_satisfies_requirement, _parse_carta_version_requirement, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -78,7 +78,7 @@ def __del__(self): self.close() @classmethod - def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, check_connection=True, connection_check_timeout=10): + def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): """Interact with an existing CARTA frontend session. Parameters @@ -96,10 +96,16 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac check_connection : boolean, optional Whether to validate the session by reading the CARTA frontend version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls. + Default: ``True``. Set to ``False`` to skip startup scripting calls + unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. + carta_version_requirement : string, optional + A CARTA version requirement string. If provided, the session + frontend version is checked before this method returns, even when + ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- @@ -117,8 +123,10 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend reports a version below the - minimum supported CARTA version. + If the connected CARTA frontend does not satisfy the internal + minimum version requirement or the user-specified requirement. + CartaValidationFailed + If the user-specified requirement string cannot be parsed. """ try: session_id = int(session_id) @@ -130,12 +138,15 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac Protocol(frontend_url, token, debug_no_auth=debug_no_auth), backend=backend, ) - if check_connection: - session._check_connection(timeout=connection_check_timeout) + if check_connection or carta_version_requirement is not None: + session._validate_session( + timeout=connection_check_timeout, + carta_version_requirement=carta_version_requirement, + ) return session @classmethod - def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, check_connection=True, connection_check_timeout=10): + def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): """Start a new CARTA backend instance and interact with the default CARTA frontend session which is created automatically in the user's browser. This method cannot be used with a CARTA controller instance. Parameters @@ -155,10 +166,16 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu check_connection : boolean, optional Whether to validate the session by reading the CARTA frontend version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls. + Default: ``True``. Set to ``False`` to skip startup scripting calls + unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. + carta_version_requirement : string, optional + A CARTA version requirement string. If provided, the session + frontend version is checked before this method returns, even when + ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- @@ -176,8 +193,10 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend reports a version below the - minimum supported CARTA version. + If the connected CARTA frontend does not satisfy the internal + minimum version requirement or the user-specified requirement. + CartaValidationFailed + If the user-specified requirement string cannot be parsed. """ backend = Backend(("--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout, session_creation_timeout) if not backend.start(): @@ -200,13 +219,14 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu backend, check_connection=check_connection, connection_check_timeout=connection_check_timeout, + carta_version_requirement=carta_version_requirement, ) except Exception: backend.stop() raise @classmethod - def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10): + def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): """Connect to an existing CARTA backend or CARTA controller instance and create a new session. Parameters @@ -224,10 +244,16 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal check_connection : boolean, optional Whether to validate the session by reading the CARTA frontend version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls. + Default: ``True``. Set to ``False`` to skip startup scripting calls + unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. + carta_version_requirement : string, optional + A CARTA version requirement string. If provided, the session + frontend version is checked before this method returns, even when + ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- @@ -243,8 +269,10 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend reports a version below the - minimum supported CARTA version. + If the connected CARTA frontend does not satisfy the internal + minimum version requirement or the user-specified requirement. + CartaValidationFailed + If the user-specified requirement string cannot be parsed. """ return browser.new_session_from_url( frontend_url, @@ -254,10 +282,11 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal debug_no_auth=debug_no_auth, check_connection=check_connection, connection_check_timeout=connection_check_timeout, + carta_version_requirement=carta_version_requirement, ) @classmethod - def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10): + def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): """Start a new CARTA backend instance and create a new session. This method cannot be used with a CARTA controller instance (which already starts and stops backend instances for the user on demand). Parameters @@ -279,10 +308,16 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa check_connection : boolean, optional Whether to validate the session by reading the CARTA frontend version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls. + Default: ``True``. Set to ``False`` to skip startup scripting calls + unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. + carta_version_requirement : string, optional + A CARTA version requirement string. If provided, the session + frontend version is checked before this method returns, even when + ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- @@ -298,8 +333,10 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend reports a version below the - minimum supported CARTA version. + If the connected CARTA frontend does not satisfy the internal + minimum version requirement or the user-specified requirement. + CartaValidationFailed + If the user-specified requirement string cannot be parsed. """ return browser.new_session_with_backend( executable_path, @@ -310,6 +347,7 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa frontend_url_timeout, check_connection=check_connection, connection_check_timeout=connection_check_timeout, + carta_version_requirement=carta_version_requirement, ) def __repr__(self): @@ -339,13 +377,14 @@ def carta_version(self): """ return self.get_value("frontendVersion") - def require_carta_version(self, minimum_version, feature=None): - """Require this session to be connected to a supported CARTA version. + def require_carta_version(self, carta_version_requirement, feature=None): + """Require this session to satisfy a CARTA version requirement. Parameters ---------- - minimum_version : string - The minimum CARTA release version required. + carta_version_requirement : string + A CARTA version requirement string. Examples: ``">=6.1.0"``, + ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. feature : string, optional The feature requiring this version. Used in error messages. @@ -357,17 +396,19 @@ def require_carta_version(self, minimum_version, feature=None): Raises ------ CartaUnsupportedVersion - If the detected CARTA version is below the minimum or cannot be - parsed. + If the frontend-reported CARTA version cannot be parsed or does + not satisfy the requirement. + CartaValidationFailed + If the user-supplied requirement string cannot be parsed. """ version = self.carta_version - if carta_version_meets_minimum(version, minimum_version): + if carta_version_satisfies_requirement(version, carta_version_requirement): return version feature_text = f" for {feature}" if feature else "" raise CartaUnsupportedVersion( f"CARTA version {version!r} is not supported{feature_text}. " - f"Required minimum version is {minimum_version!r}." + f"Required version is {carta_version_requirement!r}." ) def _fetch_frontend_version(self, timeout=None): @@ -386,7 +427,10 @@ def _cache_carta_version(self, version): self._cache = {} self._cache["carta_version"] = version - def _check_connection(self, timeout=10): + def _validate_session(self, timeout=10, carta_version_requirement=None): + if carta_version_requirement is not None: + _parse_carta_version_requirement(carta_version_requirement) + try: version = self._fetch_frontend_version(timeout=timeout) except CartaScriptingException as e: @@ -396,9 +440,14 @@ def _check_connection(self, timeout=10): self._cache_carta_version(version) self.require_carta_version( - MINIMUM_CARTA_VERSION, + f">={MINIMUM_CARTA_VERSION}", feature="carta-python session startup", ) + if carta_version_requirement is not None: + self.require_carta_version( + carta_version_requirement, + feature="user-specified CARTA version requirement", + ) return version def _connection_check_error_message(self, error, timeout): diff --git a/carta/util.py b/carta/util.py index 89b6382..7d5d5e6 100644 --- a/carta/util.py +++ b/carta/util.py @@ -38,7 +38,7 @@ class CartaBadUrl(CartaScriptingException): class CartaValidationFailed(CartaScriptingException): - """Invalid parameters were passed to a function with a :obj:`carta.validation.validate` decorator.""" + """A user-supplied parameter or requirement is invalid.""" pass @@ -172,32 +172,84 @@ def parse_carta_version(version): return (int(major), int(minor), int(patch)) -def carta_version_meets_minimum(version, minimum_version): - """Check whether a CARTA version meets a minimum version requirement. +def _parse_carta_version_requirement(requirement): + """Parse a CARTA version requirement string. + + The requirement is a comma-separated list of comparison clauses such as + ``">=6.1.0"``, ``"<7.0.0"`` or ``"==6.2.0"``. + """ + if not isinstance(requirement, str) or not requirement.strip(): + raise CartaValidationFailed( + "CARTA version requirement must be a non-empty string." + ) + + clauses = [] + for clause in requirement.split(","): + clause = clause.strip() + match = re.fullmatch(r"(>=|<=|==|>|<)\s*(.+)", clause) + if not match: + raise CartaValidationFailed( + f"Invalid CARTA version requirement clause {clause!r}. " + "Expected an operator (>=, >, <=, <, ==) followed by a " + "MAJOR.MINOR.PATCH version." + ) + + operator, required_version = match.groups() + required_base = parse_carta_version(required_version) + if required_base is None: + raise CartaValidationFailed( + f"Invalid CARTA version {required_version!r} in requirement " + f"{requirement!r}." + ) + + clauses.append((operator, required_base)) + + return clauses + + +def carta_version_satisfies_requirement(version, requirement): + """Check whether a CARTA version satisfies a version requirement. Only the ``MAJOR.MINOR.PATCH`` components are compared; any prerelease - suffix is ignored. + suffix is ignored. The requirement is a comma-separated list of comparison + clauses, all of which must be satisfied. Parameters ---------- version : string The CARTA version string to check. - minimum_version : string - The minimum CARTA version string required. + requirement : string + A comma-separated requirement string such as ``">=6.1.0"``, + ``"==6.2.0"`` or ``">=6.1.0,<7.0.0"``. Returns ------- boolean - ``True`` if ``version`` meets the minimum, ``False`` otherwise - (including when either argument cannot be parsed). + ``True`` if ``version`` satisfies the requirement, ``False`` if the + version cannot be parsed or does not satisfy every clause. + + Raises + ------ + CartaValidationFailed + If the requirement string cannot be parsed. """ + parsed_requirement = _parse_carta_version_requirement(requirement) + + checks = { + ">=": lambda left, right: left >= right, + ">": lambda left, right: left > right, + "<=": lambda left, right: left <= right, + "<": lambda left, right: left < right, + "==": lambda left, right: left == right, + } version_base = parse_carta_version(version) - minimum_base = parse_carta_version(minimum_version) - - if version_base is None or minimum_base is None: + if version_base is None: return False - return version_base >= minimum_base + return all( + checks[operator](version_base, required_base) + for operator, required_base in parsed_requirement + ) class BasePathMixin: diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 8b54a3f..96d8c2e 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -32,6 +32,12 @@ You need access either to a CARTA backend executable, on the local host or on a prerelease builds such as ``6.0.0-beta.1`` do not expose ``frontendVersion`` and therefore fail this validation. + Scripts can also require a more specific CARTA frontend version when + creating a session. Pass ``carta_version_requirement`` with one or more + comma-separated clauses such as ``">=6.1.0"``, ``"<=6.2.0"``, + ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. This check runs before the + session object is returned, even if ``check_connection=False``. + If session creation fails during startup validation, check that the frontend URL is reachable, the session ID is correct, the token is valid, and the backend was started with ``--enable_scripting``. A failure while reading @@ -54,6 +60,12 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess from carta.token import BackendToken session = Session.interact("FRONTEND URL", 123456, BackendToken("SECURITY TOKEN")) + session = Session.interact( + "FRONTEND URL", + 123456, + BackendToken("SECURITY TOKEN"), + carta_version_requirement=">=6.1.0,<7.0.0", + ) If you have launched a backend directly, the frontend URL and security token must match your running backend process. You have the option of using an environment variable, ``CARTA_AUTH_TOKEN``, to run CARTA with a fixed security token. Otherwise, a randomly generated token will be printed by the backend when it starts. If you include the security token in the URL, you may omit the security token parameter (it will be parsed from the URL automatically): @@ -108,6 +120,7 @@ Creating a new interactive session # New session, start local backend session = Session.start_and_interact() + session = Session.start_and_interact(carta_version_requirement="==6.1.0") # New session, start remote backend session = Session.start_and_interact(remote_host="REMOTE HOSTNAME OR IP") @@ -144,6 +157,12 @@ Use the ``create`` method if you want to write a non-interactive script which st # New session, connect to an existing backend session = Session.create(Chrome(), "FRONTEND URL", BackendToken("SECURITY TOKEN")) + session = Session.create( + Chrome(), + "FRONTEND URL", + BackendToken("SECURITY TOKEN"), + carta_version_requirement=">=6.1.0,<7.0.0", + ) # New session, start local backend session = Session.start_and_create(Chrome()) diff --git a/tests/test_browser.py b/tests/test_browser.py index 209fe47..3c398de 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -72,7 +72,10 @@ def test_new_session_from_url_runs_connection_check_after_parsing_session_id( assert result is session session_class.assert_called_once_with(123, protocol, browser=browser, backend=None) - session._check_connection.assert_called_once_with(timeout=5) + session._validate_session.assert_called_once_with( + timeout=5, + carta_version_requirement=None, + ) assert browser.driver.closed is False @@ -88,7 +91,28 @@ def test_new_session_from_url_skips_connection_check(mocker, browser_module): check_connection=False, ) - session._check_connection.assert_not_called() + session._validate_session.assert_not_called() + + +def test_new_session_from_url_checks_connection_when_requirement_is_provided( + mocker, browser_module +): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + mocker.patch("carta.browser.Session", return_value=session) + + browser.new_session_from_url( + "http://localhost:3000?token=x", + check_connection=False, + carta_version_requirement="<=6.2.0", + ) + + session._validate_session.assert_called_once_with( + timeout=10, + carta_version_requirement="<=6.2.0", + ) def test_new_session_from_url_closes_browser_and_preserves_validation_error( @@ -98,7 +122,7 @@ def test_new_session_from_url_closes_browser_and_preserves_validation_error( protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") mocker.patch("carta.browser.Protocol", return_value=protocol) session = mocker.Mock() - session._check_connection.side_effect = util_module.CartaUnsupportedVersion("bad version") + session._validate_session.side_effect = util_module.CartaUnsupportedVersion("bad version") mocker.patch("carta.browser.Session", return_value=session) with pytest.raises(util_module.CartaUnsupportedVersion): @@ -118,7 +142,7 @@ def test_new_session_from_url_preserves_validation_error_when_close_fails( protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") mocker.patch("carta.browser.Protocol", return_value=protocol) session = mocker.Mock() - session._check_connection.side_effect = util_module.CartaUnsupportedVersion("bad version") + session._validate_session.side_effect = util_module.CartaUnsupportedVersion("bad version") mocker.patch("carta.browser.Session", return_value=session) with pytest.raises(util_module.CartaUnsupportedVersion): @@ -150,6 +174,50 @@ def test_new_session_with_backend_stops_backend_when_session_creation_fails( browser.new_session_with_backend(check_connection=True) backend.stop.assert_called_once_with() + browser.new_session_from_url.assert_called_once_with( + "http://localhost:3000", + "token", + backend=backend, + timeout=10, + debug_no_auth=False, + check_connection=True, + connection_check_timeout=10, + carta_version_requirement=None, + ) + + +def test_new_session_with_backend_passes_carta_version_requirement( + mocker, browser_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock( + frontend_url="http://localhost:3000", + token="token", + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + mocker.patch("carta.browser.Backend", return_value=backend) + session = mocker.Mock() + new_session = mocker.patch.object(browser, "new_session_from_url", return_value=session) + + result = browser.new_session_with_backend( + check_connection=False, + connection_check_timeout=4, + carta_version_requirement="==6.1.0", + ) + + assert result is session + new_session.assert_called_once_with( + "http://localhost:3000", + "token", + backend=backend, + timeout=10, + debug_no_auth=False, + check_connection=False, + connection_check_timeout=4, + carta_version_requirement="==6.1.0", + ) def test_new_session_with_backend_stops_backend_when_frontend_url_missing( diff --git a/tests/test_session.py b/tests/test_session.py index 3096a13..979f0ec 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -12,7 +12,6 @@ CartaValidationFailed, Macro, Point as Pt, - carta_version_meets_minimum, ) from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol @@ -90,61 +89,44 @@ def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" -@pytest.mark.parametrize("version", [ - "6.0.0", - "6.1.0", - "7.0.0", - "6.0.0-dev", - "6.1.0-dev", - "7.0.0-dev", - "6.0.0-beta.1", - "6.0.0-rc.1", -]) -def test_carta_version_meets_minimum_accepts_supported_versions(version): - assert carta_version_meets_minimum(version, "6.0.0") - - -@pytest.mark.parametrize("version", [ - "6.9.9", - "6.9.9-dev", - "6.9.9-beta.1", - "bad.version", -]) -def test_carta_version_meets_minimum_rejects_unsupported_versions(version): - assert not carta_version_meets_minimum(version, "7.0.0") - - def test_require_carta_version_returns_detected_version(session, get_value): get_value.return_value = "6.1.0-dev" - assert session.require_carta_version("6.0.0", feature="test feature") == "6.1.0-dev" + assert session.require_carta_version(">=6.0.0", feature="test feature") == "6.1.0-dev" def test_require_carta_version_raises_for_unsupported_version(session, get_value): get_value.return_value = "6.9.9" with pytest.raises(CartaUnsupportedVersion) as e: - session.require_carta_version("7.0.0", feature="test feature") + session.require_carta_version(">=7.0.0", feature="test feature") message = str(e.value) assert "test feature" in message assert "6.9.9" in message - assert "7.0.0" in message + assert ">=7.0.0" in message + + +def test_require_carta_version_raises_for_invalid_requirement(session, get_value): + get_value.return_value = "6.1.0" + with pytest.raises(CartaValidationFailed): + session.require_carta_version("6.1.0") -def test_direct_session_construction_does_not_check_connection(mocker): - check_connection = mocker.patch.object(Session, "_check_connection") + +def test_direct_session_construction_does_not_validate_session(mocker): + validate_session = mocker.patch.object(Session, "_validate_session") session = Session(0, None) assert session.session_id == 0 - check_connection.assert_not_called() + validate_session.assert_not_called() -def test_check_connection_fetches_frontend_version_with_timeout(session, call_action): +def test_validate_session_fetches_frontend_version_with_timeout(session, call_action): call_action.return_value = "6.0.0-dev" - assert session._check_connection(timeout=3) == "6.0.0-dev" + assert session._validate_session(timeout=3) == "6.0.0-dev" call_action.assert_called_once_with( "fetchParameter", @@ -155,12 +137,49 @@ def test_check_connection_fetches_frontend_version_with_timeout(session, call_ac assert session.carta_version == "6.0.0-dev" -def test_check_connection_wraps_frontend_version_failure(session, call_action, mocker): +def test_validate_session_applies_user_requirement(session, call_action): + call_action.return_value = "6.1.0-dev" + + assert ( + session._validate_session( + timeout=3, + carta_version_requirement=">=6.1.0,<7.0.0", + ) + == "6.1.0-dev" + ) + + +def test_validate_session_raises_for_unsatisfied_user_requirement(session, call_action): + call_action.return_value = "6.1.0" + + with pytest.raises(CartaUnsupportedVersion) as e: + session._validate_session( + timeout=3, + carta_version_requirement="==6.2.0", + ) + + message = str(e.value) + assert "6.1.0" in message + assert "==6.2.0" in message + assert "user-specified CARTA version requirement" in message + + +def test_validate_session_validates_requirement_before_fetching_version(session, call_action): + with pytest.raises(CartaValidationFailed): + session._validate_session( + timeout=3, + carta_version_requirement="6.1.0", + ) + + call_action.assert_not_called() + + +def test_validate_session_wraps_frontend_version_failure(session, call_action, mocker): session._protocol = mocker.Mock(frontend_url="http://localhost:3000") call_action.side_effect = CartaActionFailed("frontendVersion unavailable") with pytest.raises(CartaBadSession) as e: - session._check_connection(timeout=2) + session._validate_session(timeout=2) message = str(e.value) assert "Could not validate CARTA session 0" in message @@ -175,19 +194,22 @@ def test_check_connection_wraps_frontend_version_failure(session, call_action, m def test_interact_checks_connection_by_default(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) - check_connection = mocker.patch.object(Session, "_check_connection") + validate_session = mocker.patch.object(Session, "_validate_session") session = Session.interact("http://localhost:3000?token=x", "123") assert session.session_id == 123 assert session._protocol is protocol - check_connection.assert_called_once_with(timeout=10) + validate_session.assert_called_once_with( + timeout=10, + carta_version_requirement=None, + ) def test_interact_passes_connection_check_timeout(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) - check_connection = mocker.patch.object(Session, "_check_connection") + validate_session = mocker.patch.object(Session, "_validate_session") Session.interact( "http://localhost:3000?token=x", @@ -195,13 +217,16 @@ def test_interact_passes_connection_check_timeout(mocker): connection_check_timeout=4, ) - check_connection.assert_called_once_with(timeout=4) + validate_session.assert_called_once_with( + timeout=4, + carta_version_requirement=None, + ) def test_interact_can_skip_connection_check(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) - check_connection = mocker.patch.object(Session, "_check_connection") + validate_session = mocker.patch.object(Session, "_validate_session") Session.interact( "http://localhost:3000?token=x", @@ -209,7 +234,25 @@ def test_interact_can_skip_connection_check(mocker): check_connection=False, ) - check_connection.assert_not_called() + validate_session.assert_not_called() + + +def test_interact_checks_connection_when_requirement_is_provided(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + validate_session = mocker.patch.object(Session, "_validate_session") + + Session.interact( + "http://localhost:3000?token=x", + 123, + check_connection=False, + carta_version_requirement="==6.1.0", + ) + + validate_session.assert_called_once_with( + timeout=10, + carta_version_requirement="==6.1.0", + ) def test_start_and_interact_stops_backend_when_connection_check_fails(mocker): @@ -246,6 +289,7 @@ def test_create_passes_connection_check_options_to_browser(mocker): debug_no_auth=True, check_connection=False, connection_check_timeout=4, + carta_version_requirement=">=6.1.0,<7.0.0", ) assert result is expected_session @@ -257,6 +301,7 @@ def test_create_passes_connection_check_options_to_browser(mocker): debug_no_auth=True, check_connection=False, connection_check_timeout=4, + carta_version_requirement=">=6.1.0,<7.0.0", ) @@ -275,6 +320,7 @@ def test_start_and_create_passes_connection_check_options_to_browser(mocker): frontend_url_timeout=8, check_connection=False, connection_check_timeout=4, + carta_version_requirement=">=6.1.0,<7.0.0", ) assert result is expected_session @@ -287,6 +333,7 @@ def test_start_and_create_passes_connection_check_options_to_browser(mocker): 8, check_connection=False, connection_check_timeout=4, + carta_version_requirement=">=6.1.0,<7.0.0", ) diff --git a/tests/test_util.py b/tests/test_util.py index 5bb8b3f..b3d0dad 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,10 @@ -from carta.util import Point as Pt +import pytest + +from carta.util import ( + CartaValidationFailed, + Point as Pt, + carta_version_satisfies_requirement, +) def test_point_equality(): @@ -34,3 +40,59 @@ def test_point_json(): def test_point_tuple(): assert Pt(1, 2).as_tuple() == (1, 2) assert Pt("1", "2").as_tuple() == ("1", "2") + + +@pytest.mark.parametrize("requirement", [ + ">=6.1.0", + ">6.0.0", + "<=6.1.0", + "<6.2.0", + "==6.1.0", + ">=6.0.0,<7.0.0", + " >= 6.0.0 , < 7.0.0 ", +]) +def test_carta_version_satisfies_requirement_accepts_matching_versions(requirement): + assert carta_version_satisfies_requirement("6.1.0", requirement) + + +@pytest.mark.parametrize("requirement", [ + ">=6.2.0", + ">6.1.0", + "<=6.0.0", + "<6.1.0", + "==6.2.0", + ">=6.0.0,<6.1.0", +]) +def test_carta_version_satisfies_requirement_rejects_nonmatching_versions(requirement): + assert not carta_version_satisfies_requirement("6.1.0", requirement) + + +@pytest.mark.parametrize("version", [ + "6.1.0-dev", + "6.1.0-beta.1", + "6.1.0-rc.1", +]) +def test_carta_version_satisfies_requirement_ignores_suffix(version): + assert carta_version_satisfies_requirement(version, "==6.1.0") + + +def test_carta_version_satisfies_requirement_rejects_bad_detected_version(): + assert not carta_version_satisfies_requirement("bad.version", ">=6.1.0") + + +def test_carta_version_satisfies_requirement_validates_requirement_first(): + with pytest.raises(CartaValidationFailed): + carta_version_satisfies_requirement("bad.version", "6.1.0") + + +@pytest.mark.parametrize("requirement", [ + "", + "6.1.0", + "=6.1.0", + "!=6.1.0", + ">=bad.version", + ">=6.1.0,", +]) +def test_carta_version_satisfies_requirement_raises_for_bad_requirement(requirement): + with pytest.raises(CartaValidationFailed): + carta_version_satisfies_requirement("6.1.0", requirement) From 8a5e6f2fd7dcc5be2a154faeed93e2da3ee61fcc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 4 May 2026 14:02:57 +0800 Subject: [PATCH 075/123] Rename colorblending module to color_blending and update all references to use snake_case naming convention --- carta/{colorblending.py => color_blending.py} | 28 +- carta/image_base.py | 2 +- carta/session.py | 10 +- docs/source/carta.rst | 7 +- docs/source/quickstart.rst | 4 +- ...olorblending.py => test_color_blending.py} | 274 +++++++++--------- tests/test_session.py | 2 +- 7 files changed, 163 insertions(+), 164 deletions(-) rename carta/{colorblending.py => color_blending.py} (94%) rename tests/{test_colorblending.py => test_color_blending.py} (63%) diff --git a/carta/colorblending.py b/carta/color_blending.py similarity index 94% rename from carta/colorblending.py rename to carta/color_blending.py index aab8f84..b1d20a1 100644 --- a/carta/colorblending.py +++ b/carta/color_blending.py @@ -21,14 +21,14 @@ class Layer(BasePathMixin): Parameters ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` + color_blending : :obj:`carta.color_blending.ColorBlending` The color blending object. layer_id : integer The layer ID. Attributes ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` + color_blending : :obj:`carta.color_blending.ColorBlending` The color blending object. layer_id : integer The layer ID. @@ -36,32 +36,32 @@ class Layer(BasePathMixin): The session object associated with this layer. """ - def __init__(self, colorblending, layer_id): - self.colorblending = colorblending + def __init__(self, color_blending, layer_id): + self.color_blending = color_blending self.layer_id = layer_id - self.session = colorblending.session + self.session = color_blending.session - self._base_path = f"{self.colorblending._base_path}.frames[{layer_id}]" + self._base_path = f"{self.color_blending._base_path}.frames[{layer_id}]" self._frame = Macro("", self._base_path) @classmethod - def from_list(cls, colorblending, layer_ids): + def from_list(cls, color_blending, layer_ids): """ Create a list of Layer objects from a list of layer IDs. Parameters ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` + color_blending : :obj:`carta.color_blending.ColorBlending` The color blending object. layer_ids : list of integer The layer IDs. Returns ------- - list of :obj:`carta.colorblending.Layer` + list of :obj:`carta.color_blending.Layer` A list of new Layer objects. """ - return [cls(colorblending, layer_id) for layer_id in layer_ids] + return [cls(color_blending, layer_id) for layer_id in layer_ids] @property def image_view_order(self): @@ -70,7 +70,7 @@ def image_view_order(self): This is the position of the underlying frame in the session's image list. A layer does not occupy its own position in the image list; its parent color blending does (see - :obj:`carta.colorblending.ColorBlending.image_view_order`). + :obj:`carta.color_blending.ColorBlending.image_view_order`). Returns ------- @@ -89,7 +89,7 @@ def image_view_order(self): def __repr__(self): """A human-readable representation of this layer.""" cls = type(self).__name__ - cb_id = self.colorblending.color_blending_id + cb_id = self.color_blending.color_blending_id try: order = self.image_view_order @@ -143,7 +143,7 @@ def set_alpha(self, alpha): alpha : {0} The alpha value. """ - self.colorblending.call_action("setAlpha", self.layer_id, alpha) + self.color_blending.call_action("setAlpha", self.layer_id, alpha) @validate(Constant(Colormap), Boolean()) def set_colormap(self, colormap, invert=False): @@ -273,7 +273,7 @@ def layer_list(self): Returns ------- - list of :obj:`carta.colorblending.Layer` + list of :obj:`carta.color_blending.Layer` A list of Layer objects. """ layer_count = self.get_value("frames.length") diff --git a/carta/image_base.py b/carta/image_base.py index 05ed39b..51a19b4 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -1,6 +1,6 @@ """This module contains the shared base class for image-view items (frame-backed images and color blendings). -The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.colorblending.ColorBlending` can share a common protocol without one having to import the other. +The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` can share a common protocol without one having to import the other. """ diff --git a/carta/session.py b/carta/session.py index e10a50f..f6aa220 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,7 +10,7 @@ import posixpath from .image import Image -from .colorblending import ColorBlending +from .color_blending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol @@ -553,7 +553,7 @@ def open_as_color_blending(self, files): Returns ------- - :obj:`carta.colorblending.ColorBlending` + :obj:`carta.color_blending.ColorBlending` The new color blending object. """ images = self.open_images(files, append=False) @@ -632,7 +632,7 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= image_view_order : integer, optional The index of the item in the image list. Returns whichever concrete wrapper (:obj:`carta.image.Image` - or :obj:`carta.colorblending.ColorBlending`) matches the + or :obj:`carta.color_blending.ColorBlending`) matches the entry type at that position. Raises :obj:`NotImplementedError` for any future entry type that is not yet wrapped on the Python side. @@ -723,7 +723,7 @@ def active_image(self): Returns ------- - :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` + :obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending` The currently active image-view item. Raises @@ -753,7 +753,7 @@ def create_color_blending(self): Returns ------- - :obj:`carta.colorblending.ColorBlending` + :obj:`carta.color_blending.ColorBlending` The new color blending object. Raises diff --git a/docs/source/carta.rst b/docs/source/carta.rst index f1e13f5..ab20c1d 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -17,10 +17,10 @@ carta.browser module :undoc-members: :show-inheritance: -carta.colorblending module --------------------------- +carta.color_blending module +--------------------------- -.. automodule:: carta.colorblending +.. automodule:: carta.color_blending :members: :undoc-members: :show-inheritance: @@ -152,4 +152,3 @@ carta.wcs_overlay module :members: :undoc-members: :show-inheritance: - diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 00315b8..52170b8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -189,7 +189,7 @@ Helper methods on the session object open images in the frontend and return imag Inspecting the list of open images ---------------------------------- -The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.colorblending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. +The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. .. figure:: images/image_list.jpg :alt: CARTA frontend image list panel showing frame-backed images and a color blending entry. @@ -200,7 +200,7 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba .. code-block:: python from carta.image import Image - from carta.colorblending import ColorBlending + from carta.color_blending import ColorBlending # All open image-view items, in display order items = session.image_list() diff --git a/tests/test_colorblending.py b/tests/test_color_blending.py similarity index 63% rename from tests/test_colorblending.py rename to tests/test_color_blending.py index f34dd4c..6903ee5 100644 --- a/tests/test_colorblending.py +++ b/tests/test_color_blending.py @@ -1,6 +1,6 @@ import pytest -from carta.colorblending import ColorBlending, Layer +from carta.color_blending import ColorBlending, Layer from carta.constants import Colormap as CM from carta.constants import ColormapSet as CMS from carta.constants import ImageType @@ -12,23 +12,23 @@ @pytest.fixture -def colorblending(session): +def color_blending(session): return ColorBlending(session, 0) @pytest.fixture -def layer(colorblending): - return Layer(colorblending, 1) +def layer(color_blending): + return Layer(color_blending, 1) @pytest.fixture -def cb_get_value(colorblending, mock_get_value): - return mock_get_value(colorblending) +def cb_get_value(color_blending, mock_get_value): + return mock_get_value(color_blending) @pytest.fixture -def cb_call_action(colorblending, mock_call_action): - return mock_call_action(colorblending) +def cb_call_action(color_blending, mock_call_action): + return mock_call_action(color_blending) @pytest.fixture @@ -53,28 +53,28 @@ def session_get_value(session, mock_get_value): @pytest.fixture def cb_property(mock_property): - return mock_property("carta.colorblending.ColorBlending") + return mock_property("carta.color_blending.ColorBlending") @pytest.fixture def layer_property(mock_property): - return mock_property("carta.colorblending.Layer") + return mock_property("carta.color_blending.Layer") # TESTS — Layer -def test_layer_from_list(colorblending): - layers = Layer.from_list(colorblending, [5, 6, 7]) +def test_layer_from_list(color_blending): + layers = Layer.from_list(color_blending, [5, 6, 7]) assert [ly.layer_id for ly in layers] == [5, 6, 7] - assert all(ly.colorblending is colorblending for ly in layers) + assert all(ly.color_blending is color_blending for ly in layers) -def test_layer_repr_healthy(session, colorblending, layer_property, mocker): +def test_layer_repr_healthy(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_id", 42) layer_property("file_name", "layer1.fits") - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " "file_name='layer1.fits')" @@ -83,7 +83,7 @@ def test_layer_repr_healthy(session, colorblending, layer_property, mocker): def test_layer_repr_closed_when_frame_not_in_image_list( - session, colorblending, layer_property, mocker + session, color_blending, layer_property, mocker ): layer_property("file_id", 42) mocker.patch.object( @@ -91,20 +91,20 @@ def test_layer_repr_closed_when_frame_not_in_image_list( "_find_image_view_order", side_effect=RuntimeError("not in image list"), ) - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "[Closed] Layer(image_view_order=None, color_blending_id=0, " "layer_id=3)" ) -def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): +def test_layer_repr_closed_when_frame_is_gone(session, color_blending, mocker): mocker.patch( - "carta.colorblending.Layer.file_id", + "carta.color_blending.Layer.file_id", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("frame is gone"), ) - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "[Closed] Layer(image_view_order=None, color_blending_id=0, " "layer_id=3)" @@ -112,16 +112,16 @@ def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): def test_layer_repr_closed_when_file_name_read_fails( - session, colorblending, layer_property, mocker + session, color_blending, layer_property, mocker ): mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_id", 42) mocker.patch( - "carta.colorblending.Layer.file_name", + "carta.color_blending.Layer.file_name", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("file_name read failed"), ) - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "[Closed] Layer(image_view_order=2, color_blending_id=0, " "layer_id=3)" @@ -138,15 +138,15 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") -def test_layer_image_view_order(session, colorblending, layer_property, mocker): +def test_layer_image_view_order(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=7) layer_property("file_id", 42) - assert Layer(colorblending, 3).image_view_order == 7 + assert Layer(color_blending, 3).image_view_order == 7 find.assert_called_once_with(ImageType.FRAME, 42) def test_layer_image_view_order_raises_when_frame_not_in_image_list( - session, colorblending, layer_property, mocker + session, color_blending, layer_property, mocker ): layer_property("file_id", 42) mocker.patch.object( @@ -155,19 +155,19 @@ def test_layer_image_view_order_raises_when_frame_not_in_image_list( side_effect=RuntimeError("not in image list"), ) with pytest.raises(RuntimeError): - Layer(colorblending, 3).image_view_order + Layer(color_blending, 3).image_view_order @pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) -def test_layer_set_alpha_valid(colorblending, alpha, cb_call_action): - Layer(colorblending, 2).set_alpha(alpha) +def test_layer_set_alpha_valid(color_blending, alpha, cb_call_action): + Layer(color_blending, 2).set_alpha(alpha) cb_call_action.assert_called_with("setAlpha", 2, alpha) @pytest.mark.parametrize("alpha", [-0.1, 1.1]) -def test_layer_set_alpha_invalid(colorblending, alpha): +def test_layer_set_alpha_invalid(color_blending, alpha): with pytest.raises(CartaValidationFailed): - Layer(colorblending, 2).set_alpha(alpha) + Layer(color_blending, 2).set_alpha(alpha) @pytest.mark.parametrize("invert", [True, False]) @@ -187,65 +187,65 @@ def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): # TESTS — ColorBlending basics -def test_colorblending_init(session): - colorblending = ColorBlending(session, 3) - assert colorblending.color_blending_id == 3 +def test_color_blending_init(session): + color_blending = ColorBlending(session, 3) + assert color_blending.color_blending_id == 3 expected = "imageViewConfigStore.colorBlendingImageMap[3]" - assert colorblending._base_path == expected - assert colorblending._frame == Macro( + assert color_blending._base_path == expected + assert color_blending._frame == Macro( "", "imageViewConfigStore.colorBlendingImageMap[3]" ) -def test_colorblending_repr_healthy(session, colorblending, cb_property, mocker): +def test_color_blending_repr_healthy(session, color_blending, cb_property, mocker): mocker.patch.object(session, "call_action", return_value=2) cb_property("file_name", "Color Blending 1") - r = repr(colorblending) + r = repr(color_blending) assert r == ( "ColorBlending(image_view_order=2, color_blending_id=0, " "file_name='Color Blending 1')" ) -def test_colorblending_repr_closed_when_not_in_image_list( - session, colorblending, mocker +def test_color_blending_repr_closed_when_not_in_image_list( + session, color_blending, mocker ): mocker.patch.object( session, "call_action", side_effect=RuntimeError("not in image list"), ) - r = repr(colorblending) + r = repr(color_blending) assert r == ( "[Closed] ColorBlending(image_view_order=None, color_blending_id=0)" ) -def test_colorblending_repr_closed_when_backing_entry_is_gone( - session, colorblending, mocker +def test_color_blending_repr_closed_when_backing_entry_is_gone( + session, color_blending, mocker ): mocker.patch.object(session, "call_action", return_value=2) mocker.patch( - "carta.colorblending.ColorBlending.file_name", + "carta.color_blending.ColorBlending.file_name", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("color blending is gone"), ) - r = repr(colorblending) + r = repr(color_blending) assert r == ( "[Closed] ColorBlending(image_view_order=2, color_blending_id=0)" ) -def test_colorblending_file_name(colorblending, cb_get_value): - colorblending.file_name +def test_color_blending_file_name(color_blending, cb_get_value): + color_blending.file_name cb_get_value.assert_called_with("filename") -def test_colorblending_image_view_order( - session, colorblending, session_call_action +def test_color_blending_image_view_order( + session, color_blending, session_call_action ): session_call_action.return_value = 2 - assert colorblending.image_view_order == 2 + assert color_blending.image_view_order == 2 session_call_action.assert_called_once_with( "imageViewConfigStore.getImageListIndex", ImageType.COLOR_BLENDING, @@ -254,47 +254,47 @@ def test_colorblending_image_view_order( ) -def test_colorblending_image_view_order_raises_when_missing( - session, colorblending, session_call_action +def test_color_blending_image_view_order_raises_when_missing( + session, color_blending, session_call_action ): session_call_action.return_value = -1 with pytest.raises(RuntimeError): - colorblending.image_view_order + color_blending.image_view_order -def test_colorblending_alpha(colorblending, cb_get_value): - colorblending.alpha +def test_color_blending_alpha(color_blending, cb_get_value): + color_blending.alpha cb_get_value.assert_called_with("alpha") -def test_colorblending_base_frame(colorblending, cb_get_value): +def test_color_blending_base_frame(color_blending, cb_get_value): cb_get_value.return_value = 42 - base_frame = colorblending._base_frame + base_frame = color_blending._base_frame cb_get_value.assert_called_once_with("frames[0].id") assert isinstance(base_frame, Image) - assert base_frame.session is colorblending.session + assert base_frame.session is color_blending.session assert base_frame.file_id == 42 -def test_colorblending_make_active(session, colorblending, session_call_action): +def test_color_blending_make_active(session, color_blending, session_call_action): # make_active must be driven by color_blending_id via setActiveImageById. # It must not depend on image_view_order (which is volatile). - colorblending.make_active() + color_blending.make_active() session_call_action.assert_called_with( "setActiveImageById", ImageType.COLOR_BLENDING, 0 ) -def test_colorblending_make_active_does_not_read_image_view_order( - session, colorblending, session_call_action, session_get_value +def test_color_blending_make_active_does_not_read_image_view_order( + session, color_blending, session_call_action, session_get_value ): - colorblending.make_active() + color_blending.make_active() for call in session_get_value.call_args_list: assert call.args != ("imageViewConfigStore.imageListSummary",) -def test_colorblending_layer_list_derived(session, mocker): +def test_color_blending_layer_list_derived(session, mocker): cb = ColorBlending(session, 3) # Simulate two layers from the frontend's computed frames array length. @@ -306,33 +306,33 @@ def test_colorblending_layer_list_derived(session, mocker): gv.assert_called_once_with("frames.length") -def test_colorblending_add_layer(colorblending, cb_call_action, image): - colorblending.add_layer(image) +def test_color_blending_add_layer(color_blending, cb_call_action, image): + color_blending.add_layer(image) cb_call_action.assert_called_with("addSelectedFrame", image._frame) @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) -def test_colorblending_delete_layer( - colorblending, cb_call_action, idx, expected_param +def test_color_blending_delete_layer( + color_blending, cb_call_action, idx, expected_param ): - colorblending.delete_layer(idx) + color_blending.delete_layer(idx) cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) -def test_colorblending_delete_layer_rejects_base_layer( - colorblending, cb_call_action +def test_color_blending_delete_layer_rejects_base_layer( + color_blending, cb_call_action ): with pytest.raises(ValueError, match="The base layer cannot be deleted."): - colorblending.delete_layer(0) + color_blending.delete_layer(0) cb_call_action.assert_not_called() @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) -def test_colorblending_set_layer( - colorblending, cb_call_action, image, idx, expected_param +def test_color_blending_set_layer( + color_blending, cb_call_action, image, idx, expected_param ): - colorblending.set_layer(image, idx) + color_blending.set_layer(image, idx) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame ) @@ -344,7 +344,7 @@ def __init__(self, lid, fid): self.file_id = fid -def test_colorblending_set_layer_sequence(session, colorblending, mocker): +def test_color_blending_set_layer_sequence(session, color_blending, mocker): # Prepare three existing layers with file_ids 10, 20, 30 mocker.patch.object( ColorBlending, @@ -352,15 +352,15 @@ def test_colorblending_set_layer_sequence(session, colorblending, mocker): return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) mocker.patch( - "carta.colorblending.ColorBlending.alpha", + "carta.color_blending.ColorBlending.alpha", new_callable=mocker.PropertyMock, return_value=[1.0, 0.2, 0.8], ) - del_layer = mocker.patch.object(colorblending, "delete_layer") - add_layer = mocker.patch.object(colorblending, "add_layer") + del_layer = mocker.patch.object(color_blending, "delete_layer") + add_layer = mocker.patch.object(color_blending, "add_layer") set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.set_layer_sequence([0, 2, 1]) + color_blending.set_layer_sequence([0, 2, 1]) # Deletes all non-base layers (twice) then adds layers in specified order assert del_layer.call_count == 2 @@ -369,8 +369,8 @@ def test_colorblending_set_layer_sequence(session, colorblending, mocker): assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] -def test_colorblending_set_layer_sequence_noop_when_order_is_unchanged( - colorblending, mocker +def test_color_blending_set_layer_sequence_noop_when_order_is_unchanged( + color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -378,23 +378,23 @@ def test_colorblending_set_layer_sequence_noop_when_order_is_unchanged( return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) mocker.patch( - "carta.colorblending.ColorBlending.alpha", + "carta.color_blending.ColorBlending.alpha", new_callable=mocker.PropertyMock, side_effect=AssertionError("alpha should not be read"), ) - del_layer = mocker.patch.object(colorblending, "delete_layer") - add_layer = mocker.patch.object(colorblending, "add_layer") + del_layer = mocker.patch.object(color_blending, "delete_layer") + add_layer = mocker.patch.object(color_blending, "add_layer") set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.set_layer_sequence([0, 1, 2]) + color_blending.set_layer_sequence([0, 1, 2]) del_layer.assert_not_called() add_layer.assert_not_called() set_alpha.assert_not_called() -def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_supports_user_specified_subset_order( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -402,23 +402,23 @@ def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], ) mocker.patch( - "carta.colorblending.ColorBlending.alpha", + "carta.color_blending.ColorBlending.alpha", new_callable=mocker.PropertyMock, return_value=[1.0, 0.2, 0.8, 0.4], ) - del_layer = mocker.patch.object(colorblending, "delete_layer") - add_layer = mocker.patch.object(colorblending, "add_layer") + del_layer = mocker.patch.object(color_blending, "delete_layer") + add_layer = mocker.patch.object(color_blending, "add_layer") set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.set_layer_sequence([0, 3, 1]) + color_blending.set_layer_sequence([0, 3, 1]) assert del_layer.call_count == 3 assert [call.args[0].file_id for call in add_layer.call_args_list] == [40, 20] assert [call.args[1] for call in set_alpha.call_args_list] == [0.4, 0.2] -def test_colorblending_set_layer_sequence_rejects_missing_layer_index( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_rejects_missing_layer_index( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -427,14 +427,14 @@ def test_colorblending_set_layer_sequence_rejects_missing_layer_index( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([0, 4, 1]) + color_blending.set_layer_sequence([0, 4, 1]) assert "layer_indices [0, 4, 1]" in str(e.value) assert "[4]" in str(e.value) assert "0..3" in str(e.value) -def test_colorblending_set_layer_sequence_requires_base_layer_first( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_requires_base_layer_first( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -443,13 +443,13 @@ def test_colorblending_set_layer_sequence_requires_base_layer_first( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([2, 1]) + color_blending.set_layer_sequence([2, 1]) assert "layer_indices [2, 1]" in str(e.value) assert "must start with the base layer index 0" in str(e.value) -def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_rejects_duplicate_base_layer( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -458,13 +458,13 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([0, 2, 0]) + color_blending.set_layer_sequence([0, 2, 0]) assert "layer_indices [0, 2, 0]" in str(e.value) assert "must contain the base layer index 0 only once" in str(e.value) -def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_rejects_duplicate_non_base_layer( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -473,94 +473,94 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([0, 1, 1]) + color_blending.set_layer_sequence([0, 1, 1]) assert "layer_indices [0, 1, 1]" in str(e.value) assert "must not contain duplicate layer indices" in str(e.value) assert "[1]" in str(e.value) -def test_colorblending_set_center(colorblending, mocker): +def test_color_blending_set_center(color_blending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) - colorblending.set_center(1, 2) + color_blending.set_center(1, 2) base_frame.set_center.assert_called_once_with(1, 2) @pytest.mark.parametrize("size,axis", [(123, SA.X), ("123arcsec", SA.Y)]) -def test_colorblending_zoom_to_size(colorblending, mocker, size, axis): +def test_color_blending_zoom_to_size(color_blending, mocker, size, axis): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) - colorblending.zoom_to_size(size, axis) + color_blending.zoom_to_size(size, axis) base_frame.zoom_to_size.assert_called_once_with(size, axis) @pytest.mark.parametrize("size,axis", [("123px", SA.X), (123, "z")]) -def test_colorblending_zoom_to_size_invalid(colorblending, mocker, size, axis): +def test_color_blending_zoom_to_size_invalid(color_blending, mocker, size, axis): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) with pytest.raises(CartaValidationFailed): - colorblending.zoom_to_size(size, axis) + color_blending.zoom_to_size(size, axis) base_frame.zoom_to_size.assert_not_called() @pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) -def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): +def test_color_blending_set_zoom_level(color_blending, mocker, zoom, absolute): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) - colorblending.set_zoom_level(zoom, absolute) + color_blending.set_zoom_level(zoom, absolute) base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) -def test_colorblending_set_colormap_set(colorblending, cb_call_action): - colorblending.set_colormap_set(CMS.RAINBOW) +def test_color_blending_set_colormap_set(color_blending, cb_call_action): + color_blending.set_colormap_set(CMS.RAINBOW) cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) -def test_colorblending_set_alpha_valid(colorblending, mocker): - ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) - ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) +def test_color_blending_set_alpha_valid(color_blending, mocker): + ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) - colorblending.set_alpha([0.2, 0.8]) + color_blending.set_alpha([0.2, 0.8]) ly1.set_alpha.assert_called_with(0.2) ly2.set_alpha.assert_called_with(0.8) @pytest.mark.parametrize("vals", [[-0.1, 0.5], [1.2], [0.1, 2.0, 0.3]]) -def test_colorblending_set_alpha_invalid(colorblending, vals): +def test_color_blending_set_alpha_invalid(color_blending, vals): with pytest.raises(CartaValidationFailed): - colorblending.set_alpha(vals) + color_blending.set_alpha(vals) @pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) -def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): - ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) - ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) +def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): + ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) with pytest.raises(ValueError, match="does not match"): - colorblending.set_alpha(vals) + color_blending.set_alpha(vals) @pytest.mark.parametrize( @@ -581,12 +581,12 @@ def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): ), ], ) -def test_colorblending_toggle_visibility_when_needed( - colorblending, cb_get_value, cb_call_action, getter, method, action, state +def test_color_blending_toggle_visibility_when_needed( + color_blending, cb_get_value, cb_call_action, getter, method, action, state ): # Current state opposite to desired -> should toggle cb_get_value.side_effect = [not state] - getattr(colorblending, method)(state) + getattr(color_blending, method)(state) cb_call_action.assert_called_with(action) @@ -608,17 +608,17 @@ def test_colorblending_toggle_visibility_when_needed( ), ], ) -def test_colorblending_toggle_visibility_noop( - colorblending, cb_get_value, cb_call_action, getter, method, action, state +def test_color_blending_toggle_visibility_noop( + color_blending, cb_get_value, cb_call_action, getter, method, action, state ): # Current state equals desired -> no toggle cb_get_value.side_effect = [state] - getattr(colorblending, method)(state) + getattr(color_blending, method)(state) cb_call_action.assert_not_called() -def test_colorblending_close(session, colorblending, session_call_action): - colorblending.close() +def test_color_blending_close(session, color_blending, session_call_action): + color_blending.close() session_call_action.assert_called_with( - "imageViewConfigStore.removeColorBlending", colorblending._frame + "imageViewConfigStore.removeColorBlending", color_blending._frame ) diff --git a/tests/test_session.py b/tests/test_session.py index 91e6a96..0cdd436 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,7 +3,7 @@ import pytest from carta.image import Image -from carta.colorblending import ColorBlending +from carta.color_blending import ColorBlending from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol From e4c89bf9a3c02b0658353bd8a0794382fcf9a49c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 21 May 2026 16:29:58 +0800 Subject: [PATCH 076/123] Remove set_layer_sequence method from ColorBlending class because it is considered as an unnecessay feature --- carta/color_blending.py | 77 ----------------------------------------- 1 file changed, 77 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index b1d20a1..8cb7564 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -317,83 +317,6 @@ def set_layer(self, image, layer_index): """ self.call_action("setSelectedFrame", layer_index - 1, image._frame) - @validate(IterableOf(Number(0, None), min_size=1)) - def set_layer_sequence(self, layer_indices): - """Set which layers are included in the color blending and in what - order. - - Parameters - ---------- - layer_indices : {0} - The layer indices to keep, in the desired order. The first index - must be the base layer (index = 0). Existing alpha values are - preserved. - """ - layer_indices = list(layer_indices) - current_layers = self.layer_list() - max_current_layer_index = len(current_layers) - 1 - invalid_layer_indices = [ - layer_index - for layer_index in layer_indices - if layer_index > max_current_layer_index - ] - if invalid_layer_indices: - raise ValueError( - f"layer_indices {layer_indices!r} contains non-existent layer " - f"indices {invalid_layer_indices!r}; available layer indices " - f"are 0..{max_current_layer_index}." - ) - - if layer_indices[0] != 0: - raise ValueError( - f"layer_indices {layer_indices!r} must start with the base " - "layer index 0." - ) - - if 0 in layer_indices[1:]: - raise ValueError( - f"layer_indices {layer_indices!r} must contain the base layer " - "index 0 only once, as the first index." - ) - - if len(layer_indices) != len(set(layer_indices)): - duplicate_layer_indices = sorted( - { - layer_index - for layer_index in layer_indices - if layer_indices.count(layer_index) > 1 - } - ) - raise ValueError( - f"layer_indices {layer_indices!r} must not contain duplicate " - f"layer indices; duplicates were {duplicate_layer_indices!r}." - ) - - current_layer_indices = list(range(len(current_layers))) - if list(layer_indices) == current_layer_indices: - return - - current_alpha_values = self.alpha - target_layer_states = [ - ( - Image(self.session, current_layers[layer_index].file_id), - current_alpha_values[layer_index], - ) - for layer_index in layer_indices[1:] - ] - - # Delete all layers except the base layer - for _ in current_layers[1:]: - # Delete layer at index 1 (the first non-base layer); - # after deletion, the previous layer at index 2 shifts to index 1 - self.delete_layer(1) - - for target_layer_index, (image, alpha) in enumerate( - target_layer_states, start=1 - ): - self.add_layer(image) - Layer(self, target_layer_index).set_alpha(alpha) - # NAVIGATION @validate(Coordinate(), Coordinate()) From c411b4c2f8cccf03661d7a25b34bf14d4742ca57 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 21 May 2026 17:04:18 +0800 Subject: [PATCH 077/123] Remove set_layer_sequence tests after removing the method from ColorBlending class --- tests/test_color_blending.py | 141 ----------------------------------- 1 file changed, 141 deletions(-) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 6903ee5..52b73b5 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -338,147 +338,6 @@ def test_color_blending_set_layer( ) -class _L: - def __init__(self, lid, fid): - self.layer_id = lid - self.file_id = fid - - -def test_color_blending_set_layer_sequence(session, color_blending, mocker): - # Prepare three existing layers with file_ids 10, 20, 30 - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - mocker.patch( - "carta.color_blending.ColorBlending.alpha", - new_callable=mocker.PropertyMock, - return_value=[1.0, 0.2, 0.8], - ) - del_layer = mocker.patch.object(color_blending, "delete_layer") - add_layer = mocker.patch.object(color_blending, "add_layer") - set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - - color_blending.set_layer_sequence([0, 2, 1]) - - # Deletes all non-base layers (twice) then adds layers in specified order - assert del_layer.call_count == 2 - add_args = [call.args[0] for call in add_layer.call_args_list] - assert [img.file_id for img in add_args] == [30, 20] - assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] - - -def test_color_blending_set_layer_sequence_noop_when_order_is_unchanged( - color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - mocker.patch( - "carta.color_blending.ColorBlending.alpha", - new_callable=mocker.PropertyMock, - side_effect=AssertionError("alpha should not be read"), - ) - del_layer = mocker.patch.object(color_blending, "delete_layer") - add_layer = mocker.patch.object(color_blending, "add_layer") - set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - - color_blending.set_layer_sequence([0, 1, 2]) - - del_layer.assert_not_called() - add_layer.assert_not_called() - set_alpha.assert_not_called() - - -def test_color_blending_set_layer_sequence_supports_user_specified_subset_order( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], - ) - mocker.patch( - "carta.color_blending.ColorBlending.alpha", - new_callable=mocker.PropertyMock, - return_value=[1.0, 0.2, 0.8, 0.4], - ) - del_layer = mocker.patch.object(color_blending, "delete_layer") - add_layer = mocker.patch.object(color_blending, "add_layer") - set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - - color_blending.set_layer_sequence([0, 3, 1]) - - assert del_layer.call_count == 3 - assert [call.args[0].file_id for call in add_layer.call_args_list] == [40, 20] - assert [call.args[1] for call in set_alpha.call_args_list] == [0.4, 0.2] - - -def test_color_blending_set_layer_sequence_rejects_missing_layer_index( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([0, 4, 1]) - assert "layer_indices [0, 4, 1]" in str(e.value) - assert "[4]" in str(e.value) - assert "0..3" in str(e.value) - - -def test_color_blending_set_layer_sequence_requires_base_layer_first( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([2, 1]) - assert "layer_indices [2, 1]" in str(e.value) - assert "must start with the base layer index 0" in str(e.value) - - -def test_color_blending_set_layer_sequence_rejects_duplicate_base_layer( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([0, 2, 0]) - assert "layer_indices [0, 2, 0]" in str(e.value) - assert "must contain the base layer index 0 only once" in str(e.value) - - -def test_color_blending_set_layer_sequence_rejects_duplicate_non_base_layer( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([0, 1, 1]) - assert "layer_indices [0, 1, 1]" in str(e.value) - assert "must not contain duplicate layer indices" in str(e.value) - assert "[1]" in str(e.value) - - def test_color_blending_set_center(color_blending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( From 0d8c8bc285f770efcabf389f21abc0c1419e1453 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 09:34:28 +0800 Subject: [PATCH 078/123] Rename set_layer to set_layer_image and swap parameter order to put layer_index before image for consistency --- carta/color_blending.py | 12 ++++++------ tests/test_color_blending.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 8cb7564..4cf8afd 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -303,17 +303,17 @@ def delete_layer(self, layer_index): raise ValueError("The base layer cannot be deleted.") self.call_action("deleteSelectedFrame", layer_index - 1) - @validate(InstanceOf(Image), Number(1, None)) - def set_layer(self, image, layer_index): - """Set a layer at a specified index in the color blending. + @validate(Number(1, None), InstanceOf(Image)) + def set_layer_image(self, layer_index, image): + """Set the image for a layer at a specified index in the color blending. Parameters ---------- - image : {0} - The image to set. - layer_index : {1} + layer_index : {0} The layer index. The base layer (layer_index = 0) cannot be set. + image : {1} + The image to set. """ self.call_action("setSelectedFrame", layer_index - 1, image._frame) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 52b73b5..6ee607b 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -329,10 +329,10 @@ def test_color_blending_delete_layer_rejects_base_layer( @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) -def test_color_blending_set_layer( +def test_color_blending_set_layer_image( color_blending, cb_call_action, image, idx, expected_param ): - color_blending.set_layer(image, idx) + color_blending.set_layer_image(idx, image) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame ) From 955ff8cb6c822c5361d0dbce6aeae644c461b2a2 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 11:19:29 +0800 Subject: [PATCH 079/123] Refactor ImageBase to use class-level IMAGE_TYPE constant and add image_class registry method for dynamic image-view wrapper instantiation --- carta/color_blending.py | 2 +- carta/image.py | 2 +- carta/image_base.py | 39 ++++++++++--- carta/session.py | 114 ++++++++++++++++++------------------- tests/test_image.py | 69 +++------------------- tests/test_session.py | 123 ++++++++++++++++++++++++++-------------- 6 files changed, 180 insertions(+), 169 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 4cf8afd..f1e45a4 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -178,7 +178,7 @@ class ColorBlending(ImageBase, BasePathMixin): The id of the backing ``ColorBlendingStore`` on the frontend. """ - _image_type = ImageType.COLOR_BLENDING + IMAGE_TYPE = ImageType.COLOR_BLENDING def __init__(self, session, color_blending_id): super().__init__(session) diff --git a/carta/image.py b/carta/image.py index d8deb17..e40aed4 100644 --- a/carta/image.py +++ b/carta/image.py @@ -47,7 +47,7 @@ class Image(ImageBase, BasePathMixin): Functions for manipulating regions associated with this image. """ - _image_type = ImageType.FRAME + IMAGE_TYPE = ImageType.FRAME def __init__(self, session, file_id): super().__init__(session) diff --git a/carta/image_base.py b/carta/image_base.py index 51a19b4..81f0a0b 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from .constants import ImageType +from .validation import Constant, validate class ImageBase(ABC): @@ -20,7 +21,13 @@ class ImageBase(ABC): The session object associated with this image-view item. """ - _image_type: ImageType = None + CUSTOM_CLASS = {} + """Mapping of image-view types to their concrete wrapper classes.""" + + def __init_subclass__(cls, **kwargs): + """Register concrete image-view wrapper subclasses by image type.""" + super().__init_subclass__(**kwargs) + ImageBase.CUSTOM_CLASS[cls.IMAGE_TYPE] = cls def __init__(self, session): self.session = session @@ -31,11 +38,29 @@ def _stable_id(self): """The stable identifier of this image-view item.""" raise NotImplementedError # pragma: no cover - def _require_image_type(self): - if self._image_type is None: + @classmethod + @validate(Constant(ImageType)) + def image_class(cls, image_type): + """The image class associated with an image-view type. + + Parameters + ---------- + image_type : {0} + The image-view type. + + Returns + ------- + class object + The concrete image-view wrapper class. + """ + image_type = ImageType(image_type) + image_class = cls.CUSTOM_CLASS.get(image_type) + if image_class is None: raise NotImplementedError( - "Subclasses must define _image_type." + f"No ImageBase subclass is registered for image-view type " + f"{image_type!r}." ) + return image_class @property def image_view_order(self): @@ -51,14 +76,12 @@ def image_view_order(self): RuntimeError If no matching entry exists in the image list. """ - self._require_image_type() return self.session._find_image_view_order( - self._image_type, self._stable_id + self.IMAGE_TYPE, self._stable_id ) def make_active(self): """Make this the active image-view item.""" - self._require_image_type() self.session.call_action( - "setActiveImageById", self._image_type, self._stable_id + "setActiveImageById", self.IMAGE_TYPE, self._stable_id ) diff --git a/carta/session.py b/carta/session.py index f6aa220..2706047 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,11 +10,12 @@ import posixpath from .image import Image +from .image_base import ImageBase from .color_blending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, cached, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -576,17 +577,14 @@ def image_list(self): summary = self.get_value("imageViewConfigStore.imageListSummary") result = [] for order, entry in enumerate(summary): - entry_type = entry["type"] - if entry_type == ImageType.FRAME: - result.append(Image(self, entry["id"])) - elif entry_type == ImageType.COLOR_BLENDING: - result.append(ColorBlending(self, entry["id"])) - else: - raise NotImplementedError( - f"image_list encountered an unsupported image-view " - f"entry at order {order} with type {entry_type!r}; " - "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " - "entries are currently wrapped." + try: + result.append( + ImageBase.image_class(entry["type"])(self, entry["id"]) + ) + except (CartaValidationFailed, NotImplementedError) as e: + print( + f"Skipping unsupported image-view entry at order {order}: " + f"{entry!r}: {e}" ) return result @@ -663,56 +661,64 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= "file_id": file_id, "color_blending_id": color_blending_id, } - given = [key for key, val in provided.items() if val is not None] - if len(given) != 1: - given_values = { - key: val for key, val in provided.items() if val is not None - } + provided_values = { + key: value + for key, value in provided.items() + if value is not None + } + if len(provided_values) != 1: raise ValueError( "image_by_id requires exactly one of the keyword arguments " "`image_view_order`, `file_id`, or `color_blending_id`; " - f"got {len(given)} with values {given_values!r}." + f"got {len(provided_values)} with values {provided_values!r}." ) - summary = self.get_value("imageViewConfigStore.imageListSummary") - if image_view_order is not None: - if image_view_order < 0 or image_view_order >= len(summary): + if image_view_order < 0: raise IndexError( f"image_view_order {image_view_order} is out of range " - f"for an image list of length {len(summary)}." + "for the image list." ) - entry = summary[image_view_order] - entry_type = entry["type"] - entry_id = entry["id"] - if entry_type == ImageType.FRAME: - return Image(self, entry_id) - if entry_type == ImageType.COLOR_BLENDING: - return ColorBlending(self, entry_id) - raise NotImplementedError( - f"image_by_id encountered an unsupported image-view entry " - f"at order {image_view_order} with type {entry_type!r}; " - "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " - "entries are currently wrapped." - ) + try: + entry = self.get_value( + "imageViewConfigStore.imageListSummary" + f"[{image_view_order}]" + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise IndexError( + f"image_view_order {image_view_order} is out of range " + "for the image list." + ) from e + return ImageBase.image_class(entry["type"])(self, entry["id"]) if file_id is not None: - for entry in summary: - if entry["type"] == ImageType.FRAME and entry["id"] == file_id: - return Image(self, file_id) - raise RuntimeError( - f"No frame-backed image with file_id={file_id} is open." + try: + resolved_file_id = self.get_value( + f"frameMap[{file_id}]", + return_path="frameInfo.fileId", + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise RuntimeError( + f"No frame-backed image with file_id={file_id} is open." + ) from e + return ImageBase.image_class(ImageType.FRAME)( + self, resolved_file_id ) # color_blending_id is not None - for entry in summary: - if ( - entry["type"] == ImageType.COLOR_BLENDING - and entry["id"] == color_blending_id - ): - return ColorBlending(self, color_blending_id) - raise RuntimeError( - f"No color blending with color_blending_id={color_blending_id} is open." + try: + resolved_color_blending_id = self.get_value( + f"imageViewConfigStore.colorBlendingImageMap" + f"[{color_blending_id}]", + return_path="id", + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise RuntimeError( + f"No color blending with color_blending_id={color_blending_id} " + "is open." + ) from e + return ImageBase.image_class(ImageType.COLOR_BLENDING)( + self, resolved_color_blending_id ) def active_image(self): @@ -733,16 +739,8 @@ def active_image(self): the Python side. """ active = self.get_value("activeImage") - active_type = active["type"] - active_id = active["store"]["id"] - if active_type == ImageType.FRAME: - return Image(self, active_id) - if active_type == ImageType.COLOR_BLENDING: - return ColorBlending(self, active_id) - raise NotImplementedError( - f"active_image encountered an unsupported image-view type " - f"{active_type!r}; only Image (FRAME) and ColorBlending " - "(COLOR_BLENDING) entries are currently wrapped." + return ImageBase.image_class(active["type"])( + self, active["store"]["id"] ) # COLOR BLENDING diff --git a/tests/test_image.py b/tests/test_image.py index 7f2a969..69a3d13 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -122,78 +122,27 @@ def test_image_base_is_abstract(session): def test_image_base_make_active_uses_subclass_ids(session, session_call_action): # Verify the shared ImageBase.make_active dispatches setActiveImageById - # with the subclass's _image_type and _stable_id exactly once. - class Dummy(ImageBase): - _image_type = ImageType.FRAME - - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ - - @property - def _stable_id(self): - return self._id - - Dummy(session, 42).make_active() + # with Image.IMAGE_TYPE and its stable ID exactly once. + Image(session, 42).make_active() session_call_action.assert_called_once_with( "setActiveImageById", ImageType.FRAME, 42 ) def test_image_base_image_view_order_uses_subclass_ids(session, mocker): - class Dummy(ImageBase): - _image_type = ImageType.COLOR_BLENDING - - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ - - @property - def _stable_id(self): - return self._id - find = mocker.patch.object(session, "_find_image_view_order", return_value=5) - assert Dummy(session, 42).image_view_order == 5 - find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) - - -def test_image_base_image_type_required_for_make_active( - session, session_call_action -): - class Dummy(ImageBase): - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ + from carta.color_blending import ColorBlending - @property - def _stable_id(self): - return self._id - - with pytest.raises( - NotImplementedError, match="Subclasses must define _image_type" - ): - Dummy(session, 42).make_active() - - session_call_action.assert_not_called() - - -def test_image_base_image_type_required_for_image_view_order(session, mocker): - class Dummy(ImageBase): - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ + assert ColorBlending(session, 42).image_view_order == 5 + find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) - @property - def _stable_id(self): - return self._id - find = mocker.patch.object(session, "_find_image_view_order") +def test_image_base_subclass_requires_image_type(): with pytest.raises( - NotImplementedError, match="Subclasses must define _image_type" + AttributeError, match="has no attribute 'IMAGE_TYPE'" ): - Dummy(session, 42).image_view_order - - find.assert_not_called() + class Dummy(ImageBase): + pass def test_image_view_order_uses_find_image_view_order(session, mocker, image): diff --git a/tests/test_session.py b/tests/test_session.py index 0cdd436..40f65cd 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,7 @@ from carta.image import Image from carta.color_blending import ColorBlending -from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt +from carta.util import CartaActionFailed, CartaBadResponse, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -138,13 +138,20 @@ def test_image_list_heterogeneous(session, get_value): assert isinstance(images[2], Image) and images[2].file_id == 20 -def test_image_list_raises_on_pv_preview(session, get_value): +def test_image_list_skips_unsupported_image_type(session, get_value, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.PV_PREVIEW, "id": -2}, + {"type": 99, "id": 11}, + {"type": ImageType.FRAME, "id": 20}, ] - with pytest.raises(NotImplementedError): - session.image_list() + + images = session.image_list() + + assert [image.file_id for image in images] == [10, 20] + assert ( + "Skipping unsupported image-view entry at order 1" + in capsys.readouterr().out + ) def test_image_list_empty(session, get_value): @@ -185,16 +192,6 @@ def test_find_image_view_order_raises_when_missing(session, call_action): # session.image_by_id -@pytest.fixture -def summary(get_value): - get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - {"type": ImageType.FRAME, "id": 20}, - ] - return get_value - - def test_image_by_id_requires_exactly_one_keyword(session, get_value): # Zero keywords -> ValueError with all three names listed. with pytest.raises(ValueError) as e: @@ -214,69 +211,113 @@ def test_image_by_id_rejects_positional(session): session.image_by_id(0) -def test_image_by_id_by_image_view_order(session, summary): - img = session.image_by_id(image_view_order=0) - assert isinstance(img, Image) - assert img.file_id == 10 +@pytest.mark.parametrize( + "entry,expected_type,expected_id", + [ + ({"type": ImageType.FRAME, "id": 10}, Image, 10), + ( + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ColorBlending, + 7, + ), + ], +) +def test_image_by_id_by_image_view_order( + session, get_value, entry, expected_type, expected_id +): + get_value.return_value = entry - cb = session.image_by_id(image_view_order=1) - assert isinstance(cb, ColorBlending) - assert cb.color_blending_id == 7 + img = session.image_by_id(image_view_order=0) + assert isinstance(img, expected_type) + assert ( + img.file_id if expected_type is Image else img.color_blending_id + ) == expected_id + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary[0]" + ) - img2 = session.image_by_id(image_view_order=2) - assert isinstance(img2, Image) - assert img2.file_id == 20 +def test_image_by_id_by_image_view_order_out_of_range(session, get_value): + get_value.side_effect = CartaBadResponse("undefined") -def test_image_by_id_by_image_view_order_out_of_range(session, summary): with pytest.raises(IndexError): session.image_by_id(image_view_order=99) + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary[99]" + ) + def test_image_by_id_by_image_view_order_raises_on_unsupported_type( session, get_value ): - get_value.return_value = [ - {"type": ImageType.PV_PREVIEW, "id": -2}, - ] + get_value.return_value = {"type": ImageType.PV_PREVIEW, "id": -2} with pytest.raises(NotImplementedError): session.image_by_id(image_view_order=0) -def test_image_by_id_by_file_id(session, summary): +def test_image_by_id_by_file_id(session, get_value): + get_value.return_value = 20 + img = session.image_by_id(file_id=20) assert isinstance(img, Image) assert img.file_id == 20 + get_value.assert_called_once_with( + "frameMap[20]", + return_path="frameInfo.fileId", + ) -def test_image_by_id_by_file_id_no_cross_type_fallback(session, summary): - # The summary contains a COLOR_BLENDING entry with id=7, but no FRAME - # with that id, so image_by_id(file_id=7) must raise. +def test_image_by_id_by_file_id_no_cross_type_fallback(session, get_value): + get_value.side_effect = CartaBadResponse("undefined") + with pytest.raises(RuntimeError): session.image_by_id(file_id=7) -def test_image_by_id_by_color_blending_id(session, summary): +def test_image_by_id_by_color_blending_id(session, get_value): + get_value.return_value = 7 + cb = session.image_by_id(color_blending_id=7) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 + get_value.assert_called_once_with( + "imageViewConfigStore.colorBlendingImageMap[7]", + return_path="id", + ) + +def test_image_by_id_by_color_blending_id_no_cross_type_fallback( + session, get_value +): + get_value.side_effect = CartaBadResponse("undefined") -def test_image_by_id_by_color_blending_id_no_cross_type_fallback(session, summary): - # The summary contains a FRAME with id=10, but no COLOR_BLENDING with - # that id, so image_by_id(color_blending_id=10) must raise. with pytest.raises(RuntimeError): session.image_by_id(color_blending_id=10) -def test_image_by_id_single_round_trip(session, summary): +def test_image_by_id_uses_targeted_frontend_lookups(session, get_value): + get_value.side_effect = [ + {"type": ImageType.FRAME, "id": 10}, + 10, + 7, + ] + session.image_by_id(image_view_order=0) session.image_by_id(file_id=10) session.image_by_id(color_blending_id=7) - assert summary.call_count == 3 - for call_ in summary.call_args_list: - assert call_.args == ("imageViewConfigStore.imageListSummary",) + + assert get_value.call_args_list == [ + call( + "imageViewConfigStore.imageListSummary[0]" + ), + call("frameMap[10]", return_path="frameInfo.fileId"), + call( + "imageViewConfigStore.colorBlendingImageMap[7]", + return_path="id", + ), + ] # session.active_image From 51e2c4881f32b828ac1098a2c4b55224a695e54a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 21:48:53 +0800 Subject: [PATCH 080/123] Add False parameter to setSpatialReference action call to disable color blending alert when making image spatial reference --- carta/image.py | 2 +- tests/test_image.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/carta/image.py b/carta/image.py index e40aed4..d635911 100644 --- a/carta/image.py +++ b/carta/image.py @@ -256,7 +256,7 @@ def polarizations(self): def make_spatial_reference(self): """Make this image the spatial reference.""" - self.session.call_action("setSpatialReference", self._frame) + self.session.call_action("setSpatialReference", self._frame, False) @validate(Boolean()) def set_spatial_matching(self, state): diff --git a/tests/test_image.py b/tests/test_image.py index 69a3d13..33ef995 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -115,6 +115,15 @@ def test_make_active(image, session_call_action): ) +def test_make_spatial_reference_disables_color_blending_alert( + image, session_call_action +): + image.make_spatial_reference() + session_call_action.assert_called_once_with( + "setSpatialReference", image._frame, False + ) + + def test_image_base_is_abstract(session): with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): ImageBase(session) From d46311fa392406b3d1496e9f07ff4aead444b2bb Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 22:22:58 +0800 Subject: [PATCH 081/123] Add Layer.delete method and update ColorBlending.delete_layer to handle base layer deletion by promoting next layer or closing color blending --- carta/color_blending.py | 29 +++++++++++--- docs/source/quickstart.rst | 32 +++++++++------ tests/test_color_blending.py | 75 +++++++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 22 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index f1e45a4..206a24f 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -12,6 +12,8 @@ IterableOf, Number, Size, + Attr, + Evaluate, validate, ) @@ -134,6 +136,10 @@ def file_id(self): """ return self.get_value("frameInfo.fileId") + def delete(self): + """Delete this layer from its parent color blending.""" + self.color_blending.delete_layer(self.layer_id) + @validate(Number(0, 1)) def set_alpha(self, alpha): """Set the alpha value for the layer in the color blending. @@ -248,6 +254,11 @@ def alpha(self): """ return self.get_value("alpha") + @property + def depth(self): + """The number of layers in the color blending.""" + return self.get_value("frames.length") + @validate(IterableOf(Number(0, 1))) def set_alpha(self, alpha_list): """Set the alpha value for the color blending layers. @@ -276,8 +287,7 @@ def layer_list(self): list of :obj:`carta.color_blending.Layer` A list of Layer objects. """ - layer_count = self.get_value("frames.length") - return Layer.from_list(self, list(range(layer_count))) + return Layer.from_list(self, list(range(self.depth))) def add_layer(self, image): """Add a new layer to the color blending. @@ -289,18 +299,25 @@ def add_layer(self, image): """ self.call_action("addSelectedFrame", image._frame) - @validate(Number(0, None)) + @validate(Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1)) def delete_layer(self, layer_index): """Delete a layer from the color blending. Parameters ---------- layer_index : {0} - The layer index. The base layer (layer_index = 0) cannot - be deleted. + The layer index. If the base layer (layer_index = 0) is deleted, + the next layer becomes the spatial reference. If it is the only + layer, the color blending is closed. """ if layer_index == 0: - raise ValueError("The base layer cannot be deleted.") + layers = self.layer_list() + if len(layers) == 1: + self.close() + return + + Image(self.session, layers[1].file_id).make_spatial_reference() + return self.call_action("deleteSelectedFrame", layer_index - 1) @validate(Number(1, None), InstanceOf(Image)) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 52170b8..73d2d18 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -329,18 +329,24 @@ Manipulate properties of the color blending object and the underlying layers: # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) - # Set which layers to keep, and in what order - # The first layer index must be the base layer (index = 0) - # Since the base layer cannot be moved, - # the layers will be reordered as [img0, img2, img1] - cb.set_layer_sequence([0, 2, 1]) - # Remove the last layer (index = 2) cb.delete_layer(2) - # Add a new layer + # Add the removed image back as a new layer # The layer to be added cannot be one of the current layers - cb.add_layer(img1) + cb.add_layer(img2) + + # Layer objects can delete themselves from the color blending + red, green, blue = cb.layer_list() + blue.delete() + + # Deleting the base layer promotes the next layer to the spatial + # reference. The old base remains spatially matched but is removed + # from the color blending layers. + red.delete() + + # Append the old base as a new color blending layer if desired. + cb.add_layer(img0) # Set center cb.set_center(100, 100) @@ -362,9 +368,13 @@ Manipulate properties of the color blending object and the underlying layers: cb.close() .. note:: - The base layer (index = 0) cannot be deleted or moved. If you need to - change the layer order involving the base layer, close the current color - blending object and create a new one. + Layer indices are zero-based and refer to the current layer list. The + base layer (index = 0) can be deleted: if other layers remain, the next + layer becomes the new spatial reference; if it is the only layer, the + color blending object is closed. The old base remains spatially matched + to the new reference but is no longer part of the color blending until it + is appended as a new color blending layer with + :meth:`carta.color_blending.ColorBlending.add_layer`. Saving or displaying an image ----------------------------- diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 6ee607b..3dd47f3 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -138,6 +138,14 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") +def test_layer_delete(layer, mocker): + delete_layer = mocker.patch.object(layer.color_blending, "delete_layer") + + layer.delete() + + delete_layer.assert_called_once_with(layer.layer_id) + + def test_layer_image_view_order(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=7) layer_property("file_id", 42) @@ -313,17 +321,74 @@ def test_color_blending_add_layer(color_blending, cb_call_action, image): @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) def test_color_blending_delete_layer( - color_blending, cb_call_action, idx, expected_param + color_blending, cb_call_action, idx, expected_param, mocker ): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=4, + ) color_blending.delete_layer(idx) cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) -def test_color_blending_delete_layer_rejects_base_layer( - color_blending, cb_call_action +def test_color_blending_delete_base_layer_promotes_next_layer( + color_blending, cb_call_action, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + layers = [Layer(color_blending, 0), Layer(color_blending, 1)] + mocker.patch.object(color_blending, "layer_list", return_value=layers) + mocker.patch.object( + Layer, "file_id", new_callable=mocker.PropertyMock, return_value=42 + ) + image = mocker.patch("carta.color_blending.Image", autospec=True) + + color_blending.delete_layer(0) + + cb_call_action.assert_not_called() + image.assert_called_once_with(color_blending.session, 42) + image.return_value.make_spatial_reference.assert_called_once_with() + + +def test_color_blending_delete_only_base_layer_closes_color_blending( + color_blending, cb_call_action, mocker ): - with pytest.raises(ValueError, match="The base layer cannot be deleted."): - color_blending.delete_layer(0) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=1, + ) + mocker.patch.object( + color_blending, "layer_list", return_value=[Layer(color_blending, 0)] + ) + close = mocker.patch.object(color_blending, "close") + + color_blending.delete_layer(0) + + close.assert_called_once_with() + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx", [-1, 2]) +def test_color_blending_delete_layer_rejects_out_of_range( + color_blending, cb_call_action, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.delete_layer(idx) cb_call_action.assert_not_called() From 4168f2942ff71fa915a60bf66347641070956ac5 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 22:27:22 +0800 Subject: [PATCH 082/123] Refactor set_alpha validation to use Evaluate validator with depth attribute instead of manual length checking --- carta/color_blending.py | 12 ++++-------- tests/test_color_blending.py | 23 +++++++++++++++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 206a24f..f982940 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -259,7 +259,9 @@ def depth(self): """The number of layers in the color blending.""" return self.get_value("frames.length") - @validate(IterableOf(Number(0, 1))) + @validate( + Evaluate(IterableOf, Number(0, 1), Attr("depth"), Attr("depth")) + ) def set_alpha(self, alpha_list): """Set the alpha value for the color blending layers. @@ -268,13 +270,7 @@ def set_alpha(self, alpha_list): alpha_list : {0} The alpha values. """ - layer_list = self.layer_list() - if len(alpha_list) != len(layer_list): - raise ValueError( - f"alpha_list length ({len(alpha_list)}) does not match " - f"the number of layers ({len(layer_list)})." - ) - for alpha, layer in zip(alpha_list, layer_list): + for alpha, layer in zip(alpha_list, self.layer_list()): layer.set_alpha(alpha) def layer_list(self): diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 3dd47f3..9a1d033 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -464,6 +464,12 @@ def test_color_blending_set_colormap_set(color_blending, cb_call_action): def test_color_blending_set_alpha_valid(color_blending, mocker): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) color_blending.set_alpha([0.2, 0.8]) @@ -472,7 +478,14 @@ def test_color_blending_set_alpha_valid(color_blending, mocker): @pytest.mark.parametrize("vals", [[-0.1, 0.5], [1.2], [0.1, 2.0, 0.3]]) -def test_color_blending_set_alpha_invalid(color_blending, vals): +def test_color_blending_set_alpha_invalid(color_blending, vals, mocker): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + with pytest.raises(CartaValidationFailed): color_blending.set_alpha(vals) @@ -481,9 +494,15 @@ def test_color_blending_set_alpha_invalid(color_blending, vals): def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) - with pytest.raises(ValueError, match="does not match"): + with pytest.raises(CartaValidationFailed): color_blending.set_alpha(vals) From 69aedb9c7d3db6069bca35267f2fe3b96709f1a6 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 09:44:39 +0800 Subject: [PATCH 083/123] Add colormap and alpha properties to Layer class and improve docstring formatting --- carta/color_blending.py | 42 ++++++++++++++++++++++++++++++------ tests/test_color_blending.py | 18 +++++++++++++++- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index f982940..8bc4b6a 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -48,8 +48,7 @@ def __init__(self, color_blending, layer_id): @classmethod def from_list(cls, color_blending, layer_ids): - """ - Create a list of Layer objects from a list of layer IDs. + """Create a list of Layer objects from a list of layer IDs. Parameters ---------- @@ -103,6 +102,8 @@ def __repr__(self): try: name = self.file_name + colormap = self.colormap + alpha = self.alpha except CartaScriptingException: return ( f"[Closed] {cls}(image_view_order={order}, " @@ -111,7 +112,8 @@ def __repr__(self): return ( f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " - f"layer_id={self.layer_id}, file_name={name!r})" + f"layer_id={self.layer_id}, file_name={name!r}, " + f"colormap={colormap!r}, alpha={alpha!r})" ) @property @@ -136,6 +138,28 @@ def file_id(self): """ return self.get_value("frameInfo.fileId") + @property + def colormap(self): + """The colormap used to render this layer. + + Returns + ------- + string + The colormap name. + """ + return self.get_value("renderConfig.colorMap") + + @property + def alpha(self): + """The alpha value of this layer in the color blending. + + Returns + ------- + number + The alpha value, between 0 and 1. + """ + return self.color_blending.get_value(f"alpha[{self.layer_id}]") + def delete(self): """Delete this layer from its parent color blending.""" self.color_blending.delete_layer(self.layer_id) @@ -256,7 +280,13 @@ def alpha(self): @property def depth(self): - """The number of layers in the color blending.""" + """The number of layers in the color blending. + + Returns + ------- + integer + The number of layers. + """ return self.get_value("frames.length") @validate( @@ -274,9 +304,7 @@ def set_alpha(self, alpha_list): layer.set_alpha(alpha) def layer_list(self): - """ - Returns a list of Layer objects, each representing a layer in - this color blending object. + """Return a list of Layer objects for this color blending. Returns ------- diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 9a1d033..2faaa8c 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -74,10 +74,12 @@ def test_layer_repr_healthy(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_id", 42) layer_property("file_name", "layer1.fits") + layer_property("colormap", "viridis") + layer_property("alpha", 0.5) r = repr(Layer(color_blending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " - "file_name='layer1.fits')" + "file_name='layer1.fits', colormap='viridis', alpha=0.5)" ) find.assert_called_once_with(ImageType.FRAME, 42) @@ -138,6 +140,20 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") +def test_layer_colormap_property(layer, layer_get_value): + layer_get_value.return_value = "viridis" + + assert layer.colormap == "viridis" + layer_get_value.assert_called_once_with("renderConfig.colorMap") + + +def test_layer_alpha_property(layer, cb_get_value): + cb_get_value.return_value = 0.5 + + assert layer.alpha == 0.5 + cb_get_value.assert_called_once_with("alpha[1]") + + def test_layer_delete(layer, mocker): delete_layer = mocker.patch.object(layer.color_blending, "delete_layer") From 638b032ae488fe991a50daf1987b356b4eabd3c6 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 09:59:12 +0800 Subject: [PATCH 084/123] Add Layer.set_image method and allow ColorBlending.set_layer_image to handle base layer by making it the spatial reference --- carta/color_blending.py | 22 +++++++++++++++++++--- tests/test_color_blending.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 8bc4b6a..b02c194 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -164,6 +164,17 @@ def delete(self): """Delete this layer from its parent color blending.""" self.color_blending.delete_layer(self.layer_id) + @validate(InstanceOf(Image)) + def set_image(self, image): + """Set the image for this layer. + + Parameters + ---------- + image : {0} + The image to set. + """ + self.color_blending.set_layer_image(self.layer_id, image) + @validate(Number(0, 1)) def set_alpha(self, alpha): """Set the alpha value for the layer in the color blending. @@ -344,18 +355,23 @@ def delete_layer(self, layer_index): return self.call_action("deleteSelectedFrame", layer_index - 1) - @validate(Number(1, None), InstanceOf(Image)) + @validate(Number(0, None), InstanceOf(Image)) def set_layer_image(self, layer_index, image): """Set the image for a layer at a specified index in the color blending. Parameters ---------- layer_index : {0} - The layer index. The base layer (layer_index = 0) cannot - be set. + The layer index. If the base layer (layer_index = 0) is selected, + ``image`` becomes the new spatial reference. Otherwise, the + specified secondary layer is replaced. image : {1} The image to set. """ + if layer_index == 0: + image.set_spatial_matching(True) + image.make_spatial_reference() + return self.call_action("setSelectedFrame", layer_index - 1, image._frame) # NAVIGATION diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 2faaa8c..b7bcd89 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -162,6 +162,27 @@ def test_layer_delete(layer, mocker): delete_layer.assert_called_once_with(layer.layer_id) +def test_layer_set_image(layer, image, mocker): + set_layer_image = mocker.patch.object( + layer.color_blending, "set_layer_image" + ) + + layer.set_image(image) + + set_layer_image.assert_called_once_with(layer.layer_id, image) + + +def test_layer_set_image_rejects_invalid_image(layer, mocker): + set_layer_image = mocker.patch.object( + layer.color_blending, "set_layer_image" + ) + + with pytest.raises(CartaValidationFailed): + layer.set_image(object()) + + set_layer_image.assert_not_called() + + def test_layer_image_view_order(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=7) layer_property("file_id", 42) @@ -419,6 +440,17 @@ def test_color_blending_set_layer_image( ) +def test_color_blending_set_base_layer_image(color_blending, cb_call_action, image, mocker): + set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") + make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") + + color_blending.set_layer_image(0, image) + + set_spatial_matching.assert_called_once_with(True) + make_spatial_reference.assert_called_once_with() + cb_call_action.assert_not_called() + + def test_color_blending_set_center(color_blending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( From 26f49837a55568714b7c42486e7c96ddc21231ab Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 10:27:29 +0800 Subject: [PATCH 085/123] Add Session.images and Session.color_blendings methods to retrieve image lists with optional ID filtering --- carta/session.py | 66 +++++++++++++++++++++++++++++++++++++++ carta/wcs_overlay.py | 11 ++----- tests/test_session.py | 63 +++++++++++++++++++++++++++++++++++++ tests/test_wcs_overlay.py | 3 +- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/carta/session.py b/carta/session.py index 2706047..b7eb8ab 100644 --- a/carta/session.py +++ b/carta/session.py @@ -588,6 +588,72 @@ def image_list(self): ) return result + def _image_list(self, path, image_class, return_path): + count = self.get_value(f"{path}.length") + return [ + image_class( + self, + self.get_value( + f"{path}[{index}]", + return_path=return_path, + ), + ) + for index in range(count) + ] + + @validate(NoneOr(IterableOf(Number.ID))) + def images(self, file_ids=None): + """Return frame-backed images from the session. + + When no IDs are supplied, all open frame-backed images are returned. + When IDs are supplied, they are validated against the session's frame + map. + + Parameters + ---------- + file_ids : {0} + The file IDs of the images to return. By default, all open + frame-backed images are returned. + + Returns + ------- + list of :obj:`carta.image.Image` + The requested frame-backed images. + """ + if file_ids is None: + return self._image_list("frames", Image, "frameInfo.fileId") + return [self.image_by_id(file_id=file_id) for file_id in file_ids] + + @validate(NoneOr(IterableOf(Number.ID))) + def color_blendings(self, color_blending_ids=None): + """Return color blending images from the session. + + When no IDs are supplied, all open color blending images are + returned. When IDs are supplied, they are validated against the + session's color blending map. + + Parameters + ---------- + color_blending_ids : {0} + The IDs of the color blending images to return. By default, all + open color blending images are returned. + + Returns + ------- + list of :obj:`carta.color_blending.ColorBlending` + The requested color blending images. + """ + if color_blending_ids is None: + return self._image_list( + "imageViewConfigStore.colorBlendingImages", + ColorBlending, + "id", + ) + return [ + self.image_by_id(color_blending_id=color_blending_id) + for color_blending_id in color_blending_ids + ] + def _find_image_view_order(self, image_type, stable_id): """Return the image-view order of an item identified by a stable id. diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index a46a440..f59396c 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,21 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _images(self, image_ids=None): - """Internal helper function for fetching image objects.""" - from .image import Image - if image_ids is None: - return [img for img in self.session.image_list() if isinstance(img, Image)] - return [self.session.image_by_id(file_id=image_id) for image_id in image_ids] - def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self._images(image_ids) + images = self.session.images(image_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) def _call_image_wcs_functions(self, image_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self._images(image_ids) + images = self.session.images(image_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) diff --git a/tests/test_session.py b/tests/test_session.py index 40f65cd..c50cb8e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -162,6 +162,69 @@ def test_image_list_empty(session, get_value): ) +def test_images_uses_frame_list(session, get_value): + get_value.side_effect = [2, 10, 20] + + images = session.images() + + assert [image.file_id for image in images] == [10, 20] + assert get_value.call_args_list == [ + call("frames.length"), + call("frames[0]", return_path="frameInfo.fileId"), + call("frames[1]", return_path="frameInfo.fileId"), + ] + + +def test_images_uses_frame_map_for_explicit_ids(session, mocker): + image_by_id = mocker.patch.object(session, "image_by_id") + image_by_id.side_effect = [object(), object()] + + images = session.images(file_ids=[10, 20]) + + assert len(images) == 2 + assert image_by_id.call_args_list == [ + call(file_id=10), + call(file_id=20), + ] + + +def test_color_blendings_uses_color_blending_list(session, get_value): + get_value.side_effect = [2, 3, 7] + + color_blendings = session.color_blendings() + + assert [ + color_blending.color_blending_id + for color_blending in color_blendings + ] == [3, 7] + assert get_value.call_args_list == [ + call("imageViewConfigStore.colorBlendingImages.length"), + call( + "imageViewConfigStore.colorBlendingImages[0]", + return_path="id", + ), + call( + "imageViewConfigStore.colorBlendingImages[1]", + return_path="id", + ), + ] + + +def test_color_blendings_uses_color_blending_map_for_explicit_ids( + session, mocker +): + image_by_id = mocker.patch.object(session, "image_by_id") + image_by_id.side_effect = [object(), object()] + + color_blendings = session.color_blendings([3, 7]) + + assert len(color_blendings) == 2 + assert image_by_id.call_args_list == [ + call(color_blending_id=3), + call(color_blending_id=7), + ] + + def test_find_image_view_order_single_round_trip(session, call_action): call_action.side_effect = [2, 1] diff --git a/tests/test_wcs_overlay.py b/tests/test_wcs_overlay.py index e462b92..1f3b4f4 100644 --- a/tests/test_wcs_overlay.py +++ b/tests/test_wcs_overlay.py @@ -1,7 +1,6 @@ import pytest from carta.util import CartaValidationFailed -from carta.wcs_overlay import ImageWCSConnector from carta.constants import NumberFormat as NF, Overlay as O, CoordinateSystem as CS, PaletteColor as PC, FontFamily as FF, FontStyle as FS, LabelType as LT, ColorbarPosition as CP, BeamType as BT @@ -66,7 +65,7 @@ def image_beam_property(mock_property): @pytest.fixture def mock_images(image, mocker): - return mocker.patch.object(ImageWCSConnector, "_images", return_value=[image]) + return mocker.patch.object(image.session, "images", return_value=[image]) # TESTS From 9f44decca24c3110bd8605c512a19f49f431601e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 10:36:55 +0800 Subject: [PATCH 086/123] Update quickstart documentation --- docs/source/quickstart.rst | 56 +++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 73d2d18..f852b92 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -197,17 +197,18 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba The frontend image list panel. Each row corresponds to an item returned by :obj:`carta.session.Session.image_list`, and its position in the list is the item's ``image_view_order``. -.. code-block:: python +Use :meth:`carta.session.Session.images` and +:meth:`carta.session.Session.color_blendings` to retrieve the two concrete +image-view types directly, optionally filtering by their stable IDs. - from carta.image import Image - from carta.color_blending import ColorBlending +.. code-block:: python # All open image-view items, in display order items = session.image_list() - # Filter by type if needed - images = [i for i in items if isinstance(i, Image)] - color_blendings = [i for i in items if isinstance(i, ColorBlending)] + # Get frame-backed images or color blending images directly + images = session.images() + color_blendings = session.color_blendings() # Every image-view item exposes its current image-view order print(img0.image_view_order) @@ -215,7 +216,13 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba # Retrieve a specific item by image view order img = session.image_by_id(image_view_order=0) cb = session.image_by_id(image_view_order=1) - + + # Filter by stable IDs when needed + images = session.images(file_ids=[img0.file_id, img1.file_id]) + color_blendings = session.color_blendings( + color_blending_ids=[cb.color_blending_id] + ) + Changing image properties ------------------------- @@ -224,7 +231,7 @@ Properties specific to individual images can be accessed through image objects: .. code-block:: python import numpy as np - from carta.constants import Colormap, Scaling, Polarization + from carta.constants import Colormap, ColormapSet, Scaling, Polarization # change the channel and polarization img.set_channel(10) @@ -308,12 +315,15 @@ Manipulate properties of the color blending object and the underlying layers: .. code-block:: python # Get layer objects - red, green, blue = cb.layer_list() + layer1, layer2, layer3 = cb.layer_list() - # Set colormap for individual layers - red.set_colormap(Colormap.REDS) - green.set_colormap(Colormap.GREENS) - blue.set_colormap(Colormap.BLUES) + # Set colormap for the images in individual layers + layer1.set_colormap(Colormap.REDS) + layer2.set_colormap(Colormap.GREENS) + layer3.set_colormap(Colormap.BLUES) + + # Inspect the colormap and alpha of an individual layer + print(layer1.colormap, layer1.alpha) # Or apply an existing colormap set cb.set_colormap_set(ColormapSet.RGB) @@ -322,13 +332,17 @@ Manipulate properties of the color blending object and the underlying layers: print(cb.alpha) # Set alpha for individual layers - red.set_alpha(0.7) - green.set_alpha(0.8) - blue.set_alpha(0.9) + layer1.set_alpha(0.7) + layer2.set_alpha(0.8) + layer3.set_alpha(0.9) # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) + # Replace the image in a layer + # For layer1, this also makes the new image the spatial reference. + # layer1.set_image(new_image) + # Remove the last layer (index = 2) cb.delete_layer(2) @@ -337,13 +351,13 @@ Manipulate properties of the color blending object and the underlying layers: cb.add_layer(img2) # Layer objects can delete themselves from the color blending - red, green, blue = cb.layer_list() - blue.delete() + layer1, layer2, layer3 = cb.layer_list() + layer3.delete() # Deleting the base layer promotes the next layer to the spatial # reference. The old base remains spatially matched but is removed # from the color blending layers. - red.delete() + layer1.delete() # Append the old base as a new color blending layer if desired. cb.add_layer(img0) @@ -376,6 +390,10 @@ Manipulate properties of the color blending object and the underlying layers: is appended as a new color blending layer with :meth:`carta.color_blending.ColorBlending.add_layer`. + An image can be replaced through :meth:`carta.color_blending.Layer.set_image`. + Replacing the base layer first enables spatial matching for the new image + and then makes it the spatial reference. + Saving or displaying an image ----------------------------- From 14de716c5d42708c846a409343663a5b0e7af1a2 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 13:34:54 +0800 Subject: [PATCH 087/123] Rename image_ids parameter to file_ids in wcs_overlay for consistency with Session.images method --- carta/wcs_overlay.py | 104 +++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index f59396c..6041f05 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,14 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _get_image_wcs_properties(self, image_ids, property_path): + def _get_image_wcs_properties(self, file_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self.session.images(image_ids) + images = self.session.images(file_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) - def _call_image_wcs_functions(self, image_ids, function_path, *function_args): + def _call_image_wcs_functions(self, file_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self.session.images(image_ids) + images = self.session.images(file_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) @@ -535,12 +535,12 @@ class Title(HasCustomColor, HasCustomText, HasFont, HasVisibility, ImageWCSConne COMPONENT = Overlay.TITLE @validate(ImageWCSConnector.ANY_IDS) - def text(self, image_ids=None): + def text(self, file_ids=None): """The custom title text for the specified images. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. Returns @@ -548,10 +548,10 @@ def text(self, image_ids=None): tuple of string The title text of the specified images. """ - return self._get_image_wcs_properties(image_ids, "title.text") + return self._get_image_wcs_properties(file_ids, "title.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, title_text, image_ids=None): + def set_text(self, title_text, file_ids=None): """Set the custom title text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -560,10 +560,10 @@ def set_text(self, title_text, image_ids=None): ---------- title_text : {0} The custom title text for the specified images. - image_ids : {1} + file_ids : {1} The images to configure. """ - self._call_image_wcs_functions(image_ids, "title.set_text", title_text) + self._call_image_wcs_functions(file_ids, "title.set_text", title_text) class Grid(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): @@ -1047,12 +1047,12 @@ class ColorbarLabel(HasVisibility, HasCustomColor, HasCustomText, HasFont, HasRo PREFIX = "label" @validate(ImageWCSConnector.ANY_IDS) - def text(self, image_ids=None): + def text(self, file_ids=None): """The custom colorbar label text for the specified images. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. Returns @@ -1060,10 +1060,10 @@ def text(self, image_ids=None): tuple of string The colorbar label text of the specified images. """ - return self._get_image_wcs_properties(image_ids, "colorbar.label.text") + return self._get_image_wcs_properties(file_ids, "colorbar.label.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, label_text, image_ids=None): + def set_text(self, label_text, file_ids=None): """Set the custom colorbar label text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -1072,11 +1072,11 @@ def set_text(self, label_text, image_ids=None): ---------- label_text : {0} The custom colorbar label text for the specified images. - image_ids : {1} + file_ids : {1} The images to configure. """ - self._call_image_wcs_functions(image_ids, "colorbar.label.set_text", label_text) + self._call_image_wcs_functions(file_ids, "colorbar.label.set_text", label_text) class ColorbarGradient(HasVisibility, ColorbarComponent): @@ -1200,12 +1200,12 @@ class Beam(ImageWCSConnector, OverlayComponent): COMPONENT = Overlay.BEAM @validate(ImageWCSConnector.ANY_IDS) - def position(self, image_ids=None): + def position(self, file_ids=None): """The beam position. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1213,15 +1213,15 @@ def position(self, image_ids=None): tuple of (number, number) tuples The X and Y beam positions of the specified images, in pixels. """ - return self._get_image_wcs_properties(image_ids, "beam.position") + return self._get_image_wcs_properties(file_ids, "beam.position") @validate(ImageWCSConnector.ANY_IDS) - def type(self, image_ids=None): + def type(self, file_ids=None): """The beam type. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1229,15 +1229,15 @@ def type(self, image_ids=None): tuple of members of :obj:`carta.constants.BeamType` The beam types of the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.type") + return self._get_image_wcs_properties(file_ids, "beam.type") @validate(ImageWCSConnector.ANY_IDS) - def color(self, image_ids=None): + def color(self, file_ids=None): """The color of this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1245,15 +1245,15 @@ def color(self, image_ids=None): tuple of members of :obj:`carta.constants.color.PaletteColor` The colors of the beam in the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.color") + return self._get_image_wcs_properties(file_ids, "beam.color") @validate(ImageWCSConnector.ANY_IDS) - def visible(self, image_ids=None): + def visible(self, file_ids=None): """The visibility of this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1261,15 +1261,15 @@ def visible(self, image_ids=None): tuple of boolean Whether the beam is visible in the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.visible") + return self._get_image_wcs_properties(file_ids, "beam.visible") @validate(ImageWCSConnector.ANY_IDS) - def width(self, image_ids=None): + def width(self, file_ids=None): """The width of this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1277,10 +1277,10 @@ def width(self, image_ids=None): tuple of boolean The width of the beam in the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.width") + return self._get_image_wcs_properties(file_ids, "beam.width") @validate(*all_optional(Number(), Number(), ImageWCSConnector.ANY_IDS)) - def set_position(self, position_x=None, position_y=None, image_ids=None): + def set_position(self, position_x=None, position_y=None, file_ids=None): """Set the beam position. Parameters @@ -1289,84 +1289,84 @@ def set_position(self, position_x=None, position_y=None, image_ids=None): The X position, in pixels. position_y : {1} The Y position, in pixels. - image_ids : {2} + file_ids : {2} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_position", position_x, position_y) + self._call_image_wcs_functions(file_ids, "beam.set_position", position_x, position_y) @validate(Constant(BeamType), ImageWCSConnector.ANY_IDS) - def set_type(self, beam_type, image_ids=None): + def set_type(self, beam_type, file_ids=None): """Set the beam type. Parameters ---------- beam_type : {0} The beam type. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_type", beam_type) + self._call_image_wcs_functions(file_ids, "beam.set_type", beam_type) @validate(Constant(PaletteColor), ImageWCSConnector.ANY_IDS) - def set_color(self, color, image_ids=None): + def set_color(self, color, file_ids=None): """Set the color of this component. Parameters ---------- color : {0} The color. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_color", color) + self._call_image_wcs_functions(file_ids, "beam.set_color", color) @validate(Boolean(), ImageWCSConnector.ANY_IDS) - def set_visible(self, state, image_ids=None): + def set_visible(self, state, file_ids=None): """Set the visibility of this component. Parameters ---------- visible : {0} Whether this component should be visible. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_visible", state) + self._call_image_wcs_functions(file_ids, "beam.set_visible", state) @validate(ImageWCSConnector.ANY_IDS) - def show(self, image_ids=None): + def show(self, file_ids=None): """Show this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(True, image_ids) + self.set_visible(True, file_ids) @validate(ImageWCSConnector.ANY_IDS) - def hide(self, image_ids=None): + def hide(self, file_ids=None): """Hide this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(False, image_ids) + self.set_visible(False, file_ids) @validate(Number.POSITIVE, ImageWCSConnector.ANY_IDS) - def set_width(self, width, image_ids=None): + def set_width(self, width, file_ids=None): """Set the width of this component. Parameters ---------- width : {0} The width. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_width", width) + self._call_image_wcs_functions(file_ids, "beam.set_width", width) class ImageWCSOverlay(BasePathMixin): From e6d93cef0663f01e6f15fbee29fedecaac4f62b2 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 16:08:51 +0800 Subject: [PATCH 088/123] Add inverted property to Layer class and include it in repr output --- carta/color_blending.py | 15 ++++++++++++++- docs/source/quickstart.rst | 4 ++-- tests/test_color_blending.py | 11 ++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index b02c194..720e234 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -103,6 +103,7 @@ def __repr__(self): try: name = self.file_name colormap = self.colormap + inverted = self.inverted alpha = self.alpha except CartaScriptingException: return ( @@ -113,7 +114,8 @@ def __repr__(self): return ( f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " f"layer_id={self.layer_id}, file_name={name!r}, " - f"colormap={colormap!r}, alpha={alpha!r})" + f"colormap={colormap!r}, inverted={inverted!r}, " + f"alpha={alpha!r})" ) @property @@ -149,6 +151,17 @@ def colormap(self): """ return self.get_value("renderConfig.colorMap") + @property + def inverted(self): + """Whether the layer's colormap is inverted. + + Returns + ------- + boolean + Whether the colormap is inverted. + """ + return self.get_value("renderConfig.isInverted") + @property def alpha(self): """The alpha value of this layer in the color blending. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index f852b92..0e03383 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -322,8 +322,8 @@ Manipulate properties of the color blending object and the underlying layers: layer2.set_colormap(Colormap.GREENS) layer3.set_colormap(Colormap.BLUES) - # Inspect the colormap and alpha of an individual layer - print(layer1.colormap, layer1.alpha) + # Inspect the colormap, inversion, and alpha of an individual layer + print(layer1.colormap, layer1.inverted, layer1.alpha) # Or apply an existing colormap set cb.set_colormap_set(ColormapSet.RGB) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index b7bcd89..dc84a33 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -75,11 +75,13 @@ def test_layer_repr_healthy(session, color_blending, layer_property, mocker): layer_property("file_id", 42) layer_property("file_name", "layer1.fits") layer_property("colormap", "viridis") + layer_property("inverted", False) layer_property("alpha", 0.5) r = repr(Layer(color_blending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " - "file_name='layer1.fits', colormap='viridis', alpha=0.5)" + "file_name='layer1.fits', colormap='viridis', inverted=False, " + "alpha=0.5)" ) find.assert_called_once_with(ImageType.FRAME, 42) @@ -147,6 +149,13 @@ def test_layer_colormap_property(layer, layer_get_value): layer_get_value.assert_called_once_with("renderConfig.colorMap") +def test_layer_inverted_property(layer, layer_get_value): + layer_get_value.return_value = True + + assert layer.inverted is True + layer_get_value.assert_called_once_with("renderConfig.isInverted") + + def test_layer_alpha_property(layer, cb_get_value): cb_get_value.return_value = 0.5 From 2f49b2a0c939e4470f877140c6c5a80b59c2fc56 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 16:29:06 +0800 Subject: [PATCH 089/123] Update validate decorator to support keyword-only parameters by including spec.kwonlyargs in parameter name extraction --- carta/validation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/carta/validation.py b/carta/validation.py index 9e68d15..c99bc30 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -910,7 +910,7 @@ def description(self): def validate(*vargs): """The function which returns the decorator used to validate method parameters. - It is assumed that the function to be decorated is an object method and the first parameter is ``self``; this parameter is therefore ignored by the decorator. The remaining positional parameters are validated in order using the provided descriptors. The descriptors are also combined pairwise with the parameter names in the signature of the original function to create a dictionary for validating keyword parameters. + It is assumed that the function to be decorated is an object method and the first parameter is ``self``; this parameter is therefore ignored by the decorator. The remaining positional and keyword-only parameters are validated in order using the provided descriptors. The descriptors are also combined pairwise with the parameter names in the signature of the original function to create a dictionary for validating keyword parameters. Functions with ``*args`` or ``**kwargs`` are not currently supported: use iterables and explicit keyword parameters instead. @@ -932,7 +932,9 @@ def validate(*vargs): """ def decorator(func): - kwvargs = {k: v for (k, v) in zip(inspect.getfullargspec(func).args[1:], vargs)} + spec = inspect.getfullargspec(func) + parameter_names = spec.args[1:] + spec.kwonlyargs + kwvargs = {k: v for (k, v) in zip(parameter_names, vargs)} STRIP_OBJ = re.compile(":obj:`(.*)`") STRIP_CODE = re.compile("``(.*)``") PRESERVE = re.compile("(:obj:`.+?`|``.+?``)") From 8ea65e8b12db2212ebacae93da1c5757b6e4f73e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 16:29:53 +0800 Subject: [PATCH 090/123] Add validation decorators to ColorBlending.add_layer, ColorBlending.set_layer_image, and Session.image_by_id methods with corresponding test coverage --- carta/color_blending.py | 6 ++++- carta/session.py | 10 ++++---- tests/test_color_blending.py | 44 ++++++++++++++++++++++++++++++++++-- tests/test_session.py | 15 ++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 720e234..a0b9c56 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -337,6 +337,7 @@ def layer_list(self): """ return Layer.from_list(self, list(range(self.depth))) + @validate(InstanceOf(Image)) def add_layer(self, image): """Add a new layer to the color blending. @@ -368,7 +369,10 @@ def delete_layer(self, layer_index): return self.call_action("deleteSelectedFrame", layer_index - 1) - @validate(Number(0, None), InstanceOf(Image)) + @validate( + Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1), + InstanceOf(Image), + ) def set_layer_image(self, layer_index, image): """Set the image for a layer at a specified index in the color blending. diff --git a/carta/session.py b/carta/session.py index b7eb8ab..cc66b3a 100644 --- a/carta/session.py +++ b/carta/session.py @@ -688,7 +688,10 @@ def _find_image_view_order(self, image_type, stable_id): ) return image_view_order - def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): + @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) + def image_by_id( + self, *, image_view_order=None, file_id=None, color_blending_id=None + ): """Return the image-view item identified by exactly one of the supported identifiers. Parameters @@ -740,11 +743,6 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= ) if image_view_order is not None: - if image_view_order < 0: - raise IndexError( - f"image_view_order {image_view_order} is out of range " - "for the image list." - ) try: entry = self.get_value( "imageViewConfigStore.imageListSummary" diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index dc84a33..8897b48 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -365,6 +365,15 @@ def test_color_blending_add_layer(color_blending, cb_call_action, image): cb_call_action.assert_called_with("addSelectedFrame", image._frame) +def test_color_blending_add_layer_rejects_invalid_image( + color_blending, cb_call_action +): + with pytest.raises(CartaValidationFailed): + color_blending.add_layer(object()) + + cb_call_action.assert_not_called() + + @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) def test_color_blending_delete_layer( color_blending, cb_call_action, idx, expected_param, mocker @@ -441,15 +450,46 @@ def test_color_blending_delete_layer_rejects_out_of_range( @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) def test_color_blending_set_layer_image( - color_blending, cb_call_action, image, idx, expected_param + color_blending, cb_call_action, image, idx, expected_param, mocker ): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=6, + ) color_blending.set_layer_image(idx, image) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame ) -def test_color_blending_set_base_layer_image(color_blending, cb_call_action, image, mocker): +@pytest.mark.parametrize("idx", [-1, 2]) +def test_color_blending_set_layer_image_rejects_out_of_range( + color_blending, cb_call_action, image, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.set_layer_image(idx, image) + + cb_call_action.assert_not_called() + + +def test_color_blending_set_base_layer_image( + color_blending, cb_call_action, image, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=1, + ) set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") diff --git a/tests/test_session.py b/tests/test_session.py index c50cb8e..58aacb1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -274,6 +274,21 @@ def test_image_by_id_rejects_positional(session): session.image_by_id(0) +@pytest.mark.parametrize("keyword", [ + "image_view_order", + "file_id", + "color_blending_id", +]) +@pytest.mark.parametrize("value", [-1, 1.5, "1"]) +def test_image_by_id_rejects_invalid_identifier( + session, get_value, keyword, value +): + with pytest.raises(CartaValidationFailed): + session.image_by_id(**{keyword: value}) + + get_value.assert_not_called() + + @pytest.mark.parametrize( "entry,expected_type,expected_id", [ From 272366c608e25512c91be8f104e0e24dc90fea78 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 19:31:03 +0800 Subject: [PATCH 091/123] Simplify Session.image_by_id to directly instantiate Image and ColorBlending classes instead of using ImageBase.image_class factory method --- carta/session.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/carta/session.py b/carta/session.py index cc66b3a..049179a 100644 --- a/carta/session.py +++ b/carta/session.py @@ -765,9 +765,7 @@ def image_by_id( raise RuntimeError( f"No frame-backed image with file_id={file_id} is open." ) from e - return ImageBase.image_class(ImageType.FRAME)( - self, resolved_file_id - ) + return Image(self, resolved_file_id) # color_blending_id is not None try: @@ -781,9 +779,7 @@ def image_by_id( f"No color blending with color_blending_id={color_blending_id} " "is open." ) from e - return ImageBase.image_class(ImageType.COLOR_BLENDING)( - self, resolved_color_blending_id - ) + return ColorBlending(self, resolved_color_blending_id) def active_image(self): """Return the currently active image-view item. From 03be496659af8c1824bd3fdd3c13a7abe3d977c4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 21:24:12 +0800 Subject: [PATCH 092/123] Rename image_view_order to view_index and file_id to image_id throughout codebase, refactor ImageBase to View class, and update related documentation and method names for consistency --- carta/color_blending.py | 50 +++++----- carta/constants.py | 2 +- carta/image.py | 38 ++++---- carta/image_base.py | 87 ----------------- carta/session.py | 169 +++++++++++++++++---------------- carta/view.py | 75 +++++++++++++++ carta/wcs_overlay.py | 104 ++++++++++----------- docs/source/carta.rst | 6 +- docs/source/quickstart.rst | 41 ++++---- tests/test_color_blending.py | 76 +++++++-------- tests/test_image.py | 84 +++++++++-------- tests/test_session.py | 176 ++++++++++++++++++++--------------- 12 files changed, 467 insertions(+), 441 deletions(-) delete mode 100644 carta/image_base.py create mode 100644 carta/view.py diff --git a/carta/color_blending.py b/carta/color_blending.py index a0b9c56..ace85cc 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -2,7 +2,7 @@ from .constants import Colormap, ColormapSet, ImageType, SpatialAxis from .image import Image -from .image_base import ImageBase +from .view import View from .util import BasePathMixin, CartaScriptingException, Macro from .validation import ( Boolean, @@ -65,27 +65,25 @@ def from_list(cls, color_blending, layer_ids): return [cls(color_blending, layer_id) for layer_id in layer_ids] @property - def image_view_order(self): - """The image-view order of this layer's underlying frame. + def view_index(self): + """The view index of this layer's underlying image. - This is the position of the underlying frame in the session's image - list. A layer does not occupy its own position in the image list; - its parent color blending does (see - :obj:`carta.color_blending.ColorBlending.image_view_order`). + This is the position of the underlying image in the session's views. + A layer does not occupy its own position in the views; its parent + color blending does (see + :obj:`carta.color_blending.ColorBlending.view_index`). Returns ------- integer - The image-view order of the underlying frame. + The view index of the underlying image. Raises ------ RuntimeError - If no matching frame entry exists in the image list. + If no matching image entry exists in the views. """ - return self.session._find_image_view_order( - ImageType.FRAME, self.file_id - ) + return self.session._find_view_index(ImageType.FRAME, self.image_id) def __repr__(self): """A human-readable representation of this layer.""" @@ -93,10 +91,10 @@ def __repr__(self): cb_id = self.color_blending.color_blending_id try: - order = self.image_view_order + index = self.view_index except (CartaScriptingException, RuntimeError): return ( - f"[Closed] {cls}(image_view_order=None, " + f"[Closed] {cls}(view_index=None, " f"color_blending_id={cb_id}, layer_id={self.layer_id})" ) @@ -107,12 +105,12 @@ def __repr__(self): alpha = self.alpha except CartaScriptingException: return ( - f"[Closed] {cls}(image_view_order={order}, " + f"[Closed] {cls}(view_index={index}, " f"color_blending_id={cb_id}, layer_id={self.layer_id})" ) return ( - f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " + f"{cls}(view_index={index}, color_blending_id={cb_id}, " f"layer_id={self.layer_id}, file_name={name!r}, " f"colormap={colormap!r}, inverted={inverted!r}, " f"alpha={alpha!r})" @@ -130,13 +128,13 @@ def file_name(self): return self.get_value("frameInfo.fileInfo.name") @property - def file_id(self): - """The frontend file id of the layer's underlying image. + def image_id(self): + """The frontend image id of the layer's underlying image. Returns ------- integer - The file id. + The image id. """ return self.get_value("frameInfo.fileId") @@ -214,7 +212,7 @@ def set_colormap(self, colormap, invert=False): self.call_action("renderConfig.setInverted", invert) -class ColorBlending(ImageBase, BasePathMixin): +class ColorBlending(View, BasePathMixin): """This object represents a color blending image in a session. Parameters @@ -232,7 +230,7 @@ class ColorBlending(ImageBase, BasePathMixin): The id of the backing ``ColorBlendingStore`` on the frontend. """ - IMAGE_TYPE = ImageType.COLOR_BLENDING + VIEW_TYPE = ImageType.COLOR_BLENDING def __init__(self, session, color_blending_id): super().__init__(session) @@ -251,10 +249,10 @@ def __repr__(self): cls = type(self).__name__ try: - order = self.image_view_order + index = self.view_index except (CartaScriptingException, RuntimeError): return ( - f"[Closed] {cls}(image_view_order=None, " + f"[Closed] {cls}(view_index=None, " f"color_blending_id={self.color_blending_id})" ) @@ -262,12 +260,12 @@ def __repr__(self): name = self.file_name except CartaScriptingException: return ( - f"[Closed] {cls}(image_view_order={order}, " + f"[Closed] {cls}(view_index={index}, " f"color_blending_id={self.color_blending_id})" ) return ( - f"{cls}(image_view_order={order}, " + f"{cls}(view_index={index}, " f"color_blending_id={self.color_blending_id}, " f"file_name={name!r})" ) @@ -365,7 +363,7 @@ def delete_layer(self, layer_index): self.close() return - Image(self.session, layers[1].file_id).make_spatial_reference() + Image(self.session, layers[1].image_id).make_spatial_reference() return self.call_action("deleteSelectedFrame", layer_index - 1) diff --git a/carta/constants.py b/carta/constants.py index 0cdf183..87dc673 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -31,7 +31,7 @@ class ColormapSet(StrEnum): class ImageType(IntEnum): - """Image view item types, corresponding to the frontend ImageType enum.""" + """View item types, corresponding to the frontend ImageType enum.""" FRAME = 0 COLOR_BLENDING = 1 PV_PREVIEW = 2 diff --git a/carta/image.py b/carta/image.py index d635911..bd2c2fa 100644 --- a/carta/image.py +++ b/carta/image.py @@ -1,11 +1,11 @@ -"""This module contains the image classes representing image-view items open in the session. +"""This module contains the image classes representing images open in the session. Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .image_base import ImageBase +from .view import View from .util import Macro, cached, BasePathMixin, CartaScriptingException, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point @@ -17,8 +17,8 @@ from .region import RegionSet -class Image(ImageBase, BasePathMixin): - """This object corresponds to a frame-backed image open in a CARTA frontend session. +class Image(View, BasePathMixin): + """This object corresponds to an image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. @@ -26,15 +26,15 @@ class Image(ImageBase, BasePathMixin): ---------- session : :obj:`carta.session.Session` The session object associated with this image. - file_id : integer - The frontend file ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. + image_id : integer + The frontend image ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. - file_id : integer - The frontend file ID identifying this image within the session. + image_id : integer + The frontend image ID identifying this image within the session. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` @@ -47,13 +47,13 @@ class Image(ImageBase, BasePathMixin): Functions for manipulating regions associated with this image. """ - IMAGE_TYPE = ImageType.FRAME + VIEW_TYPE = ImageType.FRAME - def __init__(self, session, file_id): + def __init__(self, session, image_id): super().__init__(session) - self.file_id = file_id + self.image_id = image_id - self._base_path = f"frameMap[{file_id}]" + self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) # Sub-objects grouping related functions @@ -65,7 +65,7 @@ def __init__(self, session, file_id): @property def _stable_id(self): - return self.file_id + return self.image_id @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): @@ -105,8 +105,8 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(make_active) params.append(update_directory) - file_id = session.call_action(command, *params, return_path="frameInfo.fileId") - return cls(session, file_id) + image_id = session.call_action(command, *params, return_path="frameInfo.fileId") + return cls(session, image_id) def __repr__(self): """A human-readable representation of this image object.""" @@ -115,16 +115,16 @@ def __repr__(self): name_part = f", file_name={cached_name!r}" if cached_name is not None else "" try: - order = self.image_view_order + index = self.view_index except (CartaScriptingException, RuntimeError): - return f"[Closed] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(view_index=None{name_part}, image_id={self.image_id})" try: name = self.file_name except CartaScriptingException: - return f"[Closed] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(view_index={index}{name_part}, image_id={self.image_id})" - return f"{cls}(image_view_order={order}, file_name={name!r}, file_id={self.file_id})" + return f"{cls}(view_index={index}, file_name={name!r}, image_id={self.image_id})" # METADATA diff --git a/carta/image_base.py b/carta/image_base.py deleted file mode 100644 index 81f0a0b..0000000 --- a/carta/image_base.py +++ /dev/null @@ -1,87 +0,0 @@ -"""This module contains the shared base class for image-view items (frame-backed images and color blendings). - -The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` can share a common protocol without one having to import the other. -""" - - -from abc import ABC, abstractmethod - -from .constants import ImageType -from .validation import Constant, validate - - -class ImageBase(ABC): - """Base class for image-view items (frame-backed images and color blendings). - - This class is not intended to be instantiated directly. - - Attributes - ---------- - session : :obj:`carta.session.Session` - The session object associated with this image-view item. - """ - - CUSTOM_CLASS = {} - """Mapping of image-view types to their concrete wrapper classes.""" - - def __init_subclass__(cls, **kwargs): - """Register concrete image-view wrapper subclasses by image type.""" - super().__init_subclass__(**kwargs) - ImageBase.CUSTOM_CLASS[cls.IMAGE_TYPE] = cls - - def __init__(self, session): - self.session = session - - @property - @abstractmethod - def _stable_id(self): - """The stable identifier of this image-view item.""" - raise NotImplementedError # pragma: no cover - - @classmethod - @validate(Constant(ImageType)) - def image_class(cls, image_type): - """The image class associated with an image-view type. - - Parameters - ---------- - image_type : {0} - The image-view type. - - Returns - ------- - class object - The concrete image-view wrapper class. - """ - image_type = ImageType(image_type) - image_class = cls.CUSTOM_CLASS.get(image_type) - if image_class is None: - raise NotImplementedError( - f"No ImageBase subclass is registered for image-view type " - f"{image_type!r}." - ) - return image_class - - @property - def image_view_order(self): - """The current index of this item in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching entry exists in the image list. - """ - return self.session._find_image_view_order( - self.IMAGE_TYPE, self._stable_id - ) - - def make_active(self): - """Make this the active image-view item.""" - self.session.call_action( - "setActiveImageById", self.IMAGE_TYPE, self._stable_id - ) diff --git a/carta/session.py b/carta/session.py index 049179a..4f2679d 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,9 +10,9 @@ import posixpath from .image import Image -from .image_base import ImageBase +from .view import View from .color_blending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet +from .constants import PanelMode, GridMode, ComplexComponent, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt @@ -534,8 +534,8 @@ def open_hypercube(self, image_paths, append=False): output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" - file_id = self.call_action(command, stokes_images, output_directory, output_hdu) - return Image(self, file_id) + image_id = self.call_action(command, stokes_images, output_directory, output_hdu) + return Image(self, image_id) @validate(IterableOf(String(), min_size=1)) def open_as_color_blending(self, files): @@ -564,34 +564,45 @@ def open_as_color_blending(self, files): cb = self.create_color_blending() return cb - # IMAGE-VIEW ITEMS + # VIEWS - def image_list(self): - """Return the list of currently open image-view items. + @validate(NoneOr(IterableOf(Number.ID))) + def views(self, view_indices=None): + """Return all or selected currently open views. + + When no indices are supplied, all open views are returned. When + indices are supplied, the views at those positions are returned in + the requested order. Returns ------- - list of :obj:`carta.image_base.ImageBase` - The heterogeneous list of image-view items open in this session. + list of :obj:`carta.view.View` + The requested heterogeneous views open in this session. """ + if view_indices is not None: + return [ + self.view_by_id(view_index=view_index) + for view_index in view_indices + ] + summary = self.get_value("imageViewConfigStore.imageListSummary") result = [] - for order, entry in enumerate(summary): + for index, entry in enumerate(summary): try: result.append( - ImageBase.image_class(entry["type"])(self, entry["id"]) + View.view_class(entry["type"])(self, entry["id"]) ) except (CartaValidationFailed, NotImplementedError) as e: print( - f"Skipping unsupported image-view entry at order {order}: " + f"Skipping unsupported view entry at index {index}: " f"{entry!r}: {e}" ) return result - def _image_list(self, path, image_class, return_path): + def _view_list(self, path, view_class, return_path): count = self.get_value(f"{path}.length") return [ - image_class( + view_class( self, self.get_value( f"{path}[{index}]", @@ -602,27 +613,25 @@ def _image_list(self, path, image_class, return_path): ] @validate(NoneOr(IterableOf(Number.ID))) - def images(self, file_ids=None): - """Return frame-backed images from the session. + def images(self, image_ids=None): + """Return images from the session. - When no IDs are supplied, all open frame-backed images are returned. - When IDs are supplied, they are validated against the session's frame - map. + When no IDs are supplied, all open images are returned. When IDs are + supplied, they are validated against the session's image map. Parameters ---------- - file_ids : {0} - The file IDs of the images to return. By default, all open - frame-backed images are returned. + image_ids : {0} + The image IDs to return. By default, all open images are returned. Returns ------- list of :obj:`carta.image.Image` - The requested frame-backed images. + The requested images. """ - if file_ids is None: - return self._image_list("frames", Image, "frameInfo.fileId") - return [self.image_by_id(file_id=file_id) for file_id in file_ids] + if image_ids is None: + return self._view_list("frames", Image, "frameInfo.fileId") + return [self.view_by_id(image_id=image_id) for image_id in image_ids] @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): @@ -644,74 +653,74 @@ def color_blendings(self, color_blending_ids=None): The requested color blending images. """ if color_blending_ids is None: - return self._image_list( + return self._view_list( "imageViewConfigStore.colorBlendingImages", ColorBlending, "id", ) return [ - self.image_by_id(color_blending_id=color_blending_id) + self.view_by_id(color_blending_id=color_blending_id) for color_blending_id in color_blending_ids ] - def _find_image_view_order(self, image_type, stable_id): - """Return the image-view order of an item identified by a stable id. + def _find_view_index(self, view_type, stable_id): + """Return the view index of an item identified by a stable id. Parameters ---------- - image_type : :obj:`carta.constants.ImageType` - The image-view item type. + view_type : :obj:`carta.constants.ImageType` + The view type. stable_id : integer - The stable id for that type (``file_id`` for frames, + The stable id for that type (``image_id`` for images, ``color_blending_id`` for color blendings). Returns ------- integer - The image-view order of the matching entry. + The view index of the matching entry. Raises ------ RuntimeError - If no matching entry exists in the image list. + If no matching entry exists in the views. """ - image_view_order = self.call_action( + view_index = self.call_action( "imageViewConfigStore.getImageListIndex", - image_type, + view_type, stable_id, response_expected=True, ) - if image_view_order == -1: + if view_index == -1: raise RuntimeError( - f"Could not find an image of type {image_type!r} with id " - f"{stable_id} in the image list." + f"Could not find a view of type {view_type!r} with id " + f"{stable_id} in the views." ) - return image_view_order + return view_index @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) - def image_by_id( - self, *, image_view_order=None, file_id=None, color_blending_id=None + def view_by_id( + self, *, view_index=None, image_id=None, color_blending_id=None ): - """Return the image-view item identified by exactly one of the supported identifiers. + """Return the view identified by exactly one supported identifier. Parameters ---------- - image_view_order : integer, optional - The index of the item in the image list. + view_index : integer, optional + The index of the item in the views. Returns whichever concrete wrapper (:obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending`) matches the entry type at that position. Raises :obj:`NotImplementedError` for any future entry type that is not yet wrapped on the Python side. - file_id : integer, optional - The stable frontend file id of a normal frame-backed image. + image_id : integer, optional + The stable id of an image. color_blending_id : integer, optional The stable id of a color blending. Returns ------- - :obj:`carta.image_base.ImageBase` - The matching image-view item. + :obj:`carta.view.View` + The matching view. Raises ------ @@ -720,14 +729,14 @@ def image_by_id( The error message lists the three accepted keyword names so the API is discoverable from the exception alone. IndexError - If ``image_view_order`` is out of range. + If ``view_index`` is out of range. RuntimeError - If no matching entry exists for the given ``file_id`` or + If no matching entry exists for the given ``image_id`` or ``color_blending_id``. There is no cross-type fallback. """ provided = { - "image_view_order": image_view_order, - "file_id": file_id, + "view_index": view_index, + "image_id": image_id, "color_blending_id": color_blending_id, } provided_values = { @@ -737,35 +746,34 @@ def image_by_id( } if len(provided_values) != 1: raise ValueError( - "image_by_id requires exactly one of the keyword arguments " - "`image_view_order`, `file_id`, or `color_blending_id`; " + "view_by_id requires exactly one of the keyword arguments " + "`view_index`, `image_id`, or `color_blending_id`; " f"got {len(provided_values)} with values {provided_values!r}." ) - if image_view_order is not None: + if view_index is not None: try: entry = self.get_value( "imageViewConfigStore.imageListSummary" - f"[{image_view_order}]" + f"[{view_index}]" ) except (CartaActionFailed, CartaBadResponse) as e: raise IndexError( - f"image_view_order {image_view_order} is out of range " - "for the image list." + f"view_index {view_index} is out of range for the views." ) from e - return ImageBase.image_class(entry["type"])(self, entry["id"]) + return View.view_class(entry["type"])(self, entry["id"]) - if file_id is not None: + if image_id is not None: try: - resolved_file_id = self.get_value( - f"frameMap[{file_id}]", + resolved_image_id = self.get_value( + f"frameMap[{image_id}]", return_path="frameInfo.fileId", ) except (CartaActionFailed, CartaBadResponse) as e: raise RuntimeError( - f"No frame-backed image with file_id={file_id} is open." + f"No image with image_id={image_id} is open." ) from e - return Image(self, resolved_file_id) + return Image(self, resolved_image_id) # color_blending_id is not None try: @@ -781,25 +789,24 @@ def image_by_id( ) from e return ColorBlending(self, resolved_color_blending_id) - def active_image(self): - """Return the currently active image-view item. + def active_view(self): + """Return the currently active view. - This is the frame-backed image or color blending image that is - currently active in the viewer. + This is the image or color blending image that is currently active. Returns ------- :obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending` - The currently active image-view item. + The currently active view. Raises ------ NotImplementedError - If the active image is of a type that is not yet wrapped on + If the active view is of a type that is not yet wrapped on the Python side. """ active = self.get_value("activeImage") - return ImageBase.image_class(active["type"])( + return View.view_class(active["type"])( self, active["store"]["id"] ) @@ -807,7 +814,7 @@ def active_image(self): def create_color_blending(self): """Create a new color blending from the current spatial reference - and its currently spatially matched frames. + and its currently spatially matched images. Returns ------- @@ -817,12 +824,12 @@ def create_color_blending(self): Raises ------ CartaActionFailed - If no frames are open or the frontend could not create the + If no images are open or the frontend could not create the color blending image. """ - frame_count = self.get_value("frames.length") - if frame_count <= 0: - raise CartaActionFailed("No frames are open.") + image_count = self.get_value("frames.length") + if image_count <= 0: + raise CartaActionFailed("No images are open.") color_blending_id = self.call_action( "imageViewConfigStore.createColorBlending", @@ -913,7 +920,7 @@ def set_cursor(self, x, y): @validate(NoneOr(Color())) def rendered_view_url(self, background_color=None): - """Get a data URL of the rendered active image. + """Get a data URL of the rendered active view. Parameters ---------- @@ -934,7 +941,7 @@ def rendered_view_url(self, background_color=None): @validate(NoneOr(Color())) def rendered_view_data(self, background_color=None): - """Get the decoded data of the rendered active image. + """Get the decoded data of the rendered active view. Parameters ---------- @@ -953,7 +960,7 @@ def rendered_view_data(self, background_color=None): @validate(String(), NoneOr(Color())) def save_rendered_view(self, file_name, background_color=None): - """Save the decoded data of the rendered active image to a file. + """Save the decoded data of the rendered active view to a file. Parameters ---------- diff --git a/carta/view.py b/carta/view.py new file mode 100644 index 0000000..3362637 --- /dev/null +++ b/carta/view.py @@ -0,0 +1,75 @@ +"""Shared wrapper for heterogeneous views in a CARTA session. + +The class in this module should not be instantiated directly. It exists so +that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` +can share a common protocol without one having to import the other. +""" + + +from abc import ABC, abstractmethod + +from .constants import ImageType +from .validation import Constant, validate + + +class View(ABC): + """Base class for views (images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this view. + """ + + CUSTOM_CLASS = {} + """Mapping of view types to their concrete wrapper classes.""" + + def __init_subclass__(cls, **kwargs): + """Register concrete view wrapper subclasses by view type.""" + super().__init_subclass__(**kwargs) + View.CUSTOM_CLASS[cls.VIEW_TYPE] = cls + + def __init__(self, session): + self.session = session + + @property + @abstractmethod + def _stable_id(self): + """The stable identifier of this view.""" + raise NotImplementedError # pragma: no cover + + @classmethod + @validate(Constant(ImageType)) + def view_class(cls, view_type): + """The wrapper class associated with a view type. + + Parameters + ---------- + view_type : {0} + The view type. + + Returns + ------- + class object + The concrete view wrapper class. + """ + view_type = ImageType(view_type) + view_class = cls.CUSTOM_CLASS.get(view_type) + if view_class is None: + raise NotImplementedError( + f"No View subclass is registered for view type {view_type!r}." + ) + return view_class + + @property + def view_index(self): + """The current index of this view in the session's views.""" + return self.session._find_view_index(self.VIEW_TYPE, self._stable_id) + + def make_active(self): + """Make this view the active view.""" + self.session.call_action( + "setActiveImageById", self.VIEW_TYPE, self._stable_id + ) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index 6041f05..f59396c 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,14 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _get_image_wcs_properties(self, file_ids, property_path): + def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self.session.images(file_ids) + images = self.session.images(image_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) - def _call_image_wcs_functions(self, file_ids, function_path, *function_args): + def _call_image_wcs_functions(self, image_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self.session.images(file_ids) + images = self.session.images(image_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) @@ -535,12 +535,12 @@ class Title(HasCustomColor, HasCustomText, HasFont, HasVisibility, ImageWCSConne COMPONENT = Overlay.TITLE @validate(ImageWCSConnector.ANY_IDS) - def text(self, file_ids=None): + def text(self, image_ids=None): """The custom title text for the specified images. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. Returns @@ -548,10 +548,10 @@ def text(self, file_ids=None): tuple of string The title text of the specified images. """ - return self._get_image_wcs_properties(file_ids, "title.text") + return self._get_image_wcs_properties(image_ids, "title.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, title_text, file_ids=None): + def set_text(self, title_text, image_ids=None): """Set the custom title text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -560,10 +560,10 @@ def set_text(self, title_text, file_ids=None): ---------- title_text : {0} The custom title text for the specified images. - file_ids : {1} + image_ids : {1} The images to configure. """ - self._call_image_wcs_functions(file_ids, "title.set_text", title_text) + self._call_image_wcs_functions(image_ids, "title.set_text", title_text) class Grid(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): @@ -1047,12 +1047,12 @@ class ColorbarLabel(HasVisibility, HasCustomColor, HasCustomText, HasFont, HasRo PREFIX = "label" @validate(ImageWCSConnector.ANY_IDS) - def text(self, file_ids=None): + def text(self, image_ids=None): """The custom colorbar label text for the specified images. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. Returns @@ -1060,10 +1060,10 @@ def text(self, file_ids=None): tuple of string The colorbar label text of the specified images. """ - return self._get_image_wcs_properties(file_ids, "colorbar.label.text") + return self._get_image_wcs_properties(image_ids, "colorbar.label.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, label_text, file_ids=None): + def set_text(self, label_text, image_ids=None): """Set the custom colorbar label text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -1072,11 +1072,11 @@ def set_text(self, label_text, file_ids=None): ---------- label_text : {0} The custom colorbar label text for the specified images. - file_ids : {1} + image_ids : {1} The images to configure. """ - self._call_image_wcs_functions(file_ids, "colorbar.label.set_text", label_text) + self._call_image_wcs_functions(image_ids, "colorbar.label.set_text", label_text) class ColorbarGradient(HasVisibility, ColorbarComponent): @@ -1200,12 +1200,12 @@ class Beam(ImageWCSConnector, OverlayComponent): COMPONENT = Overlay.BEAM @validate(ImageWCSConnector.ANY_IDS) - def position(self, file_ids=None): + def position(self, image_ids=None): """The beam position. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1213,15 +1213,15 @@ def position(self, file_ids=None): tuple of (number, number) tuples The X and Y beam positions of the specified images, in pixels. """ - return self._get_image_wcs_properties(file_ids, "beam.position") + return self._get_image_wcs_properties(image_ids, "beam.position") @validate(ImageWCSConnector.ANY_IDS) - def type(self, file_ids=None): + def type(self, image_ids=None): """The beam type. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1229,15 +1229,15 @@ def type(self, file_ids=None): tuple of members of :obj:`carta.constants.BeamType` The beam types of the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.type") + return self._get_image_wcs_properties(image_ids, "beam.type") @validate(ImageWCSConnector.ANY_IDS) - def color(self, file_ids=None): + def color(self, image_ids=None): """The color of this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1245,15 +1245,15 @@ def color(self, file_ids=None): tuple of members of :obj:`carta.constants.color.PaletteColor` The colors of the beam in the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.color") + return self._get_image_wcs_properties(image_ids, "beam.color") @validate(ImageWCSConnector.ANY_IDS) - def visible(self, file_ids=None): + def visible(self, image_ids=None): """The visibility of this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1261,15 +1261,15 @@ def visible(self, file_ids=None): tuple of boolean Whether the beam is visible in the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.visible") + return self._get_image_wcs_properties(image_ids, "beam.visible") @validate(ImageWCSConnector.ANY_IDS) - def width(self, file_ids=None): + def width(self, image_ids=None): """The width of this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1277,10 +1277,10 @@ def width(self, file_ids=None): tuple of boolean The width of the beam in the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.width") + return self._get_image_wcs_properties(image_ids, "beam.width") @validate(*all_optional(Number(), Number(), ImageWCSConnector.ANY_IDS)) - def set_position(self, position_x=None, position_y=None, file_ids=None): + def set_position(self, position_x=None, position_y=None, image_ids=None): """Set the beam position. Parameters @@ -1289,84 +1289,84 @@ def set_position(self, position_x=None, position_y=None, file_ids=None): The X position, in pixels. position_y : {1} The Y position, in pixels. - file_ids : {2} + image_ids : {2} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_position", position_x, position_y) + self._call_image_wcs_functions(image_ids, "beam.set_position", position_x, position_y) @validate(Constant(BeamType), ImageWCSConnector.ANY_IDS) - def set_type(self, beam_type, file_ids=None): + def set_type(self, beam_type, image_ids=None): """Set the beam type. Parameters ---------- beam_type : {0} The beam type. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_type", beam_type) + self._call_image_wcs_functions(image_ids, "beam.set_type", beam_type) @validate(Constant(PaletteColor), ImageWCSConnector.ANY_IDS) - def set_color(self, color, file_ids=None): + def set_color(self, color, image_ids=None): """Set the color of this component. Parameters ---------- color : {0} The color. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_color", color) + self._call_image_wcs_functions(image_ids, "beam.set_color", color) @validate(Boolean(), ImageWCSConnector.ANY_IDS) - def set_visible(self, state, file_ids=None): + def set_visible(self, state, image_ids=None): """Set the visibility of this component. Parameters ---------- visible : {0} Whether this component should be visible. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_visible", state) + self._call_image_wcs_functions(image_ids, "beam.set_visible", state) @validate(ImageWCSConnector.ANY_IDS) - def show(self, file_ids=None): + def show(self, image_ids=None): """Show this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(True, file_ids) + self.set_visible(True, image_ids) @validate(ImageWCSConnector.ANY_IDS) - def hide(self, file_ids=None): + def hide(self, image_ids=None): """Hide this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(False, file_ids) + self.set_visible(False, image_ids) @validate(Number.POSITIVE, ImageWCSConnector.ANY_IDS) - def set_width(self, width, file_ids=None): + def set_width(self, width, image_ids=None): """Set the width of this component. Parameters ---------- width : {0} The width. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_width", width) + self._call_image_wcs_functions(image_ids, "beam.set_width", width) class ImageWCSOverlay(BasePathMixin): diff --git a/docs/source/carta.rst b/docs/source/carta.rst index ab20c1d..9ab1ea6 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -49,10 +49,10 @@ carta.image module :undoc-members: :show-inheritance: -carta.image\_base module ------------------------- +carta.view module +----------------- -.. automodule:: carta.image_base +.. automodule:: carta.view :members: :undoc-members: :show-inheritance: diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 0e03383..8ea84c0 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -189,36 +189,39 @@ Helper methods on the session object open images in the frontend and return imag Inspecting the list of open images ---------------------------------- -The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. +The session's views are heterogeneous: they may contain both ordinary images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Their order matches the views panel shown in the frontend, as illustrated below. .. figure:: images/image_list.jpg - :alt: CARTA frontend image list panel showing frame-backed images and a color blending entry. + :alt: CARTA frontend views panel showing images and a color blending entry. :align: center - The frontend image list panel. Each row corresponds to an item returned by :obj:`carta.session.Session.image_list`, and its position in the list is the item's ``image_view_order``. + The frontend views panel. Each row corresponds to an item returned by :obj:`carta.session.Session.views`, and its position in the list is the item's ``view_index``. Use :meth:`carta.session.Session.images` and :meth:`carta.session.Session.color_blendings` to retrieve the two concrete -image-view types directly, optionally filtering by their stable IDs. +view types directly, optionally filtering by their stable IDs. .. code-block:: python - # All open image-view items, in display order - items = session.image_list() + # All open views, in display order + items = session.views() - # Get frame-backed images or color blending images directly + # Select views by their current indices, preserving the requested order + selected = session.views(view_indices=[1, 0]) + + # Get images or color blending images directly images = session.images() color_blendings = session.color_blendings() - # Every image-view item exposes its current image-view order - print(img0.image_view_order) + # Every view exposes its current view index + print(img0.view_index) - # Retrieve a specific item by image view order - img = session.image_by_id(image_view_order=0) - cb = session.image_by_id(image_view_order=1) + # Retrieve a specific view by view index + img = session.view_by_id(view_index=0) + cb = session.view_by_id(view_index=1) # Filter by stable IDs when needed - images = session.images(file_ids=[img0.file_id, img1.file_id]) + images = session.images(image_ids=[img0.image_id, img1.image_id]) color_blendings = session.color_blendings( color_blending_ids=[cb.color_blending_id] ) @@ -295,7 +298,7 @@ The session object provides two convenience methods which create a color blendin cb = session.open_as_color_blending(files) # Create a new color blending from the current spatial reference - # and its currently spatially matched frames. + # and its currently spatially matched images. # Set the desired base image as the current spatial reference and # enable spatial matching for the other layers first. session.clear_spatial_reference() @@ -368,10 +371,10 @@ Manipulate properties of the color blending object and the underlying layers: # Set zoom level cb.set_zoom_level(2) - # Get the current image-view order of the color blending image - print(cb.image_view_order) + # Get the current view index of the color blending image + print(cb.view_index) - # Set the color blending object as the active image-view item + # Set the color blending object as the active view cb.make_active() # Set contour visibility @@ -422,8 +425,8 @@ Closing images .. code-block:: python - # Close all image-view items open in the session - for item in session.image_list(): + # Close all views open in the session + for item in session.views(): item.close() Closing the session diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 8897b48..470560b 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -71,46 +71,46 @@ def test_layer_from_list(color_blending): def test_layer_repr_healthy(session, color_blending, layer_property, mocker): - find = mocker.patch.object(session, "_find_image_view_order", return_value=2) - layer_property("file_id", 42) + find = mocker.patch.object(session, "_find_view_index", return_value=2) + layer_property("image_id", 42) layer_property("file_name", "layer1.fits") layer_property("colormap", "viridis") layer_property("inverted", False) layer_property("alpha", 0.5) r = repr(Layer(color_blending, 3)) assert r == ( - "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " + "Layer(view_index=2, color_blending_id=0, layer_id=3, " "file_name='layer1.fits', colormap='viridis', inverted=False, " "alpha=0.5)" ) find.assert_called_once_with(ImageType.FRAME, 42) -def test_layer_repr_closed_when_frame_not_in_image_list( +def test_layer_repr_closed_when_image_not_in_views( session, color_blending, layer_property, mocker ): - layer_property("file_id", 42) + layer_property("image_id", 42) mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) r = repr(Layer(color_blending, 3)) assert r == ( - "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "[Closed] Layer(view_index=None, color_blending_id=0, " "layer_id=3)" ) -def test_layer_repr_closed_when_frame_is_gone(session, color_blending, mocker): +def test_layer_repr_closed_when_image_is_gone(session, color_blending, mocker): mocker.patch( - "carta.color_blending.Layer.file_id", + "carta.color_blending.Layer.image_id", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("frame is gone"), ) r = repr(Layer(color_blending, 3)) assert r == ( - "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "[Closed] Layer(view_index=None, color_blending_id=0, " "layer_id=3)" ) @@ -118,8 +118,8 @@ def test_layer_repr_closed_when_frame_is_gone(session, color_blending, mocker): def test_layer_repr_closed_when_file_name_read_fails( session, color_blending, layer_property, mocker ): - mocker.patch.object(session, "_find_image_view_order", return_value=2) - layer_property("file_id", 42) + mocker.patch.object(session, "_find_view_index", return_value=2) + layer_property("image_id", 42) mocker.patch( "carta.color_blending.Layer.file_name", new_callable=mocker.PropertyMock, @@ -127,7 +127,7 @@ def test_layer_repr_closed_when_file_name_read_fails( ) r = repr(Layer(color_blending, 3)) assert r == ( - "[Closed] Layer(image_view_order=2, color_blending_id=0, " + "[Closed] Layer(view_index=2, color_blending_id=0, " "layer_id=3)" ) @@ -137,8 +137,8 @@ def test_layer_file_name_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileInfo.name") -def test_layer_file_id_property(layer, layer_get_value): - layer.file_id +def test_layer_image_id_property(layer, layer_get_value): + layer.image_id layer_get_value.assert_called_with("frameInfo.fileId") @@ -192,24 +192,24 @@ def test_layer_set_image_rejects_invalid_image(layer, mocker): set_layer_image.assert_not_called() -def test_layer_image_view_order(session, color_blending, layer_property, mocker): - find = mocker.patch.object(session, "_find_image_view_order", return_value=7) - layer_property("file_id", 42) - assert Layer(color_blending, 3).image_view_order == 7 +def test_layer_view_index(session, color_blending, layer_property, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=7) + layer_property("image_id", 42) + assert Layer(color_blending, 3).view_index == 7 find.assert_called_once_with(ImageType.FRAME, 42) -def test_layer_image_view_order_raises_when_frame_not_in_image_list( +def test_layer_view_index_raises_when_image_not_in_views( session, color_blending, layer_property, mocker ): - layer_property("file_id", 42) + layer_property("image_id", 42) mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) with pytest.raises(RuntimeError): - Layer(color_blending, 3).image_view_order + Layer(color_blending, 3).view_index @pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) @@ -256,22 +256,22 @@ def test_color_blending_repr_healthy(session, color_blending, cb_property, mocke cb_property("file_name", "Color Blending 1") r = repr(color_blending) assert r == ( - "ColorBlending(image_view_order=2, color_blending_id=0, " + "ColorBlending(view_index=2, color_blending_id=0, " "file_name='Color Blending 1')" ) -def test_color_blending_repr_closed_when_not_in_image_list( +def test_color_blending_repr_closed_when_not_in_views( session, color_blending, mocker ): mocker.patch.object( session, "call_action", - side_effect=RuntimeError("not in image list"), + side_effect=RuntimeError("not in views"), ) r = repr(color_blending) assert r == ( - "[Closed] ColorBlending(image_view_order=None, color_blending_id=0)" + "[Closed] ColorBlending(view_index=None, color_blending_id=0)" ) @@ -286,7 +286,7 @@ def test_color_blending_repr_closed_when_backing_entry_is_gone( ) r = repr(color_blending) assert r == ( - "[Closed] ColorBlending(image_view_order=2, color_blending_id=0)" + "[Closed] ColorBlending(view_index=2, color_blending_id=0)" ) @@ -295,11 +295,11 @@ def test_color_blending_file_name(color_blending, cb_get_value): cb_get_value.assert_called_with("filename") -def test_color_blending_image_view_order( +def test_color_blending_view_index( session, color_blending, session_call_action ): session_call_action.return_value = 2 - assert color_blending.image_view_order == 2 + assert color_blending.view_index == 2 session_call_action.assert_called_once_with( "imageViewConfigStore.getImageListIndex", ImageType.COLOR_BLENDING, @@ -308,12 +308,12 @@ def test_color_blending_image_view_order( ) -def test_color_blending_image_view_order_raises_when_missing( +def test_color_blending_view_index_raises_when_missing( session, color_blending, session_call_action ): session_call_action.return_value = -1 with pytest.raises(RuntimeError): - color_blending.image_view_order + color_blending.view_index def test_color_blending_alpha(color_blending, cb_get_value): @@ -328,19 +328,19 @@ def test_color_blending_base_frame(color_blending, cb_get_value): cb_get_value.assert_called_once_with("frames[0].id") assert isinstance(base_frame, Image) assert base_frame.session is color_blending.session - assert base_frame.file_id == 42 + assert base_frame.image_id == 42 def test_color_blending_make_active(session, color_blending, session_call_action): # make_active must be driven by color_blending_id via setActiveImageById. - # It must not depend on image_view_order (which is volatile). + # It must not depend on view_index (which is volatile). color_blending.make_active() session_call_action.assert_called_with( "setActiveImageById", ImageType.COLOR_BLENDING, 0 ) -def test_color_blending_make_active_does_not_read_image_view_order( +def test_color_blending_make_active_does_not_read_view_index( session, color_blending, session_call_action, session_get_value ): color_blending.make_active() @@ -400,7 +400,7 @@ def test_color_blending_delete_base_layer_promotes_next_layer( layers = [Layer(color_blending, 0), Layer(color_blending, 1)] mocker.patch.object(color_blending, "layer_list", return_value=layers) mocker.patch.object( - Layer, "file_id", new_callable=mocker.PropertyMock, return_value=42 + Layer, "image_id", new_callable=mocker.PropertyMock, return_value=42 ) image = mocker.patch("carta.color_blending.Image", autospec=True) diff --git a/tests/test_image.py b/tests/test_image.py index 33ef995..1627c3b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,6 +1,7 @@ import pytest -from carta.image import Image, ImageBase +from carta.image import Image +from carta.view import View from carta.util import CartaActionFailed, CartaValidationFailed, Point as Pt from carta.constants import ImageType, NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU @@ -77,7 +78,7 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte assert type(image_object) is Image assert image_object.session == session - assert image_object.file_id == 123 + assert image_object.image_id == 123 # SUBOBJECTS @@ -124,105 +125,112 @@ def test_make_spatial_reference_disables_color_blending_alert( ) -def test_image_base_is_abstract(session): +def test_view_is_abstract(session): with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): - ImageBase(session) + View(session) -def test_image_base_make_active_uses_subclass_ids(session, session_call_action): - # Verify the shared ImageBase.make_active dispatches setActiveImageById - # with Image.IMAGE_TYPE and its stable ID exactly once. +def test_view_make_active_uses_subclass_ids(session, session_call_action): + # Verify the shared View.make_active dispatches setActiveImageById + # with Image.VIEW_TYPE and its stable ID exactly once. Image(session, 42).make_active() session_call_action.assert_called_once_with( "setActiveImageById", ImageType.FRAME, 42 ) -def test_image_base_image_view_order_uses_subclass_ids(session, mocker): - find = mocker.patch.object(session, "_find_image_view_order", return_value=5) +def test_view_class_resolves_registered_subclasses(): from carta.color_blending import ColorBlending - assert ColorBlending(session, 42).image_view_order == 5 + assert View.view_class(ImageType.FRAME) is Image + assert View.view_class(ImageType.COLOR_BLENDING) is ColorBlending + + +def test_view_index_uses_subclass_ids(session, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=5) + from carta.color_blending import ColorBlending + + assert ColorBlending(session, 42).view_index == 5 find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) -def test_image_base_subclass_requires_image_type(): +def test_view_subclass_requires_view_type(): with pytest.raises( - AttributeError, match="has no attribute 'IMAGE_TYPE'" + AttributeError, match="has no attribute 'VIEW_TYPE'" ): - class Dummy(ImageBase): + class Dummy(View): pass -def test_image_view_order_uses_find_image_view_order(session, mocker, image): +def test_view_index_uses_find_view_index(session, mocker, image): find = mocker.patch.object( - session, "_find_image_view_order", return_value=3 + session, "_find_view_index", return_value=3 ) - # Frame with file_id=0 at viewer order 3. - assert image.image_view_order == 3 + # Image with image_id=0 at view index 3. + assert image.view_index == 3 find.assert_called_once_with(ImageType.FRAME, 0) -def test_image_view_order_raises_when_missing(session, mocker): +def test_view_index_raises_when_missing(session, mocker): mocker.patch.object( - session, "_find_image_view_order", side_effect=RuntimeError + session, "_find_view_index", side_effect=RuntimeError ) img = Image(session, 3) with pytest.raises(RuntimeError): - img.image_view_order + img.view_index -def test_image_repr_cached_name_resolves_only_image_view_order(session, image, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=3) +def test_image_repr_cached_name_resolves_only_view_index(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) get_value = mocker.patch.object(image, "get_value") image._cache = {"file_name": "cube.fits"} r = repr(image) - assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + assert r == "Image(view_index=3, file_name='cube.fits', image_id=0)" get_value.assert_not_called() -def test_image_repr_resolves_image_view_order_and_file_name(session, image, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=3) +def test_image_repr_resolves_view_index_and_file_name(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) mocker.patch.object(image, "get_value", return_value="cube.fits") r = repr(image) - assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + assert r == "Image(view_index=3, file_name='cube.fits', image_id=0)" -def test_image_repr_closed_when_image_view_order_missing(session, image, mocker): +def test_image_repr_closed_when_view_index_missing(session, image, mocker): mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) r = repr(image) - assert r == "[Closed] Image(image_view_order=None, file_id=0)" + assert r == "[Closed] Image(view_index=None, image_id=0)" def test_image_repr_closed_shows_cached_file_name(session, image, mocker): - # When the image-view-order lookup fails but file_name was previously + # When the view-index lookup fails but file_name was previously # cached, the closed repr still surfaces the cached name without # triggering any fresh round-trip. mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) get_value = mocker.patch.object(image, "get_value") image._cache = {"file_name": "cube.fits"} r = repr(image) - assert r == "[Closed] Image(image_view_order=None, file_name='cube.fits', file_id=0)" + assert r == "[Closed] Image(view_index=None, file_name='cube.fits', image_id=0)" get_value.assert_not_called() -def test_image_repr_closed_when_frame_is_gone(session, image, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=3) +def test_image_repr_closed_when_image_is_gone(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) mocker.patch.object( image, "get_value", - side_effect=CartaActionFailed("frameMap entry is missing"), + side_effect=CartaActionFailed("imageMap entry is missing"), ) r = repr(image) - assert r == "[Closed] Image(image_view_order=3, file_id=0)" + assert r == "[Closed] Image(view_index=3, image_id=0)" @pytest.mark.parametrize("channel", [0, 10, 19]) diff --git a/tests/test_session.py b/tests/test_session.py index 58aacb1..401c408 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -117,57 +117,79 @@ def test_cd(session, method, call_action): call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") -# IMAGE LIST / GET_IMAGE / COLOR-BLENDING HELPERS +# VIEWS / IMAGES / COLOR-BLENDING HELPERS -def test_image_list_heterogeneous(session, get_value): +def test_views_heterogeneous(session, get_value): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, {"type": ImageType.COLOR_BLENDING, "id": 3}, {"type": ImageType.FRAME, "id": 20}, ] - images = session.image_list() + views = session.views() get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" ) - assert len(images) == 3 - assert isinstance(images[0], Image) and images[0].file_id == 10 - assert isinstance(images[1], ColorBlending) and images[1].color_blending_id == 3 - assert isinstance(images[2], Image) and images[2].file_id == 20 + assert len(views) == 3 + assert isinstance(views[0], Image) and views[0].image_id == 10 + assert isinstance(views[1], ColorBlending) and views[1].color_blending_id == 3 + assert isinstance(views[2], Image) and views[2].image_id == 20 -def test_image_list_skips_unsupported_image_type(session, get_value, capsys): +def test_views_skips_unsupported_view_type(session, get_value, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, {"type": 99, "id": 11}, {"type": ImageType.FRAME, "id": 20}, ] - images = session.image_list() + views = session.views() - assert [image.file_id for image in images] == [10, 20] + assert [view.image_id for view in views] == [10, 20] assert ( - "Skipping unsupported image-view entry at order 1" + "Skipping unsupported view entry at index 1" in capsys.readouterr().out ) -def test_image_list_empty(session, get_value): +def test_views_empty(session, get_value): get_value.return_value = [] - assert session.image_list() == [] + assert session.views() == [] get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" ) -def test_images_uses_frame_list(session, get_value): +def test_views_uses_explicit_indices(session, mocker): + view_by_id = mocker.patch.object(session, "view_by_id") + view_by_id.side_effect = ["view-2", "view-0", "view-2-again"] + + views = session.views(view_indices=[2, 0, 2]) + + assert views == ["view-2", "view-0", "view-2-again"] + assert view_by_id.call_args_list == [ + call(view_index=2), + call(view_index=0), + call(view_index=2), + ] + + +@pytest.mark.parametrize("view_indices", [[-1], [1.5], ["1"]]) +def test_views_rejects_invalid_indices(session, get_value, view_indices): + with pytest.raises(CartaValidationFailed): + session.views(view_indices=view_indices) + + get_value.assert_not_called() + + +def test_images_uses_frontend_image_array(session, get_value): get_value.side_effect = [2, 10, 20] images = session.images() - assert [image.file_id for image in images] == [10, 20] + assert [image.image_id for image in images] == [10, 20] assert get_value.call_args_list == [ call("frames.length"), call("frames[0]", return_path="frameInfo.fileId"), @@ -175,16 +197,16 @@ def test_images_uses_frame_list(session, get_value): ] -def test_images_uses_frame_map_for_explicit_ids(session, mocker): - image_by_id = mocker.patch.object(session, "image_by_id") - image_by_id.side_effect = [object(), object()] +def test_images_uses_image_map_for_explicit_ids(session, mocker): + view_by_id = mocker.patch.object(session, "view_by_id") + view_by_id.side_effect = [object(), object()] - images = session.images(file_ids=[10, 20]) + images = session.images(image_ids=[10, 20]) assert len(images) == 2 - assert image_by_id.call_args_list == [ - call(file_id=10), - call(file_id=20), + assert view_by_id.call_args_list == [ + call(image_id=10), + call(image_id=20), ] @@ -213,23 +235,23 @@ def test_color_blendings_uses_color_blending_list(session, get_value): def test_color_blendings_uses_color_blending_map_for_explicit_ids( session, mocker ): - image_by_id = mocker.patch.object(session, "image_by_id") - image_by_id.side_effect = [object(), object()] + view_by_id = mocker.patch.object(session, "view_by_id") + view_by_id.side_effect = [object(), object()] color_blendings = session.color_blendings([3, 7]) assert len(color_blendings) == 2 - assert image_by_id.call_args_list == [ + assert view_by_id.call_args_list == [ call(color_blending_id=3), call(color_blending_id=7), ] -def test_find_image_view_order_single_round_trip(session, call_action): +def test_find_view_index_single_round_trip(session, call_action): call_action.side_effect = [2, 1] - assert session._find_image_view_order(ImageType.FRAME, 3) == 2 - assert session._find_image_view_order(ImageType.COLOR_BLENDING, 7) == 1 + assert session._find_view_index(ImageType.FRAME, 3) == 2 + assert session._find_view_index(ImageType.COLOR_BLENDING, 7) == 1 assert call_action.call_args_list == [ call( "imageViewConfigStore.getImageListIndex", @@ -246,45 +268,45 @@ def test_find_image_view_order_single_round_trip(session, call_action): ] -def test_find_image_view_order_raises_when_missing(session, call_action): +def test_find_view_index_raises_when_missing(session, call_action): call_action.return_value = -1 with pytest.raises(RuntimeError): - session._find_image_view_order(ImageType.FRAME, 99) + session._find_view_index(ImageType.FRAME, 99) -# session.image_by_id +# session.view_by_id -def test_image_by_id_requires_exactly_one_keyword(session, get_value): +def test_view_by_id_requires_exactly_one_keyword(session, get_value): # Zero keywords -> ValueError with all three names listed. with pytest.raises(ValueError) as e: - session.image_by_id() - for name in ("image_view_order", "file_id", "color_blending_id"): + session.view_by_id() + for name in ("view_index", "image_id", "color_blending_id"): assert name in str(e.value) assert "got 0 with values {}" in str(e.value) # Multiple keywords -> ValueError. with pytest.raises(ValueError) as e: - session.image_by_id(file_id=1, color_blending_id=2) - assert "'file_id': 1" in str(e.value) + session.view_by_id(image_id=1, color_blending_id=2) + assert "'image_id': 1" in str(e.value) assert "'color_blending_id': 2" in str(e.value) -def test_image_by_id_rejects_positional(session): +def test_view_by_id_rejects_positional(session): with pytest.raises(TypeError): - session.image_by_id(0) + session.view_by_id(0) @pytest.mark.parametrize("keyword", [ - "image_view_order", - "file_id", + "view_index", + "image_id", "color_blending_id", ]) @pytest.mark.parametrize("value", [-1, 1.5, "1"]) -def test_image_by_id_rejects_invalid_identifier( +def test_view_by_id_rejects_invalid_identifier( session, get_value, keyword, value ): with pytest.raises(CartaValidationFailed): - session.image_by_id(**{keyword: value}) + session.view_by_id(**{keyword: value}) get_value.assert_not_called() @@ -300,64 +322,64 @@ def test_image_by_id_rejects_invalid_identifier( ), ], ) -def test_image_by_id_by_image_view_order( +def test_view_by_id_by_view_index( session, get_value, entry, expected_type, expected_id ): get_value.return_value = entry - img = session.image_by_id(image_view_order=0) + img = session.view_by_id(view_index=0) assert isinstance(img, expected_type) assert ( - img.file_id if expected_type is Image else img.color_blending_id + img.image_id if expected_type is Image else img.color_blending_id ) == expected_id get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary[0]" ) -def test_image_by_id_by_image_view_order_out_of_range(session, get_value): +def test_view_by_id_by_view_index_out_of_range(session, get_value): get_value.side_effect = CartaBadResponse("undefined") with pytest.raises(IndexError): - session.image_by_id(image_view_order=99) + session.view_by_id(view_index=99) get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary[99]" ) -def test_image_by_id_by_image_view_order_raises_on_unsupported_type( +def test_view_by_id_by_view_index_raises_on_unsupported_type( session, get_value ): get_value.return_value = {"type": ImageType.PV_PREVIEW, "id": -2} with pytest.raises(NotImplementedError): - session.image_by_id(image_view_order=0) + session.view_by_id(view_index=0) -def test_image_by_id_by_file_id(session, get_value): +def test_view_by_id_by_image_id(session, get_value): get_value.return_value = 20 - img = session.image_by_id(file_id=20) + img = session.view_by_id(image_id=20) assert isinstance(img, Image) - assert img.file_id == 20 + assert img.image_id == 20 get_value.assert_called_once_with( "frameMap[20]", return_path="frameInfo.fileId", ) -def test_image_by_id_by_file_id_no_cross_type_fallback(session, get_value): +def test_view_by_id_by_image_id_no_cross_type_fallback(session, get_value): get_value.side_effect = CartaBadResponse("undefined") with pytest.raises(RuntimeError): - session.image_by_id(file_id=7) + session.view_by_id(image_id=7) -def test_image_by_id_by_color_blending_id(session, get_value): +def test_view_by_id_by_color_blending_id(session, get_value): get_value.return_value = 7 - cb = session.image_by_id(color_blending_id=7) + cb = session.view_by_id(color_blending_id=7) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 get_value.assert_called_once_with( @@ -366,25 +388,25 @@ def test_image_by_id_by_color_blending_id(session, get_value): ) -def test_image_by_id_by_color_blending_id_no_cross_type_fallback( +def test_view_by_id_by_color_blending_id_no_cross_type_fallback( session, get_value ): get_value.side_effect = CartaBadResponse("undefined") with pytest.raises(RuntimeError): - session.image_by_id(color_blending_id=10) + session.view_by_id(color_blending_id=10) -def test_image_by_id_uses_targeted_frontend_lookups(session, get_value): +def test_view_by_id_uses_targeted_frontend_lookups(session, get_value): get_value.side_effect = [ {"type": ImageType.FRAME, "id": 10}, 10, 7, ] - session.image_by_id(image_view_order=0) - session.image_by_id(file_id=10) - session.image_by_id(color_blending_id=7) + session.view_by_id(view_index=0) + session.view_by_id(image_id=10) + session.view_by_id(color_blending_id=7) assert get_value.call_args_list == [ call( @@ -398,28 +420,28 @@ def test_image_by_id_uses_targeted_frontend_lookups(session, get_value): ] -# session.active_image +# session.active_view -def test_active_image_returns_image_when_frame_active(session, get_value): +def test_active_view_returns_image_when_image_active(session, get_value): get_value.side_effect = [ {"type": ImageType.FRAME, "store": {"id": 12}}, ] - active = session.active_image() + active = session.active_view() assert isinstance(active, Image) - assert active.file_id == 12 + assert active.image_id == 12 assert [call.args for call in get_value.call_args_list] == [ ("activeImage",), ] -def test_active_image_returns_color_blending_when_color_blending_active( +def test_active_view_returns_color_blending_when_color_blending_active( session, get_value ): get_value.side_effect = [ {"type": ImageType.COLOR_BLENDING, "store": {"id": 3}}, ] - active = session.active_image() + active = session.active_view() assert isinstance(active, ColorBlending) assert active.color_blending_id == 3 assert [call.args for call in get_value.call_args_list] == [ @@ -427,12 +449,12 @@ def test_active_image_returns_color_blending_when_color_blending_active( ] -def test_active_image_raises_on_unsupported_type(session, get_value): +def test_active_view_raises_on_unsupported_type(session, get_value): get_value.side_effect = [ {"type": ImageType.PV_PREVIEW, "store": {"id": -2}}, ] with pytest.raises(NotImplementedError): - session.active_image() + session.active_view() # open_as_color_blending / create_color_blending @@ -484,18 +506,18 @@ def test_open_as_color_blending_rejects_empty_file_list(session, mocker): create_color_blending.assert_not_called() -@pytest.mark.parametrize("open_frame_count,layer_count,expected_colormap_set", [ +@pytest.mark.parametrize("open_image_count,layer_count,expected_colormap_set", [ (1, 1, ColormapSet.RGB), (3, 3, ColormapSet.RGB), (5, 3, ColormapSet.RGB), (5, 4, ColormapSet.RAINBOW), ]) -def test_create_color_blending_calls_frontend_create_action(session, mocker, open_frame_count, layer_count, expected_colormap_set): +def test_create_color_blending_calls_frontend_create_action(session, mocker, open_image_count, layer_count, expected_colormap_set): get_value = mocker.patch.object( session, "get_value", side_effect=[ - open_frame_count, + open_image_count, layer_count, ], ) @@ -523,12 +545,12 @@ def test_create_color_blending_calls_frontend_create_action(session, mocker, ope assert result.color_blending_id == 123 -def test_create_color_blending_raises_when_no_frames_are_open(session, mocker): +def test_create_color_blending_raises_when_no_images_are_open(session, mocker): get_value = mocker.patch.object(session, "get_value", return_value=0) call_action = mocker.patch.object(session, "call_action") mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") - with pytest.raises(CartaActionFailed, match="No frames are open"): + with pytest.raises(CartaActionFailed, match="No images are open"): session.create_color_blending() get_value.assert_called_once_with("frames.length") @@ -651,7 +673,7 @@ def test_open_hypercube_guess_polarization(mocker, session, call_action, method, assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.file_id == 123 + assert hypercube.image_id == 123 @pytest.mark.parametrize("paths,expected_calls,mocked_side_effect,expected_error", [ @@ -705,7 +727,7 @@ def test_open_hypercube_explicit_polarization(mocker, session, call_action, meth assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.file_id == 123 + assert hypercube.image_id == 123 @pytest.mark.parametrize("paths,expected_error", [ From 189a3a7bc051fe9cfbe986bb0189d0b0f1480926 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 21:50:31 +0800 Subject: [PATCH 093/123] Rename alpha to alphas and layer_list to layers, add optional layer_ids parameter to layers method for selective layer retrieval with validation --- carta/color_blending.py | 31 ++++++++++++++----- docs/source/quickstart.rst | 8 ++--- tests/test_color_blending.py | 59 ++++++++++++++++++++++++++++-------- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index ace85cc..0357c20 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -11,6 +11,7 @@ InstanceOf, IterableOf, Number, + NoneOr, Size, Attr, Evaluate, @@ -290,7 +291,7 @@ def file_name(self): # LAYERS @property - def alpha(self): + def alphas(self): """The alpha value list for the color blending layers. Returns @@ -314,7 +315,7 @@ def depth(self): @validate( Evaluate(IterableOf, Number(0, 1), Attr("depth"), Attr("depth")) ) - def set_alpha(self, alpha_list): + def set_alphas(self, alpha_list): """Set the alpha value for the color blending layers. Parameters @@ -322,18 +323,32 @@ def set_alpha(self, alpha_list): alpha_list : {0} The alpha values. """ - for alpha, layer in zip(alpha_list, self.layer_list()): + for alpha, layer in zip(alpha_list, self.layers()): layer.set_alpha(alpha) - def layer_list(self): - """Return a list of Layer objects for this color blending. + @validate( + NoneOr(Evaluate(IterableOf, Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1))) + ) + def layers(self, layer_ids=None): + """Return all or selected Layer objects for this color blending. + + When no layer IDs are supplied, all layers are returned. When layer + IDs are supplied, the layers are returned in the requested order. + Duplicate layer IDs are preserved. + + Parameters + ---------- + layer_ids : {0} + The layer IDs to return. By default, all layers are returned. Returns ------- list of :obj:`carta.color_blending.Layer` - A list of Layer objects. + The requested layers. """ - return Layer.from_list(self, list(range(self.depth))) + if layer_ids is None: + layer_ids = range(self.depth) + return Layer.from_list(self, layer_ids) @validate(InstanceOf(Image)) def add_layer(self, image): @@ -358,7 +373,7 @@ def delete_layer(self, layer_index): layer, the color blending is closed. """ if layer_index == 0: - layers = self.layer_list() + layers = self.layers() if len(layers) == 1: self.close() return diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 8ea84c0..d17ba71 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -318,7 +318,7 @@ Manipulate properties of the color blending object and the underlying layers: .. code-block:: python # Get layer objects - layer1, layer2, layer3 = cb.layer_list() + layer1, layer2, layer3 = cb.layers() # Set colormap for the images in individual layers layer1.set_colormap(Colormap.REDS) @@ -332,7 +332,7 @@ Manipulate properties of the color blending object and the underlying layers: cb.set_colormap_set(ColormapSet.RGB) # Print the current alpha values of all layers - print(cb.alpha) + print(cb.alphas) # Set alpha for individual layers layer1.set_alpha(0.7) @@ -340,7 +340,7 @@ Manipulate properties of the color blending object and the underlying layers: layer3.set_alpha(0.9) # Or set alpha for all layers at once - cb.set_alpha([0.7, 0.8, 0.9]) + cb.set_alphas([0.7, 0.8, 0.9]) # Replace the image in a layer # For layer1, this also makes the new image the spatial reference. @@ -354,7 +354,7 @@ Manipulate properties of the color blending object and the underlying layers: cb.add_layer(img2) # Layer objects can delete themselves from the color blending - layer1, layer2, layer3 = cb.layer_list() + layer1, layer2, layer3 = cb.layers() layer3.delete() # Deleting the base layer promotes the next layer to the spatial diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 470560b..c0e57cb 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -317,7 +317,7 @@ def test_color_blending_view_index_raises_when_missing( def test_color_blending_alpha(color_blending, cb_get_value): - color_blending.alpha + color_blending.alphas cb_get_value.assert_called_with("alpha") @@ -348,18 +348,51 @@ def test_color_blending_make_active_does_not_read_view_index( assert call.args != ("imageViewConfigStore.imageListSummary",) -def test_color_blending_layer_list_derived(session, mocker): +def test_color_blending_layers_derived(session, mocker): cb = ColorBlending(session, 3) # Simulate two layers from the frontend's computed frames array length. gv = mocker.patch.object(cb, "get_value") gv.return_value = 2 - layers = cb.layer_list() + layers = cb.layers() assert [ly.layer_id for ly in layers] == [0, 1] gv.assert_called_once_with("frames.length") +def test_color_blending_layers_accepts_ids_in_order_with_duplicates( + color_blending, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + + layers = color_blending.layers([2, 0, 2]) + + assert [layer.layer_id for layer in layers] == [2, 0, 2] + + +@pytest.mark.parametrize("layer_ids", [[-1], [3], [1.5], ["1"]]) +def test_color_blending_layers_rejects_invalid_ids( + color_blending, mocker, layer_ids +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + from_list = mocker.patch.object(Layer, "from_list") + + with pytest.raises(CartaValidationFailed): + color_blending.layers(layer_ids) + + from_list.assert_not_called() + + def test_color_blending_add_layer(color_blending, cb_call_action, image): color_blending.add_layer(image) cb_call_action.assert_called_with("addSelectedFrame", image._frame) @@ -398,7 +431,7 @@ def test_color_blending_delete_base_layer_promotes_next_layer( return_value=2, ) layers = [Layer(color_blending, 0), Layer(color_blending, 1)] - mocker.patch.object(color_blending, "layer_list", return_value=layers) + mocker.patch.object(color_blending, "layers", return_value=layers) mocker.patch.object( Layer, "image_id", new_callable=mocker.PropertyMock, return_value=42 ) @@ -421,7 +454,7 @@ def test_color_blending_delete_only_base_layer_closes_color_blending( return_value=1, ) mocker.patch.object( - color_blending, "layer_list", return_value=[Layer(color_blending, 0)] + color_blending, "layers", return_value=[Layer(color_blending, 0)] ) close = mocker.patch.object(color_blending, "close") @@ -558,7 +591,7 @@ def test_color_blending_set_colormap_set(color_blending, cb_call_action): cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) -def test_color_blending_set_alpha_valid(color_blending, mocker): +def test_color_blending_set_alphas_valid(color_blending, mocker): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object( @@ -567,15 +600,15 @@ def test_color_blending_set_alpha_valid(color_blending, mocker): new_callable=mocker.PropertyMock, return_value=2, ) - mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + mocker.patch.object(ColorBlending, "layers", return_value=[ly1, ly2]) - color_blending.set_alpha([0.2, 0.8]) + color_blending.set_alphas([0.2, 0.8]) ly1.set_alpha.assert_called_with(0.2) ly2.set_alpha.assert_called_with(0.8) @pytest.mark.parametrize("vals", [[-0.1, 0.5], [1.2], [0.1, 2.0, 0.3]]) -def test_color_blending_set_alpha_invalid(color_blending, vals, mocker): +def test_color_blending_set_alphas_invalid(color_blending, vals, mocker): mocker.patch.object( ColorBlending, "depth", @@ -584,11 +617,11 @@ def test_color_blending_set_alpha_invalid(color_blending, vals, mocker): ) with pytest.raises(CartaValidationFailed): - color_blending.set_alpha(vals) + color_blending.set_alphas(vals) @pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) -def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): +def test_color_blending_set_alphas_length_mismatch(color_blending, mocker, vals): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object( @@ -597,10 +630,10 @@ def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): new_callable=mocker.PropertyMock, return_value=2, ) - mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + mocker.patch.object(ColorBlending, "layers", return_value=[ly1, ly2]) with pytest.raises(CartaValidationFailed): - color_blending.set_alpha(vals) + color_blending.set_alphas(vals) @pytest.mark.parametrize( From 864312568a1b4bab3254582bda2fc09abc618a2b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 22:08:13 +0800 Subject: [PATCH 094/123] Add validation to ColorBlending.set_layer_image to prevent duplicate images and include corresponding test coverage --- carta/color_blending.py | 19 ++++++++++++++++++- tests/test_color_blending.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 0357c20..5bd1106 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -3,7 +3,12 @@ from .constants import Colormap, ColormapSet, ImageType, SpatialAxis from .image import Image from .view import View -from .util import BasePathMixin, CartaScriptingException, Macro +from .util import ( + BasePathMixin, + CartaScriptingException, + CartaValidationFailed, + Macro, +) from .validation import ( Boolean, Constant, @@ -397,7 +402,19 @@ def set_layer_image(self, layer_index, image): specified secondary layer is replaced. image : {1} The image to set. + + Raises + ------ + CartaValidationFailed + If ``image`` is already present in this color blending. """ + image_id = image.image_id + if any(layer.image_id == image_id for layer in self.layers()): + raise CartaValidationFailed( + f"Image with image_id={image_id} is already in the " + "color blending layers." + ) + if layer_index == 0: image.set_spatial_matching(True) image.make_spatial_reference() diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index c0e57cb..69ab537 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -491,6 +491,7 @@ def test_color_blending_set_layer_image( new_callable=mocker.PropertyMock, return_value=6, ) + mocker.patch.object(color_blending, "layers", return_value=[]) color_blending.set_layer_image(idx, image) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame @@ -514,6 +515,34 @@ def test_color_blending_set_layer_image_rejects_out_of_range( cb_call_action.assert_not_called() +@pytest.mark.parametrize("idx", [0, 2]) +def test_color_blending_set_layer_image_rejects_existing_image( + color_blending, cb_call_action, image, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + mocker.patch.object(color_blending, "layers", return_value=[Layer(color_blending, 1)]) + mocker.patch.object( + Layer, + "image_id", + new_callable=mocker.PropertyMock, + return_value=image.image_id, + ) + set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") + make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") + + with pytest.raises(CartaValidationFailed, match="already"): + color_blending.set_layer_image(idx, image) + + cb_call_action.assert_not_called() + set_spatial_matching.assert_not_called() + make_spatial_reference.assert_not_called() + + def test_color_blending_set_base_layer_image( color_blending, cb_call_action, image, mocker ): @@ -523,6 +552,7 @@ def test_color_blending_set_base_layer_image( new_callable=mocker.PropertyMock, return_value=1, ) + mocker.patch.object(color_blending, "layers", return_value=[]) set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") From eac811932a67dd0a46ab9ba8165b4cef34a5c859 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 22:44:27 +0800 Subject: [PATCH 095/123] Add optional images parameter to Session.create_color_blending to allow explicit image selection and layer ordering, with validation for duplicates and session membership --- carta/session.py | 70 +++++++++++++++++++++++++++++------ docs/source/quickstart.rst | 5 +++ tests/test_session.py | 75 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/carta/session.py b/carta/session.py index 4f2679d..285f3cf 100644 --- a/carta/session.py +++ b/carta/session.py @@ -16,7 +16,7 @@ from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt -from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union +from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay from .raster import SessionRaster @@ -812,9 +812,30 @@ def active_view(self): # COLOR BLENDING - def create_color_blending(self): - """Create a new color blending from the current spatial reference - and its currently spatially matched images. + @validate(NoneOr(IterableOf(InstanceOf(Image), min_size=1))) + def create_color_blending(self, images=None): + """Create a color blending from open images. + + There are two ways to choose the images for the new color blending: + + * If ``images`` is provided, it must contain one or more + :obj:`carta.image.Image` objects from this session. The first image + becomes the base layer and spatial reference. The remaining images + become secondary layers in the same order as the input. The result + contains exactly these images; other open images are left open but + are not included. + * If ``images`` is omitted, the current spatial reference and all + currently spatially matched images are used. + + This method creates a color blending from images that are already + open. It does not open files or close other open images. + + Parameters + ---------- + images : {0} + An iterable of open images to combine, in the desired layer order. + The first image is used as the base layer. By default, use the + current spatial reference and spatially matched images. Returns ------- @@ -824,12 +845,31 @@ def create_color_blending(self): Raises ------ CartaActionFailed - If no images are open or the frontend could not create the - color blending image. + If no images are open, an image is no longer open, or the + frontend cannot create or update the color blending. + CartaValidationFailed + If ``images`` is empty, contains duplicates, or contains an image + from another session. """ - image_count = self.get_value("frames.length") - if image_count <= 0: - raise CartaActionFailed("No images are open.") + if images is not None: + if any(image.session is not self for image in images): + raise CartaValidationFailed( + "images must belong to the current session." + ) + + image_ids = [image.image_id for image in images] + if len(set(image_ids)) != len(image_ids): + raise CartaValidationFailed( + "images must not contain duplicate images." + ) + + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + else: + image_count = self.get_value("frames.length") + if image_count <= 0: + raise CartaActionFailed("No images are open.") color_blending_id = self.call_action( "imageViewConfigStore.createColorBlending", @@ -837,8 +877,16 @@ def create_color_blending(self): ) cb = ColorBlending(self, color_blending_id) - layer_count = cb.get_value("frames.length") - if layer_count <= 3: + if images is not None: + # The frontend action includes every currently spatially matched + # image, so normalize the created blending to the requested + # sequence before applying the final colormap set. + for layer_index in range(cb.depth - 1, 0, -1): + cb.delete_layer(layer_index) + for image in images[1:]: + cb.add_layer(image) + + if cb.depth <= 3: cb.set_colormap_set(ColormapSet.RGB) else: cb.set_colormap_set(ColormapSet.RAINBOW) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index d17ba71..253e242 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -307,6 +307,11 @@ The session object provides two convenience methods which create a color blendin img2.set_spatial_matching(True) cb = session.create_color_blending() + # Or select already-open images directly in the requested layer order. + cb = session.create_color_blending( + images=[img0, img1, img2] + ) + .. note:: ``session.open_as_color_blending(files)`` always closes any currently open images before opening ``files``. It then makes the first opened diff --git a/tests/test_session.py b/tests/test_session.py index 401c408..dc9fe85 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,6 +4,7 @@ from carta.image import Image from carta.color_blending import ColorBlending +from carta.session import Session from carta.util import CartaActionFailed, CartaBadResponse, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol @@ -545,6 +546,80 @@ def test_create_color_blending_calls_frontend_create_action(session, mocker, ope assert result.color_blending_id == 123 +def test_create_color_blending_with_images_sets_reference_and_matching( + session, mocker +): + images = [Image(session, 10), Image(session, 20), Image(session, 30)] + for image in images: + mocker.patch.object(image, "make_spatial_reference") + mocker.patch.object(image, "set_spatial_matching") + mocker.patch.object(session, "call_action", return_value=123) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + side_effect=[4, 3], + ) + delete_layer = mocker.patch.object(ColorBlending, "delete_layer") + add_layer = mocker.patch.object(ColorBlending, "add_layer") + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + result = session.create_color_blending(images=images) + + images[0].make_spatial_reference.assert_called_once_with() + images[0].set_spatial_matching.assert_not_called() + images[1].set_spatial_matching.assert_called_once_with(True) + images[2].set_spatial_matching.assert_called_once_with(True) + assert delete_layer.call_args_list == [ + mocker.call(3), + mocker.call(2), + mocker.call(1), + ] + assert add_layer.call_args_list == [ + mocker.call(images[1]), + mocker.call(images[2]), + ] + mock_set_colormap.assert_called_once_with(ColormapSet.RGB) + assert isinstance(result, ColorBlending) + + +@pytest.mark.parametrize("images", [[], [object()]]) +def test_create_color_blending_rejects_invalid_images( + session, mocker, images +): + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_rejects_duplicate_images(session, mocker): + images = [Image(session, 10), Image(session, 20), Image(session, 10)] + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises( + CartaValidationFailed, + match="must not contain duplicate images", + ): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_rejects_image_from_another_session( + session, mocker +): + images = [Image(session, 10), Image(Session(1, None), 20)] + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed, match="current session"): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + def test_create_color_blending_raises_when_no_images_are_open(session, mocker): get_value = mocker.patch.object(session, "get_value", return_value=0) call_action = mocker.patch.object(session, "call_action") From 665cef5c204e56579f067b53f1937bff6f6b9e36 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 14 Aug 2026 16:00:50 +0800 Subject: [PATCH 096/123] Refactor views, images, and color_blendings methods to use single frontend state queries, add validation for out-of-range and closed view indices, and improve error handling for unsupported view types --- carta/session.py | 105 +++++++++++++++++++---------- tests/test_session.py | 152 ++++++++++++++++++++++++++++-------------- 2 files changed, 170 insertions(+), 87 deletions(-) diff --git a/carta/session.py b/carta/session.py index 285f3cf..a091b1c 100644 --- a/carta/session.py +++ b/carta/session.py @@ -12,7 +12,7 @@ from .image import Image from .view import View from .color_blending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, Polarization, ColormapSet +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt @@ -574,44 +574,54 @@ def views(self, view_indices=None): indices are supplied, the views at those positions are returned in the requested order. + Unsupported view types are skipped when no indices are supplied. + Returns ------- list of :obj:`carta.view.View` The requested heterogeneous views open in this session. + + Raises + ------ + IndexError + If any requested view index is out of range. + NotImplementedError + If an explicitly requested view type is unsupported. + CartaValidationFailed + If ``view_indices`` contains an invalid value. """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + if view_indices is not None: - return [ - self.view_by_id(view_index=view_index) + out_of_range = [ + view_index for view_index in view_indices + if view_index >= len(summary) ] + if out_of_range: + raise IndexError( + f"view_indices {out_of_range!r} are out of range for " + f"views of length {len(summary)}." + ) + indexed_entries = [] + for view_index in view_indices: + indexed_entries.append((view_index, summary[view_index])) + skip_unsupported = False + else: + indexed_entries = enumerate(summary) + skip_unsupported = True - summary = self.get_value("imageViewConfigStore.imageListSummary") result = [] - for index, entry in enumerate(summary): + for index, entry in indexed_entries: try: - result.append( - View.view_class(entry["type"])(self, entry["id"]) - ) - except (CartaValidationFailed, NotImplementedError) as e: - print( - f"Skipping unsupported view entry at index {index}: " - f"{entry!r}: {e}" - ) + result.append(View.view_class(entry["type"])(self, entry["id"])) + except (CartaValidationFailed, NotImplementedError): + if not skip_unsupported: + raise + view_type = ImageType(entry["type"]) + print(f"Skipping unsupported {view_type.name} view at index {index}.") return result - def _view_list(self, path, view_class, return_path): - count = self.get_value(f"{path}.length") - return [ - view_class( - self, - self.get_value( - f"{path}[{index}]", - return_path=return_path, - ), - ) - for index in range(count) - ] - @validate(NoneOr(IterableOf(Number.ID))) def images(self, image_ids=None): """Return images from the session. @@ -630,8 +640,17 @@ def images(self, image_ids=None): The requested images. """ if image_ids is None: - return self._view_list("frames", Image, "frameInfo.fileId") - return [self.view_by_id(image_id=image_id) for image_id in image_ids] + return [ + Image(self, entry["value"]) for entry in self.get_value("frameNames") + ] + + frame_ids = {entry["value"] for entry in self.get_value("frameNames")} + images = [] + for image_id in image_ids: + if image_id not in frame_ids: + raise RuntimeError(f"No image with image_id={image_id} is open.") + images.append(Image(self, image_id)) + return images @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): @@ -652,16 +671,28 @@ def color_blendings(self, color_blending_ids=None): list of :obj:`carta.color_blending.ColorBlending` The requested color blending images. """ + summary = self.get_value("imageViewConfigStore.imageListSummary") if color_blending_ids is None: - return self._view_list( - "imageViewConfigStore.colorBlendingImages", - ColorBlending, - "id", - ) - return [ - self.view_by_id(color_blending_id=color_blending_id) - for color_blending_id in color_blending_ids - ] + return [ + ColorBlending(self, entry["id"]) + for entry in summary + if entry["type"] == ImageType.COLOR_BLENDING + ] + + color_blending_ids_in_summary = { + entry["id"] + for entry in summary + if entry["type"] == ImageType.COLOR_BLENDING + } + color_blendings = [] + for color_blending_id in color_blending_ids: + if color_blending_id not in color_blending_ids_in_summary: + raise RuntimeError( + f"No color blending with " + f"color_blending_id={color_blending_id} is open." + ) + color_blendings.append(ColorBlending(self, color_blending_id)) + return color_blendings def _find_view_index(self, view_type, stable_id): """Return the view index of an item identified by a stable id. diff --git a/tests/test_session.py b/tests/test_session.py index dc9fe85..14ba6af 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -142,17 +142,14 @@ def test_views_heterogeneous(session, get_value): def test_views_skips_unsupported_view_type(session, get_value, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, - {"type": 99, "id": 11}, + {"type": ImageType.PV_PREVIEW, "id": 11}, {"type": ImageType.FRAME, "id": 20}, ] views = session.views() assert [view.image_id for view in views] == [10, 20] - assert ( - "Skipping unsupported view entry at index 1" - in capsys.readouterr().out - ) + assert capsys.readouterr().out == "Skipping unsupported PV_PREVIEW view at index 1.\n" def test_views_empty(session, get_value): @@ -163,19 +160,49 @@ def test_views_empty(session, get_value): ) -def test_views_uses_explicit_indices(session, mocker): - view_by_id = mocker.patch.object(session, "view_by_id") - view_by_id.side_effect = ["view-2", "view-0", "view-2-again"] +def test_views_uses_one_summary_for_explicit_indices(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.FRAME, "id": 20}, + ] views = session.views(view_indices=[2, 0, 2]) - assert views == ["view-2", "view-0", "view-2-again"] - assert view_by_id.call_args_list == [ - call(view_index=2), - call(view_index=0), - call(view_index=2), + assert [ + (type(view), view.image_id if isinstance(view, Image) else view.color_blending_id) + for view in views + ] == [(Image, 20), (Image, 10), (Image, 20)] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_explicit_indices_reject_out_of_range(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, ] + with pytest.raises(IndexError, match=r"view_indices \[1, 2\]"): + session.views(view_indices=[1, 2]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_explicit_indices_raise_for_unsupported_type(session, get_value): + get_value.return_value = [ + {"type": ImageType.PV_PREVIEW, "id": -2} + ] + + with pytest.raises(NotImplementedError): + session.views(view_indices=[0]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + @pytest.mark.parametrize("view_indices", [[-1], [1.5], ["1"]]) def test_views_rejects_invalid_indices(session, get_value, view_indices): @@ -185,34 +212,49 @@ def test_views_rejects_invalid_indices(session, get_value, view_indices): get_value.assert_not_called() -def test_images_uses_frontend_image_array(session, get_value): - get_value.side_effect = [2, 10, 20] +def test_images_uses_frontend_frame_names(session, get_value): + get_value.return_value = [ + {"value": 10, "label": "Image 10"}, + {"value": 20, "label": "Image 20"}, + ] images = session.images() assert [image.image_id for image in images] == [10, 20] - assert get_value.call_args_list == [ - call("frames.length"), - call("frames[0]", return_path="frameInfo.fileId"), - call("frames[1]", return_path="frameInfo.fileId"), + get_value.assert_called_once_with( + "frameNames" + ) + + +def test_images_uses_frame_names_for_explicit_ids(session, get_value): + get_value.return_value = [ + {"value": 10, "label": "Image 10"}, + {"value": 20, "label": "Image 20"}, ] + images = session.images(image_ids=[20, 10, 20]) -def test_images_uses_image_map_for_explicit_ids(session, mocker): - view_by_id = mocker.patch.object(session, "view_by_id") - view_by_id.side_effect = [object(), object()] + assert [image.image_id for image in images] == [20, 10, 20] + get_value.assert_called_once_with("frameNames") - images = session.images(image_ids=[10, 20]) - assert len(images) == 2 - assert view_by_id.call_args_list == [ - call(image_id=10), - call(image_id=20), - ] +def test_images_rejects_closed_explicit_id(session, get_value): + get_value.return_value = [{"value": 10, "label": "Image 10"}] + with pytest.raises(RuntimeError, match="image_id=20"): + session.images(image_ids=[20]) -def test_color_blendings_uses_color_blending_list(session, get_value): - get_value.side_effect = [2, 3, 7] + get_value.assert_called_once_with("frameNames") + + +def test_color_blendings_uses_frontend_image_list_summary( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ] color_blendings = session.color_blendings() @@ -220,33 +262,43 @@ def test_color_blendings_uses_color_blending_list(session, get_value): color_blending.color_blending_id for color_blending in color_blendings ] == [3, 7] - assert get_value.call_args_list == [ - call("imageViewConfigStore.colorBlendingImages.length"), - call( - "imageViewConfigStore.colorBlendingImages[0]", - return_path="id", - ), - call( - "imageViewConfigStore.colorBlendingImages[1]", - return_path="id", - ), - ] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) -def test_color_blendings_uses_color_blending_map_for_explicit_ids( - session, mocker +def test_color_blendings_uses_image_list_summary_for_explicit_ids( + session, get_value ): - view_by_id = mocker.patch.object(session, "view_by_id") - view_by_id.side_effect = [object(), object()] + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ] - color_blendings = session.color_blendings([3, 7]) + color_blendings = session.color_blendings([7, 3, 7]) - assert len(color_blendings) == 2 - assert view_by_id.call_args_list == [ - call(color_blending_id=3), - call(color_blending_id=7), + assert [ + color_blending.color_blending_id + for color_blending in color_blendings + ] == [7, 3, 7] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_color_blendings_rejects_closed_explicit_id(session, get_value): + get_value.return_value = [ + {"type": ImageType.COLOR_BLENDING, "id": 3} ] + with pytest.raises(RuntimeError, match="color_blending_id=7"): + session.color_blendings([7]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + def test_find_view_index_single_round_trip(session, call_action): call_action.side_effect = [2, 1] From c33caa5d570d3bd56e841ee7a1b34d7be53a81fd Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 14 Aug 2026 16:06:20 +0800 Subject: [PATCH 097/123] Remove unnecessary docstring modifications --- carta/image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/image.py b/carta/image.py index bd2c2fa..6c7fade 100644 --- a/carta/image.py +++ b/carta/image.py @@ -1,4 +1,4 @@ -"""This module contains the image classes representing images open in the session. +"""This module contains an image class which represents a single image open in the session. Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ @@ -27,14 +27,14 @@ class Image(View, BasePathMixin): session : :obj:`carta.session.Session` The session object associated with this image. image_id : integer - The frontend image ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. + The ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. image_id : integer - The frontend image ID identifying this image within the session. + The ID identifying this image within the session. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` From ef07daedc26b74fa6e859b39e4d163784ac4a7d4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 14 Aug 2026 23:00:03 +0800 Subject: [PATCH 098/123] Refactor views, images, and color_blendings methods to use walrus operator for missing ID/index detection, simplify list comprehensions, and improve error messages to list all invalid values instead of just the first one --- carta/session.py | 152 ++++++++++++------------------------------ tests/test_session.py | 27 +++++--- 2 files changed, 61 insertions(+), 118 deletions(-) diff --git a/carta/session.py b/carta/session.py index a091b1c..615d52b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -242,10 +242,7 @@ def __repr__(self): except (AttributeError, CartaScriptingException): return f"Session(session_id={self.session_id}, uri={uri!r})" - return ( - f"Session(session_id={self.session_id}, uri={uri!r}, " - f"carta_version={version!r})" - ) + return f"Session(session_id={self.session_id}, uri={uri!r}, carta_version={version!r})" # METADATA @@ -591,36 +588,28 @@ def views(self, view_indices=None): If ``view_indices`` contains an invalid value. """ summary = self.get_value("imageViewConfigStore.imageListSummary") + remote_indices = range(len(summary)) - if view_indices is not None: - out_of_range = [ - view_index - for view_index in view_indices - if view_index >= len(summary) - ] - if out_of_range: - raise IndexError( - f"view_indices {out_of_range!r} are out of range for " - f"views of length {len(summary)}." - ) - indexed_entries = [] - for view_index in view_indices: - indexed_entries.append((view_index, summary[view_index])) - skip_unsupported = False - else: - indexed_entries = enumerate(summary) + if view_indices is None: + view_indices = remote_indices skip_unsupported = True + else: + missing_indices = [i for i in view_indices if i not in remote_indices] + if missing_indices: + raise IndexError(f"No views with indices {missing_indices} are open.") + skip_unsupported = False - result = [] - for index, entry in indexed_entries: + views = [] + for index in view_indices: + entry = summary[index] try: - result.append(View.view_class(entry["type"])(self, entry["id"])) + views.append(View.view_class(entry["type"])(self, entry["id"])) except (CartaValidationFailed, NotImplementedError): if not skip_unsupported: raise view_type = ImageType(entry["type"]) print(f"Skipping unsupported {view_type.name} view at index {index}.") - return result + return views @validate(NoneOr(IterableOf(Number.ID))) def images(self, image_ids=None): @@ -639,18 +628,13 @@ def images(self, image_ids=None): list of :obj:`carta.image.Image` The requested images. """ - if image_ids is None: - return [ - Image(self, entry["value"]) for entry in self.get_value("frameNames") - ] + remote_ids = [i["value"] for i in self.get_value("frameNames")] - frame_ids = {entry["value"] for entry in self.get_value("frameNames")} - images = [] - for image_id in image_ids: - if image_id not in frame_ids: - raise RuntimeError(f"No image with image_id={image_id} is open.") - images.append(Image(self, image_id)) - return images + if image_ids is None: + image_ids = remote_ids + elif missing_ids := [i for i in image_ids if i not in remote_ids]: + raise RuntimeError(f"No images with image_ids {missing_ids} are open.") + return [Image(self, i) for i in image_ids] @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): @@ -672,27 +656,13 @@ def color_blendings(self, color_blending_ids=None): The requested color blending images. """ summary = self.get_value("imageViewConfigStore.imageListSummary") + remote_ids = [i["id"] for i in summary if i["type"] == ImageType.COLOR_BLENDING] + if color_blending_ids is None: - return [ - ColorBlending(self, entry["id"]) - for entry in summary - if entry["type"] == ImageType.COLOR_BLENDING - ] - - color_blending_ids_in_summary = { - entry["id"] - for entry in summary - if entry["type"] == ImageType.COLOR_BLENDING - } - color_blendings = [] - for color_blending_id in color_blending_ids: - if color_blending_id not in color_blending_ids_in_summary: - raise RuntimeError( - f"No color blending with " - f"color_blending_id={color_blending_id} is open." - ) - color_blendings.append(ColorBlending(self, color_blending_id)) - return color_blendings + color_blending_ids = remote_ids + elif missing_ids := [i for i in color_blending_ids if i not in remote_ids]: + raise RuntimeError(f"No color blendings with color_blending_ids {missing_ids} are open.") + return [ColorBlending(self, i) for i in color_blending_ids] def _find_view_index(self, view_type, stable_id): """Return the view index of an item identified by a stable id. @@ -722,16 +692,11 @@ def _find_view_index(self, view_type, stable_id): response_expected=True, ) if view_index == -1: - raise RuntimeError( - f"Could not find a view of type {view_type!r} with id " - f"{stable_id} in the views." - ) + raise RuntimeError(f"Could not find a view of type {view_type!r} with id {stable_id} in the views.") return view_index @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) - def view_by_id( - self, *, view_index=None, image_id=None, color_blending_id=None - ): + def view_by_id(self, *, view_index=None, image_id=None, color_blending_id=None): """Return the view identified by exactly one supported identifier. Parameters @@ -765,16 +730,9 @@ def view_by_id( If no matching entry exists for the given ``image_id`` or ``color_blending_id``. There is no cross-type fallback. """ - provided = { - "view_index": view_index, - "image_id": image_id, - "color_blending_id": color_blending_id, - } - provided_values = { - key: value - for key, value in provided.items() - if value is not None - } + provided = {"view_index": view_index, "image_id": image_id, "color_blending_id": color_blending_id} + provided_values = {k: v for k, v in provided.items() if v is not None} + if len(provided_values) != 1: raise ValueError( "view_by_id requires exactly one of the keyword arguments " @@ -784,40 +742,25 @@ def view_by_id( if view_index is not None: try: - entry = self.get_value( - "imageViewConfigStore.imageListSummary" - f"[{view_index}]" - ) + entry = self.get_value(f"imageViewConfigStore.imageListSummary[{view_index}]") except (CartaActionFailed, CartaBadResponse) as e: - raise IndexError( - f"view_index {view_index} is out of range for the views." - ) from e + raise IndexError(f"view_index {view_index} is out of range for the views.") from e return View.view_class(entry["type"])(self, entry["id"]) if image_id is not None: try: - resolved_image_id = self.get_value( - f"frameMap[{image_id}]", - return_path="frameInfo.fileId", - ) + resolved_image_id = self.get_value(f"frameMap[{image_id}]", return_path="frameInfo.fileId") except (CartaActionFailed, CartaBadResponse) as e: - raise RuntimeError( - f"No image with image_id={image_id} is open." - ) from e + raise RuntimeError(f"No image with image_id={image_id} is open.") from e return Image(self, resolved_image_id) # color_blending_id is not None try: resolved_color_blending_id = self.get_value( - f"imageViewConfigStore.colorBlendingImageMap" - f"[{color_blending_id}]", - return_path="id", + f"imageViewConfigStore.colorBlendingImageMap[{color_blending_id}]", return_path="id" ) except (CartaActionFailed, CartaBadResponse) as e: - raise RuntimeError( - f"No color blending with color_blending_id={color_blending_id} " - "is open." - ) from e + raise RuntimeError(f"No color blending with color_blending_id={color_blending_id} is open.") from e return ColorBlending(self, resolved_color_blending_id) def active_view(self): @@ -837,9 +780,7 @@ def active_view(self): the Python side. """ active = self.get_value("activeImage") - return View.view_class(active["type"])( - self, active["store"]["id"] - ) + return View.view_class(active["type"])(self, active["store"]["id"]) # COLOR BLENDING @@ -883,16 +824,12 @@ def create_color_blending(self, images=None): from another session. """ if images is not None: - if any(image.session is not self for image in images): - raise CartaValidationFailed( - "images must belong to the current session." - ) + if any(i.session is not self for i in images): + raise CartaValidationFailed("images must belong to the current session.") - image_ids = [image.image_id for image in images] + image_ids = [i.image_id for i in images] if len(set(image_ids)) != len(image_ids): - raise CartaValidationFailed( - "images must not contain duplicate images." - ) + raise CartaValidationFailed("images must not contain duplicate images.") images[0].make_spatial_reference() for image in images[1:]: @@ -902,10 +839,7 @@ def create_color_blending(self, images=None): if image_count <= 0: raise CartaActionFailed("No images are open.") - color_blending_id = self.call_action( - "imageViewConfigStore.createColorBlending", - return_path="id", - ) + color_blending_id = self.call_action("imageViewConfigStore.createColorBlending", return_path="id") cb = ColorBlending(self, color_blending_id) if images is not None: diff --git a/tests/test_session.py b/tests/test_session.py index 14ba6af..5ef1116 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -178,13 +178,15 @@ def test_views_uses_one_summary_for_explicit_indices(session, get_value): ) -def test_views_explicit_indices_reject_out_of_range(session, get_value): +def test_views_explicit_indices_list_all_out_of_range(session, get_value): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, ] - with pytest.raises(IndexError, match=r"view_indices \[1, 2\]"): - session.views(view_indices=[1, 2]) + with pytest.raises(IndexError) as exc_info: + session.views(view_indices=[2, 0, 1]) + + assert str(exc_info.value) == "No views with indices [2, 1] are open." get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" @@ -238,11 +240,13 @@ def test_images_uses_frame_names_for_explicit_ids(session, get_value): get_value.assert_called_once_with("frameNames") -def test_images_rejects_closed_explicit_id(session, get_value): +def test_images_list_all_closed_explicit_ids(session, get_value): get_value.return_value = [{"value": 10, "label": "Image 10"}] - with pytest.raises(RuntimeError, match="image_id=20"): - session.images(image_ids=[20]) + with pytest.raises(RuntimeError) as exc_info: + session.images(image_ids=[30, 10, 20]) + + assert str(exc_info.value) == "No images with image_ids [30, 20] are open." get_value.assert_called_once_with("frameNames") @@ -287,13 +291,18 @@ def test_color_blendings_uses_image_list_summary_for_explicit_ids( ) -def test_color_blendings_rejects_closed_explicit_id(session, get_value): +def test_color_blendings_list_all_closed_explicit_ids(session, get_value): get_value.return_value = [ {"type": ImageType.COLOR_BLENDING, "id": 3} ] - with pytest.raises(RuntimeError, match="color_blending_id=7"): - session.color_blendings([7]) + with pytest.raises(RuntimeError) as exc_info: + session.color_blendings([7, 3, 5]) + + assert ( + str(exc_info.value) + == "No color blendings with color_blending_ids [7, 5] are open." + ) get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" From a88097c1cb536bd77b1b694735d3244ee6ad6962 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 17 Aug 2026 20:44:07 +0800 Subject: [PATCH 099/123] Add deprecated decorator for marking deprecated callables, fix logger setup to prevent duplicate handlers, and change logging.WARN to logging.WARNING for consistency --- carta/util.py | 25 +++++++++++++++++++++++-- tests/test_util.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/carta/util.py b/carta/util.py index 5b4ebb4..e6b9ac4 100644 --- a/carta/util.py +++ b/carta/util.py @@ -4,12 +4,14 @@ import json import functools import re +import warnings from .units import AngularSize, WorldCoordinate logger = logging.getLogger("carta_scripting") -logger.setLevel(logging.WARN) -logger.addHandler(logging.StreamHandler()) +logger.setLevel(logging.WARNING) +if not logger.handlers: + logger.addHandler(logging.StreamHandler()) class CartaScriptingException(Exception): @@ -114,6 +116,25 @@ def default(self, obj): return json.JSONEncoder.default(self, obj) +def deprecated(message): + """Mark a callable as deprecated and warn when it is called. + + Parameters + ---------- + message : string + The message to include in the :obj:`DeprecationWarning`. + """ + def decorator(func): + @functools.wraps(func) + def newfunc(*args, **kwargs): + warnings.warn(message, DeprecationWarning, stacklevel=2) + return func(*args, **kwargs) + + return newfunc + + return decorator + + def cached(func): """A decorator which transparently caches the return value of the decorated method on the parent object. diff --git a/tests/test_util.py b/tests/test_util.py index 5bb8b3f..7b7e8a0 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,37 @@ -from carta.util import Point as Pt +import warnings + +import pytest + +from carta.util import Point as Pt, deprecated + + +def test_deprecated_warns_and_preserves_function_metadata(): + message = "use replacement instead" + + @deprecated(message) + def legacy(value, *, increment=0): + """The legacy function.""" + return value + increment + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert legacy(2, increment=3) == 5 + assert legacy(4) == 4 + + assert [warning.category for warning in caught] == [DeprecationWarning, DeprecationWarning] + assert [str(warning.message) for warning in caught] == [message, message] + assert legacy.__name__ == "legacy" + assert legacy.__doc__ == "The legacy function." + + +def test_deprecated_preserves_exceptions(): + @deprecated("use replacement instead") + def legacy(): + raise RuntimeError("failure") + + with pytest.warns(DeprecationWarning, match="use replacement instead"): + with pytest.raises(RuntimeError, match="failure"): + legacy() def test_point_equality(): From 526f347eaddc3f60283e040f5770fcd1bb53fc25 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 17 Aug 2026 20:56:21 +0800 Subject: [PATCH 100/123] Add deprecated decorator to Session.image_list, Session.image_by_id, and Session.active_frame methods, replace print with logger.warning for unsupported view types, and add corresponding test coverage for deprecation warnings and validation --- carta/session.py | 66 +++++++++++++++++++++++++++++++++++++++-- tests/test_session.py | 68 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/carta/session.py b/carta/session.py index 615d52b..a8bbdf9 100644 --- a/carta/session.py +++ b/carta/session.py @@ -15,7 +15,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, deprecated, logger, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -608,7 +608,7 @@ def views(self, view_indices=None): if not skip_unsupported: raise view_type = ImageType(entry["type"]) - print(f"Skipping unsupported {view_type.name} view at index {index}.") + logger.warning(f"Skipping unsupported {view_type.name} view at index {index}.") return views @validate(NoneOr(IterableOf(Number.ID))) @@ -636,6 +636,24 @@ def images(self, image_ids=None): raise RuntimeError(f"No images with image_ids {missing_ids} are open.") return [Image(self, i) for i in image_ids] + @deprecated( + "Session.image_list() is deprecated; use Session.images() for images, " + "Session.views() for all views, or Session.color_blendings() for color blendings." + ) + def image_list(self): + """Return all open images. + + .. deprecated:: + Use :meth:`images` for images, :meth:`views` for all views, or + :meth:`color_blendings` for color blendings instead. + + Returns + ------- + list of :obj:`carta.image.Image` + The list of images open in this session. + """ + return self.images() + @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): """Return color blending images from the session. @@ -763,6 +781,28 @@ def view_by_id(self, *, view_index=None, image_id=None, color_blending_id=None): raise RuntimeError(f"No color blending with color_blending_id={color_blending_id} is open.") from e return ColorBlending(self, resolved_color_blending_id) + @deprecated("Session.image_by_id() is deprecated; use Session.view_by_id(image_id=image_id) instead.") + @validate(Number.ID) + def image_by_id(self, image_id): + """Return an image object with the specified ID. + + .. deprecated:: + Use :meth:`view_by_id` with ``image_id`` instead. + + This is a helper function which constructs a :obj:`carta.image.Image` object with the specified ID, without checking whether an image with that ID is currently open. It is the caller's responsibility to ensure this. + + Parameters + ---------- + image_id : integer + The ID of the image to return. + + Returns + ------- + :obj:`carta.image.Image` + The image with the specified ID. + """ + return Image(self, image_id) + def active_view(self): """Return the currently active view. @@ -782,6 +822,28 @@ def active_view(self): active = self.get_value("activeImage") return View.view_class(active["type"])(self, active["store"]["id"]) + @deprecated("Session.active_frame() is deprecated; use Session.active_view() instead.") + def active_frame(self): + """Return the currently active image. + + .. deprecated:: + Use :meth:`active_view` instead. + + Returns + ------- + :obj:`carta.image.Image` + The currently active image. + + Raises + ------ + TypeError + If the currently active view is not an image. + """ + active = self.active_view() + if not isinstance(active, Image): + raise TypeError("The currently active view is not an image.") + return active + # COLOR BLENDING @validate(NoneOr(IterableOf(InstanceOf(Image), min_size=1))) diff --git a/tests/test_session.py b/tests/test_session.py index 5ef1116..18b1c64 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,4 +1,5 @@ from unittest.mock import call +import logging import pytest @@ -139,17 +140,52 @@ def test_views_heterogeneous(session, get_value): assert isinstance(views[2], Image) and views[2].image_id == 20 -def test_views_skips_unsupported_view_type(session, get_value, capsys): +def test_views_skips_unsupported_view_type(session, get_value, caplog, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, {"type": ImageType.PV_PREVIEW, "id": 11}, {"type": ImageType.FRAME, "id": 20}, ] - views = session.views() + with caplog.at_level(logging.WARNING, logger="carta_scripting"): + views = session.views() assert [view.image_id for view in views] == [10, 20] - assert capsys.readouterr().out == "Skipping unsupported PV_PREVIEW view at index 1.\n" + assert [record.getMessage() for record in caplog.records] == [ + "Skipping unsupported PV_PREVIEW view at index 1." + ] + assert capsys.readouterr().out == "" + + +def test_image_list_is_deprecated_and_calls_images(session, mocker): + images = mocker.patch.object(session, "images", return_value=["image"]) + + with pytest.warns(DeprecationWarning) as warning: + assert session.image_list() == ["image"] + + assert str(warning[0].message) == ( + "Session.image_list() is deprecated; use Session.images() for images, " + "Session.views() for all views, or Session.color_blendings() for color blendings." + ) + images.assert_called_once_with() + + +def test_image_by_id_is_deprecated_and_constructs_image(session): + with pytest.warns( + DeprecationWarning, + match=r"Session.image_by_id\(\) is deprecated; use Session.view_by_id\(image_id=image_id\) instead\.", + ): + image = session.image_by_id(12) + + assert isinstance(image, Image) + assert image.image_id == 12 + + +@pytest.mark.parametrize("image_id", [-1, 1.5, "12"]) +def test_image_by_id_rejects_invalid_ids(session, image_id): + with pytest.warns(DeprecationWarning): + with pytest.raises(CartaValidationFailed): + session.image_by_id(image_id) def test_views_empty(session, get_value): @@ -518,6 +554,32 @@ def test_active_view_raises_on_unsupported_type(session, get_value): with pytest.raises(NotImplementedError): session.active_view() + +def test_active_frame_is_deprecated_and_returns_active_image(session, mocker): + active = Image(session, 12) + active_view = mocker.patch.object(session, "active_view", return_value=active) + + with pytest.warns( + DeprecationWarning, + match=r"Session.active_frame\(\) is deprecated; use Session.active_view\(\) instead\.", + ): + assert session.active_frame() is active + + active_view.assert_called_once_with() + + +def test_active_frame_raises_for_non_image_active_view(session, mocker): + active_view = mocker.patch.object( + session, "active_view", return_value=ColorBlending(session, 3) + ) + + with pytest.warns(DeprecationWarning): + with pytest.raises(TypeError, match="currently active view is not an image"): + session.active_frame() + + active_view.assert_called_once_with() + + # open_as_color_blending / create_color_blending From bffaac7fb23e80ea57453e456c7548c5473f3fc4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 17 Aug 2026 21:15:48 +0800 Subject: [PATCH 101/123] Avoid the incorrect use of self.get_value("activeImage") and retrieve the view type and ID from their specific frontend paths instead. --- carta/session.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index a8bbdf9..8de63be 100644 --- a/carta/session.py +++ b/carta/session.py @@ -819,8 +819,9 @@ def active_view(self): If the active view is of a type that is not yet wrapped on the Python side. """ - active = self.get_value("activeImage") - return View.view_class(active["type"])(self, active["store"]["id"]) + view_type = self.get_value("activeImage.type") + view_id = self.get_value("activeImage.store.id") + return View.view_class(view_type)(self, view_id) @deprecated("Session.active_frame() is deprecated; use Session.active_view() instead.") def active_frame(self): From 06fd465df69aae36607d5b259fce725327c52770 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 17 Aug 2026 21:21:36 +0800 Subject: [PATCH 102/123] Refactor Session.image_by_id to use view_by_id --- carta/session.py | 5 +++-- tests/test_session.py | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/carta/session.py b/carta/session.py index 8de63be..a850cbf 100644 --- a/carta/session.py +++ b/carta/session.py @@ -789,7 +789,8 @@ def image_by_id(self, image_id): .. deprecated:: Use :meth:`view_by_id` with ``image_id`` instead. - This is a helper function which constructs a :obj:`carta.image.Image` object with the specified ID, without checking whether an image with that ID is currently open. It is the caller's responsibility to ensure this. + This is a helper function which returns the open image with the + specified ID. Parameters ---------- @@ -801,7 +802,7 @@ def image_by_id(self, image_id): :obj:`carta.image.Image` The image with the specified ID. """ - return Image(self, image_id) + return self.view_by_id(image_id=image_id) def active_view(self): """Return the currently active view. diff --git a/tests/test_session.py b/tests/test_session.py index 18b1c64..f133e0a 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -170,15 +170,17 @@ def test_image_list_is_deprecated_and_calls_images(session, mocker): images.assert_called_once_with() -def test_image_by_id_is_deprecated_and_constructs_image(session): +def test_image_by_id_is_deprecated_and_calls_view_by_id(session, mocker): + view_by_id = mocker.patch.object(session, "view_by_id", return_value=Image(session, 12)) + with pytest.warns( DeprecationWarning, match=r"Session.image_by_id\(\) is deprecated; use Session.view_by_id\(image_id=image_id\) instead\.", ): image = session.image_by_id(12) - assert isinstance(image, Image) assert image.image_id == 12 + view_by_id.assert_called_once_with(image_id=12) @pytest.mark.parametrize("image_id", [-1, 1.5, "12"]) @@ -523,13 +525,15 @@ def test_view_by_id_uses_targeted_frontend_lookups(session, get_value): def test_active_view_returns_image_when_image_active(session, get_value): get_value.side_effect = [ - {"type": ImageType.FRAME, "store": {"id": 12}}, + ImageType.FRAME, + 12, ] active = session.active_view() assert isinstance(active, Image) assert active.image_id == 12 assert [call.args for call in get_value.call_args_list] == [ - ("activeImage",), + ("activeImage.type",), + ("activeImage.store.id",), ] @@ -537,19 +541,22 @@ def test_active_view_returns_color_blending_when_color_blending_active( session, get_value ): get_value.side_effect = [ - {"type": ImageType.COLOR_BLENDING, "store": {"id": 3}}, + ImageType.COLOR_BLENDING, + 3, ] active = session.active_view() assert isinstance(active, ColorBlending) assert active.color_blending_id == 3 assert [call.args for call in get_value.call_args_list] == [ - ("activeImage",), + ("activeImage.type",), + ("activeImage.store.id",), ] def test_active_view_raises_on_unsupported_type(session, get_value): get_value.side_effect = [ - {"type": ImageType.PV_PREVIEW, "store": {"id": -2}}, + ImageType.PV_PREVIEW, + -2, ] with pytest.raises(NotImplementedError): session.active_view() From b8b7642cc0474921779bc3ccdea1d508999d7947 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 17 Aug 2026 21:26:34 +0800 Subject: [PATCH 103/123] Change deprecated directive to warning directive in docstrings for image_list, image_by_id, and active_frame methods --- carta/session.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/carta/session.py b/carta/session.py index a850cbf..8b5f143 100644 --- a/carta/session.py +++ b/carta/session.py @@ -643,9 +643,9 @@ def images(self, image_ids=None): def image_list(self): """Return all open images. - .. deprecated:: - Use :meth:`images` for images, :meth:`views` for all views, or - :meth:`color_blendings` for color blendings instead. + .. warning:: + Deprecated. Use :meth:`images` for images, :meth:`views` for all + views, or :meth:`color_blendings` for color blendings instead. Returns ------- @@ -786,8 +786,8 @@ def view_by_id(self, *, view_index=None, image_id=None, color_blending_id=None): def image_by_id(self, image_id): """Return an image object with the specified ID. - .. deprecated:: - Use :meth:`view_by_id` with ``image_id`` instead. + .. warning:: + Deprecated. Use :meth:`view_by_id` with ``image_id`` instead. This is a helper function which returns the open image with the specified ID. @@ -828,8 +828,8 @@ def active_view(self): def active_frame(self): """Return the currently active image. - .. deprecated:: - Use :meth:`active_view` instead. + .. warning:: + Deprecated. Use :meth:`active_view` instead. Returns ------- From 05efdcc02c624eee729d02c5f98bdd8ce2c986ed Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 18 Aug 2026 15:29:50 +0800 Subject: [PATCH 104/123] Refactor version checking --- carta/browser.py | 68 ++++++------ carta/constants.py | 9 ++ carta/session.py | 219 +++++++++++++++++++------------------ carta/util.py | 105 ------------------ carta/version.py | 143 ++++++++++++++++++++++++ docs/source/quickstart.rst | 110 +++++++++++++------ tests/test_browser.py | 70 ++++++------ tests/test_session.py | 204 ++++++++++++++++++++++++---------- tests/test_util.py | 58 ---------- tests/test_version.py | 66 +++++++++++ 10 files changed, 628 insertions(+), 424 deletions(-) create mode 100644 carta/version.py create mode 100644 tests/test_version.py diff --git a/carta/browser.py b/carta/browser.py index 7771de2..23e3708 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -8,7 +8,8 @@ from selenium.common.exceptions import NoSuchElementException from .backend import Backend -from .util import CartaBadSession +from .constants import VersionMismatchAction +from .util import CartaBadSession, logger from .protocol import Protocol from .session import Session @@ -34,7 +35,7 @@ class Browser: def __init__(self, driver_class, **kwargs): self.driver = driver_class(**kwargs) - def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): + def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): """Create a new session by connecting to an existing backend. You can use :obj:`carta.session.Session.create`, which wraps this method. @@ -51,19 +52,16 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 The number of seconds to spend parsing the frontend for connection information. 10 seconds by default. debug_no_auth : boolean Disable authentication. This should be set if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. - check_connection : boolean, optional - Whether to validate the session by reading the CARTA frontend - version through the scripting interface before returning. Default: - ``True``. Set to ``False`` to skip startup scripting calls unless - ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - carta_version_requirement : string, optional - A CARTA version requirement string. If provided, the session - frontend version is checked before this method returns, even when - ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. + minimum_carta_version : string, optional + The minimum CARTA version known to support the script, for example + ``"6.1.0"``. Omit it temporarily to receive a deprecation + warning; it will be required in a future release. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.WARN`` is the default. Returns ------- @@ -116,18 +114,18 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 self.exit(f"Could not parse session ID from frontend. Last error: {last_error}") session = Session(session_id, protocol, browser=self, backend=backend) - if check_connection or carta_version_requirement is not None: - try: - session._validate_session( - timeout=connection_check_timeout, - carta_version_requirement=carta_version_requirement, - ) - except Exception: - self._close_after_error() - raise + try: + session._validate_session( + timeout=connection_check_timeout, + minimum_carta_version=minimum_carta_version, + version_mismatch_action=version_mismatch_action, + ) + except Exception: + self._close_after_error() + raise return session - def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): + def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): """Create a new session after launching a new backend process. You can use :obj:`carta.session.Session.start_and_create`, which wraps this method. This method starts a backend process, parses the frontend URL from the output, and calls :obj:`carta.browser.Browser.new_session_from_url`. @@ -146,19 +144,16 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa The security token to use. Parsed from the backend output by default. frontend_url_timeout : integer How long to keep checking the backend output for the frontend URL. Default: 10 seconds. - check_connection : boolean, optional - Whether to validate the session by reading the CARTA frontend - version through the scripting interface before returning. Default: - ``True``. Set to ``False`` to skip startup scripting calls unless - ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - carta_version_requirement : string, optional - A CARTA version requirement string. If provided, the session - frontend version is checked before this method returns, even when - ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. + minimum_carta_version : string, optional + The minimum CARTA version known to support the script, for example + ``"6.1.0"``. Omit it temporarily to receive a deprecation + warning; it will be required in a future release. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.WARN`` is the default. Returns ------- @@ -195,12 +190,15 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa backend=backend, timeout=timeout, debug_no_auth=backend.debug_no_auth, - check_connection=check_connection, connection_check_timeout=connection_check_timeout, - carta_version_requirement=carta_version_requirement, + minimum_carta_version=minimum_carta_version, + version_mismatch_action=version_mismatch_action, ) except Exception: - backend.stop() + try: + backend.stop() + except Exception: + logger.exception("Failed to stop CARTA backend after session creation failed.") raise def exit(self, msg): diff --git a/carta/constants.py b/carta/constants.py index 87dc673..243c652 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -30,6 +30,15 @@ class ColormapSet(StrEnum): RAINBOW = "Rainbow" +class VersionMismatchAction(StrEnum): + """Action to take when a CARTA version check does not pass.""" + WARN = "warn" + ERROR = "error" + + +MINIMUM_CARTA_VERSION = "6.0.0" + + class ImageType(IntEnum): """View item types, corresponding to the frontend ImageType enum.""" FRAME = 0 diff --git a/carta/session.py b/carta/session.py index d871949..7e119c0 100644 --- a/carta/session.py +++ b/carta/session.py @@ -8,24 +8,50 @@ import base64 import posixpath +import warnings from .image import Image from .view import View from .color_blending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet +from .constants import ( + PanelMode, + GridMode, + ComplexComponent, + ImageType, + Polarization, + ColormapSet, + MINIMUM_CARTA_VERSION, + VersionMismatchAction, +) from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, CartaUnsupportedVersion, cached, deprecated, logger, carta_version_satisfies_requirement, _parse_carta_version_requirement, Point as Pt +from .util import ( + Macro, + split_action_path, + CartaActionFailed, + CartaBadResponse, + CartaBadID, + CartaBadSession, + CartaBadUrl, + CartaScriptingException, + CartaValidationFailed, + CartaUnsupportedVersion, + cached, + deprecated, + logger, + Point as Pt, +) from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union +from .version import ( + validate_carta_version, + version_mismatch_details, +) from .wcs_overlay import SessionWCSOverlay from .raster import SessionRaster from .preferences import Preferences -MINIMUM_CARTA_VERSION = "6.0.0" - - class Session: """This object corresponds to a CARTA frontend session. @@ -79,7 +105,7 @@ def __del__(self): self.close() @classmethod - def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): + def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): """Interact with an existing CARTA frontend session. Parameters @@ -94,19 +120,16 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac Disable authentication. Set this if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. backend : :obj:`carta.backend.Backend` The backend object associated with this session, if any. This is set if this method is called from :obj:`carta.session.Session.start_and_interact`. - check_connection : boolean, optional - Whether to validate the session by reading the CARTA frontend - version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls - unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - carta_version_requirement : string, optional - A CARTA version requirement string. If provided, the session - frontend version is checked before this method returns, even when - ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. + minimum_carta_version : string, optional + The minimum CARTA version known to support the script, for example + ``"6.1.0"``. Omit it temporarily to receive a deprecation + warning; it will be required in a future release. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.WARN`` is the default. Returns ------- @@ -139,15 +162,15 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac Protocol(frontend_url, token, debug_no_auth=debug_no_auth), backend=backend, ) - if check_connection or carta_version_requirement is not None: - session._validate_session( - timeout=connection_check_timeout, - carta_version_requirement=carta_version_requirement, - ) + session._validate_session( + timeout=connection_check_timeout, + minimum_carta_version=minimum_carta_version, + version_mismatch_action=version_mismatch_action, + ) return session @classmethod - def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): + def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): """Start a new CARTA backend instance and interact with the default CARTA frontend session which is created automatically in the user's browser. This method cannot be used with a CARTA controller instance. Parameters @@ -164,19 +187,16 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu How long to keep checking the backend output for the frontend URL. Default: 10 seconds. session_creation_timeout : integer How long to keep checking the output for a default session ID. Default: 10 seconds. - check_connection : boolean, optional - Whether to validate the session by reading the CARTA frontend - version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls - unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - carta_version_requirement : string, optional - A CARTA version requirement string. If provided, the session - frontend version is checked before this method returns, even when - ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. + minimum_carta_version : string, optional + The minimum CARTA version known to support the script, for example + ``"6.1.0"``. Omit it temporarily to receive a deprecation + warning; it will be required in a future release. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.WARN`` is the default. Returns ------- @@ -218,16 +238,19 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu token, backend.debug_no_auth, backend, - check_connection=check_connection, connection_check_timeout=connection_check_timeout, - carta_version_requirement=carta_version_requirement, + minimum_carta_version=minimum_carta_version, + version_mismatch_action=version_mismatch_action, ) except Exception: - backend.stop() + try: + backend.stop() + except Exception: + logger.exception("Failed to stop CARTA backend after session creation failed.") raise @classmethod - def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): + def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): """Connect to an existing CARTA backend or CARTA controller instance and create a new session. Parameters @@ -242,19 +265,16 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal The number of seconds to spend retrying parsing connection information from the frontend (default: 10). debug_no_auth : boolean, optional Disable authentication. Set this if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. - check_connection : boolean, optional - Whether to validate the session by reading the CARTA frontend - version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls - unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - carta_version_requirement : string, optional - A CARTA version requirement string. If provided, the session - frontend version is checked before this method returns, even when - ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. + minimum_carta_version : string, optional + The minimum CARTA version known to support the script, for example + ``"6.1.0"``. Omit it temporarily to receive a deprecation + warning; it will be required in a future release. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.WARN`` is the default. Returns ------- @@ -281,13 +301,13 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal backend=None, timeout=timeout, debug_no_auth=debug_no_auth, - check_connection=check_connection, connection_check_timeout=connection_check_timeout, - carta_version_requirement=carta_version_requirement, + minimum_carta_version=minimum_carta_version, + version_mismatch_action=version_mismatch_action, ) @classmethod - def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, check_connection=True, connection_check_timeout=10, carta_version_requirement=None): + def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): """Start a new CARTA backend instance and create a new session. This method cannot be used with a CARTA controller instance (which already starts and stops backend instances for the user on demand). Parameters @@ -306,19 +326,16 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa The security token to use. Parsed from the backend output by default. frontend_url_timeout : integer How long to keep checking the backend output for the frontend URL. Default: 10 seconds. - check_connection : boolean, optional - Whether to validate the session by reading the CARTA frontend - version through the scripting interface before returning. - Default: ``True``. Set to ``False`` to skip startup scripting calls - unless ``carta_version_requirement`` is provided. connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - carta_version_requirement : string, optional - A CARTA version requirement string. If provided, the session - frontend version is checked before this method returns, even when - ``check_connection`` is ``False``. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. + minimum_carta_version : string, optional + The minimum CARTA version known to support the script, for example + ``"6.1.0"``. Omit it temporarily to receive a deprecation + warning; it will be required in a future release. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.WARN`` is the default. Returns ------- @@ -346,9 +363,9 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa timeout, token, frontend_url_timeout, - check_connection=check_connection, connection_check_timeout=connection_check_timeout, - carta_version_requirement=carta_version_requirement, + minimum_carta_version=minimum_carta_version, + version_mismatch_action=version_mismatch_action, ) def __repr__(self): @@ -375,40 +392,6 @@ def carta_version(self): """ return self.get_value("frontendVersion") - def require_carta_version(self, carta_version_requirement, feature=None): - """Require this session to satisfy a CARTA version requirement. - - Parameters - ---------- - carta_version_requirement : string - A CARTA version requirement string. Examples: ``">=6.1.0"``, - ``"<=6.2.0"``, ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. - feature : string, optional - The feature requiring this version. Used in error messages. - - Returns - ------- - string - The detected CARTA version. - - Raises - ------ - CartaUnsupportedVersion - If the frontend-reported CARTA version cannot be parsed or does - not satisfy the requirement. - CartaValidationFailed - If the user-supplied requirement string cannot be parsed. - """ - version = self.carta_version - if carta_version_satisfies_requirement(version, carta_version_requirement): - return version - - feature_text = f" for {feature}" if feature else "" - raise CartaUnsupportedVersion( - f"CARTA version {version!r} is not supported{feature_text}. " - f"Required version is {carta_version_requirement!r}." - ) - def _fetch_frontend_version(self, timeout=None): kwargs = {"response_expected": True} if timeout is not None: @@ -425,9 +408,22 @@ def _cache_carta_version(self, version): self._cache = {} self._cache["carta_version"] = version - def _validate_session(self, timeout=10, carta_version_requirement=None): - if carta_version_requirement is not None: - _parse_carta_version_requirement(carta_version_requirement) + @validate(Number(min=0), NoneOr(String()), Constant(VersionMismatchAction)) + def _validate_session(self, timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + if minimum_carta_version is None: + warnings.warn( + "minimum_carta_version will be required in a future release; " + "pass the CARTA version known to support this script.", + DeprecationWarning, + stacklevel=4, + ) + else: + validate_carta_version( + minimum_carta_version, + parameter_name="minimum_carta_version", + ) + + version_mismatch_action = VersionMismatchAction(version_mismatch_action) try: version = self._fetch_frontend_version(timeout=timeout) @@ -437,17 +433,30 @@ def _validate_session(self, timeout=10, carta_version_requirement=None): ) from e self._cache_carta_version(version) - self.require_carta_version( - f">={MINIMUM_CARTA_VERSION}", - feature="carta-python session startup", + mismatches, suggestions = version_mismatch_details( + version, + MINIMUM_CARTA_VERSION, + minimum_carta_version, ) - if carta_version_requirement is not None: - self.require_carta_version( - carta_version_requirement, - feature="user-specified CARTA version requirement", + if mismatches: + message = "CARTA version validation failed:\n" + "\n".join( + f"- {mismatch}" for mismatch in mismatches ) + if version_mismatch_action is VersionMismatchAction.ERROR: + suggestions.append( + "If this combination is known to work, set " + "`version_mismatch_action=VersionMismatchAction.WARN`." + ) + if suggestions: + message += "\nSuggested actions:\n" + "\n".join( + f"- {suggestion}" for suggestion in suggestions + ) + if version_mismatch_action is VersionMismatchAction.ERROR: + raise CartaUnsupportedVersion(message) + logger.warning(message) return version + def _connection_check_error_message(self, error, timeout): uri = self._protocol.frontend_url if self._protocol else None return ( diff --git a/carta/util.py b/carta/util.py index 30c835a..ab56849 100644 --- a/carta/util.py +++ b/carta/util.py @@ -168,111 +168,6 @@ def split_action_path(path): return '.'.join(parts[:-1]), parts[-1] -def parse_carta_version(version): - """Parse a CARTA version string into a ``(major, minor, patch)`` tuple. - - Any prerelease suffix (e.g. ``"-dev"``, ``"-beta.1"``) is ignored. - - Parameters - ---------- - version : string - A version string of the form ``"MAJOR.MINOR.PATCH"`` with an optional - ``"-SUFFIX"`` (e.g. ``"6.0.0"`` or ``"6.0.0-dev"``). - - Returns - ------- - tuple or None - ``(major, minor, patch)`` or ``None`` if the version string cannot be - parsed. - """ - match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-[A-Za-z0-9.]+)?", str(version)) - if not match: - return None - - major, minor, patch = match.groups() - return (int(major), int(minor), int(patch)) - - -def _parse_carta_version_requirement(requirement): - """Parse a CARTA version requirement string. - - The requirement is a comma-separated list of comparison clauses such as - ``">=6.1.0"``, ``"<7.0.0"`` or ``"==6.2.0"``. - """ - if not isinstance(requirement, str) or not requirement.strip(): - raise CartaValidationFailed( - "CARTA version requirement must be a non-empty string." - ) - - clauses = [] - for clause in requirement.split(","): - clause = clause.strip() - match = re.fullmatch(r"(>=|<=|==|>|<)\s*(.+)", clause) - if not match: - raise CartaValidationFailed( - f"Invalid CARTA version requirement clause {clause!r}. " - "Expected an operator (>=, >, <=, <, ==) followed by a " - "MAJOR.MINOR.PATCH version." - ) - - operator, required_version = match.groups() - required_base = parse_carta_version(required_version) - if required_base is None: - raise CartaValidationFailed( - f"Invalid CARTA version {required_version!r} in requirement " - f"{requirement!r}." - ) - - clauses.append((operator, required_base)) - - return clauses - - -def carta_version_satisfies_requirement(version, requirement): - """Check whether a CARTA version satisfies a version requirement. - - Only the ``MAJOR.MINOR.PATCH`` components are compared; any prerelease - suffix is ignored. The requirement is a comma-separated list of comparison - clauses, all of which must be satisfied. - - Parameters - ---------- - version : string - The CARTA version string to check. - requirement : string - A comma-separated requirement string such as ``">=6.1.0"``, - ``"==6.2.0"`` or ``">=6.1.0,<7.0.0"``. - - Returns - ------- - boolean - ``True`` if ``version`` satisfies the requirement, ``False`` if the - version cannot be parsed or does not satisfy every clause. - - Raises - ------ - CartaValidationFailed - If the requirement string cannot be parsed. - """ - parsed_requirement = _parse_carta_version_requirement(requirement) - - checks = { - ">=": lambda left, right: left >= right, - ">": lambda left, right: left > right, - "<=": lambda left, right: left <= right, - "<": lambda left, right: left < right, - "==": lambda left, right: left == right, - } - version_base = parse_carta_version(version) - if version_base is None: - return False - - return all( - checks[operator](version_base, required_base) - for operator, required_base in parsed_requirement - ) - - class BasePathMixin: """A mixin which provides ``call_action`` and ``get_value`` methods which prepend the object's base path to the path before calling the corresponding :obj:`carta.session.Session` methods. diff --git a/carta/version.py b/carta/version.py new file mode 100644 index 0000000..343e5d7 --- /dev/null +++ b/carta/version.py @@ -0,0 +1,143 @@ +"""Helpers for validating CARTA version compatibility.""" + +import re + +from .util import CartaValidationFailed + + +_VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-[A-Za-z0-9.]+)?") + + +def parse_carta_version(version): + """Parse a CARTA version string into a ``(major, minor, patch)`` tuple. + + Any prerelease suffix (e.g. ``"-dev"``, ``"-beta.1"``) is ignored. + + Parameters + ---------- + version : string + A version string of the form ``"MAJOR.MINOR.PATCH"`` with an optional + ``"-SUFFIX"`` (e.g. ``"6.0.0"`` or ``"6.0.0-dev"``). + + Returns + ------- + tuple or None + ``(major, minor, patch)`` or ``None`` if the version string cannot be + parsed. + """ + match = _VERSION_PATTERN.fullmatch(str(version)) + if not match: + return None + + major, minor, patch = match.groups() + return (int(major), int(minor), int(patch)) + + +def validate_carta_version(version, parameter_name="CARTA version"): + """Validate and parse a single CARTA version supplied by a caller. + + Version requirements are deliberately represented by one version. Callers + interpret it as a minimum supported version; comparison operators and + version ranges are not accepted. + + Parameters + ---------- + version : string + A version string of the form ``MAJOR.MINOR.PATCH`` with an optional + prerelease suffix. + parameter_name : string, optional + Name to include in the validation error. + + Returns + ------- + tuple + The parsed ``(major, minor, patch)`` tuple. + + Raises + ------ + CartaValidationFailed + If ``version`` is not a valid CARTA version. + """ + parsed = parse_carta_version(version) + if parsed is None: + raise CartaValidationFailed( + f"{parameter_name} must be a version in MAJOR.MINOR.PATCH format." + ) + return parsed + + +def version_mismatch_details( + version, wrapper_minimum_version, minimum_carta_version=None +): + """Return compatibility problems and suggested actions for a CARTA version.""" + version_base = parse_carta_version(version) + if version_base is None: + return ( + [f"frontend reported invalid CARTA version {version!r}."], + ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], + ) + + requirements = [ + ( + "wrapper", + validate_carta_version( + wrapper_minimum_version, + parameter_name="MINIMUM_CARTA_VERSION", + ), + wrapper_minimum_version, + ) + ] + if minimum_carta_version is not None: + requirements.append( + ( + "script", + validate_carta_version( + minimum_carta_version, + parameter_name="minimum_carta_version", + ), + minimum_carta_version, + ) + ) + + mismatches = [] + suggestions = [] + carta_minimums = [] + for requirement_name, requirement_base, requirement in requirements: + if version_base < requirement_base: + mismatches.append( + f"frontend version {version!r} is older than the " + f"{requirement_name} minimum {requirement!r}." + ) + carta_minimums.append((requirement_base, requirement)) + elif version_base[0] > requirement_base[0]: + if requirement_name == "wrapper": + mismatches.append( + f"frontend major version {version_base[0]} is newer than " + f"the wrapper's supported major version {requirement_base[0]}." + ) + suggestions.append( + "Upgrade carta-python to a version supporting " + f"CARTA major version {version_base[0]}." + ) + else: + mismatches.append( + f"frontend major version {version_base[0]} is newer than " + f"the script target major version {requirement_base[0]}." + ) + suggestions.append( + "Update the script's `minimum_carta_version` to a " + "compatible CARTA major version after verifying " + "compatibility." + ) + + if carta_minimums: + _, required_version = max(carta_minimums, key=lambda item: item[0]) + if len(carta_minimums) > 1: + suggestions.append( + f"Upgrade CARTA to at least {required_version!r} " + "to satisfy both the carta-python and script minimums." + ) + else: + suggestions.append(f"Upgrade CARTA to at least {required_version!r}.") + + return mismatches, suggestions diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 604227a..1555dfb 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -22,28 +22,46 @@ The required Python library dependencies should be installed automatically. To c You need access either to a CARTA backend executable, on the local host or on a remote host which you can access through SSH, or to a CARTA controller instance (a multi-user system with web-based authentication). You must be able to access the frontend served by this CARTA instance. If you are using your own backend executable, you must start it with the ``--enable_scripting`` commandline parameter to enable the scripting interface. -.. note:: - This version of the wrapper requires CARTA backend, frontend and - (optionally) controller versions compatible with CARTA 6.0.0 or later. - Session creation validates the frontend by reading ``frontendVersion`` - through the scripting interface. Only the ``MAJOR.MINOR.PATCH`` - components are compared, so any prerelease suffix (e.g. ``-dev``) is - accepted as long as the base version meets the minimum. Older - prerelease builds such as ``6.0.0-beta.1`` do not expose - ``frontendVersion`` and therefore fail this validation. - - Scripts can also require a more specific CARTA frontend version when - creating a session. Pass ``carta_version_requirement`` with one or more - comma-separated clauses such as ``">=6.1.0"``, ``"<=6.2.0"``, - ``"==6.1.0"`` or ``">=6.1.0,<7.0.0"``. This check runs before the - session object is returned, even if ``check_connection=False``. +If you want to create browser sessions from the wrapper, you also need to make sure that your desired browser is installed, together with a corresponding web driver. At present only Chrome (or Chromium) can be used for headless sessions. + +CARTA version compatibility +---------------------------- + +Every session creation method checks that the CARTA version is compatible +with the minimum version supported by the installed ``carta-python`` wrapper. +The wrapper's minimum is maintained by the package and is not normally set by +the script. + +Scripts can also provide their own minimum CARTA version with +``minimum_carta_version``. This should be the minimum version required by the +script, rather than the wrapper's minimum. For example: + +.. code-block:: python + + from carta.session import Session + from carta.constants import VersionMismatchAction + + SCRIPT_MINIMUM_CARTA_VERSION = "6.1.0" + + session = Session.interact( + "FRONTEND URL", + 123456, + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + version_mismatch_action=VersionMismatchAction.WARN, + ) + +Omit ``minimum_carta_version`` temporarily to receive a deprecation warning; +it will be required in a future release. Version mismatches produce a warning +by default, including suggested actions. Use +``version_mismatch_action=VersionMismatchAction.ERROR`` to raise an exception +instead. + +The session examples below use the ``SCRIPT_MINIMUM_CARTA_VERSION`` variable +defined above. If session creation fails during startup validation, check that the frontend URL is reachable, the session ID is correct, the token is valid, and the -backend was started with ``--enable_scripting``. A failure while reading -``frontendVersion`` can also indicate an unsupported CARTA frontend version. - -If you want to create browser sessions from the wrapper, you also need to make sure that your desired browser is installed, together with a corresponding web driver. At present only Chrome (or Chromium) can be used for headless sessions. +backend was started with ``--enable_scripting``. Connecting to an existing interactive session --------------------------------------------- @@ -59,19 +77,22 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess from carta.session import Session from carta.token import BackendToken - session = Session.interact("FRONTEND URL", 123456, BackendToken("SECURITY TOKEN")) session = Session.interact( "FRONTEND URL", 123456, BackendToken("SECURITY TOKEN"), - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) If you have launched a backend directly, the frontend URL and security token must match your running backend process. You have the option of using an environment variable, ``CARTA_AUTH_TOKEN``, to run CARTA with a fixed security token. Otherwise, a randomly generated token will be printed by the backend when it starts. If you include the security token in the URL, you may omit the security token parameter (it will be parsed from the URL automatically): .. code-block:: python - session = Session.interact("http://HOSTNAME:PORT?token=SECURITY_TOKEN", 123456) + session = Session.interact( + "http://HOSTNAME:PORT?token=SECURITY_TOKEN", + 123456, + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) The second parameter is the session ID, which must match the running frontend session: it's visible when you hover over the status indicator at the top right of the CARTA window in your browser. You can copy it by navigating to ``File > Server > Copy session ID to clipboard``, or find it in the backend executable output. @@ -97,7 +118,12 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess from carta.session import Session from carta.token import ControllerToken - session = Session.interact("FRONTEND URL", 123456, ControllerToken.from_file("path/to/token")) + session = Session.interact( + "FRONTEND URL", + 123456, + ControllerToken.from_file("path/to/token"), + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) The second parameter is the session ID, which must match the running frontend session: it's visible when you hover over the status indicator at the top right of the CARTA window in your browser. You can copy it by navigating to ``File > Server > Copy session ID to clipboard``. @@ -119,11 +145,15 @@ Creating a new interactive session from carta.session import Session # New session, start local backend - session = Session.start_and_interact() - session = Session.start_and_interact(carta_version_requirement="==6.1.0") + session = Session.start_and_interact( + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) # New session, start remote backend - session = Session.start_and_interact(remote_host="REMOTE HOSTNAME OR IP") + session = Session.start_and_interact( + remote_host="REMOTE HOSTNAME OR IP", + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) .. tab:: Controller access @@ -131,7 +161,12 @@ Creating a new interactive session .. code-block:: python - session = Session.create(Chrome(headless=False), "FRONTEND URL", ControllerToken.from_file("path/to/token")) + session = Session.create( + Chrome(headless=False), + "FRONTEND URL", + ControllerToken.from_file("path/to/token"), + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) You can also create a custom :obj:`carta.browser.Browser` object with any appropriate browser executable and driver and any commandline parameters. @@ -156,19 +191,25 @@ Use the ``create`` method if you want to write a non-interactive script which st from carta.browser import Chrome # New session, connect to an existing backend - session = Session.create(Chrome(), "FRONTEND URL", BackendToken("SECURITY TOKEN")) session = Session.create( Chrome(), "FRONTEND URL", BackendToken("SECURITY TOKEN"), - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) # New session, start local backend - session = Session.start_and_create(Chrome()) + session = Session.start_and_create( + Chrome(), + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) # New session, start remote backend - session = Session.start_and_create(Chrome(), remote_host="REMOTE HOSTNAME OR IP") + session = Session.start_and_create( + Chrome(), + remote_host="REMOTE HOSTNAME OR IP", + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) .. tab:: Controller access @@ -194,7 +235,12 @@ Use the ``create`` method if you want to write a non-interactive script which st from carta.token import ControllerToken # New session, connect to an existing controller - session = Session.create(Chrome(), "FRONTEND URL", ControllerToken.from_file("path/to/token")) + session = Session.create( + Chrome(), + "FRONTEND URL", + ControllerToken.from_file("path/to/token"), + minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, + ) These commands are further customisable with optional parameters. See :doc:`the API reference ` for more information. diff --git a/tests/test_browser.py b/tests/test_browser.py index 3c398de..9b405c5 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -1,5 +1,7 @@ import pytest +from carta.constants import VersionMismatchAction + class FakeElement: def __init__(self, text): @@ -66,7 +68,6 @@ def test_new_session_from_url_runs_connection_check_after_parsing_session_id( result = browser.new_session_from_url( "http://localhost:3000?token=x", - check_connection=True, connection_check_timeout=5, ) @@ -74,27 +75,13 @@ def test_new_session_from_url_runs_connection_check_after_parsing_session_id( session_class.assert_called_once_with(123, protocol, browser=browser, backend=None) session._validate_session.assert_called_once_with( timeout=5, - carta_version_requirement=None, + minimum_carta_version=None, + version_mismatch_action=VersionMismatchAction.WARN, ) assert browser.driver.closed is False -def test_new_session_from_url_skips_connection_check(mocker, browser_module): - browser = make_browser(browser_module.Browser) - protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") - mocker.patch("carta.browser.Protocol", return_value=protocol) - session = mocker.Mock() - mocker.patch("carta.browser.Session", return_value=session) - - browser.new_session_from_url( - "http://localhost:3000?token=x", - check_connection=False, - ) - - session._validate_session.assert_not_called() - - -def test_new_session_from_url_checks_connection_when_requirement_is_provided( +def test_new_session_from_url_passes_minimum_and_mismatch_action( mocker, browser_module ): browser = make_browser(browser_module.Browser) @@ -105,13 +92,14 @@ def test_new_session_from_url_checks_connection_when_requirement_is_provided( browser.new_session_from_url( "http://localhost:3000?token=x", - check_connection=False, - carta_version_requirement="<=6.2.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) session._validate_session.assert_called_once_with( timeout=10, - carta_version_requirement="<=6.2.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -128,7 +116,6 @@ def test_new_session_from_url_closes_browser_and_preserves_validation_error( with pytest.raises(util_module.CartaUnsupportedVersion): browser.new_session_from_url( "http://localhost:3000?token=x", - check_connection=True, ) assert browser.driver.closed is True @@ -148,7 +135,6 @@ def test_new_session_from_url_preserves_validation_error_when_close_fails( with pytest.raises(util_module.CartaUnsupportedVersion): browser.new_session_from_url( "http://localhost:3000?token=x", - check_connection=True, ) @@ -171,7 +157,7 @@ def test_new_session_with_backend_stops_backend_when_session_creation_fails( ) with pytest.raises(util_module.CartaBadSession): - browser.new_session_with_backend(check_connection=True) + browser.new_session_with_backend() backend.stop.assert_called_once_with() browser.new_session_from_url.assert_called_once_with( @@ -180,13 +166,35 @@ def test_new_session_with_backend_stops_backend_when_session_creation_fails( backend=backend, timeout=10, debug_no_auth=False, - check_connection=True, connection_check_timeout=10, - carta_version_requirement=None, + minimum_carta_version=None, + version_mismatch_action=VersionMismatchAction.WARN, ) -def test_new_session_with_backend_passes_carta_version_requirement( +def test_new_session_with_backend_preserves_error_when_backend_stop_fails( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock( + frontend_url="http://localhost:3000", + token="token", + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + backend.stop.side_effect = RuntimeError("stop failed") + mocker.patch("carta.browser.Backend", return_value=backend) + startup_error = util_module.CartaBadSession("startup failed") + mocker.patch.object(browser, "new_session_from_url", side_effect=startup_error) + + with pytest.raises(util_module.CartaBadSession, match="startup failed"): + browser.new_session_with_backend() + + backend.stop.assert_called_once_with() + + +def test_new_session_with_backend_passes_version_options( mocker, browser_module ): browser = make_browser(browser_module.Browser) @@ -202,9 +210,9 @@ def test_new_session_with_backend_passes_carta_version_requirement( new_session = mocker.patch.object(browser, "new_session_from_url", return_value=session) result = browser.new_session_with_backend( - check_connection=False, connection_check_timeout=4, - carta_version_requirement="==6.1.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) assert result is session @@ -214,9 +222,9 @@ def test_new_session_with_backend_passes_carta_version_requirement( backend=backend, timeout=10, debug_no_auth=False, - check_connection=False, connection_check_timeout=4, - carta_version_requirement="==6.1.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) diff --git a/tests/test_session.py b/tests/test_session.py index 0ea8b3e..9ca15c8 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -15,7 +15,13 @@ Macro, Point as Pt, ) -from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol +from carta.constants import ( + ColormapSet, + ComplexComponent as CC, + ImageType, + Polarization as Pol, + VersionMismatchAction, +) # FIXTURES @@ -91,31 +97,6 @@ def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" -def test_require_carta_version_returns_detected_version(session, get_value): - get_value.return_value = "6.1.0-dev" - - assert session.require_carta_version(">=6.0.0", feature="test feature") == "6.1.0-dev" - - -def test_require_carta_version_raises_for_unsupported_version(session, get_value): - get_value.return_value = "6.9.9" - - with pytest.raises(CartaUnsupportedVersion) as e: - session.require_carta_version(">=7.0.0", feature="test feature") - - message = str(e.value) - assert "test feature" in message - assert "6.9.9" in message - assert ">=7.0.0" in message - - -def test_require_carta_version_raises_for_invalid_requirement(session, get_value): - get_value.return_value = "6.1.0" - - with pytest.raises(CartaValidationFailed): - session.require_carta_version("6.1.0") - - def test_direct_session_construction_does_not_validate_session(mocker): validate_session = mocker.patch.object(Session, "_validate_session") @@ -128,7 +109,8 @@ def test_direct_session_construction_does_not_validate_session(mocker): def test_validate_session_fetches_frontend_version_with_timeout(session, call_action): call_action.return_value = "6.0.0-dev" - assert session._validate_session(timeout=3) == "6.0.0-dev" + with pytest.warns(DeprecationWarning, match="minimum_carta_version"): + assert session._validate_session(timeout=3) == "6.0.0-dev" call_action.assert_called_once_with( "fetchParameter", @@ -139,49 +121,130 @@ def test_validate_session_fetches_frontend_version_with_timeout(session, call_ac assert session.carta_version == "6.0.0-dev" -def test_validate_session_applies_user_requirement(session, call_action): - call_action.return_value = "6.1.0-dev" +def test_validate_session_applies_minimum_version(session, call_action): + call_action.return_value = "6.1.0" assert ( session._validate_session( timeout=3, - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version="6.1.0", ) - == "6.1.0-dev" + == "6.1.0" ) -def test_validate_session_raises_for_unsatisfied_user_requirement(session, call_action): +def test_validate_session_warns_for_unsatisfied_minimum_by_default( + session, call_action, caplog, mocker +): + call_action.return_value = "6.0.0" + mocker.patch("carta.session.MINIMUM_CARTA_VERSION", "6.1.0") + + session._validate_session(timeout=3, minimum_carta_version="6.2.0") + + assert "script minimum" in caplog.text + assert "Suggested actions:" in caplog.text + assert ( + "Upgrade CARTA to at least '6.2.0' to satisfy both the carta-python " + "and script minimums." + ) in caplog.text + assert "Upgrade CARTA to at least '6.1.0'." not in caplog.text + assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text + + +def test_validate_session_raises_for_unsatisfied_minimum_in_error_mode( + session, call_action +): call_action.return_value = "6.1.0" - with pytest.raises(CartaUnsupportedVersion) as e: + with pytest.raises(CartaUnsupportedVersion, match="script minimum"): session._validate_session( timeout=3, - carta_version_requirement="==6.2.0", + minimum_carta_version="6.2.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) - message = str(e.value) - assert "6.1.0" in message - assert "==6.2.0" in message - assert "user-specified CARTA version requirement" in message +def test_validate_session_warns_for_newer_frontend_major_by_default( + session, call_action, caplog +): + call_action.return_value = "7.0.0" + + session._validate_session(timeout=3, minimum_carta_version="6.0.0") + + assert "newer than the wrapper" in caplog.text + assert "Upgrade carta-python to a version supporting CARTA major version 7." in caplog.text + assert "Update the script's `minimum_carta_version`" in caplog.text + + +def test_validate_session_suggests_upgrading_carta_for_old_wrapper_minimum( + session, call_action, caplog +): + call_action.return_value = "5.9.0" + + session._validate_session(timeout=3, minimum_carta_version="5.9.0") + + assert "Upgrade CARTA to at least '6.0.0'." in caplog.text + + +def test_validate_session_raises_for_newer_frontend_major_in_error_mode( + session, call_action +): + call_action.return_value = "7.0.0" + + with pytest.raises(CartaUnsupportedVersion) as error: + session._validate_session( + timeout=3, + minimum_carta_version="6.0.0", + version_mismatch_action=VersionMismatchAction.ERROR, + ) -def test_validate_session_validates_requirement_before_fetching_version(session, call_action): + assert "newer than the wrapper" in str(error.value) + assert "version_mismatch_action=VersionMismatchAction.WARN" in str(error.value) + + +def test_validate_session_validates_minimum_before_fetching_version(session, call_action): + with pytest.raises(CartaValidationFailed): + session._validate_session( + timeout=3, + minimum_carta_version=">=6.1.0", + ) + + call_action.assert_not_called() + + +def test_validate_session_validates_action_before_fetching_version(session, call_action): with pytest.raises(CartaValidationFailed): session._validate_session( timeout=3, - carta_version_requirement="6.1.0", + minimum_carta_version="6.1.0", + version_mismatch_action="invalid", ) call_action.assert_not_called() +def test_validate_session_reports_invalid_frontend_version_in_error_mode( + session, call_action +): + call_action.return_value = "bad.version" + + with pytest.raises(CartaUnsupportedVersion, match="invalid CARTA version"): + session._validate_session( + timeout=2, + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + def test_validate_session_wraps_frontend_version_failure(session, call_action, mocker): session._protocol = mocker.Mock(frontend_url="http://localhost:3000") call_action.side_effect = CartaActionFailed("frontendVersion unavailable") with pytest.raises(CartaBadSession) as e: - session._validate_session(timeout=2) + session._validate_session( + timeout=2, + minimum_carta_version="6.0.0", + ) message = str(e.value) assert "Could not validate CARTA session 0" in message @@ -204,7 +267,8 @@ def test_interact_checks_connection_by_default(mocker): assert session._protocol is protocol validate_session.assert_called_once_with( timeout=10, - carta_version_requirement=None, + minimum_carta_version=None, + version_mismatch_action=VersionMismatchAction.WARN, ) @@ -221,11 +285,12 @@ def test_interact_passes_connection_check_timeout(mocker): validate_session.assert_called_once_with( timeout=4, - carta_version_requirement=None, + minimum_carta_version=None, + version_mismatch_action=VersionMismatchAction.WARN, ) -def test_interact_can_skip_connection_check(mocker): +def test_interact_always_validates(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) validate_session = mocker.patch.object(Session, "_validate_session") @@ -233,13 +298,16 @@ def test_interact_can_skip_connection_check(mocker): Session.interact( "http://localhost:3000?token=x", 123, - check_connection=False, ) - validate_session.assert_not_called() + validate_session.assert_called_once_with( + timeout=10, + minimum_carta_version=None, + version_mismatch_action=VersionMismatchAction.WARN, + ) -def test_interact_checks_connection_when_requirement_is_provided(mocker): +def test_interact_passes_minimum_and_mismatch_action(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) validate_session = mocker.patch.object(Session, "_validate_session") @@ -247,13 +315,14 @@ def test_interact_checks_connection_when_requirement_is_provided(mocker): Session.interact( "http://localhost:3000?token=x", 123, - check_connection=False, - carta_version_requirement="==6.1.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) validate_session.assert_called_once_with( timeout=10, - carta_version_requirement="==6.1.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -278,6 +347,25 @@ def test_start_and_interact_stops_backend_when_connection_check_fails(mocker): backend.stop.assert_called_once_with() +def test_start_and_interact_preserves_error_when_backend_stop_fails(mocker): + backend = mocker.Mock( + frontend_url="http://localhost:3000", + last_session_id=123, + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + backend.stop.side_effect = RuntimeError("stop failed") + mocker.patch("carta.session.Backend", return_value=backend) + startup_error = CartaBadSession("startup check failed") + mocker.patch.object(Session, "interact", side_effect=startup_error) + + with pytest.raises(CartaBadSession, match="startup check failed"): + Session.start_and_interact() + + backend.stop.assert_called_once_with() + + def test_create_passes_connection_check_options_to_browser(mocker): browser = mocker.Mock() expected_session = mocker.Mock() @@ -289,9 +377,9 @@ def test_create_passes_connection_check_options_to_browser(mocker): token="token", timeout=7, debug_no_auth=True, - check_connection=False, connection_check_timeout=4, - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) assert result is expected_session @@ -301,9 +389,9 @@ def test_create_passes_connection_check_options_to_browser(mocker): backend=None, timeout=7, debug_no_auth=True, - check_connection=False, connection_check_timeout=4, - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -320,9 +408,9 @@ def test_start_and_create_passes_connection_check_options_to_browser(mocker): timeout=7, token="token", frontend_url_timeout=8, - check_connection=False, connection_check_timeout=4, - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) assert result is expected_session @@ -333,9 +421,9 @@ def test_start_and_create_passes_connection_check_options_to_browser(mocker): 7, "token", 8, - check_connection=False, connection_check_timeout=4, - carta_version_requirement=">=6.1.0,<7.0.0", + minimum_carta_version="6.1.0", + version_mismatch_action=VersionMismatchAction.ERROR, ) diff --git a/tests/test_util.py b/tests/test_util.py index 2a54206..1bf916c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -3,9 +3,7 @@ import pytest from carta.util import ( - CartaValidationFailed, Point as Pt, - carta_version_satisfies_requirement, deprecated, ) @@ -72,59 +70,3 @@ def test_point_json(): def test_point_tuple(): assert Pt(1, 2).as_tuple() == (1, 2) assert Pt("1", "2").as_tuple() == ("1", "2") - - -@pytest.mark.parametrize("requirement", [ - ">=6.1.0", - ">6.0.0", - "<=6.1.0", - "<6.2.0", - "==6.1.0", - ">=6.0.0,<7.0.0", - " >= 6.0.0 , < 7.0.0 ", -]) -def test_carta_version_satisfies_requirement_accepts_matching_versions(requirement): - assert carta_version_satisfies_requirement("6.1.0", requirement) - - -@pytest.mark.parametrize("requirement", [ - ">=6.2.0", - ">6.1.0", - "<=6.0.0", - "<6.1.0", - "==6.2.0", - ">=6.0.0,<6.1.0", -]) -def test_carta_version_satisfies_requirement_rejects_nonmatching_versions(requirement): - assert not carta_version_satisfies_requirement("6.1.0", requirement) - - -@pytest.mark.parametrize("version", [ - "6.1.0-dev", - "6.1.0-beta.1", - "6.1.0-rc.1", -]) -def test_carta_version_satisfies_requirement_ignores_suffix(version): - assert carta_version_satisfies_requirement(version, "==6.1.0") - - -def test_carta_version_satisfies_requirement_rejects_bad_detected_version(): - assert not carta_version_satisfies_requirement("bad.version", ">=6.1.0") - - -def test_carta_version_satisfies_requirement_validates_requirement_first(): - with pytest.raises(CartaValidationFailed): - carta_version_satisfies_requirement("bad.version", "6.1.0") - - -@pytest.mark.parametrize("requirement", [ - "", - "6.1.0", - "=6.1.0", - "!=6.1.0", - ">=bad.version", - ">=6.1.0,", -]) -def test_carta_version_satisfies_requirement_raises_for_bad_requirement(requirement): - with pytest.raises(CartaValidationFailed): - carta_version_satisfies_requirement("6.1.0", requirement) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..15646d6 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,66 @@ +import pytest + +from carta.util import CartaValidationFailed +from carta.version import parse_carta_version, validate_carta_version + + +@pytest.mark.parametrize( + "version", + [ + "6.1.0", + "6.1.0-dev", + "6.1.0-beta.1", + "6.1.0-rc.1", + ], +) +def test_parse_carta_version_accepts_single_versions(version): + assert parse_carta_version(version) == (6, 1, 0) + + +@pytest.mark.parametrize( + "version", + [ + "", + "6.1", + ">=6.1.0", + "6.1.0,<7.0.0", + "bad.version", + ], +) +def test_parse_carta_version_rejects_invalid_versions(version): + assert parse_carta_version(version) is None + + +@pytest.mark.parametrize( + "version", + [ + "6.1.0", + "6.1.0-dev", + "6.1.0-beta.1", + "6.1.0-rc.1", + ], +) +def test_validate_carta_version_accepts_single_versions(version): + assert validate_carta_version(version) == (6, 1, 0) + + +@pytest.mark.parametrize( + "version", + [ + "", + "6.1", + ">=6.1.0", + "6.1.0,<7.0.0", + "bad.version", + ], +) +def test_validate_carta_version_rejects_invalid_versions(version): + with pytest.raises(CartaValidationFailed): + validate_carta_version(version) + + +def test_validate_carta_version_uses_parameter_name_in_error(): + with pytest.raises(CartaValidationFailed) as error: + validate_carta_version("bad.version", parameter_name="minimum_carta_version") + + assert "minimum_carta_version" in str(error.value) From 516cdb26156359d60789db4210ab3125da62c726 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 19 Aug 2026 12:18:58 +0800 Subject: [PATCH 105/123] Remove minimum_carta_version parameter and change default version_mismatch_action to ERROR --- carta/browser.py | 32 ++++------- carta/session.py | 84 ++++++++--------------------- carta/version.py | 108 +++++++------------------------------ docs/source/quickstart.rst | 48 +++-------------- tests/test_browser.py | 12 ++--- tests/test_session.py | 95 ++++++++------------------------ tests/test_version.py | 50 +++++++---------- 7 files changed, 102 insertions(+), 327 deletions(-) diff --git a/carta/browser.py b/carta/browser.py index 23e3708..ec7e65c 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -35,7 +35,7 @@ class Browser: def __init__(self, driver_class, **kwargs): self.driver = driver_class(**kwargs) - def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Create a new session by connecting to an existing backend. You can use :obj:`carta.session.Session.create`, which wraps this method. @@ -55,13 +55,9 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - minimum_carta_version : string, optional - The minimum CARTA version known to support the script, for example - ``"6.1.0"``. Omit it temporarily to receive a deprecation - warning; it will be required in a future release. version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional Whether a version mismatch should produce a warning or raise an - exception. ``VersionMismatchAction.WARN`` is the default. + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -77,10 +73,9 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the internal - minimum version requirement or the user-specified requirement. - CartaValidationFailed - If the user-specified requirement string cannot be parsed. + If the connected CARTA frontend does not satisfy the wrapper's + minimum version requirement and ``version_mismatch_action`` is + ``VersionMismatchAction.ERROR``. """ protocol = Protocol(frontend_url, token, debug_no_auth=debug_no_auth) @@ -117,7 +112,6 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 try: session._validate_session( timeout=connection_check_timeout, - minimum_carta_version=minimum_carta_version, version_mismatch_action=version_mismatch_action, ) except Exception: @@ -125,7 +119,7 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 raise return session - def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Create a new session after launching a new backend process. You can use :obj:`carta.session.Session.start_and_create`, which wraps this method. This method starts a backend process, parses the frontend URL from the output, and calls :obj:`carta.browser.Browser.new_session_from_url`. @@ -147,13 +141,9 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - minimum_carta_version : string, optional - The minimum CARTA version known to support the script, for example - ``"6.1.0"``. Omit it temporarily to receive a deprecation - warning; it will be required in a future release. version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional Whether a version mismatch should produce a warning or raise an - exception. ``VersionMismatchAction.WARN`` is the default. + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -169,10 +159,9 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the internal - minimum version requirement or the user-specified requirement. - CartaValidationFailed - If the user-specified requirement string cannot be parsed. + If the connected CARTA frontend does not satisfy the wrapper's + minimum version requirement and ``version_mismatch_action`` is + ``VersionMismatchAction.ERROR``. """ backend = Backend(("--no_browser", "--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout=frontend_url_timeout, session_creation_timeout=0) @@ -191,7 +180,6 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa timeout=timeout, debug_no_auth=backend.debug_no_auth, connection_check_timeout=connection_check_timeout, - minimum_carta_version=minimum_carta_version, version_mismatch_action=version_mismatch_action, ) except Exception: diff --git a/carta/session.py b/carta/session.py index 7e119c0..83035fb 100644 --- a/carta/session.py +++ b/carta/session.py @@ -8,7 +8,6 @@ import base64 import posixpath -import warnings from .image import Image from .view import View @@ -43,7 +42,6 @@ ) from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .version import ( - validate_carta_version, version_mismatch_details, ) @@ -105,7 +103,7 @@ def __del__(self): self.close() @classmethod - def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Interact with an existing CARTA frontend session. Parameters @@ -123,13 +121,9 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - minimum_carta_version : string, optional - The minimum CARTA version known to support the script, for example - ``"6.1.0"``. Omit it temporarily to receive a deprecation - warning; it will be required in a future release. version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional Whether a version mismatch should produce a warning or raise an - exception. ``VersionMismatchAction.WARN`` is the default. + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -147,10 +141,9 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the internal - minimum version requirement or the user-specified requirement. - CartaValidationFailed - If the user-specified requirement string cannot be parsed. + If the connected CARTA frontend does not satisfy the wrapper's + minimum version requirement and ``version_mismatch_action`` is + ``VersionMismatchAction.ERROR``. """ try: session_id = int(session_id) @@ -164,13 +157,12 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac ) session._validate_session( timeout=connection_check_timeout, - minimum_carta_version=minimum_carta_version, version_mismatch_action=version_mismatch_action, ) return session @classmethod - def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Start a new CARTA backend instance and interact with the default CARTA frontend session which is created automatically in the user's browser. This method cannot be used with a CARTA controller instance. Parameters @@ -190,13 +182,9 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - minimum_carta_version : string, optional - The minimum CARTA version known to support the script, for example - ``"6.1.0"``. Omit it temporarily to receive a deprecation - warning; it will be required in a future release. version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional Whether a version mismatch should produce a warning or raise an - exception. ``VersionMismatchAction.WARN`` is the default. + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -214,10 +202,9 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the internal - minimum version requirement or the user-specified requirement. - CartaValidationFailed - If the user-specified requirement string cannot be parsed. + If the connected CARTA frontend does not satisfy the wrapper's + minimum version requirement and ``version_mismatch_action`` is + ``VersionMismatchAction.ERROR``. """ backend = Backend(("--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout, session_creation_timeout) if not backend.start(): @@ -239,7 +226,6 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu backend.debug_no_auth, backend, connection_check_timeout=connection_check_timeout, - minimum_carta_version=minimum_carta_version, version_mismatch_action=version_mismatch_action, ) except Exception: @@ -250,7 +236,7 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu raise @classmethod - def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Connect to an existing CARTA backend or CARTA controller instance and create a new session. Parameters @@ -268,13 +254,9 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - minimum_carta_version : string, optional - The minimum CARTA version known to support the script, for example - ``"6.1.0"``. Omit it temporarily to receive a deprecation - warning; it will be required in a future release. version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional Whether a version mismatch should produce a warning or raise an - exception. ``VersionMismatchAction.WARN`` is the default. + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -290,10 +272,9 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the internal - minimum version requirement or the user-specified requirement. - CartaValidationFailed - If the user-specified requirement string cannot be parsed. + If the connected CARTA frontend does not satisfy the wrapper's + minimum version requirement and ``version_mismatch_action`` is + ``VersionMismatchAction.ERROR``. """ return browser.new_session_from_url( frontend_url, @@ -302,12 +283,11 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal timeout=timeout, debug_no_auth=debug_no_auth, connection_check_timeout=connection_check_timeout, - minimum_carta_version=minimum_carta_version, version_mismatch_action=version_mismatch_action, ) @classmethod - def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): + def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Start a new CARTA backend instance and create a new session. This method cannot be used with a CARTA controller instance (which already starts and stops backend instances for the user on demand). Parameters @@ -329,13 +309,9 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa connection_check_timeout : number, optional Maximum time in seconds to wait for the startup validation action. Default: 10 seconds. - minimum_carta_version : string, optional - The minimum CARTA version known to support the script, for example - ``"6.1.0"``. Omit it temporarily to receive a deprecation - warning; it will be required in a future release. version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional Whether a version mismatch should produce a warning or raise an - exception. ``VersionMismatchAction.WARN`` is the default. + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -351,10 +327,9 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the internal - minimum version requirement or the user-specified requirement. - CartaValidationFailed - If the user-specified requirement string cannot be parsed. + If the connected CARTA frontend does not satisfy the wrapper's + minimum version requirement and ``version_mismatch_action`` is + ``VersionMismatchAction.ERROR``. """ return browser.new_session_with_backend( executable_path, @@ -364,7 +339,6 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa token, frontend_url_timeout, connection_check_timeout=connection_check_timeout, - minimum_carta_version=minimum_carta_version, version_mismatch_action=version_mismatch_action, ) @@ -408,21 +382,8 @@ def _cache_carta_version(self, version): self._cache = {} self._cache["carta_version"] = version - @validate(Number(min=0), NoneOr(String()), Constant(VersionMismatchAction)) - def _validate_session(self, timeout=10, minimum_carta_version=None, version_mismatch_action=VersionMismatchAction.WARN): - if minimum_carta_version is None: - warnings.warn( - "minimum_carta_version will be required in a future release; " - "pass the CARTA version known to support this script.", - DeprecationWarning, - stacklevel=4, - ) - else: - validate_carta_version( - minimum_carta_version, - parameter_name="minimum_carta_version", - ) - + @validate(Number(min=0), Constant(VersionMismatchAction)) + def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): version_mismatch_action = VersionMismatchAction(version_mismatch_action) try: @@ -436,7 +397,6 @@ def _validate_session(self, timeout=10, minimum_carta_version=None, version_mism mismatches, suggestions = version_mismatch_details( version, MINIMUM_CARTA_VERSION, - minimum_carta_version, ) if mismatches: message = "CARTA version validation failed:\n" + "\n".join( diff --git a/carta/version.py b/carta/version.py index 343e5d7..c0f6a3d 100644 --- a/carta/version.py +++ b/carta/version.py @@ -33,42 +33,17 @@ def parse_carta_version(version): return (int(major), int(minor), int(patch)) -def validate_carta_version(version, parameter_name="CARTA version"): - """Validate and parse a single CARTA version supplied by a caller. - - Version requirements are deliberately represented by one version. Callers - interpret it as a minimum supported version; comparison operators and - version ranges are not accepted. - - Parameters - ---------- - version : string - A version string of the form ``MAJOR.MINOR.PATCH`` with an optional - prerelease suffix. - parameter_name : string, optional - Name to include in the validation error. - - Returns - ------- - tuple - The parsed ``(major, minor, patch)`` tuple. - - Raises - ------ - CartaValidationFailed - If ``version`` is not a valid CARTA version. - """ +def _parse_required_version(version): parsed = parse_carta_version(version) if parsed is None: raise CartaValidationFailed( - f"{parameter_name} must be a version in MAJOR.MINOR.PATCH format." + "MINIMUM_CARTA_VERSION must be a version in " + "MAJOR.MINOR.PATCH format." ) return parsed -def version_mismatch_details( - version, wrapper_minimum_version, minimum_carta_version=None -): +def version_mismatch_details(version, minimum_version): """Return compatibility problems and suggested actions for a CARTA version.""" version_base = parse_carta_version(version) if version_base is None: @@ -77,67 +52,22 @@ def version_mismatch_details( ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], ) - requirements = [ - ( - "wrapper", - validate_carta_version( - wrapper_minimum_version, - parameter_name="MINIMUM_CARTA_VERSION", - ), - wrapper_minimum_version, - ) - ] - if minimum_carta_version is not None: - requirements.append( - ( - "script", - validate_carta_version( - minimum_carta_version, - parameter_name="minimum_carta_version", - ), - minimum_carta_version, - ) - ) - + minimum_base = _parse_required_version(minimum_version) mismatches = [] suggestions = [] - carta_minimums = [] - for requirement_name, requirement_base, requirement in requirements: - if version_base < requirement_base: - mismatches.append( - f"frontend version {version!r} is older than the " - f"{requirement_name} minimum {requirement!r}." - ) - carta_minimums.append((requirement_base, requirement)) - elif version_base[0] > requirement_base[0]: - if requirement_name == "wrapper": - mismatches.append( - f"frontend major version {version_base[0]} is newer than " - f"the wrapper's supported major version {requirement_base[0]}." - ) - suggestions.append( - "Upgrade carta-python to a version supporting " - f"CARTA major version {version_base[0]}." - ) - else: - mismatches.append( - f"frontend major version {version_base[0]} is newer than " - f"the script target major version {requirement_base[0]}." - ) - suggestions.append( - "Update the script's `minimum_carta_version` to a " - "compatible CARTA major version after verifying " - "compatibility." - ) - - if carta_minimums: - _, required_version = max(carta_minimums, key=lambda item: item[0]) - if len(carta_minimums) > 1: - suggestions.append( - f"Upgrade CARTA to at least {required_version!r} " - "to satisfy both the carta-python and script minimums." - ) - else: - suggestions.append(f"Upgrade CARTA to at least {required_version!r}.") + if version_base < minimum_base: + mismatches.append( + f"frontend version {version!r} is older than the wrapper minimum " + f"{minimum_version!r}." + ) + suggestions.append(f"Upgrade CARTA to at least {minimum_version!r}.") + elif version_base[0] > minimum_base[0]: + mismatches.append( + f"frontend major version {version_base[0]} is newer than " + f"the wrapper's supported major version {minimum_base[0]}." + ) + suggestions.append( + "Upgrade carta-python to a version supporting " + f"CARTA major version {version_base[0]}.") return mismatches, suggestions diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 1555dfb..9c74719 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -29,35 +29,14 @@ CARTA version compatibility Every session creation method checks that the CARTA version is compatible with the minimum version supported by the installed ``carta-python`` wrapper. -The wrapper's minimum is maintained by the package and is not normally set by -the script. +The wrapper's minimum is maintained by the package and is not set by the +script. Version mismatches raise an exception by default. Use +``version_mismatch_action=VersionMismatchAction.WARN`` to continue with a +warning and suggested actions instead. -Scripts can also provide their own minimum CARTA version with -``minimum_carta_version``. This should be the minimum version required by the -script, rather than the wrapper's minimum. For example: - -.. code-block:: python - - from carta.session import Session - from carta.constants import VersionMismatchAction - - SCRIPT_MINIMUM_CARTA_VERSION = "6.1.0" - - session = Session.interact( - "FRONTEND URL", - 123456, - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, - version_mismatch_action=VersionMismatchAction.WARN, - ) - -Omit ``minimum_carta_version`` temporarily to receive a deprecation warning; -it will be required in a future release. Version mismatches produce a warning -by default, including suggested actions. Use -``version_mismatch_action=VersionMismatchAction.ERROR`` to raise an exception -instead. - -The session examples below use the ``SCRIPT_MINIMUM_CARTA_VERSION`` variable -defined above. +When a script needs a specific ``carta-python`` version, pin that dependency +in the script's ``uv`` metadata rather than passing a CARTA version to the +session API. If session creation fails during startup validation, check that the frontend URL is reachable, the session ID is correct, the token is valid, and the @@ -81,7 +60,6 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess "FRONTEND URL", 123456, BackendToken("SECURITY TOKEN"), - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) If you have launched a backend directly, the frontend URL and security token must match your running backend process. You have the option of using an environment variable, ``CARTA_AUTH_TOKEN``, to run CARTA with a fixed security token. Otherwise, a randomly generated token will be printed by the backend when it starts. If you include the security token in the URL, you may omit the security token parameter (it will be parsed from the URL automatically): @@ -91,7 +69,6 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess session = Session.interact( "http://HOSTNAME:PORT?token=SECURITY_TOKEN", 123456, - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) The second parameter is the session ID, which must match the running frontend session: it's visible when you hover over the status indicator at the top right of the CARTA window in your browser. You can copy it by navigating to ``File > Server > Copy session ID to clipboard``, or find it in the backend executable output. @@ -122,7 +99,6 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess "FRONTEND URL", 123456, ControllerToken.from_file("path/to/token"), - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) The second parameter is the session ID, which must match the running frontend session: it's visible when you hover over the status indicator at the top right of the CARTA window in your browser. You can copy it by navigating to ``File > Server > Copy session ID to clipboard``. @@ -145,14 +121,11 @@ Creating a new interactive session from carta.session import Session # New session, start local backend - session = Session.start_and_interact( - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, - ) + session = Session.start_and_interact() # New session, start remote backend session = Session.start_and_interact( remote_host="REMOTE HOSTNAME OR IP", - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) .. tab:: Controller access @@ -165,7 +138,6 @@ Creating a new interactive session Chrome(headless=False), "FRONTEND URL", ControllerToken.from_file("path/to/token"), - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) You can also create a custom :obj:`carta.browser.Browser` object with any appropriate browser executable and driver and any commandline parameters. @@ -195,20 +167,17 @@ Use the ``create`` method if you want to write a non-interactive script which st Chrome(), "FRONTEND URL", BackendToken("SECURITY TOKEN"), - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) # New session, start local backend session = Session.start_and_create( Chrome(), - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) # New session, start remote backend session = Session.start_and_create( Chrome(), remote_host="REMOTE HOSTNAME OR IP", - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) .. tab:: Controller access @@ -239,7 +208,6 @@ Use the ``create`` method if you want to write a non-interactive script which st Chrome(), "FRONTEND URL", ControllerToken.from_file("path/to/token"), - minimum_carta_version=SCRIPT_MINIMUM_CARTA_VERSION, ) These commands are further customisable with optional parameters. See :doc:`the API reference ` for more information. diff --git a/tests/test_browser.py b/tests/test_browser.py index 9b405c5..5c82ec1 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -75,13 +75,12 @@ def test_new_session_from_url_runs_connection_check_after_parsing_session_id( session_class.assert_called_once_with(123, protocol, browser=browser, backend=None) session._validate_session.assert_called_once_with( timeout=5, - minimum_carta_version=None, - version_mismatch_action=VersionMismatchAction.WARN, + version_mismatch_action=VersionMismatchAction.ERROR, ) assert browser.driver.closed is False -def test_new_session_from_url_passes_minimum_and_mismatch_action( +def test_new_session_from_url_passes_mismatch_action( mocker, browser_module ): browser = make_browser(browser_module.Browser) @@ -92,13 +91,11 @@ def test_new_session_from_url_passes_minimum_and_mismatch_action( browser.new_session_from_url( "http://localhost:3000?token=x", - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) session._validate_session.assert_called_once_with( timeout=10, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -167,8 +164,7 @@ def test_new_session_with_backend_stops_backend_when_session_creation_fails( timeout=10, debug_no_auth=False, connection_check_timeout=10, - minimum_carta_version=None, - version_mismatch_action=VersionMismatchAction.WARN, + version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -211,7 +207,6 @@ def test_new_session_with_backend_passes_version_options( result = browser.new_session_with_backend( connection_check_timeout=4, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -223,7 +218,6 @@ def test_new_session_with_backend_passes_version_options( timeout=10, debug_no_auth=False, connection_check_timeout=4, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) diff --git a/tests/test_session.py b/tests/test_session.py index 29259f0..d8d4b4d 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -108,8 +108,7 @@ def test_direct_session_construction_does_not_validate_session(mocker): def test_validate_session_fetches_frontend_version_with_timeout(session, call_action): call_action.return_value = "6.0.0-dev" - with pytest.warns(DeprecationWarning, match="minimum_carta_version"): - assert session._validate_session(timeout=3) == "6.0.0-dev" + assert session._validate_session(timeout=3) == "6.0.0-dev" call_action.assert_called_once_with( "fetchParameter", @@ -120,69 +119,46 @@ def test_validate_session_fetches_frontend_version_with_timeout(session, call_ac assert session.carta_version == "6.0.0-dev" -def test_validate_session_applies_minimum_version(session, call_action): - call_action.return_value = "6.1.0" - - assert ( - session._validate_session( - timeout=3, - minimum_carta_version="6.1.0", - ) - == "6.1.0" - ) - - -def test_validate_session_warns_for_unsatisfied_minimum_by_default( +def test_validate_session_warns_for_unsupported_version_when_requested( session, call_action, caplog, mocker ): call_action.return_value = "6.0.0" mocker.patch("carta.session.MINIMUM_CARTA_VERSION", "6.1.0") - session._validate_session(timeout=3, minimum_carta_version="6.2.0") + session._validate_session( + timeout=3, version_mismatch_action=VersionMismatchAction.WARN + ) - assert "script minimum" in caplog.text + assert "wrapper minimum" in caplog.text assert "Suggested actions:" in caplog.text - assert ( - "Upgrade CARTA to at least '6.2.0' to satisfy both the carta-python " - "and script minimums." - ) in caplog.text - assert "Upgrade CARTA to at least '6.1.0'." not in caplog.text + assert "Upgrade CARTA to at least '6.1.0'." in caplog.text assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text def test_validate_session_raises_for_unsatisfied_minimum_in_error_mode( session, call_action ): - call_action.return_value = "6.1.0" + call_action.return_value = "5.9.0" - with pytest.raises(CartaUnsupportedVersion, match="script minimum"): + with pytest.raises(CartaUnsupportedVersion, match="wrapper minimum"): session._validate_session( timeout=3, - minimum_carta_version="6.2.0", version_mismatch_action=VersionMismatchAction.ERROR, ) -def test_validate_session_warns_for_newer_frontend_major_by_default( +def test_validate_session_warns_for_newer_frontend_major_when_requested( session, call_action, caplog ): call_action.return_value = "7.0.0" - session._validate_session(timeout=3, minimum_carta_version="6.0.0") + session._validate_session( + timeout=3, version_mismatch_action=VersionMismatchAction.WARN + ) assert "newer than the wrapper" in caplog.text assert "Upgrade carta-python to a version supporting CARTA major version 7." in caplog.text - assert "Update the script's `minimum_carta_version`" in caplog.text - - -def test_validate_session_suggests_upgrading_carta_for_old_wrapper_minimum( - session, call_action, caplog -): - call_action.return_value = "5.9.0" - - session._validate_session(timeout=3, minimum_carta_version="5.9.0") - - assert "Upgrade CARTA to at least '6.0.0'." in caplog.text + assert "script target major version" not in caplog.text def test_validate_session_raises_for_newer_frontend_major_in_error_mode( @@ -193,7 +169,6 @@ def test_validate_session_raises_for_newer_frontend_major_in_error_mode( with pytest.raises(CartaUnsupportedVersion) as error: session._validate_session( timeout=3, - minimum_carta_version="6.0.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -201,23 +176,9 @@ def test_validate_session_raises_for_newer_frontend_major_in_error_mode( assert "version_mismatch_action=VersionMismatchAction.WARN" in str(error.value) -def test_validate_session_validates_minimum_before_fetching_version(session, call_action): - with pytest.raises(CartaValidationFailed): - session._validate_session( - timeout=3, - minimum_carta_version=">=6.1.0", - ) - - call_action.assert_not_called() - - def test_validate_session_validates_action_before_fetching_version(session, call_action): with pytest.raises(CartaValidationFailed): - session._validate_session( - timeout=3, - minimum_carta_version="6.1.0", - version_mismatch_action="invalid", - ) + session._validate_session(timeout=3, version_mismatch_action="invalid") call_action.assert_not_called() @@ -229,9 +190,7 @@ def test_validate_session_reports_invalid_frontend_version_in_error_mode( with pytest.raises(CartaUnsupportedVersion, match="invalid CARTA version"): session._validate_session( - timeout=2, - minimum_carta_version="6.1.0", - version_mismatch_action=VersionMismatchAction.ERROR, + timeout=2, version_mismatch_action=VersionMismatchAction.ERROR ) @@ -240,10 +199,7 @@ def test_validate_session_wraps_frontend_version_failure(session, call_action, m call_action.side_effect = CartaActionFailed("frontendVersion unavailable") with pytest.raises(CartaBadSession) as e: - session._validate_session( - timeout=2, - minimum_carta_version="6.0.0", - ) + session._validate_session(timeout=2) message = str(e.value) assert "Could not validate CARTA session 0" in message @@ -266,8 +222,7 @@ def test_interact_checks_connection_by_default(mocker): assert session._protocol is protocol validate_session.assert_called_once_with( timeout=10, - minimum_carta_version=None, - version_mismatch_action=VersionMismatchAction.WARN, + version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -284,8 +239,7 @@ def test_interact_passes_connection_check_timeout(mocker): validate_session.assert_called_once_with( timeout=4, - minimum_carta_version=None, - version_mismatch_action=VersionMismatchAction.WARN, + version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -301,12 +255,11 @@ def test_interact_always_validates(mocker): validate_session.assert_called_once_with( timeout=10, - minimum_carta_version=None, - version_mismatch_action=VersionMismatchAction.WARN, + version_mismatch_action=VersionMismatchAction.ERROR, ) -def test_interact_passes_minimum_and_mismatch_action(mocker): +def test_interact_passes_mismatch_action(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) validate_session = mocker.patch.object(Session, "_validate_session") @@ -314,13 +267,11 @@ def test_interact_passes_minimum_and_mismatch_action(mocker): Session.interact( "http://localhost:3000?token=x", 123, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) validate_session.assert_called_once_with( timeout=10, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -377,7 +328,6 @@ def test_create_passes_connection_check_options_to_browser(mocker): timeout=7, debug_no_auth=True, connection_check_timeout=4, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -389,7 +339,6 @@ def test_create_passes_connection_check_options_to_browser(mocker): timeout=7, debug_no_auth=True, connection_check_timeout=4, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -408,7 +357,6 @@ def test_start_and_create_passes_connection_check_options_to_browser(mocker): token="token", frontend_url_timeout=8, connection_check_timeout=4, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) @@ -421,7 +369,6 @@ def test_start_and_create_passes_connection_check_options_to_browser(mocker): "token", 8, connection_check_timeout=4, - minimum_carta_version="6.1.0", version_mismatch_action=VersionMismatchAction.ERROR, ) # PATHS diff --git a/tests/test_version.py b/tests/test_version.py index 15646d6..6e07a51 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,7 +1,6 @@ import pytest -from carta.util import CartaValidationFailed -from carta.version import parse_carta_version, validate_carta_version +from carta.version import parse_carta_version, version_mismatch_details @pytest.mark.parametrize( @@ -31,36 +30,25 @@ def test_parse_carta_version_rejects_invalid_versions(version): assert parse_carta_version(version) is None -@pytest.mark.parametrize( - "version", - [ - "6.1.0", - "6.1.0-dev", - "6.1.0-beta.1", - "6.1.0-rc.1", - ], -) -def test_validate_carta_version_accepts_single_versions(version): - assert validate_carta_version(version) == (6, 1, 0) - +def test_version_mismatch_details_reports_old_frontend_version(): + assert version_mismatch_details("5.9.0", "6.0.0") == ( + ["frontend version '5.9.0' is older than the wrapper minimum '6.0.0'."], + ["Upgrade CARTA to at least '6.0.0'."], + ) -@pytest.mark.parametrize( - "version", - [ - "", - "6.1", - ">=6.1.0", - "6.1.0,<7.0.0", - "bad.version", - ], -) -def test_validate_carta_version_rejects_invalid_versions(version): - with pytest.raises(CartaValidationFailed): - validate_carta_version(version) +def test_version_mismatch_details_reports_newer_frontend_major(): + assert version_mismatch_details("7.0.0", "6.0.0") == ( + [ + "frontend major version 7 is newer than the wrapper's " + "supported major version 6." + ], + ["Upgrade carta-python to a version supporting CARTA major version 7."], + ) -def test_validate_carta_version_uses_parameter_name_in_error(): - with pytest.raises(CartaValidationFailed) as error: - validate_carta_version("bad.version", parameter_name="minimum_carta_version") - assert "minimum_carta_version" in str(error.value) +def test_version_mismatch_details_reports_invalid_frontend_version(): + assert version_mismatch_details("bad.version", "6.0.0") == ( + ["frontend reported invalid CARTA version 'bad.version'."], + ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], + ) From 7e11b378e95ee87d9c482169b784b22d075bb671 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 19 Aug 2026 12:21:16 +0800 Subject: [PATCH 106/123] Remove outdated frontendVersion error message from connection failure help text --- carta/session.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/carta/session.py b/carta/session.py index 83035fb..83192df 100644 --- a/carta/session.py +++ b/carta/session.py @@ -427,9 +427,7 @@ def _connection_check_error_message(self, error, timeout): "- CARTA backend/frontend is not running or not reachable.\n" "- The session ID is wrong or the session has closed.\n" "- The backend was not started with --enable_scripting.\n" - "- The token is missing, invalid, or expired.\n" - "- The CARTA frontend does not expose frontendVersion, which " - "can happen with unsupported versions such as 6.0.0-beta.1." + "- The token is missing, invalid, or expired." ) def call_action(self, path, *args, **kwargs): From b645f39ffa637581e01c9d577c9eab1640f63bce Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 19 Aug 2026 12:26:05 +0800 Subject: [PATCH 107/123] Removing unnecessary line breaks --- docs/source/quickstart.rst | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 9c74719..c5d1b06 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -66,10 +66,7 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess .. code-block:: python - session = Session.interact( - "http://HOSTNAME:PORT?token=SECURITY_TOKEN", - 123456, - ) + session = Session.interact("http://HOSTNAME:PORT?token=SECURITY_TOKEN", 123456) The second parameter is the session ID, which must match the running frontend session: it's visible when you hover over the status indicator at the top right of the CARTA window in your browser. You can copy it by navigating to ``File > Server > Copy session ID to clipboard``, or find it in the backend executable output. @@ -124,9 +121,7 @@ Creating a new interactive session session = Session.start_and_interact() # New session, start remote backend - session = Session.start_and_interact( - remote_host="REMOTE HOSTNAME OR IP", - ) + session = Session.start_and_interact(remote_host="REMOTE HOSTNAME OR IP") .. tab:: Controller access @@ -170,15 +165,10 @@ Use the ``create`` method if you want to write a non-interactive script which st ) # New session, start local backend - session = Session.start_and_create( - Chrome(), - ) + session = Session.start_and_create(Chrome()) # New session, start remote backend - session = Session.start_and_create( - Chrome(), - remote_host="REMOTE HOSTNAME OR IP", - ) + session = Session.start_and_create(Chrome(), remote_host="REMOTE HOSTNAME OR IP") .. tab:: Controller access From f99c537a577cee1a8a6d5f3114664054c516656f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 19 Aug 2026 12:59:31 +0800 Subject: [PATCH 108/123] Remove frontendVersion from connection failure test assertion --- tests/test_session.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_session.py b/tests/test_session.py index d8d4b4d..71aa4f2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -208,7 +208,6 @@ def test_validate_session_wraps_frontend_version_failure(session, call_action, m assert "CartaActionFailed" in message assert "frontendVersion unavailable" in message assert "--enable_scripting" in message - assert "6.0.0-beta.1" in message def test_interact_checks_connection_by_default(mocker): From a10d17b3bd081878abe71e90680d07f631fc0b1f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 19 Aug 2026 15:39:53 +0800 Subject: [PATCH 109/123] Remove unnecessary line break --- docs/source/quickstart.rst | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index c5d1b06..93cdcb8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -56,11 +56,7 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess from carta.session import Session from carta.token import BackendToken - session = Session.interact( - "FRONTEND URL", - 123456, - BackendToken("SECURITY TOKEN"), - ) + session = Session.interact("FRONTEND URL", 123456, BackendToken("SECURITY TOKEN")) If you have launched a backend directly, the frontend URL and security token must match your running backend process. You have the option of using an environment variable, ``CARTA_AUTH_TOKEN``, to run CARTA with a fixed security token. Otherwise, a randomly generated token will be printed by the backend when it starts. If you include the security token in the URL, you may omit the security token parameter (it will be parsed from the URL automatically): @@ -92,11 +88,7 @@ Use the ``interact`` method if you want to use scripting to control a CARTA sess from carta.session import Session from carta.token import ControllerToken - session = Session.interact( - "FRONTEND URL", - 123456, - ControllerToken.from_file("path/to/token"), - ) + session = Session.interact("FRONTEND URL", 123456, ControllerToken.from_file("path/to/token")) The second parameter is the session ID, which must match the running frontend session: it's visible when you hover over the status indicator at the top right of the CARTA window in your browser. You can copy it by navigating to ``File > Server > Copy session ID to clipboard``. @@ -129,11 +121,7 @@ Creating a new interactive session .. code-block:: python - session = Session.create( - Chrome(headless=False), - "FRONTEND URL", - ControllerToken.from_file("path/to/token"), - ) + session = Session.create(Chrome(headless=False), "FRONTEND URL", ControllerToken.from_file("path/to/token")) You can also create a custom :obj:`carta.browser.Browser` object with any appropriate browser executable and driver and any commandline parameters. @@ -158,11 +146,7 @@ Use the ``create`` method if you want to write a non-interactive script which st from carta.browser import Chrome # New session, connect to an existing backend - session = Session.create( - Chrome(), - "FRONTEND URL", - BackendToken("SECURITY TOKEN"), - ) + session = Session.create(Chrome(), "FRONTEND URL", BackendToken("SECURITY TOKEN")) # New session, start local backend session = Session.start_and_create(Chrome()) @@ -194,11 +178,7 @@ Use the ``create`` method if you want to write a non-interactive script which st from carta.token import ControllerToken # New session, connect to an existing controller - session = Session.create( - Chrome(), - "FRONTEND URL", - ControllerToken.from_file("path/to/token"), - ) + session = Session.create(Chrome(), "FRONTEND URL", ControllerToken.from_file("path/to/token")) These commands are further customisable with optional parameters. See :doc:`the API reference ` for more information. From a4f428ad3a20e2d084834031852d7db632cf71dd Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 15:32:41 +0800 Subject: [PATCH 110/123] Refactor version compatibility checking to use data-driven approach with compatibility ranges --- VERSION.txt | 2 +- carta/compatibility.py | 18 ++++ carta/constants.py | 3 - carta/protocol.py | 3 +- carta/session.py | 26 ++++-- carta/version.py | 201 +++++++++++++++++++++++++++++++---------- tests/test_session.py | 95 +++++++++++++------ tests/test_version.py | 170 ++++++++++++++++++++++++++++++++-- 8 files changed, 419 insertions(+), 99 deletions(-) create mode 100644 carta/compatibility.py diff --git a/VERSION.txt b/VERSION.txt index 26aaba0..227cea2 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.0 +2.0.0 diff --git a/carta/compatibility.py b/carta/compatibility.py new file mode 100644 index 0000000..0e5591c --- /dev/null +++ b/carta/compatibility.py @@ -0,0 +1,18 @@ +"""Compatibility data for CARTA and carta-python releases.""" + + +COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.0"}, +) +"""The recommended compatibility between CARTA and carta-python versions. + +Each entry contains the oldest and newest CARTA series covered by the range and +the latest compatible carta-python series. ``carta_max`` is ``None`` for all +remaining series in the same CARTA major version. + +Ranges must be listed from oldest to newest, must not overlap, and must not span +CARTA major versions. The ``wrapper`` value in the final entry must match the +major and minor version in ``VERSION.txt``. Its ``carta_min`` value defines the +minimum CARTA version for which the current carta-python release provides +complete functionality. +""" diff --git a/carta/constants.py b/carta/constants.py index 8734fbe..7f97146 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -43,9 +43,6 @@ class VersionMismatchAction(StrEnum): ERROR = "error" -MINIMUM_CARTA_VERSION = "6.0.0" - - Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) Scaling.__doc__ = """Colormap scaling types.""" diff --git a/carta/protocol.py b/carta/protocol.py index b35ccaf..b851757 100644 --- a/carta/protocol.py +++ b/carta/protocol.py @@ -293,7 +293,8 @@ def request_scripting_action(self, session_id, path, *args, **kwargs): request_data = json.dumps(request_kwargs, cls=CartaEncoder) - carta_action_description = f"CARTA scripting action {path}.{action} called with parameters {args}" + action_path = f"{path}.{action}" if path else action + carta_action_description = f"CARTA scripting action {action_path} called with parameters {args}" headers = { 'Content-Type': 'application/json', diff --git a/carta/session.py b/carta/session.py index 83192df..613abab 100644 --- a/carta/session.py +++ b/carta/session.py @@ -19,7 +19,6 @@ ImageType, Polarization, ColormapSet, - MINIMUM_CARTA_VERSION, VersionMismatchAction, ) from .backend import Backend @@ -42,6 +41,7 @@ ) from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .version import ( + action_failure_compatibility_suggestions, version_mismatch_details, ) @@ -394,10 +394,7 @@ def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchA ) from e self._cache_carta_version(version) - mismatches, suggestions = version_mismatch_details( - version, - MINIMUM_CARTA_VERSION, - ) + mismatches, suggestions = version_mismatch_details(version) if mismatches: message = "CARTA version validation failed:\n" + "\n".join( f"- {mismatch}" for mismatch in mismatches @@ -460,7 +457,24 @@ def call_action(self, path, *args, **kwargs): CartaBadResponse If a request which was expected to have a JSON response did not have one, or if a JSON response could not be decoded. """ - return self._protocol.request_scripting_action(self.session_id, path, *args, **kwargs) + try: + return self._protocol.request_scripting_action( + self.session_id, + path, + *args, + **kwargs, + ) + except CartaActionFailed as error: + version = getattr(self, "_cache", {}).get("carta_version") + suggestions = action_failure_compatibility_suggestions(version) + if not suggestions: + raise + + message = f"{error}\nCompatibility suggestions:\n" + "\n".join( + f"- {suggestion}" for suggestion in suggestions + ) + error.args = (message,) + raise def get_value(self, path, return_path=None): """Get the value of an attribute from a frontend store. diff --git a/carta/version.py b/carta/version.py index c0f6a3d..8147d3b 100644 --- a/carta/version.py +++ b/carta/version.py @@ -1,73 +1,178 @@ """Helpers for validating CARTA version compatibility.""" import re +from dataclasses import dataclass, field -from .util import CartaValidationFailed +from .compatibility import COMPATIBILITY_DATA _VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-[A-Za-z0-9.]+)?") +_SERIES_PATTERN = re.compile(r"(\d+)\.(\d+)") +Version = tuple[int, int, int] +VersionSeries = tuple[int, int] -def parse_carta_version(version): - """Parse a CARTA version string into a ``(major, minor, patch)`` tuple. - Any prerelease suffix (e.g. ``"-dev"``, ``"-beta.1"``) is ignored. - - Parameters - ---------- - version : string - A version string of the form ``"MAJOR.MINOR.PATCH"`` with an optional - ``"-SUFFIX"`` (e.g. ``"6.0.0"`` or ``"6.0.0-dev"``). - - Returns - ------- - tuple or None - ``(major, minor, patch)`` or ``None`` if the version string cannot be - parsed. - """ +def parse_carta_version(version: object) -> Version | None: + """Parse a CARTA version, ignoring any prerelease suffix.""" match = _VERSION_PATTERN.fullmatch(str(version)) - if not match: + if match is None: return None major, minor, patch = match.groups() - return (int(major), int(minor), int(patch)) + return int(major), int(minor), int(patch) + + +def parse_version_series(series: object) -> VersionSeries | None: + """Parse a ``MAJOR.MINOR`` version series.""" + match = _SERIES_PATTERN.fullmatch(str(series)) + if match is None: + return None + major, minor = match.groups() + return int(major), int(minor) -def _parse_required_version(version): - parsed = parse_carta_version(version) + +def _require_series(name: str, value: str) -> VersionSeries: + parsed = parse_version_series(value) if parsed is None: - raise CartaValidationFailed( - "MINIMUM_CARTA_VERSION must be a version in " - "MAJOR.MINOR.PATCH format." - ) + raise ValueError(f"{name} must be in MAJOR.MINOR format, got {value!r}") return parsed -def version_mismatch_details(version, minimum_version): - """Return compatibility problems and suggested actions for a CARTA version.""" - version_base = parse_carta_version(version) - if version_base is None: +@dataclass(frozen=True) +class CompatibilityRange: + """CARTA version range and its recommended carta-python series. + + Bounds use ``MAJOR.MINOR`` granularity and are inclusive. An omitted upper + bound covers the rest of the lower bound's CARTA major version. + """ + + carta_min: str + carta_max: str | None + wrapper: str + _minimum: VersionSeries = field(init=False, repr=False, compare=False) + _maximum: VersionSeries | None = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + """Validate and cache the numeric version series.""" + minimum = _require_series("carta_min", self.carta_min) + maximum = ( + None + if self.carta_max is None + else _require_series("carta_max", self.carta_max) + ) + _require_series("wrapper", self.wrapper) + + if maximum is not None: + if maximum[0] != minimum[0]: + raise ValueError( + "a compatibility range cannot span CARTA major versions" + ) + if maximum < minimum: + raise ValueError("carta_max must not be earlier than carta_min") + + object.__setattr__(self, "_minimum", minimum) + object.__setattr__(self, "_maximum", maximum) + + def covers_carta(self, version: object) -> bool: + """Return whether this range covers a CARTA version.""" + parsed_version = parse_carta_version(version) + if parsed_version is None: + return False + + series = parsed_version[:2] + if series < self._minimum: + return False + if self._maximum is None: + return series[0] == self._minimum[0] + return series <= self._maximum + + @property + def carta_label(self) -> str: + """Return a human-readable CARTA version range.""" + maximum = self.carta_max or f"{self._minimum[0]}.x" + return f"{self.carta_min} - {maximum}" + + @property + def wrapper_label(self) -> str: + """Return the recommended carta-python series label.""" + return f"{self.wrapper}.x" + + @property + def carta_minimum_version(self) -> str: + """Return the oldest full CARTA version covered by this range.""" + return f"{self.carta_min}.0" + + +COMPATIBILITY = tuple(CompatibilityRange(**entry) for entry in COMPATIBILITY_DATA) +"""Recommended CARTA and carta-python version combinations.""" + + +def latest_compatibility() -> CompatibilityRange: + """Return the newest range in the compatibility table.""" + return COMPATIBILITY[-1] + + +def compatibility_for_carta(version: object) -> CompatibilityRange | None: + """Return the compatibility range covering a CARTA version.""" + for compatibility in COMPATIBILITY: + if compatibility.covers_carta(version): + return compatibility + return None + + +def _older_carta_suggestions( + version: object, current: CompatibilityRange +) -> list[str]: + suggestions = [ + f"Upgrade CARTA to at least {current.carta_minimum_version!r}." + ] + + recommended = compatibility_for_carta(version) + if recommended is not None: + suggestions.append( + f"Alternatively, use carta-python {recommended.wrapper_label}, " + f"the recommended series for CARTA {recommended.carta_label}." + ) + + return suggestions + + +def version_mismatch_details(version: object) -> tuple[list[str], list[str]]: + """Return connection problems and suggestions for a CARTA version.""" + parsed_version = parse_carta_version(version) + if parsed_version is None: return ( [f"frontend reported invalid CARTA version {version!r}."], ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], ) - minimum_base = _parse_required_version(minimum_version) - mismatches = [] - suggestions = [] - if version_base < minimum_base: - mismatches.append( - f"frontend version {version!r} is older than the wrapper minimum " - f"{minimum_version!r}." - ) - suggestions.append(f"Upgrade CARTA to at least {minimum_version!r}.") - elif version_base[0] > minimum_base[0]: - mismatches.append( - f"frontend major version {version_base[0]} is newer than " - f"the wrapper's supported major version {minimum_base[0]}." - ) - suggestions.append( - "Upgrade carta-python to a version supporting " - f"CARTA major version {version_base[0]}.") - - return mismatches, suggestions + current = latest_compatibility() + if parsed_version[:2] >= current._minimum: + return [], [] + + mismatches = [ + f"CARTA version {version!r} is older than the minimum " + f"{current.carta_minimum_version!r} required for complete " + f"functionality with carta-python {current.wrapper_label}." + ] + return mismatches, _older_carta_suggestions(version, current) + + +def action_failure_compatibility_suggestions(version: object) -> list[str]: + """Return compatibility suggestions after a frontend action failure.""" + parsed_version = parse_carta_version(version) + if parsed_version is None: + return [] + + current = latest_compatibility() + if parsed_version[:2] < current._minimum: + return _older_carta_suggestions(version, current) + + return [ + "For direct scripting calls, verify that the frontend action, attribute, " + "or response path exists.", + "If this failure started after a CARTA upgrade, upgrade carta-python to " + "the latest available release and retry.", + ] diff --git a/tests/test_session.py b/tests/test_session.py index 71aa4f2..a569e21 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -10,6 +10,7 @@ CartaActionFailed, CartaBadResponse, CartaBadSession, + CartaRequestFailed, CartaUnsupportedVersion, CartaValidationFailed, Macro, @@ -106,9 +107,9 @@ def test_direct_session_construction_does_not_validate_session(mocker): def test_validate_session_fetches_frontend_version_with_timeout(session, call_action): - call_action.return_value = "6.0.0-dev" + call_action.return_value = "6.1.0-dev" - assert session._validate_session(timeout=3) == "6.0.0-dev" + assert session._validate_session(timeout=3) == "6.1.0-dev" call_action.assert_called_once_with( "fetchParameter", @@ -116,20 +117,20 @@ def test_validate_session_fetches_frontend_version_with_timeout(session, call_ac response_expected=True, timeout=3, ) - assert session.carta_version == "6.0.0-dev" + assert session.carta_version == "6.1.0-dev" def test_validate_session_warns_for_unsupported_version_when_requested( - session, call_action, caplog, mocker + session, call_action, caplog ): call_action.return_value = "6.0.0" - mocker.patch("carta.session.MINIMUM_CARTA_VERSION", "6.1.0") session._validate_session( timeout=3, version_mismatch_action=VersionMismatchAction.WARN ) - assert "wrapper minimum" in caplog.text + assert "older than the minimum" in caplog.text + assert "complete functionality with carta-python 2.0.x" in caplog.text assert "Suggested actions:" in caplog.text assert "Upgrade CARTA to at least '6.1.0'." in caplog.text assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text @@ -140,40 +141,28 @@ def test_validate_session_raises_for_unsatisfied_minimum_in_error_mode( ): call_action.return_value = "5.9.0" - with pytest.raises(CartaUnsupportedVersion, match="wrapper minimum"): + with pytest.raises(CartaUnsupportedVersion, match="older than the minimum"): session._validate_session( timeout=3, version_mismatch_action=VersionMismatchAction.ERROR, ) -def test_validate_session_warns_for_newer_frontend_major_when_requested( - session, call_action, caplog -): - call_action.return_value = "7.0.0" - - session._validate_session( - timeout=3, version_mismatch_action=VersionMismatchAction.WARN - ) - - assert "newer than the wrapper" in caplog.text - assert "Upgrade carta-python to a version supporting CARTA major version 7." in caplog.text - assert "script target major version" not in caplog.text - - -def test_validate_session_raises_for_newer_frontend_major_in_error_mode( - session, call_action +@pytest.mark.parametrize( + "version_mismatch_action", + [VersionMismatchAction.WARN, VersionMismatchAction.ERROR], +) +def test_validate_session_accepts_newer_frontend_major( + session, call_action, caplog, version_mismatch_action ): call_action.return_value = "7.0.0" - with pytest.raises(CartaUnsupportedVersion) as error: - session._validate_session( - timeout=3, - version_mismatch_action=VersionMismatchAction.ERROR, - ) + assert session._validate_session( + timeout=3, + version_mismatch_action=version_mismatch_action, + ) == "7.0.0" - assert "newer than the wrapper" in str(error.value) - assert "version_mismatch_action=VersionMismatchAction.WARN" in str(error.value) + assert not caplog.text def test_validate_session_validates_action_before_fetching_version(session, call_action): @@ -210,6 +199,52 @@ def test_validate_session_wraps_frontend_version_failure(session, call_action, m assert "--enable_scripting" in message +def test_call_action_adds_compatibility_suggestion_to_frontend_failure( + session, mocker +): + original_error = CartaActionFailed("newAction is unavailable") + session._cache = {"carta_version": "7.0.0"} + session._protocol = mocker.Mock() + session._protocol.request_scripting_action.side_effect = original_error + + with pytest.raises(CartaActionFailed) as error: + session.call_action("newAction") + + message = str(error.value) + assert error.value is original_error + assert "newAction is unavailable" in message + assert "Compatibility suggestions:" in message + assert "verify that the frontend action, attribute, or response path exists" in message + assert "upgrade carta-python to the latest available release" in message + + +def test_call_action_preserves_frontend_failure_without_cached_version( + session, mocker +): + original_error = CartaActionFailed("newAction is unavailable") + session._protocol = mocker.Mock() + session._protocol.request_scripting_action.side_effect = original_error + + with pytest.raises(CartaActionFailed) as error: + session.call_action("newAction") + + assert error.value is original_error + + +def test_call_action_does_not_add_compatibility_suggestion_to_request_failure( + session, mocker +): + original_error = CartaRequestFailed("session is unavailable") + session._cache = {"carta_version": "7.0.0"} + session._protocol = mocker.Mock() + session._protocol.request_scripting_action.side_effect = original_error + + with pytest.raises(CartaRequestFailed) as error: + session.call_action("newAction") + + assert error.value is original_error + + def test_interact_checks_connection_by_default(mocker): protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch("carta.session.Protocol", return_value=protocol) diff --git a/tests/test_version.py b/tests/test_version.py index 6e07a51..50901d2 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,6 +1,19 @@ +import pathlib + import pytest -from carta.version import parse_carta_version, version_mismatch_details +from carta.version import ( + COMPATIBILITY, + CompatibilityRange, + action_failure_compatibility_suggestions, + compatibility_for_carta, + latest_compatibility, + parse_carta_version, + parse_version_series, + version_mismatch_details, +) + +VERSION_FILE = pathlib.Path(__file__).parent.parent / "VERSION.txt" @pytest.mark.parametrize( @@ -31,24 +44,161 @@ def test_parse_carta_version_rejects_invalid_versions(version): def test_version_mismatch_details_reports_old_frontend_version(): - assert version_mismatch_details("5.9.0", "6.0.0") == ( - ["frontend version '5.9.0' is older than the wrapper minimum '6.0.0'."], - ["Upgrade CARTA to at least '6.0.0'."], + assert version_mismatch_details("5.9.0") == ( + [ + "CARTA version '5.9.0' is older than the minimum '6.1.0' required " + "for complete functionality with carta-python 2.0.x." + ], + ["Upgrade CARTA to at least '6.1.0'."], ) -def test_version_mismatch_details_reports_newer_frontend_major(): - assert version_mismatch_details("7.0.0", "6.0.0") == ( +def test_version_mismatch_details_uses_table_for_downgrade_suggestion(mocker): + table = ( + CompatibilityRange(carta_min="5.0", carta_max="5.9", wrapper="1.0"), + CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), + ) + mocker.patch("carta.version.COMPATIBILITY", table) + + assert version_mismatch_details("5.9.0") == ( [ - "frontend major version 7 is newer than the wrapper's " - "supported major version 6." + "CARTA version '5.9.0' is older than the minimum '6.1.0' required " + "for complete functionality with carta-python 2.0.x." + ], + [ + "Upgrade CARTA to at least '6.1.0'.", + "Alternatively, use carta-python 1.0.x, the recommended series " + "for CARTA 5.0 - 5.9.", ], - ["Upgrade carta-python to a version supporting CARTA major version 7."], ) +@pytest.mark.parametrize("version", ["6.1.0", "6.9.9", "7.0.0"]) +def test_version_mismatch_details_accepts_current_and_newer_carta(version): + assert version_mismatch_details(version) == ([], []) + + def test_version_mismatch_details_reports_invalid_frontend_version(): - assert version_mismatch_details("bad.version", "6.0.0") == ( + assert version_mismatch_details("bad.version") == ( ["frontend reported invalid CARTA version 'bad.version'."], ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], ) + + +def test_action_failure_suggests_latest_carta_python_for_supported_carta(): + assert action_failure_compatibility_suggestions("6.1.0") == [ + "For direct scripting calls, verify that the frontend action, attribute, " + "or response path exists.", + "If this failure started after a CARTA upgrade, upgrade carta-python to " + "the latest available release and retry.", + ] + + +def test_action_failure_suggests_latest_carta_python_for_newer_carta(): + assert action_failure_compatibility_suggestions("7.0.0") == [ + "For direct scripting calls, verify that the frontend action, attribute, " + "or response path exists.", + "If this failure started after a CARTA upgrade, upgrade carta-python to " + "the latest available release and retry.", + ] + + +def test_action_failure_uses_table_for_older_carta(mocker): + table = ( + CompatibilityRange(carta_min="5.0", carta_max="5.9", wrapper="1.0"), + CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), + ) + mocker.patch("carta.version.COMPATIBILITY", table) + + assert action_failure_compatibility_suggestions("5.9.0") == [ + "Upgrade CARTA to at least '6.1.0'.", + "Alternatively, use carta-python 1.0.x, the recommended series for " + "CARTA 5.0 - 5.9.", + ] + + +def test_action_failure_ignores_unknown_carta_version(): + assert action_failure_compatibility_suggestions("bad.version") == [] + + +def assert_table_is_ordered(table): + for previous, current in zip(table, table[1:]): + previous_min = parse_version_series(previous.carta_min) + current_min = parse_version_series(current.carta_min) + if previous.carta_max is None: + assert current_min[0] > previous_min[0] + else: + assert parse_version_series(previous.carta_max) < current_min + assert parse_version_series(previous.wrapper) < parse_version_series(current.wrapper) + + +def test_compatibility_table_is_ordered_and_does_not_overlap(): + assert_table_is_ordered(COMPATIBILITY) + + +def test_open_ended_range_may_precede_a_new_carta_major(): + assert_table_is_ordered(( + CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), + CompatibilityRange(carta_min="7.0", carta_max=None, wrapper="3.0"), + )) + + +def test_open_ended_range_is_bounded_by_its_carta_major(): + entry = CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0") + + assert entry.covers_carta("6.9.0") + assert not entry.covers_carta("7.0.0") + + +def test_compatibility_table_bounds_are_valid_series(): + for entry in COMPATIBILITY: + for bound in (entry.carta_min, entry.wrapper): + assert parse_version_series(bound) is not None + assert entry.carta_max is None or parse_version_series(entry.carta_max) is not None + + +def test_compatibility_ranges_do_not_span_carta_major_versions(): + for entry in COMPATIBILITY: + if entry.carta_max is not None: + assert parse_version_series(entry.carta_min)[0] == parse_version_series(entry.carta_max)[0] + + +def test_package_version_matches_latest_compatibility(): + package_version = parse_carta_version(VERSION_FILE.read_text().strip()) + recommended_series = parse_version_series(latest_compatibility().wrapper) + + assert package_version[:2] == recommended_series + + +@pytest.mark.parametrize("version", ["6.1.0", "6.1.0-dev", "6.9.9"]) +def test_compatibility_for_carta_finds_supported_versions(version): + assert compatibility_for_carta(version) is latest_compatibility() + + +@pytest.mark.parametrize("version", ["6.0.0", "5.9.0", "7.0.0", "bad.version"]) +def test_compatibility_for_carta_rejects_unsupported_versions(version): + assert compatibility_for_carta(version) is None + + +def test_compatibility_labels(): + entry = latest_compatibility() + + assert entry.carta_label == "6.1 - 6.x" + assert entry.wrapper_label == "2.0.x" + + +def test_compatibility_label_with_explicit_maximum(): + entry = CompatibilityRange(carta_min="6.0", carta_max="6.2", wrapper="1.2") + + assert entry.carta_label == "6.0 - 6.2" + assert entry.wrapper_label == "1.2.x" + assert entry.covers_carta("6.2.5") + assert not entry.covers_carta("6.3.0") + + +@pytest.mark.parametrize( + "series", + ["", "6", "6.1.0", "6.x", "bad.series"], +) +def test_parse_version_series_rejects_invalid_series(series): + assert parse_version_series(series) is None From 393dc17c9608bc80385b5648e4eb54dd87be0e94 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 16:18:30 +0800 Subject: [PATCH 111/123] Add pull request template with comprehensive checklist for development workflow --- .github/pull_request_template.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ce861dc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +**Description** + +Linked issues and companion PRs (if any), what is implemented or fixed, and +how to test it. + +**Checklist** + +For linked issues (if any): + +- [ ] assignee and labels added +- [ ] GitHub Project estimate added + +For the pull request: + +- [ ] reviewers and assignee added +- [ ] GitHub Project estimate added +- [ ] tests added or updated / no tests needed +- [ ] documentation updated / no documentation update needed + +For dependencies and compatibility: + +- [ ] companion frontend or backend PRs linked / no companion PRs needed +- [ ] `pyproject.toml` and `uv.lock` updated / no dependency changes +- [ ] CARTA compatibility table updated when the minimum CARTA series changes + or `dev` starts a new `carta-python` major/minor series / no compatibility + change + +For pull requests targeting `main`: + +- [ ] `VERSION.txt` finalized for the release +- [ ] final compatibility entry matches the release major/minor version and + minimum supported CARTA series From e3306eb8bba2a483f16618f88678e80b38c152e8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 16:18:44 +0800 Subject: [PATCH 112/123] Add development documentation covering version compatibility workflow and release cycle management --- docs/source/development.rst | 124 ++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + 2 files changed, 125 insertions(+) create mode 100644 docs/source/development.rst diff --git a/docs/source/development.rst b/docs/source/development.rst new file mode 100644 index 0000000..3b25e60 --- /dev/null +++ b/docs/source/development.rst @@ -0,0 +1,124 @@ +Development +=========== + +Set up a development environment with ``uv``: + +.. code-block:: shell + + uv sync --all-extras + +Run the test suite and documentation build before submitting changes: + +.. code-block:: shell + + uv run pytest + uv run sphinx-build -W -b html docs/source docs/build/html + +Version compatibility data +-------------------------- + +CARTA and ``carta-python`` compatibility is defined by +``COMPATIBILITY_DATA`` in ``carta/compatibility.py``. Each entry maps an +inclusive range of CARTA ``MAJOR.MINOR`` series to the latest recommended +``carta-python`` series. + +The final entry determines the requirements for the version under development: + +* ``carta_min`` defines the minimum CARTA series that provides complete + functionality. +* ``wrapper`` must match the major and minor components in ``VERSION.txt``. +* ``carta_max=None`` covers the remaining minor releases in the same CARTA + major version. + +Keep all ranges ordered from oldest to newest, non-overlapping, and within a +single CARTA major version. + +Starting a new development cycle +-------------------------------- + +After a release, prepare ``dev`` for the next release before merging feature +work: + +#. Choose the next version and add a ``-dev`` suffix in ``VERSION.txt``. For + example, start 2.1 development with ``2.1.0-dev``. +#. If the major/minor series changes, update ``wrapper`` in the final + compatibility entry to match. If the CARTA minimum is unchanged, update the + existing entry rather than adding an overlapping range. +#. For a patch cycle, such as ``2.0.0`` to ``2.0.1-dev``, leave ``wrapper`` as + ``"2.0"`` because the compatibility table tracks major/minor series. +#. Run the focused version tests. + +For example, starting 2.1 development while retaining CARTA 6.1 as the minimum +uses: + +.. code-block:: text + + 2.1.0-dev + +in ``VERSION.txt`` and: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, + ) + +If the next release is already known to require a newer CARTA series, apply the +range update described below in the same cycle-preparation pull request. + +Pull requests during a development cycle +---------------------------------------- + +Ordinary feature and fix pull requests target ``dev``. They should not change +``VERSION.txt`` or the compatibility table unless they alter CARTA version +requirements. + +When a pull request introduces a dependency on a newer CARTA series, update the +compatibility table, its tests, and its documentation in that same pull +request. Do not defer the compatibility change to the release pull request. +Increasing the minimum requires a new ``carta-python`` major/minor series; if +the current cycle is a patch cycle, start an appropriate major/minor cycle +first. + +Close the previous range and append a range for the new minimum. For example, +if the version under development is 2.1 and a pull request raises the minimum +CARTA series from 6.1 to 6.2, change the table from: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, + ) + +to: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": "6.1", "wrapper": "2.0"}, + {"carta_min": "6.2", "carta_max": None, "wrapper": "2.1"}, + ) + +This preserves the recommendation for older CARTA releases while making 6.2 +the minimum for the version under development. If the new minimum starts a new +CARTA major version, append the new range without closing the previous +open-ended range: an open-ended range stops automatically at the end of its +CARTA major version. + +Finishing a development cycle +----------------------------- + +The release pull request from ``dev`` to ``main`` finalizes ``VERSION.txt`` by +removing the ``-dev`` suffix, for example from ``2.1.0-dev`` to ``2.1.0``. It +must verify that the final compatibility entry already matches the release +major/minor version and minimum CARTA series. It should not be the first place +where a compatibility change is recorded. + +Run the full test suite and documentation build before merging the release. If +the compatibility table changed during the cycle, also run its focused tests: + +.. code-block:: shell + + uv run pytest + uv run pytest tests/test_version.py + uv run sphinx-build -W -b html docs/source docs/build/html diff --git a/docs/source/index.rst b/docs/source/index.rst index 6d4c9fa..8183f17 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,4 +7,5 @@ carta-python: a scripting wrapper for CARTA introduction quickstart + development carta From 54f61b406478092e0cef6218105e088abb9a5e0a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 16:57:05 +0800 Subject: [PATCH 113/123] Add extra line break before compatibility suggestions in error messages --- carta/session.py | 4 ++-- tests/test_session.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/carta/session.py b/carta/session.py index 613abab..7462966 100644 --- a/carta/session.py +++ b/carta/session.py @@ -405,7 +405,7 @@ def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchA "`version_mismatch_action=VersionMismatchAction.WARN`." ) if suggestions: - message += "\nSuggested actions:\n" + "\n".join( + message += "\n\nSuggested actions:\n" + "\n".join( f"- {suggestion}" for suggestion in suggestions ) if version_mismatch_action is VersionMismatchAction.ERROR: @@ -470,7 +470,7 @@ def call_action(self, path, *args, **kwargs): if not suggestions: raise - message = f"{error}\nCompatibility suggestions:\n" + "\n".join( + message = f"{error}\n\nCompatibility suggestions:\n" + "\n".join( f"- {suggestion}" for suggestion in suggestions ) error.args = (message,) diff --git a/tests/test_session.py b/tests/test_session.py index a569e21..07a900b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -131,7 +131,7 @@ def test_validate_session_warns_for_unsupported_version_when_requested( assert "older than the minimum" in caplog.text assert "complete functionality with carta-python 2.0.x" in caplog.text - assert "Suggested actions:" in caplog.text + assert "\n\nSuggested actions:\n" in caplog.text assert "Upgrade CARTA to at least '6.1.0'." in caplog.text assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text @@ -213,7 +213,7 @@ def test_call_action_adds_compatibility_suggestion_to_frontend_failure( message = str(error.value) assert error.value is original_error assert "newAction is unavailable" in message - assert "Compatibility suggestions:" in message + assert "\n\nCompatibility suggestions:\n" in message assert "verify that the frontend action, attribute, or response path exists" in message assert "upgrade carta-python to the latest available release" in message From 8fa5c9d505470d09b4d1d21a010319d3aaace4c1 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 16:58:49 +0800 Subject: [PATCH 114/123] Update package name to carta-python and revise installation instructions for PyPI distribution --- README.md | 12 ++++------- docs/source/quickstart.rst | 42 +++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- uv.lock | 4 ++-- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e20036c..5a5dd7a 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,13 @@ carta-python This is a prototype of a scripting interface which uses a generic HTTP interface in the CARTA backend as a proxy to call actions on the CARTA frontend. -This package is not yet published on PyPi, but can be installed from the local repository directory with `pip`. Requires Python 3.10 or later. Ensure that you're using the corresponding `pip`, either using a `virtualenv` or the appropriate system executable, which may be called `pip3`. Required dependencies should be installed automatically: +Install the latest release from PyPI. Requires Python 3.10 or later. Required dependencies are installed automatically: - pip install . + pip install --upgrade carta-python -To create a new frontend session which is controlled by the wrapper instead of connecting to an existing frontend session, you also need to install the `selenium` Python library: +To create a new frontend session which is controlled by the wrapper instead of connecting to an existing frontend session, install the optional browser dependencies: - pip install selenium - -If you prefer installing the optional browser dependencies via package extras, the equivalent command is: - - pip install ".[browser]" + pip install --upgrade "carta-python[browser]" You also need to make sure that your desired browser is installed, together with a corresponding web driver. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 93cdcb8..e9375a7 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -4,21 +4,53 @@ CARTA scripting quick start Installation ------------ -This package is not yet published on PyPi, but can be installed from a local checkout of the repository. +Start with the latest available ``carta-python`` release. The latest release +contains the newest compatibility table, so it can inspect the CARTA version +reported by a session and recommend the best ``carta-python`` series when the +latest series is not suitable. + +The recommended installation workflow is: + +#. Install the latest available ``carta-python`` release. +#. Connect to the desired CARTA session as described in + `Connecting to an existing interactive session`_. +#. If session creation reports a version mismatch, follow its suggestion to + upgrade CARTA or install the recommended ``carta-python`` ``MAJOR.MINOR`` + series. +#. Pin the selected release in the script or project so future installations + use the same compatible version. + +A version mismatch with a known recommendation looks like this (the exact +versions depend on the compatibility table): + +.. code-block:: text + + CartaUnsupportedVersion: CARTA version validation failed: + - CARTA version '6.0.0' is older than the minimum '6.1.0' required for complete functionality with carta-python 2.0.x. + + Suggested actions: + - Upgrade CARTA to at least '6.1.0'. + - Alternatively, use carta-python 1.2.x, the recommended series for CARTA 6.0 - 6.0. + - If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`. Requires **Python 3.10** or later. Install with ``pip``: .. code-block:: shell - git clone https://github.com/CARTAvis/carta-python.git - cd carta-python - pip install . + pip install --upgrade carta-python + +If the compatibility error recommends an older series, install the latest +release in that series. For example, for a recommendation of ``1.2.x``: + +.. code-block:: shell + + pip install --upgrade "carta-python~=1.2.0" The required Python library dependencies should be installed automatically. To create new frontend sessions which are controlled by the wrapper instead of connecting to existing frontend sessions, you also need to install the optional browser dependencies: .. code-block:: shell - pip install ".[browser]" + pip install --upgrade "carta-python[browser]" You need access either to a CARTA backend executable, on the local host or on a remote host which you can access through SSH, or to a CARTA controller instance (a multi-user system with web-based authentication). You must be able to access the frontend served by this CARTA instance. If you are using your own backend executable, you must start it with the ``--enable_scripting`` commandline parameter to enable the scripting interface. diff --git a/pyproject.toml b/pyproject.toml index 6828222..31d768a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [project] -name = "carta" +name = "carta-python" dynamic = ["version"] description = "CARTA scripting wrapper written in Python" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/uv.lock b/uv.lock index 83b08f9..d4c5727 100644 --- a/uv.lock +++ b/uv.lock @@ -30,7 +30,7 @@ wheels = [ ] [[package]] -name = "carta" +name = "carta-python" source = { editable = "." } dependencies = [ { name = "requests" }, @@ -383,7 +383,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ From 29732663739f53c2f3ff4c2015bdf479336facb4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 17:26:03 +0800 Subject: [PATCH 115/123] Update error handling for CARTA versions earlier than 6.0.0 --- carta/browser.py | 12 +++++----- carta/protocol.py | 4 ++-- carta/session.py | 53 +++++++++++++++++++++++++++++++---------- carta/util.py | 5 ++++ tests/test_session.py | 55 +++++++++++++++++++++++++++++++++++++++---- 5 files changed, 105 insertions(+), 24 deletions(-) diff --git a/carta/browser.py b/carta/browser.py index ec7e65c..79f90a5 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -73,9 +73,9 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the wrapper's - minimum version requirement and ``version_mismatch_action`` is - ``VersionMismatchAction.ERROR``. + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ protocol = Protocol(frontend_url, token, debug_no_auth=debug_no_auth) @@ -159,9 +159,9 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the wrapper's - minimum version requirement and ``version_mismatch_action`` is - ``VersionMismatchAction.ERROR``. + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ backend = Backend(("--no_browser", "--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout=frontend_url_timeout, session_creation_timeout=0) diff --git a/carta/protocol.py b/carta/protocol.py index b851757..9a431d9 100644 --- a/carta/protocol.py +++ b/carta/protocol.py @@ -8,7 +8,7 @@ import json from .token import BackendToken, ControllerToken -from .util import logger, CartaBadRequest, CartaRequestFailed, CartaActionFailed, CartaBadResponse, CartaBadToken, CartaBadUrl, CartaEncoder, split_action_path +from .util import logger, CartaBadRequest, CartaRequestFailed, CartaActionFailed, CartaBadResponse, CartaMissingResponse, CartaBadToken, CartaBadUrl, CartaEncoder, split_action_path class AuthType: @@ -337,7 +337,7 @@ def request_scripting_action(self, session_id, path, *args, **kwargs): if 'response' not in response_data: if response_expected: - raise CartaBadResponse(f"{carta_action_description} expected a response, but did not receive one.") + raise CartaMissingResponse(f"{carta_action_description} expected a response, but did not receive one.") return None return response_data['response'] diff --git a/carta/session.py b/carta/session.py index 7462966..526f94d 100644 --- a/carta/session.py +++ b/carta/session.py @@ -28,6 +28,7 @@ split_action_path, CartaActionFailed, CartaBadResponse, + CartaMissingResponse, CartaBadID, CartaBadSession, CartaBadUrl, @@ -42,6 +43,7 @@ from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .version import ( action_failure_compatibility_suggestions, + latest_compatibility, version_mismatch_details, ) @@ -141,9 +143,9 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the wrapper's - minimum version requirement and ``version_mismatch_action`` is - ``VersionMismatchAction.ERROR``. + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ try: session_id = int(session_id) @@ -202,9 +204,9 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the wrapper's - minimum version requirement and ``version_mismatch_action`` is - ``VersionMismatchAction.ERROR``. + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ backend = Backend(("--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout, session_creation_timeout) if not backend.start(): @@ -272,9 +274,9 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the wrapper's - minimum version requirement and ``version_mismatch_action`` is - ``VersionMismatchAction.ERROR``. + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ return browser.new_session_from_url( frontend_url, @@ -327,9 +329,9 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa CartaBadSession If the session object could not be created. CartaUnsupportedVersion - If the connected CARTA frontend does not satisfy the wrapper's - minimum version requirement and ``version_mismatch_action`` is - ``VersionMismatchAction.ERROR``. + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ return browser.new_session_with_backend( executable_path, @@ -388,6 +390,33 @@ def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchA try: version = self._fetch_frontend_version(timeout=timeout) + except (CartaActionFailed, CartaMissingResponse) as e: + current = latest_compatibility() + suggestions = [ + f"Upgrade CARTA to at least {current.carta_minimum_version!r}.", + "If CARTA is already '6.0.0' or newer, wait until the frontend " + "is fully loaded and retry.", + ] + if version_mismatch_action is VersionMismatchAction.ERROR: + suggestions.append( + "If this combination is known to work, set " + "`version_mismatch_action=VersionMismatchAction.WARN`." + ) + + message = ( + "CARTA version validation failed:\n" + "- Could not retrieve `frontendVersion` from the CARTA frontend.\n" + "- CARTA versions earlier than '6.0.0' do not expose " + "`frontendVersion`.\n" + f"- carta-python {current.wrapper_label} requires CARTA " + f"{current.carta_minimum_version!r} or later.\n\n" + "Suggested actions:\n" + + "\n".join(f"- {suggestion}" for suggestion in suggestions) + ) + if version_mismatch_action is VersionMismatchAction.ERROR: + raise CartaUnsupportedVersion(message) from e + logger.warning(message) + return None except CartaScriptingException as e: raise CartaBadSession( self._connection_check_error_message(e, timeout) diff --git a/carta/util.py b/carta/util.py index ab56849..98b6dd6 100644 --- a/carta/util.py +++ b/carta/util.py @@ -69,6 +69,11 @@ class CartaBadResponse(CartaScriptingException): pass +class CartaMissingResponse(CartaBadResponse): + """An action request expected a response but received none.""" + pass + + class Macro: """A placeholder for a target and a variable which will be evaluated dynamically by the frontend. diff --git a/tests/test_session.py b/tests/test_session.py index 07a900b..e9bf4c3 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -10,6 +10,7 @@ CartaActionFailed, CartaBadResponse, CartaBadSession, + CartaMissingResponse, CartaRequestFailed, CartaUnsupportedVersion, CartaValidationFailed, @@ -183,9 +184,55 @@ def test_validate_session_reports_invalid_frontend_version_in_error_mode( ) -def test_validate_session_wraps_frontend_version_failure(session, call_action, mocker): +@pytest.mark.parametrize( + "error", + [ + CartaActionFailed("frontendVersion unavailable"), + CartaMissingResponse("frontendVersion returned no response"), + ], +) +def test_validate_session_reports_unavailable_frontend_version_in_error_mode( + session, call_action, error +): + call_action.side_effect = error + + with pytest.raises(CartaUnsupportedVersion) as e: + session._validate_session(timeout=2) + + message = str(e.value) + assert "Could not retrieve `frontendVersion`" in message + assert "earlier than '6.0.0'" in message + assert "carta-python 2.0.x requires CARTA '6.1.0' or later" in message + assert "\n\nSuggested actions:\n" in message + assert "Upgrade CARTA to at least '6.1.0'." in message + assert "CARTA is already '6.0.0' or newer" in message + assert "version_mismatch_action=VersionMismatchAction.WARN" in message + assert "Original error" not in message + assert e.value.__cause__ is error + + +def test_validate_session_warns_when_frontend_version_is_unavailable( + session, call_action, caplog +): + call_action.side_effect = CartaMissingResponse( + "frontendVersion returned no response" + ) + + assert session._validate_session( + timeout=2, + version_mismatch_action=VersionMismatchAction.WARN, + ) is None + + assert "Could not retrieve `frontendVersion`" in caplog.text + assert "\n\nSuggested actions:\n" in caplog.text + assert "Original error" not in caplog.text + assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text + assert not hasattr(session, "_cache") + + +def test_validate_session_wraps_connection_failure(session, call_action, mocker): session._protocol = mocker.Mock(frontend_url="http://localhost:3000") - call_action.side_effect = CartaActionFailed("frontendVersion unavailable") + call_action.side_effect = CartaRequestFailed("session unavailable") with pytest.raises(CartaBadSession) as e: session._validate_session(timeout=2) @@ -194,8 +241,8 @@ def test_validate_session_wraps_frontend_version_failure(session, call_action, m assert "Could not validate CARTA session 0" in message assert "http://localhost:3000" in message assert "2" in message - assert "CartaActionFailed" in message - assert "frontendVersion unavailable" in message + assert "CartaRequestFailed" in message + assert "session unavailable" in message assert "--enable_scripting" in message From d1b05022a6a2b88f69d81223cd4b0c534966bf98 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 17:27:33 +0800 Subject: [PATCH 116/123] Update version to 2.0.0-dev for development cycle --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 227cea2..d72f262 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -2.0.0 +2.0.0-dev From 97e79cf422ab15bde4eb1214d67d897034902bf1 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 17:48:19 +0800 Subject: [PATCH 117/123] Update quickstart documentation to reflect PyPI release and compatibility workflow --- docs/source/quickstart.rst | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index e9375a7..c55f571 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -4,10 +4,11 @@ CARTA scripting quick start Installation ------------ -Start with the latest available ``carta-python`` release. The latest release -contains the newest compatibility table, so it can inspect the CARTA version -reported by a session and recommend the best ``carta-python`` series when the -latest series is not suitable. +The initial PyPI release, ``carta-python`` 2.0.0, supports CARTA 6.1.0 or +later. Session creation inspects the CARTA version reported by the frontend +and reports a version mismatch when this requirement is not met. As new +``carta-python`` series are published, the compatibility table retains the +recommended series for older CARTA releases. The recommended installation workflow is: @@ -15,22 +16,24 @@ The recommended installation workflow is: #. Connect to the desired CARTA session as described in `Connecting to an existing interactive session`_. #. If session creation reports a version mismatch, follow its suggestion to - upgrade CARTA or install the recommended ``carta-python`` ``MAJOR.MINOR`` - series. + upgrade CARTA or install the recommended older ``carta-python`` series. #. Pin the selected release in the script or project so future installations use the same compatible version. -A version mismatch with a known recommendation looks like this (the exact -versions depend on the compatibility table): +For the initial 2.0.0 release, a version mismatch only recommends upgrading +CARTA because no older ``carta-python`` releases exist on PyPI. Once multiple +series have been published, a mismatch may also recommend an older series. +For example, the following hypothetical future compatibility table response +shows ``carta-python`` 3.0.x recommending 2.0.x for CARTA 6.1: .. code-block:: text CartaUnsupportedVersion: CARTA version validation failed: - - CARTA version '6.0.0' is older than the minimum '6.1.0' required for complete functionality with carta-python 2.0.x. + - CARTA version '6.1.0' is older than the minimum '7.1.0' required for complete functionality with carta-python 3.0.x. Suggested actions: - - Upgrade CARTA to at least '6.1.0'. - - Alternatively, use carta-python 1.2.x, the recommended series for CARTA 6.0 - 6.0. + - Upgrade CARTA to at least '7.1.0'. + - Alternatively, use carta-python 2.0.x, the recommended series for CARTA 6.1 - 6.x. - If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`. Requires **Python 3.10** or later. Install with ``pip``: @@ -39,12 +42,13 @@ Requires **Python 3.10** or later. Install with ``pip``: pip install --upgrade carta-python -If the compatibility error recommends an older series, install the latest -release in that series. For example, for a recommendation of ``1.2.x``: +If the compatibility error recommends an older published series, install the +latest release in that series. For example, for a recommendation of +``2.0.x``: .. code-block:: shell - pip install --upgrade "carta-python~=1.2.0" + pip install --upgrade "carta-python~=2.0.0" The required Python library dependencies should be installed automatically. To create new frontend sessions which are controlled by the wrapper instead of connecting to existing frontend sessions, you also need to install the optional browser dependencies: From 769af6a029a3a05c18325d5e78b939bfc6efe55b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 25 Aug 2026 18:13:29 +0800 Subject: [PATCH 118/123] Update pull request template headers to use markdown heading syntax --- .github/pull_request_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ce861dc..4ef676c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,9 @@ -**Description** +### Description Linked issues and companion PRs (if any), what is implemented or fixed, and how to test it. -**Checklist** +### Checklist For linked issues (if any): From 3b5dfa944bcc2ca2344e2944be338428dd380279 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 26 Aug 2026 09:19:55 +0800 Subject: [PATCH 119/123] Relax compatibility range restrictions to allow cross-major CARTA version support --- carta/compatibility.py | 15 +++++------ carta/version.py | 21 ++++++---------- docs/source/development.rst | 23 +++++++++++------ docs/source/quickstart.rst | 2 +- tests/test_version.py | 50 ++++++++++++++++++++----------------- 5 files changed, 59 insertions(+), 52 deletions(-) diff --git a/carta/compatibility.py b/carta/compatibility.py index 0e5591c..034d7d8 100644 --- a/carta/compatibility.py +++ b/carta/compatibility.py @@ -7,12 +7,13 @@ """The recommended compatibility between CARTA and carta-python versions. Each entry contains the oldest and newest CARTA series covered by the range and -the latest compatible carta-python series. ``carta_max`` is ``None`` for all -remaining series in the same CARTA major version. +the latest compatible carta-python series. ``carta_max=None`` means that the +range remains compatible with all later CARTA series until a known breaking +change requires an explicit upper bound and a new entry. -Ranges must be listed from oldest to newest, must not overlap, and must not span -CARTA major versions. The ``wrapper`` value in the final entry must match the -major and minor version in ``VERSION.txt``. Its ``carta_min`` value defines the -minimum CARTA version for which the current carta-python release provides -complete functionality. +Ranges must be listed from oldest to newest and must not overlap. Only the final +entry may omit ``carta_max``. The ``wrapper`` value in the final entry must +match the major and minor version in ``VERSION.txt``. Its ``carta_min`` value +defines the minimum CARTA version for which the current carta-python release +provides complete functionality. """ diff --git a/carta/version.py b/carta/version.py index 8147d3b..b4f4147 100644 --- a/carta/version.py +++ b/carta/version.py @@ -45,7 +45,8 @@ class CompatibilityRange: """CARTA version range and its recommended carta-python series. Bounds use ``MAJOR.MINOR`` granularity and are inclusive. An omitted upper - bound covers the rest of the lower bound's CARTA major version. + bound covers every later CARTA series until a known incompatibility closes + the range. """ carta_min: str @@ -64,13 +65,8 @@ def __post_init__(self) -> None: ) _require_series("wrapper", self.wrapper) - if maximum is not None: - if maximum[0] != minimum[0]: - raise ValueError( - "a compatibility range cannot span CARTA major versions" - ) - if maximum < minimum: - raise ValueError("carta_max must not be earlier than carta_min") + if maximum is not None and maximum < minimum: + raise ValueError("carta_max must not be earlier than carta_min") object.__setattr__(self, "_minimum", minimum) object.__setattr__(self, "_maximum", maximum) @@ -84,15 +80,14 @@ def covers_carta(self, version: object) -> bool: series = parsed_version[:2] if series < self._minimum: return False - if self._maximum is None: - return series[0] == self._minimum[0] - return series <= self._maximum + return self._maximum is None or series <= self._maximum @property def carta_label(self) -> str: """Return a human-readable CARTA version range.""" - maximum = self.carta_max or f"{self._minimum[0]}.x" - return f"{self.carta_min} - {maximum}" + if self.carta_max is None: + return f"{self.carta_min}+" + return f"{self.carta_min} - {self.carta_max}" @property def wrapper_label(self) -> str: diff --git a/docs/source/development.rst b/docs/source/development.rst index 3b25e60..13497cc 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -27,11 +27,18 @@ The final entry determines the requirements for the version under development: * ``carta_min`` defines the minimum CARTA series that provides complete functionality. * ``wrapper`` must match the major and minor components in ``VERSION.txt``. -* ``carta_max=None`` covers the remaining minor releases in the same CARTA - major version. +* ``carta_max=None`` covers every later CARTA series until a known breaking + change closes the range. -Keep all ranges ordered from oldest to newest, non-overlapping, and within a -single CARTA major version. +Keep all ranges ordered from oldest to newest and non-overlapping. Only the +final range may use ``carta_max=None``. Explicit ranges may span CARTA major +versions. + +Compatibility is forward-looking: a new CARTA major version does not by itself +require a table update or a new ``carta-python`` release. If the frontend APIs +used by the wrapper remain compatible, the existing open-ended range continues +to apply. Update the table only when a known frontend incompatibility or a new +wrapper feature changes the supported range. Starting a new development cycle -------------------------------- @@ -100,10 +107,10 @@ to: ) This preserves the recommendation for older CARTA releases while making 6.2 -the minimum for the version under development. If the new minimum starts a new -CARTA major version, append the new range without closing the previous -open-ended range: an open-ended range stops automatically at the end of its -CARTA major version. +the minimum for the version under development. The same procedure applies +across CARTA major versions: close the previous range at the last compatible +``MAJOR.MINOR`` series, then append the new open-ended range. Do not split a +range merely because CARTA increments its major version. Finishing a development cycle ----------------------------- diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index c55f571..c8a5a14 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -33,7 +33,7 @@ shows ``carta-python`` 3.0.x recommending 2.0.x for CARTA 6.1: Suggested actions: - Upgrade CARTA to at least '7.1.0'. - - Alternatively, use carta-python 2.0.x, the recommended series for CARTA 6.1 - 6.x. + - Alternatively, use carta-python 2.0.x, the recommended series for CARTA 6.1 - 7.0. - If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`. Requires **Python 3.10** or later. Install with ``pip``: diff --git a/tests/test_version.py b/tests/test_version.py index 50901d2..c2ec666 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -73,7 +73,7 @@ def test_version_mismatch_details_uses_table_for_downgrade_suggestion(mocker): ) -@pytest.mark.parametrize("version", ["6.1.0", "6.9.9", "7.0.0"]) +@pytest.mark.parametrize("version", ["6.1.0", "6.9.9", "7.0.0", "8.1.0"]) def test_version_mismatch_details_accepts_current_and_newer_carta(version): assert version_mismatch_details(version) == ([], []) @@ -123,12 +123,9 @@ def test_action_failure_ignores_unknown_carta_version(): def assert_table_is_ordered(table): for previous, current in zip(table, table[1:]): - previous_min = parse_version_series(previous.carta_min) current_min = parse_version_series(current.carta_min) - if previous.carta_max is None: - assert current_min[0] > previous_min[0] - else: - assert parse_version_series(previous.carta_max) < current_min + assert previous.carta_max is not None + assert parse_version_series(previous.carta_max) < current_min assert parse_version_series(previous.wrapper) < parse_version_series(current.wrapper) @@ -136,18 +133,22 @@ def test_compatibility_table_is_ordered_and_does_not_overlap(): assert_table_is_ordered(COMPATIBILITY) -def test_open_ended_range_may_precede_a_new_carta_major(): - assert_table_is_ordered(( +def test_open_ended_range_must_be_the_final_entry(): + table = ( CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), CompatibilityRange(carta_min="7.0", carta_max=None, wrapper="3.0"), - )) + ) + with pytest.raises(AssertionError): + assert_table_is_ordered(table) -def test_open_ended_range_is_bounded_by_its_carta_major(): + +def test_open_ended_range_covers_later_carta_major_versions(): entry = CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0") assert entry.covers_carta("6.9.0") - assert not entry.covers_carta("7.0.0") + assert entry.covers_carta("7.0.0") + assert entry.covers_carta("8.1.0") def test_compatibility_table_bounds_are_valid_series(): @@ -157,10 +158,11 @@ def test_compatibility_table_bounds_are_valid_series(): assert entry.carta_max is None or parse_version_series(entry.carta_max) is not None -def test_compatibility_ranges_do_not_span_carta_major_versions(): - for entry in COMPATIBILITY: - if entry.carta_max is not None: - assert parse_version_series(entry.carta_min)[0] == parse_version_series(entry.carta_max)[0] +def test_explicit_compatibility_range_may_span_carta_major_versions(): + entry = CompatibilityRange(carta_min="6.1", carta_max="7.2", wrapper="2.0") + + assert entry.covers_carta("7.2.9") + assert not entry.covers_carta("7.3.0") def test_package_version_matches_latest_compatibility(): @@ -170,12 +172,14 @@ def test_package_version_matches_latest_compatibility(): assert package_version[:2] == recommended_series -@pytest.mark.parametrize("version", ["6.1.0", "6.1.0-dev", "6.9.9"]) +@pytest.mark.parametrize( + "version", ["6.1.0", "6.1.0-dev", "6.9.9", "7.0.0", "8.1.0"] +) def test_compatibility_for_carta_finds_supported_versions(version): assert compatibility_for_carta(version) is latest_compatibility() -@pytest.mark.parametrize("version", ["6.0.0", "5.9.0", "7.0.0", "bad.version"]) +@pytest.mark.parametrize("version", ["6.0.0", "5.9.0", "bad.version"]) def test_compatibility_for_carta_rejects_unsupported_versions(version): assert compatibility_for_carta(version) is None @@ -183,17 +187,17 @@ def test_compatibility_for_carta_rejects_unsupported_versions(version): def test_compatibility_labels(): entry = latest_compatibility() - assert entry.carta_label == "6.1 - 6.x" + assert entry.carta_label == "6.1+" assert entry.wrapper_label == "2.0.x" -def test_compatibility_label_with_explicit_maximum(): - entry = CompatibilityRange(carta_min="6.0", carta_max="6.2", wrapper="1.2") +def test_compatibility_label_with_explicit_cross_major_maximum(): + entry = CompatibilityRange(carta_min="6.0", carta_max="7.2", wrapper="1.2") - assert entry.carta_label == "6.0 - 6.2" + assert entry.carta_label == "6.0 - 7.2" assert entry.wrapper_label == "1.2.x" - assert entry.covers_carta("6.2.5") - assert not entry.covers_carta("6.3.0") + assert entry.covers_carta("7.2.5") + assert not entry.covers_carta("7.3.0") @pytest.mark.parametrize( From 09d010c2092be3f2871c4853b187d45fa3b9fd01 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 26 Aug 2026 12:25:53 +0800 Subject: [PATCH 120/123] Reorganize development documentation by moving release cycle content to dedicated subdocument --- docs/source/development.rst | 118 +--------------------- docs/source/development/release-cycle.rst | 117 +++++++++++++++++++++ 2 files changed, 122 insertions(+), 113 deletions(-) create mode 100644 docs/source/development/release-cycle.rst diff --git a/docs/source/development.rst b/docs/source/development.rst index 13497cc..f4ea4a8 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -14,118 +14,10 @@ Run the test suite and documentation build before submitting changes: uv run pytest uv run sphinx-build -W -b html docs/source docs/build/html -Version compatibility data --------------------------- +Development topics +------------------ -CARTA and ``carta-python`` compatibility is defined by -``COMPATIBILITY_DATA`` in ``carta/compatibility.py``. Each entry maps an -inclusive range of CARTA ``MAJOR.MINOR`` series to the latest recommended -``carta-python`` series. +.. toctree:: + :maxdepth: 1 -The final entry determines the requirements for the version under development: - -* ``carta_min`` defines the minimum CARTA series that provides complete - functionality. -* ``wrapper`` must match the major and minor components in ``VERSION.txt``. -* ``carta_max=None`` covers every later CARTA series until a known breaking - change closes the range. - -Keep all ranges ordered from oldest to newest and non-overlapping. Only the -final range may use ``carta_max=None``. Explicit ranges may span CARTA major -versions. - -Compatibility is forward-looking: a new CARTA major version does not by itself -require a table update or a new ``carta-python`` release. If the frontend APIs -used by the wrapper remain compatible, the existing open-ended range continues -to apply. Update the table only when a known frontend incompatibility or a new -wrapper feature changes the supported range. - -Starting a new development cycle --------------------------------- - -After a release, prepare ``dev`` for the next release before merging feature -work: - -#. Choose the next version and add a ``-dev`` suffix in ``VERSION.txt``. For - example, start 2.1 development with ``2.1.0-dev``. -#. If the major/minor series changes, update ``wrapper`` in the final - compatibility entry to match. If the CARTA minimum is unchanged, update the - existing entry rather than adding an overlapping range. -#. For a patch cycle, such as ``2.0.0`` to ``2.0.1-dev``, leave ``wrapper`` as - ``"2.0"`` because the compatibility table tracks major/minor series. -#. Run the focused version tests. - -For example, starting 2.1 development while retaining CARTA 6.1 as the minimum -uses: - -.. code-block:: text - - 2.1.0-dev - -in ``VERSION.txt`` and: - -.. code-block:: python - - COMPATIBILITY_DATA = ( - {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, - ) - -If the next release is already known to require a newer CARTA series, apply the -range update described below in the same cycle-preparation pull request. - -Pull requests during a development cycle ----------------------------------------- - -Ordinary feature and fix pull requests target ``dev``. They should not change -``VERSION.txt`` or the compatibility table unless they alter CARTA version -requirements. - -When a pull request introduces a dependency on a newer CARTA series, update the -compatibility table, its tests, and its documentation in that same pull -request. Do not defer the compatibility change to the release pull request. -Increasing the minimum requires a new ``carta-python`` major/minor series; if -the current cycle is a patch cycle, start an appropriate major/minor cycle -first. - -Close the previous range and append a range for the new minimum. For example, -if the version under development is 2.1 and a pull request raises the minimum -CARTA series from 6.1 to 6.2, change the table from: - -.. code-block:: python - - COMPATIBILITY_DATA = ( - {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, - ) - -to: - -.. code-block:: python - - COMPATIBILITY_DATA = ( - {"carta_min": "6.1", "carta_max": "6.1", "wrapper": "2.0"}, - {"carta_min": "6.2", "carta_max": None, "wrapper": "2.1"}, - ) - -This preserves the recommendation for older CARTA releases while making 6.2 -the minimum for the version under development. The same procedure applies -across CARTA major versions: close the previous range at the last compatible -``MAJOR.MINOR`` series, then append the new open-ended range. Do not split a -range merely because CARTA increments its major version. - -Finishing a development cycle ------------------------------ - -The release pull request from ``dev`` to ``main`` finalizes ``VERSION.txt`` by -removing the ``-dev`` suffix, for example from ``2.1.0-dev`` to ``2.1.0``. It -must verify that the final compatibility entry already matches the release -major/minor version and minimum CARTA series. It should not be the first place -where a compatibility change is recorded. - -Run the full test suite and documentation build before merging the release. If -the compatibility table changed during the cycle, also run its focused tests: - -.. code-block:: shell - - uv run pytest - uv run pytest tests/test_version.py - uv run sphinx-build -W -b html docs/source docs/build/html + development/release-cycle diff --git a/docs/source/development/release-cycle.rst b/docs/source/development/release-cycle.rst new file mode 100644 index 0000000..482ea94 --- /dev/null +++ b/docs/source/development/release-cycle.rst @@ -0,0 +1,117 @@ +Version compatibility and release cycle +======================================= + +Version compatibility data +-------------------------- + +CARTA and ``carta-python`` compatibility is defined by +``COMPATIBILITY_DATA`` in ``carta/compatibility.py``. Each entry maps an +inclusive range of CARTA ``MAJOR.MINOR`` series to the latest recommended +``carta-python`` series. + +The final entry determines the requirements for the version under development: + +* ``carta_min`` defines the minimum CARTA series that provides complete + functionality. +* ``wrapper`` must match the major and minor components in ``VERSION.txt``. +* ``carta_max=None`` covers every later CARTA series until a known breaking + change closes the range. + +Keep all ranges ordered from oldest to newest and non-overlapping. Only the +final range may use ``carta_max=None``. Explicit ranges may span CARTA major +versions. + +Compatibility is forward-looking: a new CARTA major version does not by itself +require a table update or a new ``carta-python`` release. If the frontend APIs +used by the wrapper remain compatible, the existing open-ended range continues +to apply. Update the table only when a known frontend incompatibility or a new +wrapper feature changes the supported range. + +Starting a new development cycle +--------------------------------- + +After a release, prepare ``dev`` for the next release before merging feature +work: + +#. Choose the next version and add a ``-dev`` suffix in ``VERSION.txt``. For + example, start 2.1 development with ``2.1.0-dev``. +#. If the major/minor series changes, update ``wrapper`` in the final + compatibility entry to match. If the CARTA minimum is unchanged, update the + existing entry rather than adding an overlapping range. +#. For a patch cycle, such as ``2.0.0`` to ``2.0.1-dev``, leave ``wrapper`` as + ``"2.0"`` because the compatibility table tracks major/minor series. +#. Run the focused version tests. + +For example, starting 2.1 development while retaining CARTA 6.1 as the minimum +uses: + +.. code-block:: text + + 2.1.0-dev + +in ``VERSION.txt`` and: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, + ) + +If the next release is already known to require a newer CARTA series, apply the +range update described below in the same cycle-preparation pull request. + +Pull requests during a development cycle +----------------------------------------- + +Ordinary feature and fix pull requests target ``dev``. They should not change +``VERSION.txt`` or the compatibility table unless they alter CARTA version +requirements. + +When a pull request introduces a dependency on a newer CARTA series, update the +compatibility table, its tests, and its documentation in that same pull +request. Do not defer the compatibility change to the release pull request. +Increasing the minimum requires a new ``carta-python`` major/minor series; if +the current cycle is a patch cycle, start an appropriate major/minor cycle +first. + +Close the previous range and append a range for the new minimum. For example, +if the version under development is 2.1 and a pull request raises the minimum +CARTA series from 6.1 to 6.2, change the table from: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, + ) + +to: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": "6.1", "wrapper": "2.0"}, + {"carta_min": "6.2", "carta_max": None, "wrapper": "2.1"}, + ) + +This preserves the recommendation for older CARTA releases while making 6.2 +the minimum for the version under development. The same procedure applies +across CARTA major versions: close the previous range at the last compatible +``MAJOR.MINOR`` series, then append the new open-ended range. Do not split a +range merely because CARTA increments its major version. + +Finishing a development cycle +----------------------------- + +The release pull request from ``dev`` to ``main`` finalizes ``VERSION.txt`` by +removing the ``-dev`` suffix, for example from ``2.1.0-dev`` to ``2.1.0``. It +must verify that the final compatibility entry already matches the release +major/minor version and minimum CARTA series. It should not be the first place +where a compatibility change is recorded. + +Run the full test suite and documentation build before merging the release. If +the compatibility table changed during the cycle, also run its focused tests: + +.. code-block:: shell + + uv run pytest + uv run sphinx-build -W -b html docs/source docs/build/html From b6732c4c20bbbef872c4fcae57bf6b9496c7d2d0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 26 Aug 2026 15:14:45 +0800 Subject: [PATCH 121/123] Refactor error message formatting into dedicated module with improved user guidance and actionable suggestions --- carta/browser.py | 43 +++++++++++--- carta/error_messages.py | 116 +++++++++++++++++++++++++++++++++++++ carta/protocol.py | 7 ++- carta/session.py | 92 ++++++++--------------------- carta/version.py | 23 ++++++-- docs/source/quickstart.rst | 57 +++++++++++++++++- tests/test_session.py | 2 +- tests/test_version.py | 38 ++++++++++-- 8 files changed, 286 insertions(+), 92 deletions(-) create mode 100644 carta/error_messages.py diff --git a/carta/browser.py b/carta/browser.py index 79f90a5..91bffb8 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -2,10 +2,21 @@ import time -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.common.by import By -from selenium.common.exceptions import NoSuchElementException +try: + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + from selenium.webdriver.common.by import By + from selenium.common.exceptions import NoSuchElementException +except ModuleNotFoundError as error: + if error.name != "selenium": + raise + webdriver = None + Options = None + By = None + NoSuchElementException = None + _SELENIUM_IMPORT_ERROR = error +else: + _SELENIUM_IMPORT_ERROR = None from .backend import Backend from .constants import VersionMismatchAction @@ -14,6 +25,23 @@ from .session import Session +def _require_selenium(): + if _SELENIUM_IMPORT_ERROR is not None: + raise CartaBadSession( + "Browser automation requires the optional Selenium dependency.\n\n" + "Install it with pip:\n" + " python -m pip install --upgrade \"carta-python[browser]\"\n\n" + "For a uv-managed script:\n" + " uv add --script your_script.py \"carta-python[browser]\"\n" + " uv run your_script.py\n\n" + "If your script already pins carta-python, include the same " + "version constraint when adding the browser extra, for example:\n" + " uv add --script your_script.py " + "\"carta-python[browser]~=2.0.0\" --upgrade-package carta-python\n" + " uv run your_script.py" + ) from _SELENIUM_IMPORT_ERROR + + class Browser: """The top-level browser class. @@ -78,6 +106,7 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ + _require_selenium() protocol = Protocol(frontend_url, token, debug_no_auth=debug_no_auth) if protocol.controller_auth: @@ -110,10 +139,7 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 session = Session(session_id, protocol, browser=self, backend=backend) try: - session._validate_session( - timeout=connection_check_timeout, - version_mismatch_action=version_mismatch_action, - ) + session._validate_session(timeout=connection_check_timeout, version_mismatch_action=version_mismatch_action) except Exception: self._close_after_error() raise @@ -221,6 +247,7 @@ class Chrome(Browser): """ def __init__(self, headless=True, browser_path=None, driver_path=None, options=tuple()): + _require_selenium() chrome_options = Options() if headless: chrome_options.add_argument("--headless") diff --git a/carta/error_messages.py b/carta/error_messages.py new file mode 100644 index 0000000..e83aae2 --- /dev/null +++ b/carta/error_messages.py @@ -0,0 +1,116 @@ +"""Formatting helpers for user-facing CARTA error messages.""" + +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import requests + + +_STATUS_SUGGESTIONS = { + 400: "Check the scripting request parameters.", + 403: "Check the CARTA token and authentication settings.", + 404: "Verify the session ID and confirm that the session is still active.", + 500: "Check the CARTA backend logs for the underlying error.", + 501: "Restart the CARTA backend with the `--enable_scripting` option.", +} + + +def format_frontend_version_unavailable_error(current, include_warn_suggestion): + """Format the error raised when the frontend version cannot be retrieved.""" + suggestions = [ + f"Upgrade CARTA to at least {current.carta_minimum_version!r}.", + "If CARTA is already '6.0.0' or newer, wait until the frontend is fully loaded and retry.", + ] + if include_warn_suggestion: + suggestions.append( + "If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`." + ) + + return ( + "CARTA version validation failed:\n" + "- Could not retrieve `frontendVersion` from the CARTA frontend.\n" + "- CARTA versions earlier than '6.0.0' do not expose `frontendVersion`.\n" + f"- carta-python {current.wrapper_label} requires CARTA " + f"{current.carta_minimum_version!r} or later.\n\n" + "Suggested actions:\n" + + "\n".join(f"- {suggestion}" for suggestion in suggestions) + ) + + +def format_version_mismatch_error(mismatches, suggestions, include_warn_suggestion): + """Format a CARTA/carta-python version compatibility error.""" + suggestions = list(suggestions) + if include_warn_suggestion: + suggestions.append( + "If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`." + ) + + message = "CARTA version validation failed:\n" + "\n".join( + f"- {mismatch}" for mismatch in mismatches + ) + if suggestions: + message += "\n\nSuggested actions:\n" + "\n".join( + f"- {suggestion}" for suggestion in suggestions + ) + return message + + +def format_session_validation_error(session_id, uri, timeout, error): + """Format the error raised when a CARTA session cannot be validated.""" + safe_uri = _redact_url(uri) + + cause = error + while cause.__cause__ is not None: + cause = cause.__cause__ + + if isinstance(cause, requests.exceptions.Timeout): + message = ( + f"Could not validate CARTA session {session_id}: " + f"request timed out after {timeout} seconds.\n" + f"Endpoint: {safe_uri!r}\n" + f"Timeout: {timeout} seconds\n" + "Action: fetchParameter(frontendVersion)\n" + "The CARTA scripting request did not receive a response before the timeout.\n\n" + "Possible causes:\n" + "- The CARTA backend/frontend is not reachable.\n" + "- The frontend has not finished loading.\n" + "- The session ID is wrong or the session has closed.\n\n" + "Suggested actions:\n" + f"- Retry with a longer `connection_check_timeout` than {timeout} seconds.\n" + "- Verify that the CARTA session is still active.\n" + ) + elif getattr(error, "status_code", None) is not None: + status_code = error.status_code + backend_message = getattr(error, "backend_message", str(error)) + message = ( + f"Could not validate CARTA session {session_id}: " + f"the CARTA backend returned HTTP status {status_code}.\n" + f"Endpoint: {safe_uri!r}\n" + "Action: fetchParameter(frontendVersion)\n" + f"Reason: {backend_message}\n" + ) + suggested_action = _STATUS_SUGGESTIONS.get(status_code) + if suggested_action: + message += f"\nSuggested action:\n- {suggested_action}" + else: + message = ( + f"Could not validate CARTA session {session_id}.\n" + f"Endpoint: {safe_uri!r}\n" + f"Timeout setting: {timeout} seconds\n" + "Action: fetchParameter(frontendVersion)\n\n" + f"Original error ({error.__class__.__name__}): {error}" + ) + + return message.rstrip() + + +def _redact_url(uri): + """Remove authentication tokens from URLs included in error messages.""" + if uri is None: + return None + + parsed = urlsplit(uri) + query = [ + (key, "" if key.lower() in {"token", "access_token"} else value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + ] + return urlunsplit(parsed._replace(query=urlencode(query))) diff --git a/carta/protocol.py b/carta/protocol.py index 9a431d9..ac78cad 100644 --- a/carta/protocol.py +++ b/carta/protocol.py @@ -323,7 +323,12 @@ def request_scripting_action(self, session_id, path, *args, **kwargs): if response.status_code != 200: backend_message = known_errors.get(response.status_code, "Unknown error.") - raise CartaRequestFailed(f"{carta_action_description} failed with status {response.status_code}. {backend_message}") + error = CartaRequestFailed( + f"{carta_action_description} failed with status {response.status_code}. {backend_message}" + ) + error.status_code = response.status_code + error.backend_message = backend_message + raise error try: response_data = response.json() diff --git a/carta/session.py b/carta/session.py index 526f94d..fe5d7dd 100644 --- a/carta/session.py +++ b/carta/session.py @@ -22,6 +22,11 @@ VersionMismatchAction, ) from .backend import Backend +from .error_messages import ( + format_frontend_version_unavailable_error, + format_session_validation_error, + format_version_mismatch_error, +) from .protocol import Protocol from .util import ( Macro, @@ -152,15 +157,8 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac except ValueError: raise CartaBadID(f"Session ID '{session_id}' is not a number.") - session = cls( - session_id, - Protocol(frontend_url, token, debug_no_auth=debug_no_auth), - backend=backend, - ) - session._validate_session( - timeout=connection_check_timeout, - version_mismatch_action=version_mismatch_action, - ) + session = cls(session_id, Protocol(frontend_url, token, debug_no_auth=debug_no_auth), backend=backend) + session._validate_session(timeout=connection_check_timeout, version_mismatch_action=version_mismatch_action) return session @classmethod @@ -373,11 +371,7 @@ def _fetch_frontend_version(self, timeout=None): if timeout is not None: kwargs["timeout"] = timeout - return self.call_action( - "fetchParameter", - Macro("", "frontendVersion"), - **kwargs, - ) + return self.call_action("fetchParameter", Macro("", "frontendVersion"), **kwargs) def _cache_carta_version(self, version): if not hasattr(self, "_cache"): @@ -392,26 +386,9 @@ def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchA version = self._fetch_frontend_version(timeout=timeout) except (CartaActionFailed, CartaMissingResponse) as e: current = latest_compatibility() - suggestions = [ - f"Upgrade CARTA to at least {current.carta_minimum_version!r}.", - "If CARTA is already '6.0.0' or newer, wait until the frontend " - "is fully loaded and retry.", - ] - if version_mismatch_action is VersionMismatchAction.ERROR: - suggestions.append( - "If this combination is known to work, set " - "`version_mismatch_action=VersionMismatchAction.WARN`." - ) - - message = ( - "CARTA version validation failed:\n" - "- Could not retrieve `frontendVersion` from the CARTA frontend.\n" - "- CARTA versions earlier than '6.0.0' do not expose " - "`frontendVersion`.\n" - f"- carta-python {current.wrapper_label} requires CARTA " - f"{current.carta_minimum_version!r} or later.\n\n" - "Suggested actions:\n" - + "\n".join(f"- {suggestion}" for suggestion in suggestions) + message = format_frontend_version_unavailable_error( + current, + include_warn_suggestion=version_mismatch_action is VersionMismatchAction.ERROR, ) if version_mismatch_action is VersionMismatchAction.ERROR: raise CartaUnsupportedVersion(message) from e @@ -419,43 +396,27 @@ def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchA return None except CartaScriptingException as e: raise CartaBadSession( - self._connection_check_error_message(e, timeout) + format_session_validation_error( + session_id=self.session_id, + uri=self._protocol.frontend_url if self._protocol else None, + timeout=timeout, + error=e, + ) ) from e self._cache_carta_version(version) mismatches, suggestions = version_mismatch_details(version) if mismatches: - message = "CARTA version validation failed:\n" + "\n".join( - f"- {mismatch}" for mismatch in mismatches + message = format_version_mismatch_error( + mismatches, + suggestions, + include_warn_suggestion=version_mismatch_action is VersionMismatchAction.ERROR, ) - if version_mismatch_action is VersionMismatchAction.ERROR: - suggestions.append( - "If this combination is known to work, set " - "`version_mismatch_action=VersionMismatchAction.WARN`." - ) - if suggestions: - message += "\n\nSuggested actions:\n" + "\n".join( - f"- {suggestion}" for suggestion in suggestions - ) if version_mismatch_action is VersionMismatchAction.ERROR: raise CartaUnsupportedVersion(message) logger.warning(message) return version - - def _connection_check_error_message(self, error, timeout): - uri = self._protocol.frontend_url if self._protocol else None - return ( - f"Could not validate CARTA session {self.session_id} at {uri!r} " - f"within {timeout} seconds. " - f"Original error ({error.__class__.__name__}): {error}\n" - "Possible causes:\n" - "- CARTA backend/frontend is not running or not reachable.\n" - "- The session ID is wrong or the session has closed.\n" - "- The backend was not started with --enable_scripting.\n" - "- The token is missing, invalid, or expired." - ) - def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. @@ -487,21 +448,14 @@ def call_action(self, path, *args, **kwargs): If a request which was expected to have a JSON response did not have one, or if a JSON response could not be decoded. """ try: - return self._protocol.request_scripting_action( - self.session_id, - path, - *args, - **kwargs, - ) + return self._protocol.request_scripting_action(self.session_id, path, *args, **kwargs) except CartaActionFailed as error: version = getattr(self, "_cache", {}).get("carta_version") suggestions = action_failure_compatibility_suggestions(version) if not suggestions: raise - message = f"{error}\n\nCompatibility suggestions:\n" + "\n".join( - f"- {suggestion}" for suggestion in suggestions - ) + message = f"{error}\n\nCompatibility suggestions:\n" + "\n".join(f"- {suggestion}" for suggestion in suggestions) error.args = (message,) raise diff --git a/carta/version.py b/carta/version.py index b4f4147..5dcbcce 100644 --- a/carta/version.py +++ b/carta/version.py @@ -120,15 +120,18 @@ def compatibility_for_carta(version: object) -> CompatibilityRange | None: def _older_carta_suggestions( version: object, current: CompatibilityRange ) -> list[str]: - suggestions = [ - f"Upgrade CARTA to at least {current.carta_minimum_version!r}." - ] + suggestions = [f"Upgrade CARTA to at least {current.carta_minimum_version!r}."] recommended = compatibility_for_carta(version) if recommended is not None: suggestions.append( - f"Alternatively, use carta-python {recommended.wrapper_label}, " - f"the recommended series for CARTA {recommended.carta_label}." + f"If CARTA cannot be upgraded, use carta-python {recommended.wrapper_label}, " + f"the recommended series for CARTA {recommended.carta_label}:\n" + f" python -m pip install --upgrade \"carta-python~={recommended.wrapper}.0\"\n" + " or, for a uv-managed script:\n" + f" uv add --script your_script.py \"carta-python~={recommended.wrapper}.0\" " + "--upgrade-package carta-python\n" + " uv run your_script.py." ) return suggestions @@ -169,5 +172,13 @@ def action_failure_compatibility_suggestions(version: object) -> list[str]: "For direct scripting calls, verify that the frontend action, attribute, " "or response path exists.", "If this failure started after a CARTA upgrade, upgrade carta-python to " - "the latest available release and retry.", + "the latest available release and retry. For a regular Python environment:\n" + " python -m pip install --upgrade carta-python\n" + "For a uv-managed script:\n" + " uv add --script your_script.py carta-python --upgrade-package carta-python\n" + " uv run your_script.py", + "If the failure persists after upgrading, check whether your script uses " + "a renamed carta-python function or argument, a direct `call_action()` " + "API path, or a changed response structure. Updating carta-python does " + "not rewrite hard-coded API paths in your script.", ] diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index c8a5a14..2588a0a 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -33,7 +33,7 @@ shows ``carta-python`` 3.0.x recommending 2.0.x for CARTA 6.1: Suggested actions: - Upgrade CARTA to at least '7.1.0'. - - Alternatively, use carta-python 2.0.x, the recommended series for CARTA 6.1 - 7.0. + - If CARTA cannot be upgraded, use carta-python 2.0.x, the recommended series for CARTA 6.1 - 7.0. - If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`. Requires **Python 3.10** or later. Install with ``pip``: @@ -50,12 +50,67 @@ latest release in that series. For example, for a recommendation of pip install --upgrade "carta-python~=2.0.0" +If your script is managed with ``uv``, add the pinned dependency to the +script's inline metadata and run the script with ``uv run``. The following +example selects the latest release in the ``2.0.x`` series: + +.. code-block:: shell + + uv add --script your_script.py "carta-python~=2.0.0" --upgrade-package carta-python + uv run your_script.py + +This updates the PEP 723 dependency metadata at the top of +``your_script.py``. It will look similar to: + +.. code-block:: python + + # /// script + # dependencies = [ + # "carta-python~=2.0.0", + # ] + # /// + +To add the latest ``carta-python`` release without selecting a specific +series, use: + +.. code-block:: shell + + uv add --script your_script.py carta-python --upgrade-package carta-python + uv run your_script.py + +If the script already contains an exact pin such as +``carta-python==1.0.0``, specify the new requirement explicitly with +``uv add --script``. Updating a package in the system Python environment with +``pip`` does not change the isolated environment used by ``uv run``. + The required Python library dependencies should be installed automatically. To create new frontend sessions which are controlled by the wrapper instead of connecting to existing frontend sessions, you also need to install the optional browser dependencies: .. code-block:: shell pip install --upgrade "carta-python[browser]" +For a script managed with ``uv``, add the optional extra to the script's inline +metadata: + +.. code-block:: shell + + uv add --script your_script.py "carta-python[browser]" + uv run your_script.py + +To pin a browser-enabled release series, combine the extra with a version +constraint, for example: + +.. code-block:: shell + + uv add --script your_script.py "carta-python[browser]~=2.0.0" --upgrade-package carta-python + uv run your_script.py + +If ``your_script.py`` already pins ``carta-python``, include the existing +version constraint when adding the ``browser`` extra. For example, replace +``carta-python~=2.0.0`` with ``carta-python[browser]~=2.0.0`` in one command. +Adding ``carta-python[browser]`` without a version constraint can replace the +existing requirement and remove its version restriction. + You need access either to a CARTA backend executable, on the local host or on a remote host which you can access through SSH, or to a CARTA controller instance (a multi-user system with web-based authentication). You must be able to access the frontend served by this CARTA instance. If you are using your own backend executable, you must start it with the ``--enable_scripting`` commandline parameter to enable the scripting interface. If you want to create browser sessions from the wrapper, you also need to make sure that your desired browser is installed, together with a corresponding web driver. At present only Chrome (or Chromium) can be used for headless sessions. diff --git a/tests/test_session.py b/tests/test_session.py index e9bf4c3..d43c140 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -243,7 +243,7 @@ def test_validate_session_wraps_connection_failure(session, call_action, mocker) assert "2" in message assert "CartaRequestFailed" in message assert "session unavailable" in message - assert "--enable_scripting" in message + assert "Possible causes:" not in message def test_call_action_adds_compatibility_suggestion_to_frontend_failure( diff --git a/tests/test_version.py b/tests/test_version.py index c2ec666..ada4671 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -67,8 +67,13 @@ def test_version_mismatch_details_uses_table_for_downgrade_suggestion(mocker): ], [ "Upgrade CARTA to at least '6.1.0'.", - "Alternatively, use carta-python 1.0.x, the recommended series " - "for CARTA 5.0 - 5.9.", + "If CARTA cannot be upgraded, use carta-python 1.0.x, the recommended series " + "for CARTA 5.0 - 5.9:\n" + " python -m pip install --upgrade \"carta-python~=1.0.0\"\n" + " or, for a uv-managed script:\n" + " uv add --script your_script.py \"carta-python~=1.0.0\" " + "--upgrade-package carta-python\n" + " uv run your_script.py.", ], ) @@ -90,7 +95,15 @@ def test_action_failure_suggests_latest_carta_python_for_supported_carta(): "For direct scripting calls, verify that the frontend action, attribute, " "or response path exists.", "If this failure started after a CARTA upgrade, upgrade carta-python to " - "the latest available release and retry.", + "the latest available release and retry. For a regular Python environment:\n" + " python -m pip install --upgrade carta-python\n" + "For a uv-managed script:\n" + " uv add --script your_script.py carta-python --upgrade-package carta-python\n" + " uv run your_script.py", + "If the failure persists after upgrading, check whether your script uses " + "a renamed carta-python function or argument, a direct `call_action()` " + "API path, or a changed response structure. Updating carta-python does " + "not rewrite hard-coded API paths in your script.", ] @@ -99,7 +112,15 @@ def test_action_failure_suggests_latest_carta_python_for_newer_carta(): "For direct scripting calls, verify that the frontend action, attribute, " "or response path exists.", "If this failure started after a CARTA upgrade, upgrade carta-python to " - "the latest available release and retry.", + "the latest available release and retry. For a regular Python environment:\n" + " python -m pip install --upgrade carta-python\n" + "For a uv-managed script:\n" + " uv add --script your_script.py carta-python --upgrade-package carta-python\n" + " uv run your_script.py", + "If the failure persists after upgrading, check whether your script uses " + "a renamed carta-python function or argument, a direct `call_action()` " + "API path, or a changed response structure. Updating carta-python does " + "not rewrite hard-coded API paths in your script.", ] @@ -112,8 +133,13 @@ def test_action_failure_uses_table_for_older_carta(mocker): assert action_failure_compatibility_suggestions("5.9.0") == [ "Upgrade CARTA to at least '6.1.0'.", - "Alternatively, use carta-python 1.0.x, the recommended series for " - "CARTA 5.0 - 5.9.", + "If CARTA cannot be upgraded, use carta-python 1.0.x, the recommended series for " + "CARTA 5.0 - 5.9:\n" + " python -m pip install --upgrade \"carta-python~=1.0.0\"\n" + " or, for a uv-managed script:\n" + " uv add --script your_script.py \"carta-python~=1.0.0\" " + "--upgrade-package carta-python\n" + " uv run your_script.py.", ] From 31d0225df4376128c1cf2eac5d202fa303f143ae Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 27 Aug 2026 15:30:51 +0800 Subject: [PATCH 122/123] Add carta_python.egg-info to gitignore for new package name --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8aabb01..ffb1064 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__ build dist carta.egg-info +carta_python.egg-info .DS_Store .coverage .coverage.* From 3a47c86b216a0d9c6b5614eabb45b37bc604c850 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 27 Aug 2026 15:35:17 +0800 Subject: [PATCH 123/123] Improve readability of upgrade instructions by separating environment-specific commands with line break --- carta/version.py | 3 ++- tests/test_version.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/carta/version.py b/carta/version.py index 5dcbcce..e8f1d79 100644 --- a/carta/version.py +++ b/carta/version.py @@ -172,7 +172,8 @@ def action_failure_compatibility_suggestions(version: object) -> list[str]: "For direct scripting calls, verify that the frontend action, attribute, " "or response path exists.", "If this failure started after a CARTA upgrade, upgrade carta-python to " - "the latest available release and retry. For a regular Python environment:\n" + "the latest available release and retry.\n" + "For a regular Python environment:\n" " python -m pip install --upgrade carta-python\n" "For a uv-managed script:\n" " uv add --script your_script.py carta-python --upgrade-package carta-python\n" diff --git a/tests/test_version.py b/tests/test_version.py index ada4671..fcb9122 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -95,7 +95,8 @@ def test_action_failure_suggests_latest_carta_python_for_supported_carta(): "For direct scripting calls, verify that the frontend action, attribute, " "or response path exists.", "If this failure started after a CARTA upgrade, upgrade carta-python to " - "the latest available release and retry. For a regular Python environment:\n" + "the latest available release and retry.\n" + "For a regular Python environment:\n" " python -m pip install --upgrade carta-python\n" "For a uv-managed script:\n" " uv add --script your_script.py carta-python --upgrade-package carta-python\n" @@ -112,7 +113,8 @@ def test_action_failure_suggests_latest_carta_python_for_newer_carta(): "For direct scripting calls, verify that the frontend action, attribute, " "or response path exists.", "If this failure started after a CARTA upgrade, upgrade carta-python to " - "the latest available release and retry. For a regular Python environment:\n" + "the latest available release and retry.\n" + "For a regular Python environment:\n" " python -m pip install --upgrade carta-python\n" "For a uv-managed script:\n" " uv add --script your_script.py carta-python --upgrade-package carta-python\n"